chore: import upstream snapshot with attribution
This commit is contained in:
@@ -0,0 +1,18 @@
|
||||
[*]
|
||||
charset = utf-8
|
||||
indent_style = space
|
||||
indent_size = 2
|
||||
tab_width = 2
|
||||
end_of_line = lf
|
||||
insert_final_newline = true
|
||||
trim_trailing_whitespace = true
|
||||
|
||||
[*.md]
|
||||
trim_trailing_whitespace = false
|
||||
|
||||
[*.yml]
|
||||
indent_size = 2
|
||||
tab_width = 2
|
||||
|
||||
[.dist-tag]
|
||||
insert_final_newline = false
|
||||
@@ -0,0 +1,38 @@
|
||||
name: "CodeQL"
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: ["*.x"]
|
||||
pull_request:
|
||||
branches: ["*.x"]
|
||||
schedule:
|
||||
- cron: '0 6 * * 1'
|
||||
|
||||
jobs:
|
||||
analyze:
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
permissions:
|
||||
actions: read
|
||||
contents: read
|
||||
security-events: write
|
||||
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
language: ['javascript']
|
||||
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v6
|
||||
|
||||
- name: Initialize CodeQL
|
||||
uses: github/codeql-action/init@v4
|
||||
with:
|
||||
languages: ${{ matrix.language }}
|
||||
|
||||
- name: Autobuild
|
||||
uses: github/codeql-action/autobuild@v4
|
||||
|
||||
- name: Perform CodeQL Analysis
|
||||
uses: github/codeql-action/analyze@v4
|
||||
@@ -0,0 +1,51 @@
|
||||
name: Deploy Editor
|
||||
|
||||
on:
|
||||
workflow_dispatch: {}
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
test:
|
||||
uses: dicebear/dicebear/.github/workflows/test.yml@10.x
|
||||
deploy:
|
||||
needs: test
|
||||
environment: editor
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v6
|
||||
|
||||
- name: Setup Node
|
||||
uses: actions/setup-node@v6
|
||||
with:
|
||||
node-version: 24
|
||||
cache: npm
|
||||
|
||||
- name: Install dependencies
|
||||
run: npm ci
|
||||
|
||||
# Remove all languages except English due GDPR reasons, because we can't translate the legal pages.
|
||||
- name: Cleanup languages
|
||||
run: find apps/editor/src/messages -type f ! -name 'en.json' -exec rm -f {} +
|
||||
|
||||
- name: Build editor
|
||||
env:
|
||||
VITE_PRIVACY_POLICY_URL: ${{ vars.VITE_PRIVACY_POLICY_URL }}
|
||||
VITE_COOKIE_POLICY_URL: ${{ vars.VITE_COOKIE_POLICY_URL }}
|
||||
VITE_LEGAL_NOTICE_URL: ${{ vars.VITE_LEGAL_NOTICE_URL }}
|
||||
run: npx turbo run build --filter='@dicebear/editor...'
|
||||
|
||||
- name: Deploy to Bunny.net
|
||||
uses: ayeressian/bunnycdn-storage-deploy@251c77f4b9e7e683ad00086de2bef5770c3386b2 # v2.4.5
|
||||
with:
|
||||
source: 'apps/editor/dist'
|
||||
destination: ''
|
||||
storageZoneName: '${{ secrets.EDITOR_STORAGE_NAME }}'
|
||||
storagePassword: '${{ secrets.EDITOR_STORAGE_PASSWORD }}'
|
||||
accessKey: '${{ secrets.CDN_ACCESS_KEY }}'
|
||||
pullZoneId: '${{ secrets.EDITOR_PULL_ZONE_ID }}'
|
||||
upload: 'true'
|
||||
remove: 'true'
|
||||
purgePullZone: 'true'
|
||||
@@ -0,0 +1,52 @@
|
||||
name: Deploy Website
|
||||
|
||||
on:
|
||||
workflow_dispatch: {}
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
test:
|
||||
uses: dicebear/dicebear/.github/workflows/test.yml@10.x
|
||||
deploy:
|
||||
needs: test
|
||||
environment: ${{ github.ref_name == '9.x' && 'website-v9' || 'website-latest' }}
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v6
|
||||
|
||||
- name: Setup Node
|
||||
uses: actions/setup-node@v6
|
||||
with:
|
||||
node-version: 24
|
||||
cache: npm
|
||||
|
||||
- name: Install dependencies
|
||||
run: npm ci
|
||||
|
||||
- name: Download legal pages
|
||||
env:
|
||||
PAT: ${{ secrets.PAT }}
|
||||
run: ./apps/docs/scripts/download-legal-pages.sh
|
||||
|
||||
- name: Build with VitePress
|
||||
env:
|
||||
VITE_PRIVACY_POLICY_URL: ${{ vars.VITE_PRIVACY_POLICY_URL }}
|
||||
VITE_COOKIE_POLICY_URL: ${{ vars.VITE_COOKIE_POLICY_URL }}
|
||||
VITE_LEGAL_NOTICE_URL: ${{ vars.VITE_LEGAL_NOTICE_URL }}
|
||||
run: npx turbo run build --filter='@dicebear/docs...'
|
||||
|
||||
- name: Deploy to Bunny.net
|
||||
uses: ayeressian/bunnycdn-storage-deploy@251c77f4b9e7e683ad00086de2bef5770c3386b2 # v2.4.5
|
||||
with:
|
||||
source: 'apps/docs/.vitepress/dist'
|
||||
destination: ''
|
||||
storageZoneName: '${{ secrets.WEBSITE_STORAGE_NAME }}'
|
||||
storagePassword: '${{ secrets.WEBSITE_STORAGE_PASSWORD }}'
|
||||
accessKey: '${{ secrets.CDN_ACCESS_KEY }}'
|
||||
pullZoneId: '${{ secrets.WEBSITE_PULL_ZONE_ID }}'
|
||||
upload: 'true'
|
||||
remove: 'true'
|
||||
purgePullZone: 'true'
|
||||
@@ -0,0 +1,141 @@
|
||||
name: Publish
|
||||
|
||||
on:
|
||||
push:
|
||||
tags:
|
||||
- 'v*'
|
||||
|
||||
permissions:
|
||||
id-token: write # Required for OIDC
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
test:
|
||||
uses: dicebear/dicebear/.github/workflows/test.yml@10.x
|
||||
|
||||
publish:
|
||||
needs: test
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
- uses: actions/setup-node@v6
|
||||
with:
|
||||
node-version: 24
|
||||
registry-url: 'https://registry.npmjs.org'
|
||||
|
||||
- run: npm ci
|
||||
- run: npx turbo run build --filter='!@dicebear/docs' --filter='!@dicebear/editor'
|
||||
|
||||
- name: Determine dist tag
|
||||
shell: bash
|
||||
env:
|
||||
DEFAULT_BRANCH: ${{ github.event.repository.default_branch }}
|
||||
run: |
|
||||
TAG=${GITHUB_REF#refs/tags/}
|
||||
if [[ "$TAG" =~ -(alpha|beta|rc)\. ]]; then
|
||||
echo "DIST_TAG=next" >> $GITHUB_ENV
|
||||
else
|
||||
git fetch origin "$DEFAULT_BRANCH" --depth=1
|
||||
if git merge-base --is-ancestor "$GITHUB_SHA" "origin/$DEFAULT_BRANCH"; then
|
||||
echo "DIST_TAG=latest" >> $GITHUB_ENV
|
||||
else
|
||||
MAJOR=$(echo "$TAG" | sed 's/^v//' | cut -d. -f1)
|
||||
echo "DIST_TAG=v${MAJOR}-lts" >> $GITHUB_ENV
|
||||
fi
|
||||
fi
|
||||
|
||||
- run: node scripts/publish.mjs "$DIST_TAG"
|
||||
|
||||
publish-python:
|
||||
needs: test
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
|
||||
- uses: actions/setup-python@v6
|
||||
with:
|
||||
python-version: '3.12'
|
||||
|
||||
- name: Build dicebear-core
|
||||
working-directory: src/python/core
|
||||
run: |
|
||||
python -m pip install --upgrade build
|
||||
python -m build
|
||||
|
||||
# Trusted Publishing (OIDC) — no token. Configure the publisher on PyPI:
|
||||
# project dicebear-core, repo dicebear/dicebear, workflow publish.yml.
|
||||
- name: Publish to PyPI
|
||||
uses: pypa/gh-action-pypi-publish@release/v1
|
||||
with:
|
||||
packages-dir: src/python/core/dist/
|
||||
|
||||
publish-rust:
|
||||
needs: test
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
|
||||
- uses: dtolnay/rust-toolchain@stable
|
||||
|
||||
- name: Verify the crate compiles
|
||||
working-directory: src/rust/core
|
||||
run: cargo check --all-features
|
||||
|
||||
# Trusted Publishing (OIDC) — no token. Configure the publisher on
|
||||
# crates.io: crate dicebear-core, repo dicebear/dicebear, workflow
|
||||
# publish.yml.
|
||||
- name: Authenticate to crates.io
|
||||
id: auth
|
||||
uses: rust-lang/crates-io-auth-action@v1.0.4
|
||||
|
||||
- name: Publish to crates.io
|
||||
working-directory: src/rust/core
|
||||
run: cargo publish
|
||||
env:
|
||||
CARGO_REGISTRY_TOKEN: ${{ steps.auth.outputs.token }}
|
||||
|
||||
publish-pub:
|
||||
needs: test
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
|
||||
- uses: dart-lang/setup-dart@v1
|
||||
|
||||
# Automated publishing (OIDC via setup-dart, no stored token). Configure
|
||||
# on pub.dev: package dicebear_core, repository dicebear/dicebear, tag
|
||||
# pattern v{{version}}. Unlike the Go and PHP ports, pub.dev publishes
|
||||
# straight from the monorepo subdirectory — no split repository.
|
||||
- name: Copy the project changelog into the package
|
||||
# CHANGELOG.md inside the package is a git-ignored build artifact:
|
||||
# pub.dev expects it in the package directory, while the repository
|
||||
# maintains a single changelog at the root.
|
||||
run: cp CHANGELOG.md src/dart/core/CHANGELOG.md
|
||||
# The first release is published manually (pub.dev's automated-publishing
|
||||
# settings only exist once the package does), so skip cleanly when the
|
||||
# pubspec version is already live. This also makes re-runs of the
|
||||
# workflow safe.
|
||||
- name: Check whether this version is already on pub.dev
|
||||
id: pub-version
|
||||
working-directory: src/dart/core
|
||||
run: |
|
||||
VERSION=$(sed -n 's/^version: //p' pubspec.yaml)
|
||||
if curl -fsSL https://pub.dev/api/packages/dicebear_core \
|
||||
| jq -e --arg v "$VERSION" '.versions[] | select(.version == $v)' \
|
||||
> /dev/null; then
|
||||
echo "exists=true" >> "$GITHUB_OUTPUT"
|
||||
else
|
||||
echo "exists=false" >> "$GITHUB_OUTPUT"
|
||||
fi
|
||||
|
||||
- name: Publish to pub.dev
|
||||
if: steps.pub-version.outputs.exists == 'false'
|
||||
working-directory: src/dart/core
|
||||
run: dart pub publish --force
|
||||
@@ -0,0 +1,41 @@
|
||||
name: Split Go Core
|
||||
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- '*.x'
|
||||
tags:
|
||||
- 'v*'
|
||||
|
||||
jobs:
|
||||
split:
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
- if: ${{ !startsWith(github.ref, 'refs/tags/') }}
|
||||
uses: danharrin/monorepo-split-github-action@v2.4.5
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.SPLIT_TOKEN }}
|
||||
with:
|
||||
package_directory: src/go/core
|
||||
repository_organization: dicebear
|
||||
repository_name: dicebear-go
|
||||
branch: ${{ github.ref_name }}
|
||||
user_name: dicebear-bot
|
||||
user_email: bot@dicebear.com
|
||||
|
||||
- if: ${{ startsWith(github.ref, 'refs/tags/') }}
|
||||
uses: danharrin/monorepo-split-github-action@v2.4.5
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.SPLIT_TOKEN }}
|
||||
with:
|
||||
package_directory: src/go/core
|
||||
repository_organization: dicebear
|
||||
repository_name: dicebear-go
|
||||
tag: ${{ github.ref_name }}
|
||||
user_name: dicebear-bot
|
||||
user_email: bot@dicebear.com
|
||||
@@ -0,0 +1,41 @@
|
||||
name: Split PHP Core
|
||||
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- '*.x'
|
||||
tags:
|
||||
- 'v*'
|
||||
|
||||
jobs:
|
||||
split:
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
- if: ${{ !startsWith(github.ref, 'refs/tags/') }}
|
||||
uses: danharrin/monorepo-split-github-action@v2.4.5
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.SPLIT_TOKEN }}
|
||||
with:
|
||||
package_directory: src/php/core
|
||||
repository_organization: dicebear
|
||||
repository_name: dicebear-php
|
||||
branch: ${{ github.ref_name }}
|
||||
user_name: dicebear-bot
|
||||
user_email: bot@dicebear.com
|
||||
|
||||
- if: ${{ startsWith(github.ref, 'refs/tags/') }}
|
||||
uses: danharrin/monorepo-split-github-action@v2.4.5
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.SPLIT_TOKEN }}
|
||||
with:
|
||||
package_directory: src/php/core
|
||||
repository_organization: dicebear
|
||||
repository_name: dicebear-php
|
||||
tag: ${{ github.ref_name }}
|
||||
user_name: dicebear-bot
|
||||
user_email: bot@dicebear.com
|
||||
@@ -0,0 +1,174 @@
|
||||
name: Test
|
||||
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- '*.x'
|
||||
pull_request:
|
||||
branches:
|
||||
- '*.x'
|
||||
workflow_call: {}
|
||||
|
||||
jobs:
|
||||
test-js:
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
strategy:
|
||||
matrix:
|
||||
node-version: [20, 22, 24, 25]
|
||||
# See supported Node.js release schedule at https://nodejs.org/en/about/previous-releases
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
- name: Use Node.js ${{ matrix.node-version }}
|
||||
uses: actions/setup-node@v6
|
||||
with:
|
||||
node-version: ${{ matrix.node-version }}
|
||||
- run: npm ci
|
||||
- run: npx turbo run build --filter='!@dicebear/docs' --filter='!@dicebear/editor'
|
||||
- run: npx turbo run test
|
||||
|
||||
test-php:
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
strategy:
|
||||
matrix:
|
||||
php-version: ['8.2', '8.3', '8.4', '8.5']
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
- name: Use PHP ${{ matrix.php-version }}
|
||||
uses: shivammathur/setup-php@v2
|
||||
with:
|
||||
php-version: ${{ matrix.php-version }}
|
||||
extensions: mbstring
|
||||
- name: Install dependencies
|
||||
working-directory: src/php/core
|
||||
run: composer install --no-interaction --prefer-dist
|
||||
- name: Run PHPStan
|
||||
working-directory: src/php/core
|
||||
run: vendor/bin/phpstan analyse --no-progress
|
||||
- name: Run tests
|
||||
working-directory: src/php/core
|
||||
run: vendor/bin/phpunit
|
||||
|
||||
test-python:
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
strategy:
|
||||
matrix:
|
||||
python-version: ['3.10', '3.11', '3.12', '3.13', '3.14']
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
- name: Use Python ${{ matrix.python-version }}
|
||||
uses: actions/setup-python@v6
|
||||
with:
|
||||
python-version: ${{ matrix.python-version }}
|
||||
- name: Install dependencies
|
||||
working-directory: src/python/core
|
||||
run: pip install -e ".[dev]"
|
||||
- name: Run Ruff
|
||||
working-directory: src/python/core
|
||||
run: |
|
||||
ruff check .
|
||||
ruff format --check .
|
||||
- name: Run mypy
|
||||
working-directory: src/python/core
|
||||
run: mypy src
|
||||
- name: Run tests
|
||||
working-directory: src/python/core
|
||||
run: pytest
|
||||
|
||||
test-rust:
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
- name: Set up Rust
|
||||
uses: dtolnay/rust-toolchain@stable
|
||||
with:
|
||||
components: clippy, rustfmt
|
||||
- name: Check formatting
|
||||
working-directory: src/rust/core
|
||||
run: cargo fmt --check
|
||||
- name: Run Clippy
|
||||
working-directory: src/rust/core
|
||||
run: cargo clippy --all-targets --all-features -- -D warnings
|
||||
- name: Run tests
|
||||
working-directory: src/rust/core
|
||||
run: cargo test
|
||||
|
||||
test-go:
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
strategy:
|
||||
matrix:
|
||||
go-version: ['1.23', '1.24', '1.25']
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
- name: Use Go ${{ matrix.go-version }}
|
||||
uses: actions/setup-go@v6
|
||||
with:
|
||||
go-version: ${{ matrix.go-version }}
|
||||
- name: Check formatting
|
||||
working-directory: src/go/core
|
||||
run: test -z "$(gofmt -l .)" || (gofmt -l . && exit 1)
|
||||
- name: Run go vet
|
||||
working-directory: src/go/core
|
||||
run: go vet ./...
|
||||
- name: Run tests
|
||||
working-directory: src/go/core
|
||||
run: go test ./...
|
||||
|
||||
test-dart:
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
strategy:
|
||||
matrix:
|
||||
# Lower bound = the pubspec `environment.sdk` floor; keep in sync.
|
||||
sdk: ['3.4', 'stable']
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
- name: Use Dart ${{ matrix.sdk }}
|
||||
uses: dart-lang/setup-dart@v1
|
||||
with:
|
||||
sdk: ${{ matrix.sdk }}
|
||||
- name: Install dependencies
|
||||
working-directory: src/dart/core
|
||||
run: dart pub get
|
||||
- name: Check the embedded web fixtures are up to date
|
||||
# The web parity suite embeds a copy of the fixtures (dart2js cannot
|
||||
# read files); regenerate it and fail if it drifted from the canonical
|
||||
# tests/fixtures/parity.
|
||||
working-directory: src/dart/core
|
||||
run: |
|
||||
dart run tool/generate_web_fixtures.dart
|
||||
dart format test/parity/embedded_fixtures.dart
|
||||
git diff --exit-code test/parity/embedded_fixtures.dart
|
||||
- name: Check formatting
|
||||
working-directory: src/dart/core
|
||||
run: dart format --output=none --set-exit-if-changed .
|
||||
- name: Analyze
|
||||
working-directory: src/dart/core
|
||||
run: dart analyze --fatal-infos
|
||||
- name: Run tests
|
||||
working-directory: src/dart/core
|
||||
run: dart test
|
||||
- name: Run web-eligible tests on dart2js
|
||||
# The fixture suites are VM-only (they read disk via dart:io), so the
|
||||
# dart2js build's number formatting and 32-bit PRNG arithmetic is
|
||||
# asserted by the embedded web_parity_test under Chrome.
|
||||
# ubuntu-latest ships google-chrome-stable.
|
||||
working-directory: src/dart/core
|
||||
run: dart test -p chrome
|
||||
- name: Copy the project changelog into the package
|
||||
# CHANGELOG.md inside the package is a git-ignored build artifact:
|
||||
# pub.dev expects it in the package directory, while the repository
|
||||
# maintains a single changelog at the root.
|
||||
run: cp CHANGELOG.md src/dart/core/CHANGELOG.md
|
||||
- name: Validate the publishable archive
|
||||
working-directory: src/dart/core
|
||||
run: dart pub publish --dry-run
|
||||
+11
@@ -0,0 +1,11 @@
|
||||
# Cache and log files
|
||||
.turbo
|
||||
.eslintcache
|
||||
.DS_Store
|
||||
yarn-error.log
|
||||
|
||||
# Dependencies
|
||||
node_modules
|
||||
|
||||
# Local environment files
|
||||
.env*.local
|
||||
@@ -0,0 +1,4 @@
|
||||
package.json
|
||||
LICENSE
|
||||
.github
|
||||
tests
|
||||
@@ -0,0 +1,5 @@
|
||||
{
|
||||
"singleQuote": true,
|
||||
"proseWrap": "always",
|
||||
"tabWidth": 2
|
||||
}
|
||||
Vendored
+8
@@ -0,0 +1,8 @@
|
||||
{
|
||||
"recommendations": [
|
||||
"vue.volar",
|
||||
"editorconfig.editorconfig",
|
||||
"esbenp.prettier-vscode",
|
||||
"dbaeumer.vscode-eslint"
|
||||
]
|
||||
}
|
||||
Vendored
+3
@@ -0,0 +1,3 @@
|
||||
{
|
||||
"editor.wordBasedSuggestions": "off"
|
||||
}
|
||||
+222
@@ -0,0 +1,222 @@
|
||||
# Changelog
|
||||
|
||||
All notable changes to this project will be documented in this file.
|
||||
|
||||
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
|
||||
and this project adheres to
|
||||
[Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
## [10.3.0] - 2026-06-13
|
||||
|
||||
### Added
|
||||
|
||||
- **Core:** Every rendered SVG now starts with the generator comment
|
||||
`<!-- Generated by DiceBear (https://dicebear.com) -->` as the first child of
|
||||
the root `<svg>` element. The comment is byte-identical across the JavaScript,
|
||||
PHP, Python, Rust, Go, and Dart libraries. The byte output of every avatar
|
||||
changes as a result, including data URIs and content hashes, so consumers that
|
||||
compare rendered SVGs against stored snapshots need to update them. SVG
|
||||
optimizers that strip comments (e.g. SVGO with default settings) remove it
|
||||
again.
|
||||
- **Dart library:** A new Dart implementation (the
|
||||
[`dicebear_core`](https://pub.dev/packages/dicebear_core) package) that
|
||||
produces identical output to the JavaScript library when given the same styles
|
||||
and options. It validates style definitions and options against the shared
|
||||
schemas (via `dicebear_schema`) and pairs with the `dicebear_styles` package.
|
||||
- **Core (PHP, Python):** Added `Style::fromJson()` (PHP) and
|
||||
`Style.from_json()` (Python) to build a style from a raw JSON string without a
|
||||
separate `json_decode(..., true)` / `json.loads(...)` call. Malformed JSON
|
||||
raises the language's native parse error (`JsonException` /
|
||||
`json.JSONDecodeError`); an invalid definition raises the usual
|
||||
`StyleValidationError`. Mirrors `Style::from_str` (Rust) and `Style.parse`
|
||||
(Dart); the existing array/dict constructor is unchanged.
|
||||
|
||||
### Deprecated
|
||||
|
||||
- **Core (JS, PHP, Python):** Passing a raw style definition to `Avatar` is
|
||||
deprecated; pass a `Style` instead
|
||||
(`new Avatar(new Style(definition), options)`), which also lets you reuse one
|
||||
parsed style across many avatars. The definition still works for now and
|
||||
renders identically, but emits a deprecation warning (a one-time
|
||||
`console.warn` in JS, `E_USER_DEPRECATED` in PHP, `DeprecationWarning` in
|
||||
Python) and will be removed in v11. The Dart, Rust and Go libraries already
|
||||
require a `Style`, so this brings every port to the same `Avatar(style, …)`
|
||||
call.
|
||||
|
||||
## [10.2.0] - 2026-06-10
|
||||
|
||||
### Added
|
||||
|
||||
- **Go library:** A new Go implementation (the
|
||||
`github.com/dicebear/dicebear-go/v10` module) that produces identical output
|
||||
to the JavaScript library when given the same styles and options.
|
||||
|
||||
### Fixed
|
||||
|
||||
- **Core:** `Color.luminance()` now derives the sRGB linearization from a
|
||||
precomputed lookup table (one entry per 8-bit channel value) instead of
|
||||
calling `pow` at runtime. `pow` is not required to be correctly rounded and
|
||||
produced last-ULP differences between JS engines (V8 vs. others), the C math
|
||||
library (PHP, Python, Rust), and Go's pure-Go implementation, so luminance
|
||||
values, and in contrived cases contrast-based color ordering, could diverge
|
||||
across languages and even across browsers. The table holds the values the
|
||||
JavaScript reference produces today, so JavaScript output is unchanged; the
|
||||
other libraries move by at most one ULP. The Go library additionally forces
|
||||
intermediate rounding in the weighted sum, which the compiler could otherwise
|
||||
fuse into FMA instructions on arm64. Rendered SVGs are unaffected.
|
||||
- **Core (PHP):** `Avatar::toDataUri()` now percent-encodes exactly like
|
||||
JavaScript's `encodeURIComponent`. Previously the PHP library used plain
|
||||
`rawurlencode`, which additionally escapes `!*'()`, characters that occur in
|
||||
every rendered SVG (e.g. `url(#…)` references and `translate(…)` transforms),
|
||||
so the data URI diverged byte-wise from the JavaScript, Python, Rust, and Go
|
||||
libraries. The decoded SVG was unaffected.
|
||||
- **Core (JS):** The `initial` style variable now resolves to the full first
|
||||
code point of the initials. Previously the JavaScript library emitted a lone
|
||||
UTF-16 surrogate (ill-formed XML) when the initials started with a character
|
||||
outside the Basic Multilingual Plane (e.g. an emoji). The PHP, Python, Rust,
|
||||
and Go libraries already returned the full character; all libraries are now
|
||||
byte-identical for such seeds.
|
||||
- **Core (Rust):** `Avatar.to_json()` now records `size` before `title` in the
|
||||
resolved-options snapshot, matching the JavaScript, PHP, and Python libraries.
|
||||
The rendered SVG was unaffected; only consumers comparing or hashing the
|
||||
serialized options JSON across languages were affected.
|
||||
- **Core (Python):** `Avatar.to_json()` now serializes whole-number floats in
|
||||
the resolved-options snapshot as integers (`1`, not `1.0`), matching the
|
||||
JavaScript, Rust, and PHP libraries. Previously snapshot values such as
|
||||
`scale`, `rotate`, `translateX`/`translateY`, `borderRadius`, color angles,
|
||||
and per-component transforms were emitted as `1.0`/`0.0`, so the serialized
|
||||
JSON diverged from the other ports. The rendered SVG was unaffected. The
|
||||
values were already numerically equal, so only consumers comparing or hashing
|
||||
the serialized options JSON across languages were affected.
|
||||
|
||||
## [10.2.0-rc.1] - 2026-06-07
|
||||
|
||||
### Added
|
||||
|
||||
- **Rust library:** A new Rust implementation (the `dicebear-core` crate) that
|
||||
produces identical output to the JavaScript library when given the same styles
|
||||
and options.
|
||||
|
||||
### Fixed
|
||||
|
||||
- **Core:** Initials now discard everything from the first `@` to the end of the
|
||||
seed (e.g. an email domain). Previously the strip stopped at the first line
|
||||
terminator (at a line feed in PHP and Python, and additionally at a carriage
|
||||
return or `U+2028`/`U+2029` in JavaScript), so a seed with a line break after
|
||||
the `@` kept the trailing text as a second word, and the libraries could even
|
||||
diverge from each other. All language libraries now produce byte-identical
|
||||
initials for such seeds.
|
||||
|
||||
## [10.1.0] - 2026-06-06
|
||||
|
||||
### Changed
|
||||
|
||||
- **Schema:** Bumped the bundled `@dicebear/schema` to `1.1.0` across the
|
||||
JavaScript, PHP, and Python libraries. It adds an upper bound of `1000000` to
|
||||
the canvas and component `width`/`height`, preventing the language ports'
|
||||
number-to-string formatting from diverging at extreme values. Official styles
|
||||
use ~100, so no real avatar is affected.
|
||||
- **Styles:** Bumped `@dicebear/styles` to `10.1.0`. Lorelei's mouth is now
|
||||
visible through `beard` variants (the overlaying mask was previously rendered
|
||||
at `0` opacity), and all style definitions now reference
|
||||
`@dicebear/schema@1.1.0`.
|
||||
|
||||
## [10.1.0-rc.1] - 2026-06-02
|
||||
|
||||
### Added
|
||||
|
||||
- **Python library:** A new Python implementation that produces identical output
|
||||
to the JavaScript library when given the same styles and options.
|
||||
|
||||
## [10.0.2] - 2026-06-02
|
||||
|
||||
### Fixed
|
||||
|
||||
- **Core:** Numeric values in rendered SVGs are now consistently rounded to at
|
||||
most 5 decimal places, so the JavaScript and PHP libraries produce
|
||||
byte-identical output for every input. Previously, fractional or very
|
||||
small/large values (e.g. a fractional `borderRadius` or `translateX`,
|
||||
component transforms, or gradient stop offsets) could be stringified
|
||||
differently between languages (scientific notation, differing precision).
|
||||
Avatars built from whole-number options are unaffected.
|
||||
- **Core (PHP):** `Prng::float` now rounds halves toward +Infinity (matching the
|
||||
JavaScript reference's `Math.round`) instead of PHP's native `round()`, which
|
||||
rounds halves away from zero. The two diverged for negative values landing
|
||||
exactly on a `.5` boundary, so a PHP-rendered avatar could differ from the
|
||||
JavaScript one by `0.0001` in a rotate/translate transform or color angle for
|
||||
certain seeds. Output is now byte-identical across languages.
|
||||
- **Core (PHP):** Initials are now derived correctly from seeds containing
|
||||
multibyte letters such as `ü` or `ô`. The quote-stripping step was missing the
|
||||
`/u` (Unicode) flag, so it removed raw UTF-8 bytes and corrupted those
|
||||
letters: e.g. `über` and `côté` produced wrong or empty initials instead of
|
||||
`ÜB` / `CÔ`. The PHP output now matches the JavaScript reference.
|
||||
- **Core:** Range options (`scale`, `borderRadius`, `rotate`,
|
||||
`translateX`/`translateY`, and per-color angle/fill-stops) given as a
|
||||
single-element array `[n]` are now treated as the fixed value `n` (identical
|
||||
to the scalar `n`), and an empty array `[]` falls back to the option's
|
||||
default. Both forms are permitted by the schema. Previously the behaviour
|
||||
diverged: the JavaScript library emitted `NaN` (e.g. `scale(NaN)`), while PHP
|
||||
dropped `[n]` to the default. All three now agree.
|
||||
|
||||
## [10.0.1] - 2026-05-29
|
||||
|
||||
### Fixed
|
||||
|
||||
- **CLI:** `dicebear --version` and `dicebear --help` no longer fail by trying
|
||||
to read a file named `--version`/`--help`. The definition path is now resolved
|
||||
via the argument parser, so flags (and the values they consume) before the
|
||||
path are handled correctly, e.g. `dicebear --json my-style.json` and
|
||||
`dicebear --count 2 my-style.json`.
|
||||
|
||||
## [10.0.0] - 2026-05-27
|
||||
|
||||
See the
|
||||
[v10.0.0 release notes](https://github.com/dicebear/dicebear/releases/tag/v10.0.0).
|
||||
|
||||
### Added
|
||||
|
||||
- **6 new avatar styles:** Disco, Glyphs, Initial Face, Shape Grid, Stripes, and
|
||||
Triangles.
|
||||
- **PHP library:** A new PHP implementation that produces identical output to
|
||||
the JavaScript library when given the same styles and options.
|
||||
- **CLI support for custom styles:** Generate avatars from a JSON style
|
||||
definition, e.g. `dicebear ./path/to/style.json --seed test --format svg`.
|
||||
- **Weighted variants:** Assign weights to component variants to control how
|
||||
frequently each appears.
|
||||
- **Gradient support:** Colors can be defined as gradients, including an angle
|
||||
parameter.
|
||||
- **Integrated validation:** Built-in validation for avatar styles and options.
|
||||
- **Redesigned playground:** Adjust options, upload custom styles, batch
|
||||
download avatars, and view the number of possible combinations.
|
||||
- **New tools:** WCAG Contrast Picker and Bundle Size Estimator.
|
||||
- Reorganized and improved documentation, with better style docs and component
|
||||
previews.
|
||||
|
||||
### Changed
|
||||
|
||||
- Each avatar style is now stored as a JSON definition file instead of
|
||||
JavaScript code, separating licensing concerns from implementation.
|
||||
- Styles are now distributed via `@dicebear/styles` as JSON definitions.
|
||||
- The JavaScript API now uses `Style` and `Avatar` classes together with
|
||||
definition imports.
|
||||
- **BREAKING:** Component options are now suffixed with `Variant` (e.g.
|
||||
`eyesVariant` instead of `eyes`).
|
||||
|
||||
### Removed
|
||||
|
||||
- **BREAKING:** Individual style packages (e.g. `@dicebear/initials`) have been
|
||||
removed in favor of `@dicebear/styles`.
|
||||
|
||||
[Unreleased]: https://github.com/dicebear/dicebear/compare/v10.3.0...HEAD
|
||||
[10.3.0]: https://github.com/dicebear/dicebear/compare/v10.2.0...v10.3.0
|
||||
[10.2.0]: https://github.com/dicebear/dicebear/compare/v10.2.0-rc.1...v10.2.0
|
||||
[10.2.0-rc.1]:
|
||||
https://github.com/dicebear/dicebear/compare/v10.1.0...v10.2.0-rc.1
|
||||
[10.1.0]: https://github.com/dicebear/dicebear/compare/v10.1.0-rc.1...v10.1.0
|
||||
[10.1.0-rc.1]:
|
||||
https://github.com/dicebear/dicebear/compare/v10.0.2...v10.1.0-rc.1
|
||||
[10.0.2]: https://github.com/dicebear/dicebear/compare/v10.0.1...v10.0.2
|
||||
[10.0.1]: https://github.com/dicebear/dicebear/compare/v10.0.0...v10.0.1
|
||||
[10.0.0]: https://github.com/dicebear/dicebear/releases/tag/v10.0.0
|
||||
+362
@@ -0,0 +1,362 @@
|
||||
# Contributing
|
||||
|
||||
Thanks for your interest in contributing to DiceBear.
|
||||
|
||||
This is the main monorepo: the JavaScript, PHP, Python, Rust, Go, and Dart core
|
||||
libraries, the CLI, the docs site, and the editor all live here. Repositories
|
||||
covering the JSON Schema, the avatar style definitions, the HTTP API, and the
|
||||
Figma exporter are separate and each have their own `CONTRIBUTING.md`:
|
||||
|
||||
- [`dicebear/schema`](https://github.com/dicebear/schema/blob/main/CONTRIBUTING.md):
|
||||
JSON Schema for definitions and options
|
||||
- [`dicebear/styles`](https://github.com/dicebear/styles/blob/main/CONTRIBUTING.md):
|
||||
Official avatar style definitions
|
||||
- [`dicebear/api`](https://github.com/dicebear/api/blob/main/CONTRIBUTING.md):
|
||||
Self-hostable HTTP API
|
||||
- [`dicebear/exporter-plugin-for-figma`](https://github.com/dicebear/exporter-plugin-for-figma/blob/main/CONTRIBUTING.md):
|
||||
Figma plugin
|
||||
|
||||
If your contribution belongs to one of those repos, read its file first. The
|
||||
instructions below only cover this monorepo.
|
||||
|
||||
## Before you start
|
||||
|
||||
- Bug fixes, small improvements, new tests: open a pull request against the
|
||||
branch that matches the target major (for DiceBear 10 that's `10.x`; the
|
||||
current stable line lives on `9.x`).
|
||||
- New avatar styles: contribute them to
|
||||
[`dicebear/styles`](https://github.com/dicebear/styles), not here. The
|
||||
walkthrough is in
|
||||
[Create an avatar style with Figma](https://www.dicebear.com/guides/create-an-avatar-style-with-figma/)
|
||||
or
|
||||
[Create an avatar style from scratch](https://www.dicebear.com/guides/create-an-avatar-style-from-scratch/).
|
||||
- Larger changes (new public API, rendering behavior, breaking changes): open an
|
||||
issue first so we can agree on scope before you spend time on it.
|
||||
- Security issues go privately to <contact@dicebear.com>; never file a public
|
||||
issue for a vulnerability.
|
||||
- Contributors follow the
|
||||
[DiceBear Code of Conduct](https://github.com/dicebear/.github/blob/main/CODE_OF_CONDUCT.md).
|
||||
|
||||
## Requirements
|
||||
|
||||
- [Node.js](https://nodejs.org/) 20 or newer (CI runs on 20, 22, 24, 25)
|
||||
- npm 11 (this repo pins `packageManager` in `package.json`; use
|
||||
[Corepack](https://nodejs.org/api/corepack.html) if you don't already)
|
||||
- For PHP work: PHP 8.2+ and Composer, plus `vendor/bin/phpunit` via
|
||||
`composer install` inside `src/php/core/`
|
||||
- For Python work: Python 3.10+ (CI runs on 3.10 to 3.14); install the package
|
||||
in a virtualenv with `pip install -e ".[dev]"` inside `src/python/core/`
|
||||
- For Rust work: Rust 1.80+ with the `clippy` and `rustfmt` components; build
|
||||
and test with `cargo test`, `cargo clippy`, and `cargo fmt` inside
|
||||
`src/rust/core/`
|
||||
- For Go work: Go 1.23+ (CI runs on 1.23 to 1.25); test with `go test ./...` and
|
||||
check with `gofmt -l .` and `go vet ./...` inside `src/go/core/`
|
||||
- For Dart work: Dart SDK 3.4+ (CI runs on 3.4 and stable); test with
|
||||
`dart test` and check with `dart format --output=none --set-exit-if-changed .`
|
||||
and `dart analyze --fatal-infos` inside `src/dart/core/`
|
||||
|
||||
## Local setup
|
||||
|
||||
```sh
|
||||
git clone git@github.com:dicebear/dicebear.git
|
||||
cd dicebear
|
||||
npm install
|
||||
```
|
||||
|
||||
The monorepo uses npm workspaces (`src/js/*` and `apps/*`) driven by
|
||||
[Turborepo](https://turborepo.com/). A single install at the root is enough; do
|
||||
not `npm install` inside individual packages.
|
||||
|
||||
## Common scripts
|
||||
|
||||
Run these from the repo root:
|
||||
|
||||
| Script | What it does |
|
||||
| ---------------------- | --------------------------------------------------- |
|
||||
| `npm run build` | Builds every workspace via Turbo |
|
||||
| `npm test` | Runs every workspace's test target |
|
||||
| `npm run test:scripts` | Runs the repo-level scripts in `tests/**/*.test.js` |
|
||||
| `npm run lint` | Runs ESLint across the repo with caching |
|
||||
| `npm run lint:fix` | Same, with `--fix` |
|
||||
| `npm run prettier` | Formats `src/` and `apps/` with Prettier |
|
||||
|
||||
For faster loops, scope to a single workspace:
|
||||
|
||||
```sh
|
||||
npm run build --workspace @dicebear/core
|
||||
npm run test --workspace @dicebear/core
|
||||
```
|
||||
|
||||
## Repository layout
|
||||
|
||||
```
|
||||
src/
|
||||
├── js/ # TypeScript packages published to npm
|
||||
│ ├── core/ # Rendering engine (@dicebear/core)
|
||||
│ ├── cli/ # Command-line interface
|
||||
│ └── converter/ # SVG → raster converter
|
||||
├── php/ # PHP port (Composer package `dicebear/core`)
|
||||
├── python/ # Python port (PyPI package `dicebear-core`)
|
||||
├── rust/ # Rust port (crates.io crate `dicebear-core`)
|
||||
├── go/ # Go port (module `github.com/dicebear/dicebear-go/v10`)
|
||||
└── dart/ # Dart port (pub.dev package `dicebear_core`)
|
||||
apps/
|
||||
├── docs/ # VitePress documentation site (dicebear.com), including the Playground
|
||||
└── editor/ # The in-browser editor (editor.dicebear.com)
|
||||
tests/
|
||||
└── fixtures/parity/ # Cross-language parity fixtures (see below)
|
||||
scripts/
|
||||
└── version.mjs # Bumps versions across the workspace and tags
|
||||
```
|
||||
|
||||
## Working on a package
|
||||
|
||||
### TypeScript packages (`src/js/*`)
|
||||
|
||||
```sh
|
||||
npm run build --workspace <package>
|
||||
npm run test --workspace <package>
|
||||
```
|
||||
|
||||
If you are working on the CLI, call the compiled script directly once you've
|
||||
built it:
|
||||
|
||||
```sh
|
||||
node src/js/cli/bin/index.js <command>
|
||||
```
|
||||
|
||||
### PHP core (`src/php/core/`)
|
||||
|
||||
```sh
|
||||
cd src/php/core
|
||||
composer install
|
||||
vendor/bin/phpunit
|
||||
```
|
||||
|
||||
### Python core (`src/python/core/`)
|
||||
|
||||
```sh
|
||||
cd src/python/core
|
||||
pip install -e ".[dev]" # in a virtualenv
|
||||
ruff check .
|
||||
ruff format --check .
|
||||
mypy src
|
||||
pytest
|
||||
```
|
||||
|
||||
The Python core reads the two draft-07 schemas from the `dicebear-schema`
|
||||
package (the Python counterpart of `@dicebear/schema` / `dicebear/schema`),
|
||||
which `pip install -e ".[dev]"` pulls in as a runtime dependency. Nothing is
|
||||
vendored.
|
||||
|
||||
### Rust core (`src/rust/core/`)
|
||||
|
||||
```sh
|
||||
cd src/rust/core
|
||||
cargo test
|
||||
cargo clippy --all-targets --all-features -- -D warnings
|
||||
cargo fmt --check
|
||||
```
|
||||
|
||||
The Rust core reads the two draft-07 schemas from the `dicebear-schema` crate
|
||||
(the Rust counterpart of `@dicebear/schema` / `dicebear/schema`) as a runtime
|
||||
dependency. Nothing is vendored.
|
||||
|
||||
### Go core (`src/go/core/`)
|
||||
|
||||
```sh
|
||||
cd src/go/core
|
||||
go test ./...
|
||||
go vet ./...
|
||||
gofmt -l . # prints nothing when formatting is correct
|
||||
```
|
||||
|
||||
The Go core reads the two draft-07 schemas from the `github.com/dicebear/schema`
|
||||
module (the Go counterpart of `@dicebear/schema` / `dicebear/schema`) as a
|
||||
dependency. Nothing is vendored. Style definitions come from
|
||||
`github.com/dicebear/styles/v10`. The module path carries the major version
|
||||
(`/v10`), so a major bump changes the path by hand; `scripts/version.mjs` only
|
||||
creates the Git tag the module proxy reads.
|
||||
|
||||
### Dart core (`src/dart/core/`)
|
||||
|
||||
```sh
|
||||
cd src/dart/core
|
||||
dart pub get
|
||||
dart test
|
||||
dart analyze --fatal-infos
|
||||
dart format --output=none --set-exit-if-changed .
|
||||
```
|
||||
|
||||
The Dart core reads the two draft-07 schemas from the `dicebear_schema` package
|
||||
(the Dart counterpart of `@dicebear/schema` / `dicebear/schema`) as a runtime
|
||||
dependency, validated with `package:json_schema`. Nothing is vendored.
|
||||
|
||||
`src/dart/core/CHANGELOG.md` is a git-ignored build artifact: pub.dev expects a
|
||||
changelog inside the package directory, so the CI workflows copy the repository
|
||||
root `CHANGELOG.md` there before `dart pub publish [--dry-run]`. This follows
|
||||
the same pattern as the generated `LICENSE` copies in the styles and schema
|
||||
repositories. Maintain the root changelog only.
|
||||
|
||||
### Cross-language parity
|
||||
|
||||
Every port must produce output **byte-identical** to the reference JavaScript
|
||||
core (`@dicebear/core`) for the same inputs. A shared fixture suite in
|
||||
`tests/fixtures/parity/` (generated from the JS reference) enforces this, and
|
||||
each side consumes it:
|
||||
|
||||
- JS side: `src/js/core/tests/Parity.test.js`, run via
|
||||
`npm run test --workspace @dicebear/core`.
|
||||
- PHP side: `tests/ParityTest.php`, run via `vendor/bin/phpunit` in
|
||||
`src/php/core/`.
|
||||
- Python side: `tests/test_parity.py`, run via `pytest` in `src/python/core/`.
|
||||
- Rust side: the module unit tests plus `tests/avatars.rs`, run via `cargo test`
|
||||
in `src/rust/core/`.
|
||||
- Go side: the in-package tests (`parity_test.go`, `avatars_test.go`), run via
|
||||
`go test ./...` in `src/go/core/`.
|
||||
- Dart side: the tests under `test/parity/`, run via `dart test` in
|
||||
`src/dart/core/`.
|
||||
|
||||
The fixtures cover `Fnv1a` (hash + hex), `Mulberry32` (chained sequences), every
|
||||
`Prng` method, number-to-string formatting (`numbers.json`, the `formatNumber`
|
||||
contract: every emitted number rounded to at most 5 decimal places, which every
|
||||
port must reproduce identically), initials extraction, the `Color` helpers
|
||||
(including bit-exact luminance doubles), accept/reject validation outcomes plus
|
||||
circular color-reference chains, the `OptionsDescriptor` field map, and full
|
||||
`Avatar.toString()` (plus selected `toDataUri()`) output for the `initials`,
|
||||
`thumbs`, `glass`, `notionists`, and `shape-grid` styles. That last bucket
|
||||
covers seed, size, transforms, gradients, `title` escaping, and
|
||||
component-variant overrides.
|
||||
|
||||
Float determinism is part of the parity contract: `pow` is not correctly rounded
|
||||
(JS engines, libm, and Go all differ in the last bit), so the sRGB linearization
|
||||
ships as a precomputed 256-entry table in every port, and languages that fuse
|
||||
multiply-add into FMA instructions (e.g. Go on arm64) must force intermediate
|
||||
rounding. The details live in the
|
||||
[Implement DiceBear Core](https://www.dicebear.com/specification/implement-dicebear-core/)
|
||||
spec.
|
||||
|
||||
If your change affects rendering or the PRNG in `@dicebear/core`, regenerate the
|
||||
fixtures from the JS reference and commit the diff:
|
||||
|
||||
```sh
|
||||
npm run fixtures:parity
|
||||
```
|
||||
|
||||
The PHP, Python, Rust, Go, and Dart suites will then fail loudly until those
|
||||
sides are brought back in sync. That is the intended signal. If you only intend
|
||||
to touch one language, expect to update both before your PR can be merged.
|
||||
|
||||
When porting DiceBear to another language, run these fixtures against your
|
||||
implementation to prove it conforms. See
|
||||
[Implement DiceBear Core](https://www.dicebear.com/specification/implement-dicebear-core/)
|
||||
for the full spec.
|
||||
|
||||
## Documentation changes (`apps/docs/`)
|
||||
|
||||
The docs site is a VitePress app under `apps/docs/`.
|
||||
|
||||
```sh
|
||||
npm run dev --workspace @dicebear/docs # live reload on localhost
|
||||
npm run build --workspace @dicebear/docs # production build check
|
||||
```
|
||||
|
||||
For larger editorial changes, open a draft PR early so reviewers can follow
|
||||
along.
|
||||
|
||||
## Editor changes (`apps/editor/`)
|
||||
|
||||
The editor is the standalone app served at
|
||||
[editor.dicebear.com](https://editor.dicebear.com).
|
||||
|
||||
```sh
|
||||
npm run dev --workspace @dicebear/editor
|
||||
npm run build --workspace @dicebear/editor
|
||||
```
|
||||
|
||||
## Code style
|
||||
|
||||
- ESLint and Prettier decide what counts as correctly formatted code; run
|
||||
`npm run lint` and `npm run prettier` before you open a PR.
|
||||
- TypeScript is `strict`. Prefer narrow types to `any` / `unknown` casts.
|
||||
- PHP code follows PSR-12; `vendor/bin/phpunit` and Composer's built-in scripts
|
||||
catch the rest.
|
||||
- Python code is formatted and linted with [Ruff](https://docs.astral.sh/ruff/)
|
||||
and type-checked with `mypy` in strict mode; run `ruff check .`,
|
||||
`ruff format .`, and `mypy src` in `src/python/core/` before you open a PR.
|
||||
- Rust code is formatted with `rustfmt` and linted with Clippy (warnings
|
||||
denied); run `cargo fmt` and
|
||||
`cargo clippy --all-targets --all-features -- -D warnings` in `src/rust/core/`
|
||||
before you open a PR.
|
||||
- Go code is formatted with `gofmt` and vetted with `go vet`; run `gofmt -w .`
|
||||
and `go vet ./...` in `src/go/core/` before you open a PR.
|
||||
- Dart code is formatted with `dart format` and analyzed with
|
||||
`dart analyze --fatal-infos` (lints from `analysis_options.yaml`); run both in
|
||||
`src/dart/core/` before you open a PR.
|
||||
|
||||
## Releasing (maintainers only)
|
||||
|
||||
> Only maintainers with write access can release new versions. This section is
|
||||
> documented here for completeness.
|
||||
|
||||
Releases are triggered by Git tags. The version script updates every package in
|
||||
the workspace, creates a commit, and creates the tag:
|
||||
|
||||
```sh
|
||||
node scripts/version.mjs <version>
|
||||
```
|
||||
|
||||
The version must be a valid [semver](https://semver.org/) value (e.g. `10.1.0`
|
||||
or `10.2.0-alpha.1`). The script will:
|
||||
|
||||
1. Update `version` in every `package.json` across the workspace
|
||||
2. Update internal workspace dependency references
|
||||
3. Update `version` in `src/python/core/pyproject.toml`,
|
||||
`src/rust/core/Cargo.toml`, and `src/dart/core/pubspec.yaml` (the Python,
|
||||
Rust, and Dart cores are not npm workspaces, so they are bumped explicitly to
|
||||
stay in lockstep)
|
||||
4. Sync `package-lock.json`
|
||||
5. Create a Git commit and tag (e.g. `v10.1.0`)
|
||||
|
||||
Push the commit and the tag:
|
||||
|
||||
```sh
|
||||
git push && git push --tags
|
||||
```
|
||||
|
||||
The tag triggers the
|
||||
[Publish](https://github.com/dicebear/dicebear/actions/workflows/publish.yml)
|
||||
workflow, which:
|
||||
|
||||
1. Runs the test suite on Node 20, 22, 24, and 25
|
||||
2. Builds every package
|
||||
3. Picks the npm dist-tag:
|
||||
- Tags containing `alpha`, `beta`, or `rc` go out as `next`
|
||||
- Other tags on the default branch go out as `latest`
|
||||
- Other tags on any other branch go out as `v<major>-lts` (so that
|
||||
backporting a patch to `9.x` after `10.x` has shipped does not overwrite
|
||||
`latest`)
|
||||
4. Publishes all changed packages to npm via `scripts/publish.mjs`
|
||||
5. Builds `src/python/core` and publishes `dicebear-core` to PyPI via trusted
|
||||
publishing (the `publish-python` job): no token and no separate repository,
|
||||
the same way the npm packages go out
|
||||
6. Publishes the Rust core `dicebear-core` to crates.io via trusted publishing
|
||||
(the `publish-rust` job): likewise no token; `cargo publish` builds and
|
||||
uploads `src/rust/core` in one step
|
||||
7. Publishes the Dart core `dicebear_core` to pub.dev via automated publishing
|
||||
(the `publish-pub` job): likewise no token; pub.dev verifies the `v<version>`
|
||||
tag against the pubspec version and publishes `src/dart/core` straight from
|
||||
the monorepo
|
||||
|
||||
The PHP port is the exception: Composer/Packagist consumes one Git repository
|
||||
per package rather than a monorepo subdirectory, so `split-php-core.yml` mirrors
|
||||
`src/php/core` (tags included) to the standalone
|
||||
[`dicebear/dicebear-php`](https://github.com/dicebear/dicebear-php) repository,
|
||||
and Packagist publishes `dicebear/core` from that mirror. All five ports ride
|
||||
the same version the monorepo tagged.
|
||||
|
||||
## Licensing
|
||||
|
||||
By opening a pull request you agree that your contribution is released under the
|
||||
repository's [MIT license](./LICENSE). Avatar style artwork in `dicebear/styles`
|
||||
may carry other licenses; see that repo's `LICENSE.md` for details.
|
||||
@@ -0,0 +1,21 @@
|
||||
MIT License
|
||||
|
||||
Copyright (c) 2026 Florian Körner
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
in the Software without restriction, including without limitation the rights
|
||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the Software is
|
||||
furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all
|
||||
copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
SOFTWARE.
|
||||
@@ -0,0 +1,103 @@
|
||||
<h1><img src="https://www.dicebear.com/logo-readme.svg" width="28" /> DiceBear Avatar Library</h1>
|
||||
|
||||
DiceBear is an open source avatar library. It turns any seed string (a username
|
||||
or an email address, for example) into an SVG avatar in one of 35+ styles
|
||||
designed by various artists. The same seed always produces the same avatar, so
|
||||
you store a string instead of an image and never ask users to upload a profile
|
||||
picture.
|
||||
|
||||
Avatars are customizable through style options: colors, backgrounds, rotation,
|
||||
individual features like hair or glasses.
|
||||
|
||||
[Playground](https://www.dicebear.com/playground) |
|
||||
[Documentation](https://www.dicebear.com/introduction) |
|
||||
[Editor](https://editor.dicebear.com)
|
||||
|
||||
## One library, six languages
|
||||
|
||||
DiceBear 10 ships as native libraries for JavaScript, PHP, Python, Rust, Go, and
|
||||
Dart. Every port passes a shared test suite that requires byte-identical SVG
|
||||
output to the JavaScript reference. Generate an avatar in the browser,
|
||||
regenerate it later in a Go or PHP backend, and you get the same bytes.
|
||||
|
||||
| Language | Package | Install |
|
||||
| ----------------------- | ----------------------------------------------------------------------- | -------------------------------------------- |
|
||||
| JavaScript / TypeScript | [`@dicebear/core`](https://www.npmjs.com/package/@dicebear/core) | `npm install @dicebear/core` |
|
||||
| PHP | [`dicebear/core`](https://packagist.org/packages/dicebear/core) | `composer require dicebear/core` |
|
||||
| Python | [`dicebear-core`](https://pypi.org/project/dicebear-core/) | `pip install dicebear-core` |
|
||||
| Rust | [`dicebear-core`](https://crates.io/crates/dicebear-core) | `cargo add dicebear-core` |
|
||||
| Go | [`dicebear-go`](https://pkg.go.dev/github.com/dicebear/dicebear-go/v10) | `go get github.com/dicebear/dicebear-go/v10` |
|
||||
| Dart | [`dicebear_core`](https://pub.dev/packages/dicebear_core) | `dart pub add dicebear_core` |
|
||||
|
||||
In JavaScript it looks like this; the
|
||||
[documentation](https://www.dicebear.com/introduction) has the equivalent for
|
||||
each language:
|
||||
|
||||
```js
|
||||
import { Avatar } from '@dicebear/core';
|
||||
import definition from '@dicebear/styles/lorelei.json' with { type: 'json' };
|
||||
|
||||
const avatar = new Avatar(definition, {
|
||||
seed: 'John Doe',
|
||||
size: 128,
|
||||
});
|
||||
|
||||
avatar.toString(); // SVG string
|
||||
avatar.toDataUri(); // data:image/svg+xml;charset=utf-8,...
|
||||
```
|
||||
|
||||
The 35+ avatar styles are plain JSON definitions from the
|
||||
[`dicebear/styles`](https://github.com/dicebear/styles) repository, available as
|
||||
a package for each language. You can also
|
||||
[create your own style](https://www.dicebear.com/guides/create-an-avatar-style-with-figma/),
|
||||
with Figma or from scratch.
|
||||
|
||||
## Without writing code
|
||||
|
||||
- The [HTTP API](https://www.dicebear.com/how-to-use/http-api/) returns avatars
|
||||
from a plain URL, free and without an account:
|
||||
`https://api.dicebear.com/10.x/lorelei/svg?seed=Felix`. For full control and
|
||||
privacy you can
|
||||
[host it yourself](https://www.dicebear.com/guides/host-the-http-api-yourself/)
|
||||
with a single Docker container.
|
||||
- The [CLI](https://www.dicebear.com/how-to-use/cli/) generates avatar files in
|
||||
bulk: `npx dicebear lorelei --count 10`.
|
||||
- The [editor](https://editor.dicebear.com) lets you assemble a single avatar by
|
||||
hand and export it.
|
||||
|
||||
## This repository
|
||||
|
||||
This monorepo contains the six core libraries, the CLI, the SVG-to-raster
|
||||
converter, the documentation site ([dicebear.com](https://www.dicebear.com)),
|
||||
and the editor. Related projects live in their own repositories:
|
||||
|
||||
- [`dicebear/styles`](https://github.com/dicebear/styles): the official avatar
|
||||
style definitions
|
||||
- [`dicebear/schema`](https://github.com/dicebear/schema): the JSON Schema
|
||||
behind definitions and options
|
||||
- [`dicebear/api`](https://github.com/dicebear/api): the self-hostable HTTP API
|
||||
- [`dicebear/exporter-plugin-for-figma`](https://github.com/dicebear/exporter-plugin-for-figma):
|
||||
the Figma plugin for style authors
|
||||
|
||||
Contributions are welcome; [CONTRIBUTING.md](./CONTRIBUTING.md) explains the
|
||||
setup and where each kind of change belongs.
|
||||
|
||||
## License
|
||||
|
||||
The code is [MIT licensed](./LICENSE), including commercial use. The avatar
|
||||
styles are the work of their respective artists and carry their own licenses;
|
||||
the [license overview](https://www.dicebear.com/licenses/) lists them all, and
|
||||
many only ask for attribution.
|
||||
|
||||
## Sponsors
|
||||
|
||||
Advertisement: Many thanks to our sponsors who provide us with free or
|
||||
discounted products.
|
||||
|
||||
<a href="https://bunny.net/" target="_blank" rel="noopener noreferrer">
|
||||
<picture>
|
||||
<source media="(prefers-color-scheme: dark)" srcset="https://www.dicebear.com/sponsors/bunny-light.svg">
|
||||
<source media="(prefers-color-scheme: light)" srcset="https://www.dicebear.com/sponsors/bunny-dark.svg">
|
||||
<img alt="bunny.net" src="https://www.dicebear.com/sponsors/bunny-dark.svg" height="64">
|
||||
</picture>
|
||||
</a>
|
||||
@@ -0,0 +1,7 @@
|
||||
# WeHub 来源说明
|
||||
|
||||
- 原始项目:`dicebear/dicebear`
|
||||
- 原始仓库:https://github.com/dicebear/dicebear
|
||||
- 导入方式:上游默认分支的最新快照
|
||||
- 原作者、版权和许可证信息以原始仓库及本仓库 LICENSE 为准
|
||||
- 本文件仅用于记录来源,不代表 WeHub 是原项目作者
|
||||
@@ -0,0 +1,4 @@
|
||||
# Legal links shown in the footer.
|
||||
VITE_PRIVACY_POLICY_URL=
|
||||
VITE_COOKIE_POLICY_URL=
|
||||
VITE_SITE_NOTICE_URL=
|
||||
@@ -0,0 +1,9 @@
|
||||
# Cache and log files
|
||||
.DS_STORE
|
||||
|
||||
# Dependencies
|
||||
node_modules
|
||||
|
||||
# Vitepress
|
||||
.vitepress/cache
|
||||
.vitepress/dist
|
||||
@@ -0,0 +1,263 @@
|
||||
import * as path from 'node:path';
|
||||
import { defineConfig, type DefaultTheme, type HeadConfig } from 'vitepress';
|
||||
import { ThemeOptions } from '@theme/types';
|
||||
|
||||
import sidebarDocs from './config/sidebarDocs';
|
||||
import sidebarStyles from './config/sidebarStyles';
|
||||
import sidebarTools from './config/sidebarTools';
|
||||
import avatarStyles from './config/avatarStyles';
|
||||
import avatarUniqueCounts from './config/avatarUniqueCounts';
|
||||
import avatarStyleSizes from './config/avatarStyleSizes';
|
||||
import { formatStars } from './theme/utils/format';
|
||||
|
||||
async function fetchGitHubStars(
|
||||
repos: string[],
|
||||
): Promise<Record<string, string>> {
|
||||
const result: Record<string, string> = {};
|
||||
const TIMEOUT_MS = 5000;
|
||||
|
||||
await Promise.all(
|
||||
repos.map(async (repo) => {
|
||||
const controller = new AbortController();
|
||||
const timer = setTimeout(() => controller.abort(), TIMEOUT_MS);
|
||||
try {
|
||||
const res = await fetch(`https://api.github.com/repos/${repo}`, {
|
||||
signal: controller.signal,
|
||||
});
|
||||
if (res.ok) {
|
||||
const data = await res.json();
|
||||
result[repo] = formatStars(data.stargazers_count);
|
||||
}
|
||||
} catch (err) {
|
||||
console.warn(
|
||||
`[github-stars] failed for ${repo}:`,
|
||||
err instanceof Error ? err.message : err,
|
||||
);
|
||||
} finally {
|
||||
clearTimeout(timer);
|
||||
}
|
||||
}),
|
||||
);
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
const githubStars = await fetchGitHubStars([
|
||||
'dicebear/dicebear',
|
||||
'nusu/avvvatars',
|
||||
'dmester/jdenticon',
|
||||
'multiavatar/Multiavatar',
|
||||
'boringdesigners/boring-avatars',
|
||||
]);
|
||||
|
||||
const isProduction = process.env.NODE_ENV === 'production';
|
||||
|
||||
const thirdPartyScripts: HeadConfig[] = isProduction
|
||||
? [
|
||||
[
|
||||
'script',
|
||||
{
|
||||
defer: '',
|
||||
src: 'https://u.dicebear.com/script.js',
|
||||
'data-website-id': '75d27df5-3441-4530-8f29-70d04ae9085e',
|
||||
},
|
||||
],
|
||||
]
|
||||
: [];
|
||||
|
||||
export default defineConfig<ThemeOptions>({
|
||||
title: 'DiceBear',
|
||||
description:
|
||||
'DiceBear is a free, open source avatar library and avatar API with 35+ avatar styles. Generate profile pictures and user placeholder images for any project.',
|
||||
head: [
|
||||
// Most pages load avatars from the HTTP API (seed demo, style showcase,
|
||||
// playground). Warming up the connection hides the DNS/TLS latency on
|
||||
// mobile. The avatars are plain <img> requests (no CORS), so the hint
|
||||
// must not carry a crossorigin attribute to match the connection.
|
||||
['link', { rel: 'preconnect', href: 'https://api.dicebear.com' }],
|
||||
[
|
||||
'link',
|
||||
{
|
||||
rel: 'icon',
|
||||
type: 'image/png',
|
||||
href: '/favicon-96x96.png',
|
||||
sizes: '96x96',
|
||||
},
|
||||
],
|
||||
['link', { rel: 'icon', type: 'image/svg+xml', href: '/favicon.svg' }],
|
||||
['link', { rel: 'shortcut icon', href: '/favicon.ico' }],
|
||||
[
|
||||
'link',
|
||||
{
|
||||
rel: 'apple-touch-icon',
|
||||
sizes: '180x180',
|
||||
href: '/apple-touch-icon.png',
|
||||
},
|
||||
],
|
||||
['meta', { name: 'apple-mobile-web-app-title', content: 'DiceBear' }],
|
||||
['link', { rel: 'manifest', href: '/site.webmanifest' }],
|
||||
['meta', { property: 'og:site_name', content: 'DiceBear' }],
|
||||
['meta', { property: 'og:type', content: 'website' }],
|
||||
['meta', { name: 'twitter:card', content: 'summary' }],
|
||||
[
|
||||
'script',
|
||||
{ type: 'application/ld+json' },
|
||||
JSON.stringify({
|
||||
'@context': 'https://schema.org',
|
||||
'@type': 'WebSite',
|
||||
name: 'DiceBear',
|
||||
url: 'https://www.dicebear.com',
|
||||
description:
|
||||
'DiceBear is a free, open source avatar library and Avatar API. Generate unique, deterministic SVG avatars and profile pictures with 35+ styles — privacy-focused and self-hostable.',
|
||||
}),
|
||||
],
|
||||
[
|
||||
'script',
|
||||
{ type: 'application/ld+json' },
|
||||
JSON.stringify({
|
||||
'@context': 'https://schema.org',
|
||||
'@type': 'SoftwareApplication',
|
||||
name: 'DiceBear',
|
||||
applicationCategory: 'DeveloperApplication',
|
||||
operatingSystem: 'Any',
|
||||
url: 'https://www.dicebear.com',
|
||||
description:
|
||||
'Privacy-focused, open source SVG avatar library with 35+ styles. Free Avatar API, JavaScript library, PHP library, Python library, Rust library, Go library, Dart library, and CLI for generating deterministic profile pictures and user placeholder images.',
|
||||
offers: {
|
||||
'@type': 'Offer',
|
||||
price: '0',
|
||||
priceCurrency: 'USD',
|
||||
},
|
||||
}),
|
||||
],
|
||||
...thirdPartyScripts,
|
||||
],
|
||||
srcDir: path.join(__dirname, '..', 'pages'),
|
||||
transformHead: (ctx) => {
|
||||
const result: HeadConfig[] = [];
|
||||
|
||||
// The theme renders text with the self-hosted variable Inter (see
|
||||
// theme/index.ts); VitePress's built-in Inter is disabled via
|
||||
// theme-without-fonts. Preload the latin subset so the first paint
|
||||
// doesn't wait a full round trip after the CSS arrives. Font preloads
|
||||
// always need `crossorigin`, even for same-origin requests.
|
||||
const interFont = ctx.assets.find((asset) =>
|
||||
/inter-latin-wght-normal\.[\w-]+\.woff2$/.test(asset),
|
||||
);
|
||||
|
||||
if (interFont) {
|
||||
result.push([
|
||||
'link',
|
||||
{
|
||||
rel: 'preload',
|
||||
href: interFont,
|
||||
as: 'font',
|
||||
type: 'font/woff2',
|
||||
crossorigin: '',
|
||||
},
|
||||
]);
|
||||
}
|
||||
|
||||
if (ctx.pageData.relativePath) {
|
||||
const canonicalPath = ctx.pageData.relativePath
|
||||
.replace('index.md', '')
|
||||
.replace(/\.md$/, '');
|
||||
|
||||
const canonicalUrl = `https://www.dicebear.com/${canonicalPath}`;
|
||||
|
||||
result.push(['link', { rel: 'canonical', href: canonicalUrl }]);
|
||||
|
||||
if (canonicalPath.startsWith('legal/legal-notice')) {
|
||||
result.push(['meta', { name: 'robots', content: 'noindex, nofollow' }]);
|
||||
}
|
||||
|
||||
const pageTitle =
|
||||
ctx.pageData.frontmatter.title || ctx.pageData.title || 'DiceBear';
|
||||
const pageDescription =
|
||||
ctx.pageData.frontmatter.description ||
|
||||
ctx.pageData.description ||
|
||||
'DiceBear is a free, open source avatar library and Avatar API with 35+ avatar styles.';
|
||||
|
||||
result.push(
|
||||
['meta', { property: 'og:title', content: pageTitle }],
|
||||
['meta', { property: 'og:description', content: pageDescription }],
|
||||
['meta', { property: 'og:url', content: canonicalUrl }],
|
||||
['meta', { name: 'twitter:title', content: pageTitle }],
|
||||
['meta', { name: 'twitter:description', content: pageDescription }],
|
||||
);
|
||||
}
|
||||
|
||||
return result;
|
||||
},
|
||||
vite: {
|
||||
ssr: {
|
||||
noExternal: ['vue-countup-v3', 'vue-chartjs', 'globe.gl', 'three'],
|
||||
},
|
||||
resolve: {
|
||||
alias: {
|
||||
'@playground': path.resolve(__dirname, 'theme/components/playground'),
|
||||
'@theme': path.resolve(__dirname, 'theme'),
|
||||
'./components/VPLocalNav.vue': path.resolve(
|
||||
__dirname,
|
||||
'theme/components/layout/LayoutVPLocalNav.vue',
|
||||
),
|
||||
},
|
||||
},
|
||||
},
|
||||
themeConfig: {
|
||||
avatarStyles,
|
||||
avatarUniqueCounts,
|
||||
avatarStyleSizes,
|
||||
githubStars,
|
||||
siteTitle: '',
|
||||
// Intrinsic SVG dimensions as explicit attributes so the browser can
|
||||
// reserve the aspect ratio before the file loads (the displayed size
|
||||
// comes from the theme's CSS height). VPImage spreads nested src
|
||||
// objects onto the <img> at runtime, but ThemeableImage only types the
|
||||
// light/dark form with plain strings, hence the double cast.
|
||||
logo: {
|
||||
dark: { src: '/logo-dark.svg', width: 183, height: 32 },
|
||||
light: { src: '/logo.svg', width: 183, height: 32 },
|
||||
} as unknown as DefaultTheme.ThemeableImage,
|
||||
externalLinkIcon: true,
|
||||
search: {
|
||||
provider: 'local',
|
||||
},
|
||||
nav: [
|
||||
{ text: 'Playground', link: '/playground/', activeMatch: '^/playground' },
|
||||
{
|
||||
text: 'Docs',
|
||||
link: '/introduction/',
|
||||
activeMatch: '^/(introduction|how-to-use|guides|specification)',
|
||||
},
|
||||
{
|
||||
text: 'Styles',
|
||||
link: '/styles/',
|
||||
activeMatch: '^/styles',
|
||||
},
|
||||
{ text: 'Editor', link: 'https://editor.dicebear.com' },
|
||||
{
|
||||
text: '10.x',
|
||||
items: [{ text: '9.x', link: 'https://v9.dicebear.com' }],
|
||||
},
|
||||
],
|
||||
outline: [2, 2],
|
||||
socialLinks: [],
|
||||
editLink: {
|
||||
pattern:
|
||||
'https://github.com/dicebear/dicebear/edit/10.x/apps/docs/pages/:path',
|
||||
},
|
||||
sidebar: {
|
||||
'/introduction/': sidebarDocs,
|
||||
'/styles/': sidebarStyles,
|
||||
'/how-to-use/': sidebarDocs,
|
||||
'/guides/': sidebarDocs,
|
||||
'/specification/': sidebarDocs,
|
||||
'/tools/': sidebarTools,
|
||||
},
|
||||
},
|
||||
sitemap: {
|
||||
hostname: 'https://www.dicebear.com',
|
||||
},
|
||||
markdown: {},
|
||||
});
|
||||
@@ -0,0 +1,67 @@
|
||||
import { createRequire } from 'node:module';
|
||||
import * as fs from 'node:fs';
|
||||
import * as path from 'node:path';
|
||||
import { gzipSync } from 'node:zlib';
|
||||
|
||||
import type { AvatarStyleSizeBundle } from '@theme/types';
|
||||
|
||||
const require = createRequire(import.meta.url);
|
||||
|
||||
function sizeFor(file: string): { raw: number; gzip: number } {
|
||||
const buf = fs.readFileSync(file);
|
||||
return { raw: buf.byteLength, gzip: gzipSync(buf).byteLength };
|
||||
}
|
||||
|
||||
function walkJsFiles(dir: string): string[] {
|
||||
const out: string[] = [];
|
||||
for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
|
||||
const full = path.join(dir, entry.name);
|
||||
if (entry.isDirectory()) {
|
||||
out.push(...walkJsFiles(full));
|
||||
} else if (entry.isFile() && entry.name.endsWith('.js')) {
|
||||
out.push(full);
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
const stylesDir = path.dirname(
|
||||
require.resolve('@dicebear/styles/initials.json'),
|
||||
);
|
||||
const coreLibDir = path.dirname(require.resolve('@dicebear/core'));
|
||||
// @dicebear/converter `main` points to lib/node/index.js; step up to lib/
|
||||
const converterLibDir = path.dirname(
|
||||
path.dirname(require.resolve('@dicebear/converter')),
|
||||
);
|
||||
|
||||
const styles: Record<string, { raw: number; gzip: number }> = {};
|
||||
for (const file of fs.readdirSync(stylesDir)) {
|
||||
if (!file.endsWith('.min.json')) continue;
|
||||
const name = file.replace('.min.json', '');
|
||||
styles[name] = sizeFor(path.join(stylesDir, file));
|
||||
}
|
||||
|
||||
function bundleSize(
|
||||
dir: string,
|
||||
exclude: (file: string) => boolean = () => false,
|
||||
) {
|
||||
let raw = 0;
|
||||
let gzip = 0;
|
||||
for (const file of walkJsFiles(dir)) {
|
||||
if (exclude(file)) continue;
|
||||
const s = sizeFor(file);
|
||||
raw += s.raw;
|
||||
gzip += s.gzip;
|
||||
}
|
||||
return { raw, gzip };
|
||||
}
|
||||
|
||||
const avatarStyleSizes: AvatarStyleSizeBundle = {
|
||||
core: bundleSize(coreLibDir),
|
||||
converter: bundleSize(converterLibDir, (file) =>
|
||||
file.includes(`${path.sep}node${path.sep}`),
|
||||
),
|
||||
styles,
|
||||
};
|
||||
|
||||
export default avatarStyleSizes;
|
||||
@@ -0,0 +1,43 @@
|
||||
import { Style } from '@dicebear/core';
|
||||
import { AvatarStyles } from '@theme/types';
|
||||
import { createRequire } from 'node:module';
|
||||
import * as path from 'node:path';
|
||||
import * as fs from 'node:fs';
|
||||
|
||||
const require = createRequire(import.meta.url);
|
||||
|
||||
const definitionsDir = path.dirname(
|
||||
require.resolve('@dicebear/styles/initials.json'),
|
||||
);
|
||||
|
||||
const avatarStyles: AvatarStyles = {};
|
||||
|
||||
for (const file of fs.readdirSync(definitionsDir)) {
|
||||
if (!file.endsWith('.min.json')) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const name = file.replace('.min.json', '');
|
||||
const definition = JSON.parse(
|
||||
fs.readFileSync(path.join(definitionsDir, file), 'utf-8'),
|
||||
);
|
||||
const style = new Style(definition);
|
||||
const meta = style.meta();
|
||||
|
||||
avatarStyles[name] = {
|
||||
definitionUrl: style.id(),
|
||||
meta: {
|
||||
title: meta.source().name(),
|
||||
creator: meta.creator().name(),
|
||||
homepage: meta.creator().url(),
|
||||
source: meta.source().url(),
|
||||
license: {
|
||||
name: meta.license().name(),
|
||||
url: meta.license().url(),
|
||||
text: meta.license().text(),
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export default avatarStyles;
|
||||
@@ -0,0 +1,30 @@
|
||||
import type { StyleDefinition } from '@dicebear/core';
|
||||
import { createRequire } from 'node:module';
|
||||
import * as path from 'node:path';
|
||||
import * as fs from 'node:fs';
|
||||
|
||||
import type { AvatarUniqueCount } from '@theme/types';
|
||||
import { computeCount } from '../theme/utils/avatar/combinationCount';
|
||||
|
||||
const require = createRequire(import.meta.url);
|
||||
|
||||
const definitionsDir = path.dirname(
|
||||
require.resolve('@dicebear/styles/initials.json'),
|
||||
);
|
||||
|
||||
const avatarUniqueCounts: Record<string, AvatarUniqueCount> = {};
|
||||
|
||||
for (const file of fs.readdirSync(definitionsDir)) {
|
||||
if (!file.endsWith('.min.json')) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const name = file.replace('.min.json', '');
|
||||
const definition: StyleDefinition = JSON.parse(
|
||||
fs.readFileSync(path.join(definitionsDir, file), 'utf-8'),
|
||||
);
|
||||
|
||||
avatarUniqueCounts[name] = computeCount(definition);
|
||||
}
|
||||
|
||||
export default avatarUniqueCounts;
|
||||
@@ -0,0 +1,182 @@
|
||||
import { DefaultTheme } from 'vitepress';
|
||||
|
||||
// NOTE: VitePress renders sidebar item `text` with `v-html`, so inline markup is
|
||||
// allowed — we use it to attach small status badges (styled via
|
||||
// `.vp-sidebar-badge` in theme/styles/main.scss). Groups are intentionally NOT
|
||||
// collapsible (no `collapsed` key), so every section stays expanded.
|
||||
const sidebar: DefaultTheme.SidebarItem[] = [
|
||||
{
|
||||
text: 'Introduction',
|
||||
items: [
|
||||
{ text: 'What is DiceBear?', link: '/introduction/' },
|
||||
{
|
||||
text: 'DiceBear vs. Alternatives',
|
||||
link: '/guides/avatar-library-comparison/',
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
text: 'How to use',
|
||||
items: [
|
||||
{
|
||||
text: 'JS Library',
|
||||
link: '/how-to-use/js-library/',
|
||||
items: [
|
||||
{ text: 'Core', link: '/how-to-use/js-library/' },
|
||||
{ text: 'Converter', link: '/how-to-use/js-library/converter/' },
|
||||
],
|
||||
},
|
||||
{
|
||||
text: 'PHP Library <span class="vp-sidebar-badge is-new">New</span>',
|
||||
link: '/how-to-use/php-library/',
|
||||
},
|
||||
{
|
||||
text: 'Python Library <span class="vp-sidebar-badge is-new">New</span>',
|
||||
link: '/how-to-use/python-library/',
|
||||
},
|
||||
{
|
||||
text: 'Rust Library <span class="vp-sidebar-badge is-new">New</span>',
|
||||
link: '/how-to-use/rust-library/',
|
||||
},
|
||||
{
|
||||
text: 'Go Library <span class="vp-sidebar-badge is-new">New</span>',
|
||||
link: '/how-to-use/go-library/',
|
||||
},
|
||||
{
|
||||
text: 'Dart Library <span class="vp-sidebar-badge is-new">New</span>',
|
||||
link: '/how-to-use/dart-library/',
|
||||
},
|
||||
{ text: 'HTTP-API', link: '/how-to-use/http-api/' },
|
||||
{ text: 'CLI', link: '/how-to-use/cli/' },
|
||||
],
|
||||
},
|
||||
{
|
||||
text: 'Frameworks',
|
||||
items: [
|
||||
{
|
||||
text: 'Angular',
|
||||
link: '/guides/use-the-library-with-angular/',
|
||||
},
|
||||
{
|
||||
text: 'Flutter',
|
||||
link: '/guides/use-the-library-with-flutter/',
|
||||
},
|
||||
{
|
||||
text: 'Next.js',
|
||||
link: '/guides/use-the-library-with-next-js/',
|
||||
},
|
||||
{
|
||||
text: 'Nuxt',
|
||||
link: '/guides/use-the-library-with-nuxt/',
|
||||
},
|
||||
{
|
||||
text: 'React',
|
||||
link: '/guides/use-the-library-with-react/',
|
||||
},
|
||||
{
|
||||
text: 'React Native',
|
||||
link: '/guides/use-the-library-with-react-native/',
|
||||
},
|
||||
{
|
||||
text: 'Svelte',
|
||||
link: '/guides/use-the-library-with-svelte/',
|
||||
},
|
||||
{
|
||||
text: 'Vue',
|
||||
link: '/guides/use-the-library-with-vue/',
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
text: 'Custom Styles',
|
||||
items: [
|
||||
{
|
||||
text: 'With Figma',
|
||||
link: '/guides/create-an-avatar-style-with-figma/',
|
||||
},
|
||||
{
|
||||
text: 'From Scratch',
|
||||
link: '/guides/create-an-avatar-style-from-scratch/',
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
text: 'Specification',
|
||||
items: [
|
||||
{
|
||||
text: 'Definition Schema',
|
||||
link: '/specification/definition-schema/',
|
||||
},
|
||||
{
|
||||
text: 'Implement DiceBear Core',
|
||||
link: '/specification/implement-dicebear-core/',
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
text: 'Concepts',
|
||||
items: [
|
||||
{
|
||||
text: 'Gender',
|
||||
link: '/guides/how-do-i-set-a-gender/',
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
text: 'Use Cases',
|
||||
items: [
|
||||
{
|
||||
text: 'Avatar Placeholder',
|
||||
link: '/guides/use-as-avatar-placeholder/',
|
||||
},
|
||||
{
|
||||
text: 'Gravatar Default Image',
|
||||
link: '/guides/use-the-http-api-as-gravatar-default-image/',
|
||||
},
|
||||
{
|
||||
text: 'Self-host the HTTP-API',
|
||||
link: '/guides/host-the-http-api-yourself/',
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
text: 'Advanced',
|
||||
items: [
|
||||
{
|
||||
text: 'Access Style Options',
|
||||
link: '/guides/access-all-available-options/',
|
||||
},
|
||||
{
|
||||
text: 'Load All Avatar Styles',
|
||||
link: '/guides/load-all-avatar-styles/',
|
||||
},
|
||||
{
|
||||
text: 'Unique Avatar Count',
|
||||
link: '/guides/how-many-unique-avatars/',
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
text: 'Contributing',
|
||||
items: [
|
||||
{
|
||||
text: 'Documentation',
|
||||
link: '/guides/contribute-to-the-documentation/',
|
||||
},
|
||||
{
|
||||
text: 'Editor',
|
||||
link: '/guides/contribute-to-the-editor/',
|
||||
},
|
||||
{
|
||||
text: 'API',
|
||||
link: '/guides/contribute-to-the-api/',
|
||||
},
|
||||
{
|
||||
text: 'Library',
|
||||
link: '/guides/contribute-to-the-library/',
|
||||
},
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
export default sidebar;
|
||||
@@ -0,0 +1,10 @@
|
||||
import { DefaultTheme } from 'vitepress';
|
||||
|
||||
const sidebar: DefaultTheme.SidebarItem[] = [
|
||||
{
|
||||
text: 'Playground',
|
||||
items: [{ text: 'Playground', link: '/playground/' }],
|
||||
},
|
||||
];
|
||||
|
||||
export default sidebar;
|
||||
@@ -0,0 +1,36 @@
|
||||
import { capitalCase } from 'change-case';
|
||||
import { DefaultTheme } from 'vitepress';
|
||||
import avatarStyles from './avatarStyles';
|
||||
|
||||
// Avatar styles to flag with a "New" badge in the sidebar. These are the styles
|
||||
// added in the most recent @dicebear/styles release that introduced new styles
|
||||
// (v10.0.0). Update this set when new styles ship. The badge markup is rendered
|
||||
// via v-html (see `.vp-sidebar-badge` in theme/styles/main.scss).
|
||||
const NEW_STYLES = new Set<string>([
|
||||
'disco',
|
||||
'glyphs',
|
||||
'initial-face',
|
||||
'shape-grid',
|
||||
'stripes',
|
||||
'triangles',
|
||||
]);
|
||||
|
||||
const sidebar: DefaultTheme.SidebarItem[] = [
|
||||
{
|
||||
text: 'Styles',
|
||||
items: Object.keys(avatarStyles)
|
||||
.sort((a, b) => a.localeCompare(b))
|
||||
.map((styleName) => {
|
||||
const label = capitalCase(styleName);
|
||||
|
||||
return {
|
||||
text: NEW_STYLES.has(styleName)
|
||||
? `${label} <span class="vp-sidebar-badge is-new">New</span>`
|
||||
: label,
|
||||
link: `/styles/${styleName}/`,
|
||||
};
|
||||
}),
|
||||
},
|
||||
];
|
||||
|
||||
export default sidebar;
|
||||
@@ -0,0 +1,13 @@
|
||||
import { DefaultTheme } from 'vitepress';
|
||||
|
||||
const sidebar: DefaultTheme.SidebarItem[] = [
|
||||
{
|
||||
text: 'Tools',
|
||||
items: [
|
||||
{ text: 'WCAG Contrast Picker', link: '/tools/contrast/' },
|
||||
{ text: 'Bundle Size Estimator', link: '/tools/bundle-size/' },
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
export default sidebar;
|
||||
@@ -0,0 +1,41 @@
|
||||
<script setup lang="ts">
|
||||
import DefaultTheme from 'vitepress/theme-without-fonts';
|
||||
import { VPDocAsideSponsors } from 'vitepress/theme-without-fonts';
|
||||
import { withBase } from 'vitepress';
|
||||
import LayoutFooter from './components/layout/LayoutFooter.vue';
|
||||
import LayoutNavActions from './components/layout/LayoutNavActions.vue';
|
||||
import './styles/main.scss';
|
||||
|
||||
const { Layout } = DefaultTheme;
|
||||
|
||||
const sponsors = [
|
||||
{
|
||||
tier: 'CDN sponsored by',
|
||||
size: 'medium' as const,
|
||||
items: [
|
||||
{
|
||||
name: 'bunny.net',
|
||||
img: withBase('/sponsors/bunny-dark.svg'),
|
||||
url: 'https://bunny.net/',
|
||||
},
|
||||
],
|
||||
},
|
||||
];
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Layout>
|
||||
<template #nav-bar-content-after>
|
||||
<LayoutNavActions />
|
||||
</template>
|
||||
<template #aside-outline-after>
|
||||
<div class="layout-aside-sponsors">
|
||||
<VPDocAsideSponsors :data="sponsors" />
|
||||
<span class="layout-aside-sponsors-ad">Advertisement</span>
|
||||
</div>
|
||||
</template>
|
||||
<template #layout-bottom>
|
||||
<LayoutFooter />
|
||||
</template>
|
||||
</Layout>
|
||||
</template>
|
||||
@@ -0,0 +1,295 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, computed } from 'vue';
|
||||
import { Globe, Zap, Server } from '@lucide/vue';
|
||||
import { UiContainer, UiSection, UiSectionHeader, UiCard } from '../ui';
|
||||
import { useVisibility } from '../../composables/useVisibility';
|
||||
import { useApiStats } from '../../composables/useApiStats';
|
||||
import { formatNumber, formatBytes } from '../../utils/format';
|
||||
|
||||
const sectionRef = ref();
|
||||
const isVisible = useVisibility(sectionRef, { threshold: 0.15 });
|
||||
|
||||
const apiStats = useApiStats();
|
||||
|
||||
const monthLabel = computed(() => apiStats.value?.monthLabel ?? 'Month');
|
||||
|
||||
const stats = computed(() => [
|
||||
{
|
||||
value: apiStats.value
|
||||
? formatNumber(apiStats.value.monthlyRequests)
|
||||
: '1B+',
|
||||
label: `Requests in ${monthLabel.value}`,
|
||||
icon: Globe,
|
||||
},
|
||||
{
|
||||
value: apiStats.value ? formatBytes(apiStats.value.monthlyTraffic) : '3TB+',
|
||||
label: `Data Served in ${monthLabel.value}`,
|
||||
icon: Server,
|
||||
},
|
||||
{ value: '85%+', label: 'Cache Hit Rate', icon: Zap },
|
||||
]);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<UiSection ref="sectionRef" :class="{ visible: isVisible }" divider>
|
||||
<template #background>
|
||||
<div class="app-cdn-gradient"></div>
|
||||
</template>
|
||||
<UiContainer>
|
||||
<UiSectionHeader
|
||||
class="app-cdn-header"
|
||||
description="Our HTTP-API is powered by a global CDN that delivers avatars with low latency and high reliability, completely free of charge."
|
||||
>
|
||||
<template #headline
|
||||
>Lightning fast, <strong>globally delivered</strong></template
|
||||
>
|
||||
</UiSectionHeader>
|
||||
|
||||
<div class="app-cdn-content">
|
||||
<UiCard padding="2xl" class="app-cdn-card">
|
||||
<div class="app-cdn-card-layout">
|
||||
<div class="app-cdn-card-info">
|
||||
<div class="app-cdn-sponsor">
|
||||
<a
|
||||
href="https://bunny.net/"
|
||||
class="app-cdn-logo-link"
|
||||
target="_blank"
|
||||
rel="noopener sponsored"
|
||||
>
|
||||
<img
|
||||
src="/sponsors/bunny-dark.svg"
|
||||
alt="bunny.net"
|
||||
class="app-cdn-logo app-cdn-logo-light"
|
||||
/>
|
||||
<img
|
||||
src="/sponsors/bunny-light.svg"
|
||||
alt="bunny.net"
|
||||
class="app-cdn-logo app-cdn-logo-dark"
|
||||
/>
|
||||
</a>
|
||||
<p class="app-cdn-description">
|
||||
bunny.net sponsors the CDN infrastructure for our HTTP-API.
|
||||
This lets us serve avatars globally with low latency,
|
||||
completely free of charge for you.
|
||||
</p>
|
||||
<a
|
||||
href="https://bunny.net/"
|
||||
class="app-cdn-link"
|
||||
target="_blank"
|
||||
rel="noopener sponsored"
|
||||
>
|
||||
Visit bunny.net →
|
||||
</a>
|
||||
<span class="app-cdn-ad">Advertisement</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="app-cdn-stats">
|
||||
<div
|
||||
v-for="(stat, index) in stats"
|
||||
:key="stat.label"
|
||||
class="app-cdn-stat-item"
|
||||
:style="{ animationDelay: `${index * 0.1}s` }"
|
||||
>
|
||||
<component :is="stat.icon" class="app-cdn-stat-icon" />
|
||||
<span class="app-cdn-stat-value">{{ stat.value }}</span>
|
||||
<span class="app-cdn-stat-label">{{ stat.label }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</UiCard>
|
||||
</div>
|
||||
</UiContainer>
|
||||
</UiSection>
|
||||
</template>
|
||||
|
||||
<style lang="scss">
|
||||
:root {
|
||||
--app-cdn-logo-light-display: inline;
|
||||
--app-cdn-logo-dark-display: none;
|
||||
}
|
||||
.dark {
|
||||
--app-cdn-logo-light-display: none;
|
||||
--app-cdn-logo-dark-display: inline;
|
||||
}
|
||||
</style>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.app-cdn {
|
||||
&-gradient {
|
||||
background:
|
||||
radial-gradient(
|
||||
ellipse 60% 70% at 50% 0%,
|
||||
color-mix(in srgb, var(--vp-c-brand-1) 6%, transparent),
|
||||
transparent
|
||||
),
|
||||
radial-gradient(
|
||||
ellipse 60% 70% at 50% 100%,
|
||||
color-mix(in srgb, var(--vp-c-brand-1) 4%, transparent),
|
||||
transparent
|
||||
);
|
||||
}
|
||||
|
||||
&-header {
|
||||
--hl-from: var(--vp-c-brand-3);
|
||||
--hl-to: var(--vp-c-brand-1);
|
||||
opacity: 0;
|
||||
transform: translateY(30px);
|
||||
transition: all var(--duration-reveal) var(--ease-smooth);
|
||||
|
||||
.visible & {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
}
|
||||
}
|
||||
|
||||
&-content {
|
||||
opacity: 0;
|
||||
transform: translateY(30px);
|
||||
transition: all var(--duration-reveal) var(--ease-smooth) 0.15s;
|
||||
|
||||
.visible & {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
}
|
||||
}
|
||||
|
||||
&-card-layout {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 48px;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
&-sponsor {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 20px;
|
||||
}
|
||||
|
||||
&-logo-link {
|
||||
display: inline-block;
|
||||
line-height: 0;
|
||||
|
||||
&::after {
|
||||
display: none !important;
|
||||
}
|
||||
}
|
||||
|
||||
&-logo {
|
||||
height: 44px;
|
||||
width: auto;
|
||||
|
||||
&-light {
|
||||
display: var(--app-cdn-logo-light-display);
|
||||
}
|
||||
|
||||
&-dark {
|
||||
display: var(--app-cdn-logo-dark-display);
|
||||
}
|
||||
}
|
||||
|
||||
&-description {
|
||||
font-size: 15px;
|
||||
color: var(--vp-c-text-2);
|
||||
line-height: 1.7;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
&-link {
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
color: var(--vp-c-brand-1);
|
||||
text-decoration: none;
|
||||
transition: color var(--duration-fast) ease;
|
||||
|
||||
&::after {
|
||||
display: none !important;
|
||||
}
|
||||
|
||||
&:hover {
|
||||
color: var(--vp-c-brand-2);
|
||||
}
|
||||
}
|
||||
|
||||
&-ad {
|
||||
font-size: 12px;
|
||||
color: var(--vp-c-text-3);
|
||||
}
|
||||
|
||||
&-stats {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
&-stat-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 16px;
|
||||
padding: 20px 24px;
|
||||
background: var(--vp-c-bg);
|
||||
border-radius: var(--vp-radius-sm);
|
||||
opacity: 0;
|
||||
transform: translateX(20px);
|
||||
transition: all var(--duration-mid) ease;
|
||||
|
||||
.visible & {
|
||||
animation: app-cdn-stat-reveal 0.5s var(--ease-spring) forwards;
|
||||
}
|
||||
}
|
||||
|
||||
&-stat-icon {
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
color: var(--vp-c-brand-1);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
&-stat-value {
|
||||
font-size: 22px;
|
||||
font-weight: 800;
|
||||
color: var(--vp-c-text-1);
|
||||
font-variant-numeric: tabular-nums;
|
||||
min-width: 80px;
|
||||
}
|
||||
|
||||
&-stat-label {
|
||||
font-size: 14px;
|
||||
color: var(--vp-c-text-2);
|
||||
font-weight: 500;
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes app-cdn-stat-reveal {
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateX(0);
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.app-cdn {
|
||||
&-card {
|
||||
--ui-card-padding: 32px 24px;
|
||||
}
|
||||
|
||||
&-card-layout {
|
||||
grid-template-columns: 1fr;
|
||||
gap: 32px;
|
||||
}
|
||||
|
||||
&-stats {
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
&-stat-item {
|
||||
padding: 16px 20px;
|
||||
}
|
||||
|
||||
&-stat-value {
|
||||
font-size: 20px;
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,283 @@
|
||||
<script setup lang="ts">
|
||||
import { ref } from 'vue';
|
||||
import { Check, X } from '@lucide/vue';
|
||||
import { useData } from 'vitepress';
|
||||
import type { ThemeOptions } from '@theme/types';
|
||||
import { UiContainer, UiSection, UiSectionHeader, UiCard } from '../ui';
|
||||
import { useVisibility } from '../../composables/useVisibility';
|
||||
import {
|
||||
buildComparisonRows,
|
||||
comparisonServices,
|
||||
} from '@theme/config/comparison';
|
||||
|
||||
const { theme } = useData<ThemeOptions>();
|
||||
const sectionRef = ref();
|
||||
const isVisible = useVisibility(sectionRef, { threshold: 0.1 });
|
||||
|
||||
const rows = buildComparisonRows({
|
||||
stars: theme.value.githubStars ?? {},
|
||||
styleCount: Object.keys(theme.value.avatarStyles ?? {}).length,
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<UiSection ref="sectionRef" :class="{ visible: isVisible }" divider>
|
||||
<template #background>
|
||||
<div class="app-comparison-gradient"></div>
|
||||
</template>
|
||||
<UiContainer>
|
||||
<UiSectionHeader
|
||||
class="app-comparison-header"
|
||||
description="Every tool has its strengths. Choose what works best for your project."
|
||||
>
|
||||
<template #headline>How DiceBear <strong>Compares</strong></template>
|
||||
</UiSectionHeader>
|
||||
|
||||
<UiCard flush class="app-comparison-table-card">
|
||||
<div class="app-comparison-table-wrapper">
|
||||
<table class="app-comparison-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th class="app-comparison-feature-col">Feature</th>
|
||||
<th
|
||||
v-for="(service, index) in comparisonServices"
|
||||
:key="service.key"
|
||||
:class="{ 'app-comparison-highlight-col': index === 0 }"
|
||||
>
|
||||
<a
|
||||
:href="service.url"
|
||||
target="_blank"
|
||||
rel="noopener"
|
||||
class="app-comparison-service-link"
|
||||
>{{ service.name }}</a
|
||||
>
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-for="(row, rowIndex) in rows" :key="rowIndex">
|
||||
<td class="app-comparison-feature-col">{{ row.feature }}</td>
|
||||
<td
|
||||
v-for="(service, colIndex) in comparisonServices"
|
||||
:key="service.key"
|
||||
:class="{ 'app-comparison-highlight-col': colIndex === 0 }"
|
||||
>
|
||||
<span
|
||||
v-if="row.values[service.key] === 'yes'"
|
||||
class="app-comparison-cell-yes"
|
||||
>
|
||||
<Check :size="18" />
|
||||
</span>
|
||||
<span
|
||||
v-else-if="row.values[service.key] === 'free'"
|
||||
class="app-comparison-cell-free"
|
||||
>
|
||||
Free
|
||||
</span>
|
||||
<span
|
||||
v-else-if="row.values[service.key] === 'paid'"
|
||||
class="app-comparison-cell-paid"
|
||||
>
|
||||
Paid
|
||||
</span>
|
||||
<span
|
||||
v-else-if="row.values[service.key] === 'no'"
|
||||
class="app-comparison-cell-no"
|
||||
>
|
||||
<X :size="18" />
|
||||
</span>
|
||||
<span v-else class="app-comparison-cell-text">{{
|
||||
row.values[service.key]
|
||||
}}</span>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</UiCard>
|
||||
|
||||
<p class="app-comparison-note">
|
||||
This comparison is based on publicly available information and may not
|
||||
reflect the latest updates. Each tool has its own strengths, so choose
|
||||
what works best for your project.
|
||||
</p>
|
||||
</UiContainer>
|
||||
</UiSection>
|
||||
</template>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.app-comparison {
|
||||
&-gradient {
|
||||
background:
|
||||
radial-gradient(
|
||||
ellipse 50% 50% at 50% 0%,
|
||||
color-mix(in srgb, var(--vp-c-brand-1) 6%, transparent),
|
||||
transparent
|
||||
),
|
||||
radial-gradient(
|
||||
ellipse 50% 50% at 50% 100%,
|
||||
color-mix(in srgb, var(--vp-c-brand-1) 6%, transparent),
|
||||
transparent
|
||||
);
|
||||
}
|
||||
|
||||
&-header {
|
||||
opacity: 0;
|
||||
transform: translateY(30px);
|
||||
transition: all var(--duration-reveal) var(--ease-smooth);
|
||||
|
||||
.visible & {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
}
|
||||
}
|
||||
|
||||
&-table-card {
|
||||
opacity: 0;
|
||||
transform: translateY(30px);
|
||||
transition: all var(--duration-reveal) var(--ease-smooth) 0.2s;
|
||||
|
||||
.visible & {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
}
|
||||
}
|
||||
|
||||
&-table-wrapper {
|
||||
overflow-x: auto;
|
||||
-webkit-overflow-scrolling: touch;
|
||||
}
|
||||
|
||||
&-table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
font-size: 14px;
|
||||
min-width: 700px;
|
||||
|
||||
th,
|
||||
td {
|
||||
padding: 14px 16px;
|
||||
text-align: center;
|
||||
border-bottom: 1px solid var(--vp-c-border);
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
thead th {
|
||||
font-weight: 700;
|
||||
font-size: 13px;
|
||||
color: var(--vp-c-text-2);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.05em;
|
||||
padding-bottom: 16px;
|
||||
}
|
||||
|
||||
tbody tr:last-child td {
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
thead .app-comparison-highlight-col {
|
||||
color: var(--vp-c-brand-1);
|
||||
}
|
||||
}
|
||||
|
||||
&-feature-col {
|
||||
text-align: left !important;
|
||||
font-weight: 600;
|
||||
color: var(--vp-c-text-1);
|
||||
min-width: 160px;
|
||||
position: sticky;
|
||||
left: 0;
|
||||
z-index: 1;
|
||||
background: var(--vp-c-bg);
|
||||
}
|
||||
|
||||
&-highlight-col {
|
||||
background: color-mix(in srgb, var(--vp-c-brand-1) 5%, transparent);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
&-service-link {
|
||||
color: inherit;
|
||||
text-decoration: none;
|
||||
transition: color var(--duration-fast) ease;
|
||||
|
||||
&::after {
|
||||
display: none !important;
|
||||
}
|
||||
|
||||
&:hover {
|
||||
color: var(--vp-c-brand-1);
|
||||
}
|
||||
}
|
||||
|
||||
&-cell-yes {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
border-radius: 50%;
|
||||
background: var(--vp-c-green-soft);
|
||||
color: var(--vp-c-green-1);
|
||||
}
|
||||
|
||||
&-cell-free {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
height: 28px;
|
||||
padding: 0 12px;
|
||||
border-radius: var(--vp-radius-sm);
|
||||
background: var(--vp-c-green-soft);
|
||||
color: var(--vp-c-green-1);
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
&-cell-paid {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
height: 28px;
|
||||
padding: 0 12px;
|
||||
border-radius: var(--vp-radius-sm);
|
||||
background: color-mix(in srgb, var(--vp-c-text-3) 10%, transparent);
|
||||
color: var(--vp-c-text-2);
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
&-cell-no {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
border-radius: 50%;
|
||||
background: color-mix(in srgb, var(--vp-c-text-3) 15%, transparent);
|
||||
color: var(--vp-c-text-3);
|
||||
}
|
||||
|
||||
&-cell-text {
|
||||
font-size: 13px;
|
||||
color: var(--vp-c-text-2);
|
||||
}
|
||||
|
||||
&-note {
|
||||
text-align: center;
|
||||
font-size: 13px;
|
||||
color: var(--vp-c-text-3);
|
||||
margin: 24px auto 0;
|
||||
max-width: 600px;
|
||||
line-height: 1.6;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 640px) {
|
||||
.app-comparison {
|
||||
&-table-card {
|
||||
--card-padding: 16px;
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,475 @@
|
||||
<script setup lang="ts">
|
||||
import { ref } from 'vue';
|
||||
import { ArrowRight, BookOpen } from '@lucide/vue';
|
||||
import { siFigma } from 'simple-icons';
|
||||
import Button from 'primevue/button';
|
||||
import { UiContainer, UiSection, UiSectionHeader, UiCard, UiIcon } from '../ui';
|
||||
import { useVisibility } from '../../composables/useVisibility';
|
||||
|
||||
const sectionRef = ref();
|
||||
const isVisible = useVisibility(sectionRef);
|
||||
|
||||
const steps = [
|
||||
{
|
||||
title: 'Design in Figma',
|
||||
description:
|
||||
'Create your avatar components visually. Group colors and parts using our simple naming conventions.',
|
||||
},
|
||||
{
|
||||
title: 'Export with Plugin',
|
||||
description:
|
||||
'Use the DiceBear Figma plugin to configure options and export your style as a ready-to-use package.',
|
||||
},
|
||||
{
|
||||
title: 'Build & Use',
|
||||
description:
|
||||
'Run npm install and npm run build. Your custom style is ready to generate avatars.',
|
||||
},
|
||||
];
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<UiSection ref="sectionRef" :class="{ visible: isVisible }" divider>
|
||||
<template #background>
|
||||
<div class="app-create-style-bg-grid"></div>
|
||||
<div class="app-create-style-gradient"></div>
|
||||
</template>
|
||||
|
||||
<UiContainer class="app-create-style-container">
|
||||
<UiSectionHeader
|
||||
description="Design your avatar style visually in Figma. Our plugin handles the technical export, with no coding required."
|
||||
>
|
||||
<template #headline
|
||||
>Create Your Own Style<br /><strong>with Figma</strong></template
|
||||
>
|
||||
</UiSectionHeader>
|
||||
|
||||
<div class="app-create-style-grid">
|
||||
<div class="app-create-style-content">
|
||||
<div class="app-create-style-steps">
|
||||
<UiCard
|
||||
v-for="(step, index) in steps"
|
||||
:key="index"
|
||||
class="app-create-style-step-card"
|
||||
:style="{ animationDelay: `${index * 0.15}s` }"
|
||||
>
|
||||
<div class="app-create-style-step-number">{{ index + 1 }}</div>
|
||||
<div class="app-create-style-step-content">
|
||||
<h3 class="app-create-style-step-title">{{ step.title }}</h3>
|
||||
<p class="app-create-style-step-description">
|
||||
{{ step.description }}
|
||||
</p>
|
||||
</div>
|
||||
</UiCard>
|
||||
</div>
|
||||
|
||||
<div class="app-create-style-actions">
|
||||
<Button
|
||||
as="a"
|
||||
href="/guides/create-an-avatar-style-with-figma/"
|
||||
size="large"
|
||||
severity="contrast"
|
||||
>
|
||||
<BookOpen :size="20" />
|
||||
Read the Guide
|
||||
<ArrowRight :size="20" />
|
||||
</Button>
|
||||
<Button
|
||||
as="a"
|
||||
href="https://www.figma.com/community/plugin/1005765655729342787"
|
||||
target="_blank"
|
||||
rel="noopener"
|
||||
size="large"
|
||||
severity="secondary"
|
||||
variant="outlined"
|
||||
>
|
||||
<UiIcon :path="siFigma.path" :size="20" />
|
||||
Get the Plugin
|
||||
<ArrowRight :size="20" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="app-create-style-visual">
|
||||
<div class="app-create-style-figma-mockup">
|
||||
<div class="app-create-style-figma-sidebar">
|
||||
<div class="app-create-style-figma-layers">
|
||||
<div class="app-create-style-layer-group">
|
||||
<span
|
||||
class="app-create-style-layer-icon app-create-style-layer-icon-folder"
|
||||
></span>
|
||||
<span>face</span>
|
||||
</div>
|
||||
<div class="app-create-style-layer-item">
|
||||
<span
|
||||
class="app-create-style-layer-icon app-create-style-layer-icon-component"
|
||||
></span>
|
||||
<span>face/round</span>
|
||||
</div>
|
||||
<div class="app-create-style-layer-item">
|
||||
<span
|
||||
class="app-create-style-layer-icon app-create-style-layer-icon-component"
|
||||
></span>
|
||||
<span>face/oval</span>
|
||||
</div>
|
||||
<div class="app-create-style-layer-group">
|
||||
<span
|
||||
class="app-create-style-layer-icon app-create-style-layer-icon-folder"
|
||||
></span>
|
||||
<span>eyes</span>
|
||||
</div>
|
||||
<div class="app-create-style-layer-item">
|
||||
<span
|
||||
class="app-create-style-layer-icon app-create-style-layer-icon-component"
|
||||
></span>
|
||||
<span>eyes/happy</span>
|
||||
</div>
|
||||
<div class="app-create-style-layer-item active">
|
||||
<span
|
||||
class="app-create-style-layer-icon app-create-style-layer-icon-component"
|
||||
></span>
|
||||
<span>eyes/wink</span>
|
||||
</div>
|
||||
<div class="app-create-style-layer-group">
|
||||
<span
|
||||
class="app-create-style-layer-icon app-create-style-layer-icon-folder"
|
||||
></span>
|
||||
<span>mouth</span>
|
||||
</div>
|
||||
<div class="app-create-style-layer-item">
|
||||
<span
|
||||
class="app-create-style-layer-icon app-create-style-layer-icon-component"
|
||||
></span>
|
||||
<span>mouth/smile</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="app-create-style-figma-canvas">
|
||||
<div class="app-create-style-canvas-avatar">
|
||||
<svg
|
||||
viewBox="0 0 64 64"
|
||||
fill="none"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<circle cx="32" cy="32" r="28" fill="#FFD93D" />
|
||||
<circle cx="22" cy="28" r="4" fill="#1a1a2e" />
|
||||
<path
|
||||
d="M38 26 L42 30 L38 34"
|
||||
stroke="#1a1a2e"
|
||||
stroke-width="3"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
/>
|
||||
<path
|
||||
d="M20 42 Q32 52 44 42"
|
||||
stroke="#1a1a2e"
|
||||
stroke-width="3"
|
||||
stroke-linecap="round"
|
||||
fill="none"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
<div class="app-create-style-canvas-guides">
|
||||
<div
|
||||
class="app-create-style-guide app-create-style-guide-h"
|
||||
></div>
|
||||
<div
|
||||
class="app-create-style-guide app-create-style-guide-v"
|
||||
></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</UiContainer>
|
||||
</UiSection>
|
||||
</template>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.app-create-style {
|
||||
&-bg-grid {
|
||||
background-image:
|
||||
linear-gradient(var(--vp-c-border) 1px, transparent 1px),
|
||||
linear-gradient(90deg, var(--vp-c-border) 1px, transparent 1px);
|
||||
background-size: 60px 60px;
|
||||
background-repeat: repeat !important;
|
||||
opacity: 0.3;
|
||||
mask-image: radial-gradient(ellipse 80% 60% at 50% 50%, black, transparent);
|
||||
-webkit-mask-image: radial-gradient(
|
||||
ellipse 80% 60% at 50% 50%,
|
||||
black,
|
||||
transparent
|
||||
);
|
||||
}
|
||||
|
||||
&-gradient {
|
||||
background: radial-gradient(
|
||||
ellipse 80% 50% at 50% 0%,
|
||||
color-mix(in srgb, var(--vp-c-brand-1) 8%, transparent),
|
||||
transparent
|
||||
);
|
||||
}
|
||||
|
||||
&-container {
|
||||
opacity: 0;
|
||||
transform: translateY(40px);
|
||||
transition: all var(--duration-reveal) var(--ease-smooth);
|
||||
|
||||
.visible & {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
}
|
||||
}
|
||||
|
||||
&-grid {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 64px;
|
||||
align-items: start;
|
||||
}
|
||||
|
||||
&-content {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
&-steps {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 16px;
|
||||
margin-bottom: 32px;
|
||||
}
|
||||
|
||||
&-step-card {
|
||||
opacity: 0;
|
||||
transform: translateX(-20px);
|
||||
|
||||
.visible & {
|
||||
animation: reveal-up 0.6s var(--ease-smooth) forwards;
|
||||
}
|
||||
|
||||
:deep(.ui-card-body) {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 16px;
|
||||
}
|
||||
}
|
||||
|
||||
&-step-number {
|
||||
flex-shrink: 0;
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: var(--vp-c-bg-soft);
|
||||
color: var(--vp-c-text-2);
|
||||
font-size: 13px;
|
||||
font-weight: 700;
|
||||
border-radius: 50%;
|
||||
border: 1px solid var(--vp-c-border);
|
||||
}
|
||||
|
||||
&-step-content {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
&-step-title {
|
||||
font-size: 16px;
|
||||
font-weight: 700;
|
||||
color: var(--vp-c-text-1);
|
||||
margin: 0 0 8px;
|
||||
}
|
||||
|
||||
&-step-description {
|
||||
font-size: 14px;
|
||||
color: var(--vp-c-text-2);
|
||||
margin: 0;
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
&-actions {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
/* Figma Mockup Visual */
|
||||
&-visual {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
}
|
||||
|
||||
&-figma-mockup {
|
||||
display: flex;
|
||||
width: 100%;
|
||||
max-width: 520px;
|
||||
height: 420px;
|
||||
background: #2c2c2c;
|
||||
border-radius: var(--vp-radius-sm);
|
||||
overflow: hidden;
|
||||
box-shadow: var(--vp-shadow-5);
|
||||
}
|
||||
|
||||
&-figma-sidebar {
|
||||
width: 180px;
|
||||
background: #1e1e1e;
|
||||
border-right: 1px solid #3c3c3c;
|
||||
padding: 16px 0;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
&-figma-layers {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
font-size: 12px;
|
||||
font-family: var(--vp-font-family-mono);
|
||||
}
|
||||
|
||||
&-layer-group,
|
||||
&-layer-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 8px 12px;
|
||||
color: #b3b3b3;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
&-layer-group {
|
||||
color: #e0e0e0;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
&-layer-item {
|
||||
padding-left: 28px;
|
||||
|
||||
&.active {
|
||||
background: rgba(24, 160, 251, 0.2);
|
||||
color: #18a0fb;
|
||||
}
|
||||
}
|
||||
|
||||
&-layer-icon {
|
||||
width: 12px;
|
||||
height: 12px;
|
||||
border-radius: 2px;
|
||||
flex-shrink: 0;
|
||||
|
||||
&-folder {
|
||||
background: #f5a623;
|
||||
}
|
||||
|
||||
&-component {
|
||||
background: #a259ff;
|
||||
}
|
||||
}
|
||||
|
||||
&-figma-canvas {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
position: relative;
|
||||
background:
|
||||
linear-gradient(90deg, #3c3c3c 1px, transparent 1px),
|
||||
linear-gradient(#3c3c3c 1px, transparent 1px);
|
||||
background-size: 20px 20px;
|
||||
background-position: center center;
|
||||
}
|
||||
|
||||
&-canvas-avatar {
|
||||
width: 160px;
|
||||
height: 160px;
|
||||
background: white;
|
||||
border-radius: var(--vp-radius-sm);
|
||||
padding: 20px;
|
||||
box-shadow: var(--vp-shadow-4);
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
|
||||
svg {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
}
|
||||
|
||||
&-canvas-guides {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
&-guide {
|
||||
position: absolute;
|
||||
background: rgba(255, 0, 85, 0.5);
|
||||
|
||||
&-h {
|
||||
left: 0;
|
||||
right: 0;
|
||||
top: 50%;
|
||||
height: 1px;
|
||||
}
|
||||
|
||||
&-v {
|
||||
top: 0;
|
||||
bottom: 0;
|
||||
left: 50%;
|
||||
width: 1px;
|
||||
}
|
||||
}
|
||||
}
|
||||
@media (max-width: 960px) {
|
||||
.app-create-style {
|
||||
&-grid {
|
||||
grid-template-columns: 1fr;
|
||||
gap: 48px;
|
||||
}
|
||||
|
||||
&-actions {
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
&-visual {
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
&-figma-mockup {
|
||||
max-width: 480px;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 640px) {
|
||||
.app-create-style {
|
||||
&-actions {
|
||||
flex-direction: column;
|
||||
align-items: stretch;
|
||||
}
|
||||
|
||||
&-figma-mockup {
|
||||
height: 320px;
|
||||
}
|
||||
|
||||
&-figma-sidebar {
|
||||
width: 140px;
|
||||
}
|
||||
|
||||
&-canvas-avatar {
|
||||
width: 120px;
|
||||
height: 120px;
|
||||
}
|
||||
|
||||
&-layer-group,
|
||||
&-layer-item {
|
||||
font-size: 10px;
|
||||
padding: 6px 8px;
|
||||
}
|
||||
|
||||
&-layer-item {
|
||||
padding-left: 20px;
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,555 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, computed } from 'vue';
|
||||
import {
|
||||
MousePointerClick,
|
||||
Palette,
|
||||
Download,
|
||||
ArrowRight,
|
||||
Brush,
|
||||
} from '@lucide/vue';
|
||||
import Button from 'primevue/button';
|
||||
import {
|
||||
UiAvatar,
|
||||
UiHeadline,
|
||||
UiDescription,
|
||||
UiContainer,
|
||||
UiSection,
|
||||
UiWindow,
|
||||
UiIconBox,
|
||||
} from '../ui';
|
||||
import { useVisibility } from '../../composables/useVisibility';
|
||||
|
||||
const sectionRef = ref();
|
||||
const isVisible = useVisibility(sectionRef);
|
||||
|
||||
// Tab configuration
|
||||
const tabs = ['Background', 'Hair', 'Eyes', 'Nose', 'Mouth'];
|
||||
const activeTab = ref(0);
|
||||
|
||||
// Current avatar options
|
||||
const currentOptions = ref({
|
||||
backgroundColor: 'ffd5dc',
|
||||
hair: '',
|
||||
eyes: '',
|
||||
nose: '',
|
||||
mouth: '',
|
||||
});
|
||||
|
||||
// Background colors
|
||||
const bgColors = [
|
||||
'ffd5dc',
|
||||
'f8bbd9',
|
||||
'ffccbc',
|
||||
'ffe0b2',
|
||||
'fff9c4',
|
||||
'c8e6c9',
|
||||
'dcedc8',
|
||||
'b2dfdb',
|
||||
'b2ebf2',
|
||||
'bbdefb',
|
||||
];
|
||||
|
||||
// Variants for each category
|
||||
const variants = {
|
||||
hair: [
|
||||
'variant01',
|
||||
'variant02',
|
||||
'variant03',
|
||||
'variant04',
|
||||
'variant05',
|
||||
'variant06',
|
||||
'variant07',
|
||||
'variant08',
|
||||
'variant09',
|
||||
'variant10',
|
||||
],
|
||||
eyes: [
|
||||
'variant01',
|
||||
'variant02',
|
||||
'variant03',
|
||||
'variant04',
|
||||
'variant05',
|
||||
'variant06',
|
||||
'variant07',
|
||||
'variant08',
|
||||
'variant09',
|
||||
'variant10',
|
||||
],
|
||||
nose: [
|
||||
'variant01',
|
||||
'variant02',
|
||||
'variant03',
|
||||
'variant04',
|
||||
'variant05',
|
||||
'variant06',
|
||||
],
|
||||
mouth: [
|
||||
'happy01',
|
||||
'happy02',
|
||||
'happy03',
|
||||
'happy04',
|
||||
'happy05',
|
||||
'happy06',
|
||||
'happy07',
|
||||
'happy08',
|
||||
'happy09',
|
||||
'happy10',
|
||||
],
|
||||
};
|
||||
|
||||
// Darken a hex color by a percentage
|
||||
function darkenColor(hex: string, percent: number): string {
|
||||
const num = parseInt(hex, 16);
|
||||
const r = Math.max(0, Math.floor((num >> 16) * (1 - percent)));
|
||||
const g = Math.max(0, Math.floor(((num >> 8) & 0x00ff) * (1 - percent)));
|
||||
const b = Math.max(0, Math.floor((num & 0x0000ff) * (1 - percent)));
|
||||
return ((r << 16) | (g << 8) | b).toString(16).padStart(6, '0');
|
||||
}
|
||||
|
||||
// Canvas background (slightly darker than avatar background)
|
||||
const canvasBackground = computed(
|
||||
() => `#${darkenColor(currentOptions.value.backgroundColor, 0.15)}`,
|
||||
);
|
||||
|
||||
// Build style options from current options
|
||||
function buildStyleOptions(options: typeof currentOptions.value) {
|
||||
const result: Record<string, unknown> = {
|
||||
seed: 'editorUser',
|
||||
glassesProbability: 0,
|
||||
};
|
||||
if (options.backgroundColor) result.backgroundColor = options.backgroundColor;
|
||||
if (options.hair) result.hairVariant = options.hair;
|
||||
if (options.eyes) result.eyesVariant = options.eyes;
|
||||
if (options.nose) result.noseVariant = options.nose;
|
||||
if (options.mouth) result.mouthVariant = options.mouth;
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
// Main avatar style options
|
||||
const mainStyleOptions = computed(() =>
|
||||
buildStyleOptions(currentOptions.value),
|
||||
);
|
||||
|
||||
// Panel items based on active tab
|
||||
const panelItems = computed(() => {
|
||||
const tab = tabs[activeTab.value].toLowerCase();
|
||||
|
||||
if (tab === 'background') {
|
||||
return bgColors.map((color) => ({
|
||||
id: color,
|
||||
options: buildStyleOptions({
|
||||
...currentOptions.value,
|
||||
backgroundColor: color,
|
||||
}),
|
||||
selected: currentOptions.value.backgroundColor === color,
|
||||
}));
|
||||
}
|
||||
|
||||
const key = tab as keyof typeof variants;
|
||||
const variantList = variants[key] || [];
|
||||
|
||||
return variantList.map((variant) => ({
|
||||
id: variant,
|
||||
options: buildStyleOptions({ ...currentOptions.value, [key]: variant }),
|
||||
selected: currentOptions.value[key] === variant,
|
||||
}));
|
||||
});
|
||||
|
||||
// Handle item selection
|
||||
function selectItem(id: string) {
|
||||
const tab = tabs[activeTab.value].toLowerCase();
|
||||
|
||||
if (tab === 'background') {
|
||||
currentOptions.value.backgroundColor = id;
|
||||
} else {
|
||||
const key = tab as keyof typeof currentOptions.value;
|
||||
currentOptions.value[key] = currentOptions.value[key] === id ? '' : id;
|
||||
}
|
||||
}
|
||||
|
||||
const features = [
|
||||
{
|
||||
icon: MousePointerClick,
|
||||
title: 'No Coding Required',
|
||||
description: 'Click to customize hair, eyes, accessories, and more.',
|
||||
},
|
||||
{
|
||||
icon: Palette,
|
||||
title: 'Endless Combinations',
|
||||
description: 'Mix and match colors and styles to create your unique look.',
|
||||
},
|
||||
{
|
||||
icon: Download,
|
||||
title: 'Download as PNG',
|
||||
description: 'Save your avatar and use it anywhere you like.',
|
||||
},
|
||||
];
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<UiSection ref="sectionRef" :class="{ visible: isVisible }" divider>
|
||||
<template #background>
|
||||
<div class="app-editor-gradient"></div>
|
||||
<div class="app-editor-dots"></div>
|
||||
</template>
|
||||
<UiContainer class="app-editor-container">
|
||||
<div class="app-editor-preview">
|
||||
<UiWindow title="DiceBear Editor">
|
||||
<!-- Main avatar preview -->
|
||||
<div
|
||||
class="app-editor-canvas"
|
||||
:style="{ backgroundColor: canvasBackground }"
|
||||
>
|
||||
<div
|
||||
class="app-editor-avatar-preview"
|
||||
:style="{ backgroundColor: `#${currentOptions.backgroundColor}` }"
|
||||
>
|
||||
<UiAvatar
|
||||
style-name="lorelei"
|
||||
:style-options="mainStyleOptions"
|
||||
alt="Avatar Preview"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Bottom panel with tabs and options -->
|
||||
<div class="app-editor-panel">
|
||||
<div class="app-editor-panel-tabs">
|
||||
<button
|
||||
v-for="(tab, index) in tabs"
|
||||
:key="tab"
|
||||
:class="[
|
||||
'app-editor-panel-tab',
|
||||
{ active: index === activeTab },
|
||||
]"
|
||||
@click="activeTab = index"
|
||||
>
|
||||
{{ tab }}
|
||||
</button>
|
||||
</div>
|
||||
<div class="app-editor-panel-grid">
|
||||
<button
|
||||
v-for="(item, index) in panelItems"
|
||||
:key="`${activeTab}-${item.id}`"
|
||||
:class="[
|
||||
'app-editor-panel-avatar',
|
||||
{ selected: item.selected },
|
||||
]"
|
||||
:style="{ animationDelay: `${index * 0.05}s` }"
|
||||
@click="selectItem(item.id)"
|
||||
>
|
||||
<UiAvatar
|
||||
style-name="lorelei"
|
||||
:style-options="item.options"
|
||||
:alt="`Variant ${index + 1}`"
|
||||
/>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</UiWindow>
|
||||
</div>
|
||||
|
||||
<div class="app-editor-content">
|
||||
<UiHeadline class="app-editor-title-text"
|
||||
>Avatar Maker <strong>Without Code</strong></UiHeadline
|
||||
>
|
||||
<UiDescription class="app-editor-description">
|
||||
No developer? No problem! Our avatar maker lets anyone create custom
|
||||
profile pictures without writing a single line of code. Just pick,
|
||||
click, and download.
|
||||
</UiDescription>
|
||||
|
||||
<div class="app-editor-features">
|
||||
<div
|
||||
v-for="(feature, index) in features"
|
||||
:key="index"
|
||||
class="app-editor-feature"
|
||||
:style="{ animationDelay: `${index * 0.1}s` }"
|
||||
>
|
||||
<UiIconBox size="md">
|
||||
<component :is="feature.icon" />
|
||||
</UiIconBox>
|
||||
<div class="app-editor-feature-text">
|
||||
<h4 class="app-editor-feature-title">{{ feature.title }}</h4>
|
||||
<p class="app-editor-feature-description">
|
||||
{{ feature.description }}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="app-editor-actions">
|
||||
<Button
|
||||
as="a"
|
||||
href="https://editor.dicebear.com"
|
||||
target="_blank"
|
||||
rel="noopener"
|
||||
size="large"
|
||||
severity="contrast"
|
||||
>
|
||||
<Brush :size="20" />
|
||||
Open Editor
|
||||
<ArrowRight :size="20" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</UiContainer>
|
||||
</UiSection>
|
||||
</template>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.app-editor {
|
||||
&-gradient {
|
||||
background: radial-gradient(
|
||||
ellipse 60% 60% at 100% 50%,
|
||||
color-mix(in srgb, var(--vp-c-brand-1) 6%, transparent),
|
||||
transparent
|
||||
);
|
||||
}
|
||||
|
||||
&-dots {
|
||||
background-image: radial-gradient(
|
||||
circle,
|
||||
var(--vp-c-text-3) 1px,
|
||||
transparent 1px
|
||||
);
|
||||
background-size: 24px 24px;
|
||||
background-repeat: repeat !important;
|
||||
opacity: 0.12;
|
||||
mask-image: radial-gradient(ellipse 50% 50% at 0% 50%, black, transparent);
|
||||
-webkit-mask-image: radial-gradient(
|
||||
ellipse 50% 50% at 0% 50%,
|
||||
black,
|
||||
transparent
|
||||
);
|
||||
}
|
||||
|
||||
&-container {
|
||||
display: grid;
|
||||
grid-template-columns: 1.2fr 1fr;
|
||||
gap: 80px;
|
||||
align-items: center;
|
||||
opacity: 0;
|
||||
transform: translateY(40px);
|
||||
transition: all var(--duration-reveal) var(--ease-smooth);
|
||||
|
||||
.visible & {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
}
|
||||
}
|
||||
|
||||
&-preview {
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
/* Canvas */
|
||||
&-canvas {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 32px;
|
||||
min-height: 200px;
|
||||
transition: background-color var(--duration-mid) ease;
|
||||
}
|
||||
|
||||
&-avatar-preview {
|
||||
width: 140px;
|
||||
height: 140px;
|
||||
background: #ffd5dc;
|
||||
border-radius: var(--vp-radius-xl);
|
||||
overflow: hidden;
|
||||
box-shadow: var(--vp-shadow-3);
|
||||
transition: background-color var(--duration-mid) ease;
|
||||
|
||||
img {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
}
|
||||
}
|
||||
|
||||
/* Bottom panel */
|
||||
&-panel {
|
||||
background: var(--vp-c-bg);
|
||||
border-top: 1px solid var(--vp-c-border);
|
||||
padding: 16px;
|
||||
|
||||
&-tabs {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
margin-bottom: 16px;
|
||||
overflow-x: auto;
|
||||
padding-bottom: 4px;
|
||||
}
|
||||
|
||||
&-tab {
|
||||
padding: 8px 14px;
|
||||
font-size: 13px;
|
||||
font-weight: 500;
|
||||
color: var(--vp-c-text-2);
|
||||
white-space: nowrap;
|
||||
border-radius: var(--vp-radius-xs);
|
||||
border: none;
|
||||
background: transparent;
|
||||
cursor: pointer;
|
||||
transition: all var(--duration-fast) ease;
|
||||
|
||||
&:hover {
|
||||
color: var(--vp-c-text-1);
|
||||
background: var(--vp-c-bg-soft);
|
||||
}
|
||||
|
||||
&.active {
|
||||
color: var(--vp-c-brand-1);
|
||||
background: var(--vp-c-brand-soft);
|
||||
font-weight: 600;
|
||||
}
|
||||
}
|
||||
|
||||
&-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(5, 1fr);
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
&-avatar {
|
||||
aspect-ratio: 1;
|
||||
border-radius: var(--vp-radius-sm);
|
||||
overflow: hidden;
|
||||
opacity: 0;
|
||||
transform: scale(0.9);
|
||||
border: none;
|
||||
padding: 0;
|
||||
cursor: pointer;
|
||||
background: transparent;
|
||||
transition:
|
||||
transform var(--duration-fast) ease,
|
||||
box-shadow var(--duration-fast) ease;
|
||||
|
||||
&:hover {
|
||||
transform: scale(1.05);
|
||||
}
|
||||
|
||||
&.selected {
|
||||
box-shadow: 0 0 0 3px var(--vp-c-brand-1);
|
||||
|
||||
&:hover {
|
||||
transform: scale(1);
|
||||
}
|
||||
}
|
||||
|
||||
.visible & {
|
||||
animation: app-editor-avatar-pop 0.4s var(--ease-smooth) forwards;
|
||||
}
|
||||
|
||||
img {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/* Content */
|
||||
&-content {
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
&-description {
|
||||
margin: 0 0 36px 0;
|
||||
max-width: none;
|
||||
}
|
||||
|
||||
&-features {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 20px;
|
||||
margin-bottom: 36px;
|
||||
}
|
||||
|
||||
&-feature {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 16px;
|
||||
opacity: 0;
|
||||
transform: translateX(20px);
|
||||
|
||||
.visible & {
|
||||
animation: app-editor-feature-slide 0.5s var(--ease-smooth) forwards;
|
||||
}
|
||||
|
||||
&-text {
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
&-title {
|
||||
font-size: 16px;
|
||||
font-weight: 600;
|
||||
color: var(--vp-c-text-1);
|
||||
margin: 0 0 4px;
|
||||
}
|
||||
|
||||
&-description {
|
||||
font-size: 14px;
|
||||
color: var(--vp-c-text-2);
|
||||
margin: 0;
|
||||
line-height: 1.5;
|
||||
}
|
||||
}
|
||||
|
||||
&-actions {
|
||||
display: flex;
|
||||
gap: 16px;
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes app-editor-avatar-pop {
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: scale(1);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes app-editor-feature-slide {
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateX(0);
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 1000px) {
|
||||
.app-editor {
|
||||
&-container {
|
||||
grid-template-columns: 1fr;
|
||||
gap: 48px;
|
||||
}
|
||||
|
||||
&-preview {
|
||||
order: 2;
|
||||
}
|
||||
|
||||
&-content {
|
||||
order: 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 640px) {
|
||||
.app-editor {
|
||||
&-panel-grid {
|
||||
grid-template-columns: repeat(5, 1fr);
|
||||
}
|
||||
|
||||
&-panel-tabs {
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
&-panel-tab {
|
||||
padding: 6px 10px;
|
||||
font-size: 12px;
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,154 @@
|
||||
<script setup lang="ts">
|
||||
import { ref } from 'vue';
|
||||
import { siReact, siVuedotjs, siAngular, siSvelte } from 'simple-icons';
|
||||
import {
|
||||
UiCard,
|
||||
UiContainer,
|
||||
UiSection,
|
||||
UiSectionHeader,
|
||||
UiIconBox,
|
||||
UiIcon,
|
||||
} from '../ui';
|
||||
import { useVisibility } from '../../composables/useVisibility';
|
||||
|
||||
const sectionRef = ref();
|
||||
const isVisible = useVisibility(sectionRef, { threshold: 0.15 });
|
||||
|
||||
const frameworks = [
|
||||
{
|
||||
name: 'React',
|
||||
icon: siReact.path,
|
||||
href: '/guides/use-the-library-with-react/',
|
||||
color: `#${siReact.hex}`,
|
||||
},
|
||||
{
|
||||
name: 'Vue',
|
||||
icon: siVuedotjs.path,
|
||||
href: '/guides/use-the-library-with-vue/',
|
||||
color: `#${siVuedotjs.hex}`,
|
||||
},
|
||||
{
|
||||
name: 'Angular',
|
||||
icon: siAngular.path,
|
||||
href: '/guides/use-the-library-with-angular/',
|
||||
// Angular forbids its former (red) logo; black/white are its default
|
||||
// variations. simple-icons already ships the current shield shape.
|
||||
color: 'var(--logo-monochrome)',
|
||||
},
|
||||
{
|
||||
name: 'Svelte',
|
||||
icon: siSvelte.path,
|
||||
href: '/guides/use-the-library-with-svelte/',
|
||||
color: `#${siSvelte.hex}`,
|
||||
},
|
||||
];
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<UiSection ref="sectionRef" :class="{ visible: isVisible }" divider>
|
||||
<template #background>
|
||||
<div class="app-frameworks-dots"></div>
|
||||
<div class="app-frameworks-gradient"></div>
|
||||
</template>
|
||||
<UiContainer class="app-frameworks-container">
|
||||
<UiSectionHeader
|
||||
description="Use DiceBear with React, Vue, Svelte, Angular and more. Simply use our HTTP-API as image source or install the JS-library."
|
||||
>
|
||||
<template #headline
|
||||
>Works with Your <strong>Favorite</strong> Framework</template
|
||||
>
|
||||
</UiSectionHeader>
|
||||
|
||||
<div class="app-frameworks-grid">
|
||||
<UiCard
|
||||
v-for="(framework, index) in frameworks"
|
||||
:key="framework.name"
|
||||
:href="framework.href"
|
||||
padding="lg"
|
||||
class="app-frameworks-item"
|
||||
:style="{
|
||||
'--framework-color': framework.color,
|
||||
'--ui-card-hover-border-color': framework.color,
|
||||
animationDelay: `${index * 0.1}s`,
|
||||
}"
|
||||
>
|
||||
<UiIconBox size="lg" :color="framework.color">
|
||||
<UiIcon :path="framework.icon" />
|
||||
</UiIconBox>
|
||||
<span class="app-frameworks-name">{{ framework.name }}</span>
|
||||
</UiCard>
|
||||
</div>
|
||||
</UiContainer>
|
||||
</UiSection>
|
||||
</template>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.app-frameworks {
|
||||
&-dots {
|
||||
background-image: radial-gradient(
|
||||
circle,
|
||||
var(--vp-c-text-3) 1px,
|
||||
transparent 1px
|
||||
);
|
||||
background-size: 32px 32px;
|
||||
background-repeat: repeat !important;
|
||||
opacity: 0.2;
|
||||
mask-image: radial-gradient(ellipse 70% 50% at 50% 50%, black, transparent);
|
||||
-webkit-mask-image: radial-gradient(
|
||||
ellipse 70% 50% at 50% 50%,
|
||||
black,
|
||||
transparent
|
||||
);
|
||||
}
|
||||
|
||||
&-gradient {
|
||||
background: radial-gradient(
|
||||
ellipse 80% 60% at 50% 0%,
|
||||
color-mix(in srgb, var(--vp-c-brand-1) 8%, transparent),
|
||||
transparent
|
||||
);
|
||||
}
|
||||
|
||||
&-container {
|
||||
text-align: center;
|
||||
opacity: 0;
|
||||
transform: translateY(30px);
|
||||
transition: all var(--duration-reveal) var(--ease-smooth);
|
||||
|
||||
.visible & {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
}
|
||||
}
|
||||
|
||||
&-grid {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
gap: 32px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
&-item {
|
||||
min-width: 150px;
|
||||
opacity: 0;
|
||||
transform: translateY(20px);
|
||||
|
||||
:deep(.ui-card-body) {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.visible & {
|
||||
animation: reveal-up var(--duration-mid) var(--ease-spring) forwards;
|
||||
}
|
||||
}
|
||||
|
||||
&-name {
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
color: var(--vp-c-text-1);
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,178 @@
|
||||
<script setup lang="ts">
|
||||
import AppHeroContent from './AppHeroContent.vue';
|
||||
import AppHeroSwarm from './AppHeroSwarm.vue';
|
||||
import { useVisibility } from '../../composables/useVisibility';
|
||||
|
||||
// Only used to pause the avatar-cluster bob when the hero scrolls out of view.
|
||||
const isVisible = useVisibility('.app-hero', { once: false, threshold: 0.1 });
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section class="app-hero" :class="{ 'app-hero--paused': !isVisible }">
|
||||
<div class="app-hero-bg ui-bg-grid"></div>
|
||||
<div class="app-hero-glow"></div>
|
||||
|
||||
<div class="app-hero-grid">
|
||||
<AppHeroContent />
|
||||
<AppHeroSwarm />
|
||||
</div>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<style lang="scss">
|
||||
:root {
|
||||
--app-hero-glow: radial-gradient(
|
||||
ellipse 60% 50% at 50% -10%,
|
||||
var(--vp-c-brand-soft),
|
||||
transparent 70%
|
||||
);
|
||||
}
|
||||
|
||||
.app-hero {
|
||||
position: relative;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
min-height: calc(100vh - var(--vp-nav-height, 64px));
|
||||
padding: 80px 24px;
|
||||
overflow: hidden;
|
||||
|
||||
&--paused {
|
||||
.app-hero-swarm-tile {
|
||||
/* Pause only the bob (second animation). SSR renders --paused until the
|
||||
* IntersectionObserver fires after hydration. If the fade were paused
|
||||
* too, the tiles would stay at opacity 0 until all JS has loaded, which
|
||||
* delays LCP by seconds on slow connections. */
|
||||
animation-play-state: running, paused;
|
||||
}
|
||||
}
|
||||
|
||||
&-bg {
|
||||
z-index: 0;
|
||||
}
|
||||
|
||||
&-glow {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
z-index: 0;
|
||||
pointer-events: none;
|
||||
background: var(--app-hero-glow);
|
||||
}
|
||||
|
||||
&-grid {
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
width: 100%;
|
||||
max-width: 1180px;
|
||||
display: grid;
|
||||
grid-template-columns: 1.1fr 1fr;
|
||||
gap: 64px;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
&-content {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
text-align: left;
|
||||
animation: fade-up var(--duration-reveal) var(--ease-smooth);
|
||||
}
|
||||
|
||||
&-title {
|
||||
margin: 0 0 24px;
|
||||
line-height: 1.02;
|
||||
letter-spacing: -0.04em;
|
||||
text-wrap: balance;
|
||||
animation: fade-up var(--duration-reveal) var(--ease-smooth) 0.1s both;
|
||||
|
||||
&-line {
|
||||
display: block;
|
||||
font-size: clamp(42px, 4.8vw, 66px);
|
||||
font-weight: 800;
|
||||
color: var(--vp-c-text-1);
|
||||
}
|
||||
|
||||
/* The single brand-blue accent word (no gradient, no animation). */
|
||||
&-accent {
|
||||
color: var(--vp-c-brand-2);
|
||||
}
|
||||
}
|
||||
|
||||
&-description {
|
||||
font-size: 20px;
|
||||
color: var(--vp-c-text-2);
|
||||
line-height: 1.6;
|
||||
margin: 0 0 40px;
|
||||
max-width: 540px;
|
||||
text-wrap: pretty;
|
||||
animation: fade-up var(--duration-reveal) var(--ease-smooth) 0.2s both;
|
||||
}
|
||||
|
||||
/* Accent underline: solid brand colour, matching the headline accent word. */
|
||||
&-underline {
|
||||
background-image: linear-gradient(var(--vp-c-brand-2), var(--vp-c-brand-2));
|
||||
background-repeat: no-repeat;
|
||||
background-position: 0 100%;
|
||||
background-size: 100% 2px;
|
||||
padding-bottom: 1px;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
&-actions {
|
||||
display: flex;
|
||||
justify-content: flex-start;
|
||||
gap: 16px;
|
||||
animation: fade-up var(--duration-reveal) var(--ease-smooth) 0.3s both;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 960px) {
|
||||
.app-hero {
|
||||
min-height: auto;
|
||||
padding: 96px 24px 80px;
|
||||
|
||||
&-grid {
|
||||
grid-template-columns: 1fr;
|
||||
gap: 48px;
|
||||
}
|
||||
|
||||
&-content {
|
||||
align-items: center;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
&-description {
|
||||
margin-left: auto;
|
||||
margin-right: auto;
|
||||
}
|
||||
|
||||
&-actions {
|
||||
justify-content: center;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.app-hero {
|
||||
padding: 80px 16px 64px;
|
||||
|
||||
&-title-line {
|
||||
font-size: clamp(34px, 9vw, 50px);
|
||||
}
|
||||
|
||||
&-description {
|
||||
font-size: 17px;
|
||||
}
|
||||
|
||||
&-actions {
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
|
||||
.app-hero-action-btn {
|
||||
width: 100%;
|
||||
max-width: 280px;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,223 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted, onUnmounted, watch } from 'vue';
|
||||
import { UiAvatar } from '../ui';
|
||||
import { useVisibility } from '../../composables/useVisibility';
|
||||
|
||||
const listRef = ref<HTMLElement>();
|
||||
const isVisible = useVisibility(listRef, { once: false, threshold: 0.1 });
|
||||
|
||||
const avatarStyles = ['thumbs', 'initials', 'shapes', 'lorelei', 'pixel-art'];
|
||||
const styleA = ref(avatarStyles[0]);
|
||||
const styleB = ref(avatarStyles[1]);
|
||||
const activeLayer = ref<'a' | 'b'>('a');
|
||||
let styleCounter = 0;
|
||||
|
||||
let interval: ReturnType<typeof setInterval> | null = null;
|
||||
|
||||
function tick() {
|
||||
styleCounter++;
|
||||
const nextStyle = avatarStyles[styleCounter % avatarStyles.length];
|
||||
|
||||
if (activeLayer.value === 'a') {
|
||||
styleB.value = nextStyle;
|
||||
activeLayer.value = 'b';
|
||||
} else {
|
||||
styleA.value = nextStyle;
|
||||
activeLayer.value = 'a';
|
||||
}
|
||||
}
|
||||
|
||||
watch(isVisible, (visible) => {
|
||||
if (visible && !interval) {
|
||||
interval = setInterval(tick, 3000);
|
||||
} else if (!visible && interval) {
|
||||
clearInterval(interval);
|
||||
interval = null;
|
||||
}
|
||||
});
|
||||
|
||||
onMounted(() => {
|
||||
if (isVisible.value) {
|
||||
interval = setInterval(tick, 3000);
|
||||
}
|
||||
});
|
||||
|
||||
onUnmounted(() => {
|
||||
if (interval) clearInterval(interval);
|
||||
});
|
||||
|
||||
const users = [
|
||||
{ name: 'Iris W.', position: 'Product Designer', online: true },
|
||||
{ name: 'Leon B.', position: 'Frontend Dev', online: true },
|
||||
{ name: 'Cara M.', position: 'Team Lead', online: true },
|
||||
{ name: 'Finn H.', position: 'Backend Dev', online: false },
|
||||
{ name: 'Yuki S.', position: 'UX Researcher', online: false },
|
||||
];
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div ref="listRef" class="app-hero-aside-user-list">
|
||||
<div
|
||||
v-for="(user, index) in users"
|
||||
:key="user.name"
|
||||
class="app-hero-aside-user-list-card"
|
||||
:style="{ animationDelay: `${index * 0.12}s` }"
|
||||
>
|
||||
<div class="app-hero-aside-user-list-avatar">
|
||||
<UiAvatar
|
||||
class="app-hero-aside-user-list-layer"
|
||||
:class="{
|
||||
'app-hero-aside-user-list-layer-active': activeLayer === 'a',
|
||||
}"
|
||||
:style-name="styleA"
|
||||
:style-options="{ seed: user.name, size: 80 }"
|
||||
:alt="user.name"
|
||||
/>
|
||||
<UiAvatar
|
||||
class="app-hero-aside-user-list-layer"
|
||||
:class="{
|
||||
'app-hero-aside-user-list-layer-active': activeLayer === 'b',
|
||||
}"
|
||||
:style-name="styleB"
|
||||
:style-options="{ seed: user.name, size: 80 }"
|
||||
:alt="user.name"
|
||||
/>
|
||||
</div>
|
||||
<div class="app-hero-aside-user-list-info">
|
||||
<span class="app-hero-aside-user-list-name">{{ user.name }}</span>
|
||||
<span class="app-hero-aside-user-list-position">{{
|
||||
user.position
|
||||
}}</span>
|
||||
</div>
|
||||
<span
|
||||
class="app-hero-aside-user-list-status"
|
||||
:class="
|
||||
user.online
|
||||
? 'app-hero-aside-user-list-status-online'
|
||||
: 'app-hero-aside-user-list-status-offline'
|
||||
"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.app-hero-aside-user-list {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
max-width: 340px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
transform: perspective(800px) rotateY(-8deg) rotateX(4deg);
|
||||
transform-style: preserve-3d;
|
||||
|
||||
&-card {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 16px;
|
||||
padding: 12px 16px;
|
||||
border-radius: var(--vp-radius-md);
|
||||
background: linear-gradient(
|
||||
135deg,
|
||||
rgba(255, 255, 255, 0.85) 0%,
|
||||
rgba(255, 255, 255, 0.7) 100%
|
||||
);
|
||||
border: 1px solid rgba(255, 255, 255, 0.6);
|
||||
box-shadow:
|
||||
0 4px 16px rgba(0, 0, 0, 0.06),
|
||||
0 1px 3px rgba(0, 0, 0, 0.04),
|
||||
inset 0 1px 0 rgba(255, 255, 255, 0.5);
|
||||
opacity: 0;
|
||||
|
||||
.visible & {
|
||||
animation: reveal-up 0.5s var(--ease-smooth) forwards;
|
||||
}
|
||||
}
|
||||
|
||||
&-avatar {
|
||||
position: relative;
|
||||
width: 48px;
|
||||
height: 48px;
|
||||
border-radius: var(--vp-radius-sm);
|
||||
overflow: hidden;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
&-layer {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
width: 48px !important;
|
||||
height: 48px !important;
|
||||
border-radius: var(--vp-radius-sm);
|
||||
opacity: 0;
|
||||
transition: opacity var(--duration-slow) ease;
|
||||
|
||||
&-active {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
&-info {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1px;
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
&-name {
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
color: var(--vp-c-text-1);
|
||||
line-height: 1.4;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
&-position {
|
||||
font-size: 12px;
|
||||
line-height: 1.4;
|
||||
color: var(--vp-c-text-2);
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
&-status {
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
border-radius: 50%;
|
||||
flex-shrink: 0;
|
||||
|
||||
&-online {
|
||||
background: #22c55e;
|
||||
box-shadow: 0 0 6px rgba(34, 197, 94, 0.4);
|
||||
}
|
||||
|
||||
&-offline {
|
||||
background: var(--vp-c-text-3);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.dark .app-hero-aside-user-list-card {
|
||||
background: linear-gradient(
|
||||
135deg,
|
||||
rgba(40, 40, 45, 0.92) 0%,
|
||||
rgba(35, 35, 40, 0.88) 100%
|
||||
);
|
||||
border-color: rgba(255, 255, 255, 0.1);
|
||||
box-shadow:
|
||||
0 4px 16px rgba(0, 0, 0, 0.25),
|
||||
0 1px 3px rgba(0, 0, 0, 0.15),
|
||||
inset 0 1px 0 rgba(255, 255, 255, 0.06);
|
||||
}
|
||||
@media (max-width: 768px) {
|
||||
.app-hero-aside-user-list {
|
||||
max-width: 320px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,47 @@
|
||||
<script setup lang="ts">
|
||||
import { PawPrint, ArrowRight } from '@lucide/vue';
|
||||
import Button from 'primevue/button';
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="app-hero-content">
|
||||
<h1 class="app-hero-title">
|
||||
<span class="app-hero-title-line">Pick a seed.</span>
|
||||
<span class="app-hero-title-line"
|
||||
>Get an <span class="app-hero-title-accent">avatar</span>.</span
|
||||
>
|
||||
</h1>
|
||||
|
||||
<p class="app-hero-description">
|
||||
DiceBear is a privacy-focused, open source avatar library with
|
||||
<span class="app-hero-underline">35+ avatar styles</span>
|
||||
crafted by talented artists. Generate deterministic profile pictures via
|
||||
API, JS library, PHP library, Python library,
|
||||
Rust library, Go library, Dart library & CLI.
|
||||
</p>
|
||||
|
||||
<div class="app-hero-actions">
|
||||
<Button
|
||||
as="a"
|
||||
href="/playground/"
|
||||
size="large"
|
||||
severity="contrast"
|
||||
class="app-hero-action-btn"
|
||||
>
|
||||
<PawPrint :size="20" />
|
||||
Try Playground
|
||||
</Button>
|
||||
<Button
|
||||
as="a"
|
||||
href="/introduction/"
|
||||
size="large"
|
||||
severity="secondary"
|
||||
variant="outlined"
|
||||
class="app-hero-action-btn"
|
||||
>
|
||||
Get Started
|
||||
<ArrowRight :size="20" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,256 @@
|
||||
<script setup lang="ts">
|
||||
import { withBase } from 'vitepress';
|
||||
import { PALETTE, type Pastel } from '@theme/utils/palette';
|
||||
|
||||
interface Tile {
|
||||
styleName: string;
|
||||
seed: string;
|
||||
background: Pastel;
|
||||
size: number;
|
||||
delay: number;
|
||||
}
|
||||
|
||||
const tiles: Tile[] = [
|
||||
{
|
||||
styleName: 'lorelei',
|
||||
seed: 'Felix',
|
||||
background: PALETTE.rose,
|
||||
size: 256,
|
||||
delay: 0,
|
||||
},
|
||||
{
|
||||
styleName: 'lorelei',
|
||||
seed: 'Aneka',
|
||||
background: PALETTE.amber,
|
||||
size: 160,
|
||||
delay: -1.2,
|
||||
},
|
||||
{
|
||||
styleName: 'bottts',
|
||||
seed: 'Pixel',
|
||||
background: PALETTE.cyan,
|
||||
size: 128,
|
||||
delay: -2.1,
|
||||
},
|
||||
{
|
||||
styleName: 'adventurer',
|
||||
seed: 'Milo',
|
||||
background: PALETTE.blue,
|
||||
size: 160,
|
||||
delay: -0.6,
|
||||
},
|
||||
{
|
||||
styleName: 'notionists',
|
||||
seed: 'Luna',
|
||||
background: PALETTE.green,
|
||||
size: 128,
|
||||
delay: -3.0,
|
||||
},
|
||||
{
|
||||
styleName: 'thumbs',
|
||||
seed: 'Sage',
|
||||
background: PALETTE.lime,
|
||||
size: 128,
|
||||
delay: -1.6,
|
||||
},
|
||||
];
|
||||
|
||||
// The swarm sits in the LCP area, so its avatars are pre-generated as
|
||||
// same-origin static files (scripts/generate-hero-avatars.mjs) instead of
|
||||
// being fetched from api.dicebear.com, since an external origin costs extra
|
||||
// DNS/TLS round trips before the largest above-the-fold images can paint.
|
||||
function url(tile: Tile): string {
|
||||
return withBase(
|
||||
`/avatars/hero/${tile.styleName}-${tile.seed.toLowerCase()}.svg`,
|
||||
);
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="app-hero-swarm">
|
||||
<div
|
||||
v-for="(tile, index) in tiles"
|
||||
:key="`${tile.styleName}-${tile.seed}`"
|
||||
:class="['app-hero-swarm-tile', `app-hero-swarm-tile--${index}`]"
|
||||
:style="{
|
||||
'--tile-bg': `#${tile.background}`,
|
||||
'--tile-delay': `${tile.delay}s`,
|
||||
}"
|
||||
>
|
||||
<img
|
||||
:src="url(tile)"
|
||||
alt=""
|
||||
:width="tile.size"
|
||||
:height="tile.size"
|
||||
:fetchpriority="index === 0 ? 'high' : 'auto'"
|
||||
decoding="async"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.app-hero-swarm {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
height: 460px;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.app-hero-swarm-tile {
|
||||
position: absolute;
|
||||
width: var(--tile-w);
|
||||
height: var(--tile-w);
|
||||
top: var(--tile-top, auto);
|
||||
bottom: var(--tile-bottom, auto);
|
||||
left: var(--tile-left, auto);
|
||||
right: var(--tile-right, auto);
|
||||
transform: var(--tile-translate, none) rotate(var(--tile-rotate, 0deg));
|
||||
border-radius: 28px;
|
||||
overflow: hidden;
|
||||
background: var(--tile-bg);
|
||||
box-shadow:
|
||||
var(--vp-shadow-3),
|
||||
0 0 0 1px var(--vp-c-divider);
|
||||
animation:
|
||||
app-hero-swarm-fade var(--duration-reveal) var(--ease-smooth)
|
||||
var(--tile-fade-delay, 0s) both,
|
||||
app-hero-swarm-bob 6s ease-in-out var(--tile-delay, 0s) infinite;
|
||||
will-change: translate;
|
||||
contain: layout paint;
|
||||
|
||||
img {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
display: block;
|
||||
object-fit: cover;
|
||||
}
|
||||
|
||||
&--0 {
|
||||
--tile-w: 220px;
|
||||
--tile-left: 50%;
|
||||
--tile-top: 50%;
|
||||
--tile-translate: translate(-50%, -50%);
|
||||
--tile-rotate: -3deg;
|
||||
--tile-fade-delay: 0.2s;
|
||||
z-index: 3;
|
||||
}
|
||||
|
||||
&--1 {
|
||||
--tile-w: 130px;
|
||||
--tile-left: 4%;
|
||||
--tile-top: 8%;
|
||||
--tile-rotate: -8deg;
|
||||
--tile-fade-delay: 0.35s;
|
||||
}
|
||||
|
||||
&--2 {
|
||||
--tile-w: 110px;
|
||||
--tile-right: 8%;
|
||||
--tile-top: 4%;
|
||||
--tile-rotate: 6deg;
|
||||
--tile-fade-delay: 0.45s;
|
||||
}
|
||||
|
||||
&--3 {
|
||||
--tile-w: 140px;
|
||||
--tile-right: 0;
|
||||
--tile-bottom: 8%;
|
||||
--tile-rotate: 4deg;
|
||||
--tile-fade-delay: 0.55s;
|
||||
}
|
||||
|
||||
&--4 {
|
||||
--tile-w: 100px;
|
||||
--tile-left: 2%;
|
||||
--tile-bottom: 14%;
|
||||
--tile-rotate: -5deg;
|
||||
--tile-fade-delay: 0.65s;
|
||||
}
|
||||
|
||||
&--5 {
|
||||
--tile-w: 92px;
|
||||
--tile-left: 24%;
|
||||
--tile-bottom: 0;
|
||||
--tile-rotate: 8deg;
|
||||
--tile-fade-delay: 0.75s;
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes app-hero-swarm-bob {
|
||||
0%,
|
||||
100% {
|
||||
translate: 0 0;
|
||||
}
|
||||
50% {
|
||||
translate: 0 -5px;
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes app-hero-swarm-fade {
|
||||
from {
|
||||
opacity: 0;
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 960px) {
|
||||
.app-hero-swarm {
|
||||
height: 360px;
|
||||
max-width: 460px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
.app-hero-swarm-tile {
|
||||
&--0 {
|
||||
--tile-w: 180px;
|
||||
}
|
||||
&--1 {
|
||||
--tile-w: 104px;
|
||||
}
|
||||
&--2 {
|
||||
--tile-w: 88px;
|
||||
}
|
||||
&--3 {
|
||||
--tile-w: 112px;
|
||||
}
|
||||
&--4 {
|
||||
--tile-w: 80px;
|
||||
}
|
||||
&--5 {
|
||||
--tile-w: 76px;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 540px) {
|
||||
.app-hero-swarm {
|
||||
height: 300px;
|
||||
}
|
||||
|
||||
.app-hero-swarm-tile {
|
||||
&--0 {
|
||||
--tile-w: 150px;
|
||||
}
|
||||
&--1 {
|
||||
--tile-w: 86px;
|
||||
}
|
||||
&--2 {
|
||||
--tile-w: 74px;
|
||||
}
|
||||
&--3 {
|
||||
--tile-w: 94px;
|
||||
}
|
||||
&--4 {
|
||||
--tile-w: 68px;
|
||||
}
|
||||
&--5 {
|
||||
--tile-w: 64px;
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,226 @@
|
||||
<script setup lang="ts">
|
||||
import { ref } from 'vue';
|
||||
import {
|
||||
UiContainer,
|
||||
UiSection,
|
||||
UiSectionHeader,
|
||||
UiIconBox,
|
||||
UiCard,
|
||||
UiIcon,
|
||||
} from '../ui';
|
||||
import {
|
||||
Target,
|
||||
Palette,
|
||||
Shapes,
|
||||
Globe,
|
||||
Library,
|
||||
Terminal,
|
||||
SlidersHorizontal,
|
||||
} from '@lucide/vue';
|
||||
import { siGithub, siFigma } from 'simple-icons';
|
||||
import { useVisibility } from '../../composables/useVisibility';
|
||||
|
||||
withDefaults(
|
||||
defineProps<{
|
||||
headline?: string;
|
||||
description?: string;
|
||||
}>(),
|
||||
{
|
||||
headline: 'Built for Developers, Loved by Users',
|
||||
description:
|
||||
'Everything you need to create beautiful, unique avatars for your applications.',
|
||||
},
|
||||
);
|
||||
|
||||
const sectionRef = ref();
|
||||
const isVisible = useVisibility(sectionRef, { threshold: 0.15 });
|
||||
|
||||
const highlights = [
|
||||
// Row 1: the avatars themselves
|
||||
{
|
||||
icon: Target,
|
||||
title: 'Deterministic Avatars',
|
||||
description:
|
||||
'Same seed always generates the same avatar. Perfect for user profiles and consistent identities.',
|
||||
color: '#1689cc',
|
||||
},
|
||||
{
|
||||
icon: Palette,
|
||||
title: '35+ Avatar Styles',
|
||||
description:
|
||||
'Carefully crafted styles from talented artists. Characters, abstract, pixel art, and more.',
|
||||
color: '#a855f7',
|
||||
},
|
||||
{
|
||||
icon: Shapes,
|
||||
title: 'Scalable SVG',
|
||||
description:
|
||||
'Pure SVG output stays razor-sharp at any size, from tiny favicons to full-screen, and weighs just a few kilobytes.',
|
||||
color: '#06b6d4',
|
||||
},
|
||||
// Row 2: how you generate them
|
||||
{
|
||||
icon: Globe,
|
||||
title: 'Free Avatar API',
|
||||
description:
|
||||
'Our profile picture API handles millions of daily requests. Global CDN delivers random user avatars in milliseconds.',
|
||||
color: '#22c55e',
|
||||
},
|
||||
{
|
||||
// One box for the language libraries (JS / PHP / Python / Rust / Go / Dart).
|
||||
// Generic Library icon with no language logos, so the named languages stay
|
||||
// pure nominative use with no trademark/logo-modification questions.
|
||||
icon: Library,
|
||||
title: 'Official Libraries',
|
||||
description:
|
||||
'JavaScript, PHP, Python, Rust, Go, and Dart share one identical API across every language. The same seed gives the same result, and no data leaves your servers.',
|
||||
color: '#f59e0b',
|
||||
},
|
||||
{
|
||||
icon: Terminal,
|
||||
title: 'CLI',
|
||||
description:
|
||||
'Generate avatars directly from the command line. Perfect for batch processing and build pipelines.',
|
||||
color: '#64748b',
|
||||
},
|
||||
// Row 3: design & trust
|
||||
{
|
||||
iconPath: siFigma.path,
|
||||
title: 'Figma Plugin',
|
||||
description:
|
||||
'Design custom avatar styles in Figma and export them as ready-to-use DiceBear definitions, with no code required.',
|
||||
color: 'var(--logo-monochrome)',
|
||||
},
|
||||
{
|
||||
icon: SlidersHorizontal,
|
||||
title: 'Fully Customizable',
|
||||
description:
|
||||
'Colors, accessories, backgrounds, and more. Fine-tune every detail to match your brand.',
|
||||
color: '#ec4899',
|
||||
},
|
||||
{
|
||||
iconPath: siGithub.path,
|
||||
title: '100% Open Source',
|
||||
description:
|
||||
'MIT licensed core, transparent development. Contribute, fork, or self-host with confidence.',
|
||||
color: 'var(--logo-monochrome)',
|
||||
},
|
||||
];
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<UiSection ref="sectionRef" :class="{ visible: isVisible }" divider>
|
||||
<template #background>
|
||||
<div class="app-highlights-gradient"></div>
|
||||
</template>
|
||||
<UiContainer>
|
||||
<UiSectionHeader class="app-highlights-header" :description="description">
|
||||
<template #headline>
|
||||
<slot name="headline">{{ headline }}</slot>
|
||||
</template>
|
||||
</UiSectionHeader>
|
||||
|
||||
<div class="app-highlights-grid">
|
||||
<UiCard
|
||||
v-for="(highlight, index) in highlights"
|
||||
:key="index"
|
||||
padding="xl"
|
||||
class="app-highlights-card"
|
||||
:style="{
|
||||
'--accent-color': highlight.color,
|
||||
animationDelay: `${index * 0.1}s`,
|
||||
}"
|
||||
>
|
||||
<UiIconBox
|
||||
size="lg"
|
||||
:color="highlight.color"
|
||||
class="app-highlights-icon-wrapper"
|
||||
>
|
||||
<UiIcon v-if="highlight.iconPath" :path="highlight.iconPath" />
|
||||
<component v-else :is="highlight.icon" />
|
||||
</UiIconBox>
|
||||
<h3 class="app-highlights-title">{{ highlight.title }}</h3>
|
||||
<p class="app-highlights-description">{{ highlight.description }}</p>
|
||||
</UiCard>
|
||||
</div>
|
||||
</UiContainer>
|
||||
</UiSection>
|
||||
</template>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.app-highlights {
|
||||
&-gradient {
|
||||
background:
|
||||
radial-gradient(
|
||||
ellipse 50% 50% at 50% 0%,
|
||||
color-mix(in srgb, var(--vp-c-brand-1) 6%, transparent),
|
||||
transparent
|
||||
),
|
||||
radial-gradient(
|
||||
ellipse 50% 50% at 50% 100%,
|
||||
color-mix(in srgb, var(--vp-c-brand-1) 6%, transparent),
|
||||
transparent
|
||||
);
|
||||
}
|
||||
|
||||
&-header {
|
||||
opacity: 0;
|
||||
transform: translateY(30px);
|
||||
transition: all var(--duration-reveal) var(--ease-smooth);
|
||||
|
||||
.visible & {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
}
|
||||
}
|
||||
|
||||
&-card {
|
||||
opacity: 0;
|
||||
transform: translateY(30px);
|
||||
|
||||
.visible & {
|
||||
animation: reveal-up var(--duration-mid) var(--ease-spring) forwards;
|
||||
}
|
||||
}
|
||||
|
||||
&-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, 1fr);
|
||||
gap: 24px;
|
||||
}
|
||||
|
||||
&-icon-wrapper {
|
||||
margin-bottom: 24px;
|
||||
}
|
||||
|
||||
&-title {
|
||||
font-size: 18px;
|
||||
font-weight: 700;
|
||||
color: var(--vp-c-text-1);
|
||||
margin: 0 0 8px;
|
||||
}
|
||||
|
||||
&-description {
|
||||
font-size: 14px;
|
||||
color: var(--vp-c-text-2);
|
||||
margin: 0;
|
||||
line-height: 1.6;
|
||||
}
|
||||
}
|
||||
@media (max-width: 1000px) {
|
||||
.app-highlights {
|
||||
&-grid {
|
||||
grid-template-columns: repeat(2, 1fr);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 640px) {
|
||||
.app-highlights {
|
||||
&-grid {
|
||||
grid-template-columns: 1fr;
|
||||
gap: 16px;
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,424 @@
|
||||
<script setup lang="ts">
|
||||
import { ref } from 'vue';
|
||||
import { Globe, Library, Terminal, ArrowRight } from '@lucide/vue';
|
||||
import Button from 'primevue/button';
|
||||
import Tabs from 'primevue/tabs';
|
||||
import TabList from 'primevue/tablist';
|
||||
import Tab from 'primevue/tab';
|
||||
import TabPanels from 'primevue/tabpanels';
|
||||
import TabPanel from 'primevue/tabpanel';
|
||||
import {
|
||||
UiContainer,
|
||||
UiSection,
|
||||
UiSectionHeader,
|
||||
UiCard,
|
||||
UiIconBox,
|
||||
UiCode,
|
||||
} from '../ui';
|
||||
import { useVisibility } from '../../composables/useVisibility';
|
||||
|
||||
const sectionRef = ref();
|
||||
const isVisible = useVisibility(sectionRef, { threshold: 0.15 });
|
||||
|
||||
const libraryTab = ref('js');
|
||||
|
||||
const plainCode = {
|
||||
js: `import { Style, Avatar } from '@dicebear/core';
|
||||
import lorelei from '@dicebear/styles/lorelei.json' with { type: 'json' };
|
||||
|
||||
const style = new Style(lorelei);
|
||||
const svg = new Avatar(style, {
|
||||
seed: 'Mia',
|
||||
}).toString();`,
|
||||
php: `<?php
|
||||
use Composer\\InstalledVersions;
|
||||
use DiceBear\\Style;
|
||||
use DiceBear\\Avatar;
|
||||
|
||||
$basePath = InstalledVersions::getInstallPath('dicebear/styles');
|
||||
$style = Style::fromJson(
|
||||
file_get_contents($basePath . '/src/lorelei.json')
|
||||
);
|
||||
|
||||
$svg = (string) new Avatar($style, [
|
||||
'seed' => 'Mia',
|
||||
]);`,
|
||||
python: `from importlib.resources import files
|
||||
|
||||
from dicebear import Avatar, Style
|
||||
|
||||
style = Style.from_json(
|
||||
files("dicebear_styles").joinpath("lorelei.json").read_text("utf-8")
|
||||
)
|
||||
|
||||
svg = Avatar(style, {"seed": "Mia"}).to_string()`,
|
||||
rust: `use dicebear_core::{Avatar, Style};
|
||||
use serde_json::json;
|
||||
|
||||
let style = Style::from_str(dicebear_styles::LORELEI)?;
|
||||
let svg = Avatar::new(&style, json!({ "seed": "Mia" }))?.to_svg();`,
|
||||
go: `import (
|
||||
dicebear "github.com/dicebear/dicebear-go/v10"
|
||||
"github.com/dicebear/styles/v10"
|
||||
)
|
||||
|
||||
style, _ := dicebear.NewStyle([]byte(styles.Lorelei))
|
||||
avatar, _ := dicebear.NewAvatar(style, map[string]any{"seed": "Mia"})
|
||||
svg := avatar.SVG()`,
|
||||
dart: `import 'package:dicebear_core/dicebear_core.dart';
|
||||
import 'package:dicebear_styles/lorelei.dart';
|
||||
|
||||
final style = Style.parse(lorelei);
|
||||
final svg = Avatar(style, {'seed': 'Mia'}).svg;`,
|
||||
api: `https://api.dicebear.com/10.x/lorelei/svg?seed=Mia`,
|
||||
cli: `npx dicebear lorelei --seed "Mia" --format svg`,
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<UiSection ref="sectionRef" :class="{ visible: isVisible }" divider>
|
||||
<template #background>
|
||||
<div class="app-integration-dots"></div>
|
||||
<div class="app-integration-gradient"></div>
|
||||
</template>
|
||||
<UiContainer>
|
||||
<UiSectionHeader
|
||||
class="app-integration-header"
|
||||
description="Choose the integration that works best for your project."
|
||||
>
|
||||
<template #headline>Integrate in <strong>Minutes</strong></template>
|
||||
</UiSectionHeader>
|
||||
|
||||
<!-- Language libraries - combined into one tabbed card -->
|
||||
<div class="app-integration-libraries">
|
||||
<div class="app-integration-item" :style="{ animationDelay: '0s' }">
|
||||
<UiCard padding="xl" class="app-integration-card">
|
||||
<div class="app-integration-card-header">
|
||||
<UiIconBox size="lg" color="var(--vp-c-brand-1)">
|
||||
<Library />
|
||||
</UiIconBox>
|
||||
<h3 class="app-integration-title">Libraries</h3>
|
||||
<p class="app-integration-description">
|
||||
Run DiceBear entirely in your own code, so no data leaves your
|
||||
servers. JavaScript, PHP, Python, Rust, Go, and Dart share one
|
||||
identical API.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<Tabs v-model:value="libraryTab" class="app-integration-tabs">
|
||||
<TabList>
|
||||
<Tab value="js">JavaScript</Tab>
|
||||
<Tab value="php">PHP</Tab>
|
||||
<Tab value="python">Python</Tab>
|
||||
<Tab value="rust">Rust</Tab>
|
||||
<Tab value="go">Go</Tab>
|
||||
<Tab value="dart">Dart</Tab>
|
||||
</TabList>
|
||||
<TabPanels>
|
||||
<TabPanel value="js" class="app-integration-tabpanel">
|
||||
<UiCode
|
||||
:code="plainCode.js"
|
||||
lang="js"
|
||||
scroll-to-bottom
|
||||
class="app-integration-code-block"
|
||||
/>
|
||||
<Button
|
||||
as="a"
|
||||
href="/how-to-use/js-library/"
|
||||
severity="secondary"
|
||||
variant="outlined"
|
||||
class="app-integration-link"
|
||||
>
|
||||
JS Documentation
|
||||
<ArrowRight :size="18" />
|
||||
</Button>
|
||||
</TabPanel>
|
||||
<TabPanel value="php" class="app-integration-tabpanel">
|
||||
<UiCode
|
||||
:code="plainCode.php"
|
||||
lang="php"
|
||||
scroll-to-bottom
|
||||
class="app-integration-code-block"
|
||||
/>
|
||||
<Button
|
||||
as="a"
|
||||
href="/how-to-use/php-library/"
|
||||
severity="secondary"
|
||||
variant="outlined"
|
||||
class="app-integration-link"
|
||||
>
|
||||
PHP Documentation
|
||||
<ArrowRight :size="18" />
|
||||
</Button>
|
||||
</TabPanel>
|
||||
<TabPanel value="python" class="app-integration-tabpanel">
|
||||
<UiCode
|
||||
:code="plainCode.python"
|
||||
lang="python"
|
||||
scroll-to-bottom
|
||||
class="app-integration-code-block"
|
||||
/>
|
||||
<Button
|
||||
as="a"
|
||||
href="/how-to-use/python-library/"
|
||||
severity="secondary"
|
||||
variant="outlined"
|
||||
class="app-integration-link"
|
||||
>
|
||||
Python Documentation
|
||||
<ArrowRight :size="18" />
|
||||
</Button>
|
||||
</TabPanel>
|
||||
<TabPanel value="rust" class="app-integration-tabpanel">
|
||||
<UiCode
|
||||
:code="plainCode.rust"
|
||||
lang="rust"
|
||||
scroll-to-bottom
|
||||
class="app-integration-code-block"
|
||||
/>
|
||||
<Button
|
||||
as="a"
|
||||
href="/how-to-use/rust-library/"
|
||||
severity="secondary"
|
||||
variant="outlined"
|
||||
class="app-integration-link"
|
||||
>
|
||||
Rust Documentation
|
||||
<ArrowRight :size="18" />
|
||||
</Button>
|
||||
</TabPanel>
|
||||
<TabPanel value="go" class="app-integration-tabpanel">
|
||||
<UiCode
|
||||
:code="plainCode.go"
|
||||
lang="go"
|
||||
scroll-to-bottom
|
||||
class="app-integration-code-block"
|
||||
/>
|
||||
<Button
|
||||
as="a"
|
||||
href="/how-to-use/go-library/"
|
||||
severity="secondary"
|
||||
variant="outlined"
|
||||
class="app-integration-link"
|
||||
>
|
||||
Go Documentation
|
||||
<ArrowRight :size="18" />
|
||||
</Button>
|
||||
</TabPanel>
|
||||
<TabPanel value="dart" class="app-integration-tabpanel">
|
||||
<UiCode
|
||||
:code="plainCode.dart"
|
||||
lang="dart"
|
||||
scroll-to-bottom
|
||||
class="app-integration-code-block"
|
||||
/>
|
||||
<Button
|
||||
as="a"
|
||||
href="/how-to-use/dart-library/"
|
||||
severity="secondary"
|
||||
variant="outlined"
|
||||
class="app-integration-link"
|
||||
>
|
||||
Dart Documentation
|
||||
<ArrowRight :size="18" />
|
||||
</Button>
|
||||
</TabPanel>
|
||||
</TabPanels>
|
||||
</Tabs>
|
||||
</UiCard>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- HTTP API & CLI - Side by Side -->
|
||||
<div class="app-integration-grid">
|
||||
<div class="app-integration-item" :style="{ animationDelay: '0.3s' }">
|
||||
<UiCard padding="xl" class="app-integration-card">
|
||||
<div class="app-integration-card-header">
|
||||
<UiIconBox size="lg" color="var(--vp-c-pink-2)">
|
||||
<Globe />
|
||||
</UiIconBox>
|
||||
<h3 class="app-integration-title">Avatar API</h3>
|
||||
<p class="app-integration-description">
|
||||
Free avatar API for profile pictures. Handles millions of
|
||||
requests daily via global CDN.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<UiCode :code="plainCode.api" class="app-integration-code-block" />
|
||||
|
||||
<Button
|
||||
as="a"
|
||||
href="/how-to-use/http-api/"
|
||||
severity="secondary"
|
||||
variant="outlined"
|
||||
class="app-integration-link"
|
||||
>
|
||||
API Documentation
|
||||
<ArrowRight :size="18" />
|
||||
</Button>
|
||||
</UiCard>
|
||||
</div>
|
||||
|
||||
<div class="app-integration-item" :style="{ animationDelay: '0.45s' }">
|
||||
<UiCard padding="xl" class="app-integration-card">
|
||||
<div class="app-integration-card-header">
|
||||
<UiIconBox size="lg" color="var(--vp-c-brand-1)">
|
||||
<Terminal />
|
||||
</UiIconBox>
|
||||
<h3 class="app-integration-title">CLI</h3>
|
||||
<p class="app-integration-description">
|
||||
Generate avatars from the command line. Perfect for scripts and
|
||||
automation.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<UiCode :code="plainCode.cli" class="app-integration-code-block" />
|
||||
|
||||
<Button
|
||||
as="a"
|
||||
href="/how-to-use/cli/"
|
||||
severity="secondary"
|
||||
variant="outlined"
|
||||
class="app-integration-link"
|
||||
>
|
||||
CLI Documentation
|
||||
<ArrowRight :size="18" />
|
||||
</Button>
|
||||
</UiCard>
|
||||
</div>
|
||||
</div>
|
||||
</UiContainer>
|
||||
</UiSection>
|
||||
</template>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.app-integration {
|
||||
&-dots {
|
||||
background-image: radial-gradient(
|
||||
circle,
|
||||
var(--vp-c-text-3) 1px,
|
||||
transparent 1px
|
||||
);
|
||||
background-size: 32px 32px;
|
||||
background-repeat: repeat !important;
|
||||
opacity: 0.2;
|
||||
mask-image: radial-gradient(ellipse 70% 50% at 50% 50%, black, transparent);
|
||||
-webkit-mask-image: radial-gradient(
|
||||
ellipse 70% 50% at 50% 50%,
|
||||
black,
|
||||
transparent
|
||||
);
|
||||
}
|
||||
|
||||
&-gradient {
|
||||
background: radial-gradient(
|
||||
ellipse 80% 60% at 50% 0%,
|
||||
color-mix(in srgb, var(--vp-c-brand-1) 8%, transparent),
|
||||
transparent
|
||||
);
|
||||
}
|
||||
|
||||
&-header {
|
||||
opacity: 0;
|
||||
transform: translateY(30px);
|
||||
transition: all var(--duration-reveal) var(--ease-smooth);
|
||||
|
||||
.visible & {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
}
|
||||
}
|
||||
|
||||
&-item {
|
||||
opacity: 0;
|
||||
transform: translateY(30px);
|
||||
|
||||
.visible & {
|
||||
animation: reveal-up 0.6s var(--ease-smooth) forwards;
|
||||
}
|
||||
}
|
||||
|
||||
/* Combined libraries card (language libraries via tabs) */
|
||||
&-libraries {
|
||||
margin-bottom: 24px;
|
||||
}
|
||||
|
||||
&-tabs {
|
||||
// The card already supplies xl padding; drop the panel's own padding so
|
||||
// the code block and doc link sit flush with the card edges, with a little
|
||||
// breathing room under the tab bar.
|
||||
--p-tabs-tabpanel-padding: 16px 0 0;
|
||||
}
|
||||
|
||||
&-tabpanel {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
/* Grid for HTTP API & CLI */
|
||||
&-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, 1fr);
|
||||
gap: 24px;
|
||||
|
||||
> * {
|
||||
min-width: 0;
|
||||
}
|
||||
}
|
||||
|
||||
&-card {
|
||||
height: 100%;
|
||||
|
||||
:deep(.ui-card-body) {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
&-header {
|
||||
margin-bottom: 24px;
|
||||
}
|
||||
}
|
||||
|
||||
&-title {
|
||||
font-size: 20px;
|
||||
font-weight: 700;
|
||||
color: var(--vp-c-text-1);
|
||||
margin: 16px 0 8px;
|
||||
}
|
||||
|
||||
&-description {
|
||||
font-size: 15px;
|
||||
color: var(--vp-c-text-2);
|
||||
margin: 0;
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
&-code-block {
|
||||
flex: 1;
|
||||
margin-bottom: 20px;
|
||||
min-height: 48px;
|
||||
}
|
||||
|
||||
&-link {
|
||||
align-self: flex-start;
|
||||
}
|
||||
}
|
||||
@media (max-width: 1000px) {
|
||||
.app-integration {
|
||||
&-grid {
|
||||
grid-template-columns: 1fr;
|
||||
max-width: 500px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 640px) {
|
||||
.app-integration {
|
||||
&-card {
|
||||
--ui-card-padding: 24px;
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,302 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, computed } from 'vue';
|
||||
import { Star, Heart } from '@lucide/vue';
|
||||
import { siGithub } from 'simple-icons';
|
||||
import { kebabCase } from 'change-case';
|
||||
import Prando from 'prando';
|
||||
import Button from 'primevue/button';
|
||||
import {
|
||||
UiAvatar,
|
||||
UiHeadline,
|
||||
UiDescription,
|
||||
UiContainer,
|
||||
UiSection,
|
||||
UiIcon,
|
||||
} from '../ui';
|
||||
import { useVisibility } from '../../composables/useVisibility';
|
||||
import { useAvatarStyleList } from '../../composables/avatar';
|
||||
|
||||
const sectionRef = ref();
|
||||
const isVisible = useVisibility(sectionRef);
|
||||
|
||||
const avatarStyleList = useAvatarStyleList();
|
||||
|
||||
// Generate scattered avatars around the star CTA
|
||||
const scatteredAvatars = computed(() => {
|
||||
const prng = new Prando(555);
|
||||
const avatars = [];
|
||||
const seeds = [
|
||||
'OpenSource',
|
||||
'Community',
|
||||
'Stars',
|
||||
'Love',
|
||||
'Code',
|
||||
'Free',
|
||||
'MIT',
|
||||
'GitHub',
|
||||
];
|
||||
|
||||
for (let i = 0; i < 8; i++) {
|
||||
const style = kebabCase(
|
||||
avatarStyleList.value[i % avatarStyleList.value.length],
|
||||
);
|
||||
const angle = (i / 8) * Math.PI * 2;
|
||||
const radius = 38 + prng.next() * 10;
|
||||
avatars.push({
|
||||
style,
|
||||
seed: seeds[i],
|
||||
x: 50 + Math.cos(angle) * radius,
|
||||
y: 50 + Math.sin(angle) * radius,
|
||||
size: 40 + prng.next() * 24,
|
||||
delay: i * 0.15,
|
||||
rotation: prng.next() * 20 - 10,
|
||||
});
|
||||
}
|
||||
return avatars;
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<UiSection ref="sectionRef" :class="{ visible: isVisible }" divider center>
|
||||
<template #background>
|
||||
<div class="app-open-source-gradient"></div>
|
||||
<!-- Scattered avatar decorations -->
|
||||
<div
|
||||
v-for="(avatar, index) in scatteredAvatars"
|
||||
:key="index"
|
||||
class="app-open-source-bg-avatar"
|
||||
:style="{
|
||||
left: `${avatar.x}%`,
|
||||
top: `${avatar.y}%`,
|
||||
width: `${avatar.size}px`,
|
||||
height: `${avatar.size}px`,
|
||||
'--rotation': `${avatar.rotation}deg`,
|
||||
animationDelay: `${avatar.delay}s`,
|
||||
}"
|
||||
>
|
||||
<UiAvatar
|
||||
:style-name="avatar.style"
|
||||
:style-options="{ seed: avatar.seed, size: 80 }"
|
||||
:alt="avatar.style"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
<UiContainer class="app-open-source-container">
|
||||
<!-- Animated star icon -->
|
||||
<div class="app-open-source-star-wrapper">
|
||||
<div class="app-open-source-star-ring"></div>
|
||||
<div
|
||||
class="app-open-source-star-ring app-open-source-star-ring-2"
|
||||
></div>
|
||||
<div class="app-open-source-star-icon">
|
||||
<Star :size="36" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<UiHeadline
|
||||
>Free and <strong>Open Source</strong>.<br />Forever.</UiHeadline
|
||||
>
|
||||
|
||||
<UiDescription class="app-open-source-description">
|
||||
DiceBear is built in the open. Our core library is MIT licensed, and we
|
||||
believe in transparent development. Join thousands of developers who
|
||||
already love DiceBear.
|
||||
</UiDescription>
|
||||
|
||||
<div class="app-open-source-actions">
|
||||
<Button
|
||||
as="a"
|
||||
href="https://github.com/dicebear/dicebear"
|
||||
target="_blank"
|
||||
rel="noopener"
|
||||
size="large"
|
||||
severity="contrast"
|
||||
class="app-open-source-star-btn"
|
||||
>
|
||||
<UiIcon :path="siGithub.path" :size="20" />
|
||||
Star on GitHub
|
||||
</Button>
|
||||
<Button
|
||||
as="a"
|
||||
href="/guides/contribute-to-the-library/"
|
||||
size="large"
|
||||
severity="secondary"
|
||||
variant="outlined"
|
||||
class="app-open-source-contribute"
|
||||
>
|
||||
<Heart :size="20" />
|
||||
Contribute
|
||||
</Button>
|
||||
</div>
|
||||
</UiContainer>
|
||||
</UiSection>
|
||||
</template>
|
||||
|
||||
<style lang="scss">
|
||||
:root {
|
||||
--app-open-source-star-color: #f59e0b;
|
||||
--app-open-source-ring-color: rgba(245, 158, 11, 0.15);
|
||||
}
|
||||
.dark {
|
||||
--app-open-source-star-color: #fbbf24;
|
||||
--app-open-source-ring-color: rgba(251, 191, 36, 0.12);
|
||||
}
|
||||
</style>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.app-open-source {
|
||||
&-gradient {
|
||||
background:
|
||||
radial-gradient(
|
||||
ellipse 60% 60% at 50% 50%,
|
||||
color-mix(in srgb, var(--app-open-source-star-color) 6%, transparent),
|
||||
transparent
|
||||
),
|
||||
radial-gradient(
|
||||
ellipse 50% 80% at 50% 0%,
|
||||
color-mix(in srgb, var(--vp-c-brand-1) 4%, transparent),
|
||||
transparent
|
||||
);
|
||||
}
|
||||
|
||||
&-bg-avatar {
|
||||
position: absolute;
|
||||
border-radius: var(--vp-radius-sm);
|
||||
overflow: hidden;
|
||||
opacity: 0;
|
||||
pointer-events: none;
|
||||
|
||||
.visible & {
|
||||
animation: app-open-source-avatar-float 0.6s var(--ease-spring) forwards;
|
||||
}
|
||||
|
||||
img {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
}
|
||||
}
|
||||
|
||||
&-container {
|
||||
opacity: 0;
|
||||
transform: translateY(40px);
|
||||
transition: all var(--duration-reveal) var(--ease-smooth);
|
||||
|
||||
.visible & {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
}
|
||||
}
|
||||
|
||||
/* Star wrapper with animated rings */
|
||||
&-star-wrapper {
|
||||
position: relative;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 80px;
|
||||
height: 80px;
|
||||
margin-bottom: 32px;
|
||||
}
|
||||
|
||||
&-star-ring {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
border: 2px solid var(--app-open-source-ring-color);
|
||||
border-radius: 50%;
|
||||
animation: app-open-source-ring-pulse 3s ease-in-out infinite;
|
||||
|
||||
&-2 {
|
||||
inset: -12px;
|
||||
border-width: 1px;
|
||||
animation-delay: -1.5s;
|
||||
}
|
||||
}
|
||||
|
||||
&-star-icon {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 64px;
|
||||
height: 64px;
|
||||
border-radius: 50%;
|
||||
background: linear-gradient(
|
||||
135deg,
|
||||
var(--app-open-source-star-color),
|
||||
#f97316
|
||||
);
|
||||
color: white;
|
||||
box-shadow: 0 8px 24px
|
||||
color-mix(in srgb, var(--app-open-source-star-color) 40%, transparent);
|
||||
z-index: 1;
|
||||
animation: app-open-source-star-bounce 2s ease-in-out infinite;
|
||||
|
||||
svg {
|
||||
fill: currentColor;
|
||||
}
|
||||
}
|
||||
|
||||
&-description {
|
||||
margin-bottom: 40px;
|
||||
max-width: 600px;
|
||||
}
|
||||
|
||||
&-actions {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
&-heart-icon {
|
||||
color: #f43f5e;
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes app-open-source-ring-pulse {
|
||||
0%,
|
||||
100% {
|
||||
transform: scale(1);
|
||||
opacity: 1;
|
||||
}
|
||||
50% {
|
||||
transform: scale(1.15);
|
||||
opacity: 0.4;
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes app-open-source-star-bounce {
|
||||
0%,
|
||||
100% {
|
||||
transform: translateY(0) rotate(0deg);
|
||||
}
|
||||
25% {
|
||||
transform: translateY(-4px) rotate(-3deg);
|
||||
}
|
||||
75% {
|
||||
transform: translateY(2px) rotate(2deg);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes app-open-source-avatar-float {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: scale(0.5) rotate(var(--rotation, 0deg));
|
||||
}
|
||||
to {
|
||||
opacity: 0.08;
|
||||
transform: scale(1) rotate(var(--rotation, 0deg));
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.app-open-source {
|
||||
&-bg-avatar {
|
||||
display: none;
|
||||
}
|
||||
|
||||
&-actions {
|
||||
flex-direction: column;
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,163 @@
|
||||
<script setup lang="ts">
|
||||
import { ref } from 'vue';
|
||||
import { Star, Heart, Scale, ArrowRight } from '@lucide/vue';
|
||||
import { siGithub } from 'simple-icons';
|
||||
import Button from 'primevue/button';
|
||||
import { UiContainer, UiSection, UiCard, UiIconBox, UiIcon } from '../ui';
|
||||
import { useVisibility } from '../../composables/useVisibility';
|
||||
|
||||
const sectionRef = ref();
|
||||
const isVisible = useVisibility(sectionRef, { threshold: 0.15 });
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<UiSection ref="sectionRef" :class="{ visible: isVisible }" divider>
|
||||
<template #background>
|
||||
<div class="app-open-source-cards-gradient"></div>
|
||||
</template>
|
||||
<UiContainer>
|
||||
<div class="app-open-source-cards-grid">
|
||||
<UiCard padding="2xl" class="app-open-source-cards-opensource-card">
|
||||
<UiIconBox size="lg" color="var(--vp-c-brand-1)">
|
||||
<Star />
|
||||
</UiIconBox>
|
||||
<h3 class="app-open-source-cards-title">Open Source</h3>
|
||||
<p class="app-open-source-cards-text">
|
||||
We believe in open source. All our code is available on GitHub. Feel
|
||||
free to contribute, fork, or simply use it with confidence.
|
||||
</p>
|
||||
<div class="app-open-source-cards-actions">
|
||||
<Button
|
||||
as="a"
|
||||
href="https://github.com/dicebear/dicebear"
|
||||
target="_blank"
|
||||
rel="noopener"
|
||||
severity="contrast"
|
||||
class="app-open-source-cards-action-btn"
|
||||
>
|
||||
<UiIcon :path="siGithub.path" :size="20" />
|
||||
Star on GitHub
|
||||
</Button>
|
||||
<Button
|
||||
as="a"
|
||||
href="/guides/contribute-to-the-library/"
|
||||
severity="secondary"
|
||||
variant="outlined"
|
||||
class="app-open-source-cards-action-btn"
|
||||
>
|
||||
<Heart />
|
||||
Contribute
|
||||
</Button>
|
||||
</div>
|
||||
</UiCard>
|
||||
|
||||
<UiCard padding="2xl" class="app-open-source-cards-license-card">
|
||||
<UiIconBox size="lg" color="var(--vp-c-pink-2)">
|
||||
<Scale />
|
||||
</UiIconBox>
|
||||
<h3 class="app-open-source-cards-title">License</h3>
|
||||
<p class="app-open-source-cards-text">
|
||||
Our code is MIT licensed. The avatar styles are licensed under
|
||||
different licenses chosen by the artists. Check out the overview for
|
||||
details.
|
||||
</p>
|
||||
<div class="app-open-source-cards-actions">
|
||||
<Button
|
||||
as="a"
|
||||
href="/licenses/"
|
||||
severity="secondary"
|
||||
variant="outlined"
|
||||
class="app-open-source-cards-action-btn app-open-source-cards-license-btn"
|
||||
>
|
||||
License Overview
|
||||
<ArrowRight :size="20" />
|
||||
</Button>
|
||||
</div>
|
||||
</UiCard>
|
||||
</div>
|
||||
</UiContainer>
|
||||
</UiSection>
|
||||
</template>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.app-open-source-cards {
|
||||
&-gradient {
|
||||
background:
|
||||
radial-gradient(
|
||||
ellipse 50% 80% at 50% 0%,
|
||||
color-mix(in srgb, var(--vp-c-yellow-1) 5%, transparent),
|
||||
transparent
|
||||
),
|
||||
radial-gradient(
|
||||
ellipse 50% 80% at 50% 100%,
|
||||
color-mix(in srgb, var(--vp-c-brand-1) 6%, transparent),
|
||||
transparent
|
||||
);
|
||||
}
|
||||
|
||||
&-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, 1fr);
|
||||
gap: 24px;
|
||||
opacity: 0;
|
||||
transform: translateY(30px);
|
||||
transition: all var(--duration-reveal) var(--ease-smooth);
|
||||
|
||||
.visible & {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
}
|
||||
}
|
||||
|
||||
&-opensource-card,
|
||||
&-license-card {
|
||||
:deep(.ui-card-body) {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 100%;
|
||||
}
|
||||
}
|
||||
|
||||
&-title {
|
||||
font-size: 24px;
|
||||
font-weight: 700;
|
||||
color: var(--vp-c-text-1);
|
||||
margin: 20px 0 12px;
|
||||
}
|
||||
|
||||
&-text {
|
||||
font-size: 15px;
|
||||
color: var(--vp-c-text-2);
|
||||
line-height: 1.7;
|
||||
margin: 0 0 32px;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
&-actions {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.app-open-source-cards {
|
||||
&-grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
&-opensource-card,
|
||||
&-license-card {
|
||||
--ui-card-padding: 32px 24px;
|
||||
}
|
||||
|
||||
&-actions {
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
&-action-btn {
|
||||
width: 100%;
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,393 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, watch } from 'vue';
|
||||
import { useDebounceFn } from '@vueuse/core';
|
||||
import { kebabCase, camelCase, capitalCase } from 'change-case';
|
||||
import { track } from '@theme/utils/track';
|
||||
import {
|
||||
UiHeadline,
|
||||
UiDescription,
|
||||
UiContainer,
|
||||
UiSection,
|
||||
UiWindow,
|
||||
} from '../ui';
|
||||
import { useVisibility } from '../../composables/useVisibility';
|
||||
import {
|
||||
useAvatarStyleList,
|
||||
useAvatarStyleMeta,
|
||||
} from '../../composables/avatar';
|
||||
import { formatLicenseName } from '../../utils/format';
|
||||
import { safeHttpUrl } from '../../utils/url';
|
||||
import AppSeedDemoCode from './AppSeedDemoCode.vue';
|
||||
import AppSeedDemoPreview from './AppSeedDemoPreview.vue';
|
||||
|
||||
const DEFAULT_STYLE = 'lorelei';
|
||||
|
||||
const RANDOM_SEEDS: readonly string[] = [
|
||||
'Felix',
|
||||
'Aneka',
|
||||
'Milo',
|
||||
'Luna',
|
||||
'Sophie',
|
||||
];
|
||||
|
||||
const sectionRef = ref();
|
||||
const isVisible = useVisibility(sectionRef);
|
||||
|
||||
const avatarStyleList = useAvatarStyleList();
|
||||
const availableStyles = computed(() =>
|
||||
avatarStyleList.value.map((s) => kebabCase(s)),
|
||||
);
|
||||
const currentStyle = ref<string>(DEFAULT_STYLE);
|
||||
const activeStyleName = computed(
|
||||
() =>
|
||||
avatarStyleList.value.find((s) => kebabCase(s) === currentStyle.value) ??
|
||||
DEFAULT_STYLE,
|
||||
);
|
||||
const currentStyleCamel = computed(() => camelCase(activeStyleName.value));
|
||||
const currentStyleDisplay = computed(() => capitalCase(activeStyleName.value));
|
||||
const currentStyleLink = computed(() => `/styles/${currentStyle.value}/`);
|
||||
const avatarStyleMeta = useAvatarStyleMeta(activeStyleName);
|
||||
|
||||
const seed = ref<string>(RANDOM_SEEDS[0]);
|
||||
|
||||
// Never send the seed text itself, only that the user edited it. Debounced so
|
||||
// typing counts as one interaction.
|
||||
const trackSeedEdited = useDebounceFn(() => {
|
||||
track('Home Demo: Seed Edited', { style: currentStyle.value });
|
||||
}, 700);
|
||||
|
||||
function onSeedUpdate(value: string) {
|
||||
seed.value = value;
|
||||
trackSeedEdited();
|
||||
}
|
||||
|
||||
function randomizeSeed() {
|
||||
const currentIndex = RANDOM_SEEDS.indexOf(seed.value);
|
||||
const nextIndex = (currentIndex + 1) % RANDOM_SEEDS.length;
|
||||
seed.value = RANDOM_SEEDS[nextIndex];
|
||||
|
||||
track('Home Demo: Seed Randomized', { style: currentStyle.value });
|
||||
}
|
||||
|
||||
watch(currentStyle, (style) => {
|
||||
track('Home Demo: Style Selected', { style });
|
||||
});
|
||||
|
||||
const sourceUrl = computed(() => safeHttpUrl(avatarStyleMeta.value?.source));
|
||||
const homepageUrl = computed(() =>
|
||||
safeHttpUrl(avatarStyleMeta.value?.homepage),
|
||||
);
|
||||
const licenseUrl = computed(() =>
|
||||
safeHttpUrl(avatarStyleMeta.value?.license?.url),
|
||||
);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<UiSection ref="sectionRef" :class="{ visible: isVisible }" divider>
|
||||
<template #background>
|
||||
<div class="app-seed-demo-glow"></div>
|
||||
</template>
|
||||
|
||||
<UiContainer class="app-seed-demo-container">
|
||||
<div class="app-seed-demo-header">
|
||||
<UiHeadline class="app-seed-demo-title">
|
||||
Same Seed, Same Avatar.<br />
|
||||
<strong>Every Time.</strong>
|
||||
</UiHeadline>
|
||||
<UiDescription class="app-seed-demo-subtitle">
|
||||
Use any string as a seed, such as usernames, emails, or IDs, and
|
||||
DiceBear generates the identical avatar consistently across all
|
||||
platforms.
|
||||
</UiDescription>
|
||||
</div>
|
||||
|
||||
<div class="app-seed-demo-window-wrapper">
|
||||
<UiWindow title="Seed Explorer">
|
||||
<div class="app-seed-demo-body">
|
||||
<AppSeedDemoPreview
|
||||
v-model:style-name="currentStyle"
|
||||
:styles="availableStyles"
|
||||
:model-value="seed"
|
||||
@update:model-value="onSeedUpdate"
|
||||
@randomize-seed="randomizeSeed"
|
||||
/>
|
||||
|
||||
<AppSeedDemoCode
|
||||
:seed="seed"
|
||||
:style="currentStyle"
|
||||
:style-camel="currentStyleCamel"
|
||||
/>
|
||||
</div>
|
||||
</UiWindow>
|
||||
|
||||
<p class="app-seed-demo-license">
|
||||
<a :href="currentStyleLink">{{ currentStyleDisplay }}</a>
|
||||
<template v-if="avatarStyleMeta?.creator !== 'DiceBear'">
|
||||
<template
|
||||
v-if="
|
||||
avatarStyleMeta?.license?.name !== 'MIT' &&
|
||||
avatarStyleMeta?.title
|
||||
"
|
||||
>
|
||||
is a remix of
|
||||
</template>
|
||||
<template v-else> is based on </template>
|
||||
<a
|
||||
v-if="sourceUrl"
|
||||
:href="sourceUrl"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>{{ avatarStyleMeta?.title ?? 'Design' }}</a
|
||||
>
|
||||
<template v-else>{{ avatarStyleMeta?.title ?? 'Design' }}</template>
|
||||
</template>
|
||||
by
|
||||
<a
|
||||
v-if="homepageUrl"
|
||||
:href="homepageUrl"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>{{ avatarStyleMeta?.creator }}</a
|
||||
>
|
||||
<template v-else>{{ avatarStyleMeta?.creator }}</template
|
||||
>, licensed under
|
||||
<a
|
||||
v-if="licenseUrl"
|
||||
:href="licenseUrl"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>{{ formatLicenseName(avatarStyleMeta?.license?.name) }}</a
|
||||
>
|
||||
<template v-else>{{
|
||||
formatLicenseName(avatarStyleMeta?.license?.name)
|
||||
}}</template
|
||||
>.
|
||||
</p>
|
||||
</div>
|
||||
</UiContainer>
|
||||
</UiSection>
|
||||
</template>
|
||||
|
||||
<style lang="scss">
|
||||
:root {
|
||||
--app-seed-demo-avatar-shadow: 0 12px 40px rgba(0, 0, 0, 0.12);
|
||||
}
|
||||
.dark {
|
||||
--app-seed-demo-avatar-shadow: 0 12px 40px rgba(0, 0, 0, 0.4);
|
||||
}
|
||||
|
||||
.app-seed-demo {
|
||||
&-glow {
|
||||
background:
|
||||
radial-gradient(
|
||||
ellipse 60% 60% at 50% 0%,
|
||||
color-mix(in srgb, var(--vp-c-brand-1) 10%, transparent),
|
||||
transparent
|
||||
),
|
||||
radial-gradient(
|
||||
ellipse 50% 40% at 20% 80%,
|
||||
color-mix(in srgb, var(--vp-c-brand-1) 6%, transparent),
|
||||
transparent
|
||||
),
|
||||
radial-gradient(
|
||||
ellipse 50% 40% at 80% 60%,
|
||||
color-mix(in srgb, var(--vp-c-brand-1) 5%, transparent),
|
||||
transparent
|
||||
);
|
||||
}
|
||||
|
||||
&-container {
|
||||
opacity: 0;
|
||||
transform: translateY(40px);
|
||||
transition: all var(--duration-reveal) var(--ease-smooth);
|
||||
|
||||
.visible & {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
}
|
||||
}
|
||||
|
||||
&-header {
|
||||
text-align: center;
|
||||
margin-bottom: 48px;
|
||||
}
|
||||
|
||||
&-title {
|
||||
--hl-display: block;
|
||||
}
|
||||
|
||||
&-subtitle {
|
||||
max-width: 540px;
|
||||
}
|
||||
|
||||
&-license {
|
||||
margin-top: 20px;
|
||||
font-size: 13px;
|
||||
color: var(--vp-c-text-3);
|
||||
text-align: center;
|
||||
|
||||
a {
|
||||
color: color-mix(in srgb, var(--vp-c-text-2) 50%, var(--vp-c-text-3));
|
||||
font-weight: 400;
|
||||
text-decoration: underline;
|
||||
text-decoration-style: dotted;
|
||||
text-decoration-color: var(--vp-c-border);
|
||||
transition: color var(--duration-fast) ease;
|
||||
|
||||
&:hover {
|
||||
color: var(--vp-c-brand-1);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
&-control {
|
||||
width: 220px;
|
||||
}
|
||||
|
||||
&-seed-field {
|
||||
.p-inputtext {
|
||||
width: 100%;
|
||||
}
|
||||
}
|
||||
|
||||
&-dice-icon {
|
||||
cursor: pointer;
|
||||
color: var(--vp-c-text-3);
|
||||
transition: color var(--duration-fast) ease;
|
||||
outline: none;
|
||||
user-select: none;
|
||||
-webkit-user-select: none;
|
||||
|
||||
svg {
|
||||
transition: transform var(--duration-mid) var(--ease-spring);
|
||||
}
|
||||
|
||||
&:hover {
|
||||
color: var(--vp-c-brand-1);
|
||||
|
||||
svg {
|
||||
transform: rotate(90deg);
|
||||
}
|
||||
}
|
||||
|
||||
&:active svg {
|
||||
transform: rotate(180deg) scale(0.9);
|
||||
}
|
||||
|
||||
&:focus-visible {
|
||||
color: var(--vp-c-brand-1);
|
||||
}
|
||||
}
|
||||
|
||||
&-body {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
min-height: 420px;
|
||||
}
|
||||
|
||||
&-showcase {
|
||||
min-width: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 40px 24px 40px;
|
||||
gap: 10px;
|
||||
background: radial-gradient(
|
||||
ellipse 70% 60% at 50% 40%,
|
||||
color-mix(in srgb, var(--vp-c-brand-1) 6%, transparent),
|
||||
transparent
|
||||
);
|
||||
}
|
||||
|
||||
&-avatar-stage {
|
||||
position: relative;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
&-avatar-glow {
|
||||
position: absolute;
|
||||
width: 180px;
|
||||
height: 180px;
|
||||
border-radius: 50%;
|
||||
background: radial-gradient(
|
||||
circle,
|
||||
color-mix(in srgb, var(--vp-c-brand-1) 15%, transparent),
|
||||
transparent 70%
|
||||
);
|
||||
animation: app-seed-demo-glow-pulse 3s ease-in-out infinite;
|
||||
}
|
||||
|
||||
&-avatar-main {
|
||||
width: 148px;
|
||||
height: 148px;
|
||||
border-radius: var(--vp-radius-xl);
|
||||
margin-bottom: 24px;
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
box-shadow: var(--app-seed-demo-avatar-shadow);
|
||||
transition: transform var(--duration-mid) var(--ease-smooth);
|
||||
|
||||
&:hover {
|
||||
transform: scale(1.05);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes app-seed-demo-glow-pulse {
|
||||
0%,
|
||||
100% {
|
||||
transform: scale(1);
|
||||
opacity: 0.6;
|
||||
}
|
||||
50% {
|
||||
transform: scale(1.15);
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.app-seed-demo {
|
||||
&-body {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
&-showcase {
|
||||
padding: 32px 16px 24px;
|
||||
}
|
||||
|
||||
&-avatar-main {
|
||||
width: 88px;
|
||||
height: 88px;
|
||||
border-radius: var(--vp-radius-md);
|
||||
}
|
||||
|
||||
&-avatar-glow {
|
||||
width: 110px;
|
||||
height: 110px;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 480px) {
|
||||
.app-seed-demo {
|
||||
&-showcase {
|
||||
padding: 24px 12px 20px;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
&-avatar-main {
|
||||
width: 80px;
|
||||
height: 80px;
|
||||
border-radius: var(--vp-radius-md);
|
||||
}
|
||||
|
||||
&-avatar-glow {
|
||||
width: 100px;
|
||||
height: 100px;
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,281 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, ref } from 'vue';
|
||||
import { PawPrint } from '@lucide/vue';
|
||||
import Button from 'primevue/button';
|
||||
import Select from 'primevue/select';
|
||||
import { UiCode } from '../ui';
|
||||
import { escapeJsString, escapeShellArg } from '../../utils/escape';
|
||||
import { formatDartValue } from '@theme/utils/code-examples';
|
||||
|
||||
type CodeExample =
|
||||
| 'api'
|
||||
| 'js'
|
||||
| 'php'
|
||||
| 'python'
|
||||
| 'rust'
|
||||
| 'go'
|
||||
| 'dart'
|
||||
| 'cli';
|
||||
|
||||
const props = defineProps<{
|
||||
seed: string;
|
||||
style: string;
|
||||
styleCamel: string;
|
||||
}>();
|
||||
|
||||
const activeExample = ref<CodeExample>('api');
|
||||
const exampleOptions: { label: string; value: CodeExample }[] = [
|
||||
{ label: 'HTTP API', value: 'api' },
|
||||
{ label: 'JS Library', value: 'js' },
|
||||
{ label: 'PHP Library', value: 'php' },
|
||||
{ label: 'Python Library', value: 'python' },
|
||||
{ label: 'Rust Library', value: 'rust' },
|
||||
{ label: 'Go Library', value: 'go' },
|
||||
{ label: 'Dart Library', value: 'dart' },
|
||||
{ label: 'CLI', value: 'cli' },
|
||||
];
|
||||
|
||||
const apiExample = computed(
|
||||
() =>
|
||||
`https://api.dicebear.com/10.x/${props.style}/svg?seed=${encodeURIComponent(props.seed)}`,
|
||||
);
|
||||
|
||||
const jsExample = computed(
|
||||
() =>
|
||||
`import { Style, Avatar } from '@dicebear/core';
|
||||
import definition from '@dicebear/styles/${props.style}.json' with { type: 'json' };
|
||||
|
||||
const style = new Style(definition);
|
||||
const avatar = new Avatar(style, {
|
||||
seed: '${escapeJsString(props.seed)}'
|
||||
});`,
|
||||
);
|
||||
|
||||
const phpExample = computed(
|
||||
() =>
|
||||
`<?php
|
||||
|
||||
use Composer\\InstalledVersions;
|
||||
use DiceBear\\Style;
|
||||
use DiceBear\\Avatar;
|
||||
|
||||
$basePath = InstalledVersions::getInstallPath('dicebear/styles');
|
||||
$style = Style::fromJson(file_get_contents($basePath . '/src/${props.style}.json'));
|
||||
|
||||
$avatar = new Avatar($style, [
|
||||
'seed' => '${escapeJsString(props.seed)}'
|
||||
]);`,
|
||||
);
|
||||
|
||||
const pythonExample = computed(
|
||||
() =>
|
||||
`from importlib.resources import files
|
||||
|
||||
from dicebear import Avatar, Style
|
||||
|
||||
style = Style.from_json(
|
||||
files("dicebear_styles").joinpath("${props.style}.json").read_text("utf-8")
|
||||
)
|
||||
|
||||
avatar = Avatar(style, {
|
||||
"seed": "${escapeJsString(props.seed)}"
|
||||
})`,
|
||||
);
|
||||
|
||||
const rustExample = computed(
|
||||
() =>
|
||||
`use dicebear_core::{Avatar, Style};
|
||||
use serde_json::json;
|
||||
|
||||
let style = Style::from_str(dicebear_styles::${props.style.toUpperCase().replace(/-/g, '_')})?;
|
||||
let avatar = Avatar::new(&style, json!({
|
||||
"seed": "${escapeJsString(props.seed)}"
|
||||
}))?;`,
|
||||
);
|
||||
|
||||
const goExample = computed(() => {
|
||||
// The Go styles module exports each style as a PascalCase variable
|
||||
// (e.g. "big-ears" → BigEars).
|
||||
const styleConst = props.style
|
||||
.split('-')
|
||||
.map((part) => part.charAt(0).toUpperCase() + part.slice(1))
|
||||
.join('');
|
||||
|
||||
return `import (
|
||||
dicebear "github.com/dicebear/dicebear-go/v10"
|
||||
"github.com/dicebear/styles/v10"
|
||||
)
|
||||
|
||||
style, _ := dicebear.NewStyle([]byte(styles.${styleConst}))
|
||||
avatar, _ := dicebear.NewAvatar(style, map[string]any{
|
||||
"seed": "${escapeJsString(props.seed)}",
|
||||
})`;
|
||||
});
|
||||
|
||||
const dartExample = computed(
|
||||
() =>
|
||||
// formatDartValue quotes the seed and escapes $, which escapeJsString
|
||||
// leaves alone but Dart would treat as string interpolation.
|
||||
`import 'package:dicebear_core/dicebear_core.dart';
|
||||
import 'package:dicebear_styles/${props.style.replace(/-/g, '_')}.dart';
|
||||
|
||||
final style = Style.parse(${props.styleCamel});
|
||||
final avatar = Avatar(style, {
|
||||
'seed': ${formatDartValue(props.seed)},
|
||||
});`,
|
||||
);
|
||||
|
||||
const cliExample = computed(
|
||||
() => `npx dicebear ${props.style} --seed '${escapeShellArg(props.seed)}'`,
|
||||
);
|
||||
|
||||
const playgroundLink = '/playground/';
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="app-seed-demo-code">
|
||||
<div class="app-seed-demo-code-wrapper">
|
||||
<Select
|
||||
v-model="activeExample"
|
||||
:options="exampleOptions"
|
||||
option-label="label"
|
||||
option-value="value"
|
||||
size="large"
|
||||
fluid
|
||||
aria-label="Code example"
|
||||
class="app-seed-demo-code-select"
|
||||
/>
|
||||
|
||||
<div class="app-seed-demo-code-body">
|
||||
<UiCode
|
||||
:code="apiExample"
|
||||
scroll-to-bottom
|
||||
class="app-seed-demo-code-block"
|
||||
:class="{ active: activeExample === 'api' }"
|
||||
/>
|
||||
<UiCode
|
||||
:code="jsExample"
|
||||
lang="js"
|
||||
scroll-to-bottom
|
||||
class="app-seed-demo-code-block"
|
||||
:class="{ active: activeExample === 'js' }"
|
||||
/>
|
||||
<UiCode
|
||||
:code="phpExample"
|
||||
lang="php"
|
||||
scroll-to-bottom
|
||||
class="app-seed-demo-code-block"
|
||||
:class="{ active: activeExample === 'php' }"
|
||||
/>
|
||||
<UiCode
|
||||
:code="pythonExample"
|
||||
lang="python"
|
||||
scroll-to-bottom
|
||||
class="app-seed-demo-code-block"
|
||||
:class="{ active: activeExample === 'python' }"
|
||||
/>
|
||||
<UiCode
|
||||
:code="rustExample"
|
||||
lang="rust"
|
||||
scroll-to-bottom
|
||||
class="app-seed-demo-code-block"
|
||||
:class="{ active: activeExample === 'rust' }"
|
||||
/>
|
||||
<UiCode
|
||||
:code="goExample"
|
||||
lang="go"
|
||||
scroll-to-bottom
|
||||
class="app-seed-demo-code-block"
|
||||
:class="{ active: activeExample === 'go' }"
|
||||
/>
|
||||
<UiCode
|
||||
:code="dartExample"
|
||||
lang="dart"
|
||||
scroll-to-bottom
|
||||
class="app-seed-demo-code-block"
|
||||
:class="{ active: activeExample === 'dart' }"
|
||||
/>
|
||||
<UiCode
|
||||
:code="cliExample"
|
||||
scroll-to-bottom
|
||||
class="app-seed-demo-code-block"
|
||||
:class="{ active: activeExample === 'cli' }"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="app-seed-demo-code-cta">
|
||||
<Button
|
||||
as="a"
|
||||
:href="playgroundLink"
|
||||
size="large"
|
||||
severity="contrast"
|
||||
class="app-seed-demo-code-cta-btn"
|
||||
>
|
||||
<PawPrint :size="20" />
|
||||
Open Playground
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.app-seed-demo-code {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
border-left: 1px solid var(--ui-window-divider-color);
|
||||
min-width: 0;
|
||||
|
||||
// Align the code block surface with the example select above it
|
||||
// (both sit on --vp-c-bg inside this editor window mockup).
|
||||
--vp-code-block-bg: var(--vp-c-bg);
|
||||
|
||||
&-wrapper {
|
||||
padding: 16px;
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
&-select {
|
||||
// size="large" renders the label at 18px; keep the large, full-width
|
||||
// control but bring its text in line with the other demo controls (16px).
|
||||
:deep(.p-select-label) {
|
||||
font-size: 16px;
|
||||
}
|
||||
}
|
||||
|
||||
&-body {
|
||||
flex: 1;
|
||||
display: grid;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
&-block {
|
||||
grid-area: 1 / 1;
|
||||
visibility: hidden;
|
||||
|
||||
&.active {
|
||||
visibility: visible;
|
||||
}
|
||||
}
|
||||
|
||||
&-cta {
|
||||
padding: 0 16px 16px;
|
||||
|
||||
&-btn {
|
||||
width: 100%;
|
||||
justify-content: center;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.app-seed-demo-code {
|
||||
border-left: none;
|
||||
border-top: 1px solid var(--ui-window-divider-color);
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,73 @@
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue';
|
||||
import { Dice5 } from '@lucide/vue';
|
||||
import IconField from 'primevue/iconfield';
|
||||
import InputIcon from 'primevue/inputicon';
|
||||
import InputText from 'primevue/inputtext';
|
||||
import { UiAvatar, UiStyleSelect } from '../ui';
|
||||
|
||||
defineProps<{
|
||||
styles: string[];
|
||||
}>();
|
||||
|
||||
const seed = defineModel<string>({ required: true });
|
||||
const styleName = defineModel<string>('styleName', { required: true });
|
||||
|
||||
const emit = defineEmits<{
|
||||
randomizeSeed: [];
|
||||
}>();
|
||||
|
||||
const mainAvatarLink = computed(
|
||||
() =>
|
||||
`https://api.dicebear.com/10.x/${styleName.value}/svg?seed=${encodeURIComponent(seed.value)}`,
|
||||
);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="app-seed-demo-showcase">
|
||||
<div class="app-seed-demo-avatar-stage">
|
||||
<div class="app-seed-demo-avatar-glow"></div>
|
||||
<a :href="mainAvatarLink" target="_blank" rel="noopener">
|
||||
<UiAvatar
|
||||
:style-name="styleName"
|
||||
:style-options="{ seed, size: 256 }"
|
||||
alt="Avatar preview"
|
||||
class="app-seed-demo-avatar-main"
|
||||
mode="library"
|
||||
/>
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<UiStyleSelect
|
||||
v-model="styleName"
|
||||
:styles="styles"
|
||||
:seed="seed"
|
||||
:value-avatar-size="20"
|
||||
aria-label="Avatar style"
|
||||
class="app-seed-demo-control"
|
||||
/>
|
||||
|
||||
<IconField class="app-seed-demo-control app-seed-demo-seed-field">
|
||||
<InputText
|
||||
id="app-seed-demo-seed"
|
||||
v-model="seed"
|
||||
placeholder="Seed"
|
||||
aria-label="Seed"
|
||||
spellcheck="false"
|
||||
autocomplete="off"
|
||||
/>
|
||||
<InputIcon
|
||||
class="app-seed-demo-dice-icon"
|
||||
role="button"
|
||||
tabindex="0"
|
||||
aria-label="Next seed"
|
||||
title="Next seed"
|
||||
@click="emit('randomizeSeed')"
|
||||
@keydown.enter.prevent="emit('randomizeSeed')"
|
||||
@keydown.space.prevent="emit('randomizeSeed')"
|
||||
>
|
||||
<Dice5 :size="16" />
|
||||
</InputIcon>
|
||||
</IconField>
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,302 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, useSlots, computed } from 'vue';
|
||||
import { PawPrint, ArrowRight } from '@lucide/vue';
|
||||
import Button from 'primevue/button';
|
||||
import { UiHeadline, UiDescription, UiContainer, UiSection } from '../ui';
|
||||
import { useVisibility } from '../../composables/useVisibility';
|
||||
|
||||
withDefaults(
|
||||
defineProps<{
|
||||
headline?: string;
|
||||
description?: string;
|
||||
}>(),
|
||||
{
|
||||
headline: 'Avatars That Stand Out',
|
||||
description:
|
||||
'DiceBear is an open source avatar library that lets you generate unique, deterministic profile pictures in no time. Whether you need geometric shapes, cute characters, or pixel art, our privacy-focused SVG avatar library gives you 35+ styles to choose from.',
|
||||
},
|
||||
);
|
||||
|
||||
const sectionRef = ref();
|
||||
const isVisible = useVisibility(sectionRef, { threshold: 0.1 });
|
||||
|
||||
const slots = useSlots();
|
||||
const hasAside = computed(() => !!slots.aside);
|
||||
const hasActions = computed(() => !!slots.actions);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<UiSection ref="sectionRef" :class="{ visible: isVisible }">
|
||||
<template #background>
|
||||
<div class="app-small-hero-gradient"></div>
|
||||
</template>
|
||||
<UiContainer
|
||||
class="app-small-hero-container"
|
||||
:class="{ 'app-small-hero-container--has-aside': hasAside }"
|
||||
>
|
||||
<div :class="{ 'app-small-hero-layout': hasAside }">
|
||||
<div>
|
||||
<UiHeadline tag="h1">
|
||||
<slot name="headline">{{ headline }}</slot>
|
||||
</UiHeadline>
|
||||
<UiDescription class="app-small-hero-description">
|
||||
<slot name="description">{{ description }}</slot>
|
||||
</UiDescription>
|
||||
|
||||
<div v-if="hasActions" class="app-small-hero-actions">
|
||||
<slot name="actions" />
|
||||
</div>
|
||||
<div v-else class="app-small-hero-actions">
|
||||
<Button
|
||||
as="a"
|
||||
href="/playground/"
|
||||
size="large"
|
||||
severity="contrast"
|
||||
class="app-small-hero-action-btn"
|
||||
>
|
||||
<PawPrint :size="20" />
|
||||
Try Playground
|
||||
</Button>
|
||||
<Button
|
||||
as="a"
|
||||
href="/styles/"
|
||||
size="large"
|
||||
severity="secondary"
|
||||
variant="outlined"
|
||||
class="app-small-hero-action-btn"
|
||||
>
|
||||
Browse Styles
|
||||
<ArrowRight :size="20" />
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<slot name="below-actions" />
|
||||
</div>
|
||||
|
||||
<div v-if="hasAside" class="app-small-hero-aside">
|
||||
<slot name="aside" />
|
||||
</div>
|
||||
</div>
|
||||
</UiContainer>
|
||||
</UiSection>
|
||||
</template>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.app-small-hero {
|
||||
&-gradient {
|
||||
background:
|
||||
radial-gradient(
|
||||
ellipse 80% 50% at 50% -20%,
|
||||
rgba(14, 165, 233, 0.12),
|
||||
transparent
|
||||
),
|
||||
radial-gradient(
|
||||
ellipse 60% 40% at 80% 50%,
|
||||
rgba(124, 58, 237, 0.08),
|
||||
transparent
|
||||
),
|
||||
radial-gradient(
|
||||
ellipse 60% 40% at 20% 80%,
|
||||
rgba(20, 184, 166, 0.06),
|
||||
transparent
|
||||
);
|
||||
|
||||
&::before,
|
||||
&::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
border-radius: 50%;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
&::before {
|
||||
width: 400px;
|
||||
height: 400px;
|
||||
top: -10%;
|
||||
right: -5%;
|
||||
background: radial-gradient(
|
||||
circle,
|
||||
color-mix(in srgb, var(--vp-c-brand-1) 8%, transparent) 0%,
|
||||
transparent 70%
|
||||
);
|
||||
animation: shape-float 18s ease-in-out infinite;
|
||||
}
|
||||
|
||||
&::after {
|
||||
width: 300px;
|
||||
height: 300px;
|
||||
bottom: -5%;
|
||||
left: -3%;
|
||||
background: radial-gradient(
|
||||
circle,
|
||||
color-mix(in srgb, var(--vp-c-brand-1) 8%, transparent) 0%,
|
||||
transparent 70%
|
||||
);
|
||||
animation: shape-float 22s ease-in-out infinite reverse;
|
||||
}
|
||||
}
|
||||
|
||||
&-container {
|
||||
text-align: center;
|
||||
opacity: 0;
|
||||
transform: translateY(30px);
|
||||
animation: fade-up var(--duration-reveal) var(--ease-smooth) both;
|
||||
|
||||
.visible & {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
}
|
||||
|
||||
&--has-aside {
|
||||
text-align: left;
|
||||
|
||||
.app-small-hero-description {
|
||||
margin-inline: 0;
|
||||
}
|
||||
|
||||
.app-small-hero-actions {
|
||||
justify-content: flex-start;
|
||||
}
|
||||
|
||||
.app-small-hero-avatars {
|
||||
justify-content: flex-start;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
&-layout {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(12, 1fr);
|
||||
align-items: center;
|
||||
|
||||
> :first-child {
|
||||
grid-column: 1 / 7;
|
||||
}
|
||||
|
||||
> :last-child {
|
||||
grid-column: 7 / 13;
|
||||
}
|
||||
}
|
||||
|
||||
&-aside {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
&-description {
|
||||
margin-bottom: 40px;
|
||||
max-width: 550px;
|
||||
animation: fade-up var(--duration-reveal) var(--ease-smooth) 0.1s both;
|
||||
}
|
||||
|
||||
&-actions {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
gap: 16px;
|
||||
animation: fade-up var(--duration-reveal) var(--ease-smooth) 0.2s both;
|
||||
}
|
||||
|
||||
&-avatars {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
gap: 16px;
|
||||
flex-wrap: wrap;
|
||||
animation: fade-up var(--duration-reveal) var(--ease-smooth) 0.3s both;
|
||||
}
|
||||
|
||||
&-avatar {
|
||||
width: 72px;
|
||||
height: 72px;
|
||||
border-radius: var(--vp-radius-md);
|
||||
overflow: hidden;
|
||||
opacity: 0;
|
||||
transform: translateY(20px) scale(0.9);
|
||||
transition: all var(--duration-mid) var(--ease-spring);
|
||||
|
||||
&::after {
|
||||
display: none !important;
|
||||
}
|
||||
|
||||
.visible & {
|
||||
animation: app-small-hero-avatar-reveal 0.5s var(--ease-spring) forwards;
|
||||
}
|
||||
|
||||
&:hover {
|
||||
transform: translateY(-8px) scale(1.1);
|
||||
box-shadow: 0 12px 24px -8px rgba(0, 0, 0, 0.2);
|
||||
}
|
||||
|
||||
img {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
}
|
||||
}
|
||||
}
|
||||
@keyframes app-small-hero-avatar-reveal {
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateY(0) scale(1);
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.app-small-hero {
|
||||
&-gradient {
|
||||
&::before {
|
||||
width: 200px;
|
||||
height: 200px;
|
||||
}
|
||||
|
||||
&::after {
|
||||
width: 150px;
|
||||
height: 150px;
|
||||
}
|
||||
}
|
||||
|
||||
&-layout {
|
||||
grid-template-columns: 1fr;
|
||||
gap: 48px;
|
||||
|
||||
> :first-child,
|
||||
> :last-child {
|
||||
grid-column: auto;
|
||||
}
|
||||
}
|
||||
|
||||
&-container--has-aside {
|
||||
text-align: center;
|
||||
|
||||
.app-small-hero-description {
|
||||
margin-inline: auto;
|
||||
}
|
||||
|
||||
.app-small-hero-actions {
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.app-small-hero-avatars {
|
||||
justify-content: center;
|
||||
}
|
||||
}
|
||||
|
||||
&-actions {
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
|
||||
.app-small-hero-action-btn {
|
||||
width: 100%;
|
||||
max-width: 280px;
|
||||
}
|
||||
}
|
||||
|
||||
&-avatar {
|
||||
width: 56px;
|
||||
height: 56px;
|
||||
border-radius: var(--vp-radius-sm);
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,159 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, computed } from 'vue';
|
||||
import { ArrowRight, ChartNoAxesCombined } from '@lucide/vue';
|
||||
import Button from 'primevue/button';
|
||||
import { UiContainer, UiSection, UiSectionHeader } from '../ui';
|
||||
import AppStatsBannerCard from './AppStatsBannerCard.vue';
|
||||
import { useVisibility } from '../../composables/useVisibility';
|
||||
import { useApiStats, useApiStatsRaw } from '../../composables/useApiStats';
|
||||
import { formatNumberParts, formatBytesParts } from '../../utils/format';
|
||||
|
||||
const sectionRef = ref();
|
||||
const isVisible = useVisibility(sectionRef, { threshold: 0.15 });
|
||||
|
||||
const apiStats = useApiStats();
|
||||
const apiStatsRaw = useApiStatsRaw();
|
||||
|
||||
const monthLabel = computed(
|
||||
() => apiStats.value?.monthLabel ?? 'the past month',
|
||||
);
|
||||
|
||||
const requests = computed(() =>
|
||||
apiStats.value
|
||||
? formatNumberParts(apiStats.value.monthlyRequests)
|
||||
: { value: '1', unit: 'B' },
|
||||
);
|
||||
|
||||
const traffic = computed(() =>
|
||||
apiStats.value
|
||||
? formatBytesParts(apiStats.value.monthlyTraffic)
|
||||
: { value: '3', unit: 'TB' },
|
||||
);
|
||||
|
||||
const downloads = computed(() =>
|
||||
apiStats.value
|
||||
? formatNumberParts(apiStats.value.monthlyNpmDownloads)
|
||||
: { value: '500', unit: 'K' },
|
||||
);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<UiSection ref="sectionRef" :class="{ visible: isVisible }" divider>
|
||||
<template #background>
|
||||
<div class="app-stats-banner-gradient"></div>
|
||||
</template>
|
||||
<UiContainer>
|
||||
<UiSectionHeader
|
||||
class="app-stats-banner-header"
|
||||
description="Usage data from our HTTP-API and npm packages, updated weekly."
|
||||
>
|
||||
<template #headline
|
||||
>Billions of avatars. <strong>One API.</strong></template
|
||||
>
|
||||
</UiSectionHeader>
|
||||
|
||||
<div class="app-stats-banner-grid">
|
||||
<AppStatsBannerCard
|
||||
href="/stats/"
|
||||
title="API Requests"
|
||||
:description="`Avatars generated via the HTTP-API in ${monthLabel}.`"
|
||||
:value="requests.value"
|
||||
:unit="requests.unit"
|
||||
:daily="apiStatsRaw?.requests"
|
||||
/>
|
||||
<AppStatsBannerCard
|
||||
href="/stats/"
|
||||
feature
|
||||
title="Data Served"
|
||||
:description="`Total traffic delivered through our global CDN in ${monthLabel}.`"
|
||||
:value="traffic.value"
|
||||
:unit="traffic.unit"
|
||||
:daily="apiStatsRaw?.traffic"
|
||||
/>
|
||||
<AppStatsBannerCard
|
||||
href="/stats/"
|
||||
title="npm Downloads"
|
||||
:description="`Total downloads from the npm registry in ${monthLabel}.`"
|
||||
:value="downloads.value"
|
||||
:unit="downloads.unit"
|
||||
:daily="apiStatsRaw?.downloads.npm"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="app-stats-banner-action">
|
||||
<Button
|
||||
as="a"
|
||||
href="/stats/"
|
||||
size="large"
|
||||
severity="secondary"
|
||||
variant="outlined"
|
||||
>
|
||||
<ChartNoAxesCombined :size="20" />
|
||||
View Full Statistics
|
||||
<ArrowRight :size="18" />
|
||||
</Button>
|
||||
</div>
|
||||
</UiContainer>
|
||||
</UiSection>
|
||||
</template>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.app-stats-banner {
|
||||
&-gradient {
|
||||
background:
|
||||
radial-gradient(
|
||||
ellipse 50% 50% at 50% 0%,
|
||||
color-mix(in srgb, var(--vp-c-brand-1) 6%, transparent),
|
||||
transparent
|
||||
),
|
||||
radial-gradient(
|
||||
ellipse 50% 50% at 50% 100%,
|
||||
color-mix(in srgb, var(--vp-c-brand-1) 5%, transparent),
|
||||
transparent
|
||||
);
|
||||
}
|
||||
|
||||
&-header {
|
||||
opacity: 0;
|
||||
transform: translateY(30px);
|
||||
transition: all var(--duration-reveal) var(--ease-smooth);
|
||||
|
||||
.visible & {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
}
|
||||
}
|
||||
|
||||
&-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, 1fr);
|
||||
gap: 18px;
|
||||
}
|
||||
|
||||
&-action {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
margin-top: 40px;
|
||||
opacity: 0;
|
||||
transform: translateY(20px);
|
||||
transition: all var(--duration-reveal) var(--ease-smooth) 0.3s;
|
||||
|
||||
.visible & {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 1000px) {
|
||||
.app-stats-banner-grid {
|
||||
grid-template-columns: repeat(2, 1fr);
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 640px) {
|
||||
.app-stats-banner-grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,285 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, useId } from 'vue';
|
||||
import { UiCard } from '../ui';
|
||||
import {
|
||||
aggregateMonthly,
|
||||
formatMonthKey,
|
||||
type MonthlyEntry,
|
||||
} from '../../composables/useApiStats';
|
||||
|
||||
interface Chart {
|
||||
line: string;
|
||||
area: string;
|
||||
points: Array<{ x: number; y: number }>;
|
||||
labels: string[];
|
||||
}
|
||||
|
||||
interface Trend {
|
||||
pct: string;
|
||||
prevMonth: string;
|
||||
}
|
||||
|
||||
const HISTORY_MONTHS = 4;
|
||||
|
||||
const props = defineProps<{
|
||||
href: string;
|
||||
feature?: boolean;
|
||||
value: string;
|
||||
unit: string;
|
||||
title: string;
|
||||
description: string;
|
||||
daily?: Record<string, number>;
|
||||
}>();
|
||||
|
||||
const fillId = useId();
|
||||
|
||||
const history = computed<MonthlyEntry[]>(() => {
|
||||
if (!props.daily) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return aggregateMonthly(props.daily).slice(0, -1).slice(-HISTORY_MONTHS);
|
||||
});
|
||||
|
||||
// Anchored at 0 so a low month reads as "fewer", not as a phantom drop to zero.
|
||||
const chart = computed<Chart>(() => {
|
||||
const empty: Chart = { line: '', area: '', points: [], labels: [] };
|
||||
const h = history.value;
|
||||
|
||||
if (h.length < 2) {
|
||||
return empty;
|
||||
}
|
||||
|
||||
const max = Math.max(...h.map((m) => m.total)) || 1;
|
||||
const stepX = 200 / (h.length - 1);
|
||||
const points = h.map((m, i) => ({
|
||||
x: i * stepX,
|
||||
y: 50 - (m.total / max) * 36,
|
||||
}));
|
||||
const line = points
|
||||
.map((p, i) => `${i === 0 ? 'M' : 'L'}${p.x.toFixed(1)},${p.y.toFixed(1)}`)
|
||||
.join(' ');
|
||||
const last = points[points.length - 1];
|
||||
const area = `${line} L${last.x.toFixed(1)},60 L0,60 Z`;
|
||||
|
||||
return {
|
||||
line,
|
||||
area,
|
||||
points,
|
||||
labels: h.map((m) =>
|
||||
formatMonthKey(m.key, { month: 'short' }).toUpperCase(),
|
||||
),
|
||||
};
|
||||
});
|
||||
|
||||
// Positive-only by design: the pill hides on missing data or non-positive change.
|
||||
const trend = computed<Trend | null>(() => {
|
||||
const h = history.value;
|
||||
|
||||
if (h.length < 2) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const last = h[h.length - 1];
|
||||
const prev = h[h.length - 2];
|
||||
|
||||
if (prev.total === 0 || last.total <= prev.total) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
pct: (((last.total - prev.total) / prev.total) * 100).toFixed(1),
|
||||
prevMonth: formatMonthKey(prev.key, { month: 'long' }),
|
||||
};
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<UiCard
|
||||
padding="xl"
|
||||
:href="href"
|
||||
:class="['st-card', feature && 'st-card-feature']"
|
||||
>
|
||||
<div class="st-stat">
|
||||
{{ value }}<span class="st-unit">{{ unit }}</span>
|
||||
</div>
|
||||
<div class="st-meta">
|
||||
<strong>{{ title }}</strong>
|
||||
<span>{{ description }}</span>
|
||||
</div>
|
||||
<div class="st-trend">
|
||||
<span v-if="trend" class="st-trend-pill">
|
||||
↑ {{ trend.pct }}% vs {{ trend.prevMonth }}
|
||||
</span>
|
||||
<div v-if="chart.points.length" class="st-chart">
|
||||
<svg viewBox="0 0 200 60" preserveAspectRatio="none" aria-hidden="true">
|
||||
<defs>
|
||||
<linearGradient :id="fillId" x1="0" y1="0" x2="0" y2="1">
|
||||
<stop
|
||||
offset="0"
|
||||
stop-color="var(--vp-c-brand-1)"
|
||||
stop-opacity=".25"
|
||||
/>
|
||||
<stop
|
||||
offset="1"
|
||||
stop-color="var(--vp-c-brand-1)"
|
||||
stop-opacity="0"
|
||||
/>
|
||||
</linearGradient>
|
||||
</defs>
|
||||
<path :d="chart.area" :fill="`url(#${fillId})`" />
|
||||
<path
|
||||
:d="chart.line"
|
||||
fill="none"
|
||||
stroke="var(--vp-c-brand-1)"
|
||||
stroke-width="2"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
/>
|
||||
</svg>
|
||||
<div class="st-chart-dots">
|
||||
<span
|
||||
v-for="(p, i) in chart.points"
|
||||
:key="i"
|
||||
:style="{
|
||||
left: `${(p.x / 200) * 100}%`,
|
||||
top: `${(p.y / 60) * 100}%`,
|
||||
}"
|
||||
/>
|
||||
</div>
|
||||
<div class="st-chart-labels">
|
||||
<span v-for="label in chart.labels" :key="label">{{ label }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</UiCard>
|
||||
</template>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.st-card {
|
||||
min-height: 320px;
|
||||
|
||||
:deep(.ui-card-body) {
|
||||
height: 100%;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 24px;
|
||||
}
|
||||
|
||||
// Use compound selector to beat UiCard's `.ui-card` rule in cascade
|
||||
// (both selectors otherwise have identical specificity).
|
||||
&.st-card-feature {
|
||||
background: linear-gradient(
|
||||
160deg,
|
||||
color-mix(in srgb, var(--vp-c-brand-1) 8%, transparent),
|
||||
var(--vp-c-bg-elv) 50%
|
||||
);
|
||||
border-color: color-mix(in srgb, var(--vp-c-brand-1) 40%, transparent);
|
||||
}
|
||||
}
|
||||
|
||||
.st-stat {
|
||||
font-size: 84px;
|
||||
font-weight: 800;
|
||||
line-height: 1;
|
||||
letter-spacing: -0.045em;
|
||||
color: var(--vp-c-text-1);
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
.st-unit {
|
||||
font-size: 44px;
|
||||
font-weight: 700;
|
||||
color: var(--vp-c-brand-1);
|
||||
margin-left: 4px;
|
||||
letter-spacing: -0.02em;
|
||||
}
|
||||
|
||||
.st-meta {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
|
||||
strong {
|
||||
font-size: 17px;
|
||||
font-weight: 800;
|
||||
letter-spacing: -0.015em;
|
||||
color: var(--vp-c-text-1);
|
||||
}
|
||||
|
||||
span {
|
||||
font-size: 13.5px;
|
||||
line-height: 1.5;
|
||||
color: var(--vp-c-text-2);
|
||||
max-width: 260px;
|
||||
}
|
||||
}
|
||||
|
||||
.st-trend {
|
||||
margin-top: auto;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.st-trend-pill {
|
||||
align-self: flex-start;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
padding: 4px 10px;
|
||||
border-radius: 999px;
|
||||
font-size: 12px;
|
||||
font-weight: 700;
|
||||
background: color-mix(in srgb, var(--vp-c-green-1) 16%, transparent);
|
||||
color: var(--vp-c-green-1);
|
||||
}
|
||||
|
||||
.st-chart {
|
||||
position: relative;
|
||||
height: 80px;
|
||||
|
||||
svg {
|
||||
width: 100%;
|
||||
height: 60px;
|
||||
display: block;
|
||||
}
|
||||
}
|
||||
|
||||
.st-chart-dots {
|
||||
position: absolute;
|
||||
inset: 0 0 20px 0;
|
||||
pointer-events: none;
|
||||
|
||||
span {
|
||||
position: absolute;
|
||||
width: 6px;
|
||||
height: 6px;
|
||||
border-radius: 50%;
|
||||
background: var(--vp-c-brand-1);
|
||||
transform: translate(-50%, -50%);
|
||||
box-shadow: 0 0 0 2px var(--vp-c-bg-elv);
|
||||
}
|
||||
}
|
||||
|
||||
.st-chart-labels {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
margin-top: 6px;
|
||||
font-family: var(--vp-font-family-mono);
|
||||
font-size: 10.5px;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.06em;
|
||||
color: var(--vp-c-text-3);
|
||||
}
|
||||
|
||||
@media (max-width: 640px) {
|
||||
.st-stat {
|
||||
font-size: 64px;
|
||||
}
|
||||
|
||||
.st-unit {
|
||||
font-size: 34px;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,120 @@
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue';
|
||||
import { Line } from 'vue-chartjs';
|
||||
import {
|
||||
Chart as ChartJS,
|
||||
CategoryScale,
|
||||
LinearScale,
|
||||
PointElement,
|
||||
LineElement,
|
||||
Filler,
|
||||
Tooltip,
|
||||
} from 'chart.js';
|
||||
import { useChartTheme } from '../../composables/useChartTheme';
|
||||
|
||||
ChartJS.register(
|
||||
CategoryScale,
|
||||
LinearScale,
|
||||
PointElement,
|
||||
LineElement,
|
||||
Filler,
|
||||
Tooltip,
|
||||
);
|
||||
|
||||
const props = defineProps<{
|
||||
labels: string[];
|
||||
values: number[];
|
||||
color: string;
|
||||
formatValue: (value: number) => string;
|
||||
}>();
|
||||
|
||||
const { chartKey, tooltipConfig, gridColor, tickColor } = useChartTheme();
|
||||
|
||||
const chartData = computed(() => ({
|
||||
labels: props.labels,
|
||||
datasets: [
|
||||
{
|
||||
data: props.values,
|
||||
borderColor: props.color,
|
||||
backgroundColor: props.color + '18',
|
||||
borderWidth: 2,
|
||||
fill: true,
|
||||
tension: 0.3,
|
||||
pointRadius: 0,
|
||||
pointHitRadius: 10,
|
||||
pointHoverRadius: 4,
|
||||
pointHoverBackgroundColor: props.color,
|
||||
},
|
||||
],
|
||||
}));
|
||||
|
||||
const chartOptions = computed(() => ({
|
||||
responsive: true,
|
||||
maintainAspectRatio: false,
|
||||
interaction: {
|
||||
intersect: false,
|
||||
mode: 'index' as const,
|
||||
},
|
||||
plugins: {
|
||||
legend: {
|
||||
display: false,
|
||||
},
|
||||
tooltip: {
|
||||
...tooltipConfig(),
|
||||
displayColors: false,
|
||||
callbacks: {
|
||||
label: (ctx: any) => props.formatValue(ctx.parsed.y),
|
||||
},
|
||||
},
|
||||
},
|
||||
scales: {
|
||||
x: {
|
||||
grid: {
|
||||
display: false,
|
||||
},
|
||||
ticks: {
|
||||
color: tickColor(),
|
||||
maxRotation: 0,
|
||||
autoSkip: true,
|
||||
maxTicksLimit: 8,
|
||||
font: { size: 12 },
|
||||
},
|
||||
border: {
|
||||
display: false,
|
||||
},
|
||||
},
|
||||
y: {
|
||||
grid: {
|
||||
color: gridColor(),
|
||||
},
|
||||
ticks: {
|
||||
color: tickColor(),
|
||||
callback: (value: any) => props.formatValue(value),
|
||||
font: { size: 12 },
|
||||
},
|
||||
border: {
|
||||
display: false,
|
||||
},
|
||||
},
|
||||
},
|
||||
}));
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="app-stats-chart">
|
||||
<Line :key="chartKey" :data="chartData" :options="chartOptions" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.app-stats-chart {
|
||||
height: 300px;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
@media (max-width: 640px) {
|
||||
.app-stats-chart {
|
||||
height: 220px;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,569 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted, onUnmounted, watch } from 'vue';
|
||||
import { useData } from 'vitepress';
|
||||
import { useVisibility } from '@theme/composables/useVisibility';
|
||||
import {
|
||||
buildLandPoints,
|
||||
createPreloadBuffer,
|
||||
type PreloadBuffer,
|
||||
} from '@theme/utils/globeLandPoints';
|
||||
|
||||
const props = defineProps<{
|
||||
rate: number;
|
||||
}>();
|
||||
|
||||
const { isDark } = useData();
|
||||
const containerRef = ref<HTMLDivElement>();
|
||||
const globeRef = ref<HTMLDivElement>();
|
||||
const isVisible = useVisibility(globeRef, { once: false, threshold: 0.1 });
|
||||
let globe: any = null;
|
||||
let eventInterval: ReturnType<typeof setInterval> | null = null;
|
||||
let resizeObserver: ResizeObserver | null = null;
|
||||
let rotationFrame: number | null = null;
|
||||
|
||||
const apiBase = 'https://api.dicebear.com/10.x';
|
||||
const avatarStyles = [
|
||||
'thumbs',
|
||||
'shapes',
|
||||
'lorelei',
|
||||
'pixel-art',
|
||||
'adventurer',
|
||||
'bottts',
|
||||
'avataaars',
|
||||
'notionists',
|
||||
];
|
||||
|
||||
interface GlobeEvent {
|
||||
id: number;
|
||||
lat: number;
|
||||
lng: number;
|
||||
url: string;
|
||||
}
|
||||
|
||||
let countriesGeoJson: any = null;
|
||||
|
||||
let events: GlobeEvent[] = [];
|
||||
let eventId = 0;
|
||||
const elementMap = new Map<number, HTMLElement>();
|
||||
const pendingTimeouts: ReturnType<typeof setTimeout>[] = [];
|
||||
|
||||
let landPoints: [number, number][] = [];
|
||||
let preloadBuffer: PreloadBuffer | null = null;
|
||||
|
||||
function generateAvatarUrl() {
|
||||
const style = avatarStyles[Math.floor(Math.random() * avatarStyles.length)];
|
||||
const seed = Math.random().toString(36).slice(2, 8);
|
||||
return `${apiBase}/${style}/svg?seed=${seed}&size=64`;
|
||||
}
|
||||
|
||||
const MIN_DISTANCE_DEG = 12;
|
||||
|
||||
function pickLandPoint(): [number, number] {
|
||||
// Try to find a point far enough from active events
|
||||
for (let attempt = 0; attempt < 30; attempt++) {
|
||||
const pt = landPoints[Math.floor(Math.random() * landPoints.length)];
|
||||
let tooClose = false;
|
||||
for (const e of events) {
|
||||
if (
|
||||
Math.abs(pt[0] - e.lat) < MIN_DISTANCE_DEG &&
|
||||
Math.abs(pt[1] - e.lng) < MIN_DISTANCE_DEG
|
||||
) {
|
||||
tooClose = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (!tooClose) return pt;
|
||||
}
|
||||
// Fallback: pick any random land point with slight offset
|
||||
const base = landPoints[Math.floor(Math.random() * landPoints.length)];
|
||||
return [
|
||||
base[0] + (Math.random() - 0.5) * 10,
|
||||
base[1] + (Math.random() - 0.5) * 10,
|
||||
];
|
||||
}
|
||||
|
||||
function syncGlobe() {
|
||||
if (globe) globe.htmlElementsData([...events]);
|
||||
}
|
||||
|
||||
function addEvent() {
|
||||
const city = pickLandPoint();
|
||||
const url = preloadBuffer
|
||||
? preloadBuffer.getPreloadedUrl()
|
||||
: generateAvatarUrl();
|
||||
|
||||
const ev: GlobeEvent = {
|
||||
id: eventId++,
|
||||
lat: city[0],
|
||||
lng: city[1],
|
||||
url,
|
||||
};
|
||||
events.push(ev);
|
||||
syncGlobe();
|
||||
|
||||
const t1 = setTimeout(() => {
|
||||
const el = elementMap.get(ev.id);
|
||||
if (el) el.classList.add('app-stats-globe-bubble--leaving');
|
||||
|
||||
const t2 = setTimeout(() => {
|
||||
events = events.filter((e) => e !== ev);
|
||||
elementMap.delete(ev.id);
|
||||
syncGlobe();
|
||||
}, 600);
|
||||
pendingTimeouts.push(t2);
|
||||
}, 8000);
|
||||
pendingTimeouts.push(t1);
|
||||
}
|
||||
|
||||
function createAvatarElement(d: any) {
|
||||
const outer = document.createElement('div');
|
||||
outer.className = 'app-stats-globe-marker';
|
||||
outer.dataset.lat = String(d.lat);
|
||||
outer.dataset.lng = String(d.lng);
|
||||
|
||||
const inner = document.createElement('div');
|
||||
inner.className = 'app-stats-globe-bubble';
|
||||
|
||||
const ring = document.createElement('span');
|
||||
ring.className = 'app-stats-globe-bubble-ring';
|
||||
|
||||
const disc = document.createElement('span');
|
||||
disc.className = 'app-stats-globe-bubble-disc';
|
||||
|
||||
const img = document.createElement('img');
|
||||
img.src = d.url;
|
||||
img.alt = '';
|
||||
img.className = 'app-stats-globe-bubble-img';
|
||||
disc.appendChild(img);
|
||||
|
||||
inner.appendChild(ring);
|
||||
inner.appendChild(disc);
|
||||
outer.appendChild(inner);
|
||||
elementMap.set(d.id, inner);
|
||||
|
||||
requestAnimationFrame(() => {
|
||||
requestAnimationFrame(() => {
|
||||
inner.classList.add('app-stats-globe-bubble--visible');
|
||||
});
|
||||
});
|
||||
|
||||
return outer;
|
||||
}
|
||||
|
||||
// Three.js imports cached after first init
|
||||
let threeModule: any = null;
|
||||
|
||||
async function initGlobe() {
|
||||
if (!containerRef.value) return;
|
||||
|
||||
const { default: Globe } = await import('globe.gl');
|
||||
threeModule = await import('three');
|
||||
|
||||
const container = containerRef.value;
|
||||
const width = container.clientWidth;
|
||||
const height = container.clientHeight;
|
||||
const dark = isDark.value;
|
||||
|
||||
globe = new Globe(container, {
|
||||
rendererConfig: { antialias: true, alpha: true },
|
||||
})
|
||||
.width(width)
|
||||
.height(height)
|
||||
.backgroundColor('rgba(0,0,0,0)')
|
||||
.showAtmosphere(true)
|
||||
.atmosphereColor(
|
||||
dark ? 'rgba(56, 189, 248, 0.2)' : 'rgba(2, 132, 199, 0.1)',
|
||||
)
|
||||
.atmosphereAltitude(0.14)
|
||||
.globeImageUrl(null as any)
|
||||
.htmlElementsData([...events])
|
||||
.htmlElement((d: any) => createAvatarElement(d))
|
||||
.htmlLat((d: any) => d.lat)
|
||||
.htmlLng((d: any) => d.lng)
|
||||
.htmlAltitude(0.015)
|
||||
.htmlTransitionDuration(0)
|
||||
.htmlElementVisibilityModifier((el: HTMLElement, isVisible: boolean) => {
|
||||
const inner = el.firstElementChild as HTMLElement;
|
||||
if (!inner) return;
|
||||
if (isVisible) {
|
||||
inner.classList.remove('app-stats-globe-bubble--hidden');
|
||||
// Scale based on angular distance to camera center
|
||||
const lat = Number(el.dataset.lat);
|
||||
const lng = Number(el.dataset.lng);
|
||||
const pov = globe.pointOfView();
|
||||
const toRad = Math.PI / 180;
|
||||
const dLat = (lat - pov.lat) * toRad;
|
||||
const dLng = (lng - pov.lng) * toRad;
|
||||
const a =
|
||||
Math.sin(dLat / 2) ** 2 +
|
||||
Math.cos(lat * toRad) *
|
||||
Math.cos(pov.lat * toRad) *
|
||||
Math.sin(dLng / 2) ** 2;
|
||||
const angDist = 2 * Math.atan2(Math.sqrt(a), Math.sqrt(1 - a));
|
||||
// 0 = center → scale 1.0, PI/2 = edge → scale 0.55
|
||||
const scale = 0.55 + 0.45 * Math.max(0, 1 - angDist / (Math.PI / 2));
|
||||
inner.style.setProperty('--bubble-scale', scale.toFixed(2));
|
||||
} else {
|
||||
inner.classList.add('app-stats-globe-bubble--hidden');
|
||||
}
|
||||
})
|
||||
.enablePointerInteraction(false);
|
||||
|
||||
// Land polygons
|
||||
if (!countriesGeoJson) {
|
||||
try {
|
||||
const res = await fetch('/ne_110m_land.geojson');
|
||||
countriesGeoJson = await res.json();
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
}
|
||||
|
||||
if (landPoints.length === 0) {
|
||||
landPoints = buildLandPoints(countriesGeoJson);
|
||||
}
|
||||
|
||||
if (countriesGeoJson) {
|
||||
globe
|
||||
.hexPolygonsData(countriesGeoJson.features)
|
||||
.hexPolygonResolution(3)
|
||||
.hexPolygonMargin(0.35)
|
||||
.hexPolygonUseDots(true)
|
||||
.hexPolygonColor(
|
||||
dark ? 'rgba(56, 189, 248, 0.65)' : 'rgba(2, 132, 199, 0.28)',
|
||||
)
|
||||
.hexPolygonAltitude(0.006);
|
||||
}
|
||||
|
||||
// Apply material + lights for current theme
|
||||
applyTheme();
|
||||
|
||||
// Camera
|
||||
globe.pointOfView({ lat: 30, lng: 20, altitude: 1.8 });
|
||||
|
||||
const controls = globe.controls();
|
||||
controls.autoRotate = false;
|
||||
controls.enableZoom = false;
|
||||
controls.enablePan = false;
|
||||
controls.enableRotate = false;
|
||||
|
||||
globe.renderer().domElement.style.cursor = 'default';
|
||||
|
||||
const scene = globe.scene();
|
||||
if (scene) scene.background = null;
|
||||
|
||||
resizeObserver = new ResizeObserver(() => {
|
||||
if (!globe || !containerRef.value) return;
|
||||
const w = containerRef.value.clientWidth;
|
||||
const h = containerRef.value.clientHeight;
|
||||
globe.width(w).height(h);
|
||||
});
|
||||
resizeObserver.observe(container);
|
||||
}
|
||||
|
||||
function applyTheme() {
|
||||
if (!globe || !threeModule) return;
|
||||
const dark = isDark.value;
|
||||
const { MeshPhongMaterial, Color, AmbientLight, DirectionalLight } =
|
||||
threeModule;
|
||||
|
||||
// Update atmosphere
|
||||
globe.atmosphereColor(
|
||||
dark ? 'rgba(56, 189, 248, 0.2)' : 'rgba(2, 132, 199, 0.1)',
|
||||
);
|
||||
|
||||
// Update hex polygon colors: pass string directly, no per-polygon closure needed
|
||||
globe.hexPolygonColor(
|
||||
dark ? 'rgba(56, 189, 248, 0.65)' : 'rgba(2, 132, 199, 0.28)',
|
||||
);
|
||||
|
||||
// Dispose old material before creating a new one to free GPU resources
|
||||
const oldMat = globe.globeMaterial();
|
||||
if (oldMat && typeof oldMat.dispose === 'function') oldMat.dispose();
|
||||
|
||||
const globeMat = new MeshPhongMaterial({
|
||||
color: new Color(dark ? '#2a3050' : '#e4ecf6'),
|
||||
shininess: dark ? 20 : 25,
|
||||
specular: new Color(dark ? '#446688' : '#d0e0f0'),
|
||||
emissive: new Color(dark ? '#283248' : '#d4e2f0'),
|
||||
emissiveIntensity: dark ? 0.6 : 0.35,
|
||||
transparent: true,
|
||||
opacity: dark ? 0.88 : 0.95,
|
||||
});
|
||||
globe.globeMaterial(globeMat);
|
||||
|
||||
// Dispose old lights before creating new ones
|
||||
const oldLights = globe.lights();
|
||||
if (oldLights) oldLights.forEach((l: any) => l.dispose?.());
|
||||
|
||||
const ambient = new AmbientLight(
|
||||
dark ? 0xffffff : 0xf0f4fa,
|
||||
dark ? 1.6 : 1.4,
|
||||
);
|
||||
const dir = new DirectionalLight(
|
||||
dark ? 0x6699cc : 0xf4f8ff,
|
||||
dark ? 1.0 : 0.5,
|
||||
);
|
||||
dir.position.set(5, 3, 5);
|
||||
globe.lights([ambient, dir]);
|
||||
}
|
||||
|
||||
function startEvents() {
|
||||
if (eventInterval) return;
|
||||
if (landPoints.length === 0) {
|
||||
console.warn(
|
||||
'[AppStatsGlobe] No land points available, skipping avatar events.',
|
||||
);
|
||||
return;
|
||||
}
|
||||
for (let i = 0; i < 8; i++) {
|
||||
pendingTimeouts.push(setTimeout(addEvent, i * 250));
|
||||
}
|
||||
eventInterval = setInterval(addEvent, 800);
|
||||
}
|
||||
|
||||
function stopEvents() {
|
||||
if (eventInterval) {
|
||||
clearInterval(eventInterval);
|
||||
eventInterval = null;
|
||||
}
|
||||
pendingTimeouts.forEach(clearTimeout);
|
||||
pendingTimeouts.length = 0;
|
||||
}
|
||||
|
||||
function startRotation() {
|
||||
if (rotationFrame !== null || !globe) return;
|
||||
const pov = globe.pointOfView();
|
||||
let rotLng = pov.lng;
|
||||
const ROT_SPEED = 0.05;
|
||||
const TILT = 15;
|
||||
const BASE_LAT = 35;
|
||||
const TILT_FREQ = 1.3;
|
||||
|
||||
function animateRotation() {
|
||||
rotLng += ROT_SPEED;
|
||||
const lat =
|
||||
BASE_LAT + TILT * Math.sin((rotLng * TILT_FREQ * Math.PI) / 180);
|
||||
globe.pointOfView({ lat, lng: rotLng % 360, altitude: 1.8 }, 0);
|
||||
rotationFrame = requestAnimationFrame(animateRotation);
|
||||
}
|
||||
rotationFrame = requestAnimationFrame(animateRotation);
|
||||
}
|
||||
|
||||
function stopRotation() {
|
||||
if (rotationFrame !== null) {
|
||||
cancelAnimationFrame(rotationFrame);
|
||||
rotationFrame = null;
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
preloadBuffer = createPreloadBuffer(generateAvatarUrl);
|
||||
await initGlobe();
|
||||
|
||||
if (isVisible.value) {
|
||||
startEvents();
|
||||
startRotation();
|
||||
}
|
||||
});
|
||||
|
||||
watch(isVisible, (visible) => {
|
||||
if (visible) {
|
||||
startEvents();
|
||||
startRotation();
|
||||
} else {
|
||||
stopEvents();
|
||||
stopRotation();
|
||||
}
|
||||
});
|
||||
|
||||
watch(isDark, () => {
|
||||
applyTheme();
|
||||
});
|
||||
|
||||
onUnmounted(() => {
|
||||
stopEvents();
|
||||
stopRotation();
|
||||
events = [];
|
||||
eventId = 0;
|
||||
elementMap.clear();
|
||||
preloadBuffer = null;
|
||||
if (globe) {
|
||||
globe._destructor();
|
||||
globe = null;
|
||||
}
|
||||
if (resizeObserver) {
|
||||
resizeObserver.disconnect();
|
||||
resizeObserver = null;
|
||||
}
|
||||
});
|
||||
|
||||
const formattedRate = (r: number) => {
|
||||
if (r >= 1000) return `~${(r / 1000).toFixed(1)}k`;
|
||||
return `~${r.toFixed(0)}`;
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div ref="globeRef" class="app-stats-globe">
|
||||
<div ref="containerRef" class="app-stats-globe-wrap">
|
||||
<div class="app-stats-globe-ring" />
|
||||
</div>
|
||||
<p class="app-stats-globe-caption">
|
||||
{{ formattedRate(props.rate) }} avatars generated every second, worldwide
|
||||
</p>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.app-stats-globe {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
width: 100%;
|
||||
|
||||
&-wrap {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
aspect-ratio: 1;
|
||||
pointer-events: none;
|
||||
overflow: hidden;
|
||||
|
||||
:deep(canvas) {
|
||||
width: 100% !important;
|
||||
height: 100% !important;
|
||||
}
|
||||
}
|
||||
|
||||
&-ring {
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
left: 50%;
|
||||
width: 54.5%;
|
||||
aspect-ratio: 1;
|
||||
transform: translate(-50%, -50%);
|
||||
border-radius: 50%;
|
||||
border: 1.5px solid rgba(2, 132, 199, 0.2);
|
||||
pointer-events: none;
|
||||
z-index: 1;
|
||||
|
||||
.dark & {
|
||||
border-color: rgba(56, 189, 248, 0.3);
|
||||
}
|
||||
}
|
||||
|
||||
&-caption {
|
||||
font-size: 13px;
|
||||
color: var(--vp-c-text-3);
|
||||
text-align: center;
|
||||
line-height: 1.4;
|
||||
margin: 0;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.app-stats-globe {
|
||||
&-wrap {
|
||||
max-width: 320px;
|
||||
margin-inline: auto;
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
<style lang="scss">
|
||||
.app-stats-globe-bubble {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
opacity: 0;
|
||||
transform: scale(0);
|
||||
transition:
|
||||
opacity var(--duration-slow) var(--ease-spring),
|
||||
transform var(--duration-slow) var(--ease-spring);
|
||||
|
||||
&--visible {
|
||||
opacity: 1;
|
||||
transform: scale(var(--bubble-scale, 1));
|
||||
}
|
||||
|
||||
&--hidden {
|
||||
opacity: 0 !important;
|
||||
transition: opacity 0.15s ease !important;
|
||||
}
|
||||
|
||||
&--leaving {
|
||||
opacity: 0 !important;
|
||||
transform: scale(0.3) !important;
|
||||
transition:
|
||||
opacity var(--duration-slow) ease,
|
||||
transform var(--duration-slow) ease !important;
|
||||
}
|
||||
|
||||
&-ring {
|
||||
position: absolute;
|
||||
width: 44px;
|
||||
height: 44px;
|
||||
border-radius: 50%;
|
||||
border: 2px solid var(--vp-c-brand-1);
|
||||
opacity: 0;
|
||||
animation: app-stats-globe-ping 1.4s cubic-bezier(0, 0, 0.2, 1) forwards;
|
||||
}
|
||||
|
||||
&-disc {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 38px;
|
||||
height: 38px;
|
||||
border-radius: 50%;
|
||||
background: #ffffff;
|
||||
border: 2px solid rgba(255, 255, 255, 0.95);
|
||||
box-shadow:
|
||||
0 2px 12px rgba(0, 0, 0, 0.12),
|
||||
0 0 0 1px rgba(0, 0, 0, 0.04);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
&-img {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
border-radius: 50%;
|
||||
display: block;
|
||||
}
|
||||
}
|
||||
|
||||
.dark .app-stats-globe-bubble-disc {
|
||||
background: #1a1c2a;
|
||||
border-color: rgba(56, 189, 248, 0.2);
|
||||
box-shadow:
|
||||
0 2px 12px rgba(0, 0, 0, 0.5),
|
||||
0 0 0 1px rgba(56, 189, 248, 0.1);
|
||||
}
|
||||
|
||||
@keyframes app-stats-globe-ping {
|
||||
0% {
|
||||
opacity: 0.6;
|
||||
transform: scale(0.5);
|
||||
}
|
||||
100% {
|
||||
opacity: 0;
|
||||
transform: scale(2.2);
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.app-stats-globe-bubble {
|
||||
&-disc {
|
||||
width: 30px;
|
||||
height: 30px;
|
||||
}
|
||||
|
||||
&-ring {
|
||||
width: 36px;
|
||||
height: 36px;
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,142 @@
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue';
|
||||
import { Line } from 'vue-chartjs';
|
||||
import {
|
||||
Chart as ChartJS,
|
||||
CategoryScale,
|
||||
LinearScale,
|
||||
PointElement,
|
||||
LineElement,
|
||||
Tooltip,
|
||||
Legend,
|
||||
} from 'chart.js';
|
||||
import { useChartTheme } from '../../composables/useChartTheme';
|
||||
|
||||
ChartJS.register(
|
||||
CategoryScale,
|
||||
LinearScale,
|
||||
PointElement,
|
||||
LineElement,
|
||||
Tooltip,
|
||||
Legend,
|
||||
);
|
||||
|
||||
const props = defineProps<{
|
||||
labels: string[];
|
||||
series: Array<{ name: string; values: number[] }>;
|
||||
formatValue: (value: number) => string;
|
||||
}>();
|
||||
|
||||
// Generate one distinct colour per series so the palette never repeats, even
|
||||
// with all 36+ styles. Golden-angle hue spacing (137.5°) keeps adjacent lines
|
||||
// far apart on the colour wheel; starting near the brand-blue hue keeps the
|
||||
// first (top) series on-brand. Alternating lightness adds extra separation.
|
||||
const palette = computed(() =>
|
||||
props.series.map((_, i) => {
|
||||
const hue = (i * 137.508 + 205) % 360;
|
||||
const lightness = i % 2 === 0 ? 58 : 46;
|
||||
return `hsl(${hue.toFixed(1)} 65% ${lightness}%)`;
|
||||
}),
|
||||
);
|
||||
|
||||
const { chartKey, tooltipConfig, gridColor, tickColor } = useChartTheme();
|
||||
|
||||
const chartData = computed(() => ({
|
||||
labels: props.labels,
|
||||
datasets: props.series.map((s, i) => {
|
||||
const color = palette.value[i];
|
||||
|
||||
return {
|
||||
label: s.name,
|
||||
data: s.values,
|
||||
borderColor: color,
|
||||
backgroundColor: color,
|
||||
borderWidth: 2,
|
||||
fill: false,
|
||||
tension: 0.3,
|
||||
pointRadius: 0,
|
||||
pointHitRadius: 10,
|
||||
pointHoverRadius: 4,
|
||||
pointHoverBackgroundColor: color,
|
||||
};
|
||||
}),
|
||||
}));
|
||||
|
||||
const chartOptions = computed(() => ({
|
||||
responsive: true,
|
||||
maintainAspectRatio: false,
|
||||
interaction: {
|
||||
intersect: false,
|
||||
mode: 'index' as const,
|
||||
},
|
||||
plugins: {
|
||||
legend: {
|
||||
display: true,
|
||||
position: 'bottom' as const,
|
||||
labels: {
|
||||
color: tickColor(),
|
||||
boxWidth: 12,
|
||||
boxHeight: 12,
|
||||
padding: 12,
|
||||
font: { size: 12 },
|
||||
},
|
||||
},
|
||||
tooltip: {
|
||||
...tooltipConfig(),
|
||||
callbacks: {
|
||||
label: (ctx: any) =>
|
||||
`${ctx.dataset.label}: ${props.formatValue(ctx.parsed.y)}`,
|
||||
},
|
||||
},
|
||||
},
|
||||
scales: {
|
||||
x: {
|
||||
grid: {
|
||||
display: false,
|
||||
},
|
||||
ticks: {
|
||||
color: tickColor(),
|
||||
maxRotation: 0,
|
||||
autoSkip: true,
|
||||
maxTicksLimit: 8,
|
||||
font: { size: 12 },
|
||||
},
|
||||
border: {
|
||||
display: false,
|
||||
},
|
||||
},
|
||||
y: {
|
||||
grid: {
|
||||
color: gridColor(),
|
||||
},
|
||||
ticks: {
|
||||
color: tickColor(),
|
||||
callback: (value: any) => props.formatValue(value),
|
||||
font: { size: 12 },
|
||||
},
|
||||
border: {
|
||||
display: false,
|
||||
},
|
||||
},
|
||||
},
|
||||
}));
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="app-stats-multi-line-chart">
|
||||
<Line :key="chartKey" :data="chartData" :options="chartOptions" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.app-stats-multi-line-chart {
|
||||
height: 360px;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
@media (max-width: 640px) {
|
||||
.app-stats-multi-line-chart {
|
||||
height: 280px;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,440 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, onMounted, onUnmounted, watch } from 'vue';
|
||||
import { kebabCase } from 'change-case';
|
||||
import Prando from 'prando';
|
||||
import { ArrowRight, ArrowLeft, Shapes } from '@lucide/vue';
|
||||
import Button from 'primevue/button';
|
||||
import { UiAvatar, UiContainer, UiSection, UiSectionHeader } from '../ui';
|
||||
import { useVisibility } from '../../composables/useVisibility';
|
||||
import { useAvatarStyleList } from '../../composables/avatar';
|
||||
|
||||
const sectionRef = ref();
|
||||
const isVisible = useVisibility(sectionRef, { once: false, threshold: 0.1 });
|
||||
const avatarStyleList = useAvatarStyleList();
|
||||
|
||||
const SHOWCASE_SEEDS = [
|
||||
'Felix',
|
||||
'Aneka',
|
||||
'Milo',
|
||||
'Luna',
|
||||
'Max',
|
||||
'Sophie',
|
||||
'Leo',
|
||||
'Emma',
|
||||
'Noah',
|
||||
'Aria',
|
||||
'Zoe',
|
||||
'Oscar',
|
||||
];
|
||||
|
||||
const showcaseAvatars = computed(() => {
|
||||
const prng = new Prando(42);
|
||||
|
||||
return avatarStyleList.value.map((style) => ({
|
||||
style: kebabCase(style),
|
||||
seed: SHOWCASE_SEEDS[prng.nextInt(0, SHOWCASE_SEEDS.length - 1)],
|
||||
}));
|
||||
});
|
||||
|
||||
const doubledAvatars = computed(() => [
|
||||
...showcaseAvatars.value,
|
||||
...showcaseAvatars.value,
|
||||
]);
|
||||
|
||||
const ITEM_WIDTH = 140;
|
||||
const SCROLL_DURATION_MS = 400;
|
||||
|
||||
const totalWidth = computed(() => showcaseAvatars.value.length * ITEM_WIDTH);
|
||||
|
||||
const trackRef = ref<HTMLElement>();
|
||||
let scrollPosition = 0;
|
||||
let animationFrame: number | null = null;
|
||||
let isPaused = false;
|
||||
|
||||
// Smooth scroll animation state
|
||||
let isManualScrolling = false;
|
||||
let scrollStartPosition = 0;
|
||||
let scrollTargetPosition = 0;
|
||||
let scrollStartTime = 0;
|
||||
|
||||
function easeOutCubic(t: number): number {
|
||||
return 1 - Math.pow(1 - t, 3);
|
||||
}
|
||||
|
||||
function updateTrackTransform() {
|
||||
if (trackRef.value) {
|
||||
trackRef.value.style.transform = `translateX(-${scrollPosition}px)`;
|
||||
}
|
||||
}
|
||||
|
||||
function animate(timestamp: number) {
|
||||
if (!isVisible.value) {
|
||||
animationFrame = null;
|
||||
return;
|
||||
}
|
||||
|
||||
if (isManualScrolling) {
|
||||
const elapsed = timestamp - scrollStartTime;
|
||||
const progress = Math.min(elapsed / SCROLL_DURATION_MS, 1);
|
||||
const easedProgress = easeOutCubic(progress);
|
||||
|
||||
scrollPosition =
|
||||
scrollStartPosition +
|
||||
(scrollTargetPosition - scrollStartPosition) * easedProgress;
|
||||
|
||||
if (progress >= 1) {
|
||||
isManualScrolling = false;
|
||||
// Normalize position after manual scroll
|
||||
if (scrollPosition >= totalWidth.value) {
|
||||
scrollPosition = scrollPosition % totalWidth.value;
|
||||
}
|
||||
if (scrollPosition < 0) {
|
||||
scrollPosition = totalWidth.value + (scrollPosition % totalWidth.value);
|
||||
}
|
||||
}
|
||||
} else if (!isPaused) {
|
||||
scrollPosition += 0.5;
|
||||
if (scrollPosition >= totalWidth.value) {
|
||||
scrollPosition = 0;
|
||||
}
|
||||
}
|
||||
updateTrackTransform();
|
||||
animationFrame = requestAnimationFrame(animate);
|
||||
}
|
||||
|
||||
function startAnimation() {
|
||||
if (animationFrame === null && isVisible.value) {
|
||||
animationFrame = requestAnimationFrame(animate);
|
||||
}
|
||||
}
|
||||
|
||||
function pauseAnimation() {
|
||||
isPaused = true;
|
||||
}
|
||||
|
||||
function resumeAnimation() {
|
||||
isPaused = false;
|
||||
}
|
||||
|
||||
function smoothScroll(delta: number) {
|
||||
scrollStartPosition = scrollPosition;
|
||||
scrollTargetPosition = scrollPosition + delta;
|
||||
scrollStartTime = performance.now();
|
||||
isManualScrolling = true;
|
||||
}
|
||||
|
||||
function scrollLeft() {
|
||||
smoothScroll(-300);
|
||||
}
|
||||
|
||||
function scrollRight() {
|
||||
smoothScroll(300);
|
||||
}
|
||||
|
||||
watch(isVisible, (visible) => {
|
||||
if (visible) {
|
||||
startAnimation();
|
||||
}
|
||||
});
|
||||
|
||||
onMounted(() => {
|
||||
if (isVisible.value) {
|
||||
startAnimation();
|
||||
}
|
||||
});
|
||||
|
||||
onUnmounted(() => {
|
||||
if (animationFrame !== null) {
|
||||
cancelAnimationFrame(animationFrame);
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<UiSection
|
||||
ref="sectionRef"
|
||||
:class="{ visible: isVisible }"
|
||||
no-padding
|
||||
divider
|
||||
>
|
||||
<template #background>
|
||||
<div class="app-style-showcase-gradient"></div>
|
||||
</template>
|
||||
<UiContainer class="app-style-showcase-header">
|
||||
<UiSectionHeader
|
||||
description="From cute characters to abstract patterns, pixel art to professional illustrations. Our avatar library features styles crafted by talented artists and designers."
|
||||
>
|
||||
<template #headline><strong>35+</strong> Unique Avatar Styles</template>
|
||||
</UiSectionHeader>
|
||||
</UiContainer>
|
||||
|
||||
<div class="app-style-showcase-outer">
|
||||
<button
|
||||
class="app-style-showcase-scroll-btn app-style-showcase-scroll-btn-left"
|
||||
@click="scrollLeft"
|
||||
aria-label="Scroll left"
|
||||
>
|
||||
<ArrowLeft />
|
||||
</button>
|
||||
|
||||
<div class="app-style-showcase-wrapper">
|
||||
<div
|
||||
class="app-style-showcase-scroll"
|
||||
@mouseenter="pauseAnimation"
|
||||
@mouseleave="resumeAnimation"
|
||||
@touchstart="pauseAnimation"
|
||||
@touchend="resumeAnimation"
|
||||
>
|
||||
<div ref="trackRef" class="app-style-showcase-track">
|
||||
<a
|
||||
v-for="(avatar, index) in doubledAvatars"
|
||||
:key="`${avatar.style}-${index}`"
|
||||
:href="`/styles/${avatar.style}/`"
|
||||
class="app-style-showcase-item"
|
||||
>
|
||||
<div class="app-style-showcase-avatar">
|
||||
<UiAvatar
|
||||
:style-name="avatar.style"
|
||||
:style-options="{ seed: avatar.seed, size: 120 }"
|
||||
:alt="`${avatar.style} style`"
|
||||
/>
|
||||
<div class="app-style-showcase-avatar-glow"></div>
|
||||
</div>
|
||||
<span class="app-style-showcase-label">{{ avatar.style }}</span>
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button
|
||||
class="app-style-showcase-scroll-btn app-style-showcase-scroll-btn-right"
|
||||
@click="scrollRight"
|
||||
aria-label="Scroll right"
|
||||
>
|
||||
<ArrowRight />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<UiContainer class="app-style-showcase-cta">
|
||||
<Button as="a" href="/styles/" size="large" severity="contrast">
|
||||
<Shapes :size="20" />
|
||||
Browse All Styles
|
||||
<ArrowRight :size="20" />
|
||||
</Button>
|
||||
</UiContainer>
|
||||
</UiSection>
|
||||
</template>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.app-style-showcase {
|
||||
&-gradient {
|
||||
background:
|
||||
radial-gradient(
|
||||
ellipse 60% 80% at 0% 50%,
|
||||
color-mix(in srgb, var(--vp-c-brand-1) 6%, transparent),
|
||||
transparent
|
||||
),
|
||||
radial-gradient(
|
||||
ellipse 60% 80% at 100% 50%,
|
||||
color-mix(in srgb, var(--vp-c-indigo-1) 5%, transparent),
|
||||
transparent
|
||||
);
|
||||
}
|
||||
|
||||
&-header {
|
||||
padding: 0 24px;
|
||||
opacity: 0;
|
||||
transform: translateY(30px);
|
||||
transition: all var(--duration-reveal) var(--ease-smooth);
|
||||
|
||||
.visible & {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
}
|
||||
}
|
||||
|
||||
&-outer {
|
||||
position: relative;
|
||||
padding: 0 60px;
|
||||
}
|
||||
|
||||
&-wrapper {
|
||||
overflow-x: clip;
|
||||
overflow-y: visible;
|
||||
mask-image: linear-gradient(
|
||||
90deg,
|
||||
transparent,
|
||||
black 8%,
|
||||
black 92%,
|
||||
transparent
|
||||
);
|
||||
-webkit-mask-image: linear-gradient(
|
||||
90deg,
|
||||
transparent,
|
||||
black 8%,
|
||||
black 92%,
|
||||
transparent
|
||||
);
|
||||
}
|
||||
|
||||
&-scroll-btn {
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
transform: translateY(-50%);
|
||||
width: 48px;
|
||||
height: 48px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: var(--vp-c-bg);
|
||||
border: 2px solid var(--vp-c-border);
|
||||
border-radius: 50%;
|
||||
cursor: pointer;
|
||||
z-index: 10;
|
||||
transition: all var(--duration-fast) var(--ease-smooth);
|
||||
box-shadow: var(--vp-shadow-2);
|
||||
|
||||
&:hover {
|
||||
border-color: var(--vp-c-brand-1);
|
||||
background: var(--vp-c-brand-soft);
|
||||
|
||||
svg {
|
||||
color: var(--vp-c-brand-1);
|
||||
}
|
||||
}
|
||||
|
||||
svg {
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
color: var(--vp-c-text-2);
|
||||
transition: color var(--duration-fast) ease;
|
||||
}
|
||||
|
||||
&-left {
|
||||
left: 24px;
|
||||
}
|
||||
|
||||
&-right {
|
||||
right: 24px;
|
||||
}
|
||||
}
|
||||
|
||||
&-scroll {
|
||||
overflow: visible;
|
||||
padding: 30px 0;
|
||||
margin: -30px 0;
|
||||
}
|
||||
|
||||
&-track {
|
||||
display: flex;
|
||||
gap: 24px;
|
||||
width: max-content;
|
||||
will-change: transform;
|
||||
transform: translateZ(0);
|
||||
}
|
||||
|
||||
&-item {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
text-decoration: none;
|
||||
transition: transform var(--duration-mid) var(--ease-spring);
|
||||
|
||||
&:hover {
|
||||
transform: translateY(-4px) scale(1.02);
|
||||
|
||||
.app-style-showcase-avatar {
|
||||
box-shadow:
|
||||
var(--vp-shadow-5),
|
||||
0 0 40px -10px var(--vp-c-brand-1);
|
||||
}
|
||||
|
||||
.app-style-showcase-avatar-glow {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.app-style-showcase-label {
|
||||
color: var(--vp-c-brand-1);
|
||||
}
|
||||
}
|
||||
|
||||
&::after {
|
||||
display: none !important;
|
||||
}
|
||||
}
|
||||
|
||||
&-avatar {
|
||||
width: 116px;
|
||||
height: 116px;
|
||||
border-radius: var(--vp-radius-xl);
|
||||
overflow: hidden;
|
||||
background: var(--vp-c-bg-soft);
|
||||
box-shadow: var(--vp-shadow-3);
|
||||
transition: all var(--duration-mid) var(--ease-smooth);
|
||||
position: relative;
|
||||
|
||||
img {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
}
|
||||
|
||||
&-glow {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
background: linear-gradient(
|
||||
135deg,
|
||||
transparent 40%,
|
||||
rgba(2, 132, 199, 0.2)
|
||||
);
|
||||
opacity: 0;
|
||||
transition: opacity var(--duration-mid) ease;
|
||||
}
|
||||
}
|
||||
|
||||
&-label {
|
||||
font-size: 13px;
|
||||
color: var(--vp-c-text-3);
|
||||
font-weight: 500;
|
||||
max-width: 116px;
|
||||
text-align: center;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
transition: color var(--duration-mid) ease;
|
||||
}
|
||||
|
||||
&-cta {
|
||||
text-align: center;
|
||||
margin-top: 40px;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.app-style-showcase {
|
||||
&-outer {
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
&-scroll-btn {
|
||||
display: none;
|
||||
}
|
||||
|
||||
&-avatar {
|
||||
width: 88px;
|
||||
height: 88px;
|
||||
border-radius: var(--vp-radius-md);
|
||||
}
|
||||
|
||||
&-track {
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
&-label {
|
||||
max-width: 88px;
|
||||
font-size: 11px;
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,599 @@
|
||||
<script setup lang="ts">
|
||||
import { ref } from 'vue';
|
||||
import { UiContainer, UiSection, UiSectionHeader } from '../ui';
|
||||
import AppUseCasesCard from './AppUseCasesCard.vue';
|
||||
import { getAvatarApiUrl } from '@theme/utils/avatar/api';
|
||||
import { PALETTE, type Pastel } from '@theme/utils/palette';
|
||||
import { useVisibility } from '../../composables/useVisibility';
|
||||
|
||||
const sectionRef = ref();
|
||||
const isVisible = useVisibility(sectionRef, { threshold: 0.15 });
|
||||
|
||||
function avatar(
|
||||
styleName: string,
|
||||
seed: string,
|
||||
size: number,
|
||||
background?: Pastel,
|
||||
): string {
|
||||
return getAvatarApiUrl(styleName, {
|
||||
seed,
|
||||
size,
|
||||
...(background ? { backgroundColor: background } : {}),
|
||||
});
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<UiSection ref="sectionRef" :class="{ visible: isVisible }" divider>
|
||||
<template #background>
|
||||
<div class="app-use-cases-gradient-top"></div>
|
||||
<div class="app-use-cases-gradient-bottom"></div>
|
||||
</template>
|
||||
<UiContainer>
|
||||
<UiSectionHeader
|
||||
class="app-use-cases-header"
|
||||
description="From two-person side projects to global platforms, DiceBear powers the avatar layer wherever identity needs a face."
|
||||
>
|
||||
<template #headline
|
||||
>Built for <strong>every</strong> application.</template
|
||||
>
|
||||
</UiSectionHeader>
|
||||
|
||||
<div class="app-use-cases-grid">
|
||||
<AppUseCasesCard
|
||||
title="User Profiles"
|
||||
description="Give every user a unique avatar from day one. No upload, no Gravatar fallback, no awkward grey silhouette."
|
||||
>
|
||||
<div class="uc-stack">
|
||||
<img
|
||||
:src="avatar('lorelei', 'Mia', 96, PALETTE.rose)"
|
||||
alt=""
|
||||
width="80"
|
||||
height="80"
|
||||
loading="lazy"
|
||||
decoding="async"
|
||||
/>
|
||||
<img
|
||||
:src="avatar('notionists', 'Bee', 96, PALETTE.amber)"
|
||||
alt=""
|
||||
width="80"
|
||||
height="80"
|
||||
loading="lazy"
|
||||
decoding="async"
|
||||
/>
|
||||
<img
|
||||
:src="avatar('adventurer', 'Theo', 96, PALETTE.cyan)"
|
||||
alt=""
|
||||
width="80"
|
||||
height="80"
|
||||
loading="lazy"
|
||||
decoding="async"
|
||||
/>
|
||||
<img
|
||||
:src="avatar('personas', 'Sage', 96, PALETTE.peach)"
|
||||
alt=""
|
||||
width="80"
|
||||
height="80"
|
||||
loading="lazy"
|
||||
decoding="async"
|
||||
/>
|
||||
</div>
|
||||
</AppUseCasesCard>
|
||||
|
||||
<AppUseCasesCard
|
||||
title="Chat Applications"
|
||||
description="Instantly recognisable participants. Deterministic from a user ID, so the same person looks the same on every device."
|
||||
>
|
||||
<div class="uc-bubbles">
|
||||
<div class="uc-bubble uc-bubble-light uc-bubble-pink">
|
||||
<img
|
||||
:src="avatar('fun-emoji', 'Hi', 48, PALETTE.pink)"
|
||||
alt=""
|
||||
width="24"
|
||||
height="24"
|
||||
loading="lazy"
|
||||
decoding="async"
|
||||
/>
|
||||
<span>Hey 👋</span>
|
||||
</div>
|
||||
<div class="uc-bubble uc-bubble-dark">
|
||||
<span>How's the launch?</span>
|
||||
<img
|
||||
:src="avatar('lorelei', 'Otis', 48, PALETTE.amber)"
|
||||
alt=""
|
||||
width="24"
|
||||
height="24"
|
||||
loading="lazy"
|
||||
decoding="async"
|
||||
/>
|
||||
</div>
|
||||
<div class="uc-bubble uc-bubble-light uc-bubble-green">
|
||||
<img
|
||||
:src="avatar('big-smile', 'Ari', 48, PALETTE.green)"
|
||||
alt=""
|
||||
width="24"
|
||||
height="24"
|
||||
loading="lazy"
|
||||
decoding="async"
|
||||
/>
|
||||
<span>Going great!</span>
|
||||
</div>
|
||||
</div>
|
||||
</AppUseCasesCard>
|
||||
|
||||
<AppUseCasesCard
|
||||
title="Gaming"
|
||||
description="Generate millions of unique player identities, NPCs and bots. The same seed gives the same character every session."
|
||||
>
|
||||
<div class="uc-roster">
|
||||
<div class="uc-player">
|
||||
<img
|
||||
:src="avatar('bottts', 'Zap', 160)"
|
||||
alt=""
|
||||
width="160"
|
||||
height="160"
|
||||
loading="lazy"
|
||||
decoding="async"
|
||||
/>
|
||||
<span class="uc-rank">LV 47</span>
|
||||
</div>
|
||||
<div class="uc-player">
|
||||
<img
|
||||
:src="avatar('pixel-art', 'Nyx', 160)"
|
||||
alt=""
|
||||
width="160"
|
||||
height="160"
|
||||
loading="lazy"
|
||||
decoding="async"
|
||||
/>
|
||||
<span class="uc-rank">LV 32</span>
|
||||
</div>
|
||||
<div class="uc-player">
|
||||
<img
|
||||
:src="avatar('bottts-neutral', 'Rex', 160)"
|
||||
alt=""
|
||||
width="160"
|
||||
height="160"
|
||||
loading="lazy"
|
||||
decoding="async"
|
||||
/>
|
||||
<span class="uc-rank">LV 88</span>
|
||||
</div>
|
||||
</div>
|
||||
</AppUseCasesCard>
|
||||
|
||||
<AppUseCasesCard
|
||||
title="Forums & Communities"
|
||||
description="Distinct identities help build community trust. New users feel welcome with a face from the very first post."
|
||||
>
|
||||
<div class="uc-thread">
|
||||
<div class="uc-post">
|
||||
<img
|
||||
:src="avatar('notionists', 'Posy', 56, PALETTE.lime)"
|
||||
alt=""
|
||||
width="28"
|
||||
height="28"
|
||||
loading="lazy"
|
||||
decoding="async"
|
||||
/>
|
||||
<div>
|
||||
<strong>posy</strong>
|
||||
<em>Anyone tried the new beta?</em>
|
||||
</div>
|
||||
</div>
|
||||
<div class="uc-post">
|
||||
<img
|
||||
:src="avatar('lorelei', 'Ren', 56, PALETTE.cyan)"
|
||||
alt=""
|
||||
width="28"
|
||||
height="28"
|
||||
loading="lazy"
|
||||
decoding="async"
|
||||
/>
|
||||
<div>
|
||||
<strong>ren</strong>
|
||||
<em>Yep, runs 2× faster.</em>
|
||||
</div>
|
||||
</div>
|
||||
<div class="uc-post">
|
||||
<img
|
||||
:src="avatar('open-peeps', 'Ivo', 56, PALETTE.amber)"
|
||||
alt=""
|
||||
width="28"
|
||||
height="28"
|
||||
loading="lazy"
|
||||
decoding="async"
|
||||
/>
|
||||
<div>
|
||||
<strong>ivo</strong>
|
||||
<em>Submitted a PR for the bug.</em>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</AppUseCasesCard>
|
||||
|
||||
<AppUseCasesCard
|
||||
title="Team Tools"
|
||||
description="Visual distinction for team members in collaborative apps. In cursors, mentions, and sidebars, every face stays consistent."
|
||||
>
|
||||
<div class="uc-team">
|
||||
<div class="uc-pill">
|
||||
<img
|
||||
:src="avatar('personas', 'Lex', 52)"
|
||||
alt=""
|
||||
width="26"
|
||||
height="26"
|
||||
loading="lazy"
|
||||
decoding="async"
|
||||
/>
|
||||
<span>Lex Hart</span>
|
||||
<em>Design</em>
|
||||
</div>
|
||||
<div class="uc-pill">
|
||||
<img
|
||||
:src="avatar('personas', 'Mae', 52)"
|
||||
alt=""
|
||||
width="26"
|
||||
height="26"
|
||||
loading="lazy"
|
||||
decoding="async"
|
||||
/>
|
||||
<span>Mae Park</span>
|
||||
<em>Engineering</em>
|
||||
</div>
|
||||
<div class="uc-pill">
|
||||
<img
|
||||
:src="avatar('personas', 'Sam', 52)"
|
||||
alt=""
|
||||
width="26"
|
||||
height="26"
|
||||
loading="lazy"
|
||||
decoding="async"
|
||||
/>
|
||||
<span>Sam Ito</span>
|
||||
<em>Product</em>
|
||||
</div>
|
||||
<div class="uc-pill">
|
||||
<img
|
||||
:src="avatar('personas', 'Avery', 52)"
|
||||
alt=""
|
||||
width="26"
|
||||
height="26"
|
||||
loading="lazy"
|
||||
decoding="async"
|
||||
/>
|
||||
<span>Avery Lin</span>
|
||||
<em>Marketing</em>
|
||||
</div>
|
||||
</div>
|
||||
</AppUseCasesCard>
|
||||
|
||||
<AppUseCasesCard
|
||||
title="Placeholder Images"
|
||||
description="Beautiful default profile pictures while users set up their account, and a graceful fallback when uploads fail."
|
||||
>
|
||||
<div class="uc-grid-mini">
|
||||
<img
|
||||
:src="avatar('shapes', 'A', 160, PALETTE.rose)"
|
||||
alt=""
|
||||
width="160"
|
||||
height="160"
|
||||
loading="lazy"
|
||||
decoding="async"
|
||||
/>
|
||||
<img
|
||||
:src="avatar('shapes', 'B', 160, PALETTE.amber)"
|
||||
alt=""
|
||||
width="160"
|
||||
height="160"
|
||||
loading="lazy"
|
||||
decoding="async"
|
||||
/>
|
||||
<img
|
||||
:src="avatar('shapes', 'C', 160, PALETTE.green)"
|
||||
alt=""
|
||||
width="160"
|
||||
height="160"
|
||||
loading="lazy"
|
||||
decoding="async"
|
||||
/>
|
||||
<img
|
||||
:src="avatar('shapes', 'D', 160, PALETTE.cyan)"
|
||||
alt=""
|
||||
width="160"
|
||||
height="160"
|
||||
loading="lazy"
|
||||
decoding="async"
|
||||
/>
|
||||
<img
|
||||
:src="avatar('shapes', 'E', 160, PALETTE.blue)"
|
||||
alt=""
|
||||
width="160"
|
||||
height="160"
|
||||
loading="lazy"
|
||||
decoding="async"
|
||||
/>
|
||||
<img
|
||||
:src="avatar('shapes', 'F', 160, PALETTE.peach)"
|
||||
alt=""
|
||||
width="160"
|
||||
height="160"
|
||||
loading="lazy"
|
||||
decoding="async"
|
||||
/>
|
||||
</div>
|
||||
</AppUseCasesCard>
|
||||
</div>
|
||||
</UiContainer>
|
||||
</UiSection>
|
||||
</template>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.app-use-cases {
|
||||
&-gradient-top {
|
||||
top: 0;
|
||||
bottom: auto;
|
||||
height: 50%;
|
||||
background: radial-gradient(
|
||||
ellipse 100% 100% at 20% 0%,
|
||||
color-mix(in srgb, var(--vp-c-brand-1) 7%, transparent),
|
||||
transparent
|
||||
);
|
||||
}
|
||||
|
||||
&-gradient-bottom {
|
||||
top: auto;
|
||||
bottom: 0;
|
||||
height: 50%;
|
||||
background: radial-gradient(
|
||||
ellipse 100% 100% at 80% 100%,
|
||||
color-mix(in srgb, var(--vp-c-brand-1) 6%, transparent),
|
||||
transparent
|
||||
);
|
||||
}
|
||||
|
||||
&-header {
|
||||
opacity: 0;
|
||||
transform: translateY(30px);
|
||||
transition: all var(--duration-reveal) var(--ease-smooth);
|
||||
|
||||
.visible & {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
}
|
||||
}
|
||||
|
||||
&-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, 1fr);
|
||||
gap: 18px;
|
||||
}
|
||||
}
|
||||
|
||||
.uc-stack {
|
||||
position: relative;
|
||||
height: 100px;
|
||||
margin-top: 4px;
|
||||
|
||||
img {
|
||||
position: absolute;
|
||||
width: 80px;
|
||||
height: 80px;
|
||||
border-radius: 22px;
|
||||
box-shadow: 0 6px 14px rgba(15, 23, 42, 0.1);
|
||||
transition: transform var(--duration-mid) ease;
|
||||
|
||||
&:nth-child(1) {
|
||||
left: 0;
|
||||
top: 6px;
|
||||
transform: rotate(-7deg);
|
||||
}
|
||||
&:nth-child(2) {
|
||||
left: 60px;
|
||||
top: 0;
|
||||
transform: rotate(3deg);
|
||||
}
|
||||
&:nth-child(3) {
|
||||
left: 120px;
|
||||
top: 8px;
|
||||
transform: rotate(-3deg);
|
||||
}
|
||||
&:nth-child(4) {
|
||||
left: 180px;
|
||||
top: 2px;
|
||||
transform: rotate(6deg);
|
||||
}
|
||||
}
|
||||
|
||||
.uc-card:hover & img:nth-child(1) {
|
||||
transform: rotate(-12deg) translateY(-4px);
|
||||
}
|
||||
|
||||
.uc-card:hover & img:nth-child(4) {
|
||||
transform: rotate(11deg) translateY(-4px);
|
||||
}
|
||||
}
|
||||
|
||||
.uc-bubbles {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.uc-bubble {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 8px 12px 8px 8px;
|
||||
border-radius: 18px;
|
||||
max-width: 80%;
|
||||
box-shadow: 0 3px 10px rgba(15, 23, 42, 0.06);
|
||||
|
||||
&-pink,
|
||||
&-green {
|
||||
align-self: flex-start;
|
||||
color: #18181b;
|
||||
}
|
||||
|
||||
&-pink {
|
||||
background: #ffe4f1;
|
||||
}
|
||||
&-green {
|
||||
background: #dcfce7;
|
||||
}
|
||||
|
||||
&-dark {
|
||||
align-self: flex-end;
|
||||
padding: 8px 8px 8px 12px;
|
||||
background: #18181b;
|
||||
color: #fafafa;
|
||||
}
|
||||
|
||||
:global(.dark .uc-bubble-dark) {
|
||||
background: #fafafa;
|
||||
color: #18181b;
|
||||
}
|
||||
|
||||
img {
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
border-radius: 50%;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
span {
|
||||
font-size: 12.5px;
|
||||
font-weight: 600;
|
||||
}
|
||||
}
|
||||
|
||||
.uc-roster {
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.uc-player {
|
||||
position: relative;
|
||||
flex: 1;
|
||||
aspect-ratio: 1;
|
||||
border-radius: 18px;
|
||||
overflow: hidden;
|
||||
background: linear-gradient(180deg, #1e293b, #0f172a);
|
||||
|
||||
img {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
}
|
||||
}
|
||||
|
||||
.uc-rank {
|
||||
position: absolute;
|
||||
left: 6px;
|
||||
bottom: 6px;
|
||||
background: #facc15;
|
||||
color: #422006;
|
||||
font-family: var(--vp-font-family-mono);
|
||||
font-size: 10px;
|
||||
font-weight: 800;
|
||||
padding: 3px 6px;
|
||||
border-radius: 5px;
|
||||
letter-spacing: 0.3px;
|
||||
}
|
||||
|
||||
.uc-thread {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.uc-post {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
padding: 8px 10px;
|
||||
background: var(--vp-c-bg-soft);
|
||||
border: 1px solid var(--vp-c-divider);
|
||||
border-radius: 14px;
|
||||
|
||||
img {
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
border-radius: 50%;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
strong {
|
||||
display: block;
|
||||
font-size: 12.5px;
|
||||
font-weight: 700;
|
||||
color: var(--vp-c-text-1);
|
||||
}
|
||||
|
||||
em {
|
||||
font-size: 11.5px;
|
||||
font-style: normal;
|
||||
color: var(--vp-c-text-2);
|
||||
}
|
||||
}
|
||||
|
||||
.uc-team {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.uc-pill {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
padding: 7px 12px 7px 7px;
|
||||
background: var(--vp-c-bg-soft);
|
||||
border: 1px solid var(--vp-c-divider);
|
||||
border-radius: 999px;
|
||||
|
||||
img {
|
||||
width: 26px;
|
||||
height: 26px;
|
||||
border-radius: 50%;
|
||||
}
|
||||
|
||||
span {
|
||||
font-size: 13px;
|
||||
font-weight: 700;
|
||||
color: var(--vp-c-text-1);
|
||||
}
|
||||
|
||||
em {
|
||||
margin-left: auto;
|
||||
font-style: normal;
|
||||
font-size: 11px;
|
||||
color: var(--vp-c-text-2);
|
||||
font-family: var(--vp-font-family-mono);
|
||||
}
|
||||
}
|
||||
|
||||
.uc-grid-mini {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, 1fr);
|
||||
gap: 6px;
|
||||
|
||||
img {
|
||||
width: 100%;
|
||||
aspect-ratio: 1;
|
||||
border-radius: 10px;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 900px) {
|
||||
.app-use-cases-grid {
|
||||
grid-template-columns: repeat(2, 1fr);
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 600px) {
|
||||
.app-use-cases-grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,58 @@
|
||||
<script setup lang="ts">
|
||||
import { UiCard } from '../ui';
|
||||
|
||||
defineProps<{
|
||||
title: string;
|
||||
description: string;
|
||||
}>();
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<UiCard padding="xl" class="uc-card">
|
||||
<div class="uc-card-visual">
|
||||
<slot />
|
||||
</div>
|
||||
<h3 class="uc-card-title">{{ title }}</h3>
|
||||
<p class="uc-card-text">{{ description }}</p>
|
||||
</UiCard>
|
||||
</template>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.uc-card {
|
||||
min-height: 340px;
|
||||
|
||||
:deep(.ui-card-body) {
|
||||
height: 100%;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
&-visual {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
min-height: 0;
|
||||
margin-bottom: 24px;
|
||||
|
||||
> * {
|
||||
width: 100%;
|
||||
}
|
||||
}
|
||||
|
||||
&-title {
|
||||
font-size: 22px;
|
||||
font-weight: 800;
|
||||
letter-spacing: -0.02em;
|
||||
color: var(--vp-c-text-1);
|
||||
margin: 0 0 8px;
|
||||
}
|
||||
|
||||
&-text {
|
||||
font-size: 14.5px;
|
||||
line-height: 1.5;
|
||||
color: var(--vp-c-text-2);
|
||||
margin: 0;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,221 @@
|
||||
<script setup lang="ts">
|
||||
import { useData } from 'vitepress';
|
||||
import { Check, X } from '@lucide/vue';
|
||||
import type { ThemeOptions } from '@theme/types';
|
||||
import {
|
||||
buildComparisonRows,
|
||||
comparisonServices,
|
||||
} from '@theme/config/comparison';
|
||||
|
||||
const { theme } = useData<ThemeOptions>();
|
||||
|
||||
const rows = buildComparisonRows({
|
||||
stars: theme.value.githubStars ?? {},
|
||||
styleCount: Object.keys(theme.value.avatarStyles ?? {}).length,
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="docs-comparison">
|
||||
<div class="docs-comparison-wrapper">
|
||||
<table class="docs-comparison-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th class="docs-comparison-feature-col">Feature</th>
|
||||
<th
|
||||
v-for="(service, index) in comparisonServices"
|
||||
:key="service.key"
|
||||
:class="{ 'docs-comparison-highlight-col': index === 0 }"
|
||||
>
|
||||
<a
|
||||
:href="service.url"
|
||||
target="_blank"
|
||||
rel="noopener"
|
||||
class="docs-comparison-service-link"
|
||||
>{{ service.name }}</a
|
||||
>
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-for="(row, rowIndex) in rows" :key="rowIndex">
|
||||
<td class="docs-comparison-feature-col">{{ row.feature }}</td>
|
||||
<td
|
||||
v-for="(service, colIndex) in comparisonServices"
|
||||
:key="service.key"
|
||||
:class="{ 'docs-comparison-highlight-col': colIndex === 0 }"
|
||||
>
|
||||
<span
|
||||
v-if="row.values[service.key] === 'yes'"
|
||||
class="docs-comparison-cell-yes"
|
||||
>
|
||||
<Check :size="18" />
|
||||
</span>
|
||||
<span
|
||||
v-else-if="row.values[service.key] === 'free'"
|
||||
class="docs-comparison-cell-free"
|
||||
>
|
||||
Free
|
||||
</span>
|
||||
<span
|
||||
v-else-if="row.values[service.key] === 'paid'"
|
||||
class="docs-comparison-cell-paid"
|
||||
>
|
||||
Paid
|
||||
</span>
|
||||
<span
|
||||
v-else-if="row.values[service.key] === 'no'"
|
||||
class="docs-comparison-cell-no"
|
||||
>
|
||||
<X :size="18" />
|
||||
</span>
|
||||
<span v-else class="docs-comparison-cell-text">{{
|
||||
row.values[service.key]
|
||||
}}</span>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.docs-comparison {
|
||||
margin: 24px 0;
|
||||
border: 1px solid var(--vp-c-border);
|
||||
border-radius: var(--vp-radius-md, 12px);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.docs-comparison-wrapper {
|
||||
overflow-x: auto;
|
||||
-webkit-overflow-scrolling: touch;
|
||||
}
|
||||
|
||||
// This table renders inside `.vp-doc` content, which ships its own table
|
||||
// styling (block display, full borders, zebra rows). The extra
|
||||
// `.docs-comparison` prefix lifts specificity above those rules so the custom
|
||||
// look survives without `!important`.
|
||||
.docs-comparison .docs-comparison-table {
|
||||
display: table;
|
||||
width: 100%;
|
||||
min-width: 700px;
|
||||
margin: 0;
|
||||
border-collapse: collapse;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.docs-comparison .docs-comparison-table th,
|
||||
.docs-comparison .docs-comparison-table td {
|
||||
padding: 12px 16px;
|
||||
text-align: center;
|
||||
border: none;
|
||||
border-bottom: 1px solid var(--vp-c-border);
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.docs-comparison .docs-comparison-table tr {
|
||||
background: transparent;
|
||||
border-top: none;
|
||||
}
|
||||
|
||||
.docs-comparison .docs-comparison-table thead th {
|
||||
font-weight: 700;
|
||||
font-size: 13px;
|
||||
color: var(--vp-c-text-2);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.05em;
|
||||
}
|
||||
|
||||
.docs-comparison .docs-comparison-table tbody tr:last-child td {
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
.docs-comparison .docs-comparison-table thead .docs-comparison-highlight-col {
|
||||
color: var(--vp-c-brand-1);
|
||||
}
|
||||
|
||||
.docs-comparison .docs-comparison-table .docs-comparison-feature-col {
|
||||
position: sticky;
|
||||
left: 0;
|
||||
z-index: 1;
|
||||
min-width: 160px;
|
||||
text-align: left;
|
||||
font-weight: 600;
|
||||
color: var(--vp-c-text-1);
|
||||
background: var(--vp-c-bg);
|
||||
}
|
||||
|
||||
.docs-comparison .docs-comparison-table .docs-comparison-highlight-col {
|
||||
background: color-mix(in srgb, var(--vp-c-brand-1) 5%, transparent);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.docs-comparison-service-link {
|
||||
color: inherit;
|
||||
text-decoration: none;
|
||||
transition: color var(--duration-fast) ease;
|
||||
}
|
||||
|
||||
.docs-comparison-service-link::after {
|
||||
display: none !important;
|
||||
}
|
||||
|
||||
.docs-comparison-service-link:hover {
|
||||
color: var(--vp-c-brand-1);
|
||||
}
|
||||
|
||||
.docs-comparison-cell-yes {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
border-radius: 50%;
|
||||
background: var(--vp-c-green-soft);
|
||||
color: var(--vp-c-green-1);
|
||||
}
|
||||
|
||||
.docs-comparison-cell-free {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
height: 28px;
|
||||
padding: 0 12px;
|
||||
border-radius: var(--vp-radius-sm);
|
||||
background: var(--vp-c-green-soft);
|
||||
color: var(--vp-c-green-1);
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.docs-comparison-cell-paid {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
height: 28px;
|
||||
padding: 0 12px;
|
||||
border-radius: var(--vp-radius-sm);
|
||||
background: color-mix(in srgb, var(--vp-c-text-3) 10%, transparent);
|
||||
color: var(--vp-c-text-2);
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.docs-comparison-cell-no {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
border-radius: 50%;
|
||||
background: color-mix(in srgb, var(--vp-c-text-3) 15%, transparent);
|
||||
color: var(--vp-c-text-3);
|
||||
}
|
||||
|
||||
.docs-comparison-cell-text {
|
||||
font-size: 13px;
|
||||
color: var(--vp-c-text-2);
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,82 @@
|
||||
<script setup lang="ts">
|
||||
import { UiCard } from '../ui';
|
||||
|
||||
withDefaults(
|
||||
defineProps<{
|
||||
items: Array<{ title: string; description: string; badge?: string }>;
|
||||
columns?: 2 | 3;
|
||||
}>(),
|
||||
{
|
||||
columns: 3,
|
||||
},
|
||||
);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="docs-grid" :class="`docs-grid-cols-${columns}`">
|
||||
<UiCard v-for="item in items" :key="item.title" class="docs-grid-card">
|
||||
<div class="docs-grid-header">
|
||||
<span class="docs-grid-title">{{ item.title }}</span>
|
||||
<Badge v-if="item.badge" type="tip">{{ item.badge }}</Badge>
|
||||
</div>
|
||||
<p class="docs-grid-desc">{{ item.description }}</p>
|
||||
</UiCard>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.docs-grid {
|
||||
display: grid;
|
||||
gap: 12px;
|
||||
margin: 24px 0;
|
||||
|
||||
&-cols-2 {
|
||||
grid-template-columns: repeat(2, 1fr);
|
||||
}
|
||||
|
||||
&-cols-3 {
|
||||
grid-template-columns: repeat(3, 1fr);
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
&-cols-3 {
|
||||
grid-template-columns: repeat(2, 1fr);
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 640px) {
|
||||
&-cols-2,
|
||||
&-cols-3 {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
|
||||
&-card {
|
||||
:deep(.ui-card-body) {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
}
|
||||
}
|
||||
|
||||
&-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
&-title {
|
||||
font-size: 15px;
|
||||
font-weight: 700;
|
||||
color: var(--vp-c-text-1);
|
||||
}
|
||||
|
||||
&-desc {
|
||||
font-size: 14px;
|
||||
color: var(--vp-c-text-2);
|
||||
margin: 0;
|
||||
line-height: 1.7;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,75 @@
|
||||
<script setup lang="ts">
|
||||
import type { Component } from 'vue';
|
||||
import { UiCard, UiIconBox } from '../ui';
|
||||
|
||||
defineProps<{
|
||||
highlights: Array<{
|
||||
icon: Component;
|
||||
title: string;
|
||||
description: string;
|
||||
color: string;
|
||||
link?: string;
|
||||
}>;
|
||||
}>();
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="docs-highlights">
|
||||
<UiCard
|
||||
v-for="highlight in highlights"
|
||||
:key="highlight.title"
|
||||
:href="highlight.link"
|
||||
padding="xl"
|
||||
class="docs-highlights-card"
|
||||
>
|
||||
<UiIconBox
|
||||
size="md"
|
||||
:color="highlight.color"
|
||||
class="docs-highlights-icon"
|
||||
>
|
||||
<component :is="highlight.icon" />
|
||||
</UiIconBox>
|
||||
<h3 class="docs-highlights-title">{{ highlight.title }}</h3>
|
||||
<p class="docs-highlights-desc">{{ highlight.description }}</p>
|
||||
</UiCard>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.docs-highlights {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, 1fr);
|
||||
gap: 16px;
|
||||
margin: 24px 0;
|
||||
|
||||
@media (max-width: 640px) {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
@media (max-width: 640px) {
|
||||
&-card {
|
||||
--ui-card-padding: 24px;
|
||||
}
|
||||
}
|
||||
|
||||
&-icon {
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
&-title {
|
||||
font-size: 15px;
|
||||
font-weight: 700;
|
||||
color: var(--vp-c-text-1);
|
||||
margin: 0 0 8px;
|
||||
border: none;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
&-desc {
|
||||
font-size: 14px;
|
||||
color: var(--vp-c-text-2);
|
||||
margin: 0;
|
||||
line-height: 1.7;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,145 @@
|
||||
<script setup lang="ts">
|
||||
import { UiAvatar } from '../ui';
|
||||
import { ArrowRight } from '@lucide/vue';
|
||||
|
||||
withDefaults(
|
||||
defineProps<{
|
||||
styles: Array<{
|
||||
name: string;
|
||||
styleName: string;
|
||||
link: string;
|
||||
bestFor: string;
|
||||
}>;
|
||||
seeds?: string[];
|
||||
avatarSize?: number;
|
||||
allStylesLink?: string;
|
||||
allStylesLabel?: string;
|
||||
}>(),
|
||||
{
|
||||
seeds: () => ['Felix', 'Emma', 'Leo', 'Mia'],
|
||||
avatarSize: 48,
|
||||
allStylesLink: '/styles/',
|
||||
allStylesLabel: 'Browse all 35+ avatar styles',
|
||||
},
|
||||
);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="docs-style-grid">
|
||||
<a
|
||||
v-for="style in styles"
|
||||
:key="style.styleName"
|
||||
:href="style.link"
|
||||
class="docs-style-grid-card"
|
||||
>
|
||||
<div class="docs-style-grid-avatars">
|
||||
<UiAvatar
|
||||
v-for="seed in seeds"
|
||||
:key="seed"
|
||||
:size="avatarSize"
|
||||
:style-name="style.styleName"
|
||||
:style-options="{ seed, size: avatarSize, borderRadius: 50 }"
|
||||
:alt="style.name"
|
||||
/>
|
||||
</div>
|
||||
<div class="docs-style-grid-info">
|
||||
<span class="docs-style-grid-name">
|
||||
{{ style.name }}
|
||||
<ArrowRight :size="14" class="docs-style-grid-arrow" />
|
||||
</span>
|
||||
<span class="docs-style-grid-desc">{{ style.bestFor }}</span>
|
||||
</div>
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<a :href="allStylesLink" class="docs-style-grid-all">
|
||||
<span>{{ allStylesLabel }}</span>
|
||||
<ArrowRight :size="16" />
|
||||
</a>
|
||||
</template>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.docs-style-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(260px, 1fr));
|
||||
gap: 12px;
|
||||
margin: 24px 0;
|
||||
|
||||
&-card {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
padding: 16px;
|
||||
border: 1px solid var(--vp-c-divider);
|
||||
border-radius: var(--vp-radius-sm);
|
||||
text-decoration: none;
|
||||
transition:
|
||||
border-color 0.2s ease,
|
||||
box-shadow 0.2s ease;
|
||||
|
||||
&:hover {
|
||||
border-color: var(--vp-c-brand-1);
|
||||
box-shadow: 0 4px 16px rgba(0, 0, 0, 0.06);
|
||||
|
||||
.docs-style-grid-arrow {
|
||||
transform: translateX(3px);
|
||||
}
|
||||
}
|
||||
|
||||
&::after {
|
||||
display: none !important;
|
||||
}
|
||||
}
|
||||
|
||||
&-avatars {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
&-info {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
&-name {
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
color: var(--vp-c-text-1);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
&-arrow {
|
||||
color: var(--vp-c-brand-1);
|
||||
transition: transform var(--duration-fast) ease;
|
||||
}
|
||||
|
||||
&-desc {
|
||||
font-size: 13px;
|
||||
color: var(--vp-c-text-2);
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
&-all {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
margin-top: 4px;
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
color: var(--vp-c-brand-1);
|
||||
text-decoration: none;
|
||||
transition: gap var(--duration-fast) ease;
|
||||
|
||||
&:hover {
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
&::after {
|
||||
display: none !important;
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,106 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, ref } from 'vue';
|
||||
import { useData } from 'vitepress';
|
||||
import { capitalCase } from 'change-case';
|
||||
import { ThemeOptions } from '@theme/types';
|
||||
|
||||
const { theme } = useData<ThemeOptions>();
|
||||
|
||||
type SortColumn = 'title' | 'value';
|
||||
type SortDirection = 'asc' | 'desc';
|
||||
|
||||
const sortColumn = ref<SortColumn>('title');
|
||||
const sortDirection = ref<SortDirection>('asc');
|
||||
|
||||
const rows = computed(() => {
|
||||
const entries = Object.entries(theme.value.avatarUniqueCounts).map(
|
||||
([key, entry]) => ({
|
||||
key,
|
||||
title: capitalCase(key),
|
||||
display: entry.display,
|
||||
log10: entry.log10,
|
||||
}),
|
||||
);
|
||||
|
||||
const direction = sortDirection.value === 'asc' ? 1 : -1;
|
||||
|
||||
return entries.toSorted((a, b) => {
|
||||
if (sortColumn.value === 'value') {
|
||||
return (a.log10 - b.log10) * direction;
|
||||
}
|
||||
|
||||
return a.title.localeCompare(b.title) * direction;
|
||||
});
|
||||
});
|
||||
|
||||
function toggleSort(column: SortColumn): void {
|
||||
if (sortColumn.value === column) {
|
||||
sortDirection.value = sortDirection.value === 'asc' ? 'desc' : 'asc';
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
sortColumn.value = column;
|
||||
sortDirection.value = 'asc';
|
||||
}
|
||||
|
||||
function sortIndicator(column: SortColumn): string {
|
||||
if (sortColumn.value !== column) {
|
||||
return '';
|
||||
}
|
||||
|
||||
return sortDirection.value === 'asc' ? ' ↑' : ' ↓';
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th
|
||||
:aria-sort="
|
||||
sortColumn === 'title'
|
||||
? sortDirection === 'asc'
|
||||
? 'ascending'
|
||||
: 'descending'
|
||||
: 'none'
|
||||
"
|
||||
class="sortable"
|
||||
@click="toggleSort('title')"
|
||||
>
|
||||
Style{{ sortIndicator('title') }}
|
||||
</th>
|
||||
<th
|
||||
:aria-sort="
|
||||
sortColumn === 'value'
|
||||
? sortDirection === 'asc'
|
||||
? 'ascending'
|
||||
: 'descending'
|
||||
: 'none'
|
||||
"
|
||||
class="sortable numeric"
|
||||
@click="toggleSort('value')"
|
||||
>
|
||||
Unique Avatars{{ sortIndicator('value') }}
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-for="row in rows" :key="row.key">
|
||||
<td>{{ row.title }}</td>
|
||||
<td class="numeric">{{ row.display }}</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.numeric {
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
.sortable {
|
||||
cursor: pointer;
|
||||
user-select: none;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,14 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted } from 'vue';
|
||||
|
||||
const show = ref(false);
|
||||
|
||||
onMounted(() => {
|
||||
show.value = true;
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<slot v-if="show" />
|
||||
<slot v-else name="fallback" />
|
||||
</template>
|
||||
@@ -0,0 +1,317 @@
|
||||
<script setup lang="ts">
|
||||
import { useLayout } from 'vitepress/theme-without-fonts';
|
||||
import LayoutFooterBrand from './LayoutFooterBrand.vue';
|
||||
import LayoutFooterLinks from './LayoutFooterLinks.vue';
|
||||
import LayoutFooterSponsor from './LayoutFooterSponsor.vue';
|
||||
import LayoutFooterCredits from './LayoutFooterCredits.vue';
|
||||
|
||||
const { hasSidebar } = useLayout();
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<footer
|
||||
class="layout-footer"
|
||||
:class="{ 'layout-footer-has-sidebar': hasSidebar }"
|
||||
>
|
||||
<div class="layout-footer-divider"></div>
|
||||
|
||||
<div class="layout-footer-main">
|
||||
<div class="layout-footer-container">
|
||||
<LayoutFooterBrand />
|
||||
<LayoutFooterLinks />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<LayoutFooterSponsor />
|
||||
<LayoutFooterCredits />
|
||||
</footer>
|
||||
</template>
|
||||
|
||||
<style lang="scss">
|
||||
:root {
|
||||
--layout-footer-logo-light-display: inline-block;
|
||||
--layout-footer-logo-dark-display: none;
|
||||
--layout-footer-sponsor-logo-light-display: none;
|
||||
--layout-footer-sponsor-logo-dark-display: inline-block;
|
||||
}
|
||||
.dark {
|
||||
--layout-footer-logo-light-display: none;
|
||||
--layout-footer-logo-dark-display: inline-block;
|
||||
--layout-footer-sponsor-logo-light-display: inline-block;
|
||||
--layout-footer-sponsor-logo-dark-display: none;
|
||||
}
|
||||
|
||||
.layout-footer {
|
||||
position: relative;
|
||||
background: var(--vp-c-bg);
|
||||
|
||||
&-divider {
|
||||
height: 1px;
|
||||
max-width: 800px;
|
||||
margin: 0 auto;
|
||||
background: linear-gradient(
|
||||
90deg,
|
||||
transparent,
|
||||
var(--vp-c-divider),
|
||||
transparent
|
||||
);
|
||||
}
|
||||
|
||||
&-container {
|
||||
max-width: 1440px;
|
||||
margin: 0 auto;
|
||||
padding: 0 32px;
|
||||
|
||||
@media (min-width: 960px) {
|
||||
.layout-footer-has-sidebar & {
|
||||
padding-left: calc(var(--vp-sidebar-width) + 32px);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
&-main {
|
||||
padding: 64px 0 48px;
|
||||
|
||||
.layout-footer-container {
|
||||
display: flex;
|
||||
gap: 64px;
|
||||
}
|
||||
}
|
||||
|
||||
&-brand {
|
||||
flex-shrink: 0;
|
||||
max-width: 260px;
|
||||
}
|
||||
|
||||
&-logo {
|
||||
display: inline-block;
|
||||
margin-bottom: 16px;
|
||||
|
||||
img {
|
||||
/* The width/height attributes only provide the aspect ratio for
|
||||
* pre-load layout; without width: auto they would also act as a
|
||||
* presentational hint and fix the width at the intrinsic 183px. */
|
||||
height: 24px;
|
||||
width: auto;
|
||||
}
|
||||
|
||||
&-light {
|
||||
display: var(--layout-footer-logo-light-display);
|
||||
}
|
||||
|
||||
&-dark {
|
||||
display: var(--layout-footer-logo-dark-display);
|
||||
}
|
||||
}
|
||||
|
||||
&-tagline {
|
||||
font-size: 14px;
|
||||
line-height: 1.6;
|
||||
color: var(--vp-c-text-2);
|
||||
margin: 0 0 20px;
|
||||
}
|
||||
|
||||
&-social {
|
||||
display: flex;
|
||||
gap: 4px;
|
||||
|
||||
&-link {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 38px;
|
||||
height: 38px;
|
||||
border-radius: var(--vp-radius-sm);
|
||||
color: var(--vp-c-text-2);
|
||||
transition: all var(--duration-mid) var(--ease-spring);
|
||||
|
||||
&:hover {
|
||||
color: var(--vp-c-brand-1);
|
||||
background: var(--vp-c-brand-soft);
|
||||
transform: translateY(-2px);
|
||||
}
|
||||
|
||||
svg {
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
&-links {
|
||||
display: flex;
|
||||
gap: 64px;
|
||||
flex: 1;
|
||||
justify-content: flex-end;
|
||||
}
|
||||
|
||||
&-column {
|
||||
&-title {
|
||||
font-size: 13px;
|
||||
font-weight: 700;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.08em;
|
||||
color: var(--vp-c-text-1);
|
||||
margin: 0 0 16px;
|
||||
}
|
||||
|
||||
&-list {
|
||||
list-style: none;
|
||||
padding: 0;
|
||||
margin: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
}
|
||||
}
|
||||
|
||||
&-link {
|
||||
font-size: 14px;
|
||||
color: var(--vp-c-text-2);
|
||||
text-decoration: none;
|
||||
transition: color var(--duration-fast) ease;
|
||||
|
||||
&:hover {
|
||||
color: var(--vp-c-brand-1);
|
||||
}
|
||||
|
||||
&::after {
|
||||
display: none !important;
|
||||
}
|
||||
}
|
||||
|
||||
&-sponsor {
|
||||
padding: 14px 0;
|
||||
border-top: 1px solid var(--vp-c-divider);
|
||||
|
||||
.layout-footer-container {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
&-inner {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
&-logo {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
line-height: 0;
|
||||
|
||||
&::after {
|
||||
display: none !important;
|
||||
}
|
||||
|
||||
img {
|
||||
height: 52px;
|
||||
width: auto;
|
||||
}
|
||||
|
||||
&-light {
|
||||
display: var(--layout-footer-sponsor-logo-light-display);
|
||||
}
|
||||
|
||||
&-dark {
|
||||
display: var(--layout-footer-sponsor-logo-dark-display);
|
||||
}
|
||||
}
|
||||
|
||||
&-meta {
|
||||
font-size: 11px;
|
||||
color: var(--vp-c-text-3);
|
||||
}
|
||||
}
|
||||
|
||||
&-bottom {
|
||||
padding: 24px 0;
|
||||
border-top: 1px solid var(--vp-c-divider);
|
||||
|
||||
&-inner {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 16px;
|
||||
}
|
||||
}
|
||||
|
||||
&-attributions {
|
||||
margin: 0;
|
||||
font-size: 12px;
|
||||
color: var(--vp-c-text-3);
|
||||
line-height: 1.7;
|
||||
}
|
||||
|
||||
&-attribution-link {
|
||||
color: var(--vp-c-text-2);
|
||||
text-decoration: none;
|
||||
border-bottom: 1px dashed var(--vp-c-text-3);
|
||||
transition: all var(--duration-fast) ease;
|
||||
|
||||
&:hover {
|
||||
color: var(--vp-c-brand-1);
|
||||
border-bottom-color: var(--vp-c-brand-1);
|
||||
}
|
||||
|
||||
&::after {
|
||||
display: none !important;
|
||||
}
|
||||
}
|
||||
|
||||
@media (min-width: 960px) {
|
||||
&-has-sidebar .layout-footer-main .layout-footer-container {
|
||||
flex-direction: column;
|
||||
gap: 40px;
|
||||
}
|
||||
|
||||
&-has-sidebar .layout-footer-brand {
|
||||
max-width: 100%;
|
||||
}
|
||||
|
||||
&-has-sidebar .layout-footer-links {
|
||||
justify-content: flex-start;
|
||||
gap: 40px;
|
||||
}
|
||||
}
|
||||
|
||||
@media (min-width: 1340px) {
|
||||
&-has-sidebar .layout-footer-main .layout-footer-container {
|
||||
flex-direction: row;
|
||||
gap: 64px;
|
||||
}
|
||||
|
||||
&-has-sidebar .layout-footer-brand {
|
||||
max-width: 260px;
|
||||
}
|
||||
|
||||
&-has-sidebar .layout-footer-links {
|
||||
justify-content: flex-end;
|
||||
gap: 64px;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.layout-footer-main .layout-footer-container {
|
||||
flex-direction: column;
|
||||
gap: 40px;
|
||||
}
|
||||
|
||||
.layout-footer-brand {
|
||||
max-width: 100%;
|
||||
}
|
||||
|
||||
.layout-footer-links {
|
||||
justify-content: flex-start;
|
||||
gap: 40px;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 480px) {
|
||||
.layout-footer-links {
|
||||
flex-direction: column;
|
||||
gap: 32px;
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,51 @@
|
||||
<script setup lang="ts">
|
||||
import { withBase } from 'vitepress';
|
||||
import { siGithub, siFigma } from 'simple-icons';
|
||||
import { UiIcon } from '../ui';
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="layout-footer-brand">
|
||||
<a href="/" class="layout-footer-logo">
|
||||
<img
|
||||
class="layout-footer-logo-light"
|
||||
:src="withBase('/logo.svg')"
|
||||
alt="DiceBear"
|
||||
width="183"
|
||||
height="32"
|
||||
/>
|
||||
<img
|
||||
class="layout-footer-logo-dark"
|
||||
:src="withBase('/logo-dark.svg')"
|
||||
alt="DiceBear"
|
||||
width="183"
|
||||
height="32"
|
||||
/>
|
||||
</a>
|
||||
<p class="layout-footer-tagline">
|
||||
Open source SVG avatar library and avatar API for designers and
|
||||
developers.
|
||||
</p>
|
||||
|
||||
<div class="layout-footer-social">
|
||||
<a
|
||||
href="https://github.com/dicebear/dicebear"
|
||||
target="_blank"
|
||||
rel="me noopener"
|
||||
aria-label="GitHub"
|
||||
class="layout-footer-social-link"
|
||||
>
|
||||
<UiIcon :path="siGithub.path" />
|
||||
</a>
|
||||
<a
|
||||
href="https://www.figma.com/@dicebear_com"
|
||||
target="_blank"
|
||||
rel="me noopener"
|
||||
aria-label="Figma"
|
||||
class="layout-footer-social-link"
|
||||
>
|
||||
<UiIcon :path="siFigma.path" />
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,68 @@
|
||||
<script setup lang="ts">
|
||||
import { useData } from 'vitepress';
|
||||
import { computed } from 'vue';
|
||||
import type { AvatarStyleMeta, ThemeOptions } from '@theme/types';
|
||||
import { safeHttpUrl } from '@theme/utils/url';
|
||||
|
||||
const { theme } = useData<ThemeOptions>();
|
||||
|
||||
const styles = computed(() => {
|
||||
const result: AvatarStyleMeta[] = [];
|
||||
const knownWork: string[] = [];
|
||||
|
||||
for (const val of Object.values(theme.value.avatarStyles)) {
|
||||
if (
|
||||
val.meta.creator === 'Florian Körner' ||
|
||||
val.meta.creator === 'DiceBear'
|
||||
) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (val.meta.source) {
|
||||
if (knownWork.includes(val.meta.source)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
knownWork.push(val.meta.source);
|
||||
}
|
||||
|
||||
result.push(val.meta);
|
||||
}
|
||||
|
||||
return result;
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="layout-footer-bottom">
|
||||
<div class="layout-footer-container">
|
||||
<div class="layout-footer-bottom-inner">
|
||||
<p class="layout-footer-attributions">
|
||||
<template v-for="style in styles" :key="style.source">
|
||||
<a
|
||||
v-if="safeHttpUrl(style.source)"
|
||||
class="layout-footer-attribution-link"
|
||||
:href="safeHttpUrl(style.source)"
|
||||
target="_blank"
|
||||
rel="noopener"
|
||||
>{{ style.title }}</a
|
||||
>
|
||||
<template v-else>{{ style.title }}</template>
|
||||
by {{ style.creator }} /
|
||||
<a
|
||||
v-if="safeHttpUrl(style.license?.url)"
|
||||
class="layout-footer-attribution-link"
|
||||
:href="safeHttpUrl(style.license?.url)"
|
||||
target="_blank"
|
||||
rel="noopener"
|
||||
>{{ style.license?.name }}</a
|
||||
>
|
||||
<template v-else>{{ style.license?.name }}</template
|
||||
>.
|
||||
</template>
|
||||
— All avatars are remixes of the original works.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,37 @@
|
||||
<script setup lang="ts">
|
||||
import {
|
||||
productLinks,
|
||||
resourceLinks,
|
||||
legalLinks,
|
||||
} from '../../config/footer-links';
|
||||
|
||||
const columns = [
|
||||
{ title: 'Explore', links: productLinks },
|
||||
{ title: 'Resources', links: resourceLinks },
|
||||
{ title: 'Legal', links: legalLinks },
|
||||
] as const;
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="layout-footer-links">
|
||||
<div
|
||||
v-for="column in columns"
|
||||
:key="column.title"
|
||||
class="layout-footer-column"
|
||||
>
|
||||
<h3 class="layout-footer-column-title">{{ column.title }}</h3>
|
||||
<ul class="layout-footer-column-list">
|
||||
<li v-for="link in column.links" :key="link.label">
|
||||
<a
|
||||
:href="link.href"
|
||||
:target="link.external ? '_blank' : undefined"
|
||||
:rel="link.external ? 'noopener noreferrer' : undefined"
|
||||
class="layout-footer-link"
|
||||
>
|
||||
{{ link.label }}
|
||||
</a>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,36 @@
|
||||
<script setup lang="ts">
|
||||
import { withBase } from 'vitepress';
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="layout-footer-sponsor">
|
||||
<div class="layout-footer-container">
|
||||
<div class="layout-footer-sponsor-inner">
|
||||
<a
|
||||
href="https://bunny.net/"
|
||||
target="_blank"
|
||||
rel="noopener sponsored"
|
||||
class="layout-footer-sponsor-logo"
|
||||
>
|
||||
<img
|
||||
class="layout-footer-sponsor-logo-light"
|
||||
:src="withBase('/sponsors/bunny-light.svg')"
|
||||
alt="bunny.net"
|
||||
width="149"
|
||||
height="43"
|
||||
/>
|
||||
<img
|
||||
class="layout-footer-sponsor-logo-dark"
|
||||
:src="withBase('/sponsors/bunny-dark.svg')"
|
||||
alt="bunny.net"
|
||||
width="149"
|
||||
height="43"
|
||||
/>
|
||||
</a>
|
||||
<span class="layout-footer-sponsor-meta"
|
||||
>CDN sponsored by bunny.net · Advertisement</span
|
||||
>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,173 @@
|
||||
<script setup lang="ts">
|
||||
import { useData } from 'vitepress';
|
||||
import { computed } from 'vue';
|
||||
import { Search, Sun, Moon } from '@lucide/vue';
|
||||
import { siGithub } from 'simple-icons';
|
||||
import UiIcon from '@theme/components/ui/UiIcon.vue';
|
||||
import type { ThemeOptions } from '@theme/types';
|
||||
|
||||
const { isDark, theme } = useData<ThemeOptions>();
|
||||
|
||||
const starCount = computed(() => {
|
||||
return theme.value.githubStars?.['dicebear/dicebear'] ?? '';
|
||||
});
|
||||
|
||||
function toggleTheme() {
|
||||
isDark.value = !isDark.value;
|
||||
}
|
||||
|
||||
function openSearch() {
|
||||
window.dispatchEvent(
|
||||
new KeyboardEvent('keydown', { key: 'k', metaKey: true }),
|
||||
);
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="layout-nav-actions">
|
||||
<button
|
||||
class="layout-nav-actions-search"
|
||||
aria-label="Search"
|
||||
@click="openSearch"
|
||||
>
|
||||
<Search :size="16" :stroke-width="2.5" />
|
||||
</button>
|
||||
<button
|
||||
class="layout-nav-actions-theme"
|
||||
:aria-label="isDark ? 'Switch to light mode' : 'Switch to dark mode'"
|
||||
@click="toggleTheme"
|
||||
>
|
||||
<Sun v-show="isDark" :size="16" :stroke-width="2.5" />
|
||||
<Moon v-show="!isDark" :size="16" :stroke-width="2.5" />
|
||||
</button>
|
||||
<a
|
||||
href="https://github.com/dicebear/dicebear"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
class="layout-nav-actions-star"
|
||||
aria-label="Star DiceBear on GitHub"
|
||||
>
|
||||
<UiIcon :path="siGithub.path" :size="16" />
|
||||
<span class="layout-nav-actions-star-label">Star</span>
|
||||
<span v-if="starCount" class="layout-nav-actions-star-count">{{
|
||||
starCount
|
||||
}}</span>
|
||||
</a>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.layout-nav-actions {
|
||||
--star-accent: #eab308;
|
||||
--star-accent-soft: rgba(234, 179, 8, 0.12);
|
||||
--star-accent-border: rgba(234, 179, 8, 0.4);
|
||||
--star-accent-hover: rgba(234, 179, 8, 0.2);
|
||||
--star-accent-text: #a16207;
|
||||
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
margin-left: 16px;
|
||||
}
|
||||
|
||||
:root.dark .layout-nav-actions {
|
||||
--star-accent: #facc15;
|
||||
--star-accent-soft: rgba(250, 204, 21, 0.1);
|
||||
--star-accent-border: rgba(250, 204, 21, 0.35);
|
||||
--star-accent-hover: rgba(250, 204, 21, 0.16);
|
||||
--star-accent-text: #facc15;
|
||||
}
|
||||
|
||||
.layout-nav-actions-star {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 7px;
|
||||
padding: 6px 12px;
|
||||
border-radius: var(--vp-radius-xs);
|
||||
border: 1px solid var(--star-accent-border);
|
||||
background: var(--star-accent-soft);
|
||||
color: var(--vp-c-text-1);
|
||||
font-family: var(--vp-font-family-heading);
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
line-height: 1;
|
||||
text-decoration: none;
|
||||
white-space: nowrap;
|
||||
transition:
|
||||
border-color var(--duration-fast) var(--ease-smooth),
|
||||
background var(--duration-fast) var(--ease-smooth),
|
||||
box-shadow var(--duration-fast) var(--ease-smooth);
|
||||
|
||||
&:hover {
|
||||
border-color: var(--star-accent);
|
||||
background: var(--star-accent-hover);
|
||||
box-shadow: 0 0 0 3px var(--star-accent-soft);
|
||||
}
|
||||
}
|
||||
|
||||
.layout-nav-actions-star-label {
|
||||
display: inline-block;
|
||||
}
|
||||
|
||||
.layout-nav-actions-star-count {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
padding: 1px 6px;
|
||||
margin-left: 1px;
|
||||
border-radius: var(--vp-radius-xs);
|
||||
background: var(--star-accent-hover);
|
||||
color: var(--star-accent-text);
|
||||
font-size: 12px;
|
||||
font-weight: 700;
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
.layout-nav-actions-search,
|
||||
.layout-nav-actions-theme {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
border-radius: var(--vp-radius-xs);
|
||||
border: 1px solid var(--p-form-field-border-color, var(--vp-c-divider));
|
||||
background: var(--vp-c-bg);
|
||||
color: var(--ui-c-text-muted);
|
||||
cursor: pointer;
|
||||
transition:
|
||||
border-color var(--duration-fast) var(--ease-smooth),
|
||||
color var(--duration-fast) var(--ease-smooth),
|
||||
background var(--duration-fast) var(--ease-smooth),
|
||||
box-shadow var(--duration-fast) var(--ease-smooth);
|
||||
|
||||
:deep(svg) {
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
&:hover {
|
||||
border-color: var(--vp-c-brand-1);
|
||||
color: var(--vp-c-brand-1);
|
||||
background: var(--vp-c-brand-soft);
|
||||
box-shadow: 0 0 0 3px var(--vp-c-brand-soft);
|
||||
}
|
||||
}
|
||||
|
||||
/* Hide "Star" label on tighter desktop layouts where the full nav would overflow */
|
||||
@media (max-width: 959px) {
|
||||
.layout-nav-actions-star-label {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
/* Below VitePress' nav breakpoint (768px) the desktop menu collapses into the
|
||||
hamburger — align our own mobile-only rules with that same boundary. */
|
||||
@media (max-width: 767px) {
|
||||
.layout-nav-actions-theme {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.layout-nav-actions {
|
||||
margin-left: 8px;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,110 @@
|
||||
<script setup lang="ts">
|
||||
import { useData } from 'vitepress';
|
||||
import { useLayout } from 'vitepress/theme-without-fonts';
|
||||
import VPIconAlignLeft from 'vitepress/dist/client/theme-default/components/icons/VPIconAlignLeft.vue';
|
||||
|
||||
defineProps<{
|
||||
open: boolean;
|
||||
}>();
|
||||
|
||||
defineEmits<{
|
||||
(e: 'open-menu'): void;
|
||||
}>();
|
||||
|
||||
const { theme, frontmatter } = useData();
|
||||
const { hasSidebar } = useLayout();
|
||||
|
||||
function scrollToTop() {
|
||||
window.scrollTo({ top: 0, left: 0, behavior: 'smooth' });
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div v-if="hasSidebar" class="layout-vp-local-nav">
|
||||
<button
|
||||
class="layout-vp-local-nav-menu"
|
||||
:aria-expanded="open"
|
||||
aria-controls="VPSidebarNav"
|
||||
@click="$emit('open-menu')"
|
||||
>
|
||||
<VPIconAlignLeft class="layout-vp-local-nav-menu-icon" />
|
||||
<span class="layout-vp-local-nav-menu-text">
|
||||
{{ frontmatter.sidebarMenuLabel || theme.sidebarMenuLabel || 'Menu' }}
|
||||
</span>
|
||||
</button>
|
||||
|
||||
<a class="layout-vp-local-nav-top-link" href="#" @click="scrollToTop">
|
||||
{{ theme.returnToTopLabel || 'Return to top' }}
|
||||
</a>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.layout-vp-local-nav {
|
||||
position: sticky;
|
||||
top: 0;
|
||||
/*rtl:ignore*/
|
||||
left: 0;
|
||||
z-index: var(--vp-z-index-local-nav);
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
border-bottom: 1px solid var(--vp-c-gutter);
|
||||
padding-top: var(--vp-layout-top-height, 0px);
|
||||
width: 100%;
|
||||
background-color: var(--vp-local-nav-bg-color);
|
||||
transition:
|
||||
border-color 0.5s,
|
||||
background-color 0.5s;
|
||||
|
||||
@media (min-width: 960px) {
|
||||
display: none;
|
||||
}
|
||||
|
||||
&-menu {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding: 12px 24px 11px;
|
||||
line-height: 24px;
|
||||
font-size: 12px;
|
||||
font-weight: 500;
|
||||
color: var(--vp-c-text-2);
|
||||
transition: color var(--duration-slow);
|
||||
|
||||
&:hover {
|
||||
color: var(--vp-c-text-1);
|
||||
transition: color var(--duration-fast);
|
||||
}
|
||||
|
||||
@media (min-width: 768px) {
|
||||
padding: 0 32px;
|
||||
}
|
||||
|
||||
&-icon {
|
||||
margin-right: 8px;
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
fill: currentColor;
|
||||
}
|
||||
}
|
||||
|
||||
&-top-link {
|
||||
display: block;
|
||||
padding: 12px 24px 11px;
|
||||
line-height: 24px;
|
||||
font-size: 12px;
|
||||
font-weight: 500;
|
||||
color: var(--vp-c-text-2);
|
||||
transition: color var(--duration-slow);
|
||||
|
||||
&:hover {
|
||||
color: var(--vp-c-text-1);
|
||||
transition: color var(--duration-fast);
|
||||
}
|
||||
|
||||
@media (min-width: 768px) {
|
||||
padding: 12px 32px 11px;
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,21 @@
|
||||
<script setup lang="ts">
|
||||
import AppHero from '../app/AppHero.vue';
|
||||
import AppSeedDemo from '../app/AppSeedDemo.vue';
|
||||
import AppStyleShowcase from '../app/AppStyleShowcase.vue';
|
||||
import AppEditor from '../app/AppEditor.vue';
|
||||
import AppCreateStyle from '../app/AppCreateStyle.vue';
|
||||
import AppOpenSource from '../app/AppOpenSource.vue';
|
||||
import AppUseCases from '../app/AppUseCases.vue';
|
||||
import AppStatsBanner from '../app/AppStatsBanner.vue';
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<AppHero />
|
||||
<AppSeedDemo />
|
||||
<AppStyleShowcase />
|
||||
<AppUseCases />
|
||||
<AppStatsBanner />
|
||||
<AppEditor />
|
||||
<AppCreateStyle />
|
||||
<AppOpenSource />
|
||||
</template>
|
||||
@@ -0,0 +1,78 @@
|
||||
<script setup lang="ts">
|
||||
import { useData } from 'vitepress';
|
||||
import type { ThemeOptions } from '@theme/types';
|
||||
import { kebabCase } from 'change-case';
|
||||
import { safeHttpUrl } from '@theme/utils/url';
|
||||
|
||||
const { theme } = useData<ThemeOptions>();
|
||||
const styles = theme.value.avatarStyles;
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<table v-for="(style, styleName) in styles" :key="styleName">
|
||||
<colgroup>
|
||||
<col width="20%" />
|
||||
<col />
|
||||
</colgroup>
|
||||
<thead>
|
||||
<tr>
|
||||
<th colspan="2" align="left">
|
||||
<a :href="`/styles/${kebabCase(styleName)}/`">{{ styleName }}</a>
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-if="style.meta.title">
|
||||
<td>Title</td>
|
||||
<td>{{ style.meta.title }}</td>
|
||||
</tr>
|
||||
<tr v-if="style.meta.creator">
|
||||
<td>Creator</td>
|
||||
<td>{{ style.meta.creator }}</td>
|
||||
</tr>
|
||||
<tr v-if="style.meta.source">
|
||||
<td>Source</td>
|
||||
<td>
|
||||
<a
|
||||
v-if="safeHttpUrl(style.meta.source)"
|
||||
:href="safeHttpUrl(style.meta.source)"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>{{ style.meta.source }}</a
|
||||
>
|
||||
<template v-else>{{ style.meta.source }}</template>
|
||||
</td>
|
||||
</tr>
|
||||
<tr v-if="style.meta.homepage">
|
||||
<td>Homepage</td>
|
||||
<td>
|
||||
<a
|
||||
v-if="safeHttpUrl(style.meta.homepage)"
|
||||
:href="safeHttpUrl(style.meta.homepage)"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>{{ style.meta.homepage }}</a
|
||||
>
|
||||
<template v-else>{{ style.meta.homepage }}</template>
|
||||
</td>
|
||||
</tr>
|
||||
<tr v-if="style.meta.license?.name">
|
||||
<td>License Name</td>
|
||||
<td>{{ style.meta.license?.name }}</td>
|
||||
</tr>
|
||||
<tr v-if="style.meta.license?.url">
|
||||
<td>License URL</td>
|
||||
<td>
|
||||
<a
|
||||
v-if="safeHttpUrl(style.meta.license?.url)"
|
||||
:href="safeHttpUrl(style.meta.license?.url)"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>{{ style.meta.license?.url }}</a
|
||||
>
|
||||
<template v-else>{{ style.meta.license?.url }}</template>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</template>
|
||||
@@ -0,0 +1,566 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, ref } from 'vue';
|
||||
import { UiSection, UiContainer, UiSectionHeader, UiCard } from '../ui';
|
||||
import AppSmallHero from '../app/AppSmallHero.vue';
|
||||
import AppStatsChart from '../app/AppStatsChart.vue';
|
||||
import AppStatsMultiLineChart from '../app/AppStatsMultiLineChart.vue';
|
||||
import AppStatsGlobe from '../app/AppStatsGlobe.vue';
|
||||
import {
|
||||
useApiStatsRaw,
|
||||
lastCompleteMonth,
|
||||
} from '../../composables/useApiStats';
|
||||
import { formatNumber, formatBytes } from '../../utils/format';
|
||||
|
||||
const SECONDS_PER_DAY = 86400;
|
||||
const ROLLING_WINDOW_DAYS = 7;
|
||||
const TOP_STYLES = 10;
|
||||
|
||||
const stats = useApiStatsRaw();
|
||||
|
||||
function fmtDay(d: Date): string {
|
||||
return d.toLocaleDateString('en', { month: 'short', day: 'numeric' });
|
||||
}
|
||||
|
||||
function weekStartKey(dateStr: string): string {
|
||||
const d = new Date(`${dateStr}T00:00:00Z`);
|
||||
const dow = d.getUTCDay();
|
||||
const offset = (dow + 6) % 7;
|
||||
|
||||
d.setUTCDate(d.getUTCDate() - offset);
|
||||
|
||||
return d.toISOString().slice(0, 10);
|
||||
}
|
||||
|
||||
function fmtWeekRange(weekStartStr: string): string {
|
||||
const start = new Date(weekStartStr);
|
||||
const end = new Date(start.getTime() + 6 * SECONDS_PER_DAY * 1000);
|
||||
|
||||
if (start.getMonth() === end.getMonth()) {
|
||||
return `${fmtDay(start)}–${end.getDate()}`;
|
||||
}
|
||||
|
||||
return `${fmtDay(start)} – ${fmtDay(end)}`;
|
||||
}
|
||||
|
||||
function completeWeeks(dayKeys: string[], weekOrder: string[]): string[] {
|
||||
if (weekOrder.length === 0) {
|
||||
return weekOrder;
|
||||
}
|
||||
|
||||
const firstDow = new Date(`${dayKeys[0]}T00:00:00Z`).getUTCDay();
|
||||
const lastDow = new Date(
|
||||
`${dayKeys[dayKeys.length - 1]}T00:00:00Z`,
|
||||
).getUTCDay();
|
||||
const start = firstDow !== 1 ? 1 : 0;
|
||||
const end = lastDow !== 0 ? weekOrder.length - 1 : weekOrder.length;
|
||||
|
||||
return weekOrder.slice(start, end);
|
||||
}
|
||||
|
||||
function aggregateWeekly(data: Record<string, number>): {
|
||||
labels: string[];
|
||||
values: number[];
|
||||
} {
|
||||
const dayKeys = Object.keys(data).sort();
|
||||
|
||||
if (dayKeys.length === 0) {
|
||||
return { labels: [], values: [] };
|
||||
}
|
||||
|
||||
const sums: Record<string, number> = {};
|
||||
const weekOrder: string[] = [];
|
||||
|
||||
for (const dayKey of dayKeys) {
|
||||
const wk = weekStartKey(dayKey);
|
||||
|
||||
if (sums[wk] === undefined) {
|
||||
sums[wk] = 0;
|
||||
weekOrder.push(wk);
|
||||
}
|
||||
|
||||
sums[wk] += data[dayKey];
|
||||
}
|
||||
|
||||
const weeks = completeWeeks(dayKeys, weekOrder);
|
||||
|
||||
return {
|
||||
labels: weeks.map((k) => fmtWeekRange(k)),
|
||||
values: weeks.map((week) => sums[week]),
|
||||
};
|
||||
}
|
||||
|
||||
const requestsData = computed(() => {
|
||||
if (!stats.value) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return aggregateWeekly(stats.value.requests);
|
||||
});
|
||||
|
||||
const downloadsData = computed(() => {
|
||||
if (!stats.value) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return aggregateWeekly(stats.value.downloads.npm);
|
||||
});
|
||||
|
||||
function buildSeries(source: Record<string, [string, number][]>): {
|
||||
labels: string[];
|
||||
series: Array<{ name: string; values: number[] }>;
|
||||
} | null {
|
||||
const dayKeys = Object.keys(source).sort();
|
||||
|
||||
if (dayKeys.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const sums: Record<string, Record<string, number>> = {};
|
||||
const dayCount: Record<string, number> = {};
|
||||
const weekOrder: string[] = [];
|
||||
|
||||
for (const dayKey of dayKeys) {
|
||||
const wk = weekStartKey(dayKey);
|
||||
|
||||
if (!sums[wk]) {
|
||||
sums[wk] = {};
|
||||
dayCount[wk] = 0;
|
||||
weekOrder.push(wk);
|
||||
}
|
||||
|
||||
dayCount[wk] += 1;
|
||||
|
||||
for (const [name, value] of source[dayKey]) {
|
||||
sums[wk][name] = (sums[wk][name] ?? 0) + value;
|
||||
}
|
||||
}
|
||||
|
||||
const weeks = completeWeeks(dayKeys, weekOrder);
|
||||
|
||||
if (weeks.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const totals: Record<string, number> = {};
|
||||
|
||||
for (const week of weeks) {
|
||||
const count = dayCount[week] || 1;
|
||||
|
||||
for (const [name, sum] of Object.entries(sums[week])) {
|
||||
totals[name] = (totals[name] ?? 0) + sum / count;
|
||||
}
|
||||
}
|
||||
|
||||
const ranked = Object.keys(totals).sort((a, b) => totals[b] - totals[a]);
|
||||
|
||||
if (ranked.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const labels = weeks.map((k) => fmtWeekRange(k));
|
||||
const series = ranked.map((name) => ({
|
||||
name,
|
||||
values: weeks.map(
|
||||
(week) => (sums[week][name] ?? 0) / (dayCount[week] || 1),
|
||||
),
|
||||
}));
|
||||
|
||||
return { labels, series };
|
||||
}
|
||||
|
||||
function formatPercent(value: number): string {
|
||||
if (value === 0) {
|
||||
return '0%';
|
||||
}
|
||||
|
||||
if (value < 0.1) {
|
||||
return '<0.1%';
|
||||
}
|
||||
|
||||
return `${value.toFixed(1)}%`;
|
||||
}
|
||||
|
||||
const stylesData = computed(() => {
|
||||
if (!stats.value) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return buildSeries(stats.value.styles);
|
||||
});
|
||||
|
||||
const versionsData = computed(() => {
|
||||
if (!stats.value) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return buildSeries(stats.value.versions);
|
||||
});
|
||||
|
||||
const formatsData = computed(() => {
|
||||
if (!stats.value) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return buildSeries(stats.value.formats);
|
||||
});
|
||||
|
||||
const showAllStyles = ref(false);
|
||||
const activeTab = ref<'api' | 'npm'>('api');
|
||||
|
||||
const requestsPerSecond = computed(() => {
|
||||
if (!stats.value) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
const entries = Object.entries(stats.value.requests).sort(([a], [b]) =>
|
||||
a.localeCompare(b),
|
||||
);
|
||||
|
||||
if (entries.length < 2) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
entries.pop();
|
||||
|
||||
const window = entries.slice(-ROLLING_WINDOW_DAYS);
|
||||
const total = window.reduce((sum, [, v]) => sum + v, 0);
|
||||
const avgDaily = total / window.length;
|
||||
|
||||
return avgDaily / SECONDS_PER_DAY;
|
||||
});
|
||||
|
||||
const monthlyStats = computed(() => {
|
||||
if (!stats.value) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const requests = lastCompleteMonth(stats.value.requests);
|
||||
const traffic = lastCompleteMonth(stats.value.traffic);
|
||||
const downloads = lastCompleteMonth(stats.value.downloads.npm);
|
||||
|
||||
if (!requests) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
label: requests.label,
|
||||
requests: formatNumber(requests.total),
|
||||
traffic: traffic ? formatBytes(traffic.total) : null,
|
||||
downloads: downloads ? formatNumber(downloads.total) : null,
|
||||
};
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<AppSmallHero>
|
||||
<template #headline
|
||||
>Billions of Avatars.<br /><strong>One API.</strong></template
|
||||
>
|
||||
<template #description
|
||||
>Every avatar generated through our HTTP-API is tracked anonymously. This
|
||||
page gives you a transparent look at real usage data. It is updated weekly
|
||||
and broken down by requests, traffic, styles, and more.</template
|
||||
>
|
||||
<template #actions><!-- no actions --></template>
|
||||
<template #below-actions>
|
||||
<div v-if="monthlyStats" class="page-stats-hero-kpis">
|
||||
<p class="page-stats-hero-kpis-label">
|
||||
Statistics from {{ monthlyStats.label }}
|
||||
</p>
|
||||
<div class="page-stats-hero-kpis-row">
|
||||
<div class="page-stats-hero-kpi">
|
||||
<span class="page-stats-hero-kpi-value">{{
|
||||
monthlyStats.requests
|
||||
}}</span>
|
||||
<span class="page-stats-hero-kpi-label">API Requests</span>
|
||||
</div>
|
||||
<div class="page-stats-hero-kpi-divider"></div>
|
||||
<div v-if="monthlyStats.traffic" class="page-stats-hero-kpi">
|
||||
<span class="page-stats-hero-kpi-value">{{
|
||||
monthlyStats.traffic
|
||||
}}</span>
|
||||
<span class="page-stats-hero-kpi-label">Data Served</span>
|
||||
</div>
|
||||
<div
|
||||
v-if="monthlyStats.downloads"
|
||||
class="page-stats-hero-kpi-divider"
|
||||
></div>
|
||||
<div v-if="monthlyStats.downloads" class="page-stats-hero-kpi">
|
||||
<span class="page-stats-hero-kpi-value">{{
|
||||
monthlyStats.downloads
|
||||
}}</span>
|
||||
<span class="page-stats-hero-kpi-label">npm Downloads</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
<template #aside>
|
||||
<ClientOnly>
|
||||
<AppStatsGlobe :rate="requestsPerSecond" />
|
||||
</ClientOnly>
|
||||
</template>
|
||||
</AppSmallHero>
|
||||
|
||||
<UiSection divider>
|
||||
<UiContainer>
|
||||
<UiSectionHeader
|
||||
description="Weekly request and download volumes. Toggle between the HTTP API and npm packages."
|
||||
>
|
||||
<template #headline>Usage Over <strong>Time</strong></template>
|
||||
</UiSectionHeader>
|
||||
|
||||
<div class="page-stats-tabs">
|
||||
<button
|
||||
:class="{ active: activeTab === 'api' }"
|
||||
@click="activeTab = 'api'"
|
||||
>
|
||||
HTTP API
|
||||
</button>
|
||||
<button
|
||||
:class="{ active: activeTab === 'npm' }"
|
||||
@click="activeTab = 'npm'"
|
||||
>
|
||||
npm
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<ClientOnly>
|
||||
<UiCard
|
||||
v-if="requestsData && activeTab === 'api'"
|
||||
padding="xl"
|
||||
class="page-stats-chart-card"
|
||||
>
|
||||
<h3 class="page-stats-chart-title">API Requests</h3>
|
||||
<AppStatsChart
|
||||
:labels="requestsData.labels"
|
||||
:values="requestsData.values"
|
||||
color="#0284c7"
|
||||
:format-value="formatNumber"
|
||||
/>
|
||||
</UiCard>
|
||||
|
||||
<UiCard
|
||||
v-if="downloadsData && activeTab === 'npm'"
|
||||
padding="xl"
|
||||
class="page-stats-chart-card"
|
||||
>
|
||||
<h3 class="page-stats-chart-title">Package Downloads</h3>
|
||||
<AppStatsChart
|
||||
:labels="downloadsData.labels"
|
||||
:values="downloadsData.values"
|
||||
color="#cb3837"
|
||||
:format-value="formatNumber"
|
||||
/>
|
||||
</UiCard>
|
||||
</ClientOnly>
|
||||
</UiContainer>
|
||||
</UiSection>
|
||||
|
||||
<UiSection divider>
|
||||
<UiContainer>
|
||||
<UiSectionHeader
|
||||
description="Based on API request data. Shows which styles, versions, and output formats are used most."
|
||||
>
|
||||
<template #headline>Usage <strong>Details</strong></template>
|
||||
</UiSectionHeader>
|
||||
|
||||
<ClientOnly>
|
||||
<UiCard v-if="stylesData" padding="xl" class="page-stats-styles-card">
|
||||
<h3 class="page-stats-chart-title">Popular Styles</h3>
|
||||
<AppStatsMultiLineChart
|
||||
:labels="stylesData.labels"
|
||||
:series="
|
||||
showAllStyles
|
||||
? stylesData.series
|
||||
: stylesData.series.slice(0, TOP_STYLES)
|
||||
"
|
||||
:format-value="formatPercent"
|
||||
/>
|
||||
<button
|
||||
v-if="stylesData.series.length > TOP_STYLES"
|
||||
class="page-stats-show-all"
|
||||
@click="showAllStyles = !showAllStyles"
|
||||
>
|
||||
{{ showAllStyles ? `Show Top ${TOP_STYLES}` : 'Show All' }}
|
||||
</button>
|
||||
</UiCard>
|
||||
|
||||
<div class="page-stats-breakdown-grid">
|
||||
<UiCard v-if="versionsData" padding="xl">
|
||||
<h3 class="page-stats-chart-title">API Versions</h3>
|
||||
<AppStatsMultiLineChart
|
||||
:labels="versionsData.labels"
|
||||
:series="versionsData.series"
|
||||
:format-value="formatPercent"
|
||||
/>
|
||||
</UiCard>
|
||||
|
||||
<UiCard v-if="formatsData" padding="xl">
|
||||
<h3 class="page-stats-chart-title">Output Formats</h3>
|
||||
<AppStatsMultiLineChart
|
||||
:labels="formatsData.labels"
|
||||
:series="formatsData.series"
|
||||
:format-value="formatPercent"
|
||||
/>
|
||||
</UiCard>
|
||||
</div>
|
||||
</ClientOnly>
|
||||
</UiContainer>
|
||||
</UiSection>
|
||||
</template>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.page-stats-hero-kpis {
|
||||
margin-top: 32px;
|
||||
|
||||
&-label {
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.06em;
|
||||
color: var(--vp-c-text-3);
|
||||
margin: 0 0 16px;
|
||||
}
|
||||
|
||||
&-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 28px;
|
||||
}
|
||||
}
|
||||
|
||||
.page-stats-hero-kpi {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
|
||||
&-value {
|
||||
font-size: 28px;
|
||||
font-weight: 800;
|
||||
letter-spacing: -0.02em;
|
||||
color: var(--vp-c-text-1);
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
&-label {
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.06em;
|
||||
color: var(--vp-c-text-3);
|
||||
}
|
||||
|
||||
&-divider {
|
||||
width: 1px;
|
||||
height: 40px;
|
||||
background: linear-gradient(
|
||||
180deg,
|
||||
transparent,
|
||||
var(--vp-c-border),
|
||||
transparent
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
.page-stats-tabs {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
gap: 4px;
|
||||
margin-bottom: 32px;
|
||||
background: var(--vp-c-bg-alt);
|
||||
border-radius: var(--vp-radius-sm);
|
||||
padding: 4px;
|
||||
width: fit-content;
|
||||
margin-inline: auto;
|
||||
|
||||
button {
|
||||
padding: 8px 24px;
|
||||
border: none;
|
||||
background: transparent;
|
||||
color: var(--vp-c-text-2);
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
border-radius: var(--vp-radius-xs);
|
||||
cursor: pointer;
|
||||
transition: all var(--duration-fast) var(--ease-smooth);
|
||||
|
||||
&.active {
|
||||
background: var(--vp-c-bg-elv);
|
||||
color: var(--vp-c-text-1);
|
||||
box-shadow: var(--vp-shadow-1);
|
||||
}
|
||||
|
||||
&:hover:not(.active) {
|
||||
color: var(--vp-c-text-1);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.page-stats-chart-card {
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.page-stats-chart-title {
|
||||
font-size: 18px;
|
||||
font-weight: 600;
|
||||
color: var(--vp-c-text-1);
|
||||
margin-bottom: 24px;
|
||||
}
|
||||
|
||||
.page-stats-show-all {
|
||||
display: block;
|
||||
margin: 16px auto 0;
|
||||
padding: 6px 16px;
|
||||
border: 1px solid var(--vp-c-border);
|
||||
background: transparent;
|
||||
color: var(--vp-c-text-2);
|
||||
font-size: 13px;
|
||||
font-weight: 500;
|
||||
border-radius: var(--vp-radius-xs);
|
||||
cursor: pointer;
|
||||
transition: all var(--duration-fast) var(--ease-smooth);
|
||||
|
||||
&:hover {
|
||||
color: var(--vp-c-text-1);
|
||||
border-color: var(--vp-c-text-2);
|
||||
}
|
||||
}
|
||||
|
||||
.page-stats-styles-card {
|
||||
margin-bottom: 32px;
|
||||
}
|
||||
|
||||
.page-stats-breakdown-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, 1fr);
|
||||
gap: 32px;
|
||||
|
||||
> * {
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.page-stats-hero-kpis {
|
||||
text-align: center;
|
||||
|
||||
&-label {
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
&-row {
|
||||
justify-content: center;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
}
|
||||
|
||||
.page-stats-hero-kpi {
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.page-stats-breakdown-grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,26 @@
|
||||
<script setup lang="ts">
|
||||
import AppSmallHero from '../app/AppSmallHero.vue';
|
||||
import AppHeroAsideUserList from '../app/AppHeroAsideUserList.vue';
|
||||
import AppHighlights from '../app/AppHighlights.vue';
|
||||
import AppIntegration from '../app/AppIntegration.vue';
|
||||
import AppCDN from '../app/AppCDN.vue';
|
||||
import AppComparison from '../app/AppComparison.vue';
|
||||
import AppOpenSourceCards from '../app/AppOpenSourceCards.vue';
|
||||
import AppFrameworks from '../app/AppFrameworks.vue';
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<AppSmallHero>
|
||||
<template #aside>
|
||||
<AppHeroAsideUserList />
|
||||
</template>
|
||||
</AppSmallHero>
|
||||
<AppHighlights>
|
||||
<template #headline>Everything You <strong>Need</strong></template>
|
||||
</AppHighlights>
|
||||
<AppIntegration />
|
||||
<AppFrameworks />
|
||||
<AppCDN />
|
||||
<AppComparison />
|
||||
<AppOpenSourceCards />
|
||||
</template>
|
||||
@@ -0,0 +1,16 @@
|
||||
<script setup lang="ts">
|
||||
import LayoutClientOnly from '@theme/components/layout/LayoutClientOnly.vue';
|
||||
import PlaygroundLoader from './PlaygroundLoader.vue';
|
||||
import PlaygroundContent from './PlaygroundContent.vue';
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<LayoutClientOnly>
|
||||
<template #default>
|
||||
<PlaygroundContent />
|
||||
</template>
|
||||
<template #fallback>
|
||||
<PlaygroundLoader />
|
||||
</template>
|
||||
</LayoutClientOnly>
|
||||
</template>
|
||||
@@ -0,0 +1,533 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, nextTick, onBeforeUnmount, ref, watch } from 'vue';
|
||||
import Button from 'primevue/button';
|
||||
import InputNumber from 'primevue/inputnumber';
|
||||
import SelectButton from 'primevue/selectbutton';
|
||||
import Textarea from 'primevue/textarea';
|
||||
import { Download, Shuffle } from '@lucide/vue';
|
||||
import JSZip from 'jszip';
|
||||
import { Avatar } from '@dicebear/core';
|
||||
import { UiAvatar, UiConfetti, UiLicenseAlert } from '@theme/components/ui';
|
||||
import useStore from '@theme/stores/playground';
|
||||
import { loadAvatarStyle } from '@theme/utils/avatar/style';
|
||||
import { triggerDownload } from '@theme/utils/download';
|
||||
|
||||
const SEED_CAP = 500;
|
||||
const PREVIEW_LIMIT = 12;
|
||||
|
||||
type Mode = 'paste' | 'random';
|
||||
|
||||
const store = useStore();
|
||||
|
||||
const mode = ref<Mode>('random');
|
||||
const modeOptions: { label: string; value: Mode }[] = [
|
||||
{ label: 'Random', value: 'random' },
|
||||
{ label: 'Paste', value: 'paste' },
|
||||
];
|
||||
const seedsInput = ref('');
|
||||
const randomCount = ref(12);
|
||||
const randomSeeds = ref<string[]>([]);
|
||||
|
||||
function generateRandomSeeds(n: number): string[] {
|
||||
const out: string[] = [];
|
||||
for (let i = 0; i < n; i++) {
|
||||
out.push(crypto.randomUUID().replaceAll('-', '').slice(0, 10));
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
function shuffleRandom() {
|
||||
randomSeeds.value = generateRandomSeeds(randomCount.value);
|
||||
}
|
||||
|
||||
shuffleRandom();
|
||||
|
||||
watch(mode, (m) => {
|
||||
if (m === 'random' && randomSeeds.value.length === 0) {
|
||||
shuffleRandom();
|
||||
}
|
||||
});
|
||||
|
||||
watch(randomCount, (n) => {
|
||||
if (mode.value === 'random' && Number.isFinite(n) && n > 0) {
|
||||
randomSeeds.value = generateRandomSeeds(Math.min(n, SEED_CAP));
|
||||
}
|
||||
});
|
||||
|
||||
const seeds = computed<string[]>(() => {
|
||||
if (mode.value === 'paste') {
|
||||
return seedsInput.value
|
||||
.split(/\r?\n/)
|
||||
.map((line) => line.trim())
|
||||
.filter((line) => line.length > 0);
|
||||
}
|
||||
return randomSeeds.value;
|
||||
});
|
||||
|
||||
const seedCount = computed(() => seeds.value.length);
|
||||
const overCap = computed(() => seedCount.value > SEED_CAP);
|
||||
const previewSeeds = computed(() => seeds.value.slice(0, PREVIEW_LIMIT));
|
||||
|
||||
const isGenerating = ref(false);
|
||||
const progress = ref({ done: 0, total: 0 });
|
||||
const errorMessage = ref('');
|
||||
const successState = ref(false);
|
||||
|
||||
const canGenerate = computed(
|
||||
() =>
|
||||
!isGenerating.value &&
|
||||
seedCount.value > 0 &&
|
||||
!overCap.value &&
|
||||
!!store.avatarStyleName,
|
||||
);
|
||||
|
||||
const generateLabel = computed(() => {
|
||||
if (isGenerating.value) {
|
||||
return `Bundling ${progress.value.done} / ${progress.value.total}…`;
|
||||
}
|
||||
if (seedCount.value === 0) return 'Add seeds to begin';
|
||||
return `Download ${seedCount.value} SVG${seedCount.value === 1 ? '' : 's'} as ZIP`;
|
||||
});
|
||||
|
||||
const cleanStyleName = computed(() =>
|
||||
store.avatarStyleName.replace(/^custom:/, ''),
|
||||
);
|
||||
|
||||
const previewStyleOptions = computed(() => ({
|
||||
...store.avatarStyleOptionsWithoutDefaults,
|
||||
}));
|
||||
|
||||
// Some ancestor (likely VitePress local search or a PrimeVue listener) is
|
||||
// suppressing the default Enter newline in capture phase. Manually insert
|
||||
// the newline so the textarea behaves normally regardless of who calls
|
||||
// preventDefault upstream.
|
||||
async function onTextareaEnter(event: KeyboardEvent) {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
const ta = event.target as HTMLTextAreaElement;
|
||||
const start = ta.selectionStart;
|
||||
const end = ta.selectionEnd;
|
||||
seedsInput.value = ta.value.slice(0, start) + '\n' + ta.value.slice(end);
|
||||
await nextTick();
|
||||
ta.selectionStart = ta.selectionEnd = start + 1;
|
||||
ta.scrollTop = ta.scrollHeight;
|
||||
}
|
||||
|
||||
function safeName(seed: string, used: Set<string>): string {
|
||||
// eslint-disable-next-line no-control-regex
|
||||
const cleaned = seed.replace(/[/\\:*?"<>|\x00-\x1f]/g, '-').slice(0, 200);
|
||||
const base = cleaned || 'avatar';
|
||||
let name = `${base}.svg`;
|
||||
let i = 2;
|
||||
while (used.has(name)) name = `${base}-${i++}.svg`;
|
||||
used.add(name);
|
||||
return name;
|
||||
}
|
||||
|
||||
let aborted = false;
|
||||
onBeforeUnmount(() => {
|
||||
aborted = true;
|
||||
});
|
||||
|
||||
async function generate() {
|
||||
if (!canGenerate.value) return;
|
||||
|
||||
isGenerating.value = true;
|
||||
errorMessage.value = '';
|
||||
successState.value = false;
|
||||
progress.value = { done: 0, total: seedCount.value };
|
||||
|
||||
try {
|
||||
const style = await loadAvatarStyle(store.avatarStyleName);
|
||||
if (aborted) return;
|
||||
|
||||
const zip = new JSZip();
|
||||
const used = new Set<string>();
|
||||
const baseOptions = { ...store.avatarStyleOptionsWithoutDefaults };
|
||||
|
||||
for (const seed of seeds.value) {
|
||||
if (aborted) return;
|
||||
const svg = new Avatar(style, { ...baseOptions, seed }).toString();
|
||||
zip.file(safeName(seed, used), svg);
|
||||
progress.value = {
|
||||
done: progress.value.done + 1,
|
||||
total: seedCount.value,
|
||||
};
|
||||
if (progress.value.done % 25 === 0) {
|
||||
await new Promise((r) => setTimeout(r, 0));
|
||||
}
|
||||
}
|
||||
|
||||
const blob = await zip.generateAsync({ type: 'blob' });
|
||||
if (aborted) return;
|
||||
|
||||
triggerDownload(blob, `${cleanStyleName.value}-avatars.zip`);
|
||||
successState.value = true;
|
||||
} catch (err) {
|
||||
if (aborted) return;
|
||||
errorMessage.value = err instanceof Error ? err.message : String(err);
|
||||
} finally {
|
||||
isGenerating.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
function reset() {
|
||||
successState.value = false;
|
||||
errorMessage.value = '';
|
||||
progress.value = { done: 0, total: 0 };
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="pg-batch">
|
||||
<template v-if="!successState">
|
||||
<header class="pg-batch-top">
|
||||
<SelectButton
|
||||
v-model="mode"
|
||||
:options="modeOptions"
|
||||
option-label="label"
|
||||
option-value="value"
|
||||
:allow-empty="false"
|
||||
size="small"
|
||||
aria-label="Seed source"
|
||||
/>
|
||||
</header>
|
||||
|
||||
<section class="pg-batch-section">
|
||||
<div v-if="mode === 'random'" class="pg-batch-random">
|
||||
<InputNumber
|
||||
v-model="randomCount"
|
||||
:min="1"
|
||||
:max="SEED_CAP"
|
||||
:step="1"
|
||||
:show-buttons="true"
|
||||
button-layout="horizontal"
|
||||
:input-style="{ width: '5em', textAlign: 'center' }"
|
||||
decrement-button-class="pg-batch-step-button"
|
||||
increment-button-class="pg-batch-step-button"
|
||||
aria-label="Number of random seeds"
|
||||
>
|
||||
<template #incrementicon>+</template>
|
||||
<template #decrementicon>−</template>
|
||||
</InputNumber>
|
||||
<span class="pg-batch-random-suffix">
|
||||
random seed{{ randomCount === 1 ? '' : 's' }}
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
class="pg-batch-shuffle"
|
||||
@click="shuffleRandom"
|
||||
aria-label="Regenerate random seeds"
|
||||
>
|
||||
<Shuffle :size="14" />
|
||||
<span>Shuffle</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div v-else class="pg-batch-paste">
|
||||
<Textarea
|
||||
id="pg-batch-seeds"
|
||||
v-model="seedsInput"
|
||||
:rows="6"
|
||||
placeholder="One seed per line — a username, email, user ID, anything."
|
||||
class="pg-batch-textarea"
|
||||
spellcheck="false"
|
||||
autocomplete="off"
|
||||
fluid
|
||||
@keydown.enter="onTextareaEnter"
|
||||
/>
|
||||
<span class="pg-batch-counter" :class="{ 'is-over': overCap }">
|
||||
{{ seedCount }} / {{ SEED_CAP }}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<p v-if="overCap" class="pg-batch-hint is-error">
|
||||
That's more than the {{ SEED_CAP }}-seed cap. Trim the list and try
|
||||
again.
|
||||
</p>
|
||||
</section>
|
||||
|
||||
<section
|
||||
v-if="previewSeeds.length > 0"
|
||||
class="pg-batch-section pg-batch-preview-section"
|
||||
>
|
||||
<header class="pg-batch-section-header">
|
||||
<span class="pg-batch-eyebrow">Preview</span>
|
||||
<span class="pg-batch-preview-count">
|
||||
<span v-if="seedCount > PREVIEW_LIMIT">
|
||||
first {{ previewSeeds.length }} of {{ seedCount }}
|
||||
</span>
|
||||
<span v-else>
|
||||
all {{ seedCount }} avatar{{ seedCount === 1 ? '' : 's' }}
|
||||
</span>
|
||||
</span>
|
||||
</header>
|
||||
|
||||
<ul class="pg-batch-preview-grid">
|
||||
<li
|
||||
v-for="seed in previewSeeds"
|
||||
:key="seed"
|
||||
class="pg-batch-preview-tile"
|
||||
>
|
||||
<div class="pg-batch-preview-tile-avatar">
|
||||
<UiAvatar
|
||||
:size="64"
|
||||
:style-name="store.avatarStyleName"
|
||||
:style-options="{ ...previewStyleOptions, seed }"
|
||||
mode="library"
|
||||
/>
|
||||
</div>
|
||||
<code class="pg-batch-preview-tile-seed">{{ seed }}</code>
|
||||
</li>
|
||||
</ul>
|
||||
</section>
|
||||
|
||||
<footer class="pg-batch-footer">
|
||||
<Button
|
||||
:label="generateLabel"
|
||||
:disabled="!canGenerate"
|
||||
:loading="isGenerating"
|
||||
severity="contrast"
|
||||
@click="generate"
|
||||
>
|
||||
<template #icon><Download :size="16" /></template>
|
||||
</Button>
|
||||
<p v-if="errorMessage" class="pg-batch-hint is-error">
|
||||
{{ errorMessage }}
|
||||
</p>
|
||||
</footer>
|
||||
</template>
|
||||
|
||||
<template v-else>
|
||||
<div class="pg-batch-success">
|
||||
<UiConfetti />
|
||||
<div class="dialog-title">Your avatars will be downloaded! 🎉</div>
|
||||
<div class="dialog-subtitle">
|
||||
Please note the license below before using.
|
||||
</div>
|
||||
<div class="dialog-text">
|
||||
<UiLicenseAlert :style-name="store.avatarStyleName" />
|
||||
</div>
|
||||
<div class="pg-batch-success-actions">
|
||||
<Button
|
||||
label="Download another batch"
|
||||
severity="secondary"
|
||||
variant="outlined"
|
||||
@click="reset"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.pg-batch {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.pg-batch-top {
|
||||
display: flex;
|
||||
justify-content: flex-start;
|
||||
}
|
||||
|
||||
.pg-batch-section {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
|
||||
&-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
}
|
||||
}
|
||||
|
||||
.pg-batch-eyebrow {
|
||||
font-size: 11px;
|
||||
font-weight: 700;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.14em;
|
||||
color: var(--ui-c-text-subtle);
|
||||
}
|
||||
|
||||
.pg-batch-random {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.pg-batch-random-suffix {
|
||||
font-size: 14px;
|
||||
color: var(--ui-c-text-muted);
|
||||
}
|
||||
|
||||
.pg-batch-shuffle {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
margin-left: auto;
|
||||
padding: 8px 14px;
|
||||
background: transparent;
|
||||
border: 1px dashed var(--vp-c-border);
|
||||
border-radius: var(--vp-radius-xs);
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.06em;
|
||||
color: var(--ui-c-text-muted);
|
||||
cursor: pointer;
|
||||
transition: all var(--duration-fast) var(--ease-smooth);
|
||||
|
||||
&:hover {
|
||||
border-color: var(--vp-c-brand-1);
|
||||
color: var(--vp-c-brand-1);
|
||||
border-style: solid;
|
||||
}
|
||||
|
||||
&:active svg {
|
||||
transform: rotate(-20deg);
|
||||
}
|
||||
|
||||
svg {
|
||||
transition: transform var(--duration-fast) var(--ease-smooth);
|
||||
}
|
||||
}
|
||||
|
||||
.pg-batch-paste {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.pg-batch-textarea {
|
||||
font-family: var(--vp-font-family-mono);
|
||||
font-size: 13px;
|
||||
line-height: 1.55;
|
||||
min-height: 160px;
|
||||
resize: vertical;
|
||||
}
|
||||
|
||||
.pg-batch-counter {
|
||||
align-self: flex-end;
|
||||
margin-top: 6px;
|
||||
font-size: 12px;
|
||||
font-variant-numeric: tabular-nums;
|
||||
color: var(--ui-c-text-subtle);
|
||||
|
||||
&.is-over {
|
||||
color: var(--vp-c-danger-1, #dc2626);
|
||||
font-weight: 600;
|
||||
}
|
||||
}
|
||||
|
||||
.pg-batch-hint {
|
||||
margin: 0;
|
||||
font-size: 13px;
|
||||
line-height: 1.5;
|
||||
color: var(--ui-c-text-subtle);
|
||||
|
||||
&.is-error {
|
||||
color: var(--vp-c-danger-1, #dc2626);
|
||||
}
|
||||
}
|
||||
|
||||
.pg-batch-preview-section {
|
||||
padding-top: 12px;
|
||||
border-top: 1px solid var(--vp-c-divider);
|
||||
}
|
||||
|
||||
.pg-batch-preview-count {
|
||||
font-size: 12px;
|
||||
font-variant-numeric: tabular-nums;
|
||||
color: var(--ui-c-text-muted);
|
||||
}
|
||||
|
||||
.pg-batch-preview-grid {
|
||||
list-style: none;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(96px, 1fr));
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.pg-batch-preview-tile {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
padding: 10px 6px 8px;
|
||||
margin: 0;
|
||||
background: var(--vp-c-bg);
|
||||
border: 1px solid var(--vp-c-divider);
|
||||
border-radius: var(--vp-radius-sm);
|
||||
|
||||
&-avatar {
|
||||
width: 64px;
|
||||
height: 64px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background:
|
||||
linear-gradient(45deg, var(--vp-c-bg-soft) 25%, transparent 25%) 0 0 / 8px
|
||||
8px,
|
||||
linear-gradient(-45deg, var(--vp-c-bg-soft) 25%, transparent 25%) 0 0 /
|
||||
8px 8px,
|
||||
var(--vp-c-bg);
|
||||
border-radius: var(--vp-radius-xs);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
&-seed {
|
||||
max-width: 100%;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
font-family: var(--vp-font-family-mono);
|
||||
font-size: 10px;
|
||||
color: var(--ui-c-text-muted);
|
||||
padding: 0;
|
||||
background: transparent;
|
||||
}
|
||||
}
|
||||
|
||||
.pg-batch-footer {
|
||||
padding-top: 12px;
|
||||
border-top: 1px solid var(--vp-c-divider);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
|
||||
:deep(.p-button) {
|
||||
width: 100%;
|
||||
justify-content: center;
|
||||
}
|
||||
}
|
||||
|
||||
.pg-batch-success {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.pg-batch-success-actions {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
justify-content: center;
|
||||
margin-top: 8px;
|
||||
}
|
||||
</style>
|
||||
|
||||
<style lang="scss">
|
||||
html.dark {
|
||||
.pg-batch-step-button {
|
||||
background: var(--p-form-field-background);
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,75 @@
|
||||
<script setup lang="ts">
|
||||
import { Copy } from '@lucide/vue';
|
||||
import { ref } from 'vue';
|
||||
import copy from 'copy-to-clipboard';
|
||||
import { Avatar } from '@dicebear/core';
|
||||
import { loadAvatarStyle, clonePlain } from '@theme/utils/avatar/style';
|
||||
import { track, styleLabel } from '@theme/utils/track';
|
||||
import { UiAvatar, UiConfetti, UiDialog } from '../ui';
|
||||
import Button from 'primevue/button';
|
||||
import PlaygroundLicenseAlert from './PlaygroundLicenseAlert.vue';
|
||||
import { usePlaygroundDialog } from '@theme/composables/usePlaygroundDialog';
|
||||
import { DIALOG_PREVIEW_AVATAR_SIZE, DOWNLOAD_AVATAR_SIZE } from './constants';
|
||||
|
||||
const props = defineProps<{
|
||||
seed: string;
|
||||
}>();
|
||||
|
||||
const { store, open, confettiKey, options, showDialog } = usePlaygroundDialog(
|
||||
() => props.seed,
|
||||
);
|
||||
|
||||
const text = ref('');
|
||||
|
||||
async function onClick() {
|
||||
const avatarStyle = await loadAvatarStyle(store.avatarStyleName);
|
||||
const avatar = new Avatar(
|
||||
avatarStyle,
|
||||
clonePlain({
|
||||
size: DOWNLOAD_AVATAR_SIZE,
|
||||
...options.value,
|
||||
}),
|
||||
);
|
||||
|
||||
const successful = copy(avatar.toString());
|
||||
|
||||
if (successful) {
|
||||
track('Playground: Copy SVG', {
|
||||
style: styleLabel(store.avatarStyleName),
|
||||
});
|
||||
}
|
||||
|
||||
text.value = successful
|
||||
? 'Your avatar was successfully copied! 🎉'
|
||||
: 'Your avatar could not be copied. 😞';
|
||||
|
||||
showDialog();
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Button label="Copy SVG" severity="secondary" @click="onClick">
|
||||
<template #icon>
|
||||
<Copy :size="15" />
|
||||
</template>
|
||||
</Button>
|
||||
|
||||
<UiDialog v-model:open="open">
|
||||
<UiConfetti :key="confettiKey" />
|
||||
<div class="dialog-preview">
|
||||
<UiAvatar
|
||||
:style-name="store.avatarStyleName"
|
||||
:style-options="options"
|
||||
:size="DIALOG_PREVIEW_AVATAR_SIZE"
|
||||
mode="library"
|
||||
/>
|
||||
</div>
|
||||
<h2 class="dialog-title">{{ text }}</h2>
|
||||
<div class="dialog-subtitle">
|
||||
Please note the license below before using.
|
||||
</div>
|
||||
<div class="dialog-text">
|
||||
<PlaygroundLicenseAlert />
|
||||
</div>
|
||||
</UiDialog>
|
||||
</template>
|
||||
@@ -0,0 +1,142 @@
|
||||
<script setup lang="ts">
|
||||
import { ref } from 'vue';
|
||||
import { Archive, Download } from '@lucide/vue';
|
||||
import { Avatar } from '@dicebear/core';
|
||||
import { getAvatarApiUrl } from '@theme/utils/avatar/api';
|
||||
import { loadAvatarStyle, clonePlain } from '@theme/utils/avatar/style';
|
||||
import { triggerDownload } from '@theme/utils/download';
|
||||
import { track, styleLabel } from '@theme/utils/track';
|
||||
import { UiAvatar, UiConfetti, UiDialog } from '../ui';
|
||||
import PlaygroundLicenseAlert from './PlaygroundLicenseAlert.vue';
|
||||
import PlaygroundBatchDownload from './PlaygroundBatchDownload.vue';
|
||||
import { usePlaygroundDialog } from '@theme/composables/usePlaygroundDialog';
|
||||
import Button from 'primevue/button';
|
||||
import Menu from 'primevue/menu';
|
||||
import { DIALOG_PREVIEW_AVATAR_SIZE, DOWNLOAD_AVATAR_SIZE } from './constants';
|
||||
|
||||
const props = defineProps<{
|
||||
seed: string;
|
||||
}>();
|
||||
|
||||
const { store, open, confettiKey, options, showDialog } = usePlaygroundDialog(
|
||||
() => props.seed,
|
||||
);
|
||||
const menu = ref();
|
||||
const batchOpen = ref(false);
|
||||
|
||||
async function downloadSvg() {
|
||||
showDialog();
|
||||
|
||||
track('Playground: Download', {
|
||||
style: styleLabel(store.avatarStyleName),
|
||||
format: 'svg',
|
||||
});
|
||||
|
||||
const avatarStyle = await loadAvatarStyle(store.avatarStyleName);
|
||||
const avatar = new Avatar(
|
||||
avatarStyle,
|
||||
clonePlain({
|
||||
size: DOWNLOAD_AVATAR_SIZE,
|
||||
...options.value,
|
||||
}),
|
||||
);
|
||||
|
||||
const blob = new Blob([avatar.toString()], { type: 'image/svg+xml' });
|
||||
triggerDownload(blob, `${store.avatarStyleName}-${Date.now()}.svg`);
|
||||
}
|
||||
|
||||
async function downloadBinary(format: string) {
|
||||
showDialog();
|
||||
|
||||
track('Playground: Download', {
|
||||
style: styleLabel(store.avatarStyleName),
|
||||
format,
|
||||
});
|
||||
|
||||
const response = await fetch(
|
||||
getAvatarApiUrl(store.avatarStyleName, options.value, format),
|
||||
);
|
||||
|
||||
const blob = await response.blob();
|
||||
triggerDownload(blob, `${store.avatarStyleName}-${Date.now()}.${format}`);
|
||||
}
|
||||
|
||||
function openBatch() {
|
||||
batchOpen.value = true;
|
||||
}
|
||||
|
||||
// For built-in styles the API serves PNG/JPEG/WebP/AVIF; for custom styles we
|
||||
// can only produce SVGs locally — so the format choices collapse to SVG +
|
||||
// Batch. Batch stays available in both modes via a separator at the bottom.
|
||||
const builtInMenuItems = [
|
||||
{ label: 'SVG', command: () => downloadSvg() },
|
||||
{ label: 'PNG', command: () => downloadBinary('png') },
|
||||
{ label: 'JPEG', command: () => downloadBinary('jpg') },
|
||||
{ label: 'WebP', command: () => downloadBinary('webp') },
|
||||
{ label: 'AVIF', command: () => downloadBinary('avif') },
|
||||
{ separator: true },
|
||||
{ label: 'Batch download…', icon: Archive, command: () => openBatch() },
|
||||
];
|
||||
|
||||
const customMenuItems = [
|
||||
{ label: 'SVG', command: () => downloadSvg() },
|
||||
{ separator: true },
|
||||
{ label: 'Batch download…', icon: Archive, command: () => openBatch() },
|
||||
];
|
||||
|
||||
function onDownloadClick(e: Event) {
|
||||
menu.value.toggle(e);
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Button label="Download" severity="secondary" @click="onDownloadClick">
|
||||
<template #icon>
|
||||
<Download :size="15" />
|
||||
</template>
|
||||
</Button>
|
||||
<Menu
|
||||
ref="menu"
|
||||
:model="store.isCustomStyle ? customMenuItems : builtInMenuItems"
|
||||
:popup="true"
|
||||
>
|
||||
<template #item="{ item, props }">
|
||||
<a v-bind="props.action" class="pg-download-menu-item">
|
||||
<component :is="item.icon" v-if="item.icon" :size="14" />
|
||||
<span>{{ item.label }}</span>
|
||||
</a>
|
||||
</template>
|
||||
</Menu>
|
||||
|
||||
<UiDialog v-model:open="open">
|
||||
<UiConfetti :key="confettiKey" />
|
||||
<div class="dialog-preview">
|
||||
<UiAvatar
|
||||
:style-name="store.avatarStyleName"
|
||||
:style-options="options"
|
||||
:size="DIALOG_PREVIEW_AVATAR_SIZE"
|
||||
mode="library"
|
||||
/>
|
||||
</div>
|
||||
<div class="dialog-title">Your avatar will be downloaded! 🎉</div>
|
||||
<div class="dialog-subtitle">
|
||||
Please note the license below before using.
|
||||
</div>
|
||||
<div class="dialog-text">
|
||||
<PlaygroundLicenseAlert />
|
||||
</div>
|
||||
</UiDialog>
|
||||
|
||||
<UiDialog v-model:open="batchOpen" header="Batch download" max-width="640px">
|
||||
<PlaygroundBatchDownload v-if="batchOpen" />
|
||||
</UiDialog>
|
||||
</template>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.pg-download-menu-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
width: 100%;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,476 @@
|
||||
<script setup lang="ts">
|
||||
import { Code as CodeIcon } from '@lucide/vue';
|
||||
import { computed, ref, watch } from 'vue';
|
||||
import { UiCard, UiCode, UiDialog } from '../ui';
|
||||
import {
|
||||
getAvatarApiUrl,
|
||||
getAvatarApiCommand,
|
||||
unsupportedHttpApiOptions,
|
||||
} from '@theme/utils/avatar/api';
|
||||
import {
|
||||
formatPhpValue,
|
||||
formatPythonValue,
|
||||
formatGoValue,
|
||||
formatDartValue,
|
||||
} from '@theme/utils/code-examples';
|
||||
import Button from 'primevue/button';
|
||||
import PlaygroundLicenseAlert from './PlaygroundLicenseAlert.vue';
|
||||
import { usePlaygroundDialog } from '@theme/composables/usePlaygroundDialog';
|
||||
import { track, styleLabel } from '@theme/utils/track';
|
||||
import Message from 'primevue/message';
|
||||
import Tabs from 'primevue/tabs';
|
||||
import TabList from 'primevue/tablist';
|
||||
import Tab from 'primevue/tab';
|
||||
import TabPanels from 'primevue/tabpanels';
|
||||
import TabPanel from 'primevue/tabpanel';
|
||||
|
||||
const props = defineProps<{
|
||||
seed: string;
|
||||
}>();
|
||||
|
||||
const { store, open, options } = usePlaygroundDialog(() => props.seed);
|
||||
|
||||
const tab = ref<string>(store.isCustomStyle ? 'js-library' : 'http-api');
|
||||
|
||||
watch(
|
||||
() => store.isCustomStyle,
|
||||
(isCustom) => {
|
||||
if (isCustom && tab.value === 'http-api') {
|
||||
tab.value = 'js-library';
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
watch(open, (isOpen) => {
|
||||
if (isOpen) {
|
||||
track('Playground: How To Use', {
|
||||
style: styleLabel(store.avatarStyleName),
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// Only count tabs the user actively picks while the dialog is open, not the
|
||||
// automatic http-api → js-library switch for custom styles.
|
||||
watch(tab, (value) => {
|
||||
if (open.value) {
|
||||
track('Playground: How To Use Tab', { tab: value });
|
||||
}
|
||||
});
|
||||
|
||||
const exampleHttpApi = computed(() =>
|
||||
getAvatarApiUrl(store.avatarStyleName, options.value),
|
||||
);
|
||||
const hasExcludedOptions = computed(() => {
|
||||
const opts = options.value as Record<string, unknown>;
|
||||
return Object.keys(opts).some(
|
||||
(k) => unsupportedHttpApiOptions.has(k) && opts[k] !== undefined,
|
||||
);
|
||||
});
|
||||
const exampleHttpApiHtml = computed(
|
||||
() => `<img
|
||||
src="${getAvatarApiUrl(store.avatarStyleName, options.value)}"
|
||||
alt="avatar" />`,
|
||||
);
|
||||
const exampleJsLibrary = computed(() => {
|
||||
if (store.isCustomStyle) {
|
||||
return `import { Style, Avatar } from '@dicebear/core';
|
||||
|
||||
// Your custom style definition
|
||||
const definition = { /* ... */ };
|
||||
|
||||
const style = new Style(definition);
|
||||
const avatar = new Avatar(style, ${JSON.stringify(options.value, null, 2)});
|
||||
|
||||
const svg = avatar.toString();`;
|
||||
}
|
||||
|
||||
return `import { Style, Avatar } from '@dicebear/core';
|
||||
import definition from '@dicebear/styles/${store.avatarStyleName}.json' with { type: 'json' };
|
||||
|
||||
const style = new Style(definition);
|
||||
const avatar = new Avatar(style, ${JSON.stringify(options.value, null, 2)});
|
||||
|
||||
const svg = avatar.toString();`;
|
||||
});
|
||||
const examplePhp = computed(() => {
|
||||
const phpOptions = formatPhpValue(options.value, 1);
|
||||
|
||||
if (store.isCustomStyle) {
|
||||
return `<?php
|
||||
|
||||
use DiceBear\\Style;
|
||||
use DiceBear\\Avatar;
|
||||
|
||||
// Your custom style definition (raw JSON)
|
||||
$style = Style::fromJson(file_get_contents('./my-style.json'));
|
||||
$avatar = new Avatar($style, ${phpOptions});
|
||||
|
||||
$svg = (string) $avatar;`;
|
||||
}
|
||||
|
||||
return `<?php
|
||||
|
||||
use Composer\\InstalledVersions;
|
||||
use DiceBear\\Style;
|
||||
use DiceBear\\Avatar;
|
||||
|
||||
$basePath = InstalledVersions::getInstallPath('dicebear/styles');
|
||||
$style = Style::fromJson(file_get_contents($basePath . '/src/${store.avatarStyleName}.json'));
|
||||
|
||||
$avatar = new Avatar($style, ${phpOptions});
|
||||
|
||||
$svg = (string) $avatar;`;
|
||||
});
|
||||
const examplePython = computed(() => {
|
||||
const pythonOptions = formatPythonValue(options.value, 1);
|
||||
|
||||
if (store.isCustomStyle) {
|
||||
return `from pathlib import Path
|
||||
|
||||
from dicebear import Avatar, Style
|
||||
|
||||
# Your custom style definition (raw JSON)
|
||||
style = Style.from_json(Path("./my-style.json").read_text("utf-8"))
|
||||
avatar = Avatar(style, ${pythonOptions})
|
||||
|
||||
svg = avatar.to_string()`;
|
||||
}
|
||||
|
||||
return `from importlib.resources import files
|
||||
|
||||
from dicebear import Avatar, Style
|
||||
|
||||
style = Style.from_json(
|
||||
files("dicebear_styles").joinpath("${store.avatarStyleName}.json").read_text("utf-8")
|
||||
)
|
||||
|
||||
avatar = Avatar(style, ${pythonOptions})
|
||||
|
||||
svg = avatar.to_string()`;
|
||||
});
|
||||
const installRust = computed(() =>
|
||||
store.isCustomStyle
|
||||
? 'cargo add dicebear-core serde_json'
|
||||
: `cargo add dicebear-core serde_json\ncargo add dicebear-styles --features ${store.avatarStyleName}`,
|
||||
);
|
||||
const exampleRust = computed(() => {
|
||||
const rustOptions = JSON.stringify(options.value, null, 2);
|
||||
|
||||
if (store.isCustomStyle) {
|
||||
return `use dicebear_core::{Avatar, Style};
|
||||
use serde_json::json;
|
||||
|
||||
// Your custom style definition
|
||||
let definition = std::fs::read_to_string("./my-style.json")?;
|
||||
|
||||
let style = Style::from_str(&definition)?;
|
||||
let avatar = Avatar::new(&style, json!(${rustOptions}))?;
|
||||
|
||||
let svg = avatar.to_svg();`;
|
||||
}
|
||||
|
||||
return `use dicebear_core::{Avatar, Style};
|
||||
use serde_json::json;
|
||||
|
||||
let style = Style::from_str(dicebear_styles::${store.avatarStyleName.toUpperCase().replace(/-/g, '_')})?;
|
||||
let avatar = Avatar::new(&style, json!(${rustOptions}))?;
|
||||
|
||||
let svg = avatar.to_svg();`;
|
||||
});
|
||||
const exampleGo = computed(() => {
|
||||
const goOptions = formatGoValue(options.value, 1);
|
||||
|
||||
if (store.isCustomStyle) {
|
||||
return `import (
|
||||
"os"
|
||||
|
||||
dicebear "github.com/dicebear/dicebear-go/v10"
|
||||
)
|
||||
|
||||
// Your custom style definition
|
||||
definition, _ := os.ReadFile("./my-style.json")
|
||||
|
||||
style, _ := dicebear.NewStyle(definition)
|
||||
avatar, _ := dicebear.NewAvatar(style, ${goOptions})
|
||||
|
||||
svg := avatar.SVG()`;
|
||||
}
|
||||
|
||||
// The Go styles module exports each style as a PascalCase variable
|
||||
// (e.g. "big-ears" → BigEars).
|
||||
const styleConst = store.avatarStyleName
|
||||
.split('-')
|
||||
.map((part) => part.charAt(0).toUpperCase() + part.slice(1))
|
||||
.join('');
|
||||
|
||||
return `import (
|
||||
dicebear "github.com/dicebear/dicebear-go/v10"
|
||||
"github.com/dicebear/styles/v10"
|
||||
)
|
||||
|
||||
style, _ := dicebear.NewStyle([]byte(styles.${styleConst}))
|
||||
avatar, _ := dicebear.NewAvatar(style, ${goOptions})
|
||||
|
||||
svg := avatar.SVG()`;
|
||||
});
|
||||
|
||||
const exampleDart = computed(() => {
|
||||
const dartOptions = formatDartValue(options.value, 1);
|
||||
|
||||
if (store.isCustomStyle) {
|
||||
return `import 'dart:io';
|
||||
|
||||
import 'package:dicebear_core/dicebear_core.dart';
|
||||
|
||||
// Your custom style definition (raw JSON)
|
||||
final style = Style.parse(File('./my-style.json').readAsStringSync());
|
||||
final avatar = Avatar(style, ${dartOptions});
|
||||
|
||||
final svg = avatar.svg;`;
|
||||
}
|
||||
|
||||
// The Dart styles package exposes each style as its own library with a
|
||||
// camelCase string constant (e.g. "big-ears" → big_ears.dart / bigEars).
|
||||
const styleLibrary = store.avatarStyleName.replace(/-/g, '_');
|
||||
const styleConst = store.avatarStyleName
|
||||
.split('-')
|
||||
.map((part, i) =>
|
||||
i === 0 ? part : part.charAt(0).toUpperCase() + part.slice(1),
|
||||
)
|
||||
.join('');
|
||||
|
||||
return `import 'package:dicebear_core/dicebear_core.dart';
|
||||
import 'package:dicebear_styles/${styleLibrary}.dart';
|
||||
|
||||
final style = Style.parse(${styleConst});
|
||||
final avatar = Avatar(style, ${dartOptions});
|
||||
|
||||
final svg = avatar.svg;`;
|
||||
});
|
||||
|
||||
const exampleCli = computed(() =>
|
||||
getAvatarApiCommand(
|
||||
store.isCustomStyle ? './my-style.json' : store.avatarStyleName,
|
||||
options.value,
|
||||
),
|
||||
);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Button label="How to use" severity="contrast" @click="open = true">
|
||||
<template #icon>
|
||||
<CodeIcon :size="15" />
|
||||
</template>
|
||||
</Button>
|
||||
|
||||
<UiDialog v-model:open="open" max-width="800px" header="How to use">
|
||||
<div class="playground-button-how-to-use-text">
|
||||
<UiCard flush class="playground-button-how-to-use-tabs-card">
|
||||
<Tabs v-model:value="tab">
|
||||
<TabList>
|
||||
<Tab v-if="!store.isCustomStyle" value="http-api">HTTP-API</Tab>
|
||||
<Tab value="js-library">JS</Tab>
|
||||
<Tab value="php-library">PHP</Tab>
|
||||
<Tab value="python-library">Python</Tab>
|
||||
<Tab value="rust-library">Rust</Tab>
|
||||
<Tab value="go-library">Go</Tab>
|
||||
<Tab value="dart-library">Dart</Tab>
|
||||
<Tab value="cli">CLI</Tab>
|
||||
</TabList>
|
||||
<TabPanels>
|
||||
<TabPanel v-if="!store.isCustomStyle" value="http-api">
|
||||
<div class="playground-button-how-to-use-tab-content">
|
||||
<p>
|
||||
Use this URL to request this avatar style via our HTTP API.
|
||||
</p>
|
||||
<UiCode :code="exampleHttpApi" />
|
||||
<p>You can use the URL directly as image source.</p>
|
||||
<UiCode :code="exampleHttpApiHtml" lang="html" />
|
||||
<Message
|
||||
v-if="hasExcludedOptions"
|
||||
severity="warn"
|
||||
:closable="false"
|
||||
:style="{ '--p-message-text-font-size': '13px' }"
|
||||
>
|
||||
Some options you selected are not supported by our public
|
||||
HTTP-API and have been omitted from the URL. You can enable
|
||||
them by
|
||||
<a href="/guides/host-the-http-api-yourself/"
|
||||
>hosting your own instance</a
|
||||
>.
|
||||
</Message>
|
||||
<p>
|
||||
See <a href="/how-to-use/http-api">HTTP-API</a> docs for more
|
||||
information.
|
||||
</p>
|
||||
</div>
|
||||
</TabPanel>
|
||||
<TabPanel value="js-library">
|
||||
<div class="playground-button-how-to-use-tab-content">
|
||||
<p>First install the required packages via npm:</p>
|
||||
<UiCode
|
||||
:code="
|
||||
store.isCustomStyle
|
||||
? 'npm install @dicebear/core --save'
|
||||
: 'npm install @dicebear/core @dicebear/styles --save'
|
||||
"
|
||||
/>
|
||||
<p>Then you can create this avatar as follows:</p>
|
||||
<UiCode :code="exampleJsLibrary" lang="js" />
|
||||
<p>
|
||||
See <a href="/how-to-use/js-library">JS</a> docs for more
|
||||
information.
|
||||
</p>
|
||||
</div>
|
||||
</TabPanel>
|
||||
<TabPanel value="php-library">
|
||||
<div class="playground-button-how-to-use-tab-content">
|
||||
<p>First install the required packages via Composer:</p>
|
||||
<UiCode
|
||||
:code="
|
||||
store.isCustomStyle
|
||||
? 'composer require dicebear/core'
|
||||
: 'composer require dicebear/core dicebear/styles'
|
||||
"
|
||||
/>
|
||||
<p>Then you can create this avatar as follows:</p>
|
||||
<UiCode :code="examplePhp" lang="php" />
|
||||
<p v-if="store.isCustomStyle">
|
||||
Replace <code>./my-style.json</code> with the path to your
|
||||
style definition.
|
||||
</p>
|
||||
</div>
|
||||
</TabPanel>
|
||||
<TabPanel value="python-library">
|
||||
<div class="playground-button-how-to-use-tab-content">
|
||||
<p>First install the required packages via pip:</p>
|
||||
<UiCode
|
||||
:code="
|
||||
store.isCustomStyle
|
||||
? 'pip install dicebear-core'
|
||||
: 'pip install dicebear-core dicebear-styles'
|
||||
"
|
||||
/>
|
||||
<p>Then you can create this avatar as follows:</p>
|
||||
<UiCode :code="examplePython" lang="python" />
|
||||
<p v-if="store.isCustomStyle">
|
||||
Replace <code>./my-style.json</code> with the path to your
|
||||
style definition.
|
||||
</p>
|
||||
<p>
|
||||
See <a href="/how-to-use/python-library">Python</a> docs for
|
||||
more information.
|
||||
</p>
|
||||
</div>
|
||||
</TabPanel>
|
||||
<TabPanel value="rust-library">
|
||||
<div class="playground-button-how-to-use-tab-content">
|
||||
<p>First add the required crates with Cargo:</p>
|
||||
<UiCode :code="installRust" />
|
||||
<p>Then you can create this avatar as follows:</p>
|
||||
<UiCode :code="exampleRust" lang="rust" />
|
||||
<p v-if="store.isCustomStyle">
|
||||
Replace <code>./my-style.json</code> with the path to your
|
||||
style definition.
|
||||
</p>
|
||||
<p>
|
||||
See <a href="/how-to-use/rust-library">Rust</a> docs for more
|
||||
information.
|
||||
</p>
|
||||
</div>
|
||||
</TabPanel>
|
||||
<TabPanel value="go-library">
|
||||
<div class="playground-button-how-to-use-tab-content">
|
||||
<p>First add the required modules with go get:</p>
|
||||
<UiCode
|
||||
:code="
|
||||
store.isCustomStyle
|
||||
? 'go get github.com/dicebear/dicebear-go/v10'
|
||||
: 'go get github.com/dicebear/dicebear-go/v10\ngo get github.com/dicebear/styles/v10'
|
||||
"
|
||||
/>
|
||||
<p>Then you can create this avatar as follows:</p>
|
||||
<UiCode :code="exampleGo" lang="go" />
|
||||
<p v-if="store.isCustomStyle">
|
||||
Replace <code>./my-style.json</code> with the path to your
|
||||
style definition.
|
||||
</p>
|
||||
<p>
|
||||
See <a href="/how-to-use/go-library">Go</a> docs for more
|
||||
information.
|
||||
</p>
|
||||
</div>
|
||||
</TabPanel>
|
||||
<TabPanel value="dart-library">
|
||||
<div class="playground-button-how-to-use-tab-content">
|
||||
<p>First add the required packages with dart pub:</p>
|
||||
<UiCode
|
||||
:code="
|
||||
store.isCustomStyle
|
||||
? 'dart pub add dicebear_core'
|
||||
: 'dart pub add dicebear_core dicebear_styles'
|
||||
"
|
||||
/>
|
||||
<p>Then you can create this avatar as follows:</p>
|
||||
<UiCode :code="exampleDart" lang="dart" />
|
||||
<p v-if="store.isCustomStyle">
|
||||
Replace <code>./my-style.json</code> with the path to your
|
||||
style definition.
|
||||
</p>
|
||||
<p>
|
||||
See <a href="/how-to-use/dart-library">Dart</a> docs for more
|
||||
information.
|
||||
</p>
|
||||
</div>
|
||||
</TabPanel>
|
||||
<TabPanel value="cli">
|
||||
<div class="playground-button-how-to-use-tab-content">
|
||||
<p>First install the CLI package via npm:</p>
|
||||
<UiCode code="npm install --global dicebear" />
|
||||
<p>Then you can create this avatar as follows:</p>
|
||||
<UiCode :code="exampleCli" />
|
||||
<p v-if="store.isCustomStyle">
|
||||
Replace <code>./my-style.json</code> with the path to your
|
||||
style definition.
|
||||
</p>
|
||||
<p>
|
||||
See <a href="/how-to-use/cli">CLI</a> docs for more
|
||||
information.
|
||||
</p>
|
||||
</div>
|
||||
</TabPanel>
|
||||
</TabPanels>
|
||||
</Tabs>
|
||||
</UiCard>
|
||||
|
||||
<PlaygroundLicenseAlert />
|
||||
</div>
|
||||
</UiDialog>
|
||||
</template>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.playground-button-how-to-use {
|
||||
&-text {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1rem;
|
||||
a {
|
||||
color: var(--vp-c-brand-1);
|
||||
|
||||
&:hover {
|
||||
color: var(--vp-c-brand-2);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
&-tab-content {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
|
||||
p {
|
||||
margin: 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,200 @@
|
||||
<script setup lang="ts">
|
||||
import { ref } from 'vue';
|
||||
import { Plus } from '@lucide/vue';
|
||||
import Button from 'primevue/button';
|
||||
import Slider from 'primevue/slider';
|
||||
import Popover from 'primevue/popover';
|
||||
import { stripHash } from '@theme/utils/avatar/colors';
|
||||
|
||||
defineProps<{
|
||||
presetColors: string[];
|
||||
colors: string[];
|
||||
}>();
|
||||
|
||||
const emit = defineEmits<{
|
||||
add: [hex: string];
|
||||
}>();
|
||||
|
||||
const popover = ref();
|
||||
const nativePickerColor = ref('#b6e3f4');
|
||||
const pickerOpacity = ref(100);
|
||||
|
||||
function pickerHexWithAlpha(): string {
|
||||
const hex = stripHash(nativePickerColor.value);
|
||||
|
||||
if (pickerOpacity.value >= 100) return hex;
|
||||
|
||||
const alpha = Math.round((pickerOpacity.value / 100) * 255)
|
||||
.toString(16)
|
||||
.padStart(2, '0');
|
||||
|
||||
return `${hex}${alpha}`;
|
||||
}
|
||||
|
||||
function toggle(event: Event) {
|
||||
popover.value.toggle(event);
|
||||
}
|
||||
|
||||
function addFromPicker() {
|
||||
emit('add', pickerHexWithAlpha());
|
||||
popover.value.hide();
|
||||
}
|
||||
|
||||
function addPreset(hex: string) {
|
||||
emit('add', hex);
|
||||
popover.value.hide();
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<button class="pg-color-picker-trigger" @click="toggle">
|
||||
<Plus :size="20" />
|
||||
</button>
|
||||
|
||||
<Popover ref="popover">
|
||||
<div class="pg-picker">
|
||||
<div
|
||||
class="pg-picker-presets"
|
||||
v-if="presetColors.some((p) => !colors.includes(p))"
|
||||
>
|
||||
<button
|
||||
v-for="preset in presetColors"
|
||||
:key="preset"
|
||||
v-show="!colors.includes(preset)"
|
||||
class="pg-picker-preset"
|
||||
:style="{ backgroundColor: `#${preset}` }"
|
||||
@click="addPreset(preset)"
|
||||
/>
|
||||
</div>
|
||||
<div class="pg-picker-custom">
|
||||
<input
|
||||
type="color"
|
||||
v-model="nativePickerColor"
|
||||
class="pg-picker-native"
|
||||
/>
|
||||
<div class="pg-picker-opacity">
|
||||
<span class="pg-picker-opacity-label">Opacity</span>
|
||||
<Slider
|
||||
v-model="pickerOpacity"
|
||||
:min="0"
|
||||
:max="100"
|
||||
:step="1"
|
||||
class="pg-picker-opacity-slider"
|
||||
/>
|
||||
<span>{{ pickerOpacity }}%</span>
|
||||
</div>
|
||||
</div>
|
||||
<Button
|
||||
:label="`Add #${pickerHexWithAlpha()}`"
|
||||
severity="secondary"
|
||||
variant="outlined"
|
||||
size="small"
|
||||
@click="addFromPicker"
|
||||
/>
|
||||
</div>
|
||||
</Popover>
|
||||
</template>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.pg-color-picker-trigger {
|
||||
aspect-ratio: 1;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: var(--vp-c-bg-soft);
|
||||
border: 2px dashed var(--pg-border);
|
||||
border-radius: var(--vp-radius-xs);
|
||||
color: var(--ui-c-text-subtle);
|
||||
cursor: pointer;
|
||||
|
||||
&:hover {
|
||||
border-color: var(--p-primary-color);
|
||||
color: var(--p-primary-color);
|
||||
}
|
||||
}
|
||||
|
||||
.pg-picker {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
min-width: 200px;
|
||||
}
|
||||
|
||||
.pg-picker-presets {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 5px;
|
||||
}
|
||||
|
||||
.pg-picker-preset {
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
border: none;
|
||||
border-radius: var(--vp-radius-xs);
|
||||
cursor: pointer;
|
||||
transition: transform var(--duration-fast) ease;
|
||||
|
||||
&:hover {
|
||||
transform: scale(1.15);
|
||||
}
|
||||
}
|
||||
|
||||
.pg-picker-custom {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
padding-top: 6px;
|
||||
border-top: 1px solid var(--pg-border);
|
||||
}
|
||||
|
||||
.pg-picker-native {
|
||||
width: 36px;
|
||||
height: 36px;
|
||||
padding: 2px;
|
||||
border: 1px solid var(--pg-border);
|
||||
border-radius: var(--vp-radius-xs);
|
||||
background: var(--vp-c-bg);
|
||||
cursor: pointer;
|
||||
flex-shrink: 0;
|
||||
|
||||
&::-webkit-color-swatch-wrapper {
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
&::-webkit-color-swatch {
|
||||
border: none;
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
&::-moz-color-swatch {
|
||||
border: none;
|
||||
border-radius: 4px;
|
||||
}
|
||||
}
|
||||
|
||||
.pg-picker-opacity {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
flex: 1;
|
||||
|
||||
&-label {
|
||||
font-size: 12px;
|
||||
color: var(--ui-c-text-subtle);
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
span {
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
color: var(--ui-c-text-muted);
|
||||
min-width: 36px;
|
||||
text-align: right;
|
||||
}
|
||||
}
|
||||
|
||||
.pg-picker-opacity-slider {
|
||||
flex: 1;
|
||||
min-width: 60px;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,360 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, inject } from 'vue';
|
||||
import { Trash2, ArrowLeftRight, Link2 } from '@lucide/vue';
|
||||
import Button from 'primevue/button';
|
||||
import Select from 'primevue/select';
|
||||
import Slider from 'primevue/slider';
|
||||
import { capitalCase } from 'change-case';
|
||||
import useStore from '@theme/stores/playground';
|
||||
import { useRangeField } from '@theme/composables/useRangeField';
|
||||
import { stripHash } from '@theme/utils/avatar/colors';
|
||||
import { navigateToColorKey } from '@theme/components/styles/styleOptionsKeys';
|
||||
import PlaygroundColorPicker from './PlaygroundColorPicker.vue';
|
||||
import PlaygroundFieldReset from './PlaygroundFieldReset.vue';
|
||||
|
||||
const props = defineProps<{
|
||||
colorName: string;
|
||||
defaultValues: string[];
|
||||
hasFill: boolean;
|
||||
hasAngle: boolean;
|
||||
hasFillStops: boolean;
|
||||
contrastTo?: string | null;
|
||||
}>();
|
||||
|
||||
const navigateToColor = inject(navigateToColorKey, null);
|
||||
|
||||
function onContrastLinkClick() {
|
||||
if (props.contrastTo && navigateToColor) {
|
||||
navigateToColor(props.contrastTo);
|
||||
}
|
||||
}
|
||||
|
||||
const store = useStore();
|
||||
|
||||
const colorKey = `${props.colorName}Color`;
|
||||
const fillKey = `${colorKey}Fill`;
|
||||
const angleKey = `${colorKey}Angle`;
|
||||
const fillStopsKey = `${colorKey}FillStops`;
|
||||
|
||||
const colors = computed<string[]>({
|
||||
get: () => {
|
||||
const val = store.avatarStyleOptions[colorKey];
|
||||
|
||||
if (Array.isArray(val)) return val;
|
||||
|
||||
return props.defaultValues;
|
||||
},
|
||||
set: (val: string[]) => {
|
||||
const matchesDefaults =
|
||||
val.length === props.defaultValues.length &&
|
||||
val.every((v, i) => v === props.defaultValues[i]);
|
||||
|
||||
if (matchesDefaults) {
|
||||
delete store.avatarStyleOptions[colorKey];
|
||||
} else {
|
||||
store.avatarStyleOptions[colorKey] = [...val];
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
function addColor(hex: string) {
|
||||
if (!hex) return;
|
||||
|
||||
const clean = stripHash(hex).toLowerCase();
|
||||
|
||||
if (colors.value.includes(clean)) return;
|
||||
|
||||
colors.value = [...colors.value, clean];
|
||||
}
|
||||
|
||||
function removeColor(index: number) {
|
||||
const next = [...colors.value];
|
||||
|
||||
next.splice(index, 1);
|
||||
colors.value = next;
|
||||
}
|
||||
|
||||
const fillOptions = [
|
||||
{ label: 'Solid', value: 'solid' },
|
||||
{ label: 'Linear', value: 'linear' },
|
||||
{ label: 'Radial', value: 'radial' },
|
||||
];
|
||||
|
||||
const fill = computed({
|
||||
get: () => {
|
||||
const val = store.avatarStyleOptions[fillKey];
|
||||
|
||||
if (Array.isArray(val)) return val[0] ?? 'solid';
|
||||
|
||||
return typeof val === 'string' ? val : 'solid';
|
||||
},
|
||||
set: (val: string) => {
|
||||
if (val === 'solid') {
|
||||
delete store.avatarStyleOptions[fillKey];
|
||||
} else {
|
||||
store.avatarStyleOptions[fillKey] = [val];
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
const {
|
||||
isRangeMode,
|
||||
toggleRangeMode,
|
||||
resetRangeField,
|
||||
singleComputed,
|
||||
rangeComputed,
|
||||
} = useRangeField(store.avatarStyleOptions);
|
||||
|
||||
const angleSingle = singleComputed(angleKey, 0);
|
||||
const angleRange = rangeComputed(angleKey, 0);
|
||||
|
||||
const fillStopsSingle = singleComputed(fillStopsKey, 2);
|
||||
const fillStopsRange = rangeComputed(fillStopsKey, 2);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="pg-color">
|
||||
<div v-if="contrastTo" class="pg-color-contrast-banner" role="note">
|
||||
<Link2 :size="14" class="pg-color-contrast-banner-icon" />
|
||||
<p class="pg-color-contrast-banner-text">
|
||||
Linked to
|
||||
<button
|
||||
type="button"
|
||||
class="pg-color-contrast-banner-link"
|
||||
@click="onContrastLinkClick"
|
||||
>
|
||||
{{ capitalCase(contrastTo) }}
|
||||
</button>. The value with the strongest contrast against the chosen
|
||||
{{ capitalCase(contrastTo).toLowerCase() }} is preferred. Adding more
|
||||
options here introduces variation, but the highest-contrast value still
|
||||
dominates.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="pg-color-label">
|
||||
<span>Color</span>
|
||||
<PlaygroundFieldReset
|
||||
v-if="store.isOptionSet(colorKey)"
|
||||
@click="store.resetOption(colorKey)"
|
||||
/>
|
||||
</div>
|
||||
<div class="pg-color-grid">
|
||||
<div
|
||||
v-for="(color, i) in colors"
|
||||
:key="i"
|
||||
class="pg-color-tile"
|
||||
:style="{ '--tile-color': `#${color}` }"
|
||||
@click="removeColor(i)"
|
||||
>
|
||||
<div class="pg-color-tile-delete">
|
||||
<Trash2 :size="16" />
|
||||
</div>
|
||||
</div>
|
||||
<PlaygroundColorPicker
|
||||
:preset-colors="props.defaultValues"
|
||||
:colors="colors"
|
||||
@add="addColor"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<template v-if="hasFill && colors.length > 0">
|
||||
<div class="pg-field">
|
||||
<div class="pg-field-label">
|
||||
<span>Fill</span>
|
||||
<PlaygroundFieldReset
|
||||
v-if="store.isOptionSet(fillKey)"
|
||||
@click="store.resetOption(fillKey)"
|
||||
/>
|
||||
</div>
|
||||
<Select
|
||||
v-model="fill"
|
||||
:options="fillOptions"
|
||||
option-label="label"
|
||||
option-value="value"
|
||||
class="pg-color-fill-select"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="pg-field" v-if="hasAngle && fill !== 'solid'">
|
||||
<div class="pg-field-label">
|
||||
<span>Angle</span>
|
||||
<Button
|
||||
size="small"
|
||||
:severity="isRangeMode(angleKey) ? 'primary' : 'secondary'"
|
||||
variant="outlined"
|
||||
v-tooltip="
|
||||
isRangeMode(angleKey)
|
||||
? 'Switch to fixed value'
|
||||
: 'Switch to range'
|
||||
"
|
||||
@click="toggleRangeMode(angleKey, 0)"
|
||||
class="pg-field-toggle"
|
||||
>
|
||||
<ArrowLeftRight :size="14" />
|
||||
</Button>
|
||||
<PlaygroundFieldReset
|
||||
v-if="store.isOptionSet(angleKey)"
|
||||
@click="resetRangeField(angleKey)"
|
||||
/>
|
||||
<span class="pg-field-value" v-if="isRangeMode(angleKey)"
|
||||
>{{ angleRange[0] }}° — {{ angleRange[1] }}°</span
|
||||
>
|
||||
<span class="pg-field-value" v-else>{{ angleSingle }}°</span>
|
||||
</div>
|
||||
<Slider
|
||||
v-if="isRangeMode(angleKey)"
|
||||
v-model="angleRange"
|
||||
:range="true"
|
||||
:min="-360"
|
||||
:max="360"
|
||||
:step="1"
|
||||
/>
|
||||
<Slider v-else v-model="angleSingle" :min="-360" :max="360" :step="1" />
|
||||
</div>
|
||||
|
||||
<div class="pg-field" v-if="hasFillStops && fill !== 'solid'">
|
||||
<div class="pg-field-label">
|
||||
<span>Stops</span>
|
||||
<Button
|
||||
size="small"
|
||||
:severity="isRangeMode(fillStopsKey) ? 'primary' : 'secondary'"
|
||||
variant="outlined"
|
||||
v-tooltip="
|
||||
isRangeMode(fillStopsKey)
|
||||
? 'Switch to fixed value'
|
||||
: 'Switch to range'
|
||||
"
|
||||
@click="toggleRangeMode(fillStopsKey, 2)"
|
||||
class="pg-field-toggle"
|
||||
>
|
||||
<ArrowLeftRight :size="14" />
|
||||
</Button>
|
||||
<PlaygroundFieldReset
|
||||
v-if="store.isOptionSet(fillStopsKey)"
|
||||
@click="resetRangeField(fillStopsKey)"
|
||||
/>
|
||||
<span class="pg-field-value" v-if="isRangeMode(fillStopsKey)"
|
||||
>{{ fillStopsRange[0] }} — {{ fillStopsRange[1] }}</span
|
||||
>
|
||||
<span class="pg-field-value" v-else>{{ fillStopsSingle }}</span>
|
||||
</div>
|
||||
<Slider
|
||||
v-if="isRangeMode(fillStopsKey)"
|
||||
v-model="fillStopsRange"
|
||||
:range="true"
|
||||
:min="2"
|
||||
:max="5"
|
||||
:step="1"
|
||||
/>
|
||||
<Slider v-else v-model="fillStopsSingle" :min="2" :max="5" :step="1" />
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.pg-color {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.pg-color-label {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
color: var(--ui-c-text-muted);
|
||||
}
|
||||
|
||||
.pg-color-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(48px, 1fr));
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.pg-color-tile {
|
||||
aspect-ratio: 1;
|
||||
border: 1px solid var(--pg-border);
|
||||
border-radius: var(--vp-radius-xs);
|
||||
background: repeating-conic-gradient(
|
||||
var(--vp-c-bg-soft) 0% 25%,
|
||||
var(--vp-c-bg) 0% 50%
|
||||
)
|
||||
50% / 10px 10px;
|
||||
cursor: pointer;
|
||||
transition: all var(--duration-fast) ease;
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
|
||||
&::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
background: var(--tile-color);
|
||||
border-radius: calc(var(--vp-radius-xs) - 1px);
|
||||
}
|
||||
|
||||
.pg-color-tile-delete {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: rgba(0, 0, 0, 0.5);
|
||||
border-radius: calc(var(--vp-radius-xs) - 1px);
|
||||
color: white;
|
||||
opacity: 0;
|
||||
transition: opacity var(--duration-fast) ease;
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
&:hover .pg-color-tile-delete {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
.pg-color-fill-select {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.pg-color-contrast-banner {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
padding: 10px 12px;
|
||||
font-size: 12px;
|
||||
line-height: 1.5;
|
||||
color: var(--ui-c-text-muted);
|
||||
background: var(--vp-c-bg-soft);
|
||||
border: 1px solid var(--pg-border);
|
||||
border-radius: var(--vp-radius-xs);
|
||||
}
|
||||
|
||||
.pg-color-contrast-banner-icon {
|
||||
flex-shrink: 0;
|
||||
margin-top: 2px;
|
||||
opacity: 0.7;
|
||||
}
|
||||
|
||||
.pg-color-contrast-banner-text {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.pg-color-contrast-banner-link {
|
||||
display: inline;
|
||||
padding: 0;
|
||||
margin: 0;
|
||||
font: inherit;
|
||||
color: var(--vp-c-brand-1);
|
||||
background: transparent;
|
||||
border: 0;
|
||||
cursor: pointer;
|
||||
text-decoration: underline;
|
||||
text-underline-offset: 2px;
|
||||
|
||||
&:hover {
|
||||
color: var(--vp-c-brand-2);
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,66 @@
|
||||
<script setup lang="ts">
|
||||
import { useCombinationCount } from '@theme/composables/useCombinationCount';
|
||||
|
||||
const count = useCombinationCount();
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div v-if="count" class="pg-combo">
|
||||
<p class="pg-combo-main">
|
||||
<span class="pg-combo-value">{{ count.display }}</span>
|
||||
<span class="pg-combo-label">possible avatars</span>
|
||||
</p>
|
||||
<p class="pg-combo-hint">
|
||||
Shrinks as you restrict variants, colors, or probabilities.
|
||||
<a href="/guides/how-many-unique-avatars/">Learn more →</a>
|
||||
</p>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.pg-combo {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
}
|
||||
|
||||
.pg-combo-main {
|
||||
margin: 0;
|
||||
display: inline-flex;
|
||||
align-items: baseline;
|
||||
gap: 6px;
|
||||
font-size: 12px;
|
||||
line-height: 1.4;
|
||||
color: var(--ui-c-text-muted);
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.pg-combo-value {
|
||||
font-weight: 600;
|
||||
font-variant-numeric: tabular-nums;
|
||||
color: var(--vp-c-text-1);
|
||||
}
|
||||
|
||||
.pg-combo-hint {
|
||||
margin: 0;
|
||||
font-size: 11px;
|
||||
line-height: 1.4;
|
||||
color: var(--ui-c-text-subtle);
|
||||
|
||||
a {
|
||||
color: inherit;
|
||||
text-decoration: underline;
|
||||
text-decoration-color: var(--pg-border);
|
||||
text-underline-offset: 2px;
|
||||
white-space: nowrap;
|
||||
transition:
|
||||
color var(--duration-fast),
|
||||
text-decoration-color var(--duration-fast);
|
||||
|
||||
&:hover {
|
||||
color: var(--vp-c-brand-1);
|
||||
text-decoration-color: var(--vp-c-brand-1);
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,244 @@
|
||||
<script setup lang="ts">
|
||||
import { inject } from 'vue';
|
||||
import Slider from 'primevue/slider';
|
||||
import Button from 'primevue/button';
|
||||
import InputNumber from 'primevue/inputnumber';
|
||||
import { Weight } from '@lucide/vue';
|
||||
import PlaygroundFieldReset from './PlaygroundFieldReset.vue';
|
||||
import useStore from '@theme/stores/playground';
|
||||
import { useRangeField } from '@theme/composables/useRangeField';
|
||||
import { useVariantWeights } from '@theme/composables/useVariantWeights';
|
||||
import {
|
||||
componentPreviewKey,
|
||||
componentPreviewDefault,
|
||||
} from '@theme/components/styles/styleOptionsKeys';
|
||||
|
||||
const props = defineProps<{
|
||||
componentName: string;
|
||||
variants: string[];
|
||||
hasProbability: boolean;
|
||||
hasNonDefaultWeights: boolean;
|
||||
defaultWeights: Record<string, number>;
|
||||
defaultProbability: number;
|
||||
}>();
|
||||
|
||||
const store = useStore();
|
||||
const preview = inject(componentPreviewKey, componentPreviewDefault);
|
||||
|
||||
const {
|
||||
showWeights,
|
||||
variantWeights,
|
||||
toggleWeights,
|
||||
toggleVariant,
|
||||
setWeight,
|
||||
selectAll,
|
||||
selectNone,
|
||||
} = useVariantWeights(
|
||||
store.avatarStyleOptions,
|
||||
() => props.componentName,
|
||||
() => props.variants,
|
||||
() => props.hasNonDefaultWeights,
|
||||
() => props.defaultWeights,
|
||||
);
|
||||
|
||||
const { singleComputed } = useRangeField(store.avatarStyleOptions);
|
||||
|
||||
const variantKey = `${props.componentName}Variant`;
|
||||
const probabilityKey = `${props.componentName}Probability`;
|
||||
|
||||
const probability = singleComputed(
|
||||
probabilityKey,
|
||||
() => props.defaultProbability,
|
||||
);
|
||||
|
||||
function resetVariants() {
|
||||
store.resetOption(variantKey);
|
||||
showWeights.value = props.hasNonDefaultWeights;
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="pg-comp-body">
|
||||
<div class="pg-field" v-if="hasProbability">
|
||||
<div class="pg-field-label">
|
||||
<span>Probability</span>
|
||||
<PlaygroundFieldReset
|
||||
v-if="store.isOptionSet(probabilityKey)"
|
||||
@click="store.resetOption(probabilityKey)"
|
||||
/>
|
||||
<span class="pg-field-value">{{ probability }}%</span>
|
||||
</div>
|
||||
<Slider v-model="probability" :min="0" :max="100" :step="1" />
|
||||
</div>
|
||||
|
||||
<div v-if="variants.length > 0" class="pg-comp-variants">
|
||||
<div class="pg-field-label">
|
||||
<span>Variants</span>
|
||||
<Button
|
||||
size="small"
|
||||
:severity="showWeights ? 'primary' : 'secondary'"
|
||||
variant="outlined"
|
||||
v-tooltip="showWeights ? 'Hide weights' : 'Show weights'"
|
||||
@click="toggleWeights"
|
||||
class="pg-field-toggle"
|
||||
>
|
||||
<Weight :size="14" />
|
||||
</Button>
|
||||
<Button
|
||||
label="All"
|
||||
size="small"
|
||||
severity="secondary"
|
||||
variant="outlined"
|
||||
@click="selectAll"
|
||||
class="pg-field-toggle"
|
||||
/>
|
||||
<Button
|
||||
label="None"
|
||||
size="small"
|
||||
severity="secondary"
|
||||
variant="outlined"
|
||||
@click="selectNone"
|
||||
class="pg-field-toggle"
|
||||
/>
|
||||
<PlaygroundFieldReset
|
||||
v-if="store.isOptionSet(variantKey)"
|
||||
@click="resetVariants"
|
||||
/>
|
||||
</div>
|
||||
<div class="pg-comp-variants-grid">
|
||||
<div
|
||||
v-for="variant in variants"
|
||||
:key="variant"
|
||||
class="pg-comp-variant"
|
||||
:class="{
|
||||
'pg-comp-variant-off': variantWeights[variant] === undefined,
|
||||
}"
|
||||
>
|
||||
<button
|
||||
class="pg-comp-variant-btn"
|
||||
:class="{
|
||||
'pg-comp-variant-btn-active':
|
||||
variantWeights[variant] !== undefined,
|
||||
}"
|
||||
@click="toggleVariant(variant)"
|
||||
>
|
||||
<img
|
||||
v-if="preview"
|
||||
:src="preview.toDataUri(componentName, variant)"
|
||||
alt=""
|
||||
class="pg-comp-variant-img"
|
||||
/>
|
||||
</button>
|
||||
<span class="pg-comp-variant-name">{{ variant }}</span>
|
||||
<InputNumber
|
||||
v-if="showWeights && variantWeights[variant] !== undefined"
|
||||
v-tooltip="'Weight — higher values increase probability'"
|
||||
:model-value="variantWeights[variant]"
|
||||
@update:model-value="
|
||||
(val: number) => setWeight(variant, Math.max(0, val ?? 0))
|
||||
"
|
||||
:min="0"
|
||||
:min-fraction-digits="0"
|
||||
:max-fraction-digits="2"
|
||||
:show-buttons="false"
|
||||
locale="en-US"
|
||||
class="pg-comp-variant-weight"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.pg-comp-body {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 24px;
|
||||
}
|
||||
|
||||
.pg-comp-variants {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.pg-comp-variants-grid {
|
||||
display: grid;
|
||||
/* `minmax(0, 1fr)` allows the columns to shrink below the intrinsic
|
||||
content width — without it long variant names would push the
|
||||
tracks wider than the container. */
|
||||
grid-template-columns: repeat(5, minmax(0, 1fr));
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.pg-comp-variant {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 3px;
|
||||
/* Allow flex children (e.g. the variant name) to shrink so the
|
||||
ellipsis can engage. */
|
||||
min-width: 0;
|
||||
transition: opacity var(--duration-fast) ease;
|
||||
|
||||
&-off {
|
||||
opacity: 0.25;
|
||||
}
|
||||
}
|
||||
|
||||
.pg-comp-variant-btn {
|
||||
width: 100%;
|
||||
padding: 4px;
|
||||
border: 2px solid transparent;
|
||||
border-radius: var(--vp-radius-xs);
|
||||
background: none;
|
||||
cursor: pointer;
|
||||
transition: all var(--duration-fast) ease;
|
||||
|
||||
&-active {
|
||||
border-color: var(--p-primary-color);
|
||||
}
|
||||
|
||||
&:hover {
|
||||
border-color: var(--p-primary-200);
|
||||
}
|
||||
}
|
||||
|
||||
.pg-comp-variant-img {
|
||||
display: block;
|
||||
width: 100%;
|
||||
aspect-ratio: 1 / 1;
|
||||
object-fit: contain;
|
||||
border-radius: 3px;
|
||||
background: repeating-conic-gradient(
|
||||
var(--ui-avatar-bg-1, rgba(0, 0, 0, 0.02)) 0% 25%,
|
||||
var(--ui-avatar-bg-2, rgba(0, 0, 0, 0.07)) 0% 50%
|
||||
)
|
||||
50% / 12px 12px;
|
||||
}
|
||||
|
||||
.pg-comp-variant-name {
|
||||
font-size: 9px;
|
||||
color: var(--ui-c-text-subtle);
|
||||
/* `width: 100%` (not `max-width`) ensures the span fills the
|
||||
column so the ellipsis can clip long names cleanly. */
|
||||
width: 100%;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.pg-comp-variant-weight {
|
||||
width: 40px;
|
||||
|
||||
:deep(input) {
|
||||
width: 40px;
|
||||
height: 20px;
|
||||
padding: 0 4px;
|
||||
font-size: 11px;
|
||||
text-align: center;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,116 @@
|
||||
<script setup lang="ts">
|
||||
import { storeToRefs } from 'pinia';
|
||||
import { kebabCase } from 'change-case';
|
||||
import { RotateCcw } from '@lucide/vue';
|
||||
import PlaygroundOptions from './PlaygroundOptions.vue';
|
||||
import PlaygroundPreviewPanel from './PlaygroundPreviewPanel.vue';
|
||||
import useStore from '@theme/stores/playground';
|
||||
import { track, styleLabel } from '@theme/utils/track';
|
||||
import Button from 'primevue/button';
|
||||
|
||||
const store = useStore();
|
||||
const { seed } = storeToRefs(store);
|
||||
|
||||
function onReset() {
|
||||
track('Playground: Reset', { style: styleLabel(store.avatarStyleName) });
|
||||
store.resetOptions();
|
||||
}
|
||||
|
||||
// ?style= query param overrides persisted style (used by "Open in Playground" links)
|
||||
const styleParam = new URL(window.location.href).searchParams.get('style');
|
||||
|
||||
if (styleParam) {
|
||||
const styleName = kebabCase(styleParam);
|
||||
|
||||
if (store.availableAvatarStyles.includes(styleName)) {
|
||||
store.avatarStyleName = styleName;
|
||||
store.resetOptions();
|
||||
}
|
||||
|
||||
history.replaceState(null, '', window.location.pathname);
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="pg">
|
||||
<div class="pg-body">
|
||||
<aside class="pg-sidebar">
|
||||
<PlaygroundOptions v-model:seed="seed" />
|
||||
</aside>
|
||||
<main class="pg-main">
|
||||
<div class="pg-main-actions">
|
||||
<Button
|
||||
label="Reset"
|
||||
severity="secondary"
|
||||
variant="link"
|
||||
size="small"
|
||||
class="pg-field-reset"
|
||||
@click="onReset"
|
||||
>
|
||||
<template #icon>
|
||||
<RotateCcw :size="14" />
|
||||
</template>
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<PlaygroundPreviewPanel :seed="seed" />
|
||||
</main>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.pg {
|
||||
max-width: 1200px;
|
||||
margin: 0 auto;
|
||||
padding: 16px 24px 48px;
|
||||
}
|
||||
|
||||
.pg-body {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 32px;
|
||||
|
||||
@media (max-width: 860px) {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
|
||||
.pg-sidebar {
|
||||
min-width: 0;
|
||||
|
||||
@media (min-width: 861px) {
|
||||
padding-top: 10px;
|
||||
}
|
||||
}
|
||||
|
||||
.pg-main {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
|
||||
@media (min-width: 861px) {
|
||||
position: sticky;
|
||||
top: 80px;
|
||||
align-self: start;
|
||||
}
|
||||
|
||||
@media (max-width: 860px) {
|
||||
order: -1;
|
||||
}
|
||||
|
||||
&-actions {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
gap: 4px;
|
||||
|
||||
:deep(.p-button-link) {
|
||||
color: var(--ui-c-text-muted);
|
||||
}
|
||||
|
||||
:deep(.p-button-link:hover .p-button-label) {
|
||||
text-decoration: none;
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,283 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, watch } from 'vue';
|
||||
import { Style } from '@dicebear/core';
|
||||
import { registerCustomStyle } from '@theme/utils/avatar/style';
|
||||
import useStore from '@theme/stores/playground';
|
||||
import Dialog from 'primevue/dialog';
|
||||
import Button from 'primevue/button';
|
||||
import InputText from 'primevue/inputtext';
|
||||
import Message from 'primevue/message';
|
||||
import Textarea from 'primevue/textarea';
|
||||
import { Upload } from '@lucide/vue';
|
||||
import { MAX_CUSTOM_STYLE_UPLOAD_BYTES } from './constants';
|
||||
|
||||
const open = defineModel<boolean>('open', { required: true });
|
||||
|
||||
const emit = defineEmits<{
|
||||
added: [key: string];
|
||||
}>();
|
||||
|
||||
const store = useStore();
|
||||
|
||||
const jsonInput = ref('');
|
||||
const styleName = ref('');
|
||||
const error = ref('');
|
||||
const loading = ref(false);
|
||||
|
||||
watch(open, (val) => {
|
||||
if (val) {
|
||||
jsonInput.value = '';
|
||||
styleName.value = '';
|
||||
error.value = '';
|
||||
}
|
||||
});
|
||||
|
||||
function extractName(definition: Record<string, unknown>): string {
|
||||
const meta = definition.meta as Record<string, unknown> | undefined;
|
||||
|
||||
if (meta) {
|
||||
const source = meta.source as Record<string, unknown> | undefined;
|
||||
|
||||
if (source?.name && typeof source.name === 'string') {
|
||||
return source.name;
|
||||
}
|
||||
|
||||
const creator = meta.creator as Record<string, unknown> | undefined;
|
||||
|
||||
if (creator?.name && typeof creator.name === 'string') {
|
||||
return creator.name;
|
||||
}
|
||||
}
|
||||
|
||||
if (definition.$id && typeof definition.$id === 'string') {
|
||||
return definition.$id;
|
||||
}
|
||||
|
||||
return 'Custom Style';
|
||||
}
|
||||
|
||||
async function submit() {
|
||||
error.value = '';
|
||||
|
||||
if (
|
||||
new TextEncoder().encode(jsonInput.value).length >
|
||||
MAX_CUSTOM_STYLE_UPLOAD_BYTES
|
||||
) {
|
||||
error.value = 'Style definition is too large (max 1 MB).';
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
loading.value = true;
|
||||
|
||||
try {
|
||||
const parsed = JSON.parse(jsonInput.value);
|
||||
const name = styleName.value.trim() || extractName(parsed);
|
||||
|
||||
new Style(parsed);
|
||||
|
||||
const key = store.addCustomStyle(name, parsed);
|
||||
registerCustomStyle(key, parsed);
|
||||
|
||||
emit('added', key);
|
||||
} catch (err: unknown) {
|
||||
if (err instanceof SyntaxError) {
|
||||
error.value = 'Invalid JSON: ' + err.message;
|
||||
} else if (err instanceof Error) {
|
||||
error.value =
|
||||
'Invalid style definition. Check format and required fields.';
|
||||
} else {
|
||||
error.value = 'An unknown error occurred.';
|
||||
}
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function onFileSelect(event: Event) {
|
||||
const input = event.target as HTMLInputElement;
|
||||
const file = input.files?.[0];
|
||||
|
||||
error.value = '';
|
||||
|
||||
if (!file) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (file.size > MAX_CUSTOM_STYLE_UPLOAD_BYTES) {
|
||||
error.value = 'File is too large (max 1 MB).';
|
||||
input.value = '';
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
jsonInput.value = await file.text();
|
||||
} catch {
|
||||
error.value = 'Could not read file.';
|
||||
}
|
||||
|
||||
input.value = '';
|
||||
}
|
||||
|
||||
const canSubmit = computed(
|
||||
() => jsonInput.value.trim().length > 0 && !loading.value,
|
||||
);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Dialog
|
||||
v-model:visible="open"
|
||||
modal
|
||||
:closable="true"
|
||||
dismissable-mask
|
||||
header="Add Custom Style"
|
||||
:style="{ width: '600px', maxWidth: 'calc(100vw - 32px)' }"
|
||||
:pt="{ content: { class: 'pg-custom-upload-dialog-content' } }"
|
||||
>
|
||||
<div class="pg-custom-upload">
|
||||
<div class="pg-custom-upload-field">
|
||||
<label class="pg-custom-upload-label">Style Name (optional)</label>
|
||||
<InputText
|
||||
v-model="styleName"
|
||||
placeholder="My Custom Style"
|
||||
class="pg-custom-upload-name"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="pg-custom-upload-field">
|
||||
<label class="pg-custom-upload-label">Style Definition (JSON)</label>
|
||||
<Textarea
|
||||
v-model="jsonInput"
|
||||
class="pg-custom-upload-textarea"
|
||||
placeholder="Paste your style definition JSON here..."
|
||||
:rows="12"
|
||||
fluid
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="pg-custom-upload-or">
|
||||
<span>or</span>
|
||||
</div>
|
||||
|
||||
<label class="pg-custom-upload-file">
|
||||
<Upload :size="16" />
|
||||
<span>Choose JSON file</span>
|
||||
<input
|
||||
type="file"
|
||||
accept=".json,application/json"
|
||||
class="pg-custom-upload-file-input"
|
||||
@change="onFileSelect"
|
||||
/>
|
||||
</label>
|
||||
|
||||
<Message
|
||||
v-if="error"
|
||||
severity="error"
|
||||
:closable="false"
|
||||
class="pg-custom-upload-error"
|
||||
>
|
||||
{{ error }}
|
||||
</Message>
|
||||
|
||||
<Button
|
||||
label="Add Style"
|
||||
:disabled="!canSubmit"
|
||||
:loading="loading"
|
||||
@click="submit"
|
||||
class="pg-custom-upload-submit"
|
||||
/>
|
||||
|
||||
<p class="pg-custom-upload-notice">
|
||||
Note: Please only upload styles for which you hold the necessary
|
||||
copyrights. Your data is processed and stored exclusively in your local
|
||||
browser and never reaches our server.
|
||||
</p>
|
||||
</div>
|
||||
</Dialog>
|
||||
</template>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.pg-custom-upload {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.pg-custom-upload-field {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.pg-custom-upload-label {
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
color: var(--ui-c-text-muted);
|
||||
}
|
||||
|
||||
.pg-custom-upload-name {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.pg-custom-upload-textarea {
|
||||
min-height: 200px;
|
||||
font-family: var(--vp-font-family-mono);
|
||||
font-size: 12px;
|
||||
line-height: 1.5;
|
||||
resize: vertical;
|
||||
}
|
||||
|
||||
.pg-custom-upload-or {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
color: var(--ui-c-text-subtle);
|
||||
font-size: 13px;
|
||||
|
||||
&::before,
|
||||
&::after {
|
||||
content: '';
|
||||
flex: 1;
|
||||
height: 1px;
|
||||
background: var(--vp-c-border);
|
||||
}
|
||||
}
|
||||
|
||||
.pg-custom-upload-file {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 8px;
|
||||
padding: 10px;
|
||||
border: 1px dashed var(--vp-c-border);
|
||||
border-radius: var(--vp-radius-xs);
|
||||
font-size: 13px;
|
||||
font-weight: 500;
|
||||
color: var(--ui-c-text-muted);
|
||||
cursor: pointer;
|
||||
transition: all var(--duration-fast);
|
||||
|
||||
&:hover {
|
||||
border-color: var(--vp-c-brand-1);
|
||||
color: var(--vp-c-brand-1);
|
||||
}
|
||||
|
||||
&-input {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
.pg-custom-upload-submit {
|
||||
width: 100%;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.pg-custom-upload-notice {
|
||||
font-size: 13px;
|
||||
line-height: 1.5;
|
||||
color: var(--ui-c-text-subtle);
|
||||
text-align: center;
|
||||
margin: 0;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,130 @@
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue';
|
||||
import { useData } from 'vitepress';
|
||||
import { ExternalLink } from '@lucide/vue';
|
||||
import useStore from '@theme/stores/playground';
|
||||
import type { ThemeOptions } from '@theme/types';
|
||||
|
||||
const store = useStore();
|
||||
const { theme } = useData<ThemeOptions>();
|
||||
|
||||
const definitionUrl = computed(
|
||||
() => theme.value.avatarStyles[store.avatarStyleName]?.definitionUrl,
|
||||
);
|
||||
|
||||
const size = computed(
|
||||
() => theme.value.avatarStyleSizes.styles[store.avatarStyleName],
|
||||
);
|
||||
|
||||
const fileName = computed(() => {
|
||||
const url = definitionUrl.value;
|
||||
|
||||
if (!url) return null;
|
||||
|
||||
const lastSlash = url.lastIndexOf('/');
|
||||
|
||||
return lastSlash >= 0 ? url.slice(lastSlash + 1) : url;
|
||||
});
|
||||
|
||||
function formatSize(bytes: number): string {
|
||||
if (bytes >= 1e6) return `${(bytes / 1e6).toFixed(1)} MB`;
|
||||
if (bytes >= 1e3) return `${(bytes / 1e3).toFixed(1)} kB`;
|
||||
|
||||
return `${bytes} B`;
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<a
|
||||
v-if="definitionUrl && fileName"
|
||||
:href="definitionUrl"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
class="pg-def"
|
||||
>
|
||||
<span class="pg-def-main">
|
||||
<span class="pg-def-name">{{ fileName }}</span>
|
||||
<ExternalLink :size="13" class="pg-def-icon" />
|
||||
</span>
|
||||
<span v-if="size" class="pg-def-size">
|
||||
<span class="pg-def-size-value">{{ formatSize(size.gzip) }}</span>
|
||||
<span class="pg-def-size-unit">gzipped</span>
|
||||
<span class="pg-def-size-sep">·</span>
|
||||
<span class="pg-def-size-value">{{ formatSize(size.raw) }}</span>
|
||||
<span class="pg-def-size-unit">raw</span>
|
||||
</span>
|
||||
</a>
|
||||
</template>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.pg-def {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
text-decoration: none;
|
||||
color: var(--ui-c-text-muted);
|
||||
transition: color var(--duration-fast);
|
||||
|
||||
&:hover,
|
||||
&:focus-visible {
|
||||
color: var(--vp-c-brand-1);
|
||||
outline: none;
|
||||
|
||||
.pg-def-name {
|
||||
text-decoration-color: var(--vp-c-brand-1);
|
||||
}
|
||||
|
||||
.pg-def-icon {
|
||||
color: var(--vp-c-brand-1);
|
||||
transform: translate(1px, -1px);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.pg-def-main {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
font-size: 12px;
|
||||
line-height: 1.2;
|
||||
}
|
||||
|
||||
.pg-def-name {
|
||||
font-family: var(--vp-font-family-mono);
|
||||
font-weight: 500;
|
||||
color: inherit;
|
||||
text-decoration: underline;
|
||||
text-decoration-color: var(--pg-border);
|
||||
text-underline-offset: 2px;
|
||||
transition: text-decoration-color var(--duration-fast);
|
||||
word-break: break-all;
|
||||
}
|
||||
|
||||
.pg-def-icon {
|
||||
flex-shrink: 0;
|
||||
color: var(--ui-c-text-subtle);
|
||||
transition:
|
||||
color var(--duration-fast),
|
||||
transform var(--duration-fast) var(--ease-smooth);
|
||||
}
|
||||
|
||||
.pg-def-size {
|
||||
display: inline-flex;
|
||||
align-items: baseline;
|
||||
gap: 4px;
|
||||
font-size: 11px;
|
||||
line-height: 1.2;
|
||||
color: var(--ui-c-text-subtle);
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
.pg-def-size-value {
|
||||
font-weight: 600;
|
||||
color: var(--ui-c-text-muted);
|
||||
}
|
||||
|
||||
.pg-def-size-sep {
|
||||
opacity: 0.5;
|
||||
margin: 0 2px;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,78 @@
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue';
|
||||
import { kebabCase } from 'change-case';
|
||||
import { ArrowRight } from '@lucide/vue';
|
||||
import useStore from '@theme/stores/playground';
|
||||
|
||||
const store = useStore();
|
||||
|
||||
const url = computed(() => `/styles/${kebabCase(store.avatarStyleName)}/`);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<a :href="url" class="pg-doc-link">
|
||||
<span class="pg-doc-main">
|
||||
<span class="pg-doc-label">Style documentation</span>
|
||||
<ArrowRight :size="13" class="pg-doc-arrow" />
|
||||
</span>
|
||||
<span class="pg-doc-hint"
|
||||
>Full options reference, code examples, and integration guides.</span
|
||||
>
|
||||
</a>
|
||||
</template>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.pg-doc-link {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
text-decoration: none;
|
||||
color: var(--ui-c-text-muted);
|
||||
transition: color var(--duration-fast);
|
||||
|
||||
&:hover,
|
||||
&:focus-visible {
|
||||
color: var(--vp-c-brand-1);
|
||||
outline: none;
|
||||
|
||||
.pg-doc-label {
|
||||
text-decoration-color: var(--vp-c-brand-1);
|
||||
}
|
||||
|
||||
.pg-doc-arrow {
|
||||
color: var(--vp-c-brand-1);
|
||||
transform: translateX(2px);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.pg-doc-main {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
font-size: 12px;
|
||||
line-height: 1.2;
|
||||
}
|
||||
|
||||
.pg-doc-label {
|
||||
color: inherit;
|
||||
text-decoration: underline;
|
||||
text-decoration-color: var(--pg-border);
|
||||
text-underline-offset: 2px;
|
||||
transition: text-decoration-color var(--duration-fast);
|
||||
}
|
||||
|
||||
.pg-doc-arrow {
|
||||
flex-shrink: 0;
|
||||
color: var(--ui-c-text-subtle);
|
||||
transition:
|
||||
color var(--duration-fast),
|
||||
transform var(--duration-fast) var(--ease-smooth);
|
||||
}
|
||||
|
||||
.pg-doc-hint {
|
||||
font-size: 11px;
|
||||
line-height: 1.2;
|
||||
color: var(--ui-c-text-subtle);
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,19 @@
|
||||
<script setup lang="ts">
|
||||
import Button from 'primevue/button';
|
||||
import { RotateCcw } from '@lucide/vue';
|
||||
|
||||
defineEmits<{ click: [] }>();
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Button
|
||||
size="small"
|
||||
severity="secondary"
|
||||
variant="outlined"
|
||||
v-tooltip="'Reset to default'"
|
||||
class="pg-field-reset pg-field-toggle"
|
||||
@click="$emit('click')"
|
||||
>
|
||||
<RotateCcw :size="14" />
|
||||
</Button>
|
||||
</template>
|
||||
@@ -0,0 +1,98 @@
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue';
|
||||
import Select from 'primevue/select';
|
||||
import useStore from '@theme/stores/playground';
|
||||
import { webSafeFonts, fontWeights } from '@theme/utils/avatar/fonts';
|
||||
import PlaygroundFieldReset from './PlaygroundFieldReset.vue';
|
||||
|
||||
const fontFamilyOptions = [...webSafeFonts];
|
||||
const fontWeightOptions = [...fontWeights];
|
||||
|
||||
defineProps<{
|
||||
hasFontFamily: boolean;
|
||||
hasFontWeight: boolean;
|
||||
}>();
|
||||
|
||||
const store = useStore();
|
||||
|
||||
const fontFamilyKey = 'fontFamily';
|
||||
const fontWeightKey = 'fontWeight';
|
||||
|
||||
const fontFamily = computed({
|
||||
get: () => {
|
||||
const val = store.avatarStyleOptions[fontFamilyKey];
|
||||
|
||||
if (typeof val === 'string') return val;
|
||||
|
||||
return 'system-ui';
|
||||
},
|
||||
set: (val: string) => {
|
||||
if (val === 'system-ui') {
|
||||
delete store.avatarStyleOptions[fontFamilyKey];
|
||||
} else {
|
||||
store.avatarStyleOptions[fontFamilyKey] = val;
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
const fontWeight = computed({
|
||||
get: () => {
|
||||
const val = store.avatarStyleOptions[fontWeightKey];
|
||||
|
||||
if (typeof val === 'number') return val;
|
||||
|
||||
return 400;
|
||||
},
|
||||
set: (val: number) => {
|
||||
if (val === 400) {
|
||||
delete store.avatarStyleOptions[fontWeightKey];
|
||||
} else {
|
||||
store.avatarStyleOptions[fontWeightKey] = val;
|
||||
}
|
||||
},
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="pg-font">
|
||||
<div class="pg-field" v-if="hasFontFamily">
|
||||
<div class="pg-field-label">
|
||||
<span>Font Family</span>
|
||||
<PlaygroundFieldReset
|
||||
v-if="store.isOptionSet(fontFamilyKey)"
|
||||
@click="store.resetOption(fontFamilyKey)"
|
||||
/>
|
||||
</div>
|
||||
<Select
|
||||
v-model="fontFamily"
|
||||
:options="fontFamilyOptions"
|
||||
class="pg-field-select"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="pg-field" v-if="hasFontWeight">
|
||||
<div class="pg-field-label">
|
||||
<span>Font Weight</span>
|
||||
<PlaygroundFieldReset
|
||||
v-if="store.isOptionSet(fontWeightKey)"
|
||||
@click="store.resetOption(fontWeightKey)"
|
||||
/>
|
||||
</div>
|
||||
<Select
|
||||
v-model="fontWeight"
|
||||
:options="fontWeightOptions"
|
||||
option-label="label"
|
||||
option-value="value"
|
||||
class="pg-field-select"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.pg-font {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 16px;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,26 @@
|
||||
<script setup lang="ts">
|
||||
import { UiCard } from '@theme/components/ui';
|
||||
import PlaygroundLicenseText from './PlaygroundLicenseText.vue';
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<UiCard padding="sm" class="playground-license-alert">
|
||||
<strong>
|
||||
Please note the following license before using the avatar:
|
||||
</strong>
|
||||
<PlaygroundLicenseText />
|
||||
</UiCard>
|
||||
</template>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.playground-license-alert {
|
||||
font-size: 14px;
|
||||
line-height: 1.5;
|
||||
|
||||
:deep(strong) {
|
||||
display: block;
|
||||
margin-bottom: 4px;
|
||||
font-weight: 600;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,111 @@
|
||||
<script setup lang="ts">
|
||||
import { loadAvatarStyle } from '@theme/utils/avatar/style';
|
||||
import { computed } from 'vue';
|
||||
import { computedAsync } from '@vueuse/core';
|
||||
import useStore from '@theme/stores/playground';
|
||||
import { safeHttpUrl } from '@theme/utils/url';
|
||||
import { UiLicenseText } from '@theme/components/ui';
|
||||
|
||||
const store = useStore();
|
||||
|
||||
// For custom styles, load meta from the Style object directly
|
||||
const customStyleMeta = computedAsync(async () => {
|
||||
if (!store.isCustomStyle) return null;
|
||||
|
||||
try {
|
||||
const style = await loadAvatarStyle(store.avatarStyleName);
|
||||
const meta = style.meta();
|
||||
|
||||
return {
|
||||
creator: meta.creator()?.name(),
|
||||
homepage: meta.creator()?.url(),
|
||||
title: meta.source()?.name(),
|
||||
source: meta.source()?.url(),
|
||||
license: {
|
||||
name: meta.license()?.name(),
|
||||
url: meta.license()?.url(),
|
||||
},
|
||||
};
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}, null);
|
||||
|
||||
const hasCustomMeta = computed(() => {
|
||||
const meta = customStyleMeta.value;
|
||||
return meta && (meta.creator || meta.license?.name);
|
||||
});
|
||||
|
||||
const customSourceUrl = computed(() =>
|
||||
safeHttpUrl(customStyleMeta.value?.source),
|
||||
);
|
||||
const customLicenseUrl = computed(() =>
|
||||
safeHttpUrl(customStyleMeta.value?.license?.url),
|
||||
);
|
||||
|
||||
const customStyleDisplayName = computed(
|
||||
() => store.customStyles[store.avatarStyleName]?.name ?? 'Custom Style',
|
||||
);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<p class="playground-license-text" v-if="store.isCustomStyle">
|
||||
<template v-if="hasCustomMeta">
|
||||
<span class="playground-license-text-name">{{
|
||||
customStyleDisplayName
|
||||
}}</span>
|
||||
<template v-if="customStyleMeta?.title">
|
||||
is based on:
|
||||
<a
|
||||
v-if="customSourceUrl"
|
||||
:href="customSourceUrl"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>{{ customStyleMeta?.title }}</a
|
||||
>
|
||||
<template v-else>{{ customStyleMeta?.title }}</template>
|
||||
</template>
|
||||
<template v-if="customStyleMeta?.creator">
|
||||
by {{ customStyleMeta.creator }}
|
||||
</template>
|
||||
<template v-if="customStyleMeta?.license?.name">
|
||||
, licensed under
|
||||
<a
|
||||
v-if="customLicenseUrl"
|
||||
:href="customLicenseUrl"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>{{ customStyleMeta?.license?.name }}</a
|
||||
>
|
||||
<template v-else>{{ customStyleMeta?.license?.name }}</template>
|
||||
</template>
|
||||
(as stated by the creator; DiceBear has not verified this).
|
||||
</template>
|
||||
<template v-else>
|
||||
This avatar style was provided by a user. License and copyright have not
|
||||
been verified by DiceBear.
|
||||
</template>
|
||||
</p>
|
||||
|
||||
<UiLicenseText v-else :style-name="store.avatarStyleName" />
|
||||
</template>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.playground-license-text {
|
||||
&-name {
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
a {
|
||||
font-weight: 500;
|
||||
color: var(--vp-c-brand-1);
|
||||
text-decoration-style: dotted;
|
||||
transition: color var(--duration-fast);
|
||||
cursor: pointer;
|
||||
|
||||
&:hover {
|
||||
color: var(--vp-c-brand-2);
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,48 @@
|
||||
<template>
|
||||
<div class="playground-loader">
|
||||
<div class="playground-loader-body">
|
||||
<div class="playground-loader-spinner" />
|
||||
<div class="playground-loader-text">Please wait...</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.playground-loader {
|
||||
flex-grow: 1;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
min-height: calc(100vh - 180px);
|
||||
padding: 120px;
|
||||
|
||||
&-body {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
&-spinner {
|
||||
width: 48px;
|
||||
height: 48px;
|
||||
border-radius: 50%;
|
||||
border: 3px solid transparent;
|
||||
border-top-color: var(--vp-c-brand-1);
|
||||
animation: playground-loader 1.1s infinite linear;
|
||||
}
|
||||
|
||||
&-text {
|
||||
text-align: center;
|
||||
margin-top: 12px;
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes playground-loader {
|
||||
0% {
|
||||
transform: rotate(0deg);
|
||||
}
|
||||
100% {
|
||||
transform: rotate(360deg);
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,576 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, nextTick, provide, ref, watch } from 'vue';
|
||||
import { styleUsesVariable } from '@theme/utils/avatar/style';
|
||||
import {
|
||||
componentPreviewKey,
|
||||
navigateToColorKey,
|
||||
} from '@theme/components/styles/styleOptionsKeys';
|
||||
import { useStyleOptions } from '@theme/composables/useStyleOptions';
|
||||
import { capitalCase } from 'change-case';
|
||||
import useStore from '@theme/stores/playground';
|
||||
import { track, styleLabel } from '@theme/utils/track';
|
||||
import { storeToRefs } from 'pinia';
|
||||
import Accordion from 'primevue/accordion';
|
||||
import AccordionPanel from 'primevue/accordionpanel';
|
||||
import AccordionHeader from 'primevue/accordionheader';
|
||||
import AccordionContent from 'primevue/accordioncontent';
|
||||
import Tag from 'primevue/tag';
|
||||
import InputText from 'primevue/inputtext';
|
||||
import InputNumber from 'primevue/inputnumber';
|
||||
import Button from 'primevue/button';
|
||||
import ToggleSwitch from 'primevue/toggleswitch';
|
||||
import { Shuffle } from '@lucide/vue';
|
||||
import PlaygroundComponentSection from './PlaygroundComponentSection.vue';
|
||||
import PlaygroundColorSection from './PlaygroundColorSection.vue';
|
||||
import PlaygroundTransformSection from './PlaygroundTransformSection.vue';
|
||||
import PlaygroundFontSection from './PlaygroundFontSection.vue';
|
||||
import PlaygroundStyleSelect from './PlaygroundStyleSelect.vue';
|
||||
import PlaygroundFieldReset from './PlaygroundFieldReset.vue';
|
||||
|
||||
type ComponentInfo = {
|
||||
name: string;
|
||||
variants: string[];
|
||||
hasProbability: boolean;
|
||||
defaultProbability: number;
|
||||
hasNonDefaultWeights: boolean;
|
||||
defaultWeights: Record<string, number>;
|
||||
};
|
||||
|
||||
type ColorInfo = {
|
||||
name: string;
|
||||
key: string;
|
||||
defaultValues: string[];
|
||||
hasFill: boolean;
|
||||
hasAngle: boolean;
|
||||
hasFillStops: boolean;
|
||||
contrastTo: string | null;
|
||||
};
|
||||
|
||||
const seed = defineModel<string>('seed', { required: true });
|
||||
|
||||
const store = useStore();
|
||||
const { avatarStyleName } = storeToRefs(store);
|
||||
|
||||
const { loadedStyle, descriptor, styleColors, preview } =
|
||||
useStyleOptions(avatarStyleName);
|
||||
|
||||
provide(componentPreviewKey, preview);
|
||||
|
||||
const hasFontFamily = computed(() =>
|
||||
loadedStyle.value
|
||||
? styleUsesVariable(avatarStyleName.value, 'fontFamily')
|
||||
: false,
|
||||
);
|
||||
|
||||
const hasFontWeight = computed(() =>
|
||||
loadedStyle.value
|
||||
? styleUsesVariable(avatarStyleName.value, 'fontWeight')
|
||||
: false,
|
||||
);
|
||||
|
||||
const components = computed(() => {
|
||||
const result: ComponentInfo[] = [];
|
||||
const style = loadedStyle.value;
|
||||
|
||||
// OptionsDescriptor already filters out alias components, so every
|
||||
// `*Variant` enum here is guaranteed to be a non-alias source.
|
||||
for (const [key, field] of Object.entries(descriptor.value)) {
|
||||
if (!key.endsWith('Variant') || field.type !== 'enum' || !field.weighted) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const name = key.replace(/Variant$/, '');
|
||||
const comp = style?.components().get(name);
|
||||
const variantNames = (field.values as string[]) ?? [];
|
||||
const defaultWeights = comp
|
||||
? Object.fromEntries(
|
||||
[...comp.variants()].map(([n, v]) => [n, v.weight()]),
|
||||
)
|
||||
: {};
|
||||
const hasNonDefaultWeights = Object.values(defaultWeights).some(
|
||||
(w) => w !== 1,
|
||||
);
|
||||
|
||||
result.push({
|
||||
name,
|
||||
variants: variantNames,
|
||||
hasProbability: `${name}Probability` in descriptor.value,
|
||||
defaultProbability: comp?.probability() ?? 100,
|
||||
hasNonDefaultWeights,
|
||||
defaultWeights,
|
||||
});
|
||||
}
|
||||
|
||||
result.sort((a, b) => a.name.localeCompare(b.name));
|
||||
|
||||
return result;
|
||||
});
|
||||
|
||||
const allColors = computed(() => {
|
||||
const result: ColorInfo[] = [];
|
||||
|
||||
for (const [key, field] of Object.entries(descriptor.value)) {
|
||||
if (field.type !== 'color' || !key.endsWith('Color')) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (
|
||||
key.endsWith('ColorFill') ||
|
||||
key.endsWith('ColorAngle') ||
|
||||
key.endsWith('ColorFillStops')
|
||||
) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const name = key.replace(/Color$/, '');
|
||||
|
||||
result.push({
|
||||
name,
|
||||
key,
|
||||
defaultValues: styleColors.value[name] ?? [],
|
||||
hasFill: `${key}Fill` in descriptor.value,
|
||||
hasAngle: `${key}Angle` in descriptor.value,
|
||||
hasFillStops: `${key}FillStops` in descriptor.value,
|
||||
contrastTo:
|
||||
field.type === 'color' && typeof field.contrastTo === 'string'
|
||||
? field.contrastTo
|
||||
: null,
|
||||
});
|
||||
}
|
||||
|
||||
return result;
|
||||
});
|
||||
|
||||
const sortedColors = computed(() => {
|
||||
const bg = allColors.value.filter((c) => c.name === 'background');
|
||||
const rest = allColors.value.filter((c) => c.name !== 'background');
|
||||
|
||||
rest.sort((a, b) => a.name.localeCompare(b.name));
|
||||
|
||||
return [...bg, ...rest];
|
||||
});
|
||||
|
||||
const openColorPanels = ref<string[]>([]);
|
||||
watch(avatarStyleName, () => {
|
||||
openColorPanels.value = [];
|
||||
});
|
||||
|
||||
async function navigateToColor(colorName: string) {
|
||||
const targetKey = `${colorName}Color`;
|
||||
|
||||
if (!openColorPanels.value.includes(targetKey)) {
|
||||
openColorPanels.value = [...openColorPanels.value, targetKey];
|
||||
}
|
||||
|
||||
await nextTick();
|
||||
|
||||
const header = document.querySelector(
|
||||
`[id$="_accordionheader_${targetKey}"]`,
|
||||
);
|
||||
|
||||
if (header instanceof HTMLElement) {
|
||||
header.scrollIntoView({ behavior: 'smooth', block: 'start' });
|
||||
header.focus({ preventScroll: true });
|
||||
}
|
||||
}
|
||||
|
||||
provide(navigateToColorKey, navigateToColor);
|
||||
|
||||
function activeCount(comp: ComponentInfo): number {
|
||||
const val = store.avatarStyleOptions[`${comp.name}Variant`];
|
||||
|
||||
if (val === undefined) return comp.variants.length;
|
||||
if (Array.isArray(val)) return val.length;
|
||||
if (typeof val === 'object')
|
||||
return Object.values(val as Record<string, number>).filter((w) => w > 0)
|
||||
.length;
|
||||
if (typeof val === 'string') return 1;
|
||||
|
||||
return comp.variants.length;
|
||||
}
|
||||
|
||||
function colorCount(color: ColorInfo): number {
|
||||
const val = store.avatarStyleOptions[color.key];
|
||||
|
||||
if (Array.isArray(val)) return val.length;
|
||||
|
||||
return color.defaultValues.length;
|
||||
}
|
||||
|
||||
function randomizeSeed() {
|
||||
seed.value = Math.random().toString(36).substring(2, 10);
|
||||
|
||||
track('Playground: Seed Randomized', {
|
||||
style: styleLabel(avatarStyleName.value),
|
||||
});
|
||||
}
|
||||
|
||||
const sizeKey = 'size';
|
||||
const titleKey = 'title';
|
||||
const idRandomizationKey = 'idRandomization';
|
||||
|
||||
const size = computed({
|
||||
get: () => {
|
||||
const val = store.avatarStyleOptions[sizeKey];
|
||||
|
||||
return typeof val === 'number' ? val : null;
|
||||
},
|
||||
set: (val: number | null) => {
|
||||
if (val === null || Number.isNaN(val)) {
|
||||
delete store.avatarStyleOptions[sizeKey];
|
||||
} else {
|
||||
store.avatarStyleOptions[sizeKey] = val;
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
const title = computed({
|
||||
get: () => {
|
||||
const val = store.avatarStyleOptions[titleKey];
|
||||
|
||||
return typeof val === 'string' ? val : '';
|
||||
},
|
||||
set: (val: string) => {
|
||||
if (val === '') {
|
||||
delete store.avatarStyleOptions[titleKey];
|
||||
} else {
|
||||
store.avatarStyleOptions[titleKey] = val;
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
const idRandomization = computed({
|
||||
get: () => store.avatarStyleOptions[idRandomizationKey] === true,
|
||||
set: (val: boolean) => {
|
||||
if (val) {
|
||||
store.avatarStyleOptions[idRandomizationKey] = true;
|
||||
} else {
|
||||
delete store.avatarStyleOptions[idRandomizationKey];
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
const onSeedFocus = (e: FocusEvent) => {
|
||||
const input = e.target as HTMLInputElement;
|
||||
|
||||
requestAnimationFrame(() => {
|
||||
input.setSelectionRange(0, input.value.length);
|
||||
});
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="pg-options">
|
||||
<div class="pg-options-group">
|
||||
<h3 class="pg-options-group-title">Avatar Style</h3>
|
||||
<PlaygroundStyleSelect />
|
||||
</div>
|
||||
|
||||
<div class="pg-options-group">
|
||||
<h3 class="pg-options-group-title">General</h3>
|
||||
<Accordion
|
||||
:multiple="true"
|
||||
:value="['__seed']"
|
||||
class="pg-options-accordion"
|
||||
>
|
||||
<AccordionPanel value="__seed">
|
||||
<AccordionHeader>
|
||||
<span class="pg-options-label">Seed</span>
|
||||
</AccordionHeader>
|
||||
<AccordionContent>
|
||||
<div class="pg-options-seed-row">
|
||||
<InputText
|
||||
v-model="seed"
|
||||
placeholder="Enter a seed"
|
||||
class="pg-options-seed"
|
||||
@focus="onSeedFocus"
|
||||
/>
|
||||
<Button
|
||||
severity="secondary"
|
||||
variant="outlined"
|
||||
v-tooltip="'Random seed'"
|
||||
@click="randomizeSeed"
|
||||
>
|
||||
<Shuffle :size="16" />
|
||||
</Button>
|
||||
</div>
|
||||
<p class="pg-options-seed-help">
|
||||
The seed is the starting value used to generate the avatar.
|
||||
<strong>The same seed always produces the same avatar</strong>, so
|
||||
you can reuse it whenever you need the exact same result. For
|
||||
privacy, prefer an opaque identifier such as a random string or
|
||||
hashed user ID instead of personal data like names or email
|
||||
addresses.
|
||||
</p>
|
||||
</AccordionContent>
|
||||
</AccordionPanel>
|
||||
<AccordionPanel v-if="hasFontFamily || hasFontWeight" value="__font">
|
||||
<AccordionHeader>
|
||||
<span class="pg-options-label">Font</span>
|
||||
</AccordionHeader>
|
||||
<AccordionContent>
|
||||
<PlaygroundFontSection
|
||||
:key="avatarStyleName"
|
||||
:has-font-family="hasFontFamily"
|
||||
:has-font-weight="hasFontWeight"
|
||||
/>
|
||||
</AccordionContent>
|
||||
</AccordionPanel>
|
||||
<AccordionPanel value="__transform">
|
||||
<AccordionHeader>
|
||||
<span class="pg-options-label">Transform</span>
|
||||
</AccordionHeader>
|
||||
<AccordionContent>
|
||||
<PlaygroundTransformSection :key="avatarStyleName" />
|
||||
</AccordionContent>
|
||||
</AccordionPanel>
|
||||
<AccordionPanel value="__output">
|
||||
<AccordionHeader>
|
||||
<span class="pg-options-label">Output</span>
|
||||
</AccordionHeader>
|
||||
<AccordionContent>
|
||||
<div class="pg-options-output">
|
||||
<div class="pg-field">
|
||||
<div class="pg-field-label">
|
||||
<span>Size</span>
|
||||
<PlaygroundFieldReset
|
||||
v-if="store.isOptionSet(sizeKey)"
|
||||
@click="store.resetOption(sizeKey)"
|
||||
/>
|
||||
</div>
|
||||
<InputNumber
|
||||
v-model="size"
|
||||
:min="1"
|
||||
:max="4096"
|
||||
:step="1"
|
||||
:use-grouping="false"
|
||||
placeholder="Auto"
|
||||
suffix=" px"
|
||||
show-buttons
|
||||
button-layout="horizontal"
|
||||
:input-style="{ width: '6em', textAlign: 'center' }"
|
||||
>
|
||||
<template #incrementicon>+</template>
|
||||
<template #decrementicon>−</template>
|
||||
</InputNumber>
|
||||
<p class="pg-options-field-help">
|
||||
Output size in pixels. If left empty, the avatar scales to
|
||||
100% of its container.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="pg-field">
|
||||
<div class="pg-field-label">
|
||||
<span>Title</span>
|
||||
<PlaygroundFieldReset
|
||||
v-if="store.isOptionSet(titleKey)"
|
||||
@click="store.resetOption(titleKey)"
|
||||
/>
|
||||
</div>
|
||||
<InputText
|
||||
v-model="title"
|
||||
placeholder="Accessible title"
|
||||
class="pg-options-input"
|
||||
/>
|
||||
<p class="pg-options-field-help">
|
||||
Accessible <code><title></code> element rendered inside
|
||||
the SVG. Useful for screen readers.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="pg-field">
|
||||
<div class="pg-field-label pg-options-toggle-row">
|
||||
<ToggleSwitch v-model="idRandomization" />
|
||||
<span>Randomize element IDs</span>
|
||||
<PlaygroundFieldReset
|
||||
v-if="store.isOptionSet(idRandomizationKey)"
|
||||
@click="store.resetOption(idRandomizationKey)"
|
||||
/>
|
||||
</div>
|
||||
<p class="pg-options-field-help">
|
||||
Randomizes all SVG element IDs to avoid conflicts when
|
||||
embedding multiple avatars in the same page.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<p class="pg-options-field-help pg-options-field-help-warn">
|
||||
<strong>Title</strong> and
|
||||
<strong>Randomize element IDs</strong>
|
||||
are not supported by our public
|
||||
<a href="/how-to-use/http-api/">HTTP-API</a>. You can enable
|
||||
them by
|
||||
<a href="/guides/host-the-http-api-yourself/"
|
||||
>hosting your own instance</a
|
||||
>.
|
||||
</p>
|
||||
</div>
|
||||
</AccordionContent>
|
||||
</AccordionPanel>
|
||||
</Accordion>
|
||||
</div>
|
||||
|
||||
<div class="pg-options-group" v-if="components.length > 0">
|
||||
<h3 class="pg-options-group-title">Components</h3>
|
||||
<Accordion :multiple="true" class="pg-options-accordion">
|
||||
<AccordionPanel
|
||||
v-for="comp in components"
|
||||
:key="`${avatarStyleName}-${comp.name}`"
|
||||
:value="comp.name"
|
||||
>
|
||||
<AccordionHeader>
|
||||
<span class="pg-options-label">{{ capitalCase(comp.name) }}</span>
|
||||
<Tag
|
||||
:value="`${activeCount(comp)}/${comp.variants.length}`"
|
||||
severity="secondary"
|
||||
class="pg-options-tag"
|
||||
/>
|
||||
</AccordionHeader>
|
||||
<AccordionContent>
|
||||
<PlaygroundComponentSection
|
||||
:key="`${avatarStyleName}-${comp.name}`"
|
||||
:component-name="comp.name"
|
||||
:variants="comp.variants"
|
||||
:has-probability="comp.hasProbability"
|
||||
:default-probability="comp.defaultProbability"
|
||||
:has-non-default-weights="comp.hasNonDefaultWeights"
|
||||
:default-weights="comp.defaultWeights"
|
||||
/>
|
||||
</AccordionContent>
|
||||
</AccordionPanel>
|
||||
</Accordion>
|
||||
</div>
|
||||
|
||||
<div class="pg-options-group" v-if="allColors.length > 0">
|
||||
<h3 class="pg-options-group-title">Colors</h3>
|
||||
<Accordion
|
||||
v-model:value="openColorPanels"
|
||||
:multiple="true"
|
||||
class="pg-options-accordion"
|
||||
>
|
||||
<AccordionPanel
|
||||
v-for="color in sortedColors"
|
||||
:key="`${avatarStyleName}-${color.key}`"
|
||||
:value="color.key"
|
||||
>
|
||||
<AccordionHeader>
|
||||
<span class="pg-options-label">{{ capitalCase(color.name) }}</span>
|
||||
<Tag
|
||||
:value="String(colorCount(color))"
|
||||
severity="secondary"
|
||||
class="pg-options-tag"
|
||||
/>
|
||||
</AccordionHeader>
|
||||
<AccordionContent>
|
||||
<PlaygroundColorSection
|
||||
:key="`${avatarStyleName}-${color.key}`"
|
||||
:color-name="color.name"
|
||||
:default-values="color.defaultValues"
|
||||
:has-fill="color.hasFill"
|
||||
:has-angle="color.hasAngle"
|
||||
:has-fill-stops="color.hasFillStops"
|
||||
:contrast-to="color.contrastTo"
|
||||
/>
|
||||
</AccordionContent>
|
||||
</AccordionPanel>
|
||||
</Accordion>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.pg-options {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 24px;
|
||||
}
|
||||
|
||||
.pg-options-group {
|
||||
&-title {
|
||||
font-size: 11px;
|
||||
font-weight: 700;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.08em;
|
||||
color: var(--ui-c-text-subtle);
|
||||
margin: 0 0 8px 2px;
|
||||
}
|
||||
}
|
||||
|
||||
.pg-options-accordion {
|
||||
border: 1px solid var(--pg-border);
|
||||
border-radius: var(--vp-radius-sm);
|
||||
overflow: hidden;
|
||||
|
||||
:deep(.p-accordionpanel:last-child) {
|
||||
border-width: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.pg-options-label {
|
||||
font-weight: 600;
|
||||
flex: 1;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.pg-options-tag {
|
||||
margin-right: 8px;
|
||||
}
|
||||
|
||||
.pg-options-seed-row {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.pg-options-seed {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.pg-options-seed-help {
|
||||
margin: 8px 0 0;
|
||||
font-size: 12px;
|
||||
line-height: 1.5;
|
||||
color: var(--ui-c-text-muted);
|
||||
}
|
||||
|
||||
.pg-options-output {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 20px;
|
||||
}
|
||||
|
||||
.pg-options-input {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.pg-options-toggle-row {
|
||||
flex-wrap: wrap;
|
||||
|
||||
span {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.pg-options-field-help {
|
||||
margin: 0;
|
||||
font-size: 12px;
|
||||
line-height: 1.5;
|
||||
color: var(--ui-c-text-muted);
|
||||
|
||||
a {
|
||||
color: var(--vp-c-brand-1);
|
||||
text-decoration: underline;
|
||||
|
||||
&:hover {
|
||||
color: var(--vp-c-brand-2);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.pg-options-field-help-warn {
|
||||
padding: 8px 10px;
|
||||
border-radius: var(--vp-radius-xs);
|
||||
background: color-mix(in srgb, var(--p-orange-500) 10%, transparent);
|
||||
color: var(--ui-c-text);
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,187 @@
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue';
|
||||
import { UiAvatar, UiCard } from '../ui';
|
||||
import useStore from '@theme/stores/playground';
|
||||
import PlaygroundLicenseText from './PlaygroundLicenseText.vue';
|
||||
import PlaygroundButtonDownload from './PlaygroundButtonDownload.vue';
|
||||
import PlaygroundButtonCopy from './PlaygroundButtonCopy.vue';
|
||||
import PlaygroundButtonHowToUse from './PlaygroundButtonHowToUse.vue';
|
||||
import PlaygroundCombinationCount from './PlaygroundCombinationCount.vue';
|
||||
import PlaygroundDefinitionInfo from './PlaygroundDefinitionInfo.vue';
|
||||
import PlaygroundDocumentationLink from './PlaygroundDocumentationLink.vue';
|
||||
|
||||
const props = defineProps<{
|
||||
seed: string;
|
||||
}>();
|
||||
|
||||
const store = useStore();
|
||||
|
||||
const styleOptions = computed(() => ({
|
||||
...store.avatarStyleOptionsWithoutDefaults,
|
||||
seed: props.seed,
|
||||
}));
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="pg-preview">
|
||||
<div class="pg-preview-frame">
|
||||
<div class="pg-preview-canvas">
|
||||
<UiAvatar
|
||||
:size="320"
|
||||
:style-name="store.avatarStyleName"
|
||||
:style-options="styleOptions"
|
||||
mode="library"
|
||||
class="pg-preview-avatar"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="pg-preview-actions">
|
||||
<PlaygroundButtonHowToUse :seed="seed" />
|
||||
<PlaygroundButtonDownload :seed="seed" />
|
||||
<PlaygroundButtonCopy :seed="seed" />
|
||||
</div>
|
||||
|
||||
<div class="pg-preview-meta">
|
||||
<h3 class="pg-preview-meta-title">Details</h3>
|
||||
<UiCard padding="sm" flush>
|
||||
<div class="pg-preview-meta-row">
|
||||
<PlaygroundCombinationCount />
|
||||
</div>
|
||||
<div v-if="!store.isCustomStyle" class="pg-preview-meta-row">
|
||||
<PlaygroundDocumentationLink />
|
||||
</div>
|
||||
<div v-if="!store.isCustomStyle" class="pg-preview-meta-row">
|
||||
<PlaygroundDefinitionInfo />
|
||||
</div>
|
||||
<div class="pg-preview-meta-row pg-preview-meta-license">
|
||||
<PlaygroundLicenseText />
|
||||
</div>
|
||||
</UiCard>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.pg-preview {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.pg-preview-frame {
|
||||
width: 100%;
|
||||
border-radius: var(--vp-radius-sm);
|
||||
border: 1px solid var(--pg-border);
|
||||
overflow: hidden;
|
||||
background: var(--vp-c-bg);
|
||||
}
|
||||
|
||||
.pg-preview-canvas {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
width: 100%;
|
||||
padding: 40px 24px;
|
||||
background: repeating-conic-gradient(
|
||||
var(--vp-c-bg-soft) 0% 25%,
|
||||
var(--vp-c-bg) 0% 50%
|
||||
)
|
||||
50% / 20px 20px;
|
||||
|
||||
@media (max-width: 540px) {
|
||||
padding: 24px 16px;
|
||||
}
|
||||
}
|
||||
|
||||
.pg-preview-avatar {
|
||||
--ui-avatar-bg-1: transparent;
|
||||
--ui-avatar-bg-2: transparent;
|
||||
|
||||
max-width: 100%;
|
||||
height: auto;
|
||||
|
||||
@media (max-width: 540px) {
|
||||
max-width: 220px;
|
||||
}
|
||||
|
||||
:deep(svg),
|
||||
:deep(img) {
|
||||
max-width: 100%;
|
||||
height: auto;
|
||||
}
|
||||
}
|
||||
|
||||
.pg-preview-meta {
|
||||
width: 100%;
|
||||
margin-top: 16px;
|
||||
/* Match the left config accordion's surface (PrimeVue content background =
|
||||
--vp-c-bg-soft) instead of the elevated card surface, so the details panel
|
||||
reads as the same surface as the options accordion on the left. */
|
||||
--ui-card-bg: var(--p-content-background);
|
||||
}
|
||||
|
||||
.pg-preview-meta-title {
|
||||
font-size: 11px;
|
||||
font-weight: 700;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.08em;
|
||||
color: var(--ui-c-text-subtle);
|
||||
margin: 0 0 8px 2px;
|
||||
border: none;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.pg-preview-meta-row {
|
||||
padding: 12px 16px;
|
||||
|
||||
& + & {
|
||||
border-top: 1px solid var(--ui-card-border-color);
|
||||
}
|
||||
}
|
||||
|
||||
.pg-preview-meta-license {
|
||||
font-size: 12px;
|
||||
line-height: 1.45;
|
||||
|
||||
:deep(p) {
|
||||
margin: 0;
|
||||
font-size: inherit;
|
||||
line-height: inherit;
|
||||
color: var(--ui-c-text-muted);
|
||||
}
|
||||
|
||||
:deep(a) {
|
||||
color: var(--ui-c-text-muted);
|
||||
text-decoration: underline;
|
||||
text-decoration-color: var(--pg-border);
|
||||
text-underline-offset: 2px;
|
||||
transition:
|
||||
color var(--duration-fast),
|
||||
text-decoration-color var(--duration-fast);
|
||||
|
||||
&:hover {
|
||||
color: var(--vp-c-brand-1);
|
||||
text-decoration-color: var(--vp-c-brand-1);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.pg-preview-actions {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px;
|
||||
width: 100%;
|
||||
|
||||
:deep(> *) {
|
||||
flex: 1 1 160px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
:deep(button) {
|
||||
width: 100%;
|
||||
justify-content: center;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,79 @@
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue';
|
||||
import Slider from 'primevue/slider';
|
||||
import Button from 'primevue/button';
|
||||
import { ArrowLeftRight } from '@lucide/vue';
|
||||
import useStore from '@theme/stores/playground';
|
||||
import { useRangeField } from '@theme/composables/useRangeField';
|
||||
import PlaygroundFieldReset from './PlaygroundFieldReset.vue';
|
||||
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
label: string;
|
||||
optionKey: string;
|
||||
min: number;
|
||||
max: number;
|
||||
step: number;
|
||||
unit?: string;
|
||||
defaultSingle: number;
|
||||
}>(),
|
||||
{
|
||||
unit: '',
|
||||
},
|
||||
);
|
||||
|
||||
const store = useStore();
|
||||
const {
|
||||
isRangeMode,
|
||||
toggleRangeMode,
|
||||
resetRangeField,
|
||||
singleComputed,
|
||||
rangeComputed,
|
||||
} = useRangeField(store.avatarStyleOptions);
|
||||
|
||||
const singleVal = singleComputed(props.optionKey, () => props.defaultSingle);
|
||||
const rangeVal = rangeComputed(props.optionKey, () => props.defaultSingle);
|
||||
|
||||
const displayRange = computed<[number, number]>(() => {
|
||||
const [a, b] = rangeVal.value;
|
||||
|
||||
return [Math.min(a, b), Math.max(a, b)];
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="pg-field">
|
||||
<div class="pg-field-label">
|
||||
<span>{{ label }}</span>
|
||||
<Button
|
||||
size="small"
|
||||
:severity="isRangeMode(optionKey) ? 'primary' : 'secondary'"
|
||||
variant="outlined"
|
||||
v-tooltip="
|
||||
isRangeMode(optionKey) ? 'Switch to fixed value' : 'Switch to range'
|
||||
"
|
||||
@click="toggleRangeMode(optionKey, defaultSingle)"
|
||||
class="pg-field-toggle"
|
||||
>
|
||||
<ArrowLeftRight :size="14" />
|
||||
</Button>
|
||||
<PlaygroundFieldReset
|
||||
v-if="store.isOptionSet(optionKey)"
|
||||
@click="resetRangeField(optionKey)"
|
||||
/>
|
||||
<span class="pg-field-value" v-if="isRangeMode(optionKey)"
|
||||
>{{ displayRange[0] }}{{ unit }} — {{ displayRange[1] }}{{ unit }}</span
|
||||
>
|
||||
<span class="pg-field-value" v-else>{{ singleVal }}{{ unit }}</span>
|
||||
</div>
|
||||
<Slider
|
||||
v-if="isRangeMode(optionKey)"
|
||||
v-model="rangeVal"
|
||||
:range="true"
|
||||
:min="min"
|
||||
:max="max"
|
||||
:step="step"
|
||||
/>
|
||||
<Slider v-else v-model="singleVal" :min="min" :max="max" :step="step" />
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,456 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, computed } from 'vue';
|
||||
import { capitalCase } from 'change-case';
|
||||
import { useData } from 'vitepress';
|
||||
import { storeToRefs } from 'pinia';
|
||||
import { Plus, Trash2 } from '@lucide/vue';
|
||||
import ChevronRightIcon from '@primevue/icons/chevronright';
|
||||
import { useStyleFiltering } from '@theme/composables/useStyleFiltering';
|
||||
import { CUSTOM_CATEGORY } from '@theme/config/styleCategories';
|
||||
import useStore from '@theme/stores/playground';
|
||||
import { ThemeOptions } from '@theme/types';
|
||||
import { UiAvatar } from '../ui';
|
||||
import { track, styleLabel } from '@theme/utils/track';
|
||||
import PlaygroundCustomStyleUpload from './PlaygroundCustomStyleUpload.vue';
|
||||
import Dialog from 'primevue/dialog';
|
||||
import InputText from 'primevue/inputtext';
|
||||
import MultiSelect from 'primevue/multiselect';
|
||||
import Tag from 'primevue/tag';
|
||||
|
||||
const store = useStore();
|
||||
const { avatarStyleName, customStyles } = storeToRefs(store);
|
||||
const { theme } = useData<ThemeOptions>();
|
||||
|
||||
const open = ref(false);
|
||||
const uploadOpen = ref(false);
|
||||
|
||||
const {
|
||||
searchQuery,
|
||||
selectedCategories,
|
||||
selectedLicenses,
|
||||
availableCategories,
|
||||
availableLicenses,
|
||||
groupedStyles,
|
||||
styleList,
|
||||
} = useStyleFiltering(theme.value.avatarStyles, customStyles);
|
||||
|
||||
function selectStyle(name: string) {
|
||||
avatarStyleName.value = name;
|
||||
open.value = false;
|
||||
|
||||
track('Playground: Style Selected', { style: styleLabel(name) });
|
||||
}
|
||||
|
||||
function onCustomStyleAdded(key: string) {
|
||||
uploadOpen.value = false;
|
||||
selectStyle(key);
|
||||
}
|
||||
|
||||
function deleteCustomStyle(key: string, event: Event) {
|
||||
event.stopPropagation();
|
||||
store.removeCustomStyle(key);
|
||||
}
|
||||
|
||||
const customStyleList = computed(() => {
|
||||
const query = searchQuery.value.toLowerCase().trim();
|
||||
|
||||
return Object.entries(store.customStyles)
|
||||
.map(([key, entry]) => ({ key, name: entry.name }))
|
||||
.filter((cs) => !query || cs.name.toLowerCase().includes(query));
|
||||
});
|
||||
|
||||
const builtInGroupedStyles = computed(() => {
|
||||
const result: Record<string, (typeof groupedStyles.value)[string]> = {};
|
||||
|
||||
for (const [category, styles] of Object.entries(groupedStyles.value)) {
|
||||
if (category !== CUSTOM_CATEGORY) {
|
||||
result[category] = styles;
|
||||
}
|
||||
}
|
||||
|
||||
return result;
|
||||
});
|
||||
|
||||
const currentDisplayName = computed(() => {
|
||||
if (store.isCustomStyle) {
|
||||
return store.customStyles[avatarStyleName.value]?.name ?? 'Custom Style';
|
||||
}
|
||||
|
||||
return capitalCase(avatarStyleName.value);
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<button type="button" class="pg-style-select-trigger" @click="open = true">
|
||||
<span class="pg-style-select-trigger-avatar">
|
||||
<UiAvatar
|
||||
:size="40"
|
||||
:style-name="avatarStyleName"
|
||||
:style-options="{ seed: 'JD' }"
|
||||
mode="library"
|
||||
/>
|
||||
</span>
|
||||
|
||||
<span class="pg-style-select-trigger-name">{{ currentDisplayName }}</span>
|
||||
|
||||
<ChevronRightIcon class="pg-style-select-trigger-chevron" />
|
||||
</button>
|
||||
|
||||
<Dialog
|
||||
v-model:visible="open"
|
||||
modal
|
||||
:closable="true"
|
||||
dismissable-mask
|
||||
header="Choose Avatar Style"
|
||||
:style="{ width: '900px', maxWidth: 'calc(100vw - 32px)' }"
|
||||
:pt="{ content: { class: 'pg-style-select-dialog-content' } }"
|
||||
>
|
||||
<div class="pg-style-select">
|
||||
<div class="pg-style-select-toolbar">
|
||||
<InputText v-model="searchQuery" placeholder="Search styles..." fluid />
|
||||
|
||||
<MultiSelect
|
||||
v-model="selectedCategories"
|
||||
:options="availableCategories"
|
||||
placeholder="Filter by category"
|
||||
:showToggleAll="false"
|
||||
fluid
|
||||
/>
|
||||
|
||||
<MultiSelect
|
||||
v-model="selectedLicenses"
|
||||
:options="availableLicenses"
|
||||
placeholder="Filter by license"
|
||||
:showToggleAll="false"
|
||||
fluid
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="pg-style-select-body">
|
||||
<div
|
||||
v-if="
|
||||
selectedCategories.length === 0 ||
|
||||
selectedCategories.includes(CUSTOM_CATEGORY)
|
||||
"
|
||||
class="pg-style-select-group"
|
||||
>
|
||||
<h3 class="pg-style-select-group-title">Custom</h3>
|
||||
<div class="pg-style-select-grid">
|
||||
<button
|
||||
class="pg-style-select-card pg-style-select-card-add"
|
||||
@click="uploadOpen = true"
|
||||
>
|
||||
<div class="pg-style-select-card-add-icon">
|
||||
<Plus :size="24" />
|
||||
</div>
|
||||
<span class="pg-style-select-card-name">Add Custom Style</span>
|
||||
</button>
|
||||
|
||||
<div
|
||||
v-for="cs in customStyleList"
|
||||
:key="cs.key"
|
||||
class="pg-style-select-card"
|
||||
:class="{
|
||||
'pg-style-select-card-selected': cs.key === avatarStyleName,
|
||||
}"
|
||||
@click="selectStyle(cs.key)"
|
||||
>
|
||||
<button
|
||||
class="pg-style-select-card-delete"
|
||||
@click="deleteCustomStyle(cs.key, $event)"
|
||||
v-tooltip="'Remove'"
|
||||
>
|
||||
<Trash2 :size="12" />
|
||||
</button>
|
||||
<div class="pg-style-select-card-avatars">
|
||||
<UiAvatar
|
||||
v-for="seed in ['Felix', 'Aneka', 'Milo', 'Luna']"
|
||||
:key="seed"
|
||||
:size="40"
|
||||
:style-name="cs.key"
|
||||
:style-options="{ seed }"
|
||||
mode="library"
|
||||
/>
|
||||
</div>
|
||||
<div class="pg-style-select-card-info">
|
||||
<span class="pg-style-select-card-name">{{ cs.name }}</span>
|
||||
<Tag
|
||||
value="Custom"
|
||||
severity="warn"
|
||||
class="pg-style-select-card-tag"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-for="(styles, category) in builtInGroupedStyles"
|
||||
:key="category"
|
||||
class="pg-style-select-group"
|
||||
>
|
||||
<h3 class="pg-style-select-group-title">{{ category }}</h3>
|
||||
<div class="pg-style-select-grid">
|
||||
<button
|
||||
v-for="style in styles"
|
||||
:key="style.name"
|
||||
class="pg-style-select-card"
|
||||
:class="{
|
||||
'pg-style-select-card-selected': style.name === avatarStyleName,
|
||||
}"
|
||||
@click="selectStyle(style.name)"
|
||||
>
|
||||
<div class="pg-style-select-card-avatars">
|
||||
<UiAvatar
|
||||
v-for="avatar in style.avatars"
|
||||
:key="avatar.seed"
|
||||
:size="40"
|
||||
:style-name="style.name"
|
||||
:style-options="{ seed: avatar.seed }"
|
||||
mode="http-api"
|
||||
/>
|
||||
</div>
|
||||
<div class="pg-style-select-card-info">
|
||||
<span class="pg-style-select-card-name">{{
|
||||
style.displayName
|
||||
}}</span>
|
||||
</div>
|
||||
<span class="pg-style-select-card-creator">{{
|
||||
style.creator
|
||||
}}</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-if="styleList.length === 0 && searchQuery"
|
||||
class="pg-style-select-empty"
|
||||
>
|
||||
No styles found matching "{{ searchQuery }}"
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Dialog>
|
||||
|
||||
<PlaygroundCustomStyleUpload
|
||||
v-model:open="uploadOpen"
|
||||
@added="onCustomStyleAdded"
|
||||
/>
|
||||
</template>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.pg-style-select-trigger {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
width: 100%;
|
||||
padding: 8px 16px 8px 8px;
|
||||
background: var(--p-content-background);
|
||||
border: 1px solid var(--pg-border);
|
||||
border-radius: var(--vp-radius-xs);
|
||||
color: var(--p-accordion-header-color);
|
||||
cursor: pointer;
|
||||
text-align: left;
|
||||
transition: color var(--duration-fast);
|
||||
|
||||
&:hover {
|
||||
color: var(--p-accordion-header-hover-color);
|
||||
}
|
||||
|
||||
&:hover &-chevron {
|
||||
color: var(--p-accordion-header-toggle-icon-hover-color);
|
||||
}
|
||||
|
||||
&:focus-visible {
|
||||
outline: none;
|
||||
border-color: var(--p-form-field-focus-border-color);
|
||||
}
|
||||
|
||||
&-avatar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
flex-shrink: 0;
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
background: var(--vp-c-bg-soft);
|
||||
border-radius: var(--vp-radius-xs);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
&-name {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
line-height: 1.2;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
&-chevron {
|
||||
flex-shrink: 0;
|
||||
margin-left: auto;
|
||||
color: var(--p-accordion-header-toggle-icon-color);
|
||||
transition: color var(--duration-fast);
|
||||
}
|
||||
}
|
||||
|
||||
.pg-style-select {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.pg-style-select-toolbar {
|
||||
display: grid;
|
||||
grid-template-columns: 2fr 1fr 1fr;
|
||||
gap: 12px;
|
||||
|
||||
@media (max-width: 600px) {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
|
||||
.pg-style-select-body {
|
||||
max-height: 60vh;
|
||||
overflow-y: auto;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 24px;
|
||||
}
|
||||
|
||||
.pg-style-select-group-title {
|
||||
font-size: 11px;
|
||||
font-weight: 700;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.08em;
|
||||
color: var(--ui-c-text-subtle);
|
||||
margin: 0 0 8px;
|
||||
}
|
||||
|
||||
.pg-style-select-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(180px, 1fr));
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.pg-style-select-card {
|
||||
position: relative;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
padding: 12px;
|
||||
background: var(--vp-c-bg-soft);
|
||||
border: 2px solid transparent;
|
||||
border-radius: var(--vp-radius-sm);
|
||||
cursor: pointer;
|
||||
text-align: left;
|
||||
transition: all var(--duration-fast);
|
||||
|
||||
&:hover {
|
||||
border-color: var(--vp-c-brand-1);
|
||||
}
|
||||
|
||||
&-selected {
|
||||
border-color: var(--vp-c-brand-1);
|
||||
background: var(--vp-c-brand-soft);
|
||||
}
|
||||
|
||||
&-avatars {
|
||||
display: flex;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
&-info {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
&-name {
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
color: var(--vp-c-text-1);
|
||||
}
|
||||
|
||||
&-tag {
|
||||
font-size: 10px;
|
||||
}
|
||||
|
||||
&-creator {
|
||||
font-size: 12px;
|
||||
color: var(--ui-c-text-subtle);
|
||||
}
|
||||
|
||||
&-delete {
|
||||
position: absolute;
|
||||
top: 6px;
|
||||
right: 6px;
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: var(--vp-c-bg);
|
||||
border: 1px solid var(--vp-c-border);
|
||||
border-radius: var(--vp-radius-xs);
|
||||
cursor: pointer;
|
||||
opacity: 0;
|
||||
transition: all var(--duration-fast);
|
||||
color: var(--ui-c-text-subtle);
|
||||
|
||||
&:hover {
|
||||
color: var(--vp-c-danger-1);
|
||||
border-color: var(--vp-c-danger-1);
|
||||
}
|
||||
}
|
||||
|
||||
&:hover &-delete {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
&-add {
|
||||
border-style: dashed;
|
||||
border-color: var(--vp-c-border);
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
min-height: 120px;
|
||||
|
||||
&:hover {
|
||||
border-color: var(--vp-c-brand-1);
|
||||
}
|
||||
|
||||
&-icon {
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border-radius: 50%;
|
||||
background: var(--vp-c-bg);
|
||||
color: var(--ui-c-text-subtle);
|
||||
transition: all var(--duration-fast);
|
||||
}
|
||||
}
|
||||
|
||||
&-add:hover &-add-icon {
|
||||
color: var(--vp-c-brand-1);
|
||||
background: var(--vp-c-brand-soft);
|
||||
}
|
||||
}
|
||||
|
||||
.pg-style-select-empty {
|
||||
text-align: center;
|
||||
padding: 40px;
|
||||
color: var(--ui-c-text-subtle);
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
@media (max-width: 640px) {
|
||||
.pg-style-select-grid {
|
||||
grid-template-columns: repeat(auto-fill, minmax(140px, 1fr));
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,136 @@
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue';
|
||||
import Slider from 'primevue/slider';
|
||||
import SelectButton from 'primevue/selectbutton';
|
||||
import useStore from '@theme/stores/playground';
|
||||
import { useRangeField } from '@theme/composables/useRangeField';
|
||||
import PlaygroundRangeField from './PlaygroundRangeField.vue';
|
||||
import PlaygroundFieldReset from './PlaygroundFieldReset.vue';
|
||||
|
||||
const store = useStore();
|
||||
const { singleComputed } = useRangeField(store.avatarStyleOptions);
|
||||
|
||||
const flipKey = 'flip';
|
||||
const borderRadiusKey = 'borderRadius';
|
||||
|
||||
const flipOptions = ['none', 'horizontal', 'vertical', 'both'];
|
||||
|
||||
const flip = computed({
|
||||
get: () => {
|
||||
const val = store.avatarStyleOptions[flipKey];
|
||||
|
||||
return typeof val === 'string' ? val : 'none';
|
||||
},
|
||||
set: (val: string) => {
|
||||
if (val === 'none') {
|
||||
delete store.avatarStyleOptions[flipKey];
|
||||
} else {
|
||||
store.avatarStyleOptions[flipKey] = val;
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
const borderRadius = singleComputed(borderRadiusKey, 0);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="pg-transform">
|
||||
<div class="pg-transform-body">
|
||||
<div class="pg-field">
|
||||
<div class="pg-field-label">
|
||||
<span>Flip</span>
|
||||
<PlaygroundFieldReset
|
||||
v-if="store.isOptionSet(flipKey)"
|
||||
@click="store.resetOption(flipKey)"
|
||||
/>
|
||||
</div>
|
||||
<SelectButton
|
||||
v-model="flip"
|
||||
:options="flipOptions"
|
||||
:allow-empty="false"
|
||||
class="pg-transform-flip"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<PlaygroundRangeField
|
||||
label="Rotate"
|
||||
option-key="rotate"
|
||||
:min="-360"
|
||||
:max="360"
|
||||
:step="1"
|
||||
unit="°"
|
||||
:default-single="0"
|
||||
/>
|
||||
|
||||
<PlaygroundRangeField
|
||||
label="Scale"
|
||||
option-key="scale"
|
||||
:min="0"
|
||||
:max="10"
|
||||
:step="0.01"
|
||||
:default-single="1"
|
||||
/>
|
||||
|
||||
<div class="pg-field">
|
||||
<div class="pg-field-label">
|
||||
<span>Border Radius</span>
|
||||
<PlaygroundFieldReset
|
||||
v-if="store.isOptionSet(borderRadiusKey)"
|
||||
@click="store.resetOption(borderRadiusKey)"
|
||||
/>
|
||||
<span class="pg-field-value">{{ borderRadius }}</span>
|
||||
</div>
|
||||
<Slider v-model="borderRadius" :min="0" :max="50" :step="1" />
|
||||
</div>
|
||||
|
||||
<PlaygroundRangeField
|
||||
label="Translate X"
|
||||
option-key="translateX"
|
||||
:min="-100"
|
||||
:max="100"
|
||||
:step="1"
|
||||
unit="%"
|
||||
:default-single="0"
|
||||
/>
|
||||
|
||||
<PlaygroundRangeField
|
||||
label="Translate Y"
|
||||
option-key="translateY"
|
||||
:min="-100"
|
||||
:max="100"
|
||||
:step="1"
|
||||
unit="%"
|
||||
:default-single="0"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.pg-transform {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.pg-transform-body {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 24px;
|
||||
}
|
||||
|
||||
/* Let the Flip SelectButton keep its natural button widths and scroll
|
||||
horizontally on narrow viewports instead of squeezing the labels. */
|
||||
.pg-transform-flip {
|
||||
overflow-x: auto;
|
||||
scrollbar-width: none;
|
||||
|
||||
&::-webkit-scrollbar {
|
||||
display: none;
|
||||
}
|
||||
|
||||
:deep(.p-togglebutton) {
|
||||
flex: 0 0 auto;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,3 @@
|
||||
export const DOWNLOAD_AVATAR_SIZE = 512;
|
||||
export const DIALOG_PREVIEW_AVATAR_SIZE = 128;
|
||||
export const MAX_CUSTOM_STYLE_UPLOAD_BYTES = 1024 * 1024;
|
||||
@@ -0,0 +1,76 @@
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue';
|
||||
import { useData } from 'vitepress';
|
||||
import { ThemeOptions } from '@theme/types';
|
||||
import { formatLicenseName } from '@theme/utils/format';
|
||||
import { safeHttpUrl } from '@theme/utils/url';
|
||||
|
||||
const { theme } = useData<ThemeOptions>();
|
||||
const props = defineProps<{
|
||||
styleName: string;
|
||||
}>();
|
||||
|
||||
const style = computed(() => {
|
||||
return theme.value.avatarStyles[props.styleName];
|
||||
});
|
||||
|
||||
const sourceUrl = computed(() => safeHttpUrl(style.value.meta?.source));
|
||||
const homepageUrl = computed(() => safeHttpUrl(style.value.meta?.homepage));
|
||||
const licenseUrl = computed(() => safeHttpUrl(style.value.meta?.license?.url));
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="info custom-block">
|
||||
<p class="custom-block-title">LICENSE</p>
|
||||
<p>
|
||||
<template v-if="style.meta?.creator !== 'DiceBear' && style.meta?.title">
|
||||
<template v-if="style.meta?.license?.name !== 'MIT'">
|
||||
This avatar style is a remix of:
|
||||
</template>
|
||||
<template v-else> This avatar style is based on: </template>
|
||||
</template>
|
||||
<a
|
||||
v-if="sourceUrl"
|
||||
:href="sourceUrl"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
{{ style.meta?.title ?? 'Design' }}
|
||||
</a>
|
||||
<template v-else>{{ style.meta?.title ?? 'Design' }}</template>
|
||||
by
|
||||
<a
|
||||
v-if="homepageUrl"
|
||||
:href="homepageUrl"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>{{ style.meta?.creator }}</a
|
||||
>
|
||||
<template v-else>{{ style.meta?.creator }}</template
|
||||
>, licensed under
|
||||
<a
|
||||
v-if="licenseUrl"
|
||||
:href="licenseUrl"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>{{ formatLicenseName(style.meta?.license?.name) }}</a
|
||||
>
|
||||
<template v-else>{{
|
||||
formatLicenseName(style.meta?.license?.name)
|
||||
}}</template
|
||||
>.
|
||||
<template v-if="style.meta?.license?.name !== 'MIT'">
|
||||
See <a href="#details">details</a> for more information.
|
||||
</template>
|
||||
</p>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.custom-block {
|
||||
padding: 20px;
|
||||
/* Neutral elevated fill (shared framed-box surface) instead of VitePress's
|
||||
bluish info tint, so this box matches the preview frame and usage card. */
|
||||
--vp-custom-block-info-bg: var(--ui-window-bg);
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,144 @@
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue';
|
||||
import { useData } from 'vitepress';
|
||||
import { ThemeOptions } from '@theme/types';
|
||||
import { UiCard, UiCode as Code } from '../ui';
|
||||
import { kebabCase } from 'change-case';
|
||||
import { safeHttpUrl } from '@theme/utils/url';
|
||||
import DataTable from 'primevue/datatable';
|
||||
import Column from 'primevue/column';
|
||||
|
||||
const { theme } = useData<ThemeOptions>();
|
||||
|
||||
const props = defineProps<{
|
||||
styleName: string;
|
||||
}>();
|
||||
|
||||
const style = computed(() => {
|
||||
return theme.value.avatarStyles[props.styleName];
|
||||
});
|
||||
|
||||
const exampleHttpApiUrl = computed(() => {
|
||||
return `https://api.dicebear.com/10.x/${kebabCase(props.styleName)}/svg`;
|
||||
});
|
||||
|
||||
const exampleDefinitionImport = computed(() => {
|
||||
return `import definition from '@dicebear/styles/${kebabCase(props.styleName)}.json' with { type: 'json' };`;
|
||||
});
|
||||
|
||||
const exampleCliCommand = computed(() => {
|
||||
return `dicebear ${props.styleName}`;
|
||||
});
|
||||
|
||||
type Row =
|
||||
| { label: string; type: 'code'; code: string; lang?: string }
|
||||
| { label: string; type: 'link'; href: string; text?: string }
|
||||
| { label: string; type: 'text'; text: string };
|
||||
|
||||
const namingRows = computed<Row[]>(() => {
|
||||
const rows: Row[] = [
|
||||
{
|
||||
label: 'Definition Import',
|
||||
type: 'code',
|
||||
code: exampleDefinitionImport.value,
|
||||
lang: 'js',
|
||||
},
|
||||
{ label: 'CLI', type: 'code', code: exampleCliCommand.value },
|
||||
{ label: 'HTTP-API', type: 'link', href: exampleHttpApiUrl.value },
|
||||
];
|
||||
|
||||
if (style.value.definitionUrl) {
|
||||
rows.push({
|
||||
label: 'Definition',
|
||||
type: 'link',
|
||||
href: style.value.definitionUrl,
|
||||
});
|
||||
}
|
||||
|
||||
return rows;
|
||||
});
|
||||
|
||||
function linkOrText(
|
||||
label: string,
|
||||
text: string | undefined,
|
||||
url: string | undefined,
|
||||
): Row | null {
|
||||
if (!text) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const href = safeHttpUrl(url);
|
||||
|
||||
return href
|
||||
? { label, type: 'link', href, text }
|
||||
: { label, type: 'text', text };
|
||||
}
|
||||
|
||||
const sourceRows = computed<Row[]>(() => {
|
||||
const meta = style.value.meta;
|
||||
|
||||
return [
|
||||
linkOrText('Title', meta.title, undefined),
|
||||
// Creator/Website both link to the homepage URL.
|
||||
linkOrText('Creator', meta.creator, meta.homepage),
|
||||
linkOrText('Website', meta.homepage, meta.homepage),
|
||||
linkOrText('License', meta.license?.name, meta.license?.url),
|
||||
linkOrText('Source', meta.source, meta.source),
|
||||
].filter((row): row is Row => row !== null);
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<UiCard class="style-info-section" title="Naming">
|
||||
<DataTable :value="namingRows">
|
||||
<Column field="label" style="width: 200px" />
|
||||
<Column>
|
||||
<template #body="{ data }">
|
||||
<Code
|
||||
v-if="data.type === 'code'"
|
||||
:lang="data.lang"
|
||||
:code="data.code"
|
||||
/>
|
||||
<a
|
||||
v-else-if="data.type === 'link'"
|
||||
:href="data.href"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
{{ data.text ?? data.href }}
|
||||
</a>
|
||||
<template v-else>{{ data.text }}</template>
|
||||
</template>
|
||||
</Column>
|
||||
</DataTable>
|
||||
</UiCard>
|
||||
|
||||
<UiCard class="style-info-section" title="Source">
|
||||
<DataTable :value="sourceRows">
|
||||
<Column field="label" style="width: 200px" />
|
||||
<Column>
|
||||
<template #body="{ data }">
|
||||
<a
|
||||
v-if="data.type === 'link'"
|
||||
:href="data.href"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
{{ data.text ?? data.href }}
|
||||
</a>
|
||||
<template v-else>{{ data.text }}</template>
|
||||
</template>
|
||||
</Column>
|
||||
</DataTable>
|
||||
</UiCard>
|
||||
</template>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.style-info-section {
|
||||
margin-bottom: 16px;
|
||||
|
||||
:deep(.p-datatable-thead) {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,301 @@
|
||||
<script setup lang="ts">
|
||||
import { useData } from 'vitepress';
|
||||
import type { ThemeOptions } from '@theme/types';
|
||||
import { useStyleFiltering } from '@theme/composables/useStyleFiltering';
|
||||
import { Search, CircleUser, Scale, ArrowRight, Filter } from '@lucide/vue';
|
||||
import InputText from 'primevue/inputtext';
|
||||
import MultiSelect from 'primevue/multiselect';
|
||||
import { UiAvatar, UiCard } from '../ui';
|
||||
|
||||
const { theme } = useData<ThemeOptions>();
|
||||
|
||||
const {
|
||||
searchQuery,
|
||||
selectedLicenses,
|
||||
selectedCategories,
|
||||
availableLicenses,
|
||||
availableCategories,
|
||||
styleList,
|
||||
groupedStyles,
|
||||
totalStyles,
|
||||
} = useStyleFiltering(theme.value.avatarStyles);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="style-list">
|
||||
<UiCard class="style-list-filters">
|
||||
<div class="style-list-filter-row">
|
||||
<div class="style-list-filter-group">
|
||||
<span class="style-list-filter-label">
|
||||
<Search />
|
||||
Search
|
||||
</span>
|
||||
<InputText
|
||||
v-model="searchQuery"
|
||||
:placeholder="`Search ${totalStyles} styles...`"
|
||||
fluid
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="style-list-filter-group">
|
||||
<span class="style-list-filter-label">
|
||||
<Filter />
|
||||
Category
|
||||
</span>
|
||||
<MultiSelect
|
||||
v-model="selectedCategories"
|
||||
:options="availableCategories"
|
||||
placeholder="Filter by category"
|
||||
:showToggleAll="false"
|
||||
fluid
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="style-list-filter-group">
|
||||
<span class="style-list-filter-label">
|
||||
<Scale />
|
||||
License
|
||||
</span>
|
||||
<MultiSelect
|
||||
v-model="selectedLicenses"
|
||||
:options="availableLicenses"
|
||||
placeholder="Filter by license"
|
||||
:showToggleAll="false"
|
||||
fluid
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</UiCard>
|
||||
|
||||
<div
|
||||
v-for="(styles, category) in groupedStyles"
|
||||
:key="category"
|
||||
class="style-list-category"
|
||||
>
|
||||
<h2 class="style-list-category-title">{{ category }}</h2>
|
||||
<div class="style-list-grid">
|
||||
<UiCard
|
||||
v-for="style in styles"
|
||||
:key="style.name"
|
||||
:href="`/styles/${style.slug}/`"
|
||||
class="style-list-card"
|
||||
>
|
||||
<div class="style-list-card-avatars">
|
||||
<div
|
||||
v-for="avatar in style.avatars"
|
||||
:key="avatar.seed"
|
||||
class="style-list-card-avatar"
|
||||
>
|
||||
<UiAvatar
|
||||
:style-name="style.slug"
|
||||
:style-options="{ seed: avatar.seed, size: 80 }"
|
||||
:alt="`${style.displayName} - ${avatar.seed}`"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="style-list-card-content">
|
||||
<h3 class="style-list-card-name">{{ style.displayName }}</h3>
|
||||
|
||||
<div class="style-list-card-meta">
|
||||
<span class="style-list-card-creator">
|
||||
<CircleUser />
|
||||
{{ style.creator }}
|
||||
</span>
|
||||
<span class="style-list-card-license">
|
||||
<Scale />
|
||||
{{ style.license }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="style-list-card-arrow">
|
||||
<ArrowRight />
|
||||
</div>
|
||||
</UiCard>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="styleList.length === 0" class="style-list-empty">
|
||||
<p v-if="searchQuery">No styles found matching "{{ searchQuery }}"</p>
|
||||
<p v-else>No styles found with the selected filters</p>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.style-list {
|
||||
margin-top: 32px;
|
||||
|
||||
&-filters {
|
||||
margin-bottom: 32px;
|
||||
}
|
||||
|
||||
&-filter-row {
|
||||
display: grid;
|
||||
grid-template-columns: 2fr 1fr 1fr;
|
||||
gap: 16px;
|
||||
align-items: flex-start;
|
||||
}
|
||||
|
||||
&-filter-group {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
&-filter-label {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
color: var(--vp-c-text-2);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.5px;
|
||||
|
||||
svg {
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
}
|
||||
}
|
||||
|
||||
&-category {
|
||||
margin-bottom: 48px;
|
||||
|
||||
&-title {
|
||||
font-size: 24px;
|
||||
font-weight: 700;
|
||||
letter-spacing: -0.025em;
|
||||
color: var(--vp-c-text-1);
|
||||
margin: 0 0 20px;
|
||||
border-top: 0 !important;
|
||||
padding-bottom: 12px;
|
||||
border-bottom: 2px solid var(--vp-c-border);
|
||||
}
|
||||
}
|
||||
|
||||
&-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(340px, 1fr));
|
||||
gap: 20px;
|
||||
}
|
||||
|
||||
&-card {
|
||||
height: 100%;
|
||||
position: relative;
|
||||
|
||||
&-avatars {
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
&-avatar {
|
||||
width: 64px;
|
||||
height: 64px;
|
||||
border-radius: var(--vp-radius-sm);
|
||||
overflow: hidden;
|
||||
background: var(--vp-c-bg);
|
||||
box-shadow: var(--vp-shadow-1);
|
||||
flex-shrink: 0;
|
||||
|
||||
img {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
}
|
||||
}
|
||||
|
||||
&-content {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
&-name {
|
||||
font-size: 18px;
|
||||
font-weight: 600;
|
||||
color: var(--vp-c-text-1);
|
||||
margin: 0 0 8px;
|
||||
}
|
||||
|
||||
&-meta {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
&-creator,
|
||||
&-license {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
font-size: 13px;
|
||||
color: var(--vp-c-text-2);
|
||||
|
||||
svg {
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
color: var(--vp-c-text-3);
|
||||
}
|
||||
}
|
||||
|
||||
&-arrow {
|
||||
position: absolute;
|
||||
top: 20px;
|
||||
right: 20px;
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: var(--vp-c-bg);
|
||||
border-radius: var(--vp-radius-xs);
|
||||
opacity: 0;
|
||||
transform: translateX(-8px);
|
||||
transition: all var(--duration-fast) ease;
|
||||
|
||||
svg {
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
color: var(--vp-c-brand-1);
|
||||
}
|
||||
}
|
||||
|
||||
&:hover &-arrow {
|
||||
opacity: 1;
|
||||
transform: translateX(0);
|
||||
}
|
||||
}
|
||||
|
||||
&-empty {
|
||||
text-align: center;
|
||||
padding: 60px 24px;
|
||||
color: var(--vp-c-text-2);
|
||||
|
||||
p {
|
||||
margin: 0;
|
||||
font-size: 16px;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 640px) {
|
||||
&-filter-row {
|
||||
grid-template-columns: 1fr;
|
||||
gap: 20px;
|
||||
}
|
||||
|
||||
&-category-title {
|
||||
font-size: 20px;
|
||||
}
|
||||
|
||||
&-grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
&-card-arrow {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,357 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, nextTick, provide, ref, toRef } from 'vue';
|
||||
import { getScrollOffset, inBrowser } from 'vitepress';
|
||||
import { styleUsesVariable } from '@theme/utils/avatar/style';
|
||||
import { watchOnce, watchDebounced } from '@vueuse/core';
|
||||
import { track } from '@theme/utils/track';
|
||||
import { capitalCase } from 'change-case';
|
||||
import { Search } from '@lucide/vue';
|
||||
import InputText from 'primevue/inputtext';
|
||||
import StyleOptionsGroup from './StyleOptionsGroup.vue';
|
||||
import { useStyleOptions } from '@theme/composables/useStyleOptions';
|
||||
import {
|
||||
componentNamesKey,
|
||||
styleColorsKey,
|
||||
componentPreviewKey,
|
||||
styleDefaultsKey,
|
||||
} from './styleOptionsKeys';
|
||||
|
||||
type RangeLike = { min: number; max: number } | undefined;
|
||||
|
||||
function rangeDefault(
|
||||
range: RangeLike,
|
||||
fallback: number,
|
||||
): number | [number, number] {
|
||||
if (!range) {
|
||||
return fallback;
|
||||
}
|
||||
|
||||
return range.min === range.max ? range.min : [range.min, range.max];
|
||||
}
|
||||
|
||||
interface OptionGroup {
|
||||
id: string;
|
||||
label: string;
|
||||
category: 'general' | 'component' | 'color';
|
||||
options: Record<string, any>;
|
||||
}
|
||||
|
||||
const props = defineProps<{
|
||||
styleName: string;
|
||||
}>();
|
||||
|
||||
const searchQuery = ref('');
|
||||
|
||||
// Track that a user used the option filter, once per non-empty session, so a
|
||||
// multi-character query counts as a single "searched" interaction, not one per
|
||||
// keystroke.
|
||||
let searchTracked = false;
|
||||
|
||||
watchDebounced(
|
||||
searchQuery,
|
||||
(query) => {
|
||||
const trimmed = query.trim();
|
||||
|
||||
if (!trimmed) {
|
||||
searchTracked = false;
|
||||
return;
|
||||
}
|
||||
|
||||
if (!searchTracked) {
|
||||
searchTracked = true;
|
||||
track('Docs Options: Search', { style: props.styleName });
|
||||
}
|
||||
},
|
||||
{ debounce: 700 },
|
||||
);
|
||||
|
||||
const {
|
||||
loadedStyle,
|
||||
descriptor,
|
||||
componentNames,
|
||||
colorNames,
|
||||
styleColors,
|
||||
preview,
|
||||
} = useStyleOptions(toRef(() => props.styleName));
|
||||
|
||||
provide(componentNamesKey, componentNames);
|
||||
provide(componentPreviewKey, preview);
|
||||
provide(styleColorsKey, styleColors);
|
||||
|
||||
const styleDefaults = computed<Record<string, unknown>>(() => {
|
||||
if (!loadedStyle.value) {
|
||||
return {};
|
||||
}
|
||||
|
||||
const result: Record<string, unknown> = {
|
||||
flip: 'none',
|
||||
fontFamily: 'system-ui',
|
||||
fontWeight: 400,
|
||||
scale: 1,
|
||||
borderRadius: 0,
|
||||
rotate: 0,
|
||||
translateX: 0,
|
||||
translateY: 0,
|
||||
idRandomization: false,
|
||||
};
|
||||
|
||||
for (const [name, component] of loadedStyle.value.components()) {
|
||||
const variantDefaults: Record<string, number> = {};
|
||||
|
||||
for (const [v, variant] of component.variants()) {
|
||||
variantDefaults[v] = variant.weight();
|
||||
}
|
||||
|
||||
result[`${name}Variant`] = variantDefaults;
|
||||
result[`${name}Probability`] = component.probability();
|
||||
result[`${name}Rotate`] = rangeDefault(component.rotate(), 0);
|
||||
result[`${name}TranslateX`] = rangeDefault(component.translate().x(), 0);
|
||||
result[`${name}TranslateY`] = rangeDefault(component.translate().y(), 0);
|
||||
result[`${name}Scale`] = rangeDefault(component.scale(), 1);
|
||||
}
|
||||
|
||||
for (const [name, values] of Object.entries(styleColors.value)) {
|
||||
result[`${name}Color`] = values;
|
||||
result[`${name}ColorFill`] = 'solid';
|
||||
result[`${name}ColorFillStops`] = 2;
|
||||
result[`${name}ColorAngle`] = 0;
|
||||
}
|
||||
|
||||
return result;
|
||||
});
|
||||
|
||||
provide(styleDefaultsKey, styleDefaults);
|
||||
|
||||
function isComponentOption(key: string, names: string[]): boolean {
|
||||
return names.some(
|
||||
(name) =>
|
||||
key === `${name}Variant` ||
|
||||
key === `${name}Probability` ||
|
||||
key === `${name}Rotate` ||
|
||||
key === `${name}TranslateX` ||
|
||||
key === `${name}TranslateY` ||
|
||||
key === `${name}Scale`,
|
||||
);
|
||||
}
|
||||
|
||||
function isColorOption(key: string, names: string[]): boolean {
|
||||
return names.some(
|
||||
(name) =>
|
||||
key === `${name}Color` ||
|
||||
key === `${name}ColorFill` ||
|
||||
key === `${name}ColorFillStops` ||
|
||||
key === `${name}ColorAngle`,
|
||||
);
|
||||
}
|
||||
|
||||
function pick(
|
||||
source: Record<string, any>,
|
||||
keys: string[],
|
||||
): Record<string, any> {
|
||||
const result: Record<string, any> = {};
|
||||
|
||||
for (const key of keys) {
|
||||
if (key in source) {
|
||||
result[key] = source[key];
|
||||
}
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
const groups = computed<OptionGroup[]>(() => {
|
||||
if (!loadedStyle.value) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const result: OptionGroup[] = [];
|
||||
const hiddenKeys = new Set<string>();
|
||||
|
||||
if (!styleUsesVariable(props.styleName, 'fontFamily')) {
|
||||
hiddenKeys.add('fontFamily');
|
||||
}
|
||||
|
||||
if (!styleUsesVariable(props.styleName, 'fontWeight')) {
|
||||
hiddenKeys.add('fontWeight');
|
||||
}
|
||||
|
||||
const generalKeys = Object.keys(descriptor.value).filter(
|
||||
(key) =>
|
||||
!hiddenKeys.has(key) &&
|
||||
!isComponentOption(key, componentNames.value) &&
|
||||
!isColorOption(key, colorNames.value),
|
||||
);
|
||||
|
||||
if (generalKeys.length > 0) {
|
||||
result.push({
|
||||
id: 'general',
|
||||
label: 'General',
|
||||
category: 'general',
|
||||
options: pick(descriptor.value, generalKeys),
|
||||
});
|
||||
}
|
||||
|
||||
for (const name of componentNames.value) {
|
||||
const keys = Object.keys(descriptor.value).filter((k) =>
|
||||
isComponentOption(k, [name]),
|
||||
);
|
||||
|
||||
if (keys.length > 0) {
|
||||
result.push({
|
||||
id: `component-${name}`,
|
||||
label: capitalCase(name),
|
||||
category: 'component',
|
||||
options: pick(descriptor.value, keys),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
for (const name of colorNames.value) {
|
||||
const keys = Object.keys(descriptor.value).filter((k) =>
|
||||
isColorOption(k, [name]),
|
||||
);
|
||||
|
||||
if (keys.length > 0) {
|
||||
result.push({
|
||||
id: `color-${name}`,
|
||||
label: `${capitalCase(name)} Color`,
|
||||
category: 'color',
|
||||
options: pick(descriptor.value, keys),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return result;
|
||||
});
|
||||
|
||||
const showSearch = computed(() => Object.keys(descriptor.value).length > 15);
|
||||
|
||||
const filteredGroups = computed(() => {
|
||||
const query = searchQuery.value.trim().toLowerCase();
|
||||
|
||||
if (!query) {
|
||||
return groups.value;
|
||||
}
|
||||
|
||||
return groups.value
|
||||
.map((group) => {
|
||||
const filtered = Object.fromEntries(
|
||||
Object.entries(group.options).filter(([key]) =>
|
||||
key.toLowerCase().includes(query),
|
||||
),
|
||||
);
|
||||
|
||||
return { ...group, options: filtered };
|
||||
})
|
||||
.filter((group) => Object.keys(group.options).length > 0);
|
||||
});
|
||||
|
||||
// Option cards render asynchronously, so the browser's initial hash-scroll
|
||||
// runs before the target anchors exist. Re-do the scroll once they're in.
|
||||
watchOnce(loadedStyle, async (style) => {
|
||||
if (!inBrowser || !style) {
|
||||
return;
|
||||
}
|
||||
|
||||
const hash = window.location.hash;
|
||||
|
||||
if (!hash.startsWith('#options-')) {
|
||||
return;
|
||||
}
|
||||
|
||||
await nextTick();
|
||||
|
||||
let id: string;
|
||||
|
||||
try {
|
||||
id = decodeURIComponent(hash).slice(1);
|
||||
} catch {
|
||||
return;
|
||||
}
|
||||
|
||||
const target = document.getElementById(id);
|
||||
|
||||
if (!target) {
|
||||
return;
|
||||
}
|
||||
|
||||
const top =
|
||||
window.scrollY + target.getBoundingClientRect().top - getScrollOffset();
|
||||
|
||||
window.scrollTo({ left: 0, top, behavior: 'instant' });
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="style-options" v-if="loadedStyle">
|
||||
<div class="style-options-search" v-if="showSearch">
|
||||
<div class="style-options-search-wrapper">
|
||||
<Search :size="16" class="style-options-search-icon" />
|
||||
<InputText
|
||||
v-model="searchQuery"
|
||||
placeholder="Filter options..."
|
||||
class="style-options-search-input"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="style-options-groups">
|
||||
<StyleOptionsGroup
|
||||
v-for="group in filteredGroups"
|
||||
:key="group.id"
|
||||
:style-name="styleName"
|
||||
:label="group.label"
|
||||
:category="group.category"
|
||||
:options="group.options"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<p
|
||||
class="style-options-empty"
|
||||
v-if="filteredGroups.length === 0 && searchQuery"
|
||||
>
|
||||
No options match "{{ searchQuery }}".
|
||||
</p>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.style-options {
|
||||
&-search {
|
||||
margin-bottom: 20px;
|
||||
|
||||
&-wrapper {
|
||||
position: relative;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
&-icon {
|
||||
position: absolute;
|
||||
left: 14px;
|
||||
color: var(--vp-c-text-3);
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
&-input {
|
||||
width: 100%;
|
||||
padding-left: 40px !important;
|
||||
border-radius: var(--vp-radius-xs) !important;
|
||||
font-size: 14px !important;
|
||||
}
|
||||
}
|
||||
|
||||
&-groups {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
&-empty {
|
||||
text-align: center;
|
||||
color: var(--vp-c-text-3);
|
||||
font-size: 14px;
|
||||
padding: 32px 0;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,615 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, inject, type Component } from 'vue';
|
||||
import {
|
||||
Type,
|
||||
Hash,
|
||||
ToggleLeft,
|
||||
List,
|
||||
Palette,
|
||||
SlidersHorizontal,
|
||||
Weight,
|
||||
Link2,
|
||||
} from '@lucide/vue';
|
||||
import Accordion from 'primevue/accordion';
|
||||
import AccordionPanel from 'primevue/accordionpanel';
|
||||
import AccordionHeader from 'primevue/accordionheader';
|
||||
import AccordionContent from 'primevue/accordioncontent';
|
||||
import { UiCard } from '../ui';
|
||||
import Tag from 'primevue/tag';
|
||||
import { capitalCase } from 'change-case';
|
||||
import StyleOptionsPreview from './StyleOptionsPreview.vue';
|
||||
import StyleOptionsCodePanel from './StyleOptionsCodePanel.vue';
|
||||
import Message from 'primevue/message';
|
||||
import { padColors } from '@theme/utils/avatar/colors';
|
||||
import { track } from '@theme/utils/track';
|
||||
import { unsupportedHttpApiOptions } from '@theme/utils/avatar/api';
|
||||
import {
|
||||
getOptionDescription,
|
||||
getOptionExamples,
|
||||
} from '@theme/utils/styleOptionMeta';
|
||||
import {
|
||||
styleColorsKey,
|
||||
styleColorsDefault,
|
||||
styleDefaultsKey,
|
||||
styleDefaultsDefault,
|
||||
} from './styleOptionsKeys';
|
||||
|
||||
export interface OptionValue {
|
||||
type: string;
|
||||
values?: string[];
|
||||
min?: number;
|
||||
max?: number;
|
||||
list?: boolean;
|
||||
weighted?: boolean;
|
||||
contrastTo?: string;
|
||||
}
|
||||
|
||||
const props = defineProps<{
|
||||
styleName: string;
|
||||
name: string;
|
||||
value: OptionValue;
|
||||
}>();
|
||||
|
||||
const styleColors = inject(styleColorsKey, styleColorsDefault);
|
||||
const styleDefaults = inject(styleDefaultsKey, styleDefaultsDefault);
|
||||
|
||||
function colorExamples(colorName: string, min?: number): string[] {
|
||||
return padColors(styleColors.value[colorName] ?? [], min);
|
||||
}
|
||||
|
||||
const naturalSort = (a: string | number, b: string | number) => {
|
||||
return a.toString().localeCompare(b.toString(), undefined, {
|
||||
numeric: true,
|
||||
sensitivity: 'base',
|
||||
});
|
||||
};
|
||||
|
||||
const fieldType = computed(() => props.value.type);
|
||||
const fieldValues = computed(() => props.value.values ?? []);
|
||||
const fieldMin = computed(() => props.value.min);
|
||||
const fieldMax = computed(() => props.value.max);
|
||||
const isList = computed(() => props.value.list === true);
|
||||
const isWeighted = computed(() => props.value.weighted === true);
|
||||
|
||||
interface BadgeConfig {
|
||||
label: string;
|
||||
color: string;
|
||||
icon: Component;
|
||||
}
|
||||
|
||||
const typeStyles: Record<string, { color: string; icon: Component }> = {
|
||||
string: { color: 'var(--vp-c-text-2)', icon: Type },
|
||||
number: { color: 'var(--vp-c-green-1)', icon: Hash },
|
||||
boolean: { color: 'var(--vp-c-brand-1)', icon: ToggleLeft },
|
||||
enum: { color: 'var(--vp-c-purple-1)', icon: List },
|
||||
color: { color: 'var(--vp-c-yellow-1)', icon: Palette },
|
||||
range: { color: 'var(--vp-c-indigo-1)', icon: SlidersHorizontal },
|
||||
};
|
||||
|
||||
function style(type: string) {
|
||||
return typeStyles[type] ?? { color: 'var(--vp-c-text-2)', icon: Type };
|
||||
}
|
||||
|
||||
const badges = computed<BadgeConfig[]>(() => {
|
||||
const type = fieldType.value;
|
||||
const s = style(type);
|
||||
|
||||
switch (type) {
|
||||
case 'string':
|
||||
case 'number':
|
||||
case 'color': {
|
||||
const result: BadgeConfig[] = [{ label: type, ...s }];
|
||||
if (isList.value) result.push({ label: `${type}[]`, ...s });
|
||||
return result;
|
||||
}
|
||||
|
||||
case 'boolean':
|
||||
return [{ label: 'boolean', ...s }];
|
||||
|
||||
case 'enum': {
|
||||
const result: BadgeConfig[] = [{ label: 'enum', ...s }];
|
||||
if (isList.value) result.push({ label: 'enum[]', ...s });
|
||||
if (isWeighted.value)
|
||||
result.push({ label: 'weighted', color: s.color, icon: Weight });
|
||||
return result;
|
||||
}
|
||||
|
||||
case 'range':
|
||||
return [
|
||||
{ label: 'number', color: s.color, icon: Hash },
|
||||
{ label: '[min, max]', ...s },
|
||||
];
|
||||
|
||||
default:
|
||||
return [{ label: type, ...s }];
|
||||
}
|
||||
});
|
||||
|
||||
const skipPreview = computed(
|
||||
() =>
|
||||
fieldType.value === 'boolean' ||
|
||||
props.name === 'fontFamily' ||
|
||||
props.name === 'title',
|
||||
);
|
||||
|
||||
const excludeHttpApi = computed(() =>
|
||||
unsupportedHttpApiOptions.has(props.name),
|
||||
);
|
||||
|
||||
const possibleValues = computed(() => {
|
||||
if (skipPreview.value) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return fieldValues.value.slice().sort(naturalSort);
|
||||
});
|
||||
|
||||
const examples = computed<(string | number | boolean)[] | undefined>(() => {
|
||||
if (skipPreview.value) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
if (isWeighted.value && fieldValues.value.length > 0) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
return getOptionExamples(props.name, colorExamples);
|
||||
});
|
||||
|
||||
const previewItems = computed(() => {
|
||||
if (examples.value) {
|
||||
return examples.value;
|
||||
}
|
||||
|
||||
if (possibleValues.value.length > 0) {
|
||||
return possibleValues.value;
|
||||
}
|
||||
|
||||
return [];
|
||||
});
|
||||
|
||||
const codeExampleValue = computed(() => {
|
||||
if (examples.value) {
|
||||
return isList.value ? examples.value.slice(0, 2) : examples.value[0];
|
||||
}
|
||||
|
||||
if (possibleValues.value.length > 0) {
|
||||
return isList.value
|
||||
? possibleValues.value.slice(0, 2)
|
||||
: possibleValues.value[0];
|
||||
}
|
||||
|
||||
if (fieldType.value === 'boolean') {
|
||||
return true;
|
||||
}
|
||||
|
||||
if (fieldType.value === 'color') {
|
||||
return ['b6e3f4'];
|
||||
}
|
||||
|
||||
if (fieldType.value === 'number' || fieldType.value === 'range') {
|
||||
const dv = styleDefaults.value[props.name];
|
||||
|
||||
if (typeof dv === 'number') {
|
||||
return dv;
|
||||
}
|
||||
|
||||
if (fieldMin.value !== undefined) {
|
||||
return fieldMin.value;
|
||||
}
|
||||
|
||||
return 0;
|
||||
}
|
||||
|
||||
if (props.name === 'title') {
|
||||
return 'Avatar';
|
||||
}
|
||||
|
||||
if (props.name === 'fontFamily') {
|
||||
return 'Arial';
|
||||
}
|
||||
|
||||
return undefined;
|
||||
});
|
||||
|
||||
const headerId = computed(() => {
|
||||
return `options-${props.name.replace(/([A-Z])/g, '-$1').toLowerCase()}`;
|
||||
});
|
||||
|
||||
const contrastTo = computed(() =>
|
||||
fieldType.value === 'color' && typeof props.value.contrastTo === 'string'
|
||||
? props.value.contrastTo
|
||||
: null,
|
||||
);
|
||||
|
||||
const contrastTargetHref = computed(() => {
|
||||
const name = contrastTo.value;
|
||||
if (!name) return null;
|
||||
const optionName = `${name}Color`;
|
||||
return `#options-${optionName.replace(/([A-Z])/g, '-$1').toLowerCase()}`;
|
||||
});
|
||||
|
||||
const contrastTargetLabel = computed(() => {
|
||||
const name = contrastTo.value;
|
||||
return name ? capitalCase(name) : '';
|
||||
});
|
||||
|
||||
const description = computed(() => getOptionDescription(props.name));
|
||||
|
||||
interface MetaItem {
|
||||
label: string;
|
||||
value: string;
|
||||
}
|
||||
|
||||
const metaItems = computed<MetaItem[]>(() => {
|
||||
const items: MetaItem[] = [];
|
||||
const min = fieldMin.value;
|
||||
const max = fieldMax.value;
|
||||
|
||||
if (min !== undefined && max !== undefined) {
|
||||
items.push({ label: 'Range', value: `${min} — ${max}` });
|
||||
} else if (min !== undefined) {
|
||||
items.push({ label: 'Min', value: String(min) });
|
||||
} else if (max !== undefined) {
|
||||
items.push({ label: 'Max', value: String(max) });
|
||||
}
|
||||
|
||||
const dv = styleDefaults.value[props.name];
|
||||
|
||||
if (dv !== undefined) {
|
||||
let formatted: string;
|
||||
if (Array.isArray(dv)) {
|
||||
formatted = `[${dv.join(', ')}]`;
|
||||
} else if (typeof dv === 'object' && dv !== null) {
|
||||
formatted = Object.entries(dv)
|
||||
.map(([k, v]) => `${k}: ${v}`)
|
||||
.join(', ');
|
||||
} else {
|
||||
formatted = String(dv);
|
||||
}
|
||||
items.push({ label: 'Default', value: formatted });
|
||||
}
|
||||
|
||||
return items;
|
||||
});
|
||||
|
||||
const descriptionWithHints = computed(() => {
|
||||
let text = description.value;
|
||||
if (!text) return undefined;
|
||||
|
||||
if (excludeHttpApi.value) text += ' Not available via HTTP-API.';
|
||||
|
||||
return text;
|
||||
});
|
||||
|
||||
const weightedExampleValue = computed(() => {
|
||||
if (!isWeighted.value) return undefined;
|
||||
|
||||
const vals = fieldValues.value;
|
||||
|
||||
if (vals.length >= 2) {
|
||||
return { [vals[0]]: 2, [vals[1]]: 1 };
|
||||
}
|
||||
|
||||
if (vals.length === 1) {
|
||||
return { [vals[0]]: 1 };
|
||||
}
|
||||
|
||||
return { variant01: 2, variant02: 1 };
|
||||
});
|
||||
|
||||
const hasDetails = computed(() => {
|
||||
return (
|
||||
previewItems.value.length > 0 ||
|
||||
codeExampleValue.value !== undefined ||
|
||||
excludeHttpApi.value
|
||||
);
|
||||
});
|
||||
|
||||
// Track when a reader expands an option's "Examples" panel. PrimeVue's
|
||||
// Accordion `update:value` emits the same panel value on both open and close,
|
||||
// so instead we read the header's resulting `aria-expanded` state after the
|
||||
// click and only count opens.
|
||||
function onExamplesToggle(event: MouseEvent) {
|
||||
const header = event.currentTarget as HTMLElement | null;
|
||||
|
||||
requestAnimationFrame(() => {
|
||||
if (header?.getAttribute('aria-expanded') === 'true') {
|
||||
track('Docs Options: Examples Opened', {
|
||||
style: props.styleName,
|
||||
option: props.name,
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<UiCard class="style-options-card">
|
||||
<div class="style-options-card-header">
|
||||
<h3 :id="headerId" tabindex="-1" class="style-options-card-title">
|
||||
{{ name }}
|
||||
<a class="header-anchor" :href="`#${headerId}`" aria-hidden="true"></a>
|
||||
</h3>
|
||||
<div class="style-options-card-badges">
|
||||
<Tag
|
||||
v-for="badge in badges"
|
||||
:key="badge.label"
|
||||
:style="{
|
||||
background: `color-mix(in srgb, ${badge.color} 10%, transparent)`,
|
||||
color: badge.color,
|
||||
}"
|
||||
>
|
||||
<component v-if="badge.icon" :is="badge.icon" :size="13" />
|
||||
{{ badge.label }}
|
||||
</Tag>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<p class="style-options-card-description" v-if="descriptionWithHints">
|
||||
{{ descriptionWithHints }}
|
||||
</p>
|
||||
|
||||
<div
|
||||
v-if="contrastTo"
|
||||
class="style-options-card-contrast-banner"
|
||||
role="note"
|
||||
>
|
||||
<Link2 :size="14" class="style-options-card-contrast-banner-icon" />
|
||||
<p class="style-options-card-contrast-banner-text">
|
||||
Linked to
|
||||
<a
|
||||
:href="contrastTargetHref ?? undefined"
|
||||
class="style-options-card-contrast-banner-link"
|
||||
>
|
||||
{{ contrastTargetLabel }}
|
||||
</a>. The renderer picks the value with the strongest contrast against the
|
||||
selected {{ contrastTargetLabel.toLowerCase() }} color, so additional
|
||||
values mainly serve as fallbacks.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="style-options-card-meta" v-if="metaItems.length > 0">
|
||||
<span
|
||||
v-for="item in metaItems"
|
||||
:key="item.label"
|
||||
class="style-options-card-meta-item"
|
||||
>
|
||||
{{ item.label }} <code>{{ item.value }}</code>
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<Accordion
|
||||
v-if="hasDetails"
|
||||
:value="[]"
|
||||
multiple
|
||||
lazy
|
||||
class="style-options-card-details"
|
||||
>
|
||||
<AccordionPanel value="0">
|
||||
<AccordionHeader @click="onExamplesToggle">Examples</AccordionHeader>
|
||||
<AccordionContent>
|
||||
<div class="style-options-card-details-body">
|
||||
<div
|
||||
class="style-options-card-preview"
|
||||
v-if="previewItems.length > 0"
|
||||
>
|
||||
<div class="style-options-card-preview-grid">
|
||||
<StyleOptionsPreview
|
||||
v-for="(val, key) in previewItems"
|
||||
:key="key"
|
||||
:style-name="styleName"
|
||||
:name="name"
|
||||
:value="val"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
class="style-options-card-code"
|
||||
v-if="codeExampleValue !== undefined && !weightedExampleValue"
|
||||
>
|
||||
<StyleOptionsCodePanel
|
||||
:style-name="styleName"
|
||||
:option-name="name"
|
||||
:value="codeExampleValue"
|
||||
:exclude-http-api="excludeHttpApi"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div
|
||||
class="style-options-card-code-group"
|
||||
v-if="codeExampleValue !== undefined && weightedExampleValue"
|
||||
>
|
||||
<div class="style-options-card-code-section">
|
||||
<span class="style-options-card-code-label">Usage</span>
|
||||
<StyleOptionsCodePanel
|
||||
:style-name="styleName"
|
||||
:option-name="name"
|
||||
:value="codeExampleValue"
|
||||
:exclude-http-api="excludeHttpApi"
|
||||
/>
|
||||
</div>
|
||||
<div class="style-options-card-code-section">
|
||||
<span class="style-options-card-code-label">Weighted</span>
|
||||
<StyleOptionsCodePanel
|
||||
:style-name="styleName"
|
||||
:option-name="name"
|
||||
:value="weightedExampleValue"
|
||||
:exclude-http-api="excludeHttpApi"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="excludeHttpApi" class="style-options-card-message">
|
||||
<Message severity="warn" :closable="false">
|
||||
This option is not supported by our public
|
||||
<a href="/how-to-use/http-api/">HTTP-API</a>. You can enable it
|
||||
by
|
||||
<a href="/guides/host-the-http-api-yourself/"
|
||||
>hosting your own instance</a
|
||||
>.
|
||||
</Message>
|
||||
</div>
|
||||
</div>
|
||||
</AccordionContent>
|
||||
</AccordionPanel>
|
||||
</Accordion>
|
||||
</UiCard>
|
||||
</template>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.style-options-card {
|
||||
min-width: 0;
|
||||
|
||||
&-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
&-badges {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 4px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
&-title {
|
||||
margin: 0 !important;
|
||||
padding: 0;
|
||||
border: none;
|
||||
font-size: 16px;
|
||||
font-weight: 700;
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
&-description {
|
||||
margin-top: 10px;
|
||||
margin-bottom: 0;
|
||||
font-size: 14px;
|
||||
line-height: 1.6;
|
||||
color: var(--vp-c-text-2);
|
||||
}
|
||||
|
||||
&-details {
|
||||
margin-top: 12px;
|
||||
--p-accordion-panel-border-color: var(--pg-border);
|
||||
--p-accordion-header-padding: 8px 0;
|
||||
--p-accordion-content-padding: 0;
|
||||
--p-accordion-header-background: transparent;
|
||||
--p-accordion-header-hover-background: transparent;
|
||||
--p-accordion-header-active-background: transparent;
|
||||
--p-accordion-header-active-hover-background: transparent;
|
||||
--p-accordion-content-background: transparent;
|
||||
--p-accordion-panel-border-width: 0;
|
||||
--p-accordion-header-font-size: 13px;
|
||||
--p-accordion-header-font-weight: 600;
|
||||
--p-accordion-header-color: var(--vp-c-text-3);
|
||||
--p-accordion-header-hover-color: var(--vp-c-text-2);
|
||||
--p-accordion-header-active-color: var(--vp-c-text-3);
|
||||
--p-accordion-header-active-hover-color: var(--vp-c-text-2);
|
||||
|
||||
&-body {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
padding-top: 4px;
|
||||
}
|
||||
|
||||
:deep(.p-accordioncontent-wrapper) {
|
||||
min-width: 0;
|
||||
}
|
||||
}
|
||||
|
||||
&-contrast-banner {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
margin-top: 12px;
|
||||
padding: 10px 12px;
|
||||
font-size: 13px;
|
||||
line-height: 1.5;
|
||||
color: var(--vp-c-text-2);
|
||||
background: var(--vp-c-bg-soft);
|
||||
border: 1px solid var(--vp-c-divider);
|
||||
border-radius: var(--vp-radius-xs);
|
||||
|
||||
&-icon {
|
||||
flex-shrink: 0;
|
||||
margin-top: 2px;
|
||||
opacity: 0.7;
|
||||
}
|
||||
|
||||
&-text {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
&-link {
|
||||
color: var(--vp-c-brand-1);
|
||||
text-decoration: underline;
|
||||
text-underline-offset: 2px;
|
||||
|
||||
&:hover {
|
||||
color: var(--vp-c-brand-2);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
&-meta {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 12px;
|
||||
margin-top: 8px;
|
||||
|
||||
&-item {
|
||||
font-size: 13px;
|
||||
color: var(--vp-c-text-3);
|
||||
|
||||
code {
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
padding: 1px 5px;
|
||||
border-radius: 3px;
|
||||
background-color: var(--vp-code-bg);
|
||||
color: var(--vp-c-text-2);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
&-preview {
|
||||
margin-top: 16px;
|
||||
|
||||
&-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(104px, 1fr));
|
||||
gap: 8px;
|
||||
}
|
||||
}
|
||||
|
||||
&-code {
|
||||
margin-top: 16px;
|
||||
}
|
||||
|
||||
&-code-group {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 16px;
|
||||
margin-top: 16px;
|
||||
}
|
||||
|
||||
&-code-label {
|
||||
display: block;
|
||||
font-size: 12px;
|
||||
font-weight: 700;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.04em;
|
||||
color: var(--vp-c-text-3);
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
&-message {
|
||||
margin-top: 16px;
|
||||
--p-message-text-font-size: 13px;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user