Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
2356fc56f7 |
No files matched your search
@@ -32,23 +32,24 @@ Import only from an exported package entrypoint. Do not deep-import `@homarr/com
|
||||
|
||||
## Root exports
|
||||
|
||||
| Area | Public exports | Purpose |
|
||||
| ----------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------- |
|
||||
| Objects | `objectKeys`, `objectEntries`, `isRecord`, `hashObjectBase64` | Typed key/entry iteration, record guards, and stable query-key hashing |
|
||||
| Strings | `capitalize`, `isNullOrWhitespace`, `bestMatch`, `normalizeImageName`, `getImageMatchRank` | Text normalization and matching |
|
||||
| Cookies | `parseCookies`, `setClientCookie` | Cookie parsing and browser cookie serialization |
|
||||
| Arrays | `splitToNChunks`, `splitToChunksWithNItems` | Split by number of groups or maximum group size |
|
||||
| Dates | `formatDuration`, `toValidDate`, `isDateWithin` | Duration formatting, safe date coercion, relative-window checks |
|
||||
| Timing | `Stopwatch` | Elapsed-time measurement and formatting |
|
||||
| React hooks | `useTimeAgo`, `useIntegrationConnected` | Relative time and integration freshness state |
|
||||
| URLs | `removeTrailingSlash`, `extractBaseUrlFromHeaders`, `getPortFromUrl`, `isAbsoluteUrl`, `SAFE_NEW_TAB_REL`, `getSafeApplicationUrl`, `getSafeAppHref` | URL normalization, safe navigation, ports, and new-tab policy |
|
||||
| Numbers and bytes | `formatNumber`, `formatBytes`, `formatBytesPair`, `formatByteRate`, `formatBitRate`, `defaultByteUnitSystem`, `metricToImperial` | Compact numbers, byte displays, byte/bit rates, and unit conversion |
|
||||
| Byte types | `ByteUnitSystem`, `FormatBytesOptions`, `FormatBitRateOptions` | Options for byte and bit-rate formatting |
|
||||
| Errors | `extractErrorMessage`, `FlattenError` | Unknown-error messages and flattenable domain errors |
|
||||
| Theme | `getMantineColor` | Resolve a Mantine color and shade |
|
||||
| Functions | `isFunction` | Unknown-value function guard |
|
||||
| Grid | `GridAlgorithmItem`, `generateResponsiveGridFor` | Responsive nested-section grid placement |
|
||||
| IDs | `createId` | CUID2 ID creation |
|
||||
| Area | Public exports | Purpose |
|
||||
| ----------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------- | --------------------------------------------------------------- |
|
||||
| Objects | `objectKeys`, `objectEntries`, `hashObjectBase64` | Typed key/entry iteration and stable query-key hashing |
|
||||
| Strings | `capitalize`, `isNullOrWhitespace`, `bestMatch`, `normalizeImageName`, `getImageMatchRank` | Text normalization and matching |
|
||||
| Cookies | `parseCookies`, `setClientCookie` | Cookie parsing and browser cookie serialization |
|
||||
| Arrays | `splitToNChunks`, `splitToChunksWithNItems` | Split by number of groups or maximum group size |
|
||||
| Dates | `formatDuration`, `toValidDate`, `isDateWithin` | Duration formatting, safe date coercion, relative-window checks |
|
||||
| Timing | `Stopwatch` | Elapsed-time measurement and formatting |
|
||||
| React hooks | `useTimeAgo`, `useIntegrationConnected` | Relative time and integration freshness state |
|
||||
| URLs | `removeTrailingSlash`, `extractBaseUrlFromHeaders`, `getPortFromUrl`, `isAbsoluteUrl`, `SAFE_NEW_TAB_REL`, `getSafeApplicationUrl`, `getSafeAppHref` | URL normalization, safe navigation, ports, and new-tab policy |
|
||||
| Numbers and bytes | `formatNumber`, `randomInt`, `formatBytes`, `formatBytesPair`, `formatByteRate`, `metricToImperial`, `imperialToMetric` | Compact numbers, byte displays, rates, and unit conversion |
|
||||
| Legacy bytes | `humanFileSize` | Deprecated; use the `formatBytes*` helpers in new code |
|
||||
| Byte types | `ByteUnitSystem`, `FormatBytesOptions` | Options for byte formatting |
|
||||
| Errors | `extractErrorMessage`, `FlattenError` | Unknown-error messages and flattenable domain errors |
|
||||
| Theme | `getMantineColor` | Resolve a Mantine color and shade |
|
||||
| Functions | `isFunction` | Unknown-value function guard |
|
||||
| Grid | `GridAlgorithmItem`, `generateResponsiveGridFor` | Responsive nested-section grid placement |
|
||||
| IDs | `createId` | CUID2 ID creation |
|
||||
|
||||
The root deliberately does not re-export `types`, `server`, `client`, or `env`; use their named entrypoints.
|
||||
|
||||
|
||||
@@ -97,16 +97,17 @@ jobs:
|
||||
|
||||
- name: Validate Custom Widget bundle budget
|
||||
run: |
|
||||
pnpm turbo run build --filter=@homarr/nextjs
|
||||
pnpm --filter nextjs build
|
||||
pnpm check:custom-widget-bundle
|
||||
|
||||
- name: Run unit tests
|
||||
run: pnpm test
|
||||
|
||||
container:
|
||||
name: Container build
|
||||
# Same-repository previews already build amd64; only build a fallback when no digest is available.
|
||||
name: Container and E2E
|
||||
needs: [fast-gate, preview-amd64]
|
||||
if: |
|
||||
!cancelled() && needs.fast-gate.result == 'success' &&
|
||||
needs.preview-amd64.result != 'failure' && needs.preview-amd64.outputs.digest == '' &&
|
||||
(
|
||||
github.event_name == 'merge_group' ||
|
||||
(github.event_name == 'push' && github.ref == 'refs/heads/release/v2') ||
|
||||
@@ -114,9 +115,10 @@ jobs:
|
||||
(github.event_name == 'workflow_dispatch' && (inputs.operation == 'validate' || inputs.operation == 'publish'))
|
||||
)
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 20
|
||||
timeout-minutes: 30
|
||||
permissions:
|
||||
contents: read
|
||||
packages: read
|
||||
steps:
|
||||
- uses: actions/checkout@v7
|
||||
if: "!env.ACT"
|
||||
@@ -124,10 +126,23 @@ jobs:
|
||||
persist-credentials: false
|
||||
ref: ${{ github.event_name == 'workflow_dispatch' && inputs.ref || '' }}
|
||||
|
||||
- name: Setup
|
||||
uses: ./tooling/github/setup
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
if: needs.preview-amd64.outputs.digest == '' && !env.ACT
|
||||
if: "!env.ACT"
|
||||
uses: docker/setup-buildx-action@v4
|
||||
|
||||
- name: Reuse amd64 preview image
|
||||
if: needs.preview-amd64.outputs.digest != '' && !env.ACT
|
||||
env:
|
||||
DIGEST: ${{ needs.preview-amd64.outputs.digest }}
|
||||
run: |
|
||||
echo "${{ secrets.GITHUB_TOKEN }}" | docker login ghcr.io -u "${{ github.actor }}" --password-stdin
|
||||
docker pull "${PREVIEW_IMAGE}@${DIGEST}"
|
||||
docker tag "${PREVIEW_IMAGE}@${DIGEST}" homarr-ci
|
||||
docker tag "${PREVIEW_IMAGE}@${DIGEST}" homarr-e2e
|
||||
|
||||
- name: Build container once
|
||||
if: needs.preview-amd64.outputs.digest == '' && !env.ACT
|
||||
uses: docker/build-push-action@v7
|
||||
@@ -137,7 +152,9 @@ jobs:
|
||||
platforms: linux/amd64
|
||||
load: true
|
||||
push: false
|
||||
tags: homarr-ci
|
||||
tags: |
|
||||
homarr-ci
|
||||
homarr-e2e
|
||||
labels: |
|
||||
org.opencontainers.image.source=${{ github.server_url }}/${{ github.repository }}
|
||||
org.opencontainers.image.revision=${{ github.sha }}
|
||||
@@ -165,7 +182,37 @@ jobs:
|
||||
fi
|
||||
done
|
||||
DOCKER_BUILDKIT=1 docker build --platform linux/amd64 \
|
||||
--tag homarr-ci "${secret_args[@]}" .
|
||||
--tag homarr-ci --tag homarr-e2e "${secret_args[@]}" .
|
||||
|
||||
- name: Cache Playwright browsers
|
||||
if: "!env.ACT"
|
||||
id: playwright-cache
|
||||
uses: actions/cache@v6
|
||||
with:
|
||||
path: ~/.cache/ms-playwright
|
||||
key: playwright-${{ runner.os }}-${{ hashFiles('pnpm-lock.yaml') }}
|
||||
|
||||
- name: Install Playwright Chromium
|
||||
if: steps.playwright-cache.outputs.cache-hit != 'true' || env.ACT
|
||||
run: pnpm exec playwright install --with-deps chromium
|
||||
|
||||
- name: Install Playwright system deps
|
||||
if: steps.playwright-cache.outputs.cache-hit == 'true'
|
||||
run: pnpm exec playwright install-deps chromium
|
||||
|
||||
- name: Run E2E tests
|
||||
env:
|
||||
NODE_ENV: development
|
||||
CI: "true"
|
||||
run: pnpm exec vitest e2e --retry 1 --maxWorkers 4
|
||||
|
||||
- name: Upload E2E artifacts
|
||||
if: failure() && !env.ACT
|
||||
uses: actions/upload-artifact@v7
|
||||
with:
|
||||
name: playwright-report
|
||||
path: e2e/test-results/
|
||||
retention-days: 7
|
||||
|
||||
preview-amd64:
|
||||
name: Build amd64 preview image
|
||||
@@ -273,10 +320,10 @@ jobs:
|
||||
|
||||
publish-preview:
|
||||
name: Publish multi-platform preview image
|
||||
needs: [fast-gate, preview-amd64, preview-arm64]
|
||||
needs: [container, preview-amd64, preview-arm64]
|
||||
if: |
|
||||
!cancelled() &&
|
||||
needs.fast-gate.result == 'success' &&
|
||||
needs.container.result == 'success' &&
|
||||
needs.preview-amd64.result == 'success' &&
|
||||
needs.preview-arm64.result == 'success' &&
|
||||
(
|
||||
|
||||
@@ -37,10 +37,6 @@ jobs:
|
||||
run: pnpm turbo build --filter=@homarr/docs
|
||||
env:
|
||||
CI: "true"
|
||||
- name: Validate exported links and search
|
||||
run: |
|
||||
pnpm --filter @homarr/docs validate:links
|
||||
pnpm --filter @homarr/docs verify:search
|
||||
- name: Setup Pages
|
||||
uses: actions/configure-pages@v6
|
||||
- name: Upload pages artifact
|
||||
|
||||
@@ -26,8 +26,36 @@ jobs:
|
||||
- name: Copy env
|
||||
run: cp .env.example .env
|
||||
|
||||
- name: Generate and validate OpenAPI spec
|
||||
run: pnpm scripts:update-openapi
|
||||
- name: Start dev server
|
||||
run: pnpm --filter @homarr/nextjs dev &
|
||||
env:
|
||||
TURBO_TELEMETRY_DISABLED: 1
|
||||
DISABLE_TELEMETRY: 1
|
||||
|
||||
- name: Wait for server and fetch spec
|
||||
run: |
|
||||
set -euo pipefail
|
||||
for i in $(seq 1 30); do
|
||||
if curl -fsS http://localhost:3000/api/openapi -o /tmp/openapi.json 2>/dev/null; then
|
||||
echo "Server is up, spec fetched"
|
||||
break
|
||||
fi
|
||||
echo "Waiting for server... ($i/30)"
|
||||
sleep 3
|
||||
done
|
||||
|
||||
if [ ! -f /tmp/openapi.json ]; then
|
||||
echo "::error::Server failed to start within timeout"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
jq -e '.openapi and .paths' /tmp/openapi.json > /dev/null || {
|
||||
echo "::error::Fetched file is not a valid OpenAPI spec"
|
||||
exit 1
|
||||
}
|
||||
|
||||
cp /tmp/openapi.json apps/docs/public/api/open-api-schema.json
|
||||
echo "OpenAPI spec updated ($(jq '.paths | length' /tmp/openapi.json) paths)"
|
||||
|
||||
- name: Commit updated spec
|
||||
uses: EndBug/add-and-commit@v10
|
||||
|
||||
@@ -2,36 +2,8 @@ name: Workshop
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
paths:
|
||||
- ".github/workflows/workshop.yml"
|
||||
- "apps/workshop/**"
|
||||
- "apps/docs/**"
|
||||
- "packages/custom-widgets/**"
|
||||
- "packages/definitions/**"
|
||||
- "packages/workshop/**"
|
||||
- "tooling/typescript/**"
|
||||
- "patches/**"
|
||||
- "package.json"
|
||||
- "pnpm-lock.yaml"
|
||||
- "pnpm-workspace.yaml"
|
||||
- "turbo.json"
|
||||
- ".npmrc"
|
||||
push:
|
||||
branches: [dev, v2, release/v2, main]
|
||||
paths:
|
||||
- ".github/workflows/workshop.yml"
|
||||
- "apps/workshop/**"
|
||||
- "apps/docs/**"
|
||||
- "packages/custom-widgets/**"
|
||||
- "packages/definitions/**"
|
||||
- "packages/workshop/**"
|
||||
- "tooling/typescript/**"
|
||||
- "patches/**"
|
||||
- "package.json"
|
||||
- "pnpm-lock.yaml"
|
||||
- "pnpm-workspace.yaml"
|
||||
- "turbo.json"
|
||||
- ".npmrc"
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
@@ -43,10 +15,21 @@ concurrency:
|
||||
jobs:
|
||||
validate:
|
||||
runs-on: ubuntu-24.04
|
||||
timeout-minutes: 5
|
||||
timeout-minutes: 30
|
||||
steps:
|
||||
- uses: actions/checkout@v7
|
||||
- uses: actions/setup-node@v6
|
||||
with:
|
||||
node-version: 24.18.0
|
||||
- uses: pnpm/action-setup@v6
|
||||
with:
|
||||
run_install: false
|
||||
- run: pnpm install --frozen-lockfile
|
||||
- run: pnpm --filter @homarr/workshop typecheck
|
||||
- run: pnpm --filter @homarr/docs typecheck
|
||||
- run: docker compose --env-file apps/workshop/.env.staging.example -f apps/workshop/docker-compose.staging.yml config --quiet
|
||||
- run: pnpm test:workshop
|
||||
- run: pnpm test:workshop-image
|
||||
|
||||
publish-amd64:
|
||||
if: github.event_name == 'push' && (github.ref_name == 'v2' || github.ref_name == 'release/v2' || github.ref_name == 'main')
|
||||
|
||||
@@ -15,7 +15,6 @@
|
||||
"apps/docs/blog/2024/**",
|
||||
"apps/docs/blog/2025/**",
|
||||
"apps/docs/blog/authors.yml",
|
||||
"apps/docs/static/api/open-api-schema.json",
|
||||
"packages/custom-widgets/src/core/component-catalog.generated.json",
|
||||
"apps/docs/static/custom-widgets/component-catalog-v1.json"
|
||||
]
|
||||
|
||||
@@ -7,12 +7,3 @@ This version has breaking changes — APIs, conventions, and file structure may
|
||||
This block is written and re-added by `next dev` — verify at `node_modules/next/dist/server/lib/generate-agent-files.js`. Removing it from a diff only re-creates the uncommitted change; committing it with your work keeps the tree clean.
|
||||
|
||||
<!-- END:nextjs-agent-rules -->
|
||||
|
||||
## Fumadocs workflow
|
||||
|
||||
- Start with `README.md`, `src/lib/source.ts`, `source.config.ts`, and the matching route under `src/app/`.
|
||||
- Read current [Fumadocs documentation](https://fumadocs.dev/docs) with Context7 before changing framework APIs. Check the installed version and types; latest examples may use a different search adapter.
|
||||
- Preserve static export: search uses `staticGET` with the matching static client. Do not introduce a request-time search server into this app.
|
||||
- Keep integration/widget metadata in the existing typed definitions. Check both rendered HTML and processed Markdown when changing MDX components; JSX alone does not guarantee readable search or AI output.
|
||||
- Run the README validation commands after content or navigation changes. Search results and table-of-contents links must resolve to rendered anchors.
|
||||
- Kapa is an independent hosted assistant. Keep its keyboard shortcut disabled so it does not override docs search; follow the README for its build configuration and crawler setup.
|
||||
@@ -22,12 +22,10 @@ WORKSHOP_API_URL=http://127.0.0.1:8090 pnpm dev:docs
|
||||
pnpm --filter @homarr/docs typecheck
|
||||
pnpm --filter @homarr/docs build
|
||||
pnpm --filter @homarr/docs validate:links
|
||||
pnpm --filter @homarr/docs verify:search
|
||||
```
|
||||
|
||||
The static export is written to `out/`. The build validates that every registered integration and widget has a docs
|
||||
page. The link check validates internal Markdown and MDX links and rendered anchors against that export.
|
||||
The search check exercises the exported index with title, heading, and body queries and verifies result destinations.
|
||||
page. The link check validates internal Markdown and MDX links against that export.
|
||||
|
||||
## Write content
|
||||
|
||||
@@ -51,34 +49,4 @@ When a user-facing change affects setup or behavior, update the matching page in
|
||||
- `/blog/rss.xml` — RSS feed for project posts
|
||||
|
||||
Every docs page and blog post includes copy-Markdown and view-source actions. Search is generated at build time and
|
||||
runs locally in the browser; it does not depend on an external crawler. Markdown exports and search resolve
|
||||
integration credentials and widget defaults from the same typed metadata used by the pages.
|
||||
The getting-started overview and complete Custom JSX component catalog are also included, with property types,
|
||||
binding contracts, and blocked capabilities taken from the published catalog JSON.
|
||||
|
||||
Published Docusaurus `/docs/category/...` addresses remain available as static compatibility pages. They forward to
|
||||
the corresponding overview or developer setup guide, preserving query strings and anchors, and provide a direct
|
||||
link when JavaScript is disabled. Their canonical metadata points to the destination; compatibility pages are
|
||||
excluded from search and the sitemap.
|
||||
|
||||
## Kapa AI
|
||||
|
||||
The **Ask AI** launcher uses Homarr's existing public Kapa Website ID. The root layout loads the widget once across
|
||||
client-side navigation. **Search** and `Ctrl+K` / `Cmd+K` always use Fumadocs search, including when Kapa is unavailable.
|
||||
|
||||
Set `KAPA_WEBSITE_ID` at build time to use a different Kapa Website Widget integration. An explicitly empty value
|
||||
disables the widget, for example `KAPA_WEBSITE_ID= pnpm --filter @homarr/docs build`. This is a public integration ID
|
||||
included in exported pages, not a Kapa API key. Rebuild the static export after changing it.
|
||||
|
||||
In the Kapa dashboard, confirm the integration is live and enable the production and preview domains. Restoring the
|
||||
script does not verify that the historical integration is still active. Follow the
|
||||
[Kapa widget setup](https://docs.kapa.ai/integrations/website-widget/quickstart) for domain restrictions and CSP settings.
|
||||
|
||||
After changing the documentation structure, review the Website Crawl source in Kapa: use the deployed `/docs` URL as
|
||||
the start URL and preview `main` as the content selector. Replace any old Docusaurus-specific selector and check that
|
||||
titles, headings, code samples, and integration details appear in the extracted Markdown. Docs are exported as HTML;
|
||||
the site also publishes `/sitemap.xml` and permits crawling in `/robots.txt`. The `/llms.txt` exports do not configure
|
||||
Kapa ingestion automatically. See [Kapa source setup](https://docs.kapa.ai/getting-started/index-your-first-source).
|
||||
|
||||
Before publishing, check that Ask AI opens after navigating between pages, ask a documentation question, and verify
|
||||
its citations point to the current docs. In a separate check, block `widget.kapa.ai` and confirm search still works.
|
||||
runs locally in the browser; it does not depend on an external crawler.
|
||||
@@ -22,7 +22,7 @@ Plan to recreate your apps, integrations, and boards manually in the new install
|
||||
|
||||
We highly recommend to create a parallel installation of Homarr 1.0.0.
|
||||
So if something with for example the import does not quite work, the data in the old installation is still available.
|
||||
To do this you can follow the appropriate [installation guide](/docs/getting-started) for your system.
|
||||
To do this you can follow the appropriate [installation guide](https://homarr.dev/docs/category/installation-1) for your system.
|
||||
Please note that if you are using docker you need to specify a diffrent external port for the new installation as the old one had the same port.
|
||||
So in your compose file the ports would be `7576:7575` instead of `7575:7575`.
|
||||
|
||||
|
||||
@@ -4,8 +4,6 @@ date: 2026-09-03
|
||||
authors:
|
||||
- ajnart
|
||||
tags: [homarr, update, version, assistant, custom widgets, dashboards, docker]
|
||||
description: Homarr 2.0 introduces Custom Widgets and Workshop, rebuilt board editing, Assistant tooling, responsive layouts, onboarding, and more.
|
||||
image: ./img/homarr-v2-dashboard.webp
|
||||
draft: false
|
||||
---
|
||||
|
||||
@@ -25,9 +23,7 @@ Homarr 2.0 is an upgrade, not a brand-new app wearing the same logo. Here is wha
|
||||
|
||||
:::warning Back up before upgrading
|
||||
|
||||
Database migrations run automatically when Homarr starts. Create a
|
||||
[Homarr backup](/docs/management/backup) first. Do not test a prerelease image on your only production volume.
|
||||
Backups are boring. Restores are worse.
|
||||
Create a [Homarr backup](/docs/management/backup) first. Do not test a prerelease image on your only production volume. Backups are boring. Restores are worse.
|
||||
|
||||
:::
|
||||
|
||||
@@ -163,7 +159,6 @@ Board startup work runs in parallel, heavy screens load when opened, and identic
|
||||
## Links
|
||||
|
||||
- Try the [Homarr 2.0 preview](https://app-v2.preview.homarr.dev).
|
||||
- Follow the [v2 beta setup guide](/docs/getting-started/upgrade-v2).
|
||||
- Follow the [v2 release pull request](https://github.com/homarr-labs/homarr/pull/6545) and [public beta thread](https://github.com/homarr-labs/homarr/issues/6600).
|
||||
- Compare [v1.76.2 with the release/v2 branch](https://github.com/homarr-labs/homarr/compare/v1.76.2...release/v2).
|
||||
- Read the guides for [Boards](/docs/management/boards), [Custom Widgets](/docs/management/custom-widgets), [Assistant](/docs/management/assistant), [MCP](/docs/management/mcp) and [Docker labels](/docs/integrations/docker-labels).
|
||||
@@ -148,18 +148,13 @@ homarr images rm feature
|
||||
# List data volumes with owner PR/tag and in-use status
|
||||
homarr data
|
||||
|
||||
# Preview deleting a data volume and its owning container
|
||||
# Delete data volume (safely stops and removes owning container first)
|
||||
homarr data rm homarr_feature_data
|
||||
|
||||
# Permanently delete the data after reviewing the preview
|
||||
homarr data rm homarr_feature_data --force
|
||||
|
||||
# Remove stopped containers (prompts without --force)
|
||||
homarr prune --force
|
||||
```
|
||||
|
||||
Forced data deletion removes the derived owner container only when it mounts the exact selected volume. A same-named container that does not own that volume stops the operation.
|
||||
|
||||
### Instance Operations & Diagnostics
|
||||
|
||||
```bash
|
||||
|
||||
@@ -14,4 +14,4 @@ homarr fix-usernames
|
||||
|
||||
This will convert all credential usernames to lowercase and update the database accordingly.
|
||||
|
||||

|
||||

|
||||
@@ -4,7 +4,6 @@ The Homarr image includes a recovery CLI for operations that cannot be completed
|
||||
application API; use the [API](/docs/management/api) or [MCP endpoint](/docs/management/mcp) for automation.
|
||||
|
||||
Back up the database and stop Homarr before running a command that changes data.
|
||||
Failed commands print an error and return a non-zero exit code, so shell scripts can stop safely.
|
||||
|
||||
## Run the recovery CLI
|
||||
|
||||
|
||||
@@ -10,10 +10,10 @@ Using this command, you can forcefully reset the password of a user.
|
||||
It will be set to a random value and the hash for the user will be updated.
|
||||
All sessions for that user will be terminated and the user must re-login.
|
||||
|
||||

|
||||

|
||||
|
||||
Using the `--username` parameter, you can specify who you want to reset the password for.
|
||||
Executing the command will create a new random password, update the database and display the new password.
|
||||
All sessions for this user will be terminated and they must re-login.
|
||||
|
||||

|
||||

|
||||
@@ -82,43 +82,3 @@ Workshop contributors can set up PocketBase and Fumadocs with the dedicated
|
||||
- `pnpm build` — production monorepo build.
|
||||
- `docker build -t homarr .` — build a production Docker image.
|
||||
- `docker run -p 7575:7575 -e SECRET_ENCRYPTION_KEY='your_64_character_hex_string' homarr:<name>` — run it.
|
||||
|
||||
## CI and focused validation
|
||||
|
||||
The main CI workflow runs lint, typechecking, affected workspace builds, workspace configuration checks, OpenAPI validation, and Custom Widget architecture and bundle checks. Unit and E2E suites do not run in CI. Preview images publish after the Fast gate and both architecture builds succeed; browser tests do not block publication. The container job builds an amd64 image when no preview image was built.
|
||||
|
||||
Workshop CI runs only when its workflow or Docker build inputs change. It validates Compose configuration and publishes production images on its configured release branches. Its Docker integration and image tests are manual; workspace typechecks run in the main Fast gate.
|
||||
|
||||
Choose the smallest relevant suite when changing behavior:
|
||||
|
||||
| Command | Scope | Requirements |
|
||||
| ------------------------------------------------- | -------------------------------------------------------------- | --------------------------------------------------------------------- |
|
||||
| `pnpm test <file>` | Focused unit or contract tests | Workspace dependencies |
|
||||
| `pnpm test` | All unit and contract tests, without Docker integration suites | Workspace dependencies |
|
||||
| `pnpm test:coverage` | Unit and contract tests with coverage | Workspace dependencies |
|
||||
| `pnpm test:integration <file>` | Docker-backed service and database compatibility tests | Docker |
|
||||
| `pnpm test:e2e <file>` | Browser and container scenarios | Docker; Playwright Chromium and system dependencies for browser tests |
|
||||
| `pnpm test:docs-screenshots` | Regenerate Assistant documentation screenshots | Same browser prerequisites |
|
||||
| `pnpm test:workshop` / `pnpm test:workshop-image` | Workshop integration / production-image validation | Docker |
|
||||
|
||||
Omit `<file>` to run all tests in that command's scope. E2E and screenshot commands use a local image tagged `homarr-e2e`, or an image selected with `HOMARR_E2E_IMAGE`. The default commands run once without automatic retries; coverage is opt-in. The screenshot generator is excluded from both unit and E2E commands.
|
||||
|
||||
Keep behavioral coverage for authorization, migrations, persistence, and widget execution. Avoid tests that only search source code for particular component names, JSX, CSS, or documentation wording.
|
||||
|
||||
## Documentation development
|
||||
|
||||
Run `pnpm dev:docs` to open the Next.js and Fumadocs site at `http://127.0.0.1:3003`. Content lives in
|
||||
`apps/docs/docs/`; the nearest `meta.json` controls navigation. Keep integration and widget setup metadata in their
|
||||
typed `index.ts` files alongside the page.
|
||||
|
||||
After changing content, navigation, or shared MDX components, run:
|
||||
|
||||
```sh
|
||||
pnpm --filter @homarr/docs build
|
||||
pnpm --filter @homarr/docs validate:links
|
||||
pnpm --filter @homarr/docs verify:search
|
||||
```
|
||||
|
||||
The build writes a static site to `apps/docs/out/`. Check heading links, search results, and the page's Markdown export
|
||||
when changing shared components. The [Fumadocs documentation](https://fumadocs.dev/docs) describes supported components
|
||||
and source APIs; match examples to the versions installed in this repository.
|
||||
@@ -34,21 +34,6 @@ Workshop uses PocketBase. The production image serves the Workshop website and A
|
||||
Public URL variables accept HTTP(S) origins without embedded credentials, query strings, or fragments. Restart the
|
||||
container after changing runtime configuration.
|
||||
|
||||
## Documentation search and AI
|
||||
|
||||
The documentation uses Fumadocs search with an index generated during the website build. Search runs in the browser
|
||||
and needs no hosted search credentials or crawler.
|
||||
|
||||
The **Ask AI** launcher connects to Kapa separately. `KAPA_WEBSITE_ID` is a public website integration ID, read when
|
||||
building the documentation. The default uses Homarr's integration. For a private deployment, pass your own ID or
|
||||
an empty value to disable it: `docker build --build-arg KAPA_WEBSITE_ID= -f apps/workshop/Dockerfile .`.
|
||||
Changing this value requires rebuilding the image; setting it on an already-built container has no effect.
|
||||
|
||||
For your own Kapa integration, enable the deployment domain in Kapa and configure a Website Crawl source for your
|
||||
published docs. Preview the `main` content selector and verify extracted headings, code, and integration details.
|
||||
After a migration, refresh the source and check answer citations. `/llms.txt` alone does not configure Kapa ingestion.
|
||||
See the [Kapa website widget guide](https://docs.kapa.ai/integrations/website-widget/quickstart).
|
||||
|
||||
## Authentication and email
|
||||
|
||||
Configure both GitHub OAuth variables and use `https://<workshop-host>/api/oauth2-redirect` as the callback. Supplying
|
||||
|
||||
@@ -53,6 +53,20 @@ Commands at a grid boundary or at the tile's minimum size are ignored and announ
|
||||
|
||||
On touch screens, press and hold a tile before moving it; a quick tap does not start a drag. Resize handles accept direct touch input. During a pointer or touch move or resize, `Esc` cancels the interaction and restores the layout from before it started.
|
||||
|
||||
## Board editing
|
||||
|
||||
When a tile is focused in edit mode:
|
||||
|
||||
| Shortcut | Description |
|
||||
| -------------------- | --------------------------------------------------------------------------- |
|
||||
| `Enter` or `Space` | Starts or stops keyboard editing |
|
||||
| Arrow keys | Moves the focused tile within its current canvas, rail, or Container |
|
||||
| `Shift` + Arrow keys | Resizes from the tile's lower-right corner: Left/Up shrink; Right/Down grow |
|
||||
| `Esc` | Stops keyboard editing without undoing its changes |
|
||||
|
||||
Keyboard moves and resizes apply immediately. To move an item between a canvas, rail, or Container, drag it there or use **Move / resize item** for precise numeric placement.
|
||||
Commands at a grid boundary or at the tile's minimum size are ignored and announced instead of moving or shrinking the tile.
|
||||
|
||||
:::tip
|
||||
|
||||
Here `MOD` refers to the modifier key, which is `Ctrl` on Windows and `Cmd` on macOS.
|
||||
|
||||
@@ -14,7 +14,7 @@ tags:
|
||||
|
||||
Homarr supports multiple authentication options, from internal userbase (credentials), to LDAP (with Active directory support), and OIDC.
|
||||
|
||||
## Common configuration
|
||||
### Auth configuration common variables
|
||||
|
||||
| Environment Variable | Description | Default Value |
|
||||
| ------------------------ | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------- |
|
||||
@@ -23,193 +23,198 @@ Homarr supports multiple authentication options, from internal userbase (credent
|
||||
| AUTH_SESSION_EXPIRY_TIME | Time for the session to time out. Can be set as pure number, which will automatically be used in seconds, or followed by s, m, h or d for seconds, minutes, hours or days. (ex: "30m") | "30d" |
|
||||
| AUTH_COOKIE_PREFIX | Prefix used for all authentication cookies (session, csrf, callback, pkce, state, nonce). Change this if you run another Auth.js/NextAuth app on the same hostname as Homarr (browsers scope cookies by host, not by port) to avoid cookie name collisions. Only letters, numbers, hyphens and underscores are allowed. | `homarr` |
|
||||
|
||||
## Credentials provider
|
||||
<Tabs>
|
||||
<TabItem value="credentials" label="Credentials Provider" default>
|
||||
|
||||
This is the default provider.
|
||||
This is the default provider.
|
||||
|
||||
First user is created using the onboarding process and the rest can be created by this user (see [user management](/docs/management/users))
|
||||
First user is created using the onboarding process and the rest can be created by this user (see [user management](/docs/management/users))
|
||||
|
||||
## LDAP provider
|
||||
</TabItem>
|
||||
<TabItem value="ldap" label="LDAP provider">
|
||||
|
||||
This provider authenticates against an LDAP server.
|
||||
This provider authenticates against an LDAP server.
|
||||
|
||||
Any user in LDAP server that signs in gets created in Homarr database.
|
||||
Any user in LDAP server that signs in gets created in Homarr database.
|
||||
|
||||
Roles are fetched from LDAP groups. Groups with the same name of Homarr will be used to synchronize them.
|
||||
Roles are fetched from LDAP groups. Groups with the same name of Homarr will be used to synchronize them.
|
||||
|
||||
|
||||
|
||||
|
||||
<details>
|
||||
<summary>
|
||||
Example Setup
|
||||
</summary>
|
||||
<div>
|
||||
<details>
|
||||
<summary>
|
||||
Example Setup
|
||||
</summary>
|
||||
<div>
|
||||
|
||||
In this setup we are using [lldap](https://github.com/lldap/lldap). Install your server using docker or [as a service](https://github.com/lldap/lldap/blob/main/example_configs/lldap.service).
|
||||
In this setup we are using [lldap](https://github.com/lldap/lldap). Install your server using docker or [as a service](https://github.com/lldap/lldap/blob/main/example_configs/lldap.service).
|
||||
|
||||
Minimal configuration requires 4 env variables, LDAP URI, base, user and password for querying data.
|
||||
Minimal configuration requires 4 env variables, LDAP URI, base, user and password for querying data.
|
||||
|
||||
There's more variables, but all have defaults corresponding to lldap defaults. These might need to be changed if you have different LDAP provider.
|
||||
```
|
||||
docker run ...
|
||||
AUTH_PROVIDERS="ldap"
|
||||
AUTH_LDAP_URI="ldap://example.com:3890"
|
||||
AUTH_LDAP_BASE="dc=example,dc=com" // Same as LLDAP_LDAP_BASE_DN
|
||||
AUTH_LDAP_BIND_DN="uid=admin,ou=People,dc=example,dc=com"
|
||||
AUTH_LDAP_BIND_PASSWORD="adminpass" // Same as LLDAP_LDAP_USER_PASS
|
||||
```
|
||||
```
|
||||
#Docker compose
|
||||
version: x
|
||||
services:
|
||||
homarr:
|
||||
environment:
|
||||
AUTH_PROVIDERS: ldap
|
||||
AUTH_LDAP_URI: ldap://example.com:3890
|
||||
AUTH_LDAP_BASE: dc=example,dc=com #Same as LLDAP_LDAP_BASE_DN
|
||||
AUTH_LDAP_BIND_DN: uid=admin,ou=People,dc=example,dc=com
|
||||
AUTH_LDAP_BIND_PASSWORD: adminpass #Same as LLDAP_LDAP_USER_PASS
|
||||
```
|
||||
There's more variables, but all have defaults corresponding to lldap defaults. These might need to be changed if you have different LDAP provider.
|
||||
```
|
||||
docker run ...
|
||||
AUTH_PROVIDERS="ldap"
|
||||
AUTH_LDAP_URI="ldap://example.com:3890"
|
||||
AUTH_LDAP_BASE="dc=example,dc=com" // Same as LLDAP_LDAP_BASE_DN
|
||||
AUTH_LDAP_BIND_DN="uid=admin,ou=People,dc=example,dc=com"
|
||||
AUTH_LDAP_BIND_PASSWORD="adminpass" // Same as LLDAP_LDAP_USER_PASS
|
||||
```
|
||||
```
|
||||
#Docker compose
|
||||
version: x
|
||||
services:
|
||||
homarr:
|
||||
environment:
|
||||
AUTH_PROVIDERS: ldap
|
||||
AUTH_LDAP_URI: ldap://example.com:3890
|
||||
AUTH_LDAP_BASE: dc=example,dc=com #Same as LLDAP_LDAP_BASE_DN
|
||||
AUTH_LDAP_BIND_DN: uid=admin,ou=People,dc=example,dc=com
|
||||
AUTH_LDAP_BIND_PASSWORD: adminpass #Same as LLDAP_LDAP_USER_PASS
|
||||
```
|
||||
|
||||
In lldap, create a user and admin group, assign this user to the external admin group configured during onboarding. You can log in using this user and he will be in the group.
|
||||
In lldap, create a user and admin group, assign this user to the external admin group configured during onboarding. You can log in using this user and he will be in the group.
|
||||
|
||||
Here is another example for Active Directory:
|
||||
Here is another example for Active Directory:
|
||||
|
||||
```
|
||||
AUTH_LDAP_URI="ldap://ldap.abc.xyz:389
|
||||
AUTH_LDAP_BASE="DC=abc,DC=xyz"
|
||||
AUTH_LDAP_BIND_DN="CN=Administrator,CN=Users,DC=abc,DC=xyz"
|
||||
AUTH_LDAP_BIND_PASSWORD="YourAdministratorPassword"
|
||||
AUTH_LDAP_USERNAME_ATTRIBUTE="sAMAccountName"
|
||||
AUTH_LDAP_USER_MAIL_ATTRIBUTE="userPrincipalName"
|
||||
AUTH_LDAP_GROUP_CLASS="group"
|
||||
AUTH_LDAP_GROUP_MEMBER_ATTRIBUTE="member"
|
||||
AUTH_LDAP_GROUP_MEMBER_USER_ATTRIBUTE="dn"
|
||||
AUTH_LDAP_SEARCH_SCOPE="sub"
|
||||
AUTH_LDAP_USERNAME_FILTER_EXTRA_ARG="(sAMAccountType=805306368)"
|
||||
```
|
||||
User mail attribute is set to userPrincipalName as it follows the right schema, but it is recommended to use real emails and the default 'mail' value.
|
||||
```
|
||||
AUTH_LDAP_URI="ldap://ldap.abc.xyz:389
|
||||
AUTH_LDAP_BASE="DC=abc,DC=xyz"
|
||||
AUTH_LDAP_BIND_DN="CN=Administrator,CN=Users,DC=abc,DC=xyz"
|
||||
AUTH_LDAP_BIND_PASSWORD="YourAdministratorPassword"
|
||||
AUTH_LDAP_USERNAME_ATTRIBUTE="sAMAccountName"
|
||||
AUTH_LDAP_USER_MAIL_ATTRIBUTE="userPrincipalName"
|
||||
AUTH_LDAP_GROUP_CLASS="group"
|
||||
AUTH_LDAP_GROUP_MEMBER_ATTRIBUTE="member"
|
||||
AUTH_LDAP_GROUP_MEMBER_USER_ATTRIBUTE="dn"
|
||||
AUTH_LDAP_SEARCH_SCOPE="sub"
|
||||
AUTH_LDAP_USERNAME_FILTER_EXTRA_ARG="(sAMAccountType=805306368)"
|
||||
```
|
||||
User mail attribute is set to userPrincipalName as it follows the right schema, but it is recommended to use real emails and the default 'mail' value.
|
||||
|
||||
</div>
|
||||
</details>
|
||||
</div>
|
||||
</details>
|
||||
|
||||
|
||||
### LDAP configuration
|
||||
### Configuration
|
||||
|
||||
| Environment Variable | Description | Default value |
|
||||
|-------------------------------------------|-----------------------------------------------------------|---------------------|
|
||||
| ``AUTH_LDAP_URI`` | URI of your LDAP server | --- |
|
||||
| ``AUTH_LDAP_BASE`` | Base dn of your LDAP server | --- |
|
||||
| ``AUTH_LDAP_BIND_DN`` | User used for finding users and groups | --- |
|
||||
| ``AUTH_LDAP_BIND_PASSWORD`` | Password for bind user | --- |
|
||||
| ``AUTH_LDAP_USERNAME_ATTRIBUTE`` | Attribute used for username | uid |
|
||||
| ``AUTH_LDAP_USER_MAIL_ATTRIBUTE`` | Attribute used for mail field | mail |
|
||||
| ``AUTH_LDAP_GROUP_CLASS`` | Class used for querying groups | groupOfUniqueNames |
|
||||
| ``AUTH_LDAP_GROUP_MEMBER_ATTRIBUTE`` | Attribute used for querying group member | member |
|
||||
| ``AUTH_LDAP_GROUP_MEMBER_USER_ATTRIBUTE`` | User attribute used for comparing with group member | dn |
|
||||
| ``AUTH_LDAP_SEARCH_SCOPE`` | Serach scopes between base, one and sub | base |
|
||||
| ``AUTH_LDAP_USERNAME_FILTER_EXTRA_ARG`` | Extra arguments for user search filter (& based) | --- |
|
||||
| ``AUTH_LDAP_GROUP_FILTER_EXTRA_ARG`` | Extra arguments for user's groups search filter (& based) | --- |
|
||||
| Environment Variable | Description | Default value |
|
||||
|-------------------------------------------|-----------------------------------------------------------|---------------------|
|
||||
| ``AUTH_LDAP_URI`` | URI of your LDAP server | --- |
|
||||
| ``AUTH_LDAP_BASE`` | Base dn of your LDAP server | --- |
|
||||
| ``AUTH_LDAP_BIND_DN`` | User used for finding users and groups | --- |
|
||||
| ``AUTH_LDAP_BIND_PASSWORD`` | Password for bind user | --- |
|
||||
| ``AUTH_LDAP_USERNAME_ATTRIBUTE`` | Attribute used for username | uid |
|
||||
| ``AUTH_LDAP_USER_MAIL_ATTRIBUTE`` | Attribute used for mail field | mail |
|
||||
| ``AUTH_LDAP_GROUP_CLASS`` | Class used for querying groups | groupOfUniqueNames |
|
||||
| ``AUTH_LDAP_GROUP_MEMBER_ATTRIBUTE`` | Attribute used for querying group member | member |
|
||||
| ``AUTH_LDAP_GROUP_MEMBER_USER_ATTRIBUTE`` | User attribute used for comparing with group member | dn |
|
||||
| ``AUTH_LDAP_SEARCH_SCOPE`` | Serach scopes between base, one and sub | base |
|
||||
| ``AUTH_LDAP_USERNAME_FILTER_EXTRA_ARG`` | Extra arguments for user search filter (& based) | --- |
|
||||
| ``AUTH_LDAP_GROUP_FILTER_EXTRA_ARG`` | Extra arguments for user's groups search filter (& based) | --- |
|
||||
|
||||
## OIDC provider
|
||||
</TabItem>
|
||||
<TabItem value="oidc" label="OIDC provider">
|
||||
|
||||
This provider authenticates using OIDC protocol.
|
||||
This provider authenticates using OIDC protocol.
|
||||
|
||||
Users signed in using OIDC are created in Homarr.
|
||||
Users signed in using OIDC are created in Homarr.
|
||||
|
||||
Roles are fetched from group claims. This can also be changed to roles for example added to a azure app registration by using the `AUTH_OIDC_GROUPS_ATTRIBUTE`.
|
||||
Roles are fetched from group claims. This can also be changed to roles for example added to a azure app registration by using the `AUTH_OIDC_GROUPS_ATTRIBUTE`.
|
||||
|
||||
If you'd rather manage group memberships for OIDC users locally (instead of via the IdP), set `AUTH_OIDC_GROUPS_LOCAL_MANAGEMENT=true`. While enabled, Homarr stops syncing OIDC users' group memberships from the groups claim, and admins can add/remove OIDC users on a group's members page. If you turn this back to `false`, the next login of each user resumes the regular sync and reconciles their groups with the IdP's claim again, which may remove memberships that were added manually while local management was enabled.
|
||||
If you'd rather manage group memberships for OIDC users locally (instead of via the IdP), set `AUTH_OIDC_GROUPS_LOCAL_MANAGEMENT=true`. While enabled, Homarr stops syncing OIDC users' group memberships from the groups claim, and admins can add/remove OIDC users on a group's members page. If you turn this back to `false`, the next login of each user resumes the regular sync and reconciles their groups with the IdP's claim again, which may remove memberships that were added manually while local management was enabled.
|
||||
|
||||
To let an existing credentials user also sign in with OIDC, set `AUTH_OIDC_ENABLE_DANGEROUS_CREDENTIALS_LINKING=true`. On the first OIDC sign-in, Homarr links a credentials user with the same email address instead of creating a second user. The user keeps their password login, local username and profile, groups, permissions, boards, settings, and API keys. OIDC profile and group claims do not overwrite the credentials-owned data. Homarr requires the OIDC profile to contain `email_verified: true` while this option is enabled. Only enable this when your OIDC provider verifies email addresses, because a matching email could otherwise be used to take over an existing account.
|
||||
To let an existing credentials user also sign in with OIDC, set `AUTH_OIDC_ENABLE_DANGEROUS_CREDENTIALS_LINKING=true`. On the first OIDC sign-in, Homarr links a credentials user with the same email address instead of creating a second user. The user keeps their password login, local username and profile, groups, permissions, boards, settings, and API keys. OIDC profile and group claims do not overwrite the credentials-owned data. Homarr requires the OIDC profile to contain `email_verified: true` while this option is enabled. Only enable this when your OIDC provider verifies email addresses, because a matching email could otherwise be used to take over an existing account.
|
||||
|
||||
<details>
|
||||
<summary>
|
||||
Example Setup
|
||||
</summary>
|
||||
<div>
|
||||
<details>
|
||||
<summary>
|
||||
Example Setup
|
||||
</summary>
|
||||
<div>
|
||||
|
||||
In this example we will be using [Authelia](https://github.com/authelia/authelia).
|
||||
In this example we will be using [Authelia](https://github.com/authelia/authelia).
|
||||
|
||||
You can use any setup, but the simplest is [local](https://github.com/authelia/authelia/blob/master/examples/compose/local/authelia/configuration.yml).
|
||||
You can use any setup, but the simplest is [local](https://github.com/authelia/authelia/blob/master/examples/compose/local/authelia/configuration.yml).
|
||||
|
||||
You also have to [enable OIDC in Authelia](https://www.authelia.com/configuration/identity-providers/open-id-connect/).
|
||||
You also have to [enable OIDC in Authelia](https://www.authelia.com/configuration/identity-providers/open-id-connect/).
|
||||
|
||||
Create a client for homarr. To generate client secret you can [use authelia](https://www.authelia.com/integration/openid-connect/frequently-asked-questions/#how-do-i-generate-client-secrets). This is an example config:
|
||||
Create a client for homarr. To generate client secret you can [use authelia](https://www.authelia.com/integration/openid-connect/frequently-asked-questions/#how-do-i-generate-client-secrets). This is an example config:
|
||||
|
||||
```yaml
|
||||
identity_providers:
|
||||
oidc:
|
||||
...
|
||||
clients:
|
||||
- id: homarr
|
||||
secret: <secret_hash>
|
||||
public: false
|
||||
authorization_policy: one_factor
|
||||
redirect_uris:
|
||||
- https://example.com/api/auth/callback/oidc
|
||||
- http://localhost:3000/api/auth/callback/oidc
|
||||
scopes:
|
||||
- openid
|
||||
- groups
|
||||
- profile
|
||||
- email
|
||||
userinfo_signing_algorithm: none
|
||||
consent_mode: implicit # self hosted
|
||||
```
|
||||
```yaml
|
||||
identity_providers:
|
||||
oidc:
|
||||
...
|
||||
clients:
|
||||
- id: homarr
|
||||
secret: <secret_hash>
|
||||
public: false
|
||||
authorization_policy: one_factor
|
||||
redirect_uris:
|
||||
- https://example.com/api/auth/callback/oidc
|
||||
- http://localhost:3000/api/auth/callback/oidc
|
||||
scopes:
|
||||
- openid
|
||||
- groups
|
||||
- profile
|
||||
- email
|
||||
userinfo_signing_algorithm: none
|
||||
consent_mode: implicit # self hosted
|
||||
```
|
||||
|
||||
In Homarr use following env variables.
|
||||
In Homarr use following env variables.
|
||||
|
||||
```
|
||||
AUTH_PROVIDERS="oidc"
|
||||
AUTH_OIDC_ISSUER="https://auth.example.com"
|
||||
AUTH_OIDC_CLIENT_SECRET="client_secret"
|
||||
AUTH_OIDC_CLIENT_ID="homarr"
|
||||
AUTH_OIDC_CLIENT_NAME="Authelia"
|
||||
AUTH_OIDC_FORCE_USERINFO="true" --> from v4.39 of authelia and above this is required
|
||||
```
|
||||
```
|
||||
AUTH_PROVIDERS="oidc"
|
||||
AUTH_OIDC_ISSUER="https://auth.example.com"
|
||||
AUTH_OIDC_CLIENT_SECRET="client_secret"
|
||||
AUTH_OIDC_CLIENT_ID="homarr"
|
||||
AUTH_OIDC_CLIENT_NAME="Authelia"
|
||||
AUTH_OIDC_FORCE_USERINFO="true" --> from v4.39 of authelia and above this is required
|
||||
```
|
||||
|
||||
For an azure app registration the setup could look like this:
|
||||
For an azure app registration the setup could look like this:
|
||||
|
||||
```
|
||||
AUTH_PROVIDERS="oidc"
|
||||
AUTH_OIDC_ISSUER="https://login.microsoftonline.com/<teanant-id>/v2.0"
|
||||
AUTH_OIDC_CLIENT_SECRET="<client-secret>"
|
||||
AUTH_OIDC_CLIENT_ID="<client-id>"
|
||||
AUTH_OIDC_CLIENT_NAME="Azure"
|
||||
AUTH_OIDC_SCOPE_OVERWRITE="openid email profile" # Groups scope does not exist in azure
|
||||
AUTH_OIDC_GROUPS_ATTRIBUTE="roles" # We use the roles of the app registration so that we don't need to use uuids as our groups
|
||||
```
|
||||
```
|
||||
AUTH_PROVIDERS="oidc"
|
||||
AUTH_OIDC_ISSUER="https://login.microsoftonline.com/<teanant-id>/v2.0"
|
||||
AUTH_OIDC_CLIENT_SECRET="<client-secret>"
|
||||
AUTH_OIDC_CLIENT_ID="<client-id>"
|
||||
AUTH_OIDC_CLIENT_NAME="Azure"
|
||||
AUTH_OIDC_SCOPE_OVERWRITE="openid email profile" # Groups scope does not exist in azure
|
||||
AUTH_OIDC_GROUPS_ATTRIBUTE="roles" # We use the roles of the app registration so that we don't need to use uuids as our groups
|
||||
```
|
||||
|
||||
</div>
|
||||
</details>
|
||||
</div>
|
||||
</details>
|
||||
|
||||
|
||||
### OIDC configuration
|
||||
### Configuration
|
||||
|
||||
| Environment Variable | Description | Default value |
|
||||
|-----------------------------|------------------------------------------------------------|---------------|
|
||||
| ``AUTH_OIDC_ISSUER`` | Issuer URI of OIDC provider. This has generally to be **without trailing slash** except for **Authentik** | --- |
|
||||
| ``AUTH_OIDC_CLIENT_ID`` | ID of OIDC client (application) | --- |
|
||||
| ``AUTH_OIDC_CLIENT_SECRET`` | Secret of OIDC client (application) | --- |
|
||||
| ``AUTH_OIDC_CLIENT_NAME`` | Display name of provider (in login screen) | OIDC |
|
||||
| ``AUTH_OIDC_AUTO_LOGIN`` | Automatically redirect to OIDC login | false |
|
||||
| ``AUTH_OIDC_SCOPE_OVERWRITE`` | Overwrite default scopes (openid, profile, email) | openid email profile groups |
|
||||
| ``AUTH_OIDC_GROUPS_ATTRIBUTE`` | Attribute used for groups (roles) claim. Dot-separated paths such as `resource_access.homarr.roles` are supported. | groups |
|
||||
| ``AUTH_OIDC_GROUPS_LOCAL_MANAGEMENT`` | Stop syncing group memberships of OIDC users from the groups claim and manage them locally via the group members page instead | false |
|
||||
| ``AUTH_OIDC_NAME_ATTRIBUTE_OVERWRITE`` | Overwrite name attribute. Dot-separated paths are supported. By default it will use preferred_username if it does not contain a `@` and otherwise name. | --- |
|
||||
| ``AUTH_OIDC_FORCE_USERINFO`` | Force userinfo endpoint to be used for user information. | false |
|
||||
| ``AUTH_OIDC_ENABLE_DANGEROUS_ACCOUNT_LINKING`` | Enable account linking for OIDC provider. This will link the OIDC accounts by email. Be sure that you have verified emails to prevent stealing accounts. | false |
|
||||
| ``AUTH_OIDC_ENABLE_DANGEROUS_CREDENTIALS_LINKING`` | Link an OIDC identity to an existing credentials user with the same email. The credentials login and existing user data are preserved. Requires the OIDC profile to contain `email_verified: true`. | false |
|
||||
| ``AUTH_OIDC_TOKEN_ENDPOINT_AUTH_METHOD`` | Override method for the token endpoint authentication. Supported values are `client_secret_basic`, `client_secret_post`, `client_secret_jwt` and `none`. | client_secret_basic |
|
||||
| Environment Variable | Description | Default value |
|
||||
|-----------------------------|------------------------------------------------------------|---------------|
|
||||
| ``AUTH_OIDC_ISSUER`` | Issuer URI of OIDC provider. This has generally to be **without trailing slash** except for **Authentik** | --- |
|
||||
| ``AUTH_OIDC_CLIENT_ID`` | ID of OIDC client (application) | --- |
|
||||
| ``AUTH_OIDC_CLIENT_SECRET`` | Secret of OIDC client (application) | --- |
|
||||
| ``AUTH_OIDC_CLIENT_NAME`` | Display name of provider (in login screen) | OIDC |
|
||||
| ``AUTH_OIDC_AUTO_LOGIN`` | Automatically redirect to OIDC login | false |
|
||||
| ``AUTH_OIDC_SCOPE_OVERWRITE`` | Overwrite default scopes (openid, profile, email) | openid email profile groups |
|
||||
| ``AUTH_OIDC_GROUPS_ATTRIBUTE`` | Attribute used for groups (roles) claim. Dot-separated paths such as `resource_access.homarr.roles` are supported. | groups |
|
||||
| ``AUTH_OIDC_GROUPS_LOCAL_MANAGEMENT`` | Stop syncing group memberships of OIDC users from the groups claim and manage them locally via the group members page instead | false |
|
||||
| ``AUTH_OIDC_NAME_ATTRIBUTE_OVERWRITE`` | Overwrite name attribute. Dot-separated paths are supported. By default it will use preferred_username if it does not contain a `@` and otherwise name. | --- |
|
||||
| ``AUTH_OIDC_FORCE_USERINFO`` | Force userinfo endpoint to be used for user information. | false |
|
||||
| ``AUTH_OIDC_ENABLE_DANGEROUS_ACCOUNT_LINKING`` | Enable account linking for OIDC provider. This will link the OIDC accounts by email. Be sure that you have verified emails to prevent stealing accounts. | false |
|
||||
| ``AUTH_OIDC_ENABLE_DANGEROUS_CREDENTIALS_LINKING`` | Link an OIDC identity to an existing credentials user with the same email. The credentials login and existing user data are preserved. Requires the OIDC profile to contain `email_verified: true`. | false |
|
||||
| ``AUTH_OIDC_TOKEN_ENDPOINT_AUTH_METHOD`` | Override method for the token endpoint authentication. Supported values are `client_secret_basic`, `client_secret_post`, `client_secret_jwt` and `none`. | client_secret_basic |
|
||||
|
||||
### OIDC permissions
|
||||
### Permission System
|
||||
|
||||
To give a user special permissions, first create a new group in homarr and assign the permission desired.
|
||||
For example if the group on homarr is called `homarr-admins` a new group on the oidc provider, for example authentik needs to be created with the same name, that would be `homarr-admins`.
|
||||
After assigning the group on the oidc provider to the user, and logging again into homarr, the user should automatically get placed in that group and inherit the permissions defined.
|
||||
To give a user special permissions, first create a new group in homarr and assign the permission desired.
|
||||
For example if the group on homarr is called `homarr-admins` a new group on the oidc provider, for example authentik needs to be created with the same name, that would be `homarr-admins`.
|
||||
After assigning the group on the oidc provider to the user, and logging again into homarr, the user should automatically get placed in that group and inherit the permissions defined.
|
||||
|
||||
</TabItem>
|
||||
</Tabs>
|
||||
|
||||
## Example setups
|
||||
|
||||
@@ -290,7 +295,7 @@ After assigning the group on the oidc provider to the user, and logging again in
|
||||
</div>
|
||||
</details>
|
||||
|
||||
See [OIDC configuration](#oidc-configuration) for additional options.
|
||||
Additional OIDC configuration options are described in the OIDC tab above.
|
||||
|
||||
</TabItem>
|
||||
<TabItem value="google" label="Google (OIDC) Example" default>
|
||||
@@ -356,7 +361,7 @@ After assigning the group on the oidc provider to the user, and logging again in
|
||||
</div>
|
||||
</details>
|
||||
|
||||
See [OIDC configuration](#oidc-configuration) for additional options.
|
||||
Additional OIDC configuration options are described in the OIDC tab above.
|
||||
|
||||
</TabItem>
|
||||
</Tabs>
|
||||
@@ -1,32 +1,31 @@
|
||||
---
|
||||
title: Frequently Asked Questions
|
||||
description: Answers to common Homarr questions about installation, customization, connectivity, support, and commercial use.
|
||||
tags:
|
||||
- Community
|
||||
- Help
|
||||
---
|
||||
|
||||
## Can Homarr run on a Raspberry Pi?
|
||||
### Can Homarr run on a Raspberry Pi?
|
||||
|
||||
Yes, when the device uses a supported `linux/arm64` operating system. `linux/arm/v7` is not supported. See
|
||||
[Prerequisites](/docs/getting-started).
|
||||
|
||||
## Can I use custom app icons?
|
||||
### Can I use custom app icons?
|
||||
|
||||
Yes. Use a direct image URL, one of Homarr's icon sources, or upload an image. See [Icons](/docs/advanced/icons).
|
||||
|
||||
## Can I create widgets?
|
||||
### Can I create widgets?
|
||||
|
||||
Yes. [Custom Widgets](/docs/management/custom-widgets) use server-side API requests and safe JSX. They can be written in
|
||||
the workbench or created with an MCP-connected agent.
|
||||
|
||||
## Can Homarr be exposed to the internet?
|
||||
### Can Homarr be exposed to the internet?
|
||||
|
||||
Homarr supports authenticated and public deployments. Terminate HTTPS at a reverse proxy, keep Homarr updated, and grant
|
||||
public board access only when anonymous viewing is intended. See [Board access control](/docs/management/boards#access-control)
|
||||
and [Single sign-on](/docs/advanced/single-sign-on).
|
||||
|
||||
## Where are the logs?
|
||||
### Where are the logs?
|
||||
|
||||
Use **Management → Tools → Logs** for retained and live application logs. Container startup failures and older events are
|
||||
available through the deployment platform, for example `docker container logs homarr`.
|
||||
@@ -34,18 +33,18 @@ available through the deployment platform, for example `docker container logs ho
|
||||
Use the browser developer console only for client-side failures. Review logs before sharing them because URLs and other
|
||||
deployment details may be present.
|
||||
|
||||
## Why can Homarr not reach a service behind Gluetun?
|
||||
### Why can Homarr not reach a service behind Gluetun?
|
||||
|
||||
Gluetun's firewall blocks traffic that is not explicitly allowed. Configure its
|
||||
[`FIREWALL_OUTBOUND_SUBNETS`](https://github.com/qdm12/gluetun-wiki/blob/main/setup/options/firewall.md) for the relevant
|
||||
LAN or Docker subnet, and use an address reachable from the Homarr container.
|
||||
|
||||
## Where do I report a bug or request a feature?
|
||||
### Where do I report a bug or request a feature?
|
||||
|
||||
Use the [GitHub issue templates](https://github.com/homarr-labs/homarr/issues/new/choose). For community support, see
|
||||
[Get in touch](/docs/community/get-in-touch).
|
||||
|
||||
## Can Homarr be used commercially?
|
||||
### Can Homarr be used commercially?
|
||||
|
||||
Yes. Homarr is licensed under the [Apache License 2.0](/docs/community/license). Commercial users can support development
|
||||
through [OpenCollective](/docs/community/donate).
|
||||
@@ -76,7 +76,7 @@ appearance can all be changed later.
|
||||
A **board** is a dashboard page containing apps, widgets, and Containers. Boards can be public or restricted to users
|
||||
and groups. Each user can choose a home board.
|
||||
|
||||

|
||||

|
||||
|
||||
See [Boards](/docs/management/boards) for responsive layouts, access control, appearance, and home-board settings.
|
||||
|
||||
@@ -85,7 +85,7 @@ See [Boards](/docs/management/boards) for responsive layouts, access control, ap
|
||||
An **app** is a saved shortcut: a name, URL, icon, and optional status check. Add an App tile or a Bookmarks widget to
|
||||
show apps on a board.
|
||||
|
||||

|
||||

|
||||
|
||||
See [Apps](/docs/management/apps) or [Icons](/docs/advanced/icons).
|
||||
|
||||
@@ -94,7 +94,7 @@ See [Apps](/docs/management/apps) or [Icons](/docs/advanced/icons).
|
||||
An **integration** is a server-side connection from Homarr to a supported service such as Sonarr, Home Assistant, or
|
||||
Docker. Integrations provide data and actions to widgets. They can also be linked to apps.
|
||||
|
||||

|
||||

|
||||
|
||||
See [Managing integrations](/docs/management/integrations) and the
|
||||
[integration catalog](/docs/integrations).
|
||||
@@ -104,7 +104,7 @@ See [Managing integrations](/docs/management/integrations) and the
|
||||
A **widget** is a board tile. Some widgets work on their own; others require one or more integrations. Configuration,
|
||||
supported integrations, and non-obvious limitations are documented on each widget page.
|
||||
|
||||

|
||||

|
||||
|
||||
See the [widget catalog](/docs/widgets).
|
||||
|
||||
@@ -113,7 +113,7 @@ See the [widget catalog](/docs/widgets).
|
||||
A **Container** groups board items and can be nested. A **rail** reserves a fixed area at the left or right of a
|
||||
responsive layout. Both are configured in board edit mode.
|
||||
|
||||

|
||||

|
||||
|
||||
See [Boards](/docs/management/boards#containers-and-rails).
|
||||
|
||||
@@ -122,7 +122,7 @@ See [Boards](/docs/management/boards#containers-and-rails).
|
||||
Users inherit permissions and default boards from their groups. Resource-specific access can further restrict boards
|
||||
and integrations.
|
||||
|
||||

|
||||

|
||||
|
||||
See [Users and groups](/docs/management/users) and [single sign-on](/docs/advanced/single-sign-on).
|
||||
|
||||
|
||||
|
After Width: | Height: | Size: 96 KiB |
|
Before Width: | Height: | Size: 29 KiB |
|
After Width: | Height: | Size: 75 KiB |
|
Before Width: | Height: | Size: 22 KiB |
|
After Width: | Height: | Size: 100 KiB |
|
Before Width: | Height: | Size: 36 KiB |
|
After Width: | Height: | Size: 141 KiB |
|
Before Width: | Height: | Size: 56 KiB |
|
After Width: | Height: | Size: 140 KiB |
|
Before Width: | Height: | Size: 38 KiB |
|
After Width: | Height: | Size: 527 KiB |
|
Before Width: | Height: | Size: 87 KiB |
@@ -2,4 +2,4 @@
|
||||
|
||||
Pika Pods is a hosting platform for open source apps. You can deploy Homarr on Pika Pods with just a few clicks:
|
||||
|
||||
[__](https://www.pikapods.com/pods?run=homarr)
|
||||
[__](https://www.pikapods.com/pods?run=homarr-v1)
|
||||
@@ -30,7 +30,7 @@ You can install Homarr directly from your Unraid Dashboard, no terminal required
|
||||
|
||||
Don't know whether you have the plugin installed or not? Search for this tab in the navigation:
|
||||
|
||||

|
||||

|
||||
:::
|
||||
|
||||
#### Installing
|
||||
@@ -41,7 +41,7 @@ After you've installed the Community Apps Plugin, you can install Homarr from th
|
||||
2. Search for "Homarr" and click on the result.
|
||||
3. Click on "Install" and adjust the settings to your liking.
|
||||
|
||||

|
||||

|
||||
|
||||
After you've installed Homarr, you can find it under the tab "Docker".
|
||||
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
---
|
||||
title: Try the v2 beta
|
||||
title: Upgrade from v1 to v2
|
||||
sidebar_position: 2
|
||||
tags:
|
||||
- Getting started
|
||||
@@ -8,20 +8,8 @@ tags:
|
||||
- Backup
|
||||
---
|
||||
|
||||
This page covers the temporary Homarr v2 public beta. Try the [live demo](https://app-v2.preview.homarr.dev/)
|
||||
without installing anything, or run the beta image published in the
|
||||
[public beta announcement](https://github.com/homarr-labs/homarr/issues/6600):
|
||||
|
||||
```sh
|
||||
docker pull ghcr.io/homarr-labs/homarr-test:v2
|
||||
```
|
||||
|
||||
For a fresh beta installation, follow the [Docker installation guide](/docs/getting-started/installation/docker),
|
||||
use `ghcr.io/homarr-labs/homarr-test:v2` as the image, and use a separate `/appdata` volume.
|
||||
Set `WORKSHOP_API_URL=https://v2.preview.homarr.dev` to use the preview Workshop.
|
||||
|
||||
Homarr runs database migrations automatically at startup; no manual database migration command is needed.
|
||||
Once v2 is released, existing installations can use their normal update process. The beta image below is temporary.
|
||||
Homarr v2 upgrades the existing v1 database in place. Keep the same data storage, database configuration, and
|
||||
`SECRET_ENCRYPTION_KEY`; only change the Homarr image or package version for the first start.
|
||||
|
||||
:::danger Back up before starting v2
|
||||
|
||||
@@ -30,23 +18,21 @@ before starting v2. See [Backup and restore](/docs/management/backup) for detail
|
||||
|
||||
:::
|
||||
|
||||
## Try the beta with existing v1 data
|
||||
## Upgrade
|
||||
|
||||
1. Change the Homarr image to `ghcr.io/homarr-labs/homarr-test:v2`.
|
||||
1. Pin the Homarr image to `ghcr.io/homarr-labs/homarr:v2.0.0` for the first v2 start.
|
||||
2. Keep the existing `/appdata` mount, database variables, Redis configuration, and `SECRET_ENCRYPTION_KEY` unchanged.
|
||||
3. Start one Homarr replica and follow its logs. Startup must reach `Migration complete`; Homarr aborts startup when a
|
||||
database migration fails.
|
||||
4. Open Homarr and sign in. Confirm **Management → About** reports v2 before starting additional replicas.
|
||||
5. Keep using the beta image for beta updates. To return to v1, restore the pre-beta backup as described below.
|
||||
5. After validation, keep the version pinned or return to your normal stable release policy.
|
||||
|
||||
Keep your other existing Compose settings, including environment variables. For Docker Compose, update the image and preview Workshop URL before the usual update:
|
||||
For Docker Compose, the change is limited to the image tag before the usual update:
|
||||
|
||||
```yaml
|
||||
services:
|
||||
homarr:
|
||||
image: ghcr.io/homarr-labs/homarr-test:v2
|
||||
environment:
|
||||
WORKSHOP_API_URL: https://v2.preview.homarr.dev
|
||||
image: ghcr.io/homarr-labs/homarr:v2.0.0
|
||||
```
|
||||
|
||||
```sh
|
||||
|
||||
@@ -1,55 +0,0 @@
|
||||
---
|
||||
title: "llama.cpp"
|
||||
description: "Connect Homarr to a llama.cpp llama-server to monitor your local LLM: health, loaded model, generation speed and request status."
|
||||
hide_title: true
|
||||
---
|
||||
|
||||
import { IntegrationHeader } from "@site/src/components/integrations/header";
|
||||
import { IntegrationCapabilites } from "@site/src/components/integrations/widgets";
|
||||
import { AddingIntegration } from "@site/src/components/integrations/adding";
|
||||
import { IntegrationSecrets } from "@site/src/components/integrations/secrets";
|
||||
import { llamacppIntegration } from ".";
|
||||
import { llamacppWidget } from "@site/docs/widgets/llama-cpp";
|
||||
|
||||
<IntegrationHeader integration={llamacppIntegration} categories={["System Monitoring"]} />
|
||||
|
||||
The llama.cpp integration lets Homarr connect to a [llama-server](https://github.com/ggml-org/llama.cpp) instance so you can see the health of your local LLM, which model is loaded, and how fast it is generating — all from your dashboard.
|
||||
|
||||
### Widgets & Capabilities
|
||||
|
||||
<IntegrationCapabilites items={[{ widget: llamacppWidget }]} />
|
||||
|
||||
### Adding the integration
|
||||
|
||||
<AddingIntegration />
|
||||
|
||||
:::info URL configuration
|
||||
Use the root URL of your `llama-server`, for example `http://192.168.1.50:8080`. Homarr calls the `/health`, `/v1/models`, `/metrics` and `/slots` endpoints under that URL automatically.
|
||||
:::
|
||||
|
||||
:::tip Metrics
|
||||
Generation speed and token counters are read from the Prometheus endpoint at `/metrics`. Start `llama-server` with the `--metrics` flag so these values are available:
|
||||
|
||||
```shell
|
||||
llama-server --model /path/to/model.gguf --metrics
|
||||
```
|
||||
|
||||
Without `--metrics` the widget still shows health, model information, and active-request speed from `/slots`; metrics-backed counters and aggregate speed stay empty.
|
||||
:::
|
||||
|
||||
:::info Context and per-request speed
|
||||
Context (KV cache) usage and the in-flight request data are read from the `/slots` endpoint, which is available on every recent llama-server build and requires no extra flags. The per-request generation speed shown while a request is running is derived by tracking the request's decoded token count across polls, so it reflects the average speed of the request that is currently being generated.
|
||||
:::
|
||||
|
||||
### Secrets
|
||||
|
||||
llama-server does not require authentication by default. The integration only needs the URL of your server.
|
||||
|
||||
<IntegrationSecrets
|
||||
secrets={[
|
||||
{
|
||||
credentials: [],
|
||||
steps: [],
|
||||
},
|
||||
]}
|
||||
/>
|
||||
@@ -1,9 +0,0 @@
|
||||
import { IntegrationDefinition } from "@site/src/types";
|
||||
|
||||
export const llamacppIntegration: IntegrationDefinition = {
|
||||
name: "llama.cpp",
|
||||
description:
|
||||
"Connect to a local llama.cpp llama-server to show model, throughput and request status on your dashboard.",
|
||||
iconUrl: "https://cdn.jsdelivr.net/gh/selfhst/icons/svg/llama-cpp.svg",
|
||||
path: "../../integrations/llama-cpp",
|
||||
};
|
||||
@@ -18,7 +18,7 @@ Create an API key from the **Authentication** tab. Its value has the form `<id>.
|
||||
Send it in the `ApiKey` header:
|
||||
|
||||
```sh
|
||||
curl -H 'ApiKey: <id>.<token>' https://homarr.example.com/api/info
|
||||
curl -H 'ApiKey: <id>.<token>' https://homarr.example.com/api/health/live
|
||||
```
|
||||
|
||||
The same header authenticates tRPC requests under `/api/trpc`. Without a key, browser requests can use the Homarr
|
||||
@@ -27,27 +27,6 @@ session cookie.
|
||||
API keys do not expire automatically and act as the user that created them. Every request uses that user's group and
|
||||
resource permissions.
|
||||
|
||||
## Invitation dates
|
||||
|
||||
`POST /api/invites` and the tRPC `invite.createInvite` procedure accept `expirationDate` as an ISO 8601 timestamp with a
|
||||
timezone, for example `2026-12-01T18:00:00Z`. Homarr validates the timestamp and stores it as a date. tRPC callers should
|
||||
send the ISO string rather than a JavaScript `Date` object.
|
||||
|
||||
## Health probes
|
||||
|
||||
The liveness endpoint does not require authentication:
|
||||
|
||||
```sh
|
||||
curl https://homarr.example.com/api/health/live
|
||||
```
|
||||
|
||||
It returns JSON with overall and per-component status and latency. HTTP `200` means every dependency is healthy; HTTP
|
||||
`500` identifies an unhealthy component without exposing its internal error. Health responses are not cached.
|
||||
|
||||
## Pagination
|
||||
|
||||
General paginated endpoints accept at most 100 records per page, and shared search endpoints accept a limit of at most 100. Request subsequent pages instead of using a larger value.
|
||||
|
||||
## Permission behavior
|
||||
|
||||
Unauthorized operations return `FORBIDDEN`. List endpoints filter records on the server when resource-specific access
|
||||
|
||||
@@ -36,11 +36,6 @@ current Base layout.
|
||||
A **Container** groups apps, widgets, and nested Containers. It can have a label, collapse control, app-opening action,
|
||||
border color, and CSS classes.
|
||||
|
||||
Items can occupy the outermost rows and columns of a Container. The drop preview shows the position where the item
|
||||
will remain when released.
|
||||
Dragging an item onto another item of the same size swaps their positions. Displaced items snap directly to their
|
||||
previewed positions without a movement animation and stay there when you release the pointer.
|
||||
|
||||
A **rail** reserves one to three columns at the left or right of a non-Mobile layout. Rails stay fixed while the page
|
||||
scrolls and store their contents separately for each layout.
|
||||
|
||||
@@ -56,8 +51,6 @@ layout.
|
||||
- Press Escape during a pointer or touch interaction to restore the item to its previous position.
|
||||
|
||||
The add-content menu can create an app, integration, widget, or Container without leaving the board.
|
||||
When adding apps, click one app to add it immediately, or choose **Select multiple** to add several at once. `Ctrl`/`Cmd`
|
||||
click remains available as a shortcut.
|
||||
|
||||
## Board settings
|
||||
|
||||
|
||||
@@ -17,7 +17,7 @@ The color of the icon indicates how long it is valid.
|
||||
|
||||

|
||||
|
||||

|
||||

|
||||
|
||||
| Color | Meaning |
|
||||
| --------- | --------------------------- |
|
||||
@@ -34,7 +34,7 @@ Navigate to `Management` > `Tools` > `Certificates`.
|
||||
|
||||
To add a certificate click on the "Add certificate" button. The upload form expands directly on the certificates page.
|
||||
|
||||

|
||||

|
||||
|
||||
Then select the certificate file you want to add and click the "Add" button. You can cancel to collapse the form without
|
||||
leaving the page.
|
||||
@@ -43,7 +43,7 @@ leaving the page.
|
||||
|
||||
To delete a certificate you can click on the trash icon in the certificate card.
|
||||
|
||||

|
||||

|
||||
|
||||
The first click arms the action and the second click confirms the deletion in place; no confirmation dialog is opened.
|
||||
|
||||
|
||||
@@ -29,8 +29,6 @@ encrypted separately and are not included in exports, prompts, browser data, or
|
||||
|
||||
Administrators can create, import, duplicate, enable, export, publish, and delete definitions under
|
||||
**Management → Custom Widgets**. Adding or configuring one on a board also requires permission to modify that board.
|
||||
Use **Actions → Configure sources** to update deployment-specific URLs, network access, and encrypted credentials
|
||||
without reopening the authoring workbench.
|
||||
|
||||
Once placed, normal board permissions control rendering and requests. Queries can run for anonymous viewers on public
|
||||
boards; actions require authentication and the permission declared by the request.
|
||||
|
||||
@@ -8,17 +8,18 @@ tags:
|
||||
|
||||
# Model Context Protocol (MCP)
|
||||
|
||||
Connect an AI client to Homarr to manage boards, apps, integrations, and other services through MCP tools.
|
||||
|
||||
Homarr uses MCP v2 (protocol `2026-07-28`) at `/api/mcp`, with compatibility for 2025 Streamable HTTP clients.
|
||||
Requests are stateless: each request authenticates independently, without an MCP session ID.
|
||||
Homarr exposes a stateless [Model Context Protocol](https://modelcontextprotocol.io) server at `/api/mcp`. It maps the
|
||||
current Homarr API to tools for compatible AI clients.
|
||||
|
||||

|
||||
|
||||
## Connect a client
|
||||
The endpoint implements MCP `2026-07-28` and accepts 2025 Streamable HTTP clients through a compatibility path. The old
|
||||
`/api/mcp/mcp` route remains as an alias; use `/api/mcp` for new clients.
|
||||
|
||||
Create an API key under **Management → Tools → API → Authentication**. Add this configuration to your client,
|
||||
replacing the URL and API key:
|
||||
## Configure a client
|
||||
|
||||
Create an API key under **Management → Tools → API → Authentication**, then add the endpoint and header to the client's
|
||||
MCP configuration:
|
||||
|
||||
```json
|
||||
{
|
||||
@@ -33,10 +34,7 @@ replacing the URL and API key:
|
||||
}
|
||||
```
|
||||
|
||||
Use the complete key, including the ID, dot, and token. Keep it private and use HTTPS for remote connections.
|
||||
The **AI / MCP** tab on Homarr's API page provides your endpoint, configuration, and available tools.
|
||||
|
||||
For clients that only support STDIO, use `mcp-remote`:
|
||||
Clients without Streamable HTTP support can use `mcp-remote` over STDIO:
|
||||
|
||||
```json
|
||||
{
|
||||
@@ -49,30 +47,25 @@ For clients that only support STDIO, use `mcp-remote`:
|
||||
}
|
||||
```
|
||||
|
||||
### OAuth
|
||||
The **AI / MCP** tab on Homarr's API page shows the current endpoint, configuration, and generated tool catalog.
|
||||
|
||||
Clients with OAuth support can authorize through OAuth 2.1 with PKCE instead of an API key.
|
||||
Homarr publishes discovery metadata under `/.well-known/`.
|
||||
## Authentication and permissions
|
||||
|
||||
If your reverse proxy does not preserve the public host and protocol, set `BASE_URL` to your public origin,
|
||||
such as `https://homarr.example.com`, without a path.
|
||||
MCP supports API keys and OAuth 2.1 with PKCE. API keys are simpler for direct client configuration; OAuth is available
|
||||
for clients that support dynamic authorization.
|
||||
|
||||
## Permissions and tools
|
||||
When Homarr is behind a reverse proxy that does not preserve the public host and protocol, set `BASE_URL` to the exact
|
||||
public origin, for example `https://homarr.example.com`. Do not include a path. OAuth clients discover the authorization
|
||||
server and protected resource through Homarr's `/.well-known/` routes.
|
||||
|
||||
Tools use the permissions of the API key owner or OAuth user. Queries read data; mutations change it.
|
||||
Inspect the **AI / MCP** tab or your client's tool list for available actions.
|
||||
Tools use the permissions of the API key owner or authorized user. Creating a key does not grant additional access.
|
||||
Custom Widget and Workshop authoring tools, prompts, and resources are visible only to administrators.
|
||||
|
||||
Custom Widget and Workshop authoring tools, prompts, and resources require administrator access.
|
||||
For Custom Widgets, install the official skill and follow the preview-and-evidence workflow described in
|
||||
[Connect an agent](/docs/management/custom-widgets/agent-authoring). Secret values are configured in Homarr and are never returned
|
||||
through MCP.
|
||||
Use HTTPS when the endpoint crosses an untrusted network.
|
||||
|
||||
The built-in [Assistant](/docs/management/assistant) uses the same tool catalog with the current Homarr session and asks for approval
|
||||
before mutations by default.
|
||||
## Verify the endpoint
|
||||
|
||||
## Verify the connection
|
||||
|
||||
This request lists tools through the older-client compatibility path. It checks authentication and tool discovery:
|
||||
The 2025 compatibility path accepts a compact `tools/list` request:
|
||||
|
||||
```sh
|
||||
curl -X POST \
|
||||
@@ -83,14 +76,17 @@ curl -X POST \
|
||||
https://homarr.example.com/api/mcp
|
||||
```
|
||||
|
||||
A successful discovery or initialization response alone does not verify that tools can be listed.
|
||||
The removed HTTP+SSE `/sse` and `/message` transports return `410 Gone`.
|
||||
|
||||
## Troubleshooting
|
||||
## Tools and resources
|
||||
|
||||
- **`invalid_token`**: copy the complete API key in `<id>.<token>` format.
|
||||
- **Missing tools**: check the user's permissions. Invalid tool schemas and duplicate names are omitted; check server
|
||||
logs for `MCP tool omitted from catalog` and report the diagnostic with your Homarr version. Exclude API keys.
|
||||
- **`Date cannot be represented in JSON Schema`**: update Homarr. Older versions could fail to list all tools because
|
||||
of an unsupported date schema.
|
||||
- **`410 Gone` on `/sse` or `/message`**: use `/api/mcp` with a Streamable HTTP client. The old `/api/mcp/mcp` URL remains
|
||||
an alias.
|
||||
The generated catalog includes available board, app, integration, Docker, Kubernetes, media, DNS, download, and other
|
||||
Homarr procedures. Queries are read-only; mutations change state. The exact catalog depends on the release and current
|
||||
user permissions, so inspect `tools/list` or the table in Homarr instead of relying on a static list.
|
||||
|
||||
For Custom Widgets, install the official skill and follow the preview-and-evidence workflow described in
|
||||
[Connect an agent](/docs/management/custom-widgets/agent-authoring). Secret values are configured in Homarr and are never returned
|
||||
through MCP.
|
||||
|
||||
The built-in [Assistant](/docs/management/assistant) uses the same tool catalog with the current Homarr session and asks for approval
|
||||
before mutations by default.
|
||||
@@ -11,6 +11,6 @@ tags:
|
||||
Using the media feature, you can upload images for custom icons to Homarr.
|
||||
Images will be stored in the database. They can be selected in the [icon picker](/docs/advanced/icons).
|
||||
|
||||

|
||||

|
||||
|
||||

|
||||
@@ -22,7 +22,7 @@ You can add any search engine that has a search URL that accepts a search term a
|
||||
|
||||
To create a custom search engine, you can click the button at the top of the page.
|
||||
|
||||

|
||||

|
||||
|
||||
### Name
|
||||
|
||||
|
||||
@@ -13,13 +13,8 @@ A **user** is a Homarr identity with preferences, sessions, group memberships, a
|
||||
use local credentials, LDAP, or OIDC; see [Single sign-on](/docs/advanced/single-sign-on).
|
||||
|
||||
Manage users under **Management → Users → Manage**. Administrators can change a user's profile, language, home boards,
|
||||
search preferences, data-size units (binary KiB/MiB or decimal KB/MB), week start, accessibility preference, and local
|
||||
password. Decimal units are the default. Change them from the user form or **Command menu (Ctrl/Cmd + K) →
|
||||
Preferences → Data size units**. Byte rates follow the same preference; bit rates stay decimal and use labels such as
|
||||
Mb/s. Deleting a user does not delete boards they created.
|
||||
|
||||
In demo mode (`DEMO_MODE=true`), user deletion and password changes are disabled for everyone, including administrators,
|
||||
even when `DEMO_READ_ONLY=false` allows other changes. This applies to the UI and API.
|
||||
search preferences, week start, accessibility preference, and local password. Deleting a user does not delete boards
|
||||
they created.
|
||||
|
||||

|
||||
|
||||
|
||||
@@ -15,12 +15,6 @@ import { bookmarksWidget } from ".";
|
||||
|
||||
The Bookmarks widget keeps direct URLs and Homarr Apps in one ordered list. URLs do not require an App record; Homarr
|
||||
uses the site's favicon when available. Choose adaptive, compact-grid, or other layouts from the widget configuration.
|
||||
Vertical uses consistent compact rows and keeps bookmark names visible whenever the widget is wide enough, regardless
|
||||
of how many bookmarks are configured. Adaptive and grid layouts reduce card height and spacing to show as many
|
||||
bookmarks as possible before scrolling. Horizontal uses a compact, centered scrolling row with narrower cards in
|
||||
one-row widgets. Horizontal, vertical, compact-grid, and icon-only layouts keep their gaps tight. Compact grid also
|
||||
preserves its configured columns when the dashboard is scaled. Bookmark cards, icons, text, and gaps keep the same
|
||||
visual density when a low-column dashboard is enlarged to fill the screen.
|
||||
|
||||
### Screenshots
|
||||
|
||||
|
||||
@@ -16,8 +16,7 @@ export const bookmarksWidget: WidgetDefinition = {
|
||||
},
|
||||
{
|
||||
name: "Layout",
|
||||
description:
|
||||
"How bookmarks use the available widget space. Vertical keeps compact rows and stable title visibility.",
|
||||
description: "How bookmarks use the available widget space.",
|
||||
values: { type: "select", options: ["Adaptive", "Vertical", "Horizontal", "Grid", "Compact grid", "Icons"] },
|
||||
defaultValue: "Adaptive",
|
||||
},
|
||||
@@ -29,7 +28,7 @@ export const bookmarksWidget: WidgetDefinition = {
|
||||
},
|
||||
{
|
||||
name: "Card spacing",
|
||||
description: "Space between bookmark cards. Horizontal, compact-grid, and icon-only layouts cap larger gaps.",
|
||||
description: "Space between bookmark cards.",
|
||||
values: { type: "select", options: ["Extra small", "Small", "Medium", "Large", "Extra large"] },
|
||||
defaultValue: "Extra small",
|
||||
},
|
||||
|
||||
@@ -66,9 +66,8 @@ export const downloadsWidget: WidgetDefinition = {
|
||||
defaultValue: "yes",
|
||||
},
|
||||
{
|
||||
name: "Hide completed torrent under this threshold",
|
||||
description:
|
||||
"This hides completed torrents whose upload speed is below the threshold. The input uses KiB/s with binary units and KB/s with decimal units.",
|
||||
name: "Hide completed torrent under this threshold (in kiB/s)",
|
||||
description: "This will hide completed torrent entries whose upload speed is below the specified threshold.",
|
||||
values: "Any number above 0, 0 to disable",
|
||||
defaultValue: "0",
|
||||
},
|
||||
|
||||
@@ -1,43 +0,0 @@
|
||||
---
|
||||
title: "llama.cpp"
|
||||
description: "Displays the health, loaded model and generation speed of a local llama.cpp llama-server."
|
||||
hide_title: true
|
||||
---
|
||||
|
||||
import { WidgetHeader } from "@site/src/components/widgets/header";
|
||||
import { AddingWidget } from "@site/src/components/widgets/adding";
|
||||
import { WidgetConfig } from "@site/src/components/widgets/configuration";
|
||||
import { WidgetIntegrations } from "@site/src/components/widgets/integrations";
|
||||
import { llamacppWidget } from ".";
|
||||
import { llamacppIntegration } from "@site/docs/integrations/llama-cpp";
|
||||
|
||||
<WidgetHeader widget={llamacppWidget} categories={["System Monitoring"]} />
|
||||
|
||||
This widget shows the live status of your local [llama.cpp](https://github.com/ggml-org/llama.cpp) `llama-server`: whether it is online, which model is loaded (with quantization, context size, file size and parameter count), the generation speed in tokens per second, and how many requests are being processed. When the server is started with `--metrics`, it also reports total token counts and the prompt cache hit rate.
|
||||
|
||||
### Generation speed
|
||||
|
||||
The widget picks the most meaningful speed for the current state (hover the speed for an explanation of which one is shown):
|
||||
|
||||
- **While a request is being generated** — the average speed of that request since it started, derived from the token count reported by `/slots`.
|
||||
- **While the server is busy but the per-request data is unavailable** — the live speed from the `/metrics` rate gauge.
|
||||
- **While the server is idle** — the average generation speed since the server started (cumulative tokens ÷ seconds), so the value stays meaningful instead of dropping to zero.
|
||||
|
||||
### Supported Integrations
|
||||
|
||||
<WidgetIntegrations
|
||||
items={[
|
||||
{
|
||||
integration: llamacppIntegration,
|
||||
note: "Displays health, model details, generation speed and request status",
|
||||
},
|
||||
]}
|
||||
/>
|
||||
|
||||
### Adding the widget
|
||||
|
||||
<AddingWidget />
|
||||
|
||||
### Configuration
|
||||
|
||||
<WidgetConfig configuration={llamacppWidget.configuration} />
|
||||
@@ -1,37 +0,0 @@
|
||||
import { WidgetDefinition } from "@site/src/types";
|
||||
import { IconCpu } from "@tabler/icons-react";
|
||||
|
||||
export const llamacppWidget: WidgetDefinition = {
|
||||
icon: IconCpu,
|
||||
name: "llama.cpp",
|
||||
description: "Shows the health, loaded model and generation speed of a local llama.cpp llama-server.",
|
||||
path: "../../widgets/llama-cpp",
|
||||
configuration: {
|
||||
items: [
|
||||
{
|
||||
name: "Show title",
|
||||
description: "Displays the llama.cpp label and status badge at the top of the widget",
|
||||
values: { type: "boolean" },
|
||||
defaultValue: "yes",
|
||||
},
|
||||
{
|
||||
name: "Show model info",
|
||||
description: "Displays the loaded model name, quantization, context size and file size",
|
||||
values: { type: "boolean" },
|
||||
defaultValue: "yes",
|
||||
},
|
||||
{
|
||||
name: "Show cache hit rate",
|
||||
description: "Displays the prompt cache hit rate as a percentage bar",
|
||||
values: { type: "boolean" },
|
||||
defaultValue: "yes",
|
||||
},
|
||||
{
|
||||
name: "Show context usage",
|
||||
description: "Displays the context (KV cache) token usage as a progress bar",
|
||||
values: { type: "boolean" },
|
||||
defaultValue: "yes",
|
||||
},
|
||||
],
|
||||
},
|
||||
};
|
||||
@@ -19,7 +19,11 @@ export const systemDisksWidget: WidgetDefinition = {
|
||||
description: "How disk usage values are shown",
|
||||
values: {
|
||||
type: "select",
|
||||
options: ["Percentage (e.g., 76%)", "Absolute values", "Free space percentage (e.g., 24% free)"],
|
||||
options: [
|
||||
"Percentage (e.g., 76%)",
|
||||
"Absolute values (e.g., 800GB / 1TB)",
|
||||
"Free space percentage (e.g., 24% free)",
|
||||
],
|
||||
},
|
||||
defaultValue: "Percentage (e.g., 76%)",
|
||||
},
|
||||
|
||||
@@ -14,8 +14,7 @@
|
||||
"clean": "git clean -xdf .next out node_modules",
|
||||
"api-dump-contributors": "node ./scripts/dump-contributors.mjs",
|
||||
"validate:coverage": "tsx ./scripts/validate-docs-coverage.ts",
|
||||
"validate:links": "tsx ./scripts/validate-links.ts",
|
||||
"verify:search": "tsx ./scripts/verify-search.ts"
|
||||
"validate:links": "tsx ./scripts/validate-links.ts"
|
||||
},
|
||||
"dependencies": {
|
||||
"@base-ui/react": "^1.5.0",
|
||||
@@ -26,17 +25,21 @@
|
||||
"@mdx-js/react": "^3.1.1",
|
||||
"@nivo/line": "^0.99.0",
|
||||
"@scalar/api-client-react": "catalog:",
|
||||
"@shadcn/react": "catalog:",
|
||||
"@tabler/icons-react": "catalog:",
|
||||
"@tailwindcss/postcss": "^4.3.2",
|
||||
"class-variance-authority": "^0.7.1",
|
||||
"clsx": "catalog:",
|
||||
"cmdk": "catalog:",
|
||||
"date-fns": "catalog:",
|
||||
"dayjs": "catalog:",
|
||||
"embla-carousel-react": "catalog:",
|
||||
"feed": "catalog:",
|
||||
"fumadocs-core": "catalog:",
|
||||
"fumadocs-mdx": "catalog:",
|
||||
"fumadocs-openapi": "catalog:",
|
||||
"fumadocs-ui": "catalog:",
|
||||
"input-otp": "catalog:",
|
||||
"json-schema-typed": "catalog:",
|
||||
"mermaid": "catalog:",
|
||||
"next": "catalog:",
|
||||
@@ -45,7 +48,10 @@
|
||||
"postcss": "^8.5.16",
|
||||
"posthog-js": "catalog:",
|
||||
"react": "catalog:",
|
||||
"react-day-picker": "catalog:",
|
||||
"react-dom": "catalog:",
|
||||
"react-resizable-panels": "catalog:",
|
||||
"recharts": "3.8.0",
|
||||
"remark-directive": "catalog:",
|
||||
"shiki": "catalog:",
|
||||
"sonner": "catalog:",
|
||||
|
||||
@@ -1,4 +0,0 @@
|
||||
WEBVTT
|
||||
|
||||
00:00.000 --> 00:10.500
|
||||
Silent demonstration: Dashboard widgets are moved into new positions, then a feed widget is widened.
|
||||
@@ -1,45 +0,0 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { spawnSync } from "node:child_process";
|
||||
import { mkdtemp, mkdir, rm, writeFile } from "node:fs/promises";
|
||||
import { tmpdir } from "node:os";
|
||||
import path from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
|
||||
const directory = await mkdtemp(path.join(tmpdir(), "homarr-docs-links-"));
|
||||
const validator = fileURLToPath(new URL("./validate-links.ts", import.meta.url));
|
||||
|
||||
try {
|
||||
await mkdir(path.join(directory, "docs"));
|
||||
await mkdir(path.join(directory, "out/docs/child"), { recursive: true });
|
||||
await writeFile(path.join(directory, "out/docs/child/index.html"), '<h1 id="child">Child</h1>');
|
||||
await writeFile(path.join(directory, "out/asset.svg"), "<svg />");
|
||||
|
||||
const cases = [
|
||||
{ name: "valid anchors, relative routes, assets and external URLs", href: "#section", expected: 0 },
|
||||
{ name: "same-page generated anchor", href: "#missing", expected: 1 },
|
||||
{ name: "cross-page anchor", href: "/docs/child/#missing", expected: 1 },
|
||||
{ name: "rendered route", href: "/missing", expected: 1 },
|
||||
{ name: "same-origin absolute route", href: "https://homarr.dev/missing", expected: 1 },
|
||||
{ name: "rendered asset", href: "/missing.svg", expected: 1 },
|
||||
{ name: "source-only link", href: "#section", source: "[Missing](/missing)", expected: 1 },
|
||||
{ name: "source-only anchor", href: "#section", source: "[Missing](/docs/#missing)", expected: 1 },
|
||||
];
|
||||
|
||||
for (const test of cases) {
|
||||
await writeFile(path.join(directory, "docs/index.mdx"), test.source ?? "[Valid](/docs/#section)");
|
||||
await writeFile(
|
||||
path.join(directory, "out/docs/index.html"),
|
||||
`<main><h1 id="section">Section</h1><a href="${test.href}">Test</a>
|
||||
<a href="child/#child">Child</a><a href="/asset.svg">Asset</a>
|
||||
<a href="https://example.com/missing">External</a><a href="#top">Top</a></main>`,
|
||||
);
|
||||
const result = spawnSync(process.execPath, ["--import", import.meta.resolve("tsx"), validator], {
|
||||
cwd: directory,
|
||||
encoding: "utf8",
|
||||
});
|
||||
assert.equal(result.status, test.expected, `${test.name}\n${result.stdout}\n${result.stderr}`);
|
||||
}
|
||||
console.log(`${cases.length} link validation checks passed`);
|
||||
} finally {
|
||||
await rm(directory, { recursive: true, force: true });
|
||||
}
|
||||
@@ -1,7 +1,7 @@
|
||||
import { readFile, readdir } from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
import { load } from "cheerio";
|
||||
import { printErrors, readFiles, validateFiles, type ScanResult, type UrlMeta } from "next-validate-link";
|
||||
import { printErrors, readFiles, validateFiles, type ScanResult } from "next-validate-link";
|
||||
|
||||
const outputDirectory = path.resolve("out");
|
||||
|
||||
@@ -11,7 +11,7 @@ const toContentUrl = (file: string) => {
|
||||
if (normalized.startsWith("docs/")) {
|
||||
const slug = normalized
|
||||
.slice("docs/".length)
|
||||
.replace(/(?:^|\/)index\.mdx?$/, "")
|
||||
.replace(/\/(index)?\.mdx?$/, "")
|
||||
.replace(/\.mdx?$/, "");
|
||||
return `/docs${slug ? `/${slug}` : ""}/`;
|
||||
}
|
||||
@@ -25,8 +25,8 @@ const toContentUrl = (file: string) => {
|
||||
|
||||
const files = await readFiles(["docs/**/*.{md,mdx}", "blog/**/*.{md,mdx}"], { pathToUrl: toContentUrl });
|
||||
const output = await readdir(outputDirectory, { recursive: true, withFileTypes: true });
|
||||
const urls = new Map<string, UrlMeta>();
|
||||
const renderedPages: { url: string; links: string[] }[] = [];
|
||||
const urls = new Map<string, object>();
|
||||
const renderedPages: { file: string; url: string }[] = [];
|
||||
|
||||
for (const entry of output) {
|
||||
if (!entry.isFile()) continue;
|
||||
@@ -37,20 +37,9 @@ for (const entry of output) {
|
||||
continue;
|
||||
}
|
||||
const url = `/${relative.replace(/\/?index\.html$/, "")}`.replace(/\/$/, "") || "/";
|
||||
const $ = load(await readFile(file, "utf8"));
|
||||
const hashes = $("[id], a[name]")
|
||||
.toArray()
|
||||
.flatMap((element) => [$(element).attr("id"), $(element).attr("name")].filter((value) => value !== undefined));
|
||||
const meta = { hashes };
|
||||
urls.set(url, meta);
|
||||
urls.set(url === "/" ? url : `${url}/`, meta);
|
||||
renderedPages.push({
|
||||
url,
|
||||
links: $("a[href]")
|
||||
.toArray()
|
||||
.map((element) => $(element).attr("href"))
|
||||
.filter((href) => href !== undefined),
|
||||
});
|
||||
urls.set(url, {});
|
||||
urls.set(`${url}/`, {});
|
||||
renderedPages.push({ file, url });
|
||||
}
|
||||
|
||||
const scanned: ScanResult = { urls, fallbackUrls: [] };
|
||||
@@ -58,42 +47,38 @@ const results = await validateFiles(files, {
|
||||
scanned,
|
||||
checkExternal: false,
|
||||
checkRelativePaths: "exists",
|
||||
ignoreFragment: true,
|
||||
markdown: {
|
||||
components: {
|
||||
Link: { attributes: ["href", "to"] },
|
||||
},
|
||||
},
|
||||
whitelist: (url) => /^(?:https?:|mailto:|tel:)/.test(url),
|
||||
whitelist: (url) =>
|
||||
/^(?:https?:|mailto:|tel:|#)/.test(url) ||
|
||||
/^\/(?:api\/|custom-widgets\/|data\/|img\/|workshop(?:\/|$)|llms(?:-|\.|\/)|robots\.txt|sitemap\.xml)/.test(url),
|
||||
});
|
||||
|
||||
const sourceErrors = results.filter((result) => result.errors.length > 0);
|
||||
printErrors(sourceErrors);
|
||||
if (sourceErrors.length > 0) process.exitCode = 1;
|
||||
printErrors(
|
||||
results.filter((result) => result.errors.length > 0),
|
||||
true,
|
||||
);
|
||||
|
||||
const renderedLinkErrors = new Set<string>();
|
||||
|
||||
for (const page of renderedPages) {
|
||||
for (const href of page.links) {
|
||||
if (!href) continue;
|
||||
try {
|
||||
// The export serves directories with trailing slashes, including relative links.
|
||||
const target = new URL(href, `https://homarr.dev${page.url.replace(/\/$/, "")}/`);
|
||||
if (target.origin !== "https://homarr.dev") continue;
|
||||
const pathname = decodeURI(target.pathname);
|
||||
const meta = urls.get(pathname) ?? urls.get(pathname.replace(/\/$/, "")) ?? urls.get(`${pathname}/`);
|
||||
if (!meta) {
|
||||
renderedLinkErrors.add(`${page.url}: ${href} resolves to missing route ${pathname}`);
|
||||
continue;
|
||||
}
|
||||
// Text fragments do not refer to element IDs; validate any preceding anchor.
|
||||
const hash = decodeURIComponent(target.hash.slice(1).split(":~:text=")[0]);
|
||||
if (hash && hash !== "top" && meta.hashes && !meta.hashes.includes(hash)) {
|
||||
renderedLinkErrors.add(`${page.url}: ${href} resolves to missing anchor #${hash} on ${pathname}`);
|
||||
}
|
||||
} catch {
|
||||
renderedLinkErrors.add(`${page.url}: invalid URL ${href}`);
|
||||
}
|
||||
}
|
||||
const $ = load(await readFile(page.file, "utf8"));
|
||||
|
||||
$("main a[href]").each((_, element) => {
|
||||
const href = $(element).attr("href");
|
||||
if (!href || /^(?:https?:|mailto:|tel:|#)/.test(href)) return;
|
||||
|
||||
const target = new URL(href, `https://homarr.dev${page.url}`).pathname;
|
||||
if (/^\/(?:_next\/|api\/|custom-widgets\/|data\/|img\/|videos\/)/.test(target)) return;
|
||||
if (/\.(?:avif|gif|jpe?g|json|md|mp4|png|svg|webm|webp)$/i.test(target)) return;
|
||||
if (urls.has(target) || urls.has(target.replace(/\/$/, "")) || urls.has(`${target}/`)) return;
|
||||
|
||||
renderedLinkErrors.add(`${page.url}: ${href} resolves to missing route ${target}`);
|
||||
});
|
||||
}
|
||||
|
||||
if (renderedLinkErrors.size > 0) {
|
||||
|
||||
@@ -1,76 +0,0 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { access, readFile } from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
import { load } from "cheerio";
|
||||
import { staticClient } from "fumadocs-core/search/client/orama-static";
|
||||
|
||||
const outputDirectory = path.resolve("out");
|
||||
const index = await readFile(path.join(outputDirectory, "api/search"), "utf8");
|
||||
// Exercise the same client and exported database as the browser, without a server.
|
||||
const from = `data:application/json;base64,${Buffer.from(index).toString("base64")}`;
|
||||
const client = staticClient({ from });
|
||||
const cases = [
|
||||
{ query: "Sonarr", type: "page", url: "/docs/integrations/sonarr" },
|
||||
{ query: "Docker Compose", type: "heading", url: "/docs/getting-started/installation/docker#docker-compose" },
|
||||
{ query: "SECRET_ENCRYPTION_KEY", type: "text", url: "/docs/advanced/environment-variables" },
|
||||
] as const;
|
||||
const verifiedUrls = new Set<string>();
|
||||
|
||||
for (const test of cases) {
|
||||
const results = await client.search(test.query);
|
||||
assert(
|
||||
results.some(
|
||||
(result) =>
|
||||
result.type === test.type &&
|
||||
result.url.split("#")[0] === test.url.split("#")[0] &&
|
||||
(!test.url.includes("#") || result.url === test.url),
|
||||
),
|
||||
`${test.query}: missing ${test.type} result for ${test.url}`,
|
||||
);
|
||||
|
||||
for (const result of results) {
|
||||
assert(result.url.startsWith("/docs/"), `Unexpected search destination: ${result.url}`);
|
||||
if (verifiedUrls.has(result.url)) continue;
|
||||
const url = new URL(result.url, "https://homarr.dev");
|
||||
const html = await readFile(path.join(outputDirectory, decodeURIComponent(url.pathname), "index.html"), "utf8");
|
||||
if (url.hash) {
|
||||
const id = decodeURIComponent(url.hash.slice(1));
|
||||
assert(
|
||||
load(html)("[id]")
|
||||
.toArray()
|
||||
.some((element) => element.attribs.id === id),
|
||||
`Missing search anchor: ${result.url}`,
|
||||
);
|
||||
}
|
||||
verifiedUrls.add(result.url);
|
||||
}
|
||||
console.log(`${test.query}: ${results.length} results; expected ${test.type} found`);
|
||||
}
|
||||
|
||||
assert.deepEqual(await client.search("homarrsearchnomatchqzxv987654321"), [], "Unknown words should return no results");
|
||||
const credentialResults = await staticClient({ from, search: { limit: 1000 } }).search("API Key");
|
||||
assert(
|
||||
credentialResults.some((result) => result.type === "text" && result.url.startsWith("/docs/integrations/sonarr#")),
|
||||
"Sonarr credential metadata must be searchable",
|
||||
);
|
||||
|
||||
for (const [slug, expected] of [
|
||||
["integrations/sonarr", ["API Key", "Management", "Calendar"]],
|
||||
["widgets/calendar", ["Radarr release type", "Default:", "Show unmonitored"]],
|
||||
] as const) {
|
||||
const markdown = await readFile(path.join(outputDirectory, "llms.mdx/docs", slug, "content.md"), "utf8");
|
||||
for (const text of expected) assert(markdown.includes(text), `${slug}: missing resolved metadata ${text}`);
|
||||
assert(!/<(?:Integration|Widget|Adding)[A-Z][^>]*[/>]/.test(markdown), `${slug}: unresolved metadata component`);
|
||||
}
|
||||
|
||||
const discovery = await readFile(path.join(outputDirectory, "llms.txt"), "utf8");
|
||||
const markdownLinks = [...discovery.matchAll(/\]\(([^)]*\/llms\.mdx\/[^)]+)\)/g)];
|
||||
assert(markdownLinks.length > 0, "LLM discovery must link to exported Markdown");
|
||||
await Promise.all(
|
||||
markdownLinks.map(async ([, href]) => {
|
||||
const url = new URL(href, "https://homarr.dev");
|
||||
await access(path.join(outputDirectory, decodeURIComponent(url.pathname)));
|
||||
}),
|
||||
);
|
||||
console.log(`LLM discovery verified: ${markdownLinks.length} Markdown exports.`);
|
||||
console.log(`Search verified: page titles, headings, body content, and ${verifiedUrls.size} destinations.`);
|
||||
@@ -0,0 +1,29 @@
|
||||
import type { Metadata } from "next";
|
||||
|
||||
import { CarbonCoverCssOnlySecond, CarbonCoverObserver, CarbonCoverStrict } from "@/components/carbon";
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: "Carbon Ads test",
|
||||
robots: { index: false, follow: false },
|
||||
};
|
||||
|
||||
export default function CarbonTestPage() {
|
||||
return (
|
||||
<main className="mx-auto w-full max-w-5xl px-5 py-16 sm:px-8">
|
||||
<h1 className="text-3xl font-semibold">Carbon Ads test</h1>
|
||||
<p className="mt-3 text-fd-muted-foreground">Compare the three cover-unit implementations.</p>
|
||||
<section className="my-8">
|
||||
<h2 className="text-xl font-medium">Strict re-initialization</h2>
|
||||
<CarbonCoverStrict />
|
||||
</section>
|
||||
<section className="my-8">
|
||||
<h2 className="text-xl font-medium">Observer trimming</h2>
|
||||
<CarbonCoverObserver />
|
||||
</section>
|
||||
<section className="my-8">
|
||||
<h2 className="text-xl font-medium">CSS-only</h2>
|
||||
<CarbonCoverCssOnlySecond />
|
||||
</section>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
@@ -25,7 +25,6 @@ export default function HomePage() {
|
||||
|
||||
<svg
|
||||
aria-hidden="true"
|
||||
focusable="false"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
preserveAspectRatio="none"
|
||||
viewBox="0 0 1440 320"
|
||||
|
||||
@@ -1,33 +1,9 @@
|
||||
import { createFromSource } from "fumadocs-core/search/server";
|
||||
import { structure } from "fumadocs-core/mdx-plugins/remark-structure";
|
||||
|
||||
import { getPageMarkdown, source } from "@/lib/source";
|
||||
import { source } from "@/lib/source";
|
||||
|
||||
export const revalidate = false;
|
||||
|
||||
export const { staticGET: GET } = createFromSource(source, {
|
||||
language: "english",
|
||||
async buildIndex(page) {
|
||||
const markdown = await getPageMarkdown(page);
|
||||
const original = page.data.structuredData;
|
||||
const headings = new Set(original.headings.map(({ id }) => id));
|
||||
const resolved = structure(markdown.replace(/^(#{1,6} .+) \[#[^\]]+\]$/gm, "$1"));
|
||||
const contents = new Map(original.contents.map((content) => [`${content.heading}\0${content.content}`, content]));
|
||||
for (const content of resolved.contents) {
|
||||
// Generated metadata can contain subheadings that are not HTML anchors.
|
||||
const heading = content.heading && headings.has(content.heading) ? content.heading : undefined;
|
||||
contents.set(`${heading}\0${content.content}`, { ...content, heading });
|
||||
}
|
||||
|
||||
return {
|
||||
id: page.url,
|
||||
title: page.data.title,
|
||||
description: page.data.description,
|
||||
url: page.url,
|
||||
structuredData: {
|
||||
headings: original.headings,
|
||||
contents: [...contents.values()],
|
||||
},
|
||||
};
|
||||
},
|
||||
});
|
||||
@@ -1,58 +0,0 @@
|
||||
import type { Metadata } from "next";
|
||||
import { notFound } from "next/navigation";
|
||||
import { DocsBody, DocsPage, DocsTitle } from "fumadocs-ui/layouts/docs/page";
|
||||
|
||||
import { LegacyCategoryRedirect } from "@/components/docs/legacy-category-redirect";
|
||||
import { source } from "@/lib/source";
|
||||
|
||||
// Docusaurus generated these URLs from category labels, not directory names.
|
||||
const categories: Record<string, string> = {
|
||||
advanced: "advanced",
|
||||
"developer-guides": "advanced/development/getting-started",
|
||||
community: "community",
|
||||
"getting-started": "getting-started",
|
||||
installation: "getting-started/installation",
|
||||
integrations: "integrations",
|
||||
management: "management",
|
||||
widgets: "widgets",
|
||||
};
|
||||
|
||||
interface PageProps {
|
||||
params: Promise<{ category: string }>;
|
||||
}
|
||||
|
||||
async function getCategoryPage({ params }: PageProps) {
|
||||
const { category } = await params;
|
||||
const destination = Object.hasOwn(categories, category) && categories[category];
|
||||
if (!destination) notFound();
|
||||
const page = source.getPage(destination.split("/"));
|
||||
if (!page) throw new Error(`Missing documentation destination for legacy category: ${category}`);
|
||||
return page;
|
||||
}
|
||||
|
||||
export default async function LegacyCategoryPage(props: PageProps) {
|
||||
const page = await getCategoryPage(props);
|
||||
|
||||
return (
|
||||
<DocsPage tableOfContent={{ enabled: false }}>
|
||||
<DocsTitle>{page.data.title}</DocsTitle>
|
||||
<DocsBody>
|
||||
<p>This documentation has moved to a new address.</p>
|
||||
<LegacyCategoryRedirect href={`${page.url}/`} />
|
||||
</DocsBody>
|
||||
</DocsPage>
|
||||
);
|
||||
}
|
||||
|
||||
export function generateStaticParams() {
|
||||
return Object.keys(categories).map((category) => ({ category }));
|
||||
}
|
||||
|
||||
export async function generateMetadata(props: PageProps): Promise<Metadata> {
|
||||
const page = await getCategoryPage(props);
|
||||
return {
|
||||
title: page.data.title,
|
||||
alternates: { canonical: page.url },
|
||||
robots: { index: false, follow: true },
|
||||
};
|
||||
}
|
||||
@@ -38,7 +38,7 @@
|
||||
--card-foreground: var(--foreground);
|
||||
--popover: var(--card);
|
||||
--popover-foreground: var(--foreground);
|
||||
--primary: #c62828;
|
||||
--primary: oklch(0.665 0.222 25.7);
|
||||
--primary-foreground: oklch(1 0 0);
|
||||
--secondary: oklch(0.96 0.012 25.7);
|
||||
--secondary-foreground: var(--foreground);
|
||||
@@ -51,7 +51,7 @@
|
||||
--input: var(--border);
|
||||
--ring: var(--primary);
|
||||
--radius: 0.375rem;
|
||||
--ifm-color-primary: #c62828;
|
||||
--ifm-color-primary: #fa5252;
|
||||
--ifm-navbar-background-color: var(--card);
|
||||
--ifm-font-family-base: Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
|
||||
--homarr-secondary-bg: oklch(0.965 0 0);
|
||||
@@ -76,7 +76,6 @@
|
||||
}
|
||||
|
||||
.dark {
|
||||
--ifm-color-primary: #fa5252;
|
||||
--background: oklch(0.185 0.012 25.7);
|
||||
--foreground: oklch(0.965 0.004 25.7);
|
||||
--card: oklch(0.225 0.014 25.7);
|
||||
@@ -230,13 +229,3 @@ body {
|
||||
animation-iteration-count: 1 !important;
|
||||
}
|
||||
}
|
||||
|
||||
.prose a:not([data-card="true"]),
|
||||
footer a {
|
||||
text-decoration-line: underline;
|
||||
text-underline-offset: 0.16em;
|
||||
}
|
||||
|
||||
.dark .prose a code {
|
||||
color: var(--color-fd-primary);
|
||||
}
|
||||
@@ -8,7 +8,6 @@ import { Provider } from "@/components/provider";
|
||||
import "./global.css";
|
||||
|
||||
const siteUrl = process.env.HOMARR_WEBSITE_URL ?? "https://homarr.dev";
|
||||
const kapaWebsiteId = process.env.KAPA_WEBSITE_ID ?? "1e4656f4-abeb-4343-bbae-1d8626f52378";
|
||||
|
||||
export const metadata: Metadata = {
|
||||
metadataBase: new URL(siteUrl),
|
||||
@@ -38,18 +37,6 @@ export default function RootLayout({ children }: { children: ReactNode }) {
|
||||
<Script src="/workshop-runtime-config.js" strategy="beforeInteractive" />
|
||||
<Provider>{children}</Provider>
|
||||
<Analytics />
|
||||
{kapaWebsiteId && (
|
||||
<Script
|
||||
id="kapa-widget"
|
||||
src="https://widget.kapa.ai/kapa-widget.bundle.js"
|
||||
strategy="afterInteractive"
|
||||
data-website-id={kapaWebsiteId}
|
||||
data-project-name="Homarr"
|
||||
data-project-color="#2B2B2B"
|
||||
data-project-logo={new URL("/img/favicon.png", siteUrl).href}
|
||||
data-modal-open-on-command-k="false"
|
||||
/>
|
||||
)}
|
||||
</body>
|
||||
</html>
|
||||
);
|
||||
|
||||
@@ -1,24 +1,12 @@
|
||||
import { llms } from "fumadocs-core/source";
|
||||
|
||||
import { getPageMarkdownUrl, getPostMarkdownUrl, getPostUrl, posts, source } from "@/lib/source";
|
||||
import { getPostMarkdownUrl, getPostUrl, posts, source } from "@/lib/source";
|
||||
|
||||
export const revalidate = false;
|
||||
|
||||
export function GET() {
|
||||
const blog = posts.entries.map(
|
||||
(post) => `- [${post.title}](${getPostUrl(post)}): [Markdown](${getPostMarkdownUrl(post)})`,
|
||||
);
|
||||
const index = llms(source, {
|
||||
renderDescription(node, { lang }) {
|
||||
if (node.type === "page") {
|
||||
const page = source.getNodePage(node, lang);
|
||||
if (page)
|
||||
return [page.data.description, `[Markdown](${getPageMarkdownUrl(page).url})`].filter(Boolean).join(" ");
|
||||
}
|
||||
return typeof node.description === "string" ? node.description : "";
|
||||
},
|
||||
}).index();
|
||||
const content = `${index}\n\n## Blog\n\n${blog.join("\n")}\n`;
|
||||
const blog = posts.entries.map((post) => `- [${post.title}](${getPostUrl(post)}): ${getPostMarkdownUrl(post)}`);
|
||||
const content = `${llms(source).index()}\n\n## Blog\n\n${blog.join("\n")}\n`;
|
||||
|
||||
return new Response(content, {
|
||||
headers: { "Content-Type": "text/plain; charset=utf-8" },
|
||||
|
||||
@@ -26,3 +26,147 @@ export function Carbon() {
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
// Variant 1: Strict re-initialization – remove prior scripts and injected nodes, then load cover
|
||||
export function CarbonCoverStrict() {
|
||||
const ref = React.useRef<HTMLDivElement>(null!);
|
||||
const location = usePathname();
|
||||
if (process.env.NODE_ENV === "development") {
|
||||
return null;
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
const serve = "CWBDTKQM";
|
||||
const placement = "homarrdev";
|
||||
|
||||
// Clear our local container
|
||||
ref.current.innerHTML = "";
|
||||
|
||||
// Remove any globally injected Carbon ad containers
|
||||
Array.from(document.querySelectorAll('[id^="carbonads"], .carbonads')).forEach(
|
||||
(el) => el.parentElement && el.parentElement.removeChild(el),
|
||||
);
|
||||
|
||||
// Remove existing script if present
|
||||
const existing = document.getElementById("_carbonads_js");
|
||||
if (existing && existing.parentElement) existing.parentElement.removeChild(existing);
|
||||
|
||||
// Reset possible global handle
|
||||
try {
|
||||
delete (window as any)._carbonads;
|
||||
} catch {}
|
||||
|
||||
// Inject fresh cover script into our container
|
||||
const s = document.createElement("script");
|
||||
s.id = "_carbonads_js";
|
||||
s.src = `https://cdn.carbonads.com/carbon.js?serve=${serve}&placement=${placement}&format=cover`;
|
||||
ref.current.appendChild(s);
|
||||
}, [location]);
|
||||
|
||||
return (
|
||||
<>
|
||||
<style>
|
||||
{`
|
||||
#carbon-responsive-wrap * { margin: initial; padding: initial; }
|
||||
`}
|
||||
</style>
|
||||
<div
|
||||
ref={ref}
|
||||
id="carbonads"
|
||||
data-selector="carbonads"
|
||||
className="bg-background flex flex-col m-4 space-y-2 carbonads argos-ignore"
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
// Variant 2: Observer – load cover then keep only the latest injected ad node
|
||||
export function CarbonCoverObserver() {
|
||||
const ref = React.useRef<HTMLDivElement>(null!);
|
||||
const location = usePathname();
|
||||
if (process.env.NODE_ENV === "development") {
|
||||
return null;
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
const serve = "CWBDTKQM";
|
||||
const placement = "homarrdev";
|
||||
|
||||
ref.current.innerHTML = "";
|
||||
|
||||
const s = document.createElement("script");
|
||||
s.id = "_carbonads_js";
|
||||
s.src = `https://cdn.carbonads.com/carbon.js?serve=${serve}&placement=${placement}&format=cover`;
|
||||
ref.current.appendChild(s);
|
||||
|
||||
// Observe body and ensure we keep only the most recent carbon container
|
||||
const trim = () => {
|
||||
const nodes = Array.from(document.querySelectorAll('[id^="carbonads"]')) as HTMLElement[];
|
||||
if (nodes.length > 1) {
|
||||
// Keep the last (most recently inserted); remove the rest
|
||||
nodes.slice(0, -1).forEach((n) => n.parentElement && n.parentElement.removeChild(n));
|
||||
}
|
||||
};
|
||||
|
||||
const observer = new MutationObserver(() => trim());
|
||||
observer.observe(document.body, { childList: true, subtree: true });
|
||||
|
||||
// Initial pass
|
||||
trim();
|
||||
|
||||
return () => observer.disconnect();
|
||||
}, [location]);
|
||||
|
||||
return (
|
||||
<>
|
||||
<style>
|
||||
{`
|
||||
#carbon-responsive-wrap * { margin: initial; padding: initial; }
|
||||
`}
|
||||
</style>
|
||||
<div
|
||||
ref={ref}
|
||||
id="carbonads"
|
||||
data-selector="carbonads"
|
||||
className="bg-background flex flex-col m-4 space-y-2 carbonads argos-ignore"
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
// Variant 3: CSS-only – hide the first instance so only the second (cover) remains visible
|
||||
export function CarbonCoverCssOnlySecond() {
|
||||
const ref = React.useRef<HTMLDivElement>(null!);
|
||||
const location = usePathname();
|
||||
if (process.env.NODE_ENV === "development") {
|
||||
return null;
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
const serve = "CWBDTKQM";
|
||||
const placement = "homarrdev";
|
||||
ref.current.innerHTML = "";
|
||||
const s = document.createElement("script");
|
||||
s.id = "_carbonads_js";
|
||||
s.src = `https://cdn.carbonads.com/carbon.js?serve=${serve}&placement=${placement}&format=cover`;
|
||||
ref.current.appendChild(s);
|
||||
}, [location]);
|
||||
|
||||
return (
|
||||
<>
|
||||
<style>
|
||||
{`
|
||||
/* Hide the first injected container; show subsequent one (cover) */
|
||||
#carbon-responsive-wrap { display: none; }
|
||||
#carbon-responsive-wrap * { margin: initial; padding: initial; }
|
||||
`}
|
||||
</style>
|
||||
<div
|
||||
ref={ref}
|
||||
id="carbonads"
|
||||
data-selector="carbonads"
|
||||
className="bg-background flex flex-col m-4 space-y-2 carbonads argos-ignore"
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -1,11 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect } from "react";
|
||||
|
||||
export function LegacyCategoryRedirect({ href }: { href: string }) {
|
||||
useEffect(() => {
|
||||
window.location.replace(`${href}${window.location.search}${window.location.hash}`);
|
||||
}, [href]);
|
||||
|
||||
return <a href={href}>Continue to the documentation</a>;
|
||||
}
|
||||
@@ -3,7 +3,7 @@ import { ReactNode } from "react";
|
||||
import Alert from "@/components/mdx/admonition";
|
||||
import Tabs, { TabItem } from "@/components/mdx/legacy-tabs";
|
||||
|
||||
export const secretKinds = {
|
||||
const secretKinds = {
|
||||
apiKey: {
|
||||
name: "API Key",
|
||||
description: "API Key from the service for authentication.",
|
||||
|
||||
@@ -1,76 +0,0 @@
|
||||
// oxlint-disable-next-line import/no-unassigned-import -- Prevent catalog data from entering a client bundle.
|
||||
import "server-only";
|
||||
import { asMarkdown } from "fumadocs-core/server";
|
||||
|
||||
import type { CustomJsxAuthoringCatalog, CustomJsxPropDescriptor } from "@homarr/custom-widgets/catalog";
|
||||
import catalogData from "@site/public/custom-widgets/component-catalog-v1.json";
|
||||
|
||||
const catalog = catalogData as CustomJsxAuthoringCatalog;
|
||||
|
||||
function Props({ items }: { items: CustomJsxPropDescriptor[] }) {
|
||||
asMarkdown();
|
||||
return (
|
||||
<ul>
|
||||
{items.map((prop) => (
|
||||
<li key={prop.name}>
|
||||
<code>{prop.name}</code>: <code>{catalog.types[prop.typeRef] ?? "unknown"}</code>.
|
||||
{prop.required && " Required."} {prop.description}
|
||||
{prop.literalValues && (
|
||||
<> Known values: {prop.literalValues.map((value) => JSON.stringify(value)).join(", ")}.</>
|
||||
)}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
);
|
||||
}
|
||||
|
||||
export function ComponentCatalogMarkdown() {
|
||||
return (
|
||||
<>
|
||||
<p>
|
||||
Custom JSX {catalog.customWidgetVersion}, Mantine {catalog.mantineVersion}. Properties are optional unless
|
||||
marked required. <a href="/custom-widgets/component-catalog-v1.json">Download the complete JSON catalog</a>.
|
||||
</p>
|
||||
<h2>Global properties</h2>
|
||||
<Props items={catalog.globalProps} />
|
||||
<h2>Blocked capabilities</h2>
|
||||
<ul>
|
||||
{catalog.blockedCapabilities.map((capability) => (
|
||||
<li key={`${capability.kind}-${capability.name}`}>
|
||||
<code>{capability.name}</code> ({capability.kind}): {capability.reason}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
{catalog.components.map((component) => (
|
||||
<section key={component.name}>
|
||||
<h2>{component.name}</h2>
|
||||
<p>
|
||||
{component.package}; {component.category}; safety: {component.safety}. {component.description}
|
||||
</p>
|
||||
{component.deniedReason && <p>Unavailable: {component.deniedReason}</p>}
|
||||
{component.bind && (
|
||||
<p>
|
||||
Bind a temporary {component.bind.type} input with <code>bind</code>. Initialize with{" "}
|
||||
<code>{component.bind.initialProp}</code>; reset with <code>{component.bind.resetProp}</code>.
|
||||
</p>
|
||||
)}
|
||||
{component.subcomponents.length > 0 && <p>Subcomponents: {component.subcomponents.join(", ")}.</p>}
|
||||
<Props items={component.props} />
|
||||
<ul>
|
||||
{component.blockedProps.map((prop) => (
|
||||
<li key={prop.name}>
|
||||
<code>{prop.name}</code> is blocked: {prop.reason}
|
||||
</li>
|
||||
))}
|
||||
{component.accessibilityRequirements.map((requirement) => (
|
||||
<li key={requirement}>{requirement}</li>
|
||||
))}
|
||||
</ul>
|
||||
<p>
|
||||
<a href={component.documentationUrl}>Upstream documentation for {component.name}</a>
|
||||
</p>
|
||||
</section>
|
||||
))}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -1,121 +0,0 @@
|
||||
import { asMarkdown } from "fumadocs-core/server";
|
||||
import type { ComponentProps, ReactNode } from "react";
|
||||
|
||||
import { AddingIntegration } from "@/components/integrations/adding";
|
||||
import type { IntegrationHeader } from "@/components/integrations/header";
|
||||
import { secretKinds, type IntegrationSecrets } from "@/components/integrations/secrets";
|
||||
import type { IntegrationCapabilites } from "@/components/integrations/widgets";
|
||||
import { AddingWidget } from "@/components/widgets/adding";
|
||||
import type { WidgetHeader } from "@/components/widgets/header";
|
||||
import type { WidgetIntegrations } from "@/components/widgets/integrations";
|
||||
import { WidgetInteractionGuide } from "@/components/widgets/interactions";
|
||||
import { GettingStartedOverview, InstallationPaths } from "@/components/pages/getting-started/getting-started-overview";
|
||||
import { getDocsHref } from "@/lib/docs-path";
|
||||
import type { WidgetConfiguration } from "@/types";
|
||||
import { ComponentCatalogMarkdown } from "./component-catalog-markdown";
|
||||
|
||||
// Fumadocs supplies the original MDX props, including typed metadata and JSX steps.
|
||||
function markdown<Props>(component: (props: Props) => ReactNode) {
|
||||
return (props: Props) => {
|
||||
asMarkdown();
|
||||
return component(props);
|
||||
};
|
||||
}
|
||||
|
||||
function configuration({ items }: WidgetConfiguration): ReactNode {
|
||||
return items.map((item, index) => (
|
||||
<div key={index}>
|
||||
<p>
|
||||
<strong>{item.name}</strong>: {item.description}
|
||||
</p>
|
||||
<p>
|
||||
Values:{" "}
|
||||
{typeof item.values === "string"
|
||||
? item.values
|
||||
: item.values.type === "boolean"
|
||||
? "yes / no"
|
||||
: item.values.type === "select"
|
||||
? item.values.options.join(", ")
|
||||
: item.values.type === "string"
|
||||
? "String"
|
||||
: `${item.values.name}${item.values.type === "array" ? "[]" : ""}`}
|
||||
. Default: {item.defaultValue}.
|
||||
</p>
|
||||
{typeof item.values !== "string" && "configuration" in item.values
|
||||
? configuration(item.values.configuration)
|
||||
: null}
|
||||
</div>
|
||||
));
|
||||
}
|
||||
|
||||
export const metadataMarkdownComponents = {
|
||||
CustomJsxComponentReference: markdown(ComponentCatalogMarkdown),
|
||||
GettingStartedOverview: markdown(GettingStartedOverview),
|
||||
InstallationPaths: markdown(InstallationPaths),
|
||||
AddingIntegration: markdown(AddingIntegration),
|
||||
AddingWidget: markdown(AddingWidget),
|
||||
WidgetInteractionGuide: markdown(WidgetInteractionGuide),
|
||||
IntegrationHeader: markdown(({ integration, categories }: ComponentProps<typeof IntegrationHeader>) => (
|
||||
<>
|
||||
<p>{integration.description}</p>
|
||||
<p>Categories: {categories.join(", ")}</p>
|
||||
</>
|
||||
)),
|
||||
WidgetHeader: markdown(({ widget, categories }: ComponentProps<typeof WidgetHeader>) => (
|
||||
<>
|
||||
<p>{widget.description}</p>
|
||||
<p>Categories: {categories.join(", ")}</p>
|
||||
</>
|
||||
)),
|
||||
IntegrationSecrets: markdown(({ secrets }: ComponentProps<typeof IntegrationSecrets>) =>
|
||||
secrets.map((secret, index) => (
|
||||
<div key={index}>
|
||||
{secret.tabLabel ? (
|
||||
<p>
|
||||
<strong>{secret.tabLabel}</strong>
|
||||
</p>
|
||||
) : null}
|
||||
{secret.header}
|
||||
{secret.credentials.length === 0 ? (
|
||||
<p>No credentials required.</p>
|
||||
) : (
|
||||
secret.credentials.map((credential) => (
|
||||
<p key={credential}>
|
||||
<strong>{secretKinds[credential].name}</strong>: {secretKinds[credential].description}
|
||||
</p>
|
||||
))
|
||||
)}
|
||||
{secret.body}
|
||||
{secret.steps.length > 0 ? (
|
||||
<>
|
||||
<p>Steps to retrieve the credentials:</p>
|
||||
<ol>
|
||||
{secret.steps.map((step, stepIndex) => (
|
||||
<li key={stepIndex}>{step}</li>
|
||||
))}
|
||||
</ol>
|
||||
</>
|
||||
) : null}
|
||||
{secret.footer}
|
||||
</div>
|
||||
)),
|
||||
),
|
||||
IntegrationCapabilites: markdown(({ items }: ComponentProps<typeof IntegrationCapabilites>) =>
|
||||
items.map((item, index) => {
|
||||
const capability = "widget" in item ? item.widget : item.capability;
|
||||
return (
|
||||
<p key={index}>
|
||||
<a href={getDocsHref(capability.path)}>{capability.name}</a>: {capability.description} {item.note}
|
||||
</p>
|
||||
);
|
||||
}),
|
||||
),
|
||||
WidgetIntegrations: markdown(({ items }: ComponentProps<typeof WidgetIntegrations>) =>
|
||||
items.map(({ integration, note }) => (
|
||||
<p key={integration.name}>
|
||||
<a href={getDocsHref(integration.path)}>{integration.name}</a>: {integration.description} {note}
|
||||
</p>
|
||||
)),
|
||||
),
|
||||
WidgetConfig: markdown(({ configuration: value }: { configuration: WidgetConfiguration }) => configuration(value)),
|
||||
};
|
||||
@@ -1,6 +1,4 @@
|
||||
import Link from "next/link";
|
||||
import { asMarkdown } from "fumadocs-core/server";
|
||||
import type { ElementType } from "react";
|
||||
import { IconArrowRight } from "@tabler/icons-react";
|
||||
|
||||
import addItem from "@site/docs/getting-started/img/manage-board-header-choose-item.png";
|
||||
@@ -35,21 +33,15 @@ const editingSteps = [
|
||||
];
|
||||
|
||||
export function GettingStartedOverview() {
|
||||
const isMarkdown = asMarkdown();
|
||||
let BoardLink: ElementType = Link;
|
||||
if (isMarkdown) BoardLink = "a";
|
||||
|
||||
return (
|
||||
<div className={styles.overview}>
|
||||
<section className={styles.conceptCard} aria-labelledby="getting-started-overview-title">
|
||||
<div className={styles.intro}>
|
||||
<h2 id="getting-started-overview-title">How Homarr works</h2>
|
||||
<p>
|
||||
Services expose data, integrations connect to them, widgets use the data, and boards arrange the widgets.
|
||||
</p>
|
||||
<p>Services expose data, integrations connect to them, widgets use the data, and boards arrange the widgets.</p>
|
||||
</div>
|
||||
|
||||
{!isMarkdown && <ConceptFlow />}
|
||||
<ConceptFlow />
|
||||
</section>
|
||||
|
||||
<section className={styles.editor} aria-labelledby="board-editor-title">
|
||||
@@ -58,18 +50,19 @@ export function GettingStartedOverview() {
|
||||
<h3 id="board-editor-title">Board edit mode</h3>
|
||||
<p>Add items, move them, and resize them on the grid. Each viewport can have its own layout.</p>
|
||||
</div>
|
||||
<BoardLink href="/docs/management/boards">
|
||||
Board docs {!isMarkdown && <IconArrowRight size={15} aria-hidden="true" />}
|
||||
</BoardLink>
|
||||
<Link href="/docs/management/boards">
|
||||
Board docs <IconArrowRight size={15} aria-hidden="true" />
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
<ol className={styles.editorSteps}>
|
||||
{editingSteps.map((step, index) => (
|
||||
<li className={styles.editorStep} key={step.title}>
|
||||
<div className={styles.stepCopy}>
|
||||
{!isMarkdown && <span>{index + 1}</span>}
|
||||
<span>{index + 1}</span>
|
||||
<div>
|
||||
<strong>{step.title}</strong> <small>{step.description}</small>
|
||||
<strong>{step.title}</strong>
|
||||
<small>{step.description}</small>
|
||||
</div>
|
||||
</div>
|
||||
<div className={styles.stepMedia}>
|
||||
@@ -108,19 +101,6 @@ const installationPaths = [
|
||||
];
|
||||
|
||||
export function InstallationPaths() {
|
||||
const isMarkdown = asMarkdown();
|
||||
if (isMarkdown) {
|
||||
return (
|
||||
<ul>
|
||||
{installationPaths.map((path) => (
|
||||
<li key={path.href}>
|
||||
<a href={path.href}>{path.title}</a> ({path.eyebrow}): {path.description}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={styles.installPaths}>
|
||||
{installationPaths.map((path) => {
|
||||
|
||||
@@ -33,13 +33,13 @@ export const DragAndDropShowcase = () => {
|
||||
<video
|
||||
className="h-full w-full object-cover"
|
||||
src={video}
|
||||
aria-label="Silent demonstration of moving and resizing Homarr dashboard widgets"
|
||||
controls
|
||||
aria-label="Homarr drag-and-drop editor demonstration"
|
||||
autoPlay
|
||||
loop
|
||||
muted
|
||||
playsInline
|
||||
preload="metadata"
|
||||
>
|
||||
<track kind="captions" src="/media/homepage-drag-and-drop.vtt" srcLang="en" label="English" />
|
||||
</video>
|
||||
/>
|
||||
</div>
|
||||
</SectionContainer>
|
||||
</section>
|
||||
|
||||
@@ -54,7 +54,7 @@ function FeatureComponent(props: Feature) {
|
||||
<props.icon aria-hidden="true" size={24} stroke={1.8} />
|
||||
</div>
|
||||
<div>
|
||||
<h2 className="m-0 text-lg font-semibold tracking-tight text-fd-foreground">{props.title}</h2>
|
||||
<h3 className="m-0 text-lg font-semibold tracking-tight text-fd-foreground">{props.title}</h3>
|
||||
<p className="mb-0 mt-2 text-sm leading-6 text-fd-muted-foreground">{props.content}</p>
|
||||
</div>
|
||||
</article>
|
||||
|
||||
@@ -40,12 +40,12 @@ export default function HomeHero() {
|
||||
<Link
|
||||
data-attr="Redirect to PikaPods"
|
||||
className={clsx(styles.heroButton, styles.partnerButton)}
|
||||
to="https://www.pikapods.com/pods?run=homarr"
|
||||
to="https://www.pikapods.com/pods?run=homarr-v1"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
<img src="/img/pictures/partner/pika-pods.svg" alt="PikaPods" height={20} />
|
||||
<span>Host with PikaPods</span>
|
||||
<span>Host from $2.3/month</span>
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -25,33 +25,10 @@ export default function Search(props: SharedProps) {
|
||||
<SearchDialogContent>
|
||||
<SearchDialogHeader>
|
||||
<SearchDialogIcon />
|
||||
<SearchDialogInput aria-label="Search documentation" />
|
||||
<SearchDialogInput />
|
||||
<SearchDialogClose />
|
||||
</SearchDialogHeader>
|
||||
{query.error ? (
|
||||
<div role="alert" className="space-y-3 px-4 py-6 text-sm">
|
||||
<p>Search could not load. Reload the page to try again, or browse the documentation.</p>
|
||||
<div className="flex gap-4">
|
||||
<button type="button" className="underline underline-offset-4" onClick={() => window.location.reload()}>
|
||||
Reload page
|
||||
</button>
|
||||
<a className="underline underline-offset-4" href="/docs/">
|
||||
Browse documentation
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<output className="sr-only">
|
||||
{query.isLoading
|
||||
? "Searching documentation"
|
||||
: Array.isArray(query.data)
|
||||
? `${query.data.length} search results`
|
||||
: ""}
|
||||
</output>
|
||||
<SearchDialogList items={query.data !== "empty" ? query.data : null} />
|
||||
</>
|
||||
)}
|
||||
<SearchDialogList items={query.data !== "empty" ? query.data : null} />
|
||||
</SearchDialogContent>
|
||||
</SearchDialog>
|
||||
);
|
||||
|
||||
@@ -0,0 +1,60 @@
|
||||
import { Accordion as AccordionPrimitive } from "@base-ui/react/accordion";
|
||||
|
||||
import { cn } from "@/lib/utils";
|
||||
import { IconChevronDown, IconChevronUp } from "@tabler/icons-react";
|
||||
|
||||
function Accordion({ className, ...props }: AccordionPrimitive.Root.Props) {
|
||||
return <AccordionPrimitive.Root data-slot="accordion" className={cn("flex w-full flex-col", className)} {...props} />;
|
||||
}
|
||||
|
||||
function AccordionItem({ className, ...props }: AccordionPrimitive.Item.Props) {
|
||||
return (
|
||||
<AccordionPrimitive.Item data-slot="accordion-item" className={cn("not-last:border-b", className)} {...props} />
|
||||
);
|
||||
}
|
||||
|
||||
function AccordionTrigger({ className, children, ...props }: AccordionPrimitive.Trigger.Props) {
|
||||
return (
|
||||
<AccordionPrimitive.Header className="flex">
|
||||
<AccordionPrimitive.Trigger
|
||||
data-slot="accordion-trigger"
|
||||
className={cn(
|
||||
"group/accordion-trigger relative flex flex-1 items-start justify-between rounded-lg border border-transparent py-2.5 text-left text-sm font-medium transition-all outline-none hover:underline focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50 focus-visible:after:border-ring aria-disabled:pointer-events-none aria-disabled:opacity-50 **:data-[slot=accordion-trigger-icon]:ml-auto **:data-[slot=accordion-trigger-icon]:size-4 **:data-[slot=accordion-trigger-icon]:text-muted-foreground",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
<IconChevronDown
|
||||
data-slot="accordion-trigger-icon"
|
||||
className="pointer-events-none shrink-0 group-aria-expanded/accordion-trigger:hidden"
|
||||
/>
|
||||
<IconChevronUp
|
||||
data-slot="accordion-trigger-icon"
|
||||
className="pointer-events-none hidden shrink-0 group-aria-expanded/accordion-trigger:inline"
|
||||
/>
|
||||
</AccordionPrimitive.Trigger>
|
||||
</AccordionPrimitive.Header>
|
||||
);
|
||||
}
|
||||
|
||||
function AccordionContent({ className, children, ...props }: AccordionPrimitive.Panel.Props) {
|
||||
return (
|
||||
<AccordionPrimitive.Panel
|
||||
data-slot="accordion-content"
|
||||
className="overflow-hidden text-sm data-open:animate-accordion-down data-closed:animate-accordion-up"
|
||||
{...props}
|
||||
>
|
||||
<div
|
||||
className={cn(
|
||||
"h-(--accordion-panel-height) pt-0 pb-2.5 data-ending-style:h-0 data-starting-style:h-0 [&_a]:underline [&_a]:underline-offset-3 [&_a]:hover:text-foreground [&_p:not(:last-child)]:mb-4",
|
||||
className,
|
||||
)}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
</AccordionPrimitive.Panel>
|
||||
);
|
||||
}
|
||||
|
||||
export { Accordion, AccordionItem, AccordionTrigger, AccordionContent };
|
||||
@@ -0,0 +1,18 @@
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
function AspectRatio({ ratio, className, ...props }: React.ComponentProps<"div"> & { ratio: number }) {
|
||||
return (
|
||||
<div
|
||||
data-slot="aspect-ratio"
|
||||
style={
|
||||
{
|
||||
"--ratio": ratio,
|
||||
} as React.CSSProperties
|
||||
}
|
||||
className={cn("relative aspect-(--ratio)", className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export { AspectRatio };
|
||||
@@ -0,0 +1,185 @@
|
||||
import * as React from "react";
|
||||
import { mergeProps } from "@base-ui/react/merge-props";
|
||||
import { useRender } from "@base-ui/react/use-render";
|
||||
import { cva, type VariantProps } from "class-variance-authority";
|
||||
|
||||
import { cn } from "@/lib/utils";
|
||||
import { Button } from "@/components/ui/button";
|
||||
|
||||
const attachmentVariants = cva(
|
||||
"group/attachment relative flex w-fit max-w-full min-w-0 shrink-0 flex-wrap rounded-xl border bg-card text-card-foreground transition-colors focus-within:ring-1 focus-within:ring-ring/50 has-[>a,>button]:hover:bg-muted/50 data-[state=error]:border-destructive/30 data-[state=idle]:border-dashed",
|
||||
{
|
||||
variants: {
|
||||
size: {
|
||||
default:
|
||||
"gap-2 text-sm has-data-[slot=attachment-content]:px-2.5 has-data-[slot=attachment-content]:py-2 has-data-[slot=attachment-media]:p-2",
|
||||
sm: "gap-2.5 text-xs has-data-[slot=attachment-content]:px-2 has-data-[slot=attachment-content]:py-1.5 has-data-[slot=attachment-media]:p-1.5",
|
||||
xs: "gap-1.5 rounded-lg text-xs has-data-[slot=attachment-content]:px-1.5 has-data-[slot=attachment-content]:py-1 has-data-[slot=attachment-media]:p-1",
|
||||
},
|
||||
orientation: {
|
||||
horizontal: "min-w-40 items-center",
|
||||
vertical: "w-24 flex-col has-data-[slot=attachment-content]:w-30",
|
||||
},
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
function Attachment({
|
||||
className,
|
||||
state = "done",
|
||||
size = "default",
|
||||
orientation = "horizontal",
|
||||
...props
|
||||
}: React.ComponentProps<"div"> &
|
||||
VariantProps<typeof attachmentVariants> & {
|
||||
state?: "idle" | "uploading" | "processing" | "error" | "done";
|
||||
}) {
|
||||
return (
|
||||
<div
|
||||
data-slot="attachment"
|
||||
data-state={state}
|
||||
data-size={size}
|
||||
data-orientation={orientation}
|
||||
className={cn(attachmentVariants({ size, orientation }), className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
const attachmentMediaVariants = cva(
|
||||
"relative flex aspect-square w-10 shrink-0 items-center justify-center overflow-hidden rounded-lg bg-muted text-foreground group-data-[orientation=vertical]/attachment:w-full group-data-[size=sm]/attachment:w-8 group-data-[size=xs]/attachment:w-7 group-data-[size=xs]/attachment:rounded-md group-data-[state=error]/attachment:bg-destructive/10 group-data-[state=error]/attachment:text-destructive group-data-[orientation=vertical]/attachment:*:data-[slot=spinner]:size-6! [&_svg]:pointer-events-none [&_svg:not([class*='size-'])]:size-4 group-data-[orientation=vertical]/attachment:[&_svg:not([class*='size-'])]:size-6 group-data-[size=xs]/attachment:[&_svg:not([class*='size-'])]:size-3.5",
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
icon: "",
|
||||
image:
|
||||
"opacity-60 group-data-[state=done]/attachment:opacity-100 group-data-[state=idle]/attachment:opacity-100 *:[img]:aspect-square *:[img]:w-full *:[img]:object-cover",
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
variant: "icon",
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
function AttachmentMedia({
|
||||
className,
|
||||
variant = "icon",
|
||||
...props
|
||||
}: React.ComponentProps<"div"> & VariantProps<typeof attachmentMediaVariants>) {
|
||||
return (
|
||||
<div
|
||||
data-slot="attachment-media"
|
||||
data-variant={variant}
|
||||
className={cn(attachmentMediaVariants({ variant }), className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function AttachmentContent({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="attachment-content"
|
||||
className={cn(
|
||||
"max-w-full min-w-0 flex-1 leading-tight group-data-[orientation=vertical]/attachment:px-1",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function AttachmentTitle({ className, ...props }: React.ComponentProps<"span">) {
|
||||
return (
|
||||
<span
|
||||
data-slot="attachment-title"
|
||||
className={cn(
|
||||
"block max-w-full min-w-0 truncate font-medium group-data-[state=processing]/attachment:shimmer group-data-[state=uploading]/attachment:shimmer",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function AttachmentDescription({ className, ...props }: React.ComponentProps<"span">) {
|
||||
return (
|
||||
<span
|
||||
data-slot="attachment-description"
|
||||
className={cn(
|
||||
"mt-0.5 block min-w-0 truncate text-xs text-muted-foreground group-data-[state=error]/attachment:text-destructive/80",
|
||||
"max-w-full",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function AttachmentActions({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="attachment-actions"
|
||||
className={cn(
|
||||
"relative z-20 flex shrink-0 items-center group-data-[orientation=vertical]/attachment:absolute group-data-[orientation=vertical]/attachment:top-3 group-data-[orientation=vertical]/attachment:right-3 group-data-[orientation=vertical]/attachment:gap-1",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function AttachmentAction({ className, variant, size = "icon-xs", ...props }: React.ComponentProps<typeof Button>) {
|
||||
return (
|
||||
<Button
|
||||
data-slot="attachment-action"
|
||||
variant={variant ?? "ghost"}
|
||||
size={size}
|
||||
className={cn(className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function AttachmentTrigger({ className, render, type, ...props }: useRender.ComponentProps<"button">) {
|
||||
return useRender({
|
||||
defaultTagName: "button",
|
||||
props: mergeProps<"button">(
|
||||
{
|
||||
type: render ? type : (type ?? "button"),
|
||||
className: cn("absolute inset-0 z-10 outline-none", className),
|
||||
},
|
||||
props,
|
||||
),
|
||||
render,
|
||||
state: {
|
||||
slot: "attachment-trigger",
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
function AttachmentGroup({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="attachment-group"
|
||||
className={cn(
|
||||
"flex min-w-0 scroll-fade-x snap-x snap-mandatory scroll-px-1 scrollbar-none gap-3 overflow-x-auto overscroll-x-contain py-1 *:data-[slot=attachment]:flex-none *:data-[slot=attachment]:snap-start",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export {
|
||||
Attachment,
|
||||
AttachmentGroup,
|
||||
AttachmentMedia,
|
||||
AttachmentContent,
|
||||
AttachmentTitle,
|
||||
AttachmentDescription,
|
||||
AttachmentActions,
|
||||
AttachmentAction,
|
||||
AttachmentTrigger,
|
||||
};
|
||||
@@ -0,0 +1,92 @@
|
||||
import * as React from "react";
|
||||
import { mergeProps } from "@base-ui/react/merge-props";
|
||||
import { useRender } from "@base-ui/react/use-render";
|
||||
|
||||
import { cn } from "@/lib/utils";
|
||||
import { IconChevronRight, IconDots } from "@tabler/icons-react";
|
||||
|
||||
function Breadcrumb({ className, ...props }: React.ComponentProps<"nav">) {
|
||||
return <nav aria-label="breadcrumb" data-slot="breadcrumb" className={cn(className)} {...props} />;
|
||||
}
|
||||
|
||||
function BreadcrumbList({ className, ...props }: React.ComponentProps<"ol">) {
|
||||
return (
|
||||
<ol
|
||||
data-slot="breadcrumb-list"
|
||||
className={cn("flex flex-wrap items-center gap-1.5 text-sm wrap-break-word text-muted-foreground", className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function BreadcrumbItem({ className, ...props }: React.ComponentProps<"li">) {
|
||||
return <li data-slot="breadcrumb-item" className={cn("inline-flex items-center gap-1", className)} {...props} />;
|
||||
}
|
||||
|
||||
function BreadcrumbLink({ className, render, ...props }: useRender.ComponentProps<"a">) {
|
||||
return useRender({
|
||||
defaultTagName: "a",
|
||||
props: mergeProps<"a">(
|
||||
{
|
||||
className: cn("transition-colors hover:text-foreground", className),
|
||||
},
|
||||
props,
|
||||
),
|
||||
render,
|
||||
state: {
|
||||
slot: "breadcrumb-link",
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
function BreadcrumbPage({ className, ...props }: React.ComponentProps<"span">) {
|
||||
return (
|
||||
<span
|
||||
data-slot="breadcrumb-page"
|
||||
role="link"
|
||||
aria-disabled="true"
|
||||
aria-current="page"
|
||||
className={cn("font-normal text-foreground", className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function BreadcrumbSeparator({ children, className, ...props }: React.ComponentProps<"li">) {
|
||||
return (
|
||||
<li
|
||||
data-slot="breadcrumb-separator"
|
||||
role="presentation"
|
||||
aria-hidden="true"
|
||||
className={cn("[&>svg]:size-3.5", className)}
|
||||
{...props}
|
||||
>
|
||||
{children ?? <IconChevronRight />}
|
||||
</li>
|
||||
);
|
||||
}
|
||||
|
||||
function BreadcrumbEllipsis({ className, ...props }: React.ComponentProps<"span">) {
|
||||
return (
|
||||
<span
|
||||
data-slot="breadcrumb-ellipsis"
|
||||
role="presentation"
|
||||
aria-hidden="true"
|
||||
className={cn("flex size-5 items-center justify-center [&>svg]:size-4", className)}
|
||||
{...props}
|
||||
>
|
||||
<IconDots />
|
||||
<span className="sr-only">More</span>
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
export {
|
||||
Breadcrumb,
|
||||
BreadcrumbList,
|
||||
BreadcrumbItem,
|
||||
BreadcrumbLink,
|
||||
BreadcrumbPage,
|
||||
BreadcrumbSeparator,
|
||||
BreadcrumbEllipsis,
|
||||
};
|
||||
@@ -0,0 +1,118 @@
|
||||
import * as React from "react";
|
||||
import { mergeProps } from "@base-ui/react/merge-props";
|
||||
import { useRender } from "@base-ui/react/use-render";
|
||||
import { cva, type VariantProps } from "class-variance-authority";
|
||||
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
function BubbleGroup({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return <div data-slot="bubble-group" className={cn("flex min-w-0 flex-col gap-2", className)} {...props} />;
|
||||
}
|
||||
|
||||
const bubbleVariants = cva(
|
||||
"group/bubble relative flex w-fit max-w-[80%] min-w-0 flex-col gap-1 group-data-[align=end]/message:self-end data-[align=end]:self-end data-[variant=ghost]:max-w-full",
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
default:
|
||||
"*:data-[slot=bubble-content]:bg-primary *:data-[slot=bubble-content]:text-primary-foreground [&>[data-slot=bubble-content]:is(button,a):hover]:bg-primary/80",
|
||||
secondary:
|
||||
"*:data-[slot=bubble-content]:bg-secondary *:data-[slot=bubble-content]:text-secondary-foreground [&>[data-slot=bubble-content]:is(button,a):hover]:bg-[color-mix(in_oklch,var(--secondary),var(--foreground)_5%)]",
|
||||
muted:
|
||||
"*:data-[slot=bubble-content]:bg-muted [&>[data-slot=bubble-content]:is(button,a):hover]:bg-[color-mix(in_oklch,var(--muted),var(--foreground)_5%)]",
|
||||
tinted:
|
||||
"*:data-[slot=bubble-content]:bg-[oklch(from_var(--primary)_0.93_calc(c*0.4)_h)] *:data-[slot=bubble-content]:text-foreground dark:*:data-[slot=bubble-content]:bg-[oklch(from_var(--primary)_0.3_calc(c*0.4)_h)] [&>[data-slot=bubble-content]:is(button,a):hover]:bg-[oklch(from_var(--primary)_0.88_calc(c*0.5)_h)] dark:[&>[data-slot=bubble-content]:is(button,a):hover]:bg-[oklch(from_var(--primary)_0.35_calc(c*0.5)_h)]",
|
||||
outline:
|
||||
"*:data-[slot=bubble-content]:border-border *:data-[slot=bubble-content]:bg-background [&>[data-slot=bubble-content]:is(button,a):hover]:bg-muted [&>[data-slot=bubble-content]:is(button,a):hover]:text-foreground dark:[&>[data-slot=bubble-content]:is(button,a):hover]:bg-input/30",
|
||||
ghost:
|
||||
"border-none *:data-[slot=bubble-content]:rounded-none *:data-[slot=bubble-content]:bg-transparent *:data-[slot=bubble-content]:p-0 [&>[data-slot=bubble-content]:is(button,a):hover]:bg-muted [&>[data-slot=bubble-content]:is(button,a):hover]:text-foreground dark:[&>[data-slot=bubble-content]:is(button,a):hover]:bg-muted/50",
|
||||
destructive:
|
||||
"*:data-[slot=bubble-content]:bg-destructive/10 *:data-[slot=bubble-content]:text-destructive dark:*:data-[slot=bubble-content]:bg-destructive/20 [&>[data-slot=bubble-content]:is(button,a):hover]:bg-destructive/20 dark:[&>[data-slot=bubble-content]:is(button,a):hover]:bg-destructive/30",
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
variant: "default",
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
function Bubble({
|
||||
variant = "default",
|
||||
align = "start",
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<"div"> &
|
||||
VariantProps<typeof bubbleVariants> & {
|
||||
align?: "start" | "end";
|
||||
}) {
|
||||
return (
|
||||
<div
|
||||
data-slot="bubble"
|
||||
data-variant={variant}
|
||||
data-align={align}
|
||||
className={cn(bubbleVariants({ variant }), className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function BubbleContent({ className, render, ...props }: useRender.ComponentProps<"div">) {
|
||||
return useRender({
|
||||
defaultTagName: "div",
|
||||
props: mergeProps<"div">(
|
||||
{
|
||||
className: cn(
|
||||
"w-fit max-w-full min-w-0 overflow-hidden rounded-xl border border-transparent px-3 py-2 text-sm leading-relaxed wrap-break-word group-data-[align=end]/bubble:self-end [button]:text-left [button,a]:transition-colors [button,a]:outline-none [button,a]:focus-visible:border-ring [button,a]:focus-visible:ring-3 [button,a]:focus-visible:ring-ring/50",
|
||||
className,
|
||||
),
|
||||
},
|
||||
props,
|
||||
),
|
||||
render,
|
||||
state: {
|
||||
slot: "bubble-content",
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
const bubbleReactionsVariants = cva(
|
||||
"absolute z-10 flex w-fit shrink-0 items-center justify-center gap-1 rounded-full bg-muted px-1.5 py-0.5 text-sm ring-3 ring-card has-[button]:p-0",
|
||||
{
|
||||
variants: {
|
||||
side: {
|
||||
top: "top-0 -translate-y-3/4",
|
||||
bottom: "bottom-0 translate-y-3/4",
|
||||
},
|
||||
align: {
|
||||
start: "left-3",
|
||||
end: "right-3",
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
side: "bottom",
|
||||
align: "end",
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
function BubbleReactions({
|
||||
side = "bottom",
|
||||
align = "end",
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<"div"> & {
|
||||
align?: "start" | "end";
|
||||
side?: "top" | "bottom";
|
||||
}) {
|
||||
return (
|
||||
<div
|
||||
data-slot="bubble-reactions"
|
||||
data-align={align}
|
||||
data-side={side}
|
||||
className={cn(bubbleReactionsVariants({ side, align }), className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export { BubbleGroup, Bubble, BubbleContent, BubbleReactions };
|
||||
@@ -0,0 +1,78 @@
|
||||
import { mergeProps } from "@base-ui/react/merge-props";
|
||||
import { useRender } from "@base-ui/react/use-render";
|
||||
import { cva, type VariantProps } from "class-variance-authority";
|
||||
|
||||
import { cn } from "@/lib/utils";
|
||||
import { Separator } from "@/components/ui/separator";
|
||||
|
||||
const buttonGroupVariants = cva(
|
||||
"flex w-fit items-stretch *:focus-visible:relative *:focus-visible:z-10 has-[>[data-slot=button-group]]:gap-2 has-[select[aria-hidden=true]:last-child]:[&>[data-slot=select-trigger]:last-of-type]:rounded-r-lg [&>[data-slot=select-trigger]:not([class*='w-'])]:w-fit [&>input]:flex-1",
|
||||
{
|
||||
variants: {
|
||||
orientation: {
|
||||
horizontal:
|
||||
"*:data-slot:rounded-r-none [&>[data-slot]:not(:has(~[data-slot]))]:rounded-r-lg! [&>[data-slot]~[data-slot]]:rounded-l-none [&>[data-slot]~[data-slot]]:border-l-0",
|
||||
vertical:
|
||||
"flex-col *:data-slot:rounded-b-none [&>[data-slot]:not(:has(~[data-slot]))]:rounded-b-lg! [&>[data-slot]~[data-slot]]:rounded-t-none [&>[data-slot]~[data-slot]]:border-t-0",
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
orientation: "horizontal",
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
function ButtonGroup({
|
||||
className,
|
||||
orientation,
|
||||
...props
|
||||
}: React.ComponentProps<"div"> & VariantProps<typeof buttonGroupVariants>) {
|
||||
return (
|
||||
<div
|
||||
role="group"
|
||||
data-slot="button-group"
|
||||
data-orientation={orientation}
|
||||
className={cn(buttonGroupVariants({ orientation }), className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function ButtonGroupText({ className, render, ...props }: useRender.ComponentProps<"div">) {
|
||||
return useRender({
|
||||
defaultTagName: "div",
|
||||
props: mergeProps<"div">(
|
||||
{
|
||||
className: cn(
|
||||
"flex items-center gap-2 rounded-lg border bg-muted px-2.5 text-sm font-medium [&_svg]:pointer-events-none [&_svg:not([class*='size-'])]:size-4",
|
||||
className,
|
||||
),
|
||||
},
|
||||
props,
|
||||
),
|
||||
render,
|
||||
state: {
|
||||
slot: "button-group-text",
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
function ButtonGroupSeparator({
|
||||
className,
|
||||
orientation = "vertical",
|
||||
...props
|
||||
}: React.ComponentProps<typeof Separator>) {
|
||||
return (
|
||||
<Separator
|
||||
data-slot="button-group-separator"
|
||||
orientation={orientation}
|
||||
className={cn(
|
||||
"relative self-stretch bg-input data-horizontal:mx-px data-horizontal:w-auto data-vertical:my-px data-vertical:h-auto",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export { ButtonGroup, ButtonGroupSeparator, ButtonGroupText, buttonGroupVariants };
|
||||
@@ -0,0 +1,169 @@
|
||||
import * as React from "react";
|
||||
import { DayPicker, getDefaultClassNames, type DayButton, type Locale } from "react-day-picker";
|
||||
|
||||
import { cn } from "@/lib/utils";
|
||||
import { Button, buttonVariants } from "@/components/ui/button";
|
||||
import { IconChevronLeft, IconChevronRight, IconChevronDown } from "@tabler/icons-react";
|
||||
|
||||
function Calendar({
|
||||
className,
|
||||
classNames,
|
||||
showOutsideDays = true,
|
||||
captionLayout = "label",
|
||||
buttonVariant = "ghost",
|
||||
locale,
|
||||
formatters,
|
||||
components,
|
||||
...props
|
||||
}: React.ComponentProps<typeof DayPicker> & {
|
||||
buttonVariant?: React.ComponentProps<typeof Button>["variant"];
|
||||
}) {
|
||||
const defaultClassNames = getDefaultClassNames();
|
||||
|
||||
return (
|
||||
<DayPicker
|
||||
showOutsideDays={showOutsideDays}
|
||||
className={cn(
|
||||
"group/calendar bg-background p-2 [--cell-radius:var(--radius-md)] [--cell-size:--spacing(7)] in-data-[slot=card-content]:bg-transparent in-data-[slot=popover-content]:bg-transparent",
|
||||
String.raw`rtl:**:[.rdp-button\_next>svg]:rotate-180`,
|
||||
String.raw`rtl:**:[.rdp-button\_previous>svg]:rotate-180`,
|
||||
className,
|
||||
)}
|
||||
captionLayout={captionLayout}
|
||||
locale={locale}
|
||||
formatters={{
|
||||
formatMonthDropdown: (date) => date.toLocaleString(locale?.code, { month: "short" }),
|
||||
...formatters,
|
||||
}}
|
||||
classNames={{
|
||||
root: cn("w-fit", defaultClassNames.root),
|
||||
months: cn("relative flex flex-col gap-4 md:flex-row", defaultClassNames.months),
|
||||
month: cn("flex w-full flex-col gap-4", defaultClassNames.month),
|
||||
nav: cn("absolute inset-x-0 top-0 flex w-full items-center justify-between gap-1", defaultClassNames.nav),
|
||||
button_previous: cn(
|
||||
buttonVariants({ variant: buttonVariant }),
|
||||
"size-(--cell-size) p-0 select-none aria-disabled:opacity-50",
|
||||
defaultClassNames.button_previous,
|
||||
),
|
||||
button_next: cn(
|
||||
buttonVariants({ variant: buttonVariant }),
|
||||
"size-(--cell-size) p-0 select-none aria-disabled:opacity-50",
|
||||
defaultClassNames.button_next,
|
||||
),
|
||||
month_caption: cn(
|
||||
"flex h-(--cell-size) w-full items-center justify-center px-(--cell-size)",
|
||||
defaultClassNames.month_caption,
|
||||
),
|
||||
dropdowns: cn(
|
||||
"flex h-(--cell-size) w-full items-center justify-center gap-1.5 text-sm font-medium",
|
||||
defaultClassNames.dropdowns,
|
||||
),
|
||||
dropdown_root: cn("relative rounded-(--cell-radius)", defaultClassNames.dropdown_root),
|
||||
dropdown: cn("absolute inset-0 bg-popover opacity-0", defaultClassNames.dropdown),
|
||||
caption_label: cn(
|
||||
"font-medium select-none",
|
||||
captionLayout === "label"
|
||||
? "text-sm"
|
||||
: "flex items-center gap-1 rounded-(--cell-radius) text-sm [&>svg]:size-3.5 [&>svg]:text-muted-foreground",
|
||||
defaultClassNames.caption_label,
|
||||
),
|
||||
month_grid: cn("w-full border-collapse", defaultClassNames.month_grid),
|
||||
weekdays: cn("flex", defaultClassNames.weekdays),
|
||||
weekday: cn(
|
||||
"flex-1 rounded-(--cell-radius) text-[0.8rem] font-normal text-muted-foreground select-none",
|
||||
defaultClassNames.weekday,
|
||||
),
|
||||
week: cn("mt-2 flex w-full", defaultClassNames.week),
|
||||
week_number_header: cn("w-(--cell-size) select-none", defaultClassNames.week_number_header),
|
||||
week_number: cn("text-[0.8rem] text-muted-foreground select-none", defaultClassNames.week_number),
|
||||
day: cn(
|
||||
"group/day relative aspect-square h-full w-full rounded-(--cell-radius) p-0 text-center select-none [&:last-child[data-selected=true]_button]:rounded-r-(--cell-radius)",
|
||||
props.showWeekNumber
|
||||
? "[&:nth-child(2)[data-selected=true]_button]:rounded-l-(--cell-radius)"
|
||||
: "[&:first-child[data-selected=true]_button]:rounded-l-(--cell-radius)",
|
||||
defaultClassNames.day,
|
||||
),
|
||||
range_start: cn(
|
||||
"relative isolate z-0 rounded-l-(--cell-radius) bg-muted after:absolute after:inset-y-0 after:right-0 after:w-4 after:bg-muted",
|
||||
defaultClassNames.range_start,
|
||||
),
|
||||
range_middle: cn("rounded-none", defaultClassNames.range_middle),
|
||||
range_end: cn(
|
||||
"relative isolate z-0 rounded-r-(--cell-radius) bg-muted after:absolute after:inset-y-0 after:left-0 after:w-4 after:bg-muted",
|
||||
defaultClassNames.range_end,
|
||||
),
|
||||
today: cn(
|
||||
"rounded-(--cell-radius) bg-muted text-foreground data-[selected=true]:rounded-none",
|
||||
defaultClassNames.today,
|
||||
),
|
||||
outside: cn("text-muted-foreground aria-selected:text-muted-foreground", defaultClassNames.outside),
|
||||
disabled: cn("text-muted-foreground opacity-50", defaultClassNames.disabled),
|
||||
hidden: cn("invisible", defaultClassNames.hidden),
|
||||
...classNames,
|
||||
}}
|
||||
components={{
|
||||
Root: ({ className, rootRef, ...props }) => {
|
||||
return <div data-slot="calendar" ref={rootRef} className={cn(className)} {...props} />;
|
||||
},
|
||||
Chevron: ({ className, orientation, ...props }) => {
|
||||
if (orientation === "left") {
|
||||
return <IconChevronLeft className={cn("size-4", className)} {...props} />;
|
||||
}
|
||||
|
||||
if (orientation === "right") {
|
||||
return <IconChevronRight className={cn("size-4", className)} {...props} />;
|
||||
}
|
||||
|
||||
return <IconChevronDown className={cn("size-4", className)} {...props} />;
|
||||
},
|
||||
DayButton: ({ ...props }) => <CalendarDayButton locale={locale} {...props} />,
|
||||
WeekNumber: ({ children, ...props }) => {
|
||||
return (
|
||||
<td {...props}>
|
||||
<div className="flex size-(--cell-size) items-center justify-center text-center">{children}</div>
|
||||
</td>
|
||||
);
|
||||
},
|
||||
...components,
|
||||
}}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function CalendarDayButton({
|
||||
className,
|
||||
day,
|
||||
modifiers,
|
||||
locale,
|
||||
...props
|
||||
}: React.ComponentProps<typeof DayButton> & { locale?: Partial<Locale> }) {
|
||||
const defaultClassNames = getDefaultClassNames();
|
||||
|
||||
const ref = React.useRef<HTMLButtonElement>(null);
|
||||
React.useEffect(() => {
|
||||
if (modifiers.focused) ref.current?.focus();
|
||||
}, [modifiers.focused]);
|
||||
|
||||
return (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
data-day={day.date.toLocaleDateString(locale?.code)}
|
||||
data-selected-single={
|
||||
modifiers.selected && !modifiers.range_start && !modifiers.range_end && !modifiers.range_middle
|
||||
}
|
||||
data-range-start={modifiers.range_start}
|
||||
data-range-end={modifiers.range_end}
|
||||
data-range-middle={modifiers.range_middle}
|
||||
className={cn(
|
||||
"relative isolate z-10 flex aspect-square size-auto w-full min-w-(--cell-size) flex-col gap-1 border-0 leading-none font-normal group-data-[focused=true]/day:relative group-data-[focused=true]/day:z-10 group-data-[focused=true]/day:border-ring group-data-[focused=true]/day:ring-[3px] group-data-[focused=true]/day:ring-ring/50 data-[range-end=true]:rounded-(--cell-radius) data-[range-end=true]:rounded-r-(--cell-radius) data-[range-end=true]:bg-primary data-[range-end=true]:text-primary-foreground data-[range-middle=true]:rounded-none data-[range-middle=true]:bg-muted data-[range-middle=true]:text-foreground data-[range-start=true]:rounded-(--cell-radius) data-[range-start=true]:rounded-l-(--cell-radius) data-[range-start=true]:bg-primary data-[range-start=true]:text-primary-foreground data-[selected-single=true]:bg-primary data-[selected-single=true]:text-primary-foreground dark:hover:text-foreground [&>span]:text-xs [&>span]:opacity-70",
|
||||
defaultClassNames.day,
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export { Calendar, CalendarDayButton };
|
||||
@@ -0,0 +1,212 @@
|
||||
"use client";
|
||||
|
||||
import * as React from "react";
|
||||
import useEmblaCarousel, { type UseEmblaCarouselType } from "embla-carousel-react";
|
||||
|
||||
import { cn } from "@/lib/utils";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { IconChevronLeft, IconChevronRight } from "@tabler/icons-react";
|
||||
|
||||
type CarouselApi = UseEmblaCarouselType[1];
|
||||
type UseCarouselParameters = Parameters<typeof useEmblaCarousel>;
|
||||
type CarouselOptions = UseCarouselParameters[0];
|
||||
type CarouselPlugin = UseCarouselParameters[1];
|
||||
|
||||
type CarouselProps = {
|
||||
opts?: CarouselOptions;
|
||||
plugins?: CarouselPlugin;
|
||||
orientation?: "horizontal" | "vertical";
|
||||
setApi?: (api: CarouselApi) => void;
|
||||
};
|
||||
|
||||
type CarouselContextProps = {
|
||||
carouselRef: ReturnType<typeof useEmblaCarousel>[0];
|
||||
api: ReturnType<typeof useEmblaCarousel>[1];
|
||||
scrollPrev: () => void;
|
||||
scrollNext: () => void;
|
||||
canScrollPrev: boolean;
|
||||
canScrollNext: boolean;
|
||||
} & CarouselProps;
|
||||
|
||||
const CarouselContext = React.createContext<CarouselContextProps | null>(null);
|
||||
|
||||
function useCarousel() {
|
||||
const context = React.useContext(CarouselContext);
|
||||
|
||||
if (!context) {
|
||||
throw new Error("useCarousel must be used within a <Carousel />");
|
||||
}
|
||||
|
||||
return context;
|
||||
}
|
||||
|
||||
function Carousel({
|
||||
orientation = "horizontal",
|
||||
opts,
|
||||
setApi,
|
||||
plugins,
|
||||
className,
|
||||
children,
|
||||
...props
|
||||
}: React.ComponentProps<"div"> & CarouselProps) {
|
||||
const [carouselRef, api] = useEmblaCarousel(
|
||||
{
|
||||
...opts,
|
||||
axis: orientation === "horizontal" ? "x" : "y",
|
||||
},
|
||||
plugins,
|
||||
);
|
||||
const [canScrollPrev, setCanScrollPrev] = React.useState(false);
|
||||
const [canScrollNext, setCanScrollNext] = React.useState(false);
|
||||
|
||||
const onSelect = React.useCallback((api: CarouselApi) => {
|
||||
if (!api) return;
|
||||
setCanScrollPrev(api.canScrollPrev());
|
||||
setCanScrollNext(api.canScrollNext());
|
||||
}, []);
|
||||
|
||||
const scrollPrev = React.useCallback(() => {
|
||||
api?.scrollPrev();
|
||||
}, [api]);
|
||||
|
||||
const scrollNext = React.useCallback(() => {
|
||||
api?.scrollNext();
|
||||
}, [api]);
|
||||
|
||||
const handleKeyDown = React.useCallback(
|
||||
(event: React.KeyboardEvent<HTMLDivElement>) => {
|
||||
if (event.key === "ArrowLeft") {
|
||||
event.preventDefault();
|
||||
scrollPrev();
|
||||
} else if (event.key === "ArrowRight") {
|
||||
event.preventDefault();
|
||||
scrollNext();
|
||||
}
|
||||
},
|
||||
[scrollPrev, scrollNext],
|
||||
);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (!api || !setApi) return;
|
||||
setApi(api);
|
||||
}, [api, setApi]);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (!api) return;
|
||||
onSelect(api);
|
||||
api.on("reInit", onSelect);
|
||||
api.on("select", onSelect);
|
||||
|
||||
return () => {
|
||||
api?.off("select", onSelect);
|
||||
};
|
||||
}, [api, onSelect]);
|
||||
|
||||
return (
|
||||
<CarouselContext.Provider
|
||||
value={{
|
||||
carouselRef,
|
||||
api: api,
|
||||
opts,
|
||||
orientation: orientation || (opts?.axis === "y" ? "vertical" : "horizontal"),
|
||||
scrollPrev,
|
||||
scrollNext,
|
||||
canScrollPrev,
|
||||
canScrollNext,
|
||||
}}
|
||||
>
|
||||
<div
|
||||
onKeyDownCapture={handleKeyDown}
|
||||
className={cn("relative", className)}
|
||||
role="region"
|
||||
aria-roledescription="carousel"
|
||||
data-slot="carousel"
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
</CarouselContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
function CarouselContent({ className, ...props }: React.ComponentProps<"div">) {
|
||||
const { carouselRef, orientation } = useCarousel();
|
||||
|
||||
return (
|
||||
<div ref={carouselRef} className="overflow-hidden" data-slot="carousel-content">
|
||||
<div className={cn("flex", orientation === "horizontal" ? "-ml-4" : "-mt-4 flex-col", className)} {...props} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function CarouselItem({ className, ...props }: React.ComponentProps<"div">) {
|
||||
const { orientation } = useCarousel();
|
||||
|
||||
return (
|
||||
<div
|
||||
role="group"
|
||||
aria-roledescription="slide"
|
||||
data-slot="carousel-item"
|
||||
className={cn("min-w-0 shrink-0 grow-0 basis-full", orientation === "horizontal" ? "pl-4" : "pt-4", className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function CarouselPrevious({
|
||||
className,
|
||||
variant = "outline",
|
||||
size = "icon-sm",
|
||||
...props
|
||||
}: React.ComponentProps<typeof Button>) {
|
||||
const { orientation, scrollPrev, canScrollPrev } = useCarousel();
|
||||
|
||||
return (
|
||||
<Button
|
||||
data-slot="carousel-previous"
|
||||
variant={variant}
|
||||
size={size}
|
||||
className={cn(
|
||||
"absolute touch-manipulation rounded-full",
|
||||
orientation === "horizontal" ? "inset-y-0 -left-12 my-auto" : "-top-12 left-1/2 -translate-x-1/2 rotate-90",
|
||||
className,
|
||||
)}
|
||||
disabled={!canScrollPrev}
|
||||
onClick={scrollPrev}
|
||||
{...props}
|
||||
>
|
||||
<IconChevronLeft />
|
||||
<span className="sr-only">Previous slide</span>
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
|
||||
function CarouselNext({
|
||||
className,
|
||||
variant = "outline",
|
||||
size = "icon-sm",
|
||||
...props
|
||||
}: React.ComponentProps<typeof Button>) {
|
||||
const { orientation, scrollNext, canScrollNext } = useCarousel();
|
||||
|
||||
return (
|
||||
<Button
|
||||
data-slot="carousel-next"
|
||||
variant={variant}
|
||||
size={size}
|
||||
className={cn(
|
||||
"absolute touch-manipulation rounded-full",
|
||||
orientation === "horizontal" ? "inset-y-0 -right-12 my-auto" : "-bottom-12 left-1/2 -translate-x-1/2 rotate-90",
|
||||
className,
|
||||
)}
|
||||
disabled={!canScrollNext}
|
||||
onClick={scrollNext}
|
||||
{...props}
|
||||
>
|
||||
<IconChevronRight />
|
||||
<span className="sr-only">Next slide</span>
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
|
||||
export { type CarouselApi, Carousel, CarouselContent, CarouselItem, CarouselPrevious, CarouselNext, useCarousel };
|
||||
@@ -0,0 +1,307 @@
|
||||
"use client";
|
||||
|
||||
import * as React from "react";
|
||||
import * as RechartsPrimitive from "recharts";
|
||||
import type { TooltipValueType } from "recharts";
|
||||
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
// Format: { THEME_NAME: CSS_SELECTOR }
|
||||
const THEMES = { light: "", dark: ".dark" } as const;
|
||||
|
||||
const INITIAL_DIMENSION = { width: 320, height: 200 } as const;
|
||||
type TooltipNameType = number | string;
|
||||
|
||||
export type ChartConfig = Record<
|
||||
string,
|
||||
{
|
||||
label?: React.ReactNode;
|
||||
icon?: React.ComponentType;
|
||||
} & ({ color?: string; theme?: never } | { color?: never; theme: Record<keyof typeof THEMES, string> })
|
||||
>;
|
||||
|
||||
type ChartContextProps = {
|
||||
config: ChartConfig;
|
||||
};
|
||||
|
||||
const ChartContext = React.createContext<ChartContextProps | null>(null);
|
||||
|
||||
function useChart() {
|
||||
const context = React.useContext(ChartContext);
|
||||
|
||||
if (!context) {
|
||||
throw new Error("useChart must be used within a <ChartContainer />");
|
||||
}
|
||||
|
||||
return context;
|
||||
}
|
||||
|
||||
function ChartContainer({
|
||||
id,
|
||||
className,
|
||||
children,
|
||||
config,
|
||||
initialDimension = INITIAL_DIMENSION,
|
||||
...props
|
||||
}: React.ComponentProps<"div"> & {
|
||||
config: ChartConfig;
|
||||
children: React.ComponentProps<typeof RechartsPrimitive.ResponsiveContainer>["children"];
|
||||
initialDimension?: {
|
||||
width: number;
|
||||
height: number;
|
||||
};
|
||||
}) {
|
||||
const uniqueId = React.useId();
|
||||
const chartId = `chart-${id ?? uniqueId.replace(/:/g, "")}`;
|
||||
|
||||
return (
|
||||
<ChartContext.Provider value={{ config }}>
|
||||
<div
|
||||
data-slot="chart"
|
||||
data-chart={chartId}
|
||||
className={cn(
|
||||
"flex aspect-video justify-center text-xs [&_.recharts-cartesian-axis-tick_text]:fill-muted-foreground [&_.recharts-cartesian-grid_line[stroke='#ccc']]:stroke-border/50 [&_.recharts-curve.recharts-tooltip-cursor]:stroke-border [&_.recharts-dot[stroke='#fff']]:stroke-transparent [&_.recharts-layer]:outline-hidden [&_.recharts-polar-grid_[stroke='#ccc']]:stroke-border [&_.recharts-radial-bar-background-sector]:fill-muted [&_.recharts-rectangle.recharts-tooltip-cursor]:fill-muted [&_.recharts-reference-line_[stroke='#ccc']]:stroke-border [&_.recharts-sector]:outline-hidden [&_.recharts-sector[stroke='#fff']]:stroke-transparent [&_.recharts-surface]:outline-hidden",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<ChartStyle id={chartId} config={config} />
|
||||
<RechartsPrimitive.ResponsiveContainer initialDimension={initialDimension}>
|
||||
{children}
|
||||
</RechartsPrimitive.ResponsiveContainer>
|
||||
</div>
|
||||
</ChartContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
const ChartStyle = ({ id, config }: { id: string; config: ChartConfig }) => {
|
||||
const colorConfig = Object.entries(config).filter(([, config]) => config.theme ?? config.color);
|
||||
|
||||
if (!colorConfig.length) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<style
|
||||
dangerouslySetInnerHTML={{
|
||||
__html: Object.entries(THEMES)
|
||||
.map(
|
||||
([theme, prefix]) => `
|
||||
${prefix} [data-chart=${id}] {
|
||||
${colorConfig
|
||||
.map(([key, itemConfig]) => {
|
||||
const color = itemConfig.theme?.[theme as keyof typeof itemConfig.theme] ?? itemConfig.color;
|
||||
return color ? ` --color-${key}: ${color};` : null;
|
||||
})
|
||||
.join("\n")}
|
||||
}
|
||||
`,
|
||||
)
|
||||
.join("\n"),
|
||||
}}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
const ChartTooltip = RechartsPrimitive.Tooltip;
|
||||
|
||||
function ChartTooltipContent({
|
||||
active,
|
||||
payload,
|
||||
className,
|
||||
indicator = "dot",
|
||||
hideLabel = false,
|
||||
hideIndicator = false,
|
||||
label,
|
||||
labelFormatter,
|
||||
labelClassName,
|
||||
formatter,
|
||||
color,
|
||||
nameKey,
|
||||
labelKey,
|
||||
}: React.ComponentProps<typeof RechartsPrimitive.Tooltip> &
|
||||
React.ComponentProps<"div"> & {
|
||||
hideLabel?: boolean;
|
||||
hideIndicator?: boolean;
|
||||
indicator?: "line" | "dot" | "dashed";
|
||||
nameKey?: string;
|
||||
labelKey?: string;
|
||||
} & Omit<RechartsPrimitive.DefaultTooltipContentProps<TooltipValueType, TooltipNameType>, "accessibilityLayer">) {
|
||||
const { config } = useChart();
|
||||
|
||||
const tooltipLabel = React.useMemo(() => {
|
||||
if (hideLabel || !payload?.length) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const [item] = payload;
|
||||
const key = `${labelKey ?? item?.dataKey ?? item?.name ?? "value"}`;
|
||||
const itemConfig = getPayloadConfigFromPayload(config, item, key);
|
||||
const value = !labelKey && typeof label === "string" ? (config[label]?.label ?? label) : itemConfig?.label;
|
||||
|
||||
if (labelFormatter) {
|
||||
return <div className={cn("font-medium", labelClassName)}>{labelFormatter(value, payload)}</div>;
|
||||
}
|
||||
|
||||
if (!value) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return <div className={cn("font-medium", labelClassName)}>{value}</div>;
|
||||
}, [label, labelFormatter, payload, hideLabel, labelClassName, config, labelKey]);
|
||||
|
||||
if (!active || !payload?.length) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const nestLabel = payload.length === 1 && indicator !== "dot";
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"grid min-w-32 items-start gap-1.5 rounded-lg border border-border/50 bg-background px-2.5 py-1.5 text-xs shadow-xl",
|
||||
className,
|
||||
)}
|
||||
>
|
||||
{!nestLabel ? tooltipLabel : null}
|
||||
<div className="grid gap-1.5">
|
||||
{payload
|
||||
.filter((item) => item.type !== "none")
|
||||
.map((item, index) => {
|
||||
const key = `${nameKey ?? item.name ?? item.dataKey ?? "value"}`;
|
||||
const itemConfig = getPayloadConfigFromPayload(config, item, key);
|
||||
const indicatorColor = color ?? item.payload?.fill ?? item.color;
|
||||
|
||||
return (
|
||||
<div
|
||||
key={index}
|
||||
className={cn(
|
||||
"flex w-full flex-wrap items-stretch gap-2 [&>svg]:h-2.5 [&>svg]:w-2.5 [&>svg]:text-muted-foreground",
|
||||
indicator === "dot" && "items-center",
|
||||
)}
|
||||
>
|
||||
{formatter && item?.value !== undefined && item.name ? (
|
||||
formatter(item.value, item.name, item, index, item.payload)
|
||||
) : (
|
||||
<>
|
||||
{itemConfig?.icon ? (
|
||||
<itemConfig.icon />
|
||||
) : (
|
||||
!hideIndicator && (
|
||||
<div
|
||||
className={cn("shrink-0 rounded-[2px] border-(--color-border) bg-(--color-bg)", {
|
||||
"h-2.5 w-2.5": indicator === "dot",
|
||||
"w-1": indicator === "line",
|
||||
"w-0 border-[1.5px] border-dashed bg-transparent": indicator === "dashed",
|
||||
"my-0.5": nestLabel && indicator === "dashed",
|
||||
})}
|
||||
style={
|
||||
{
|
||||
"--color-bg": indicatorColor,
|
||||
"--color-border": indicatorColor,
|
||||
} as React.CSSProperties
|
||||
}
|
||||
/>
|
||||
)
|
||||
)}
|
||||
<div
|
||||
className={cn(
|
||||
"flex flex-1 justify-between leading-none",
|
||||
nestLabel ? "items-end" : "items-center",
|
||||
)}
|
||||
>
|
||||
<div className="grid gap-1.5">
|
||||
{nestLabel ? tooltipLabel : null}
|
||||
<span className="text-muted-foreground">{itemConfig?.label ?? item.name}</span>
|
||||
</div>
|
||||
{item.value != null && (
|
||||
<span className="font-mono font-medium text-foreground tabular-nums">
|
||||
{typeof item.value === "number" ? item.value.toLocaleString() : String(item.value)}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const ChartLegend = RechartsPrimitive.Legend;
|
||||
|
||||
function ChartLegendContent({
|
||||
className,
|
||||
hideIcon = false,
|
||||
payload,
|
||||
verticalAlign = "bottom",
|
||||
nameKey,
|
||||
}: React.ComponentProps<"div"> & {
|
||||
hideIcon?: boolean;
|
||||
nameKey?: string;
|
||||
} & RechartsPrimitive.DefaultLegendContentProps) {
|
||||
const { config } = useChart();
|
||||
|
||||
if (!payload?.length) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={cn("flex items-center justify-center gap-4", verticalAlign === "top" ? "pb-3" : "pt-3", className)}>
|
||||
{payload
|
||||
.filter((item) => item.type !== "none")
|
||||
.map((item, index) => {
|
||||
const key = `${nameKey ?? item.dataKey ?? "value"}`;
|
||||
const itemConfig = getPayloadConfigFromPayload(config, item, key);
|
||||
|
||||
return (
|
||||
<div
|
||||
key={index}
|
||||
className={cn("flex items-center gap-1.5 [&>svg]:h-3 [&>svg]:w-3 [&>svg]:text-muted-foreground")}
|
||||
>
|
||||
{itemConfig?.icon && !hideIcon ? (
|
||||
<itemConfig.icon />
|
||||
) : (
|
||||
<div
|
||||
className="h-2 w-2 shrink-0 rounded-[2px]"
|
||||
style={{
|
||||
backgroundColor: item.color,
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
{itemConfig?.label}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function getPayloadConfigFromPayload(config: ChartConfig, payload: unknown, key: string) {
|
||||
if (typeof payload !== "object" || payload === null) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const payloadPayload =
|
||||
"payload" in payload && typeof payload.payload === "object" && payload.payload !== null
|
||||
? payload.payload
|
||||
: undefined;
|
||||
|
||||
let configLabelKey: string = key;
|
||||
|
||||
if (key in payload && typeof payload[key as keyof typeof payload] === "string") {
|
||||
configLabelKey = payload[key as keyof typeof payload] as string;
|
||||
} else if (
|
||||
payloadPayload &&
|
||||
key in payloadPayload &&
|
||||
typeof payloadPayload[key as keyof typeof payloadPayload] === "string"
|
||||
) {
|
||||
configLabelKey = payloadPayload[key as keyof typeof payloadPayload] as string;
|
||||
}
|
||||
|
||||
return configLabelKey in config ? config[configLabelKey] : config[key];
|
||||
}
|
||||
|
||||
export { ChartContainer, ChartTooltip, ChartTooltipContent, ChartLegend, ChartLegendContent, ChartStyle };
|
||||
@@ -0,0 +1,28 @@
|
||||
"use client";
|
||||
|
||||
import { Checkbox as CheckboxPrimitive } from "@base-ui/react/checkbox";
|
||||
|
||||
import { cn } from "@/lib/utils";
|
||||
import { IconCheck } from "@tabler/icons-react";
|
||||
|
||||
function Checkbox({ className, ...props }: CheckboxPrimitive.Root.Props) {
|
||||
return (
|
||||
<CheckboxPrimitive.Root
|
||||
data-slot="checkbox"
|
||||
className={cn(
|
||||
"peer relative flex size-4 shrink-0 items-center justify-center rounded-[4px] border border-input transition-colors outline-none group-has-disabled/field:opacity-50 after:absolute after:-inset-x-3 after:-inset-y-2 focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50 disabled:cursor-not-allowed disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-3 aria-invalid:ring-destructive/20 aria-invalid:aria-checked:border-primary dark:bg-input/30 dark:aria-invalid:border-destructive/50 dark:aria-invalid:ring-destructive/40 data-checked:border-primary data-checked:bg-primary data-checked:text-primary-foreground dark:data-checked:bg-primary",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<CheckboxPrimitive.Indicator
|
||||
data-slot="checkbox-indicator"
|
||||
className="grid place-content-center text-current transition-none [&>svg]:size-3.5"
|
||||
>
|
||||
<IconCheck />
|
||||
</CheckboxPrimitive.Indicator>
|
||||
</CheckboxPrimitive.Root>
|
||||
);
|
||||
}
|
||||
|
||||
export { Checkbox };
|
||||
@@ -0,0 +1,15 @@
|
||||
import { Collapsible as CollapsiblePrimitive } from "@base-ui/react/collapsible";
|
||||
|
||||
function Collapsible({ ...props }: CollapsiblePrimitive.Root.Props) {
|
||||
return <CollapsiblePrimitive.Root data-slot="collapsible" {...props} />;
|
||||
}
|
||||
|
||||
function CollapsibleTrigger({ ...props }: CollapsiblePrimitive.Trigger.Props) {
|
||||
return <CollapsiblePrimitive.Trigger data-slot="collapsible-trigger" {...props} />;
|
||||
}
|
||||
|
||||
function CollapsibleContent({ ...props }: CollapsiblePrimitive.Panel.Props) {
|
||||
return <CollapsiblePrimitive.Panel data-slot="collapsible-content" {...props} />;
|
||||
}
|
||||
|
||||
export { Collapsible, CollapsibleTrigger, CollapsibleContent };
|
||||
@@ -0,0 +1,261 @@
|
||||
"use client";
|
||||
|
||||
import * as React from "react";
|
||||
import { Combobox as ComboboxPrimitive } from "@base-ui/react";
|
||||
|
||||
import { cn } from "@/lib/utils";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { InputGroup, InputGroupAddon, InputGroupButton, InputGroupInput } from "@/components/ui/input-group";
|
||||
import { IconChevronDown, IconX, IconCheck } from "@tabler/icons-react";
|
||||
|
||||
const Combobox = ComboboxPrimitive.Root;
|
||||
|
||||
function ComboboxValue({ ...props }: ComboboxPrimitive.Value.Props) {
|
||||
return <ComboboxPrimitive.Value data-slot="combobox-value" {...props} />;
|
||||
}
|
||||
|
||||
function ComboboxTrigger({ className, children, ...props }: ComboboxPrimitive.Trigger.Props) {
|
||||
return (
|
||||
<ComboboxPrimitive.Trigger
|
||||
data-slot="combobox-trigger"
|
||||
className={cn("[&_svg:not([class*='size-'])]:size-4", className)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
<IconChevronDown className="pointer-events-none size-4 text-muted-foreground" />
|
||||
</ComboboxPrimitive.Trigger>
|
||||
);
|
||||
}
|
||||
|
||||
function ComboboxClear({ className, ...props }: ComboboxPrimitive.Clear.Props) {
|
||||
return (
|
||||
<ComboboxPrimitive.Clear
|
||||
data-slot="combobox-clear"
|
||||
render={<InputGroupButton variant="ghost" size="icon-xs" />}
|
||||
className={cn(className)}
|
||||
{...props}
|
||||
>
|
||||
<IconX className="pointer-events-none" />
|
||||
</ComboboxPrimitive.Clear>
|
||||
);
|
||||
}
|
||||
|
||||
function ComboboxInput({
|
||||
className,
|
||||
children,
|
||||
disabled = false,
|
||||
showTrigger = true,
|
||||
showClear = false,
|
||||
...props
|
||||
}: ComboboxPrimitive.Input.Props & {
|
||||
showTrigger?: boolean;
|
||||
showClear?: boolean;
|
||||
}) {
|
||||
return (
|
||||
<InputGroup className={cn("w-auto", className)}>
|
||||
<ComboboxPrimitive.Input render={<InputGroupInput disabled={disabled} />} {...props} />
|
||||
<InputGroupAddon align="inline-end">
|
||||
{showTrigger && (
|
||||
<InputGroupButton
|
||||
size="icon-xs"
|
||||
variant="ghost"
|
||||
render={<ComboboxTrigger />}
|
||||
data-slot="input-group-button"
|
||||
className="group-has-data-[slot=combobox-clear]/input-group:hidden data-pressed:bg-transparent"
|
||||
disabled={disabled}
|
||||
/>
|
||||
)}
|
||||
{showClear && <ComboboxClear disabled={disabled} />}
|
||||
</InputGroupAddon>
|
||||
{children}
|
||||
</InputGroup>
|
||||
);
|
||||
}
|
||||
|
||||
function ComboboxContent({
|
||||
className,
|
||||
side = "bottom",
|
||||
sideOffset = 6,
|
||||
align = "start",
|
||||
alignOffset = 0,
|
||||
anchor,
|
||||
...props
|
||||
}: ComboboxPrimitive.Popup.Props &
|
||||
Pick<ComboboxPrimitive.Positioner.Props, "side" | "align" | "sideOffset" | "alignOffset" | "anchor">) {
|
||||
return (
|
||||
<ComboboxPrimitive.Portal>
|
||||
<ComboboxPrimitive.Positioner
|
||||
side={side}
|
||||
sideOffset={sideOffset}
|
||||
align={align}
|
||||
alignOffset={alignOffset}
|
||||
anchor={anchor}
|
||||
className="isolate z-50"
|
||||
>
|
||||
<ComboboxPrimitive.Popup
|
||||
data-slot="combobox-content"
|
||||
data-chips={!!anchor}
|
||||
className={cn(
|
||||
"group/combobox-content relative max-h-(--available-height) w-(--anchor-width) max-w-(--available-width) min-w-[calc(var(--anchor-width)+--spacing(7))] origin-(--transform-origin) overflow-hidden rounded-lg bg-popover text-popover-foreground shadow-md ring-1 ring-foreground/10 duration-100 data-[chips=true]:min-w-(--anchor-width) data-[side=bottom]:slide-in-from-top-2 data-[side=inline-end]:slide-in-from-left-2 data-[side=inline-start]:slide-in-from-right-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 *:data-[slot=input-group]:m-1 *:data-[slot=input-group]:mb-0 *:data-[slot=input-group]:h-8 *:data-[slot=input-group]:border-input/30 *:data-[slot=input-group]:bg-input/30 *:data-[slot=input-group]:shadow-none data-open:animate-in data-open:fade-in-0 data-open:zoom-in-95 data-closed:animate-out data-closed:fade-out-0 data-closed:zoom-out-95",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
</ComboboxPrimitive.Positioner>
|
||||
</ComboboxPrimitive.Portal>
|
||||
);
|
||||
}
|
||||
|
||||
function ComboboxList({ className, ...props }: ComboboxPrimitive.List.Props) {
|
||||
return (
|
||||
<ComboboxPrimitive.List
|
||||
data-slot="combobox-list"
|
||||
className={cn(
|
||||
"no-scrollbar max-h-[min(calc(--spacing(72)---spacing(9)),calc(var(--available-height)---spacing(9)))] scroll-py-1 overflow-y-auto overscroll-contain p-1 data-empty:p-0",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function ComboboxItem({ className, children, ...props }: ComboboxPrimitive.Item.Props) {
|
||||
return (
|
||||
<ComboboxPrimitive.Item
|
||||
data-slot="combobox-item"
|
||||
className={cn(
|
||||
"relative flex w-full cursor-default items-center gap-2 rounded-md py-1 pr-8 pl-1.5 text-sm outline-hidden select-none data-highlighted:bg-accent data-highlighted:text-accent-foreground not-data-[variant=destructive]:data-highlighted:**:text-accent-foreground data-disabled:pointer-events-none data-disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
<ComboboxPrimitive.ItemIndicator
|
||||
render={<span className="pointer-events-none absolute right-2 flex size-4 items-center justify-center" />}
|
||||
>
|
||||
<IconCheck className="pointer-events-none" />
|
||||
</ComboboxPrimitive.ItemIndicator>
|
||||
</ComboboxPrimitive.Item>
|
||||
);
|
||||
}
|
||||
|
||||
function ComboboxGroup({ className, ...props }: ComboboxPrimitive.Group.Props) {
|
||||
return <ComboboxPrimitive.Group data-slot="combobox-group" className={cn(className)} {...props} />;
|
||||
}
|
||||
|
||||
function ComboboxLabel({ className, ...props }: ComboboxPrimitive.GroupLabel.Props) {
|
||||
return (
|
||||
<ComboboxPrimitive.GroupLabel
|
||||
data-slot="combobox-label"
|
||||
className={cn("px-2 py-1.5 text-xs text-muted-foreground", className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function ComboboxCollection({ ...props }: ComboboxPrimitive.Collection.Props) {
|
||||
return <ComboboxPrimitive.Collection data-slot="combobox-collection" {...props} />;
|
||||
}
|
||||
|
||||
function ComboboxEmpty({ className, ...props }: ComboboxPrimitive.Empty.Props) {
|
||||
return (
|
||||
<ComboboxPrimitive.Empty
|
||||
data-slot="combobox-empty"
|
||||
className={cn(
|
||||
"hidden w-full justify-center py-2 text-center text-sm text-muted-foreground group-data-empty/combobox-content:flex",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function ComboboxSeparator({ className, ...props }: ComboboxPrimitive.Separator.Props) {
|
||||
return (
|
||||
<ComboboxPrimitive.Separator
|
||||
data-slot="combobox-separator"
|
||||
className={cn("-mx-1 my-1 h-px bg-border", className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function ComboboxChips({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentPropsWithRef<typeof ComboboxPrimitive.Chips> & ComboboxPrimitive.Chips.Props) {
|
||||
return (
|
||||
<ComboboxPrimitive.Chips
|
||||
data-slot="combobox-chips"
|
||||
className={cn(
|
||||
"flex min-h-8 flex-wrap items-center gap-1 rounded-lg border border-input bg-transparent bg-clip-padding px-2.5 py-1 text-sm transition-colors focus-within:border-ring focus-within:ring-3 focus-within:ring-ring/50 has-aria-invalid:border-destructive has-aria-invalid:ring-3 has-aria-invalid:ring-destructive/20 has-data-[slot=combobox-chip]:px-1 dark:bg-input/30 dark:has-aria-invalid:border-destructive/50 dark:has-aria-invalid:ring-destructive/40",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function ComboboxChip({
|
||||
className,
|
||||
children,
|
||||
showRemove = true,
|
||||
...props
|
||||
}: ComboboxPrimitive.Chip.Props & {
|
||||
showRemove?: boolean;
|
||||
}) {
|
||||
return (
|
||||
<ComboboxPrimitive.Chip
|
||||
data-slot="combobox-chip"
|
||||
className={cn(
|
||||
"flex h-[calc(--spacing(5.25))] w-fit items-center justify-center gap-1 rounded-sm bg-muted px-1.5 text-xs font-medium whitespace-nowrap text-foreground has-disabled:pointer-events-none has-disabled:cursor-not-allowed has-disabled:opacity-50 has-data-[slot=combobox-chip-remove]:pr-0",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
{showRemove && (
|
||||
<ComboboxPrimitive.ChipRemove
|
||||
render={<Button variant="ghost" size="icon-xs" />}
|
||||
className="-ml-1 opacity-50 hover:opacity-100"
|
||||
data-slot="combobox-chip-remove"
|
||||
>
|
||||
<IconX className="pointer-events-none" />
|
||||
</ComboboxPrimitive.ChipRemove>
|
||||
)}
|
||||
</ComboboxPrimitive.Chip>
|
||||
);
|
||||
}
|
||||
|
||||
function ComboboxChipsInput({ className, ...props }: ComboboxPrimitive.Input.Props) {
|
||||
return (
|
||||
<ComboboxPrimitive.Input
|
||||
data-slot="combobox-chip-input"
|
||||
className={cn("min-w-16 flex-1 outline-none", className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function useComboboxAnchor() {
|
||||
return React.useRef<HTMLDivElement | null>(null);
|
||||
}
|
||||
|
||||
export {
|
||||
Combobox,
|
||||
ComboboxInput,
|
||||
ComboboxContent,
|
||||
ComboboxList,
|
||||
ComboboxItem,
|
||||
ComboboxGroup,
|
||||
ComboboxLabel,
|
||||
ComboboxCollection,
|
||||
ComboboxEmpty,
|
||||
ComboboxSeparator,
|
||||
ComboboxChips,
|
||||
ComboboxChip,
|
||||
ComboboxChipsInput,
|
||||
ComboboxTrigger,
|
||||
ComboboxValue,
|
||||
useComboboxAnchor,
|
||||
};
|
||||
@@ -0,0 +1,151 @@
|
||||
import * as React from "react";
|
||||
import { Command as CommandPrimitive } from "cmdk";
|
||||
|
||||
import { cn } from "@/lib/utils";
|
||||
import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle } from "@/components/ui/dialog";
|
||||
import { InputGroup, InputGroupAddon } from "@/components/ui/input-group";
|
||||
import { IconSearch, IconCheck } from "@tabler/icons-react";
|
||||
|
||||
function Command({ className, ...props }: React.ComponentProps<typeof CommandPrimitive>) {
|
||||
return (
|
||||
<CommandPrimitive
|
||||
data-slot="command"
|
||||
className={cn(
|
||||
"flex size-full flex-col overflow-hidden rounded-xl! bg-popover p-1 text-popover-foreground",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function CommandDialog({
|
||||
title = "Command Palette",
|
||||
description = "Search for a command to run...",
|
||||
children,
|
||||
className,
|
||||
showCloseButton = false,
|
||||
...props
|
||||
}: Omit<React.ComponentProps<typeof Dialog>, "children"> & {
|
||||
title?: string;
|
||||
description?: string;
|
||||
className?: string;
|
||||
showCloseButton?: boolean;
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<Dialog {...props}>
|
||||
<DialogHeader className="sr-only">
|
||||
<DialogTitle>{title}</DialogTitle>
|
||||
<DialogDescription>{description}</DialogDescription>
|
||||
</DialogHeader>
|
||||
<DialogContent
|
||||
className={cn("top-1/3 translate-y-0 overflow-hidden rounded-xl! p-0", className)}
|
||||
showCloseButton={showCloseButton}
|
||||
>
|
||||
{children}
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
|
||||
function CommandInput({ className, ...props }: React.ComponentProps<typeof CommandPrimitive.Input>) {
|
||||
return (
|
||||
<div data-slot="command-input-wrapper" className="p-1 pb-0">
|
||||
<InputGroup className="h-8! rounded-lg! border-input/30 bg-input/30 shadow-none! *:data-[slot=input-group-addon]:pl-2!">
|
||||
<CommandPrimitive.Input
|
||||
data-slot="command-input"
|
||||
className={cn("w-full text-sm outline-hidden disabled:cursor-not-allowed disabled:opacity-50", className)}
|
||||
{...props}
|
||||
/>
|
||||
<InputGroupAddon>
|
||||
<IconSearch className="size-4 shrink-0 opacity-50" />
|
||||
</InputGroupAddon>
|
||||
</InputGroup>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function CommandList({ className, ...props }: React.ComponentProps<typeof CommandPrimitive.List>) {
|
||||
return (
|
||||
<CommandPrimitive.List
|
||||
data-slot="command-list"
|
||||
className={cn("no-scrollbar max-h-72 scroll-py-1 overflow-x-hidden overflow-y-auto outline-none", className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function CommandEmpty({ className, ...props }: React.ComponentProps<typeof CommandPrimitive.Empty>) {
|
||||
return (
|
||||
<CommandPrimitive.Empty
|
||||
data-slot="command-empty"
|
||||
className={cn("py-6 text-center text-sm", className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function CommandGroup({ className, ...props }: React.ComponentProps<typeof CommandPrimitive.Group>) {
|
||||
return (
|
||||
<CommandPrimitive.Group
|
||||
data-slot="command-group"
|
||||
className={cn(
|
||||
"overflow-hidden p-1 text-foreground **:[[cmdk-group-heading]]:px-2 **:[[cmdk-group-heading]]:py-1.5 **:[[cmdk-group-heading]]:text-xs **:[[cmdk-group-heading]]:font-medium **:[[cmdk-group-heading]]:text-muted-foreground",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function CommandSeparator({ className, ...props }: React.ComponentProps<typeof CommandPrimitive.Separator>) {
|
||||
return (
|
||||
<CommandPrimitive.Separator
|
||||
data-slot="command-separator"
|
||||
className={cn("-mx-1 h-px bg-border", className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function CommandItem({ className, children, ...props }: React.ComponentProps<typeof CommandPrimitive.Item>) {
|
||||
return (
|
||||
<CommandPrimitive.Item
|
||||
data-slot="command-item"
|
||||
className={cn(
|
||||
"group/command-item relative flex cursor-default items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-hidden select-none in-data-[slot=dialog-content]:rounded-lg! data-[disabled=true]:pointer-events-none data-[disabled=true]:opacity-50 data-selected:bg-muted data-selected:text-foreground [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4 data-selected:*:[svg]:text-foreground",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
<IconCheck className="ml-auto opacity-0 group-has-data-[slot=command-shortcut]/command-item:hidden group-data-[checked=true]/command-item:opacity-100" />
|
||||
</CommandPrimitive.Item>
|
||||
);
|
||||
}
|
||||
|
||||
function CommandShortcut({ className, ...props }: React.ComponentProps<"span">) {
|
||||
return (
|
||||
<span
|
||||
data-slot="command-shortcut"
|
||||
className={cn(
|
||||
"ml-auto text-xs tracking-widest text-muted-foreground group-data-selected/command-item:text-foreground",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export {
|
||||
Command,
|
||||
CommandDialog,
|
||||
CommandInput,
|
||||
CommandList,
|
||||
CommandEmpty,
|
||||
CommandGroup,
|
||||
CommandItem,
|
||||
CommandShortcut,
|
||||
CommandSeparator,
|
||||
};
|
||||
@@ -0,0 +1,235 @@
|
||||
"use client";
|
||||
|
||||
import * as React from "react";
|
||||
import { ContextMenu as ContextMenuPrimitive } from "@base-ui/react/context-menu";
|
||||
|
||||
import { cn } from "@/lib/utils";
|
||||
import { IconChevronRight, IconCheck } from "@tabler/icons-react";
|
||||
|
||||
function ContextMenu({ ...props }: ContextMenuPrimitive.Root.Props) {
|
||||
return <ContextMenuPrimitive.Root data-slot="context-menu" {...props} />;
|
||||
}
|
||||
|
||||
function ContextMenuPortal({ ...props }: ContextMenuPrimitive.Portal.Props) {
|
||||
return <ContextMenuPrimitive.Portal data-slot="context-menu-portal" {...props} />;
|
||||
}
|
||||
|
||||
function ContextMenuTrigger({ className, ...props }: ContextMenuPrimitive.Trigger.Props) {
|
||||
return (
|
||||
<ContextMenuPrimitive.Trigger
|
||||
data-slot="context-menu-trigger"
|
||||
className={cn("select-none", className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function ContextMenuContent({
|
||||
className,
|
||||
align = "start",
|
||||
alignOffset = 4,
|
||||
side = "right",
|
||||
sideOffset = 0,
|
||||
...props
|
||||
}: ContextMenuPrimitive.Popup.Props &
|
||||
Pick<ContextMenuPrimitive.Positioner.Props, "align" | "alignOffset" | "side" | "sideOffset">) {
|
||||
return (
|
||||
<ContextMenuPrimitive.Portal>
|
||||
<ContextMenuPrimitive.Positioner
|
||||
className="isolate z-50 outline-none"
|
||||
align={align}
|
||||
alignOffset={alignOffset}
|
||||
side={side}
|
||||
sideOffset={sideOffset}
|
||||
>
|
||||
<ContextMenuPrimitive.Popup
|
||||
data-slot="context-menu-content"
|
||||
className={cn(
|
||||
"z-50 max-h-(--available-height) min-w-36 origin-(--transform-origin) overflow-x-hidden overflow-y-auto rounded-lg bg-popover p-1 text-popover-foreground shadow-md ring-1 ring-foreground/10 duration-100 outline-none data-[side=bottom]:slide-in-from-top-2 data-[side=inline-end]:slide-in-from-left-2 data-[side=inline-start]:slide-in-from-right-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 data-open:animate-in data-open:fade-in-0 data-open:zoom-in-95 data-closed:animate-out data-closed:fade-out-0 data-closed:zoom-out-95",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
</ContextMenuPrimitive.Positioner>
|
||||
</ContextMenuPrimitive.Portal>
|
||||
);
|
||||
}
|
||||
|
||||
function ContextMenuGroup({ ...props }: ContextMenuPrimitive.Group.Props) {
|
||||
return <ContextMenuPrimitive.Group data-slot="context-menu-group" {...props} />;
|
||||
}
|
||||
|
||||
function ContextMenuLabel({
|
||||
className,
|
||||
inset,
|
||||
...props
|
||||
}: ContextMenuPrimitive.GroupLabel.Props & {
|
||||
inset?: boolean;
|
||||
}) {
|
||||
return (
|
||||
<ContextMenuPrimitive.GroupLabel
|
||||
data-slot="context-menu-label"
|
||||
data-inset={inset}
|
||||
className={cn("px-1.5 py-1 text-xs font-medium text-muted-foreground data-inset:pl-7", className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function ContextMenuItem({
|
||||
className,
|
||||
inset,
|
||||
variant = "default",
|
||||
...props
|
||||
}: ContextMenuPrimitive.Item.Props & {
|
||||
inset?: boolean;
|
||||
variant?: "default" | "destructive";
|
||||
}) {
|
||||
return (
|
||||
<ContextMenuPrimitive.Item
|
||||
data-slot="context-menu-item"
|
||||
data-inset={inset}
|
||||
data-variant={variant}
|
||||
className={cn(
|
||||
"group/context-menu-item relative flex cursor-default items-center gap-1.5 rounded-md px-1.5 py-1 text-sm outline-hidden select-none focus:bg-accent focus:text-accent-foreground data-inset:pl-7 data-[variant=destructive]:text-destructive data-[variant=destructive]:focus:bg-destructive/10 data-[variant=destructive]:focus:text-destructive dark:data-[variant=destructive]:focus:bg-destructive/20 data-disabled:pointer-events-none data-disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4 focus:*:[svg]:text-accent-foreground data-[variant=destructive]:*:[svg]:text-destructive",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function ContextMenuSub({ ...props }: ContextMenuPrimitive.SubmenuRoot.Props) {
|
||||
return <ContextMenuPrimitive.SubmenuRoot data-slot="context-menu-sub" {...props} />;
|
||||
}
|
||||
|
||||
function ContextMenuSubTrigger({
|
||||
className,
|
||||
inset,
|
||||
children,
|
||||
...props
|
||||
}: ContextMenuPrimitive.SubmenuTrigger.Props & {
|
||||
inset?: boolean;
|
||||
}) {
|
||||
return (
|
||||
<ContextMenuPrimitive.SubmenuTrigger
|
||||
data-slot="context-menu-sub-trigger"
|
||||
data-inset={inset}
|
||||
className={cn(
|
||||
"flex cursor-default items-center gap-1.5 rounded-md px-1.5 py-1 text-sm outline-hidden select-none focus:bg-accent focus:text-accent-foreground data-inset:pl-7 data-open:bg-accent data-open:text-accent-foreground [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
<IconChevronRight className="ml-auto" />
|
||||
</ContextMenuPrimitive.SubmenuTrigger>
|
||||
);
|
||||
}
|
||||
|
||||
function ContextMenuSubContent({ ...props }: React.ComponentProps<typeof ContextMenuContent>) {
|
||||
return <ContextMenuContent data-slot="context-menu-sub-content" className="shadow-lg" side="right" {...props} />;
|
||||
}
|
||||
|
||||
function ContextMenuCheckboxItem({
|
||||
className,
|
||||
children,
|
||||
checked,
|
||||
inset,
|
||||
...props
|
||||
}: ContextMenuPrimitive.CheckboxItem.Props & {
|
||||
inset?: boolean;
|
||||
}) {
|
||||
return (
|
||||
<ContextMenuPrimitive.CheckboxItem
|
||||
data-slot="context-menu-checkbox-item"
|
||||
data-inset={inset}
|
||||
className={cn(
|
||||
"relative flex cursor-default items-center gap-1.5 rounded-md py-1 pr-8 pl-1.5 text-sm outline-hidden select-none focus:bg-accent focus:text-accent-foreground data-inset:pl-7 data-disabled:pointer-events-none data-disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
||||
className,
|
||||
)}
|
||||
checked={checked}
|
||||
{...props}
|
||||
>
|
||||
<span className="pointer-events-none absolute right-2">
|
||||
<ContextMenuPrimitive.CheckboxItemIndicator>
|
||||
<IconCheck />
|
||||
</ContextMenuPrimitive.CheckboxItemIndicator>
|
||||
</span>
|
||||
{children}
|
||||
</ContextMenuPrimitive.CheckboxItem>
|
||||
);
|
||||
}
|
||||
|
||||
function ContextMenuRadioGroup({ ...props }: ContextMenuPrimitive.RadioGroup.Props) {
|
||||
return <ContextMenuPrimitive.RadioGroup data-slot="context-menu-radio-group" {...props} />;
|
||||
}
|
||||
|
||||
function ContextMenuRadioItem({
|
||||
className,
|
||||
children,
|
||||
inset,
|
||||
...props
|
||||
}: ContextMenuPrimitive.RadioItem.Props & {
|
||||
inset?: boolean;
|
||||
}) {
|
||||
return (
|
||||
<ContextMenuPrimitive.RadioItem
|
||||
data-slot="context-menu-radio-item"
|
||||
data-inset={inset}
|
||||
className={cn(
|
||||
"relative flex cursor-default items-center gap-1.5 rounded-md py-1 pr-8 pl-1.5 text-sm outline-hidden select-none focus:bg-accent focus:text-accent-foreground data-inset:pl-7 data-disabled:pointer-events-none data-disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<span className="pointer-events-none absolute right-2">
|
||||
<ContextMenuPrimitive.RadioItemIndicator>
|
||||
<IconCheck />
|
||||
</ContextMenuPrimitive.RadioItemIndicator>
|
||||
</span>
|
||||
{children}
|
||||
</ContextMenuPrimitive.RadioItem>
|
||||
);
|
||||
}
|
||||
|
||||
function ContextMenuSeparator({ className, ...props }: ContextMenuPrimitive.Separator.Props) {
|
||||
return (
|
||||
<ContextMenuPrimitive.Separator
|
||||
data-slot="context-menu-separator"
|
||||
className={cn("-mx-1 my-1 h-px bg-border", className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function ContextMenuShortcut({ className, ...props }: React.ComponentProps<"span">) {
|
||||
return (
|
||||
<span
|
||||
data-slot="context-menu-shortcut"
|
||||
className={cn(
|
||||
"ml-auto text-xs tracking-widest text-muted-foreground group-focus/context-menu-item:text-accent-foreground",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export {
|
||||
ContextMenu,
|
||||
ContextMenuTrigger,
|
||||
ContextMenuContent,
|
||||
ContextMenuItem,
|
||||
ContextMenuCheckboxItem,
|
||||
ContextMenuRadioItem,
|
||||
ContextMenuLabel,
|
||||
ContextMenuSeparator,
|
||||
ContextMenuShortcut,
|
||||
ContextMenuGroup,
|
||||
ContextMenuPortal,
|
||||
ContextMenuSub,
|
||||
ContextMenuSubContent,
|
||||
ContextMenuSubTrigger,
|
||||
ContextMenuRadioGroup,
|
||||
};
|
||||
@@ -0,0 +1 @@
|
||||
export { DirectionProvider, useDirection } from "@base-ui/react/direction-provider";
|
||||
@@ -0,0 +1,209 @@
|
||||
"use client";
|
||||
|
||||
import * as React from "react";
|
||||
import { Drawer as DrawerPrimitive } from "@base-ui/react/drawer";
|
||||
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
type DrawerContextProps = {
|
||||
hasSnapPoints: boolean;
|
||||
modal: DrawerPrimitive.Root.Props["modal"];
|
||||
showSwipeHandle: boolean;
|
||||
swipeDirection: NonNullable<DrawerPrimitive.Root.Props["swipeDirection"]>;
|
||||
};
|
||||
|
||||
const DrawerContext = React.createContext<DrawerContextProps | null>(null);
|
||||
|
||||
function useDrawer() {
|
||||
const context = React.useContext(DrawerContext);
|
||||
|
||||
if (!context) {
|
||||
throw new Error("useDrawer must be used within a Drawer.");
|
||||
}
|
||||
|
||||
return context;
|
||||
}
|
||||
|
||||
function Drawer({
|
||||
modal = true,
|
||||
showSwipeHandle = false,
|
||||
snapPoints,
|
||||
swipeDirection = "down",
|
||||
...props
|
||||
}: DrawerPrimitive.Root.Props & {
|
||||
showSwipeHandle?: boolean;
|
||||
}) {
|
||||
const hasSnapPoints = snapPoints != null && snapPoints.length > 0;
|
||||
const contextValue = React.useMemo(
|
||||
() => ({ hasSnapPoints, modal, showSwipeHandle, swipeDirection }),
|
||||
[hasSnapPoints, modal, showSwipeHandle, swipeDirection],
|
||||
);
|
||||
|
||||
return (
|
||||
<DrawerContext.Provider value={contextValue}>
|
||||
<DrawerPrimitive.Root
|
||||
data-slot="drawer"
|
||||
modal={modal}
|
||||
snapPoints={snapPoints}
|
||||
swipeDirection={swipeDirection}
|
||||
{...props}
|
||||
/>
|
||||
</DrawerContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
function DrawerTrigger({ ...props }: DrawerPrimitive.Trigger.Props) {
|
||||
return <DrawerPrimitive.Trigger data-slot="drawer-trigger" {...props} />;
|
||||
}
|
||||
|
||||
function DrawerPortal({ ...props }: DrawerPrimitive.Portal.Props) {
|
||||
return <DrawerPrimitive.Portal data-slot="drawer-portal" {...props} />;
|
||||
}
|
||||
|
||||
function DrawerClose({ ...props }: DrawerPrimitive.Close.Props) {
|
||||
return <DrawerPrimitive.Close data-slot="drawer-close" {...props} />;
|
||||
}
|
||||
|
||||
function DrawerOverlay({ className, ...props }: DrawerPrimitive.Backdrop.Props) {
|
||||
return (
|
||||
<DrawerPrimitive.Backdrop
|
||||
data-slot="drawer-overlay"
|
||||
className={cn(
|
||||
"fixed inset-0 z-50 min-h-dvh bg-black/10 opacity-[max(var(--drawer-overlay-min-opacity,0),calc(1-var(--drawer-swipe-progress)))] transition-opacity duration-450 ease-[cubic-bezier(0.32,0.72,0,1)] select-none data-ending-style:pointer-events-none data-ending-style:opacity-0 data-ending-style:duration-[calc(var(--drawer-swipe-strength)*400ms)] data-snap-points:[--drawer-overlay-min-opacity:0.5] data-starting-style:opacity-0 data-swiping:duration-0 supports-backdrop-filter:backdrop-blur-xs supports-[-webkit-touch-callout:none]:absolute",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function DrawerSwipeHandle({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="drawer-swipe-handle"
|
||||
aria-hidden="true"
|
||||
className={cn(
|
||||
"relative z-10 flex shrink-0 cursor-grab transition-opacity duration-200 group-data-nested-drawer-open/drawer-popup:opacity-0 group-data-nested-drawer-swiping/drawer-popup:opacity-100 group-data-[swipe-axis=x]/drawer-popup:h-full group-data-[swipe-axis=x]/drawer-popup:w-3 group-data-[swipe-axis=x]/drawer-popup:items-center group-data-[swipe-axis=y]/drawer-popup:h-3 group-data-[swipe-axis=y]/drawer-popup:w-full group-data-[swipe-axis=y]/drawer-popup:justify-center group-data-[swipe-direction=down]/drawer-popup:items-end group-data-[swipe-direction=left]/drawer-popup:order-last group-data-[swipe-direction=left]/drawer-popup:justify-start group-data-[swipe-direction=right]/drawer-popup:justify-end group-data-[swipe-direction=up]/drawer-popup:order-last group-data-[swipe-direction=up]/drawer-popup:items-start after:block after:shrink-0 after:rounded-full after:bg-muted group-data-[swipe-axis=x]/drawer-popup:after:h-24 group-data-[swipe-axis=x]/drawer-popup:after:w-1 group-data-[swipe-axis=y]/drawer-popup:after:h-1 group-data-[swipe-axis=y]/drawer-popup:after:w-24 active:cursor-grabbing",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function DrawerContent({ className, children, ...props }: DrawerPrimitive.Popup.Props) {
|
||||
const { hasSnapPoints, modal, showSwipeHandle, swipeDirection } = useDrawer();
|
||||
const swipeAxis = swipeDirection === "down" || swipeDirection === "up" ? "y" : "x";
|
||||
|
||||
return (
|
||||
<DrawerPortal data-slot="drawer-portal">
|
||||
{modal === true && <DrawerOverlay data-snap-points={hasSnapPoints ? "" : undefined} />}
|
||||
<DrawerPrimitive.Viewport
|
||||
data-slot="drawer-viewport"
|
||||
data-modal={modal}
|
||||
className="pointer-events-none fixed inset-0 z-50 select-none data-[modal=true]:pointer-events-auto"
|
||||
>
|
||||
<DrawerPrimitive.Popup
|
||||
data-slot="drawer-popup"
|
||||
data-swipe-axis={swipeAxis}
|
||||
data-snap-points={hasSnapPoints ? "" : undefined}
|
||||
className={cn(
|
||||
// Base.
|
||||
"group/drawer-popup pointer-events-auto fixed z-50 m-(--drawer-inset,0px) flex h-(--drawer-content-height) max-h-(--drawer-content-max-height,none) min-h-0 w-(--drawer-content-width,auto) transform-[translate3d(var(--translate-x,0px),var(--translate-y,0px),0)_scale(var(--stack-scale))] flex-col bg-popover text-sm text-popover-foreground transition-[transform,height,opacity,filter] duration-450 ease-[cubic-bezier(0.22,1,0.36,1)] will-change-transform outline-none select-none [interpolate-size:allow-keywords] data-[swipe-direction=down]:rounded-t-xl data-[swipe-direction=down]:border-t data-[swipe-direction=left]:rounded-r-xl data-[swipe-direction=left]:border-r data-[swipe-direction=right]:rounded-l-xl data-[swipe-direction=right]:border-l data-[swipe-direction=up]:rounded-b-xl data-[swipe-direction=up]:border-b",
|
||||
// Nested.
|
||||
"data-nested-drawer-open:overflow-hidden data-nested-drawer-open:brightness-95",
|
||||
// Bleed.
|
||||
"after:pointer-events-none after:absolute after:bg-(--drawer-bleed-background,var(--color-popover)) data-[swipe-axis=x]:after:inset-y-0 data-[swipe-axis=x]:after:w-(--bleed) data-[swipe-axis=y]:after:inset-x-0 data-[swipe-axis=y]:after:h-(--bleed) data-[swipe-direction=down]:after:top-full data-[swipe-direction=left]:after:right-full data-[swipe-direction=right]:after:left-full data-[swipe-direction=up]:after:bottom-full",
|
||||
// Sizing.
|
||||
"[--drawer-content-height:var(--drawer-height,auto)] data-[swipe-axis=x]:[--drawer-content-width:75%] data-[swipe-axis=y]:[--drawer-content-max-height:calc(100dvh-6rem)] data-[swipe-axis=y]:data-snap-points:[--drawer-content-height:100dvh] data-[swipe-axis=x]:sm:[--drawer-content-width:24rem]",
|
||||
// Stack.
|
||||
"[--bleed:3rem] [--peek:1rem] [--stack-height:var(--drawer-frontmost-height,var(--drawer-height,0px))] [--stack-peek-offset:max(0px,calc((var(--nested-drawers)-var(--stack-progress))*var(--peek)))] [--stack-progress:clamp(0,var(--drawer-swipe-progress),1)] [--stack-scale-base:max(0,calc(1-(var(--nested-drawers)*var(--stack-step))))] [--stack-scale:clamp(0,calc(var(--stack-scale-base)+(var(--stack-step)*var(--stack-progress))),1)] [--stack-shrink:calc(1-var(--stack-scale))] [--stack-step:0.05]",
|
||||
// Transitions.
|
||||
"data-ending-style:transform-(--closed-transform) data-ending-style:opacity-[0.9999] data-ending-style:duration-[calc(var(--drawer-swipe-strength)*400ms)] data-nested-drawer-swiping:duration-0 data-ending-style:data-nested-drawer-swiping:duration-[calc(var(--drawer-swipe-strength)*400ms)] data-starting-style:transform-(--closed-transform) data-swiping:duration-0 data-ending-style:data-swiping:duration-[calc(var(--drawer-swipe-strength)*400ms)]",
|
||||
// Axis: y.
|
||||
"data-[swipe-axis=y]:inset-x-0 data-[swipe-axis=y]:data-nested-drawer-open:h-(--stack-height)",
|
||||
// Axis: x.
|
||||
"data-[swipe-axis=x]:inset-y-0 data-[swipe-axis=x]:flex-row",
|
||||
// Direction: down.
|
||||
"data-[swipe-direction=down]:bottom-0 data-[swipe-direction=down]:origin-bottom data-[swipe-direction=down]:[--closed-transform:translate3d(0,calc(100%+var(--drawer-inset,0px)+2px),0)] data-[swipe-direction=down]:[--translate-y:calc(var(--drawer-snap-point-offset,0px)+var(--drawer-swipe-movement-y)-var(--stack-peek-offset)-(var(--stack-shrink)*var(--stack-height)))]",
|
||||
// Direction: up.
|
||||
"data-[swipe-direction=up]:top-0 data-[swipe-direction=up]:origin-top data-[swipe-direction=up]:[--closed-transform:translate3d(0,calc(-100%-var(--drawer-inset,0px)-2px),0)] data-[swipe-direction=up]:[--translate-y:calc(var(--drawer-snap-point-offset,0px)+var(--drawer-swipe-movement-y)+var(--stack-peek-offset)+(var(--stack-shrink)*var(--stack-height)))]",
|
||||
// Direction: left.
|
||||
"data-[swipe-direction=left]:left-0 data-[swipe-direction=left]:origin-left data-[swipe-direction=left]:[--closed-transform:translate3d(calc(-100%-var(--drawer-inset,0px)-2px),0,0)] data-[swipe-direction=left]:[--translate-x:calc(var(--drawer-swipe-movement-x)+var(--stack-peek-offset)+(var(--stack-shrink)*100%))]",
|
||||
// Direction: right.
|
||||
"data-[swipe-direction=right]:right-0 data-[swipe-direction=right]:origin-right data-[swipe-direction=right]:[--closed-transform:translate3d(calc(100%+var(--drawer-inset,0px)+2px),0,0)] data-[swipe-direction=right]:[--translate-x:calc(var(--drawer-swipe-movement-x)-var(--stack-peek-offset)-(var(--stack-shrink)*100%))]",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{showSwipeHandle && <DrawerSwipeHandle />}
|
||||
<DrawerPrimitive.Content
|
||||
data-slot="drawer-content"
|
||||
className={cn(
|
||||
"flex min-h-0 flex-1 flex-col overflow-hidden overscroll-contain rounded-[inherit] transition-opacity duration-300 ease-[cubic-bezier(0.45,1.005,0,1.005)] select-text group-data-nested-drawer-open/drawer-popup:opacity-0 group-data-nested-drawer-swiping/drawer-popup:opacity-100 group-data-swiping/drawer-popup:select-none",
|
||||
)}
|
||||
>
|
||||
{children}
|
||||
</DrawerPrimitive.Content>
|
||||
</DrawerPrimitive.Popup>
|
||||
</DrawerPrimitive.Viewport>
|
||||
</DrawerPortal>
|
||||
);
|
||||
}
|
||||
|
||||
function DrawerHeader({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="drawer-header"
|
||||
className={cn(
|
||||
"flex shrink-0 flex-col gap-0.5 p-4 pb-0 group-data-[swipe-axis=y]/drawer-popup:text-center md:gap-0.5 md:text-left",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function DrawerFooter({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="drawer-footer"
|
||||
className={cn("mt-auto flex shrink-0 flex-col gap-2 p-4 pt-0", className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function DrawerTitle({ className, ...props }: DrawerPrimitive.Title.Props) {
|
||||
return (
|
||||
<DrawerPrimitive.Title
|
||||
data-slot="drawer-title"
|
||||
className={cn("font-heading text-base font-medium text-foreground", className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function DrawerDescription({ className, ...props }: DrawerPrimitive.Description.Props) {
|
||||
return (
|
||||
<DrawerPrimitive.Description
|
||||
data-slot="drawer-description"
|
||||
className={cn("text-sm text-balance text-muted-foreground", className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export {
|
||||
Drawer,
|
||||
DrawerPortal,
|
||||
DrawerOverlay,
|
||||
DrawerSwipeHandle,
|
||||
DrawerTrigger,
|
||||
DrawerClose,
|
||||
DrawerContent,
|
||||
DrawerHeader,
|
||||
DrawerFooter,
|
||||
DrawerTitle,
|
||||
DrawerDescription,
|
||||
};
|
||||
@@ -0,0 +1,46 @@
|
||||
"use client";
|
||||
|
||||
import { PreviewCard as PreviewCardPrimitive } from "@base-ui/react/preview-card";
|
||||
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
function HoverCard({ ...props }: PreviewCardPrimitive.Root.Props) {
|
||||
return <PreviewCardPrimitive.Root data-slot="hover-card" {...props} />;
|
||||
}
|
||||
|
||||
function HoverCardTrigger({ ...props }: PreviewCardPrimitive.Trigger.Props) {
|
||||
return <PreviewCardPrimitive.Trigger data-slot="hover-card-trigger" {...props} />;
|
||||
}
|
||||
|
||||
function HoverCardContent({
|
||||
className,
|
||||
side = "bottom",
|
||||
sideOffset = 4,
|
||||
align = "center",
|
||||
alignOffset = 4,
|
||||
...props
|
||||
}: PreviewCardPrimitive.Popup.Props &
|
||||
Pick<PreviewCardPrimitive.Positioner.Props, "align" | "alignOffset" | "side" | "sideOffset">) {
|
||||
return (
|
||||
<PreviewCardPrimitive.Portal data-slot="hover-card-portal">
|
||||
<PreviewCardPrimitive.Positioner
|
||||
align={align}
|
||||
alignOffset={alignOffset}
|
||||
side={side}
|
||||
sideOffset={sideOffset}
|
||||
className="isolate z-50"
|
||||
>
|
||||
<PreviewCardPrimitive.Popup
|
||||
data-slot="hover-card-content"
|
||||
className={cn(
|
||||
"z-50 w-64 origin-(--transform-origin) rounded-lg bg-popover p-2.5 text-sm text-popover-foreground shadow-md ring-1 ring-foreground/10 outline-hidden duration-100 data-[side=bottom]:slide-in-from-top-2 data-[side=inline-end]:slide-in-from-left-2 data-[side=inline-start]:slide-in-from-right-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 data-open:animate-in data-open:fade-in-0 data-open:zoom-in-95 data-closed:animate-out data-closed:fade-out-0 data-closed:zoom-out-95",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
</PreviewCardPrimitive.Positioner>
|
||||
</PreviewCardPrimitive.Portal>
|
||||
);
|
||||
}
|
||||
|
||||
export { HoverCard, HoverCardTrigger, HoverCardContent };
|
||||
@@ -0,0 +1,81 @@
|
||||
import * as React from "react";
|
||||
import { OTPInput, OTPInputContext } from "input-otp";
|
||||
|
||||
import { cn } from "@/lib/utils";
|
||||
import { IconMinus } from "@tabler/icons-react";
|
||||
|
||||
function InputOTP({
|
||||
className,
|
||||
containerClassName,
|
||||
...props
|
||||
}: React.ComponentProps<typeof OTPInput> & {
|
||||
containerClassName?: string;
|
||||
}) {
|
||||
return (
|
||||
<OTPInput
|
||||
data-slot="input-otp"
|
||||
containerClassName={cn("cn-input-otp flex items-center has-disabled:opacity-50", containerClassName)}
|
||||
spellCheck={false}
|
||||
className={cn("disabled:cursor-not-allowed", className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function InputOTPGroup({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="input-otp-group"
|
||||
className={cn(
|
||||
"flex items-center rounded-lg has-aria-invalid:border-destructive has-aria-invalid:ring-3 has-aria-invalid:ring-destructive/20 dark:has-aria-invalid:ring-destructive/40",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function InputOTPSlot({
|
||||
index,
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<"div"> & {
|
||||
index: number;
|
||||
}) {
|
||||
const inputOTPContext = React.useContext(OTPInputContext);
|
||||
const { char, hasFakeCaret, isActive } = inputOTPContext?.slots[index] ?? {};
|
||||
|
||||
return (
|
||||
<div
|
||||
data-slot="input-otp-slot"
|
||||
data-active={isActive}
|
||||
className={cn(
|
||||
"relative flex size-8 items-center justify-center border-y border-r border-input text-sm transition-all outline-none first:rounded-l-lg first:border-l last:rounded-r-lg aria-invalid:border-destructive data-[active=true]:z-10 data-[active=true]:border-ring data-[active=true]:ring-3 data-[active=true]:ring-ring/50 data-[active=true]:aria-invalid:border-destructive data-[active=true]:aria-invalid:ring-destructive/20 dark:bg-input/30 dark:data-[active=true]:aria-invalid:ring-destructive/40",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{char}
|
||||
{hasFakeCaret && (
|
||||
<div className="pointer-events-none absolute inset-0 flex items-center justify-center">
|
||||
<div className="h-4 w-px animate-caret-blink bg-foreground duration-1000" />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function InputOTPSeparator({ ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="input-otp-separator"
|
||||
className="flex items-center [&_svg:not([class*='size-'])]:size-4"
|
||||
role="separator"
|
||||
{...props}
|
||||
>
|
||||
<IconMinus />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export { InputOTP, InputOTPGroup, InputOTPSlot, InputOTPSeparator };
|
||||
@@ -0,0 +1,179 @@
|
||||
import * as React from "react";
|
||||
import { mergeProps } from "@base-ui/react/merge-props";
|
||||
import { useRender } from "@base-ui/react/use-render";
|
||||
import { cva, type VariantProps } from "class-variance-authority";
|
||||
|
||||
import { cn } from "@/lib/utils";
|
||||
import { Separator } from "@/components/ui/separator";
|
||||
|
||||
function ItemGroup({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
role="list"
|
||||
data-slot="item-group"
|
||||
className={cn(
|
||||
"group/item-group flex w-full flex-col gap-4 has-data-[size=sm]:gap-2.5 has-data-[size=xs]:gap-2",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function ItemSeparator({ className, ...props }: React.ComponentProps<typeof Separator>) {
|
||||
return <Separator data-slot="item-separator" orientation="horizontal" className={cn("my-2", className)} {...props} />;
|
||||
}
|
||||
|
||||
const itemVariants = cva(
|
||||
"group/item flex w-full flex-wrap items-center rounded-lg border text-sm transition-colors duration-100 outline-none focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 [a]:transition-colors [a]:hover:bg-muted",
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
default: "border-transparent",
|
||||
outline: "border-border",
|
||||
muted: "border-transparent bg-muted/50",
|
||||
},
|
||||
size: {
|
||||
default: "gap-2.5 px-3 py-2.5",
|
||||
sm: "gap-2.5 px-3 py-2.5",
|
||||
xs: "gap-2 px-2.5 py-2 in-data-[slot=dropdown-menu-content]:p-0",
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
variant: "default",
|
||||
size: "default",
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
function Item({
|
||||
className,
|
||||
variant = "default",
|
||||
size = "default",
|
||||
render,
|
||||
...props
|
||||
}: useRender.ComponentProps<"div"> & VariantProps<typeof itemVariants>) {
|
||||
return useRender({
|
||||
defaultTagName: "div",
|
||||
props: mergeProps<"div">(
|
||||
{
|
||||
className: cn(itemVariants({ variant, size, className })),
|
||||
},
|
||||
props,
|
||||
),
|
||||
render,
|
||||
state: {
|
||||
slot: "item",
|
||||
variant,
|
||||
size,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
const itemMediaVariants = cva(
|
||||
"flex shrink-0 items-center justify-center gap-2 group-has-data-[slot=item-description]/item:translate-y-0.5 group-has-data-[slot=item-description]/item:self-start [&_svg]:pointer-events-none",
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
default: "bg-transparent",
|
||||
icon: "[&_svg:not([class*='size-'])]:size-4",
|
||||
image:
|
||||
"size-10 overflow-hidden rounded-sm group-data-[size=sm]/item:size-8 group-data-[size=xs]/item:size-6 [&_img]:size-full [&_img]:object-cover",
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
variant: "default",
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
function ItemMedia({
|
||||
className,
|
||||
variant = "default",
|
||||
...props
|
||||
}: React.ComponentProps<"div"> & VariantProps<typeof itemMediaVariants>) {
|
||||
return (
|
||||
<div
|
||||
data-slot="item-media"
|
||||
data-variant={variant}
|
||||
className={cn(itemMediaVariants({ variant, className }))}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function ItemContent({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="item-content"
|
||||
className={cn(
|
||||
"flex flex-1 flex-col gap-1 group-data-[size=xs]/item:gap-0 [&+[data-slot=item-content]]:flex-none",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function ItemTitle({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="item-title"
|
||||
className={cn(
|
||||
"line-clamp-1 flex w-fit items-center gap-2 text-sm leading-snug font-medium underline-offset-4",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function ItemDescription({ className, ...props }: React.ComponentProps<"p">) {
|
||||
return (
|
||||
<p
|
||||
data-slot="item-description"
|
||||
className={cn(
|
||||
"line-clamp-2 text-left text-sm leading-normal font-normal text-muted-foreground group-data-[size=xs]/item:text-xs [&>a]:underline [&>a]:underline-offset-4 [&>a:hover]:text-primary",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function ItemActions({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return <div data-slot="item-actions" className={cn("flex items-center gap-2", className)} {...props} />;
|
||||
}
|
||||
|
||||
function ItemHeader({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="item-header"
|
||||
className={cn("flex basis-full items-center justify-between gap-2", className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function ItemFooter({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="item-footer"
|
||||
className={cn("flex basis-full items-center justify-between gap-2", className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export {
|
||||
Item,
|
||||
ItemMedia,
|
||||
ItemContent,
|
||||
ItemActions,
|
||||
ItemGroup,
|
||||
ItemSeparator,
|
||||
ItemTitle,
|
||||
ItemDescription,
|
||||
ItemHeader,
|
||||
ItemFooter,
|
||||
};
|
||||
@@ -0,0 +1,20 @@
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
function Kbd({ className, ...props }: React.ComponentProps<"kbd">) {
|
||||
return (
|
||||
<kbd
|
||||
data-slot="kbd"
|
||||
className={cn(
|
||||
"pointer-events-none inline-flex h-5 w-fit min-w-5 items-center justify-center gap-1 rounded-sm bg-muted px-1 font-sans text-xs font-medium text-muted-foreground select-none in-data-[slot=tooltip-content]:bg-background/20 in-data-[slot=tooltip-content]:text-background dark:in-data-[slot=tooltip-content]:bg-background/10 [&_svg:not([class*='size-'])]:size-3",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function KbdGroup({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return <kbd data-slot="kbd-group" className={cn("inline-flex items-center gap-1", className)} {...props} />;
|
||||
}
|
||||
|
||||
export { Kbd, KbdGroup };
|
||||
@@ -0,0 +1,68 @@
|
||||
import * as React from "react";
|
||||
import { mergeProps } from "@base-ui/react/merge-props";
|
||||
import { useRender } from "@base-ui/react/use-render";
|
||||
import { cva, type VariantProps } from "class-variance-authority";
|
||||
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
const markerVariants = cva(
|
||||
"group/marker relative flex min-h-4 w-full items-center gap-2 text-left text-sm text-muted-foreground [&_svg:not([class*='size-'])]:size-4 [a]:underline [a]:underline-offset-3 [a]:hover:text-foreground",
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
default: "",
|
||||
separator:
|
||||
"before:mr-1 before:h-px before:min-w-0 before:flex-1 before:bg-border after:ml-1 after:h-px after:min-w-0 after:flex-1 after:bg-border",
|
||||
border: "border-b border-border pb-2",
|
||||
},
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
function Marker({
|
||||
className,
|
||||
variant = "default",
|
||||
render,
|
||||
...props
|
||||
}: useRender.ComponentProps<"div"> & VariantProps<typeof markerVariants>) {
|
||||
return useRender({
|
||||
defaultTagName: "div",
|
||||
props: mergeProps<"div">(
|
||||
{
|
||||
className: cn(markerVariants({ variant, className })),
|
||||
},
|
||||
props,
|
||||
),
|
||||
render,
|
||||
state: {
|
||||
slot: "marker",
|
||||
variant,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
function MarkerIcon({ className, ...props }: React.ComponentProps<"span">) {
|
||||
return (
|
||||
<span
|
||||
data-slot="marker-icon"
|
||||
aria-hidden="true"
|
||||
className={cn("size-4 shrink-0 [&_svg:not([class*='size-'])]:size-4", className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function MarkerContent({ className, ...props }: React.ComponentProps<"span">) {
|
||||
return (
|
||||
<span
|
||||
data-slot="marker-content"
|
||||
className={cn(
|
||||
"min-w-0 wrap-break-word group-data-[variant=separator]/marker:flex-none group-data-[variant=separator]/marker:text-center *:[a]:underline *:[a]:underline-offset-3 *:[a]:hover:text-foreground",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export { Marker, MarkerIcon, MarkerContent, markerVariants };
|
||||
@@ -0,0 +1,256 @@
|
||||
import * as React from "react";
|
||||
import { Menu as MenuPrimitive } from "@base-ui/react/menu";
|
||||
import { Menubar as MenubarPrimitive } from "@base-ui/react/menubar";
|
||||
|
||||
import { cn } from "@/lib/utils";
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuGroup,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuLabel,
|
||||
DropdownMenuPortal,
|
||||
DropdownMenuRadioGroup,
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuShortcut,
|
||||
DropdownMenuSub,
|
||||
DropdownMenuSubContent,
|
||||
DropdownMenuSubTrigger,
|
||||
DropdownMenuTrigger,
|
||||
} from "@/components/ui/dropdown-menu";
|
||||
import { IconCheck } from "@tabler/icons-react";
|
||||
|
||||
function Menubar({ className, ...props }: MenubarPrimitive.Props) {
|
||||
return (
|
||||
<MenubarPrimitive
|
||||
data-slot="menubar"
|
||||
className={cn("flex h-8 items-center gap-0.5 rounded-lg border p-[3px]", className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function MenubarMenu({ ...props }: React.ComponentProps<typeof DropdownMenu>) {
|
||||
return <DropdownMenu data-slot="menubar-menu" {...props} />;
|
||||
}
|
||||
|
||||
function MenubarGroup({ ...props }: React.ComponentProps<typeof DropdownMenuGroup>) {
|
||||
return <DropdownMenuGroup data-slot="menubar-group" {...props} />;
|
||||
}
|
||||
|
||||
function MenubarPortal({ ...props }: React.ComponentProps<typeof DropdownMenuPortal>) {
|
||||
return <DropdownMenuPortal data-slot="menubar-portal" {...props} />;
|
||||
}
|
||||
|
||||
function MenubarTrigger({ className, ...props }: React.ComponentProps<typeof DropdownMenuTrigger>) {
|
||||
return (
|
||||
<DropdownMenuTrigger
|
||||
data-slot="menubar-trigger"
|
||||
className={cn(
|
||||
"flex items-center rounded-sm px-1.5 py-[2px] text-sm font-medium outline-hidden select-none hover:bg-muted aria-expanded:bg-muted",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function MenubarContent({
|
||||
className,
|
||||
align = "start",
|
||||
alignOffset = -4,
|
||||
sideOffset = 8,
|
||||
...props
|
||||
}: React.ComponentProps<typeof DropdownMenuContent>) {
|
||||
return (
|
||||
<DropdownMenuContent
|
||||
data-slot="menubar-content"
|
||||
align={align}
|
||||
alignOffset={alignOffset}
|
||||
sideOffset={sideOffset}
|
||||
className={cn(
|
||||
"min-w-36 rounded-lg bg-popover p-1 text-popover-foreground shadow-md ring-1 ring-foreground/10 duration-100 data-[side=bottom]:slide-in-from-top-2 data-[side=inline-end]:slide-in-from-left-2 data-[side=inline-start]:slide-in-from-right-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 data-open:animate-in data-open:fade-in-0 data-open:zoom-in-95",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function MenubarItem({
|
||||
className,
|
||||
inset,
|
||||
variant = "default",
|
||||
...props
|
||||
}: React.ComponentProps<typeof DropdownMenuItem>) {
|
||||
return (
|
||||
<DropdownMenuItem
|
||||
data-slot="menubar-item"
|
||||
data-inset={inset}
|
||||
data-variant={variant}
|
||||
className={cn(
|
||||
"group/menubar-item gap-1.5 rounded-md px-1.5 py-1 text-sm focus:bg-accent focus:text-accent-foreground not-data-[variant=destructive]:focus:**:text-accent-foreground data-inset:pl-7 data-[variant=destructive]:text-destructive data-[variant=destructive]:focus:bg-destructive/10 data-[variant=destructive]:focus:text-destructive dark:data-[variant=destructive]:focus:bg-destructive/20 data-disabled:opacity-50 [&_svg:not([class*='size-'])]:size-4 data-[variant=destructive]:*:[svg]:text-destructive!",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function MenubarCheckboxItem({
|
||||
className,
|
||||
children,
|
||||
checked,
|
||||
inset,
|
||||
...props
|
||||
}: MenuPrimitive.CheckboxItem.Props & {
|
||||
inset?: boolean;
|
||||
}) {
|
||||
return (
|
||||
<MenuPrimitive.CheckboxItem
|
||||
data-slot="menubar-checkbox-item"
|
||||
data-inset={inset}
|
||||
className={cn(
|
||||
"relative flex cursor-default items-center gap-1.5 rounded-md py-1 pr-1.5 pl-7 text-sm outline-hidden select-none focus:bg-accent focus:text-accent-foreground focus:**:text-accent-foreground data-inset:pl-7 data-disabled:pointer-events-none data-disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0",
|
||||
className,
|
||||
)}
|
||||
checked={checked}
|
||||
{...props}
|
||||
>
|
||||
<span className="pointer-events-none absolute left-1.5 flex size-4 items-center justify-center [&_svg:not([class*='size-'])]:size-4">
|
||||
<MenuPrimitive.CheckboxItemIndicator>
|
||||
<IconCheck />
|
||||
</MenuPrimitive.CheckboxItemIndicator>
|
||||
</span>
|
||||
{children}
|
||||
</MenuPrimitive.CheckboxItem>
|
||||
);
|
||||
}
|
||||
|
||||
function MenubarRadioGroup({ ...props }: React.ComponentProps<typeof DropdownMenuRadioGroup>) {
|
||||
return <DropdownMenuRadioGroup data-slot="menubar-radio-group" {...props} />;
|
||||
}
|
||||
|
||||
function MenubarRadioItem({
|
||||
className,
|
||||
children,
|
||||
inset,
|
||||
...props
|
||||
}: MenuPrimitive.RadioItem.Props & {
|
||||
inset?: boolean;
|
||||
}) {
|
||||
return (
|
||||
<MenuPrimitive.RadioItem
|
||||
data-slot="menubar-radio-item"
|
||||
data-inset={inset}
|
||||
className={cn(
|
||||
"relative flex cursor-default items-center gap-1.5 rounded-md py-1 pr-1.5 pl-7 text-sm outline-hidden select-none focus:bg-accent focus:text-accent-foreground focus:**:text-accent-foreground data-inset:pl-7 data-disabled:pointer-events-none data-disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<span className="pointer-events-none absolute left-1.5 flex size-4 items-center justify-center [&_svg:not([class*='size-'])]:size-4">
|
||||
<MenuPrimitive.RadioItemIndicator>
|
||||
<IconCheck />
|
||||
</MenuPrimitive.RadioItemIndicator>
|
||||
</span>
|
||||
{children}
|
||||
</MenuPrimitive.RadioItem>
|
||||
);
|
||||
}
|
||||
|
||||
function MenubarLabel({
|
||||
className,
|
||||
inset,
|
||||
...props
|
||||
}: React.ComponentProps<typeof DropdownMenuLabel> & {
|
||||
inset?: boolean;
|
||||
}) {
|
||||
return (
|
||||
<DropdownMenuLabel
|
||||
data-slot="menubar-label"
|
||||
data-inset={inset}
|
||||
className={cn("px-1.5 py-1 text-sm font-medium data-inset:pl-7", className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function MenubarSeparator({ className, ...props }: React.ComponentProps<typeof DropdownMenuSeparator>) {
|
||||
return (
|
||||
<DropdownMenuSeparator
|
||||
data-slot="menubar-separator"
|
||||
className={cn("-mx-1 my-1 h-px bg-border", className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function MenubarShortcut({ className, ...props }: React.ComponentProps<typeof DropdownMenuShortcut>) {
|
||||
return (
|
||||
<DropdownMenuShortcut
|
||||
data-slot="menubar-shortcut"
|
||||
className={cn(
|
||||
"ml-auto text-xs tracking-widest text-muted-foreground group-focus/menubar-item:text-accent-foreground",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function MenubarSub({ ...props }: React.ComponentProps<typeof DropdownMenuSub>) {
|
||||
return <DropdownMenuSub data-slot="menubar-sub" {...props} />;
|
||||
}
|
||||
|
||||
function MenubarSubTrigger({
|
||||
className,
|
||||
inset,
|
||||
...props
|
||||
}: React.ComponentProps<typeof DropdownMenuSubTrigger> & {
|
||||
inset?: boolean;
|
||||
}) {
|
||||
return (
|
||||
<DropdownMenuSubTrigger
|
||||
data-slot="menubar-sub-trigger"
|
||||
data-inset={inset}
|
||||
className={cn(
|
||||
"gap-1.5 rounded-md px-1.5 py-1 text-sm focus:bg-accent focus:text-accent-foreground data-inset:pl-7 data-open:bg-accent data-open:text-accent-foreground [&_svg:not([class*='size-'])]:size-4",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function MenubarSubContent({ className, ...props }: React.ComponentProps<typeof DropdownMenuSubContent>) {
|
||||
return (
|
||||
<DropdownMenuSubContent
|
||||
data-slot="menubar-sub-content"
|
||||
className={cn(
|
||||
"min-w-32 rounded-lg bg-popover p-1 text-popover-foreground shadow-lg ring-1 ring-foreground/10 duration-100 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 data-open:animate-in data-open:fade-in-0 data-open:zoom-in-95 data-closed:animate-out data-closed:fade-out-0 data-closed:zoom-out-95",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export {
|
||||
Menubar,
|
||||
MenubarPortal,
|
||||
MenubarMenu,
|
||||
MenubarTrigger,
|
||||
MenubarContent,
|
||||
MenubarGroup,
|
||||
MenubarSeparator,
|
||||
MenubarLabel,
|
||||
MenubarItem,
|
||||
MenubarShortcut,
|
||||
MenubarCheckboxItem,
|
||||
MenubarRadioGroup,
|
||||
MenubarRadioItem,
|
||||
MenubarSub,
|
||||
MenubarSubTrigger,
|
||||
MenubarSubContent,
|
||||
};
|
||||
@@ -0,0 +1,117 @@
|
||||
"use client";
|
||||
|
||||
import * as React from "react";
|
||||
import {
|
||||
MessageScroller as MessageScrollerPrimitive,
|
||||
useMessageScroller,
|
||||
useMessageScrollerScrollable,
|
||||
useMessageScrollerVisibility,
|
||||
} from "@shadcn/react/message-scroller";
|
||||
|
||||
import { cn } from "@/lib/utils";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { IconArrowDown } from "@tabler/icons-react";
|
||||
|
||||
function MessageScrollerProvider(props: React.ComponentProps<typeof MessageScrollerPrimitive.Provider>) {
|
||||
return <MessageScrollerPrimitive.Provider {...props} />;
|
||||
}
|
||||
|
||||
function MessageScroller({ className, ...props }: React.ComponentProps<typeof MessageScrollerPrimitive.Root>) {
|
||||
return (
|
||||
<MessageScrollerPrimitive.Root
|
||||
data-slot="message-scroller"
|
||||
className={cn("group/message-scroller relative flex size-full min-h-0 flex-col overflow-hidden", className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function MessageScrollerViewport({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof MessageScrollerPrimitive.Viewport>) {
|
||||
return (
|
||||
<MessageScrollerPrimitive.Viewport
|
||||
data-slot="message-scroller-viewport"
|
||||
className={cn(
|
||||
"size-full min-h-0 min-w-0 scroll-fade-b scrollbar-thin scrollbar-gutter-stable overflow-y-auto overscroll-contain contain-content data-autoscrolling:scrollbar-thumb-transparent data-autoscrolling:scrollbar-track-transparent",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function MessageScrollerContent({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof MessageScrollerPrimitive.Content>) {
|
||||
return (
|
||||
<MessageScrollerPrimitive.Content
|
||||
data-slot="message-scroller-content"
|
||||
className={cn("flex h-max min-h-full flex-col gap-6", className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function MessageScrollerItem({
|
||||
className,
|
||||
scrollAnchor = false,
|
||||
...props
|
||||
}: React.ComponentProps<typeof MessageScrollerPrimitive.Item>) {
|
||||
return (
|
||||
<MessageScrollerPrimitive.Item
|
||||
data-slot="message-scroller-item"
|
||||
scrollAnchor={scrollAnchor}
|
||||
className={cn("min-w-0 shrink-0 [contain-intrinsic-size:auto_10rem] [content-visibility:auto]", className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function MessageScrollerButton({
|
||||
direction = "end",
|
||||
className,
|
||||
children,
|
||||
render,
|
||||
variant = "secondary",
|
||||
size = "icon-sm",
|
||||
...props
|
||||
}: React.ComponentProps<typeof MessageScrollerPrimitive.Button> &
|
||||
Pick<React.ComponentProps<typeof Button>, "variant" | "size">) {
|
||||
return (
|
||||
<MessageScrollerPrimitive.Button
|
||||
data-slot="message-scroller-button"
|
||||
data-direction={direction}
|
||||
data-variant={variant}
|
||||
data-size={size}
|
||||
direction={direction}
|
||||
className={cn(
|
||||
"absolute inset-s-1/2 -translate-x-1/2 border-border bg-background text-foreground transition-[translate,scale,opacity] duration-200 hover:bg-muted hover:text-foreground data-[active=false]:pointer-events-none data-[active=false]:scale-95 data-[active=false]:opacity-0 data-[active=false]:duration-400 data-[active=false]:ease-[cubic-bezier(0.7,0,0.84,0)] data-[active=true]:translate-y-0 data-[active=true]:scale-100 data-[active=true]:opacity-100 data-[active=true]:ease-[cubic-bezier(0.23,1,0.32,1)] data-[direction=end]:bottom-4 data-[direction=end]:data-[active=false]:translate-y-full data-[direction=start]:top-4 data-[direction=start]:data-[active=false]:-translate-y-full rtl:translate-x-1/2 data-[direction=start]:[&_svg]:rotate-180",
|
||||
className,
|
||||
)}
|
||||
render={render ?? <Button variant={variant} size={size} />}
|
||||
{...props}
|
||||
>
|
||||
{children ?? (
|
||||
<>
|
||||
<IconArrowDown />
|
||||
<span className="sr-only">{direction === "end" ? "Scroll to end" : "Scroll to start"}</span>
|
||||
</>
|
||||
)}
|
||||
</MessageScrollerPrimitive.Button>
|
||||
);
|
||||
}
|
||||
|
||||
export {
|
||||
MessageScrollerProvider,
|
||||
MessageScroller,
|
||||
MessageScrollerViewport,
|
||||
MessageScrollerContent,
|
||||
MessageScrollerItem,
|
||||
MessageScrollerButton,
|
||||
useMessageScroller,
|
||||
useMessageScrollerScrollable,
|
||||
useMessageScrollerVisibility,
|
||||
};
|
||||
@@ -0,0 +1,75 @@
|
||||
import * as React from "react";
|
||||
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
function MessageGroup({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return <div data-slot="message-group" className={cn("flex min-w-0 flex-col gap-2", className)} {...props} />;
|
||||
}
|
||||
|
||||
function Message({ className, align = "start", ...props }: React.ComponentProps<"div"> & { align?: "start" | "end" }) {
|
||||
return (
|
||||
<div
|
||||
data-slot="message"
|
||||
data-align={align}
|
||||
className={cn(
|
||||
"group/message relative flex w-full min-w-0 gap-2 text-sm data-[align=end]:flex-row-reverse",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function MessageAvatar({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="message-avatar"
|
||||
className={cn(
|
||||
"flex w-fit min-w-8 shrink-0 items-center justify-center self-end overflow-hidden rounded-full bg-muted group-has-data-[slot=message-footer]/message:-translate-y-8",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function MessageContent({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="message-content"
|
||||
className={cn(
|
||||
"flex w-full min-w-0 flex-col gap-2.5 wrap-break-word group-data-[align=end]/message:*:data-slot:self-end",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function MessageHeader({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="message-header"
|
||||
className={cn(
|
||||
"flex max-w-full min-w-0 items-center px-3 text-xs font-medium text-muted-foreground group-has-data-[variant=ghost]/message:px-0",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function MessageFooter({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="message-footer"
|
||||
className={cn(
|
||||
"flex max-w-full min-w-0 items-center px-3 text-xs font-medium text-muted-foreground group-has-data-[variant=ghost]/message:px-0 group-data-[align=end]/message:justify-end",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export { MessageGroup, Message, MessageAvatar, MessageContent, MessageFooter, MessageHeader };
|
||||