Compare commits

..
Author SHA1 Message Date
Thomas Camlong 9037da96b5 feat: rebuild workshop 2026-07-13 19:24:31 +02:00
Thomas ba7718bde5 feat: rework the ci (#6296) 2026-07-13 18:32:48 +02:00
100 changed files with 4058 additions and 4589 deletions

No files matched your search

+7
View File
@@ -6,7 +6,14 @@ npm-debug.log
README.md
.next
.turbo
.pnpm-store
**/.pnpm-store
coverage
**/coverage
**/.docusaurus
**/build
.git
.github
dev
.build
e2e
+1 -6
View File
@@ -67,10 +67,5 @@ UNSAFE_ENABLE_MOCK_INTEGRATION=true
# Whether demo mode is read-only. Defaults to true (the public demo blocks all mutations).
# Set to false for writable preview deployments so the demo user can modify the dashboard.
# DEMO_READ_ONLY=false
# Community workshop (PocketBase) base URL used by the in-app "Install from Workshop" buttons
# Workshop API override for local Homarr development:
# Central Homarr Workshop API. Override only when developing the Workshop locally.
# NEXT_PUBLIC_WORKSHOP_API_URL=http://localhost:8090
# Docs site workshop URL used for "Open Workshop" links
# In production this defaults to https://homarr.dev/workshop
# NEXT_PUBLIC_WORKSHOP_URL=http://localhost:3003/workshop
+2
View File
@@ -0,0 +1,2 @@
.secrets
!.secrets.example
+4
View File
@@ -0,0 +1,4 @@
TURBO_API=
TURBO_TEAM=
TURBO_TOKEN=
TURBO_REMOTE_CACHE_SIGNATURE_KEY=
+36
View File
@@ -0,0 +1,36 @@
# Running CI locally
Install [Act](https://nektosact.com/installation/index.html) and ensure Docker is running, then use:
```bash
pnpm ci:act:fast
pnpm ci:act:docker
pnpm ci:act:all
pnpm ci:act:cleanup
```
`fast` runs the quality gate. `docker` and `all` run the gate followed by the single container build, E2E suite, and memory regression test. `cleanup` validates the manual cleanup path without calling GitHub. The wrapper supports regular clones and Git worktrees, including non-default Docker contexts. Linux dependencies are kept in Docker volumes instead of overwriting host `node_modules`. The first run downloads the Act runner and Playwright browser dependencies.
Remote caching is optional locally. Copy `.github/act/.secrets.example` to `.github/act/.secrets`, populate it with rotated credentials, and run with:
```bash
ACT_SECRET_FILE=.github/act/.secrets pnpm ci:act:all
```
Act never publishes or deletes GHCR images. The secret file is ignored by Git.
## PR preview images
Successful non-draft pull requests from this repository publish an amd64 image:
```bash
docker pull ghcr.io/homarr-labs/homarr-test:pr-<number>
```
The tag is replaced when the pull request is updated and deleted when it closes. Production Homarr images remain the supported multi-architecture releases. The `homarr-test` package must be configured as public in GHCR for unauthenticated testers.
## Dokploy preview cache
Keep `needs-demo` as the Dokploy preview label. Under the application's **Preview Build-time Secrets**, configure `TURBO_API`, `TURBO_TEAM`, `TURBO_TOKEN`, and `TURBO_REMOTE_CACHE_SIGNATURE_KEY`. Dokploy passes these to the Dockerfile as BuildKit secrets. Do not configure the token or signature key as Docker build arguments.
Rotate any credentials that have appeared in chat, logs, or committed configuration before enabling the cache.
-19
View File
@@ -1,19 +0,0 @@
# Workshop deployment with ACT
The Workshop workflow builds the same image used on the VPS and smoke-tests both Docusaurus and PocketBase. Registry login and pushes are skipped under ACT.
From the repository root:
```sh
pnpm ci:act:workshop
```
The wrapper selects `.github/workflows/deployment-workshop.yml`, derives the active Docker socket (including Docker Desktop and OrbStack contexts), and runs only the `deploy` job using a `workflow_dispatch` event.
To inspect the workflow without running it:
```sh
act workflow_dispatch --list --workflows .github/workflows/deployment-workshop.yml
```
No GitHub or registry secrets are required for the local run.
+48
View File
@@ -0,0 +1,48 @@
name: Auto-fix lint and format
on:
pull_request:
types: [opened, synchronize, reopened]
branches: ["*"]
permissions:
contents: write
concurrency:
group: ${{ github.workflow }}-${{ github.event.pull_request.number }}
cancel-in-progress: true
jobs:
autofix:
name: Auto-fix
if: github.event.pull_request.head.repo.id == github.repository_id
runs-on: ubuntu-latest
timeout-minutes: 5
steps:
- uses: actions/checkout@v7
with:
fetch-depth: 0
ref: ${{ github.head_ref }}
persist-credentials: false
- name: Setup
uses: ./tooling/github/setup
- name: Get changed files
id: changed
env:
BASE_REF: ${{ github.base_ref }}
run: |
git diff --name-only --diff-filter=ACMR "origin/${BASE_REF}...HEAD" -- '*.ts' '*.tsx' '*.js' '*.jsx' > /tmp/changed-files
echo "count=$(wc -l < /tmp/changed-files | tr -d '[:space:]')" >> $GITHUB_OUTPUT
- name: Auto-fix lint + format
if: steps.changed.outputs.count != '0'
run: |
xargs -r -a /tmp/changed-files pnpm exec oxlint --fix || true
xargs -r -a /tmp/changed-files pnpm exec oxfmt --write
- name: Commit fixes
uses: stefanzweifel/git-auto-commit-action@v7
with:
commit_message: "style: auto-fix lint and format"
+347
View File
@@ -0,0 +1,347 @@
name: CI
on:
pull_request:
types: [opened, synchronize, reopened, ready_for_review]
branches: ["*"]
pull_request_target:
types: [closed]
branches: ["*"]
push:
branches: [main, dev, beta, next]
merge_group:
workflow_dispatch:
inputs:
operation:
description: Operation to run
required: true
default: validate
type: choice
options:
- validate
- publish
- cleanup
pr_number:
description: PR number for publish or cleanup
required: false
type: number
ref:
description: Git ref to validate or publish
required: false
type: string
permissions:
contents: read
concurrency:
group: ${{ github.workflow }}-${{ github.event_name }}-${{ github.event.pull_request.number || inputs.pr_number || github.ref }}
cancel-in-progress: ${{ github.ref != 'refs/heads/main' }}
env:
FORCE_COLOR: 3
SKIP_ENV_VALIDATION: true
TURBO_TELEMETRY_DISABLED: 1
REGISTRY: ghcr.io
PREVIEW_IMAGE: ghcr.io/homarr-labs/homarr-test
jobs:
fast-gate:
name: Fast gate
if: github.event_name != 'pull_request_target' && (github.event_name != 'workflow_dispatch' || inputs.operation != 'cleanup')
runs-on: ubuntu-latest
timeout-minutes: 15
env:
TURBO_API: ${{ secrets.TURBO_API }}
TURBO_TEAM: ${{ secrets.TURBO_TEAM }}
TURBO_TOKEN: ${{ secrets.TURBO_TOKEN }}
TURBO_REMOTE_CACHE_SIGNATURE_KEY: ${{ secrets.TURBO_REMOTE_CACHE_SIGNATURE_KEY }}
TURBO_SCM_BASE: ${{ github.event.pull_request.base.sha || github.event.merge_group.base_sha || github.event.before || 'HEAD~1' }}
TURBO_SCM_HEAD: ${{ github.sha }}
TURBO_PLATFORM: linux/amd64
steps:
- uses: actions/checkout@v7
if: '!env.ACT'
with:
fetch-depth: 0
persist-credentials: false
ref: ${{ github.event_name == 'workflow_dispatch' && inputs.ref || '' }}
- name: Validate manual inputs
if: github.event_name == 'workflow_dispatch' && inputs.operation == 'publish'
env:
PR_NUMBER: ${{ inputs.pr_number }}
BUILD_REF: ${{ inputs.ref }}
run: |
test -n "$PR_NUMBER" || { echo "pr_number is required for publish"; exit 1; }
test -n "$BUILD_REF" || { echo "ref is required for publish"; exit 1; }
- name: Setup
uses: ./tooling/github/setup
- name: Copy environment defaults
run: cp .env.example .env
- name: Check formatting in affected workspaces
run: pnpm turbo run format --affected --continue -- --check
- name: Lint, typecheck, and build affected workspaces
run: pnpm turbo run lint typecheck build --affected --continue=dependencies-successful
- name: Validate workspace configuration
run: pnpm lint:ws
- name: Run unit tests
run: pnpm test
- name: Report coverage
if: always() && !env.ACT
uses: davelosert/vitest-coverage-report-action@v2
container:
name: Container, E2E, and memory
needs: fast-gate
if: |
github.event_name == 'merge_group' ||
(github.event_name == 'pull_request' && github.event.pull_request.draft == false) ||
(github.event_name == 'workflow_dispatch' && (inputs.operation == 'validate' || inputs.operation == 'publish'))
runs-on: ubuntu-latest
timeout-minutes: 30
permissions:
contents: read
packages: write
steps:
- uses: actions/checkout@v7
if: '!env.ACT'
with:
persist-credentials: false
ref: ${{ github.event_name == 'workflow_dispatch' && inputs.ref || '' }}
- name: Setup
uses: ./tooling/github/setup
- name: Set up Docker Buildx
if: '!env.ACT'
uses: docker/setup-buildx-action@v4
- name: Build container once
if: '!env.ACT'
uses: docker/build-push-action@v7
with:
context: .
network: host
platforms: linux/amd64
load: true
push: false
tags: |
homarr-ci
homarr-e2e
labels: |
org.opencontainers.image.source=${{ github.server_url }}/${{ github.repository }}
org.opencontainers.image.revision=${{ github.sha }}
org.opencontainers.image.ref.name=pr-${{ github.event.pull_request.number || inputs.pr_number || 'none' }}
cache-from: type=gha,scope=homarr-linux-amd64
cache-to: type=gha,scope=homarr-linux-amd64,mode=max
secrets: |
TURBO_API=${{ secrets.TURBO_API }}
TURBO_TEAM=${{ secrets.TURBO_TEAM }}
TURBO_TOKEN=${{ secrets.TURBO_TOKEN }}
TURBO_REMOTE_CACHE_SIGNATURE_KEY=${{ secrets.TURBO_REMOTE_CACHE_SIGNATURE_KEY }}
- name: Build container once with Act
if: env.ACT
env:
TURBO_API: ${{ secrets.TURBO_API }}
TURBO_TEAM: ${{ secrets.TURBO_TEAM }}
TURBO_TOKEN: ${{ secrets.TURBO_TOKEN }}
TURBO_REMOTE_CACHE_SIGNATURE_KEY: ${{ secrets.TURBO_REMOTE_CACHE_SIGNATURE_KEY }}
run: |
secret_args=()
for secret in TURBO_API TURBO_TEAM TURBO_TOKEN TURBO_REMOTE_CACHE_SIGNATURE_KEY; do
if [ -n "${!secret:-}" ]; then
secret_args+=(--secret "id=${secret},env=${secret}")
fi
done
DOCKER_BUILDKIT=1 docker build --platform linux/amd64 \
--tag homarr-ci --tag homarr-e2e "${secret_args[@]}" .
- name: Cache Playwright browsers
if: '!env.ACT'
id: playwright-cache
uses: actions/cache@v4
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
run: pnpm test:e2e
- name: Upload E2E artifacts
if: failure() && !env.ACT
uses: actions/upload-artifact@v4
with:
name: playwright-report
path: e2e/test-results/
retention-days: 7
- name: Run memory regression test
env:
CONTAINER_NAME: homarr-mem-${{ github.run_id }}-${{ github.run_attempt }}
SECRET_ENCRYPTION_KEY: 0000000000000000000000000000000000000000000000000000000000000000
run: |
docker run -d --name "$CONTAINER_NAME" -p 127.0.0.1::7575 \
-e NO_EXTERNAL_CONNECTION=true \
-e SECRET_ENCRYPTION_KEY="$SECRET_ENCRYPTION_KEY" \
homarr-ci
PORT=$(docker port "$CONTAINER_NAME" 7575/tcp | sed 's/.*://')
[[ "$PORT" =~ ^[0-9]+$ ]] || { echo "ERROR: invalid port mapping: '$PORT'"; exit 1; }
URL="http://127.0.0.1:${PORT}"
healthy=false
for i in $(seq 1 30); do
if curl -sf "$URL/api/health/ready" >/dev/null 2>&1; then
echo "Container healthy after $((i * 2))s"
healthy=true
break
fi
sleep 2
done
if [ "$healthy" != "true" ]; then
echo "ERROR: Container never became healthy"
docker logs "$CONTAINER_NAME" --tail 100
exit 1
fi
warmup_ok=0
for _ in $(seq 1 5); do
curl -sf "$URL/" >/dev/null 2>&1 && warmup_ok=$((warmup_ok + 1))
sleep 2
done
if [ "$warmup_ok" -eq 0 ]; then
echo "ERROR: All warmup requests failed"
docker logs "$CONTAINER_NAME" --tail 50
exit 1
fi
docker inspect -f '{{.State.Running}}' "$CONTAINER_NAME" | grep -qx true || {
echo "ERROR: Container died before memory measurement"
docker logs "$CONTAINER_NAME" --tail 100
exit 1
}
RAW_MEMORY=$(docker stats --no-stream --format '{{.MemUsage}}' "$CONTAINER_NAME" | sed 's| /.*||')
MEMORY_MB=$(echo "$RAW_MEMORY" | awk '
/GiB/ { gsub(/GiB/, ""); print $1 * 1024; next }
/MiB/ { gsub(/MiB/, ""); print $1; next }
/KiB/ { gsub(/KiB/, ""); print $1 / 1024; next }
{ print $1 / 1048576 }
')
echo "Memory usage: ${MEMORY_MB} MB (${RAW_MEMORY})"
[[ "$MEMORY_MB" =~ ^[0-9]+(\.[0-9]+)?$ ]] || {
echo "ERROR: non-numeric memory value: '$MEMORY_MB' (raw: '$RAW_MEMORY')"
docker logs "$CONTAINER_NAME" --tail 100
exit 1
}
if awk "BEGIN { exit !($MEMORY_MB < 50) }"; then
echo "ERROR: Memory suspiciously low (${MEMORY_MB} MB) — container may have crashed"
docker logs "$CONTAINER_NAME" --tail 100
exit 1
elif awk "BEGIN { exit !($MEMORY_MB > 700) }"; then
echo "ERROR: Memory usage ${MEMORY_MB} MB exceeds 700 MB"
docker logs "$CONTAINER_NAME" --tail 100
exit 1
fi
- name: Clean up memory test container
if: always()
env:
CONTAINER_NAME: homarr-mem-${{ github.run_id }}-${{ github.run_attempt }}
run: docker rm -f "$CONTAINER_NAME" >/dev/null 2>&1 || true
- name: Determine preview tag
id: preview
if: |
!env.ACT &&
(
(github.event_name == 'pull_request' && github.event.pull_request.head.repo.full_name == github.repository) ||
(github.event_name == 'workflow_dispatch' && inputs.operation == 'publish')
)
env:
PR_NUMBER: ${{ github.event.pull_request.number || inputs.pr_number }}
run: echo "tag=pr-${PR_NUMBER}" >> "$GITHUB_OUTPUT"
- name: Log in to GHCR
if: steps.preview.outputs.tag != ''
uses: docker/login-action@v4
with:
registry: ${{ env.REGISTRY }}
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Push preview image
if: steps.preview.outputs.tag != ''
env:
PREVIEW_TAG: ${{ steps.preview.outputs.tag }}
run: |
docker tag homarr-ci "${PREVIEW_IMAGE}:${PREVIEW_TAG}"
docker push "${PREVIEW_IMAGE}:${PREVIEW_TAG}"
- name: Show Act publication result
if: env.ACT
run: echo "Act validated the publication path; external GHCR writes are disabled locally."
cleanup:
name: Clean up preview image
if: github.event_name == 'pull_request_target' || (github.event_name == 'workflow_dispatch' && inputs.operation == 'cleanup')
runs-on: ubuntu-latest
timeout-minutes: 5
permissions:
packages: write
env:
IMAGE_TAG: pr-${{ github.event.pull_request.number || inputs.pr_number }}
steps:
- name: Validate cleanup input
if: github.event_name == 'workflow_dispatch'
env:
PR_NUMBER: ${{ inputs.pr_number }}
run: test -n "$PR_NUMBER" || { echo "pr_number is required for cleanup"; exit 1; }
- name: Delete GHCR package version
if: '!env.ACT'
uses: actions/github-script@v8
with:
script: |
const packageName = "homarr-test";
const org = context.repo.owner;
const tag = process.env.IMAGE_TAG;
const versions = await github.paginate(
github.rest.packages.getAllPackageVersionsForPackageOwnedByOrg,
{ package_type: "container", package_name: packageName, org, per_page: 100 },
);
const target = versions.find((version) =>
version.metadata?.container?.tags?.includes(tag),
);
if (!target) {
core.info(`No package version found with tag "${tag}"`);
return;
}
await github.rest.packages.deletePackageVersionForOrg({
package_type: "container",
package_name: packageName,
org,
package_version_id: target.id,
});
core.info(`Deleted package version ${target.id} (${tag})`);
- name: Show Act cleanup result
if: env.ACT
run: echo "Act validated cleanup for ${IMAGE_TAG}; external GHCR writes are disabled locally."
-165
View File
@@ -1,165 +0,0 @@
name: "[Quality] Code Analysis"
permissions:
contents: read
on:
pull_request:
branches: ["*"]
push:
branches: ["main"]
merge_group:
concurrency:
group: "${{ github.workflow }}-${{ github.ref }}"
cancel-in-progress: ${{ github.ref != 'refs/heads/main' }}
env:
FORCE_COLOR: 3
SKIP_ENV_VALIDATION: true
TURBO_API: ${{ secrets.TURBO_API }}
TURBO_TEAM: ${{ secrets.TURBO_TEAM }}
TURBO_TOKEN: ${{ secrets.TURBO_TOKEN }}
TURBO_REMOTE_CACHE_SIGNATURE_KEY: ${{ secrets.TURBO_REMOTE_CACHE_SIGNATURE_KEY }}
TURBO_SCM_BASE: ${{ github.event.pull_request.base.sha || github.event.before || 'HEAD~1' }}
TURBO_SCM_HEAD: ${{ github.sha }}
jobs:
lint-format-autofix:
if: github.event_name == 'pull_request' && github.event.pull_request.head.repo.id == github.repository_id
runs-on: ubuntu-latest
timeout-minutes: 5
permissions:
contents: write
steps:
- uses: actions/checkout@v7
with:
fetch-depth: 0
ref: ${{ github.head_ref }}
- name: Setup
uses: ./tooling/github/setup
- name: Get changed files
id: changed
run: |
git diff --name-only --diff-filter=ACMR origin/${{ github.base_ref }}...HEAD -- '*.ts' '*.tsx' '*.js' '*.jsx' > /tmp/changed-files
echo "count=$(wc -l < /tmp/changed-files | tr -d '[:space:]')" >> $GITHUB_OUTPUT
- name: Auto-fix lint + format
if: steps.changed.outputs.count != '0'
run: |
xargs -r -a /tmp/changed-files pnpm exec oxlint --fix || true
xargs -r -a /tmp/changed-files pnpm exec oxfmt --write
- name: Commit fixes
uses: stefanzweifel/git-auto-commit-action@v7
with:
commit_message: "style: auto-fix lint and format"
lint:
runs-on: ubuntu-latest
timeout-minutes: 10
steps:
- uses: actions/checkout@v7
with:
fetch-depth: 0
- name: Setup
uses: ./tooling/github/setup
- name: Copy env
shell: bash
run: cp .env.example .env
- name: Lint
run: pnpm turbo lint --affected && pnpm lint:ws
format:
runs-on: ubuntu-latest
timeout-minutes: 10
steps:
- uses: actions/checkout@v7
with:
fetch-depth: 0
- name: Setup
uses: ./tooling/github/setup
- name: Format
run: pnpm turbo format --affected --continue
typecheck:
runs-on: ubuntu-latest
timeout-minutes: 10
steps:
- uses: actions/checkout@v7
with:
fetch-depth: 0
- name: Setup
uses: ./tooling/github/setup
- name: Typecheck
run: pnpm turbo typecheck --affected
test:
runs-on: ubuntu-latest
timeout-minutes: 10
steps:
- uses: actions/checkout@v7
- name: Setup
uses: ./tooling/github/setup
- name: Test
run: pnpm test
- name: "Report Coverage"
# Set if: always() to also generate the report if tests are failing
# Only works if you set `reportOnFailure: true` in your vite config as specified above
if: always()
uses: davelosert/vitest-coverage-report-action@v2
e2e:
runs-on: ubuntu-latest
timeout-minutes: 15
steps:
- uses: actions/checkout@v7
- name: Setup
uses: ./tooling/github/setup
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v4
- name: Build docker image
id: build-docker-image
uses: docker/build-push-action@v7
with:
platforms: linux/amd64
context: .
push: false
load: true
tags: homarr-e2e
network: host
cache-from: type=gha
cache-to: type=gha,mode=max
env:
SKIP_ENV_VALIDATION: true
- name: Install playwright browsers
run: pnpm exec playwright install chromium
- name: Run E2E Tests
shell: bash
run: pnpm test:e2e
build:
runs-on: ubuntu-latest
timeout-minutes: 10
steps:
- uses: actions/checkout@v7
with:
fetch-depth: 0
- name: Setup
uses: ./tooling/github/setup
- name: Copy env
shell: bash
run: cp .env.example .env
- name: Build
run: pnpm turbo build --affected
@@ -148,6 +148,11 @@ jobs:
platforms: linux/amd64
labels: ${{ steps.meta.outputs.labels }}
outputs: type=image,"name=${{ env.GHCR_REPO }}",push-by-digest=true,name-canonical=true,push=true
secrets: |
TURBO_API=${{ secrets.TURBO_API }}
TURBO_TEAM=${{ secrets.TURBO_TEAM }}
TURBO_TOKEN=${{ secrets.TURBO_TOKEN }}
TURBO_REMOTE_CACHE_SIGNATURE_KEY=${{ secrets.TURBO_REMOTE_CACHE_SIGNATURE_KEY }}
env:
SKIP_ENV_VALIDATION: true
build-arm64:
@@ -187,6 +192,11 @@ jobs:
platforms: linux/arm64
labels: ${{ steps.meta.outputs.labels }}
outputs: type=image,"name=${{ env.GHCR_REPO }}",push-by-digest=true,name-canonical=true,push=true
secrets: |
TURBO_API=${{ secrets.TURBO_API }}
TURBO_TEAM=${{ secrets.TURBO_TEAM }}
TURBO_TOKEN=${{ secrets.TURBO_TOKEN }}
TURBO_REMOTE_CACHE_SIGNATURE_KEY=${{ secrets.TURBO_REMOTE_CACHE_SIGNATURE_KEY }}
env:
SKIP_ENV_VALIDATION: true
extract-asset-amd64:
+56
View File
@@ -0,0 +1,56 @@
name: "[Deployment] Documentation"
on:
push:
branches: ["dev"]
paths:
- "apps/docs/**"
- "packages/definitions/src/docs/**"
- ".github/workflows/deployment-docs.yml"
workflow_dispatch:
permissions:
contents: read
pages: write
id-token: write
concurrency:
group: "pages"
cancel-in-progress: false
env:
SKIP_ENV_VALIDATION: true
TURBO_API: ${{ secrets.TURBO_API }}
TURBO_TEAM: ${{ secrets.TURBO_TEAM }}
TURBO_TOKEN: ${{ secrets.TURBO_TOKEN }}
TURBO_REMOTE_CACHE_SIGNATURE_KEY: ${{ secrets.TURBO_REMOTE_CACHE_SIGNATURE_KEY }}
jobs:
build:
runs-on: ubuntu-latest
timeout-minutes: 10
steps:
- uses: actions/checkout@v7
- name: Setup
uses: ./tooling/github/setup
- name: Build docs
run: pnpm turbo build --filter=@homarr/docs
env:
CI: "true"
- name: Setup Pages
uses: actions/configure-pages@v6
- name: Upload pages artifact
uses: actions/upload-pages-artifact@v5
with:
path: "apps/docs/build"
deploy:
runs-on: ubuntu-latest
needs: build
environment:
name: github-pages
url: ${{ steps.deployment.outputs.page_url }}
steps:
- name: Deploy to GitHub Pages
id: deployment
uses: actions/deploy-pages@v5
-47
View File
@@ -1,47 +0,0 @@
name: "[Deployment] Workshop"
on:
push:
branches: ["dev"]
paths:
- "apps/docs/**"
- "apps/workshop/**"
workflow_dispatch:
permissions:
contents: read
packages: write
concurrency:
group: workshop-deployment
cancel-in-progress: true
jobs:
deploy:
name: Build, test, and publish
runs-on: ubuntu-latest
timeout-minutes: 25
env:
LOCAL_IMAGE: homarr/workshop:ci
REGISTRY_IMAGE: ghcr.io/homarr-labs/workshop
steps:
- name: Check out repository
uses: actions/checkout@v7
- name: Build combined Workshop image
run: docker build --file apps/workshop/Dockerfile --tag "$LOCAL_IMAGE" .
- name: Smoke-test static docs and PocketBase
run: bash apps/workshop/scripts/smoke-test.sh "$LOCAL_IMAGE"
- name: Log in to GitHub Container Registry
if: ${{ env.ACT != 'true' }}
run: echo "${{ github.token }}" | docker login ghcr.io --username "${{ github.actor }}" --password-stdin
- name: Publish immutable and latest tags
if: ${{ env.ACT != 'true' }}
run: |
docker tag "$LOCAL_IMAGE" "$REGISTRY_IMAGE:sha-${GITHUB_SHA}"
docker tag "$LOCAL_IMAGE" "$REGISTRY_IMAGE:latest"
docker push "$REGISTRY_IMAGE:sha-${GITHUB_SHA}"
docker push "$REGISTRY_IMAGE:latest"
-28
View File
@@ -1,28 +0,0 @@
# Changes — 2026-07-03
## PR #5984 Review And Dev Merge Fixes
- **Why**: Make `feat/widget-store` mergeable with `dev` and address CodeRabbit review feedback before requesting another review.
- **What**: Merged `origin/dev`, resolved `apps/docs/package.json` and `pnpm-lock.yaml`, and fixed workshop vote state handling, 404-only vote recovery, PocketBase filename URL encoding, required widget `$schema` validation, install-button localization, public author email fallback, placeholder product copy, and workshop safety/submission guidance.
- **Files**: `apps/docs/src/components/store/useStore.ts`, `apps/docs/src/lib/pocketbase.ts`, `apps/docs/src/lib/store-schema.ts`, `apps/docs/src/lib/store-schema.spec.ts`, `apps/nextjs/src/components/store-install/install-from-store-buttons.tsx`, `packages/translation/src/lang/en.json`, `apps/store/pb_migrations/1750000002_marketplace_view.js`, `apps/docs/docs/management/store/index.mdx`, `PRODUCT.md`, `pnpm-lock.yaml`, `apps/docs/package.json`.
- **Tests**: `pnpm lint` passed with warnings, `pnpm typecheck` passed, `pnpm exec vitest run apps/docs/src/lib/store-schema.spec.ts` passed. Full `pnpm test` failed on unrelated timeout/external-dependency tests: Immich icon fetch, Pi-hole container, MySQL migration, and Nextcloud hook.
- **Docs**: Updated workshop management docs with a stronger safety warning and submission guidelines.
- **Risks**: The commit includes a large required `dev` merge, so review should distinguish upstream merge changes from the scoped CodeRabbit fixes.
## PR #5984 Additional CodeRabbit Fixes
- **Why**: Resolve the remaining actionable CodeRabbit comments before requesting Copilot review.
- **What**: Commented out opt-in example env flags, pinned the PocketBase image, ignored store-local env files, documented PocketBase admin bootstrap vars, enabled GitHub OAuth only when credentials are present, indexed `comments.submission`, added reduced-motion handling for submit-step animation, added accessible comment action labels, used created vote records directly on detail pages, tracked copy-failure timers for cleanup, prevented screenshot carousel controls from navigating card links, and normalized the `useStore` utils import alias.
- **Files**: `.env.example`, `apps/store/docker-compose.yml`, `apps/store/.gitignore`, `apps/store/README.md`, `apps/store/pb_migrations/1750000000_init_store.js`, `apps/docs/src/css/custom.css`, `apps/docs/src/components/store/DetailPage.tsx`, `apps/docs/src/components/store/StoreApp.tsx`, `apps/docs/src/components/store/useStore.ts`.
- **Tests**: `pnpm --filter @homarr/docs typecheck`, `pnpm --filter @homarr/docs lint`, `pnpm --filter nextjs typecheck`, and `pnpm exec vitest run apps/docs/src/lib/store-schema.spec.ts` passed; docs lint still reports pre-existing warnings outside this fix set.
- **Docs**: Updated `apps/store/README.md` with admin bootstrap variables.
- **Risks**: PocketBase image pin assumes `ghcr.io/muchobien/pocketbase:0.26.2` remains the intended runtime version for this new store service.
## PR #5984 Final Review Stability Fix
- **Why**: Address CodeRabbit's remaining outside-diff stability comments before the final push.
- **What**: Updated `useStore` to await PocketBase auth refresh before loading workshop data and to return `null` from `requireUserId` on auth failures, allowing vote lock cleanup to run.
- **Files**: `apps/docs/src/components/store/useStore.ts`, `.opencode/changelog/2026-07-03.md`.
- **Tests**: `pnpm --filter @homarr/docs typecheck`, `pnpm --filter @homarr/docs lint`, and `pnpm exec vitest run apps/docs/src/lib/store-schema.spec.ts` passed; docs lint still reports pre-existing warnings outside this fix.
- **Docs**: Changelog updated only.
- **Risks**: Low; the change preserves existing auth refresh behavior but sequences initial loading after auth state is known.
-9
View File
@@ -1,9 +0,0 @@
# Changes — 2026-07-06
## Workshop Review Cleanup
- **Why**: Address PR review feedback to use Day.js for date/time logic, split the oversized detail component, and avoid duplicated workshop helpers.
- **What**: Added shared Day.js relative-time formatting, shared download/vote helpers, extracted detail comments and detail UI sections into separate components, and recorded the Day.js/shared-helper convention in `AGENTS.md`.
- **Files**: `apps/docs/src/components/store/DetailPage.tsx`, `apps/docs/src/components/store/StoreApp.tsx`, `apps/docs/src/components/store/useStore.ts`, `apps/docs/src/components/store/DetailComments.tsx`, `apps/docs/src/components/store/DetailSections.tsx`, `apps/docs/src/components/store/format.ts`, `apps/docs/src/components/store/store-utils.ts`, `AGENTS.md`
- **Tests**: `pnpm --filter @homarr/docs typecheck`; `pnpm --filter @homarr/docs lint`; targeted `oxfmt --check` for changed store files.
- **Docs**: Updated `AGENTS.md` with date/time and shared-helper conventions.
- **Risks**: Refactor moves code without intentional behavior changes; UI smoke testing in browser not run.
-2
View File
@@ -81,5 +81,3 @@ When modifying user-facing code, you MUST also update the corresponding document
- UI: Mantine (not Tailwind) — Tailwind is only used in docs app
- Icons: @tabler/icons-react
- Docs app can import from `@homarr/definitions` for shared types
- Use `dayjs` for date/time formatting or comparisons; do not hand-roll relative time or date math.
- Extract shared helpers/components when the same logic appears in multiple workshop/store files.
+18 -11
View File
@@ -1,24 +1,31 @@
# syntax=docker/dockerfile:1.10
FROM node:24.18.0-alpine AS base
FROM base AS builder
RUN apk add --no-cache libc6-compat
RUN apk update
# Set working directory
WORKDIR /app
RUN apk add --no-cache libc6-compat curl bash
RUN apk update
RUN apk add --no-cache libc6-compat curl bash && apk update
RUN corepack enable pnpm
COPY pnpm-lock.yaml pnpm-workspace.yaml package.json ./
COPY patches ./patches
RUN --mount=type=cache,id=pnpm-store,target=/pnpm/store \
pnpm config set store-dir /pnpm/store && pnpm fetch
COPY . .
RUN --mount=type=cache,id=pnpm-store,target=/pnpm/store \
pnpm install --recursive --offline --frozen-lockfile
RUN corepack enable pnpm && pnpm install --recursive --frozen-lockfile
# Copy static data as it is not part of the build
COPY static-data ./static-data
ARG SKIP_ENV_VALIDATION='true'
ARG CI='true'
ARG DISABLE_REDIS_LOGS='true'
ARG TARGETPLATFORM
RUN corepack enable pnpm && pnpm build
RUN --mount=type=secret,id=TURBO_API,env=TURBO_API \
--mount=type=secret,id=TURBO_TEAM,env=TURBO_TEAM \
--mount=type=secret,id=TURBO_TOKEN,env=TURBO_TOKEN \
--mount=type=secret,id=TURBO_REMOTE_CACHE_SIGNATURE_KEY,env=TURBO_REMOTE_CACHE_SIGNATURE_KEY \
TURBO_PLATFORM="${TARGETPLATFORM:-linux/amd64}" pnpm build
FROM base AS runner
WORKDIR /app
-35
View File
@@ -1,35 +0,0 @@
# Product
## Register
GitHub OAuth is used for workshop accounts so community members can submit, vote on, comment on, and report shared widgets or CSS themes without creating a separate Homarr-specific identity.
## Users
Self-hosting enthusiasts managing homelab services. Technical users comfortable with Docker, APIs, and configuration files. They use Homarr as a personal dashboard to monitor and control 50+ services (media servers, DNS, NAS, download clients). Context: desktop browser, often on a local network, task-focused.
## Product Purpose
Homarr is a self-hosted dashboard for homelab management. It aggregates service status, media libraries, system stats, and administrative controls into a single drag-and-drop interface with 39+ widgets. The marketplace extension lets users share and install custom widgets and CSS themes created by the community.
## Brand Personality
Practical, capable, community-driven.
## Anti-references
- Generic SaaS landing pages with gradient hero sections and metric grids
- Overly corporate enterprise dashboards (ServiceNow, Salesforce)
- Cluttered "hacker" UIs with no visual hierarchy
## Design Principles
1. **Tool disappears into the task.** The interface should feel invisible; users came to manage their homelab, not admire the dashboard.
2. **Density when it earns it.** Show useful information compactly; don't spread thin content across oversized cards.
3. **Community first.** Sharing, voting, and installing should feel effortless and trustworthy.
4. **Consistency across surfaces.** The marketplace in the docs app should feel like it belongs to the same product as the main Next.js app.
5. **Respect the user's time.** Fast loads, instant feedback, no choreographed animations.
## Accessibility & Inclusion
WCAG 2.1 AA. Support for reduced motion. Sufficient contrast ratios (4.5:1 body text, 3:1 large text). Keyboard navigable.
-24
View File
@@ -1,24 +0,0 @@
{
"$schema": "https://ui.shadcn.com/schema.json",
"style": "base-nova",
"rsc": false,
"tsx": true,
"tailwind": {
"config": "",
"css": "src/css/custom.css",
"baseColor": "mist",
"cssVariables": true,
"prefix": ""
},
"aliases": {
"components": "@/components",
"utils": "@/lib/utils",
"ui": "@/components/ui",
"lib": "@/lib",
"hooks": "@/hooks"
},
"iconLibrary": "tabler",
"rtl": false,
"menuColor": "default",
"menuAccent": "subtle"
}
@@ -82,6 +82,10 @@ Custom widgets can be exported and imported as JSON files. To export, select the
The import format uses `"$schema": "homarr-custom-widget-v2"` to identify the widget version.
## Community Workshop
Use **Install widget** on **Management → Workshop** to inspect and import community widgets. Use **Share in Workshop** from a widget's action menu to publish its canonical export without manually copying JSON. Secrets are never included in exports and must be configured on each Homarr instance.
## Available Mantine Components for Custom JSX
When using the `customJsx` display type, your template has access to a whitelist of [Mantine v9](https://mantine.dev) components. Unrecognized components, event handlers, and dangerous tags are stripped for security.
+16 -35
View File
@@ -7,53 +7,34 @@ tags:
# Community Workshop
The [Community Workshop](pathname:///workshop) is where Homarr users share and discover
**custom CSS themes** and **custom widgets**. You can browse submissions, upvote the ones
you like, report abuse, and install entries directly into your own Homarr instance.
The [Workshop](pathname:///workshop) is Homarr's community catalog for custom widgets and CSS themes. You can inspect every creation before downloading it or using it in Homarr.
:::warning
:::warning Community content
The workshop contains community-made content. Widgets may contain unwanted or malicious content and it is your duty to check & verify safety of the content. While we try to remove malicious content as best as we can, we take no responsibility. Always review the widget JSON and all CSS before installing it on your instance and take frequent backups as prevention measures.
Workshop entries are created by community members. Review widget endpoints, templates, and CSS before using them. Keep a current Homarr backup and never add secrets to an exported widget.
:::
## Browsing and voting
## Discover and install
- Filter by **Widgets** or **CSS**, and sort by **Top rated** or **Newest**.
- Sign in with **GitHub** to upvote/downvote, submit your own creations, or report a submission.
- Each submission shows its title, description, author, version, an optional screenshot, and the full content.
In Homarr, open **Management → Workshop** to browse, search, filter, inspect, download, and install creations. The custom-widget management page and a board's custom CSS settings also provide contextual Workshop actions.
## Submitting
- Widgets are validated against `homarr-custom-widget-v2` before installation. API keys and other secrets are not included and must be configured locally.
- CSS is loaded into the board editor for review. It is not saved until you choose **Save changes**.
- The public website never executes submitted widget templates or CSS.
1. Sign in with GitHub.
2. Click **Submit** and choose the type:
- **Custom widget** — paste the JSON exported from **Manage → Custom Widgets → Export**.
It must match the `homarr-custom-widget-v2` schema (validated before submission).
- **Custom CSS** — paste your stylesheet (max 16384 characters).
3. Add a title, description, optional screenshot, and changelog, then submit.
## Share a creation
Submissions are published immediately. Reported entries are reviewed and may be removed.
Use **Share in Workshop** beside a custom widget or in a board's custom CSS settings. Sign in with GitHub, provide a public title and description, inspect the exported source, and publish it. You can also publish from the Workshop website by pasting an exported widget or CSS source.
## Submission guidelines
Submissions are public immediately. Authors can update or delete their own submissions. Updating content increases its revision and shows the supplied changelog.
Submissions must be useful to Homarr users and safe to install. Do not submit content that is
malicious, deceptive, illegal, hateful, sexually explicit, gore, gambling-focused, spam, or content
that primarily promotes unrelated products or services.
## Votes and reports
## Installing into Homarr
Signed-in members can vote to help useful creations rank higher. Report malicious content, spam, copyright problems, or inappropriate material from a submission's detail page. Reports are visible only to Workshop staff.
You can install workshop entries without leaving Homarr:
## Accounts and moderation
- **Custom widgets** — go to **Manage → Custom Widgets**, click **Install from Workshop**, pick an
entry, and it is imported (secrets such as API keys must be re-entered afterwards).
- **Custom CSS** — open a board's **Settings → Custom CSS**, click **Install from Workshop**, and
pick a theme to load it into the editor before saving.
Workshop accounts use GitHub OAuth and are separate from users in a self-hosted Homarr instance. Workshop moderators can remove submissions, prevent an account from publishing, disable an account, and resolve reports. These actions require a reason and are recorded.
Alternatively, use **Copy** (or **Download** for widgets) on any submission and import it
manually.
## Workshop deployment
Workshop is powered by [PocketBase](https://pocketbase.io). The production image compiles the current Docusaurus site and serves it together with the API from one origin. The deployment lives in `apps/workshop/` with a Dockerfile, Compose configuration, version-controlled migrations, hooks, a smoke test, and ACT-local workflow instructions.
Homarr instances use `NEXT_PUBLIC_WORKSHOP_API_URL` to reach the central API and `NEXT_PUBLIC_WORKSHOP_URL` for links to the public page. Docusaurus uses `WORKSHOP_URL` only when its development server runs separately; the combined production image always uses its own origin.
The Workshop is a central service operated by Homarr at `store.homarr.dev`. Running the backend locally is supported for contributors developing or testing this feature, not as a private production marketplace.
+7 -14
View File
@@ -16,15 +16,13 @@ const config: Config = {
defaultLocale: "en",
locales: ["en"],
},
customFields: {
workshopApiUrl: process.env.NEXT_PUBLIC_WORKSHOP_API_URL ?? "https://store.homarr.dev",
},
onBrokenLinks: "throw",
onBrokenAnchors: "throw",
onDuplicateRoutes: "throw",
customFields: {
// Empty means same-origin, which is the production Workshop image shape.
workshopUrl: process.env.WORKSHOP_URL ?? "",
},
future: {
v4: {
removeLegacyPostBuildHeadAttribute: true,
@@ -288,12 +286,7 @@ const config: Config = {
return {
name: "resolve-homarr-packages",
configureWebpack() {
return {
resolve: {
symlinks: false,
alias: { "@": require("path").resolve(__dirname, "src") },
},
};
return { resolve: { symlinks: false } };
},
};
},
@@ -324,13 +317,13 @@ const config: Config = {
},
};
},
function workshopDetailRoutePlugin() {
function workshopRoutesPlugin() {
return {
name: "workshop-detail-route",
name: "workshop-routes",
async contentLoaded({ actions }) {
actions.addRoute({
path: "/workshop/:id",
component: "@site/src/components/workshop/DetailPage",
component: "@site/src/components/workshop/WorkshopDetailRoutePage",
exact: true,
});
},
+4 -7
View File
@@ -21,25 +21,23 @@
"verify:search": "node ./scripts/verify-algolia-search.mjs"
},
"dependencies": {
"@base-ui/react": "catalog:",
"@docusaurus/core": "^3.10.1",
"@docusaurus/faster": "^3.10.1",
"@docusaurus/plugin-content-docs": "^3.10.1",
"@docusaurus/plugin-sitemap": "^3.10.1",
"@docusaurus/preset-classic": "^3.10.1",
"@homarr/definitions": "workspace:^",
"@homarr/workshop": "workspace:^0.1.0",
"@mdx-js/react": "^3.1.1",
"@nivo/line": "^0.99.0",
"@scalar/api-reference-react": "catalog:",
"@signalwire/docusaurus-plugin-llms-txt": "^1.2.2",
"@tabler/icons-react": "catalog:",
"@tailwindcss/postcss": "^4.3.2",
"class-variance-authority": "catalog:",
"clsx": "catalog:",
"dayjs": "catalog:",
"docusaurus-plugin-image-zoom": "^3.0.1",
"leader-line-new": "^1.1.9",
"pocketbase": "catalog:",
"postcss": "^8.5.16",
"posthog-docusaurus": "^2.0.5",
"prism-react-renderer": "^2.4.1",
@@ -47,9 +45,7 @@
"react": "catalog:",
"react-dom": "catalog:",
"swiper": "^14.0.2",
"tailwind-merge": "catalog:",
"tailwindcss": "^4.3.2",
"zod": "catalog:"
"tailwindcss": "^4.3.2"
},
"devDependencies": {
"@docusaurus/module-type-aliases": "^3.10.1",
@@ -60,7 +56,8 @@
"@types/react": "catalog:",
"@types/react-dom": "catalog:",
"cheerio": "^1.2.0",
"typescript": "catalog:"
"typescript": "catalog:",
"zod": "catalog:"
},
"peerDependencies": {
"@docusaurus/theme-common": "^3.10.1",
+10 -46
View File
@@ -1,49 +1,13 @@
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 { ReactNode } from "react";
import { cn } from "@/lib/utils";
const badgeVariants = cva(
"group/badge inline-flex h-5 w-fit shrink-0 items-center justify-center gap-1 overflow-hidden rounded-4xl border border-transparent px-2 py-0.5 text-xs font-medium whitespace-nowrap transition-all focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 has-data-[icon=inline-end]:pr-1.5 has-data-[icon=inline-start]:pl-1.5 aria-invalid:border-destructive aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 [&>svg]:pointer-events-none [&>svg]:size-3!",
{
variants: {
variant: {
default: "bg-primary text-primary-foreground [a]:hover:bg-primary/80",
secondary: "bg-secondary text-secondary-foreground [a]:hover:bg-secondary/80",
destructive:
"bg-destructive/10 text-destructive focus-visible:ring-destructive/20 dark:bg-destructive/20 dark:focus-visible:ring-destructive/40 [a]:hover:bg-destructive/20",
outline: "border-border text-foreground [a]:hover:bg-muted [a]:hover:text-muted-foreground",
ghost: "hover:bg-muted hover:text-muted-foreground dark:hover:bg-muted/50",
link: "text-primary underline-offset-4 hover:underline",
},
},
defaultVariants: {
variant: "default",
},
},
);
function Badge({
className,
variant = "default",
render,
...props
}: useRender.ComponentProps<"span"> & VariantProps<typeof badgeVariants>) {
return useRender({
defaultTagName: "span",
props: mergeProps<"span">(
{
className: cn(badgeVariants({ variant }), className),
},
props,
),
render,
state: {
slot: "badge",
variant,
},
});
interface BadgeProps {
children: ReactNode;
}
export { Badge, badgeVariants };
export const Badge = ({ children }: BadgeProps) => {
return (
<span className="inline-flex items-center justify-center rounded-md border px-2 py-0.5 text-xs font-medium w-fit whitespace-nowrap overflow-hidden border-transparent dark:bg-slate-700 bg-slate-200">
{children}
</span>
);
};
-50
View File
@@ -1,50 +0,0 @@
import { Button as ButtonPrimitive } from "@base-ui/react/button";
import { cva, type VariantProps } from "class-variance-authority";
import { cn } from "@/lib/utils";
const buttonVariants = cva(
"group/button inline-flex shrink-0 items-center justify-center rounded-lg border border-transparent bg-clip-padding text-sm font-medium whitespace-nowrap transition-all outline-none select-none focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50 active:not-aria-[haspopup]:translate-y-px disabled:pointer-events-none disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-3 aria-invalid:ring-destructive/20 dark:aria-invalid:border-destructive/50 dark:aria-invalid:ring-destructive/40 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
{
variants: {
variant: {
default: "bg-primary text-primary-foreground hover:bg-primary/80",
outline:
"border-border bg-background hover:bg-muted hover:text-foreground aria-expanded:bg-muted aria-expanded:text-foreground dark:border-input dark:bg-input/30 dark:hover:bg-input/50",
secondary:
"bg-secondary text-secondary-foreground hover:bg-[color-mix(in_oklch,var(--secondary),var(--foreground)_5%)] aria-expanded:bg-secondary aria-expanded:text-secondary-foreground",
ghost:
"hover:bg-muted hover:text-foreground aria-expanded:bg-muted aria-expanded:text-foreground dark:hover:bg-muted/50",
destructive:
"bg-destructive/10 text-destructive hover:bg-destructive/20 focus-visible:border-destructive/40 focus-visible:ring-destructive/20 dark:bg-destructive/20 dark:hover:bg-destructive/30 dark:focus-visible:ring-destructive/40",
link: "text-primary underline-offset-4 hover:underline",
},
size: {
default: "h-8 gap-1.5 px-2.5 has-data-[icon=inline-end]:pr-2 has-data-[icon=inline-start]:pl-2",
xs: "h-6 gap-1 rounded-[min(var(--radius-md),10px)] px-2 text-xs in-data-[slot=button-group]:rounded-lg has-data-[icon=inline-end]:pr-1.5 has-data-[icon=inline-start]:pl-1.5 [&_svg:not([class*='size-'])]:size-3",
sm: "h-7 gap-1 rounded-[min(var(--radius-md),12px)] px-2.5 text-[0.8rem] in-data-[slot=button-group]:rounded-lg has-data-[icon=inline-end]:pr-1.5 has-data-[icon=inline-start]:pl-1.5 [&_svg:not([class*='size-'])]:size-3.5",
lg: "h-9 gap-1.5 px-2.5 has-data-[icon=inline-end]:pr-2 has-data-[icon=inline-start]:pl-2",
icon: "size-8",
"icon-xs":
"size-6 rounded-[min(var(--radius-md),10px)] in-data-[slot=button-group]:rounded-lg [&_svg:not([class*='size-'])]:size-3",
"icon-sm": "size-7 rounded-[min(var(--radius-md),12px)] in-data-[slot=button-group]:rounded-lg",
"icon-lg": "size-9",
},
},
defaultVariants: {
variant: "default",
size: "default",
},
},
);
function Button({
className,
variant = "default",
size = "default",
...props
}: ButtonPrimitive.Props & VariantProps<typeof buttonVariants>) {
return <ButtonPrimitive data-slot="button" className={cn(buttonVariants({ variant, size, className }))} {...props} />;
}
export { Button, buttonVariants };
-70
View File
@@ -1,70 +0,0 @@
import * as React from "react";
import { cn } from "@/lib/utils";
function Card({ className, size = "default", ...props }: React.ComponentProps<"div"> & { size?: "default" | "sm" }) {
return (
<div
data-slot="card"
data-size={size}
className={cn(
"group/card flex break-inside-avoid flex-col gap-(--card-spacing) overflow-hidden rounded-xl bg-card py-(--card-spacing) text-sm text-card-foreground ring-1 ring-foreground/10 [--card-spacing:--spacing(4)] has-data-[slot=card-footer]:pb-0 has-[>img:first-child]:pt-0 data-[size=sm]:[--card-spacing:--spacing(3)] data-[size=sm]:has-data-[slot=card-footer]:pb-0 *:[img:first-child]:rounded-t-xl *:[img:last-child]:rounded-b-xl",
className,
)}
{...props}
/>
);
}
function CardHeader({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card-header"
className={cn(
"group/card-header @container/card-header grid auto-rows-min items-start gap-1 rounded-t-xl px-(--card-spacing) has-data-[slot=card-action]:grid-cols-[1fr_auto] has-data-[slot=card-description]:grid-rows-[auto_auto] [.border-b]:pb-(--card-spacing)",
className,
)}
{...props}
/>
);
}
function CardTitle({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card-title"
className={cn("font-heading text-base leading-snug font-medium group-data-[size=sm]/card:text-sm", className)}
{...props}
/>
);
}
function CardDescription({ className, ...props }: React.ComponentProps<"div">) {
return <div data-slot="card-description" className={cn("text-sm text-muted-foreground", className)} {...props} />;
}
function CardAction({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card-action"
className={cn("col-start-2 row-span-2 row-start-1 self-start justify-self-end", className)}
{...props}
/>
);
}
function CardContent({ className, ...props }: React.ComponentProps<"div">) {
return <div data-slot="card-content" className={cn("px-(--card-spacing)", className)} {...props} />;
}
function CardFooter({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card-footer"
className={cn("flex items-center rounded-b-xl border-t border-border bg-muted/50 p-(--card-spacing)", className)}
{...props}
/>
);
}
export { Card, CardHeader, CardFooter, CardTitle, CardAction, CardDescription, CardContent };
-134
View File
@@ -1,134 +0,0 @@
"use client";
import * as React from "react";
import { Dialog as DialogPrimitive } from "@base-ui/react/dialog";
import { cn } from "@/lib/utils";
import { Button } from "@/components/ui/button";
import { IconX } from "@tabler/icons-react";
function Dialog({ ...props }: DialogPrimitive.Root.Props) {
return <DialogPrimitive.Root data-slot="dialog" {...props} />;
}
function DialogTrigger({ ...props }: DialogPrimitive.Trigger.Props) {
return <DialogPrimitive.Trigger data-slot="dialog-trigger" {...props} />;
}
function DialogPortal({ ...props }: DialogPrimitive.Portal.Props) {
return <DialogPrimitive.Portal data-slot="dialog-portal" {...props} />;
}
function DialogClose({ ...props }: DialogPrimitive.Close.Props) {
return <DialogPrimitive.Close data-slot="dialog-close" {...props} />;
}
function DialogOverlay({ className, ...props }: DialogPrimitive.Backdrop.Props) {
return (
<DialogPrimitive.Backdrop
data-slot="dialog-overlay"
className={cn(
"fixed inset-0 isolate z-50 bg-black/10 duration-100 supports-backdrop-filter:backdrop-blur-xs data-open:animate-in data-open:fade-in-0 data-closed:animate-out data-closed:fade-out-0",
className,
)}
{...props}
/>
);
}
function DialogContent({
className,
children,
showCloseButton = true,
...props
}: DialogPrimitive.Popup.Props & {
showCloseButton?: boolean;
}) {
return (
<DialogPortal>
<DialogOverlay />
<DialogPrimitive.Popup
data-slot="dialog-content"
className={cn(
"fixed top-1/2 left-1/2 z-50 grid w-full max-w-[calc(100%-2rem)] -translate-x-1/2 -translate-y-1/2 gap-4 rounded-xl bg-popover p-4 text-sm text-popover-foreground ring-1 ring-foreground/10 duration-100 outline-none sm:max-w-sm 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}
>
{children}
{showCloseButton && (
<DialogPrimitive.Close
data-slot="dialog-close"
render={<Button variant="ghost" className="absolute top-2 right-2" size="icon-sm" />}
>
<IconX />
<span className="sr-only">Close</span>
</DialogPrimitive.Close>
)}
</DialogPrimitive.Popup>
</DialogPortal>
);
}
function DialogHeader({ className, ...props }: React.ComponentProps<"div">) {
return <div data-slot="dialog-header" className={cn("flex flex-col gap-2", className)} {...props} />;
}
function DialogFooter({
className,
showCloseButton = false,
children,
...props
}: React.ComponentProps<"div"> & {
showCloseButton?: boolean;
}) {
return (
<div
data-slot="dialog-footer"
className={cn(
"-mx-4 -mb-4 flex flex-col-reverse gap-2 rounded-b-xl border-t bg-muted/50 p-4 sm:flex-row sm:justify-end",
className,
)}
{...props}
>
{children}
{showCloseButton && <DialogPrimitive.Close render={<Button variant="outline" />}>Close</DialogPrimitive.Close>}
</div>
);
}
function DialogTitle({ className, ...props }: DialogPrimitive.Title.Props) {
return (
<DialogPrimitive.Title
data-slot="dialog-title"
className={cn("font-heading text-base leading-none font-medium", className)}
{...props}
/>
);
}
function DialogDescription({ className, ...props }: DialogPrimitive.Description.Props) {
return (
<DialogPrimitive.Description
data-slot="dialog-description"
className={cn(
"text-sm text-muted-foreground *:[a]:underline *:[a]:underline-offset-3 *:[a]:hover:text-foreground",
className,
)}
{...props}
/>
);
}
export {
Dialog,
DialogClose,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogOverlay,
DialogPortal,
DialogTitle,
DialogTrigger,
};
+2 -2
View File
@@ -1,4 +1,4 @@
import type { ReactNode } from "react";
import { ReactNode } from "react";
import { Badge } from "../ui/badge";
interface DocsHeaderProps {
@@ -18,7 +18,7 @@ export const DocsHeader = (props: DocsHeaderProps) => {
<h1 className="!mb-0 text-4xl">{props.title}</h1>
<div className="flex gap-2">
{props.categories.map((category) => (
<Badge key={category}>{category}</Badge>
<Badge>{category}</Badge>
))}
</div>
</div>
-20
View File
@@ -1,20 +0,0 @@
import * as React from "react";
import { Input as InputPrimitive } from "@base-ui/react/input";
import { cn } from "@/lib/utils";
function Input({ className, type, ...props }: React.ComponentProps<"input">) {
return (
<InputPrimitive
type={type}
data-slot="input"
className={cn(
"h-8 w-full min-w-0 rounded-lg border border-input bg-transparent px-2.5 py-1 text-base transition-colors outline-none file:inline-flex file:h-6 file:border-0 file:bg-transparent file:text-sm file:font-medium file:text-foreground placeholder:text-muted-foreground focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50 disabled:pointer-events-none disabled:cursor-not-allowed disabled:bg-input/50 disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-3 aria-invalid:ring-destructive/20 md:text-sm dark:bg-input/30 dark:disabled:bg-input/80 dark:aria-invalid:border-destructive/50 dark:aria-invalid:ring-destructive/40",
className,
)}
{...props}
/>
);
}
export { Input };
-167
View File
@@ -1,167 +0,0 @@
"use client";
import * as React from "react";
import { Select as SelectPrimitive } from "@base-ui/react/select";
import { cn } from "@/lib/utils";
import { IconSelector, IconCheck, IconChevronUp, IconChevronDown } from "@tabler/icons-react";
const Select = SelectPrimitive.Root;
function SelectGroup({ className, ...props }: SelectPrimitive.Group.Props) {
return <SelectPrimitive.Group data-slot="select-group" className={cn("scroll-my-1 p-1", className)} {...props} />;
}
function SelectValue({ className, ...props }: SelectPrimitive.Value.Props) {
return (
<SelectPrimitive.Value data-slot="select-value" className={cn("flex flex-1 text-left", className)} {...props} />
);
}
function SelectTrigger({
className,
size = "default",
children,
...props
}: SelectPrimitive.Trigger.Props & {
size?: "sm" | "default";
}) {
return (
<SelectPrimitive.Trigger
data-slot="select-trigger"
data-size={size}
className={cn(
"flex w-fit items-center justify-between gap-1.5 rounded-lg border border-input bg-transparent py-2 pr-2 pl-2.5 text-sm whitespace-nowrap transition-colors outline-none select-none 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 data-placeholder:text-muted-foreground data-[size=default]:h-8 data-[size=sm]:h-7 data-[size=sm]:rounded-[min(var(--radius-md),10px)] *:data-[slot=select-value]:line-clamp-1 *:data-[slot=select-value]:flex *:data-[slot=select-value]:items-center *:data-[slot=select-value]:gap-1.5 dark:bg-input/30 dark:hover:bg-input/50 dark:aria-invalid:border-destructive/50 dark:aria-invalid:ring-destructive/40 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
className,
)}
{...props}
>
{children}
<SelectPrimitive.Icon render={<IconSelector className="pointer-events-none size-4 text-muted-foreground" />} />
</SelectPrimitive.Trigger>
);
}
function SelectContent({
className,
children,
side = "bottom",
sideOffset = 4,
align = "center",
alignOffset = 0,
alignItemWithTrigger = true,
...props
}: SelectPrimitive.Popup.Props &
Pick<SelectPrimitive.Positioner.Props, "align" | "alignOffset" | "side" | "sideOffset" | "alignItemWithTrigger">) {
return (
<SelectPrimitive.Portal>
<SelectPrimitive.Positioner
side={side}
sideOffset={sideOffset}
align={align}
alignOffset={alignOffset}
alignItemWithTrigger={alignItemWithTrigger}
className="isolate z-50"
>
<SelectPrimitive.Popup
data-slot="select-content"
data-align-trigger={alignItemWithTrigger}
className={cn(
"relative isolate z-50 max-h-(--available-height) w-(--anchor-width) min-w-36 origin-(--transform-origin) overflow-x-hidden overflow-y-auto rounded-lg bg-popover text-popover-foreground shadow-md ring-1 ring-foreground/10 duration-100 data-[align-trigger=true]:animate-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}
>
<SelectScrollUpButton />
<SelectPrimitive.List>{children}</SelectPrimitive.List>
<SelectScrollDownButton />
</SelectPrimitive.Popup>
</SelectPrimitive.Positioner>
</SelectPrimitive.Portal>
);
}
function SelectLabel({ className, ...props }: SelectPrimitive.GroupLabel.Props) {
return (
<SelectPrimitive.GroupLabel
data-slot="select-label"
className={cn("px-1.5 py-1 text-xs text-muted-foreground", className)}
{...props}
/>
);
}
function SelectItem({ className, children, ...props }: SelectPrimitive.Item.Props) {
return (
<SelectPrimitive.Item
data-slot="select-item"
className={cn(
"relative flex w-full 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 not-data-[variant=destructive]:focus:**: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 *:[span]:last:flex *:[span]:last:items-center *:[span]:last:gap-2",
className,
)}
{...props}
>
<SelectPrimitive.ItemText className="flex flex-1 shrink-0 gap-2 whitespace-nowrap">
{children}
</SelectPrimitive.ItemText>
<SelectPrimitive.ItemIndicator
render={<span className="pointer-events-none absolute right-2 flex size-4 items-center justify-center" />}
>
<IconCheck className="pointer-events-none" />
</SelectPrimitive.ItemIndicator>
</SelectPrimitive.Item>
);
}
function SelectSeparator({ className, ...props }: SelectPrimitive.Separator.Props) {
return (
<SelectPrimitive.Separator
data-slot="select-separator"
className={cn("pointer-events-none -mx-1 my-1 h-px bg-border", className)}
{...props}
/>
);
}
function SelectScrollUpButton({ className, ...props }: React.ComponentProps<typeof SelectPrimitive.ScrollUpArrow>) {
return (
<SelectPrimitive.ScrollUpArrow
data-slot="select-scroll-up-button"
className={cn(
"top-0 z-10 flex w-full cursor-default items-center justify-center bg-popover py-1 [&_svg:not([class*='size-'])]:size-4",
className,
)}
{...props}
>
<IconChevronUp />
</SelectPrimitive.ScrollUpArrow>
);
}
function SelectScrollDownButton({ className, ...props }: React.ComponentProps<typeof SelectPrimitive.ScrollDownArrow>) {
return (
<SelectPrimitive.ScrollDownArrow
data-slot="select-scroll-down-button"
className={cn(
"bottom-0 z-10 flex w-full cursor-default items-center justify-center bg-popover py-1 [&_svg:not([class*='size-'])]:size-4",
className,
)}
{...props}
>
<IconChevronDown />
</SelectPrimitive.ScrollDownArrow>
);
}
export {
Select,
SelectContent,
SelectGroup,
SelectItem,
SelectLabel,
SelectScrollDownButton,
SelectScrollUpButton,
SelectSeparator,
SelectTrigger,
SelectValue,
};
-19
View File
@@ -1,19 +0,0 @@
import { Separator as SeparatorPrimitive } from "@base-ui/react/separator";
import { cn } from "@/lib/utils";
function Separator({ className, orientation = "horizontal", ...props }: SeparatorPrimitive.Props) {
return (
<SeparatorPrimitive
data-slot="separator"
orientation={orientation}
className={cn(
"shrink-0 bg-border data-horizontal:h-px data-horizontal:w-full data-vertical:w-px data-vertical:self-stretch",
className,
)}
{...props}
/>
);
}
export { Separator };
-18
View File
@@ -1,18 +0,0 @@
import * as React from "react";
import { cn } from "@/lib/utils";
function Textarea({ className, ...props }: React.ComponentProps<"textarea">) {
return (
<textarea
data-slot="textarea"
className={cn(
"flex field-sizing-content min-h-16 w-full rounded-lg border border-input bg-transparent px-2.5 py-2 text-base transition-colors outline-none placeholder:text-muted-foreground focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50 disabled:cursor-not-allowed disabled:bg-input/50 disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-3 aria-invalid:ring-destructive/20 md:text-sm dark:bg-input/30 dark:disabled:bg-input/80 dark:aria-invalid:border-destructive/50 dark:aria-invalid:ring-destructive/40",
className,
)}
{...props}
/>
);
}
export { Textarea };
@@ -1,178 +0,0 @@
import React, { useEffect, useState } from "react";
import { IconCheck, IconEdit, IconSend, IconTrash, IconX } from "@tabler/icons-react";
import type { WorkshopComment } from "@site/src/lib/pocketbase";
import { getPocketBase } from "@site/src/lib/pocketbase";
import { errorMessage } from "@site/src/lib/utils";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { formatRelativeTime } from "./format";
const commentAuthorName = (comment: WorkshopComment) =>
comment.expand?.author?.name || comment.expand?.author?.username || "unknown";
interface CommentsSectionProps {
submissionId: string;
pb: ReturnType<typeof getPocketBase>;
currentUserId?: string;
onRequireAuth: (action: string) => Promise<string | null>;
onError: (message: string) => void;
}
export const CommentsSection = ({ submissionId, pb, currentUserId, onRequireAuth, onError }: CommentsSectionProps) => {
const [rows, setRows] = useState<WorkshopComment[]>([]);
const [loading, setLoading] = useState(true);
const [fetchError, setFetchError] = useState(false);
const [newComment, setNewComment] = useState("");
const [editingId, setEditingId] = useState<string | null>(null);
const [editContent, setEditContent] = useState("");
useEffect(() => {
let cancelled = false;
setLoading(true);
setFetchError(false);
pb.collection("comments")
.getFullList<WorkshopComment>({
filter: pb.filter("submission = {:id}", { id: submissionId }),
sort: "-created",
expand: "author",
})
.then((data) => {
if (!cancelled) setRows(data);
})
.catch(() => {
if (!cancelled) setFetchError(true);
})
.finally(() => {
if (!cancelled) setLoading(false);
});
return () => {
cancelled = true;
};
}, [pb, submissionId]);
const handleAdd = async () => {
const trimmed = newComment.trim();
if (!trimmed) return;
const userId = await onRequireAuth("comment");
if (!userId) return;
try {
const created = await pb
.collection("comments")
.create<WorkshopComment>({ submission: submissionId, content: trimmed, author: userId }, { expand: "author" });
setRows((prev) => [created, ...prev]);
setNewComment("");
} catch (caught) {
onError(errorMessage(caught, "Failed to post comment"));
}
};
const handleUpdate = async (id: string) => {
const trimmed = editContent.trim();
if (!trimmed) return;
try {
const updated = await pb
.collection("comments")
.update<WorkshopComment>(id, { content: trimmed }, { expand: "author" });
setRows((prev) => prev.map((comment) => (comment.id === id ? updated : comment)));
setEditingId(null);
} catch (caught) {
onError(errorMessage(caught, "Failed to update comment"));
}
};
const handleDelete = async (id: string) => {
try {
await pb.collection("comments").delete(id);
setRows((prev) => prev.filter((comment) => comment.id !== id));
} catch (caught) {
onError(errorMessage(caught, "Failed to delete comment"));
}
};
return (
<div className="flex flex-col gap-3">
<h2 className="text-sm font-medium">Comments</h2>
<div className="flex items-center gap-2">
<Input
className="flex-1"
placeholder="Write a comment…"
aria-label="Write a comment"
value={newComment}
onChange={(event) => setNewComment(event.target.value)}
onKeyDown={(event) => {
if (event.key === "Enter") void handleAdd();
}}
/>
<Button size="icon-sm" onClick={() => void handleAdd()} aria-label="Post comment">
<IconSend size={14} />
</Button>
</div>
{loading && <p className="py-4 text-center text-sm text-muted-foreground">Loading comments…</p>}
{!loading && fetchError && <p className="py-4 text-center text-sm text-destructive">Failed to load comments</p>}
{!loading && !fetchError && rows.length === 0 && (
<p className="py-4 text-center text-sm text-muted-foreground">No comments yet</p>
)}
<div className="space-y-2">
{rows.map((comment) => {
const isOwner = currentUserId === comment.author;
const isEditing = editingId === comment.id;
return (
<div key={comment.id} className="rounded-lg bg-muted/30 px-3 py-2 dark:bg-input/20">
<div className="flex items-center gap-2 text-xs">
<span className="font-medium">{commentAuthorName(comment)}</span>
<span className="text-muted-foreground/60">{formatRelativeTime(comment.created)}</span>
{isOwner && !isEditing && (
<div className="ml-auto flex gap-0.5">
<button
className="rounded p-1 hover:bg-accent"
aria-label="Edit comment"
onClick={() => {
setEditingId(comment.id);
setEditContent(comment.content);
}}
>
<IconEdit size={12} />
</button>
<button
className="rounded p-1 hover:bg-destructive/20 hover:text-destructive"
aria-label="Delete comment"
onClick={() => void handleDelete(comment.id)}
>
<IconTrash size={12} />
</button>
</div>
)}
</div>
{isEditing ? (
<div className="mt-2 flex items-center gap-1">
<Input
className="flex-1"
aria-label="Edit comment"
value={editContent}
onChange={(event) => setEditContent(event.target.value)}
onKeyDown={(event) => {
if (event.key === "Enter") void handleUpdate(comment.id);
}}
/>
<Button size="icon-sm" aria-label="Save comment" onClick={() => void handleUpdate(comment.id)}>
<IconCheck size={12} />
</Button>
<Button variant="ghost" size="icon-sm" aria-label="Cancel editing" onClick={() => setEditingId(null)}>
<IconX size={12} />
</Button>
</div>
) : (
<p className="mt-1 text-sm leading-relaxed text-muted-foreground">{comment.content}</p>
)}
</div>
);
})}
</div>
</div>
);
};
@@ -1,361 +0,0 @@
import React, { useCallback, useEffect, useMemo, useRef, useState } from "react";
import BrowserOnly from "@docusaurus/BrowserOnly";
import Link from "@docusaurus/Link";
import { useLocation } from "@docusaurus/router";
import useDocusaurusContext from "@docusaurus/useDocusaurusContext";
import Layout from "@theme/Layout";
import {
IconArrowBigDown,
IconArrowBigUp,
IconArrowLeft,
IconCheck,
IconCopy,
IconDownload,
IconX,
} from "@tabler/icons-react";
import type { ClientResponseError } from "pocketbase";
import type { WorkshopSubmission, WorkshopVote } from "@site/src/lib/pocketbase";
import { getPocketBase, getSubmissionFileUrl } from "@site/src/lib/pocketbase";
import type { SubmissionType } from "@site/src/lib/workshop-schema";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { cn, errorMessage } from "@site/src/lib/utils";
import { CommentsSection } from "./DetailComments";
import { CodeBlock, DeleteConfirmButton, DetailSkeleton, ScreenshotGallery } from "./DetailSections";
import { formatRelativeTime } from "./format";
import { downloadSubmissionJson, voteDelta } from "./workshop-utils";
const typeLabels: Record<SubmissionType, string> = { css: "CSS", widget: "Widget" };
const typeDotColors: Record<SubmissionType, string> = { css: "bg-blue-500", widget: "bg-yellow-500" };
const contentLanguages: Record<SubmissionType, string> = { css: "css", widget: "json" };
const copyState = [
{ Icon: IconCopy, label: "Copy", iconClass: "" },
{ Icon: IconCheck, label: "Copied!", iconClass: "text-green-500" },
] as const;
const scoreClassBySign = { positive: "text-foreground", negative: "text-destructive", neutral: "" } as const;
const scoreSign = (score: number): keyof typeof scoreClassBySign => {
if (score > 0) return "positive";
if (score < 0) return "negative";
return "neutral";
};
const parseSubmissionId = (pathname: string) => {
const segments = pathname.split("/").filter(Boolean);
const last = segments[segments.length - 1];
if (!last || last === "workshop") return null;
return last;
};
const isNotFound = (caught: unknown) =>
typeof caught === "object" && caught !== null && "status" in caught && (caught as ClientResponseError).status === 404;
const MarketplaceDetail = ({ workshopUrl }: { workshopUrl: string }) => {
const location = useLocation();
const submissionId = parseSubmissionId(location.pathname);
const pb = useMemo(() => getPocketBase(workshopUrl), [workshopUrl]);
const [submission, setSubmission] = useState<WorkshopSubmission | null>(null);
const [userVote, setUserVote] = useState<WorkshopVote | undefined>();
const [user, setUser] = useState(pb.authStore.record);
const [loading, setLoading] = useState(true);
const [notFound, setNotFound] = useState(false);
const [error, setError] = useState<string | null>(null);
const [copied, setCopied] = useState(false);
const [copyFailed, setCopyFailed] = useState(false);
const copyTimer = useRef<ReturnType<typeof setTimeout>>(null);
const copyFailedTimer = useRef<ReturnType<typeof setTimeout>>(null);
const voting = useRef(false);
const requireUserId = useCallback(
async (action: string) => {
if (!pb.authStore.isValid) {
try {
await pb.collection("users").authWithOAuth2({ provider: "github" });
} catch (caught) {
setError(errorMessage(caught, "Sign in failed"));
return null;
}
}
const userId = pb.authStore.record?.id;
if (!userId) {
setError(`Sign in to ${action}`);
return null;
}
return userId;
},
[pb],
);
useEffect(() => {
if (pb.authStore.isValid)
pb.collection("users")
.authRefresh()
.catch(() => pb.authStore.clear());
return pb.authStore.onChange(() => setUser(pb.authStore.record));
}, [pb]);
useEffect(
() => () => {
if (copyTimer.current) clearTimeout(copyTimer.current);
if (copyFailedTimer.current) clearTimeout(copyFailedTimer.current);
},
[],
);
useEffect(() => {
if (!submissionId) {
setNotFound(true);
setLoading(false);
return;
}
let cancelled = false;
setLoading(true);
setNotFound(false);
setError(null);
const load = async () => {
try {
const record = await pb.collection("marketplace").getOne<WorkshopSubmission>(submissionId);
if (cancelled) return;
setSubmission(record);
if (pb.authStore.isValid && pb.authStore.record) {
const votes = await pb.collection("votes").getFullList<WorkshopVote>({
filter: pb.filter("user = {:uid} && submission = {:sid}", {
uid: pb.authStore.record.id,
sid: submissionId,
}),
});
if (!cancelled) setUserVote(votes[0]);
}
} catch (caught) {
if (cancelled) return;
if (isNotFound(caught)) setNotFound(true);
else setError(errorMessage(caught, "Failed to load submission"));
} finally {
if (!cancelled) setLoading(false);
}
};
void load();
return () => {
cancelled = true;
};
}, [pb, submissionId]);
const screenshotUrls = useMemo(
() => submission?.screenshots?.map((f) => getSubmissionFileUrl(pb.baseURL, submission.id, f)) ?? [],
[submission, pb],
);
const handleVote = async (value: 1 | -1) => {
if (!submission || voting.current) return;
voting.current = true;
const userId = await requireUserId("vote");
if (!userId) {
voting.current = false;
return;
}
const prev = userVote;
const isToggleOff = prev?.value === value;
const [upD, downD] = voteDelta(prev?.value, value);
setUserVote(
isToggleOff
? undefined
: ({ ...(prev ?? { id: "", submission: submission.id, user: userId }), value } as WorkshopVote),
);
setSubmission((s) => (s ? { ...s, upvotes: s.upvotes + upD, downvotes: s.downvotes + downD } : s));
try {
if (!prev) {
const created = await pb
.collection("votes")
.create<WorkshopVote>({ submission: submission.id, value, user: userId });
setUserVote(created);
} else if (isToggleOff) {
await pb.collection("votes").delete(prev.id);
} else {
await pb.collection("votes").update(prev.id, { value });
}
} catch (caught) {
setUserVote(prev);
setSubmission((s) => (s ? { ...s, upvotes: s.upvotes - upD, downvotes: s.downvotes - downD } : s));
setError(errorMessage(caught, "Failed to register your vote"));
} finally {
voting.current = false;
}
};
const handleDelete = async () => {
if (!submission) return;
try {
await pb.collection("submissions").delete(submission.id);
window.location.href = "/workshop/";
} catch (caught) {
setError(errorMessage(caught, "Failed to delete submission"));
}
};
const handleCopy = async () => {
if (!submission) return;
try {
await navigator.clipboard.writeText(submission.content);
setCopied(true);
setCopyFailed(false);
if (copyTimer.current) clearTimeout(copyTimer.current);
if (copyFailedTimer.current) clearTimeout(copyFailedTimer.current);
copyTimer.current = setTimeout(() => setCopied(false), 1500);
} catch {
setCopyFailed(true);
if (copyFailedTimer.current) clearTimeout(copyFailedTimer.current);
copyFailedTimer.current = setTimeout(() => setCopyFailed(false), 2000);
}
};
if (loading) return <DetailSkeleton />;
if (notFound || !submission) {
return (
<div className="mx-auto max-w-4xl px-4 py-16 text-center">
<p className="text-lg font-medium">Submission not found</p>
<p className="mt-1 text-sm text-muted-foreground">This listing may have been removed or the link is invalid.</p>
<Link
to="/workshop"
className="mt-6 inline-flex items-center gap-1.5 rounded-lg px-2.5 py-1.5 text-sm text-muted-foreground hover:bg-muted hover:text-foreground"
>
<IconArrowLeft size={14} /> Back to Workshop
</Link>
</div>
);
}
const score = submission.upvotes - submission.downvotes;
const { Icon: CopyIcon, label: copyLabel, iconClass: copyIconClass } = copyState[Number(copied)];
return (
<div className="mx-auto max-w-4xl px-4 pb-16 pt-8">
<Link
to="/workshop"
className="mb-6 inline-flex items-center gap-1.5 rounded-lg px-2.5 py-1.5 text-sm text-muted-foreground hover:bg-muted hover:text-foreground"
>
<IconArrowLeft size={14} /> Back to Workshop
</Link>
{error && (
<div className="mb-4 rounded-lg border border-destructive/30 bg-destructive/10 px-4 py-2.5 text-sm text-destructive">
{error}
</div>
)}
{screenshotUrls.length > 0 && (
<div className="mb-6">
<ScreenshotGallery urls={screenshotUrls} title={submission.title} />
</div>
)}
<div className="flex flex-wrap items-start justify-between gap-4">
<div>
<div className="flex items-center gap-2">
<h1 className="text-2xl font-semibold tracking-tight">{submission.title}</h1>
<Badge variant="secondary" className="gap-1.5">
<span className={cn("size-2 rounded-full", typeDotColors[submission.type])} />
{typeLabels[submission.type]}
</Badge>
</div>
<p className="mt-1 text-sm text-muted-foreground">
{submission.authorName} · v{submission.version} · {formatRelativeTime(submission.created)}
</p>
</div>
<div className="flex items-center gap-px rounded-md border border-border bg-muted/40 p-px">
<button
onClick={() => void handleVote(1)}
aria-label="Upvote"
className={cn(
"flex items-center justify-center rounded-[5px] p-1.5 transition-colors hover:bg-accent",
userVote?.value === 1 && "bg-primary/15 text-primary",
)}
>
<IconArrowBigUp size={18} />
</button>
<span
className={cn("min-w-6 text-center text-sm font-semibold tabular-nums", scoreClassBySign[scoreSign(score)])}
>
{score}
</span>
<button
onClick={() => void handleVote(-1)}
aria-label="Downvote"
className={cn(
"flex items-center justify-center rounded-[5px] p-1.5 transition-colors hover:bg-accent",
userVote?.value === -1 && "bg-primary/15 text-primary",
)}
>
<IconArrowBigDown size={18} />
</button>
</div>
</div>
{submission.description && (
<p className="mt-6 text-sm leading-relaxed text-muted-foreground">{submission.description}</p>
)}
<div className="mt-6 flex flex-wrap items-center gap-2">
<Button variant="outline" size="sm" onClick={() => void handleCopy()}>
{copyFailed ? (
<>
<IconX size={14} className="text-destructive" /> Copy failed
</>
) : (
<>
<CopyIcon size={14} className={copyIconClass} /> {copyLabel}
</>
)}
</Button>
{submission.type === "widget" && (
<Button variant="outline" size="sm" onClick={() => downloadSubmissionJson(submission)}>
<IconDownload size={14} /> Download
</Button>
)}
{user?.id === submission.author && <DeleteConfirmButton onConfirm={handleDelete} />}
</div>
<div className="mt-6">
<CodeBlock content={submission.content} language={contentLanguages[submission.type]} />
</div>
<div className="mt-10 border-t border-border pt-8">
<CommentsSection
submissionId={submission.id}
pb={pb}
currentUserId={user?.id}
onRequireAuth={requireUserId}
onError={setError}
/>
</div>
</div>
);
};
export default function MarketplaceDetailPage() {
const { siteConfig } = useDocusaurusContext();
const configuredWorkshopUrl = (siteConfig.customFields?.workshopUrl as string | undefined) ?? "";
return (
<Layout title="Workshop" description="Community custom CSS and custom widgets for Homarr">
<main className="marketplace bg-background text-foreground min-h-[80vh]">
<BrowserOnly fallback={<DetailSkeleton />}>
{() => <MarketplaceDetail workshopUrl={configuredWorkshopUrl || window.location.origin} />}
</BrowserOnly>
</main>
</Layout>
);
}
@@ -1,123 +0,0 @@
import React, { useEffect, useRef, useState } from "react";
import { useColorMode } from "@docusaurus/theme-common";
import { IconChevronLeft, IconChevronRight, IconTrash } from "@tabler/icons-react";
import { Highlight, themes } from "prism-react-renderer";
import { Button } from "@/components/ui/button";
import { cn } from "@site/src/lib/utils";
const prismThemes = { light: themes.github, dark: themes.dracula } as const;
const colorModeKeys = { dark: "dark", light: "light" } as const;
const dotClass = ["bg-white/40", "bg-white"];
export const DetailSkeleton = () => (
<div className="mx-auto max-w-4xl px-4 py-8 animate-pulse space-y-6">
<div className="h-4 w-24 rounded bg-muted" />
<div className="aspect-video w-full rounded-lg bg-muted" />
<div className="space-y-2">
<div className="h-7 w-2/3 rounded bg-muted" />
<div className="h-4 w-40 rounded bg-muted" />
</div>
<div className="h-24 rounded-lg bg-muted" />
<div className="h-48 rounded-lg bg-muted" />
</div>
);
export const ScreenshotGallery = ({ urls, title }: { urls: string[]; title: string }) => {
const [idx, setIdx] = useState(0);
return (
<div className="group/gallery relative w-full">
<div className="aspect-video overflow-hidden rounded-lg bg-muted">
<img className="h-full w-full object-cover" src={urls[idx]} alt={`${title} screenshot ${idx + 1}`} />
</div>
{urls.length > 1 && (
<>
<button
className="absolute left-3 top-1/2 flex size-8 -translate-y-1/2 items-center justify-center rounded-md bg-background/80 opacity-60 shadow transition-opacity hover:opacity-100"
onClick={() => setIdx((i) => (i - 1 + urls.length) % urls.length)}
aria-label="Previous screenshot"
>
<IconChevronLeft size={16} />
</button>
<button
className="absolute right-3 top-1/2 flex size-8 -translate-y-1/2 items-center justify-center rounded-md bg-background/80 opacity-60 shadow transition-opacity hover:opacity-100"
onClick={() => setIdx((i) => (i + 1) % urls.length)}
aria-label="Next screenshot"
>
<IconChevronRight size={16} />
</button>
<div className="absolute bottom-3 left-1/2 flex -translate-x-1/2 gap-1.5 rounded-full bg-black/50 px-2.5 py-1">
{urls.map((_, i) => (
<button
key={i}
onClick={() => setIdx(i)}
aria-label={`Screenshot ${i + 1}`}
className={cn("size-2 rounded-full transition-all", dotClass[Number(i === idx)])}
/>
))}
</div>
</>
)}
</div>
);
};
export const CodeBlock = ({ content, language }: { content: string; language: string }) => {
const { colorMode } = useColorMode();
const theme = prismThemes[colorModeKeys[colorMode as keyof typeof colorModeKeys] ?? "light"];
return (
<Highlight theme={theme} code={content} language={language}>
{({ className, style, tokens, getLineProps, getTokenProps }) => (
<pre
className={cn(className, "overflow-auto rounded-lg border border-border p-4 text-sm leading-relaxed")}
style={style}
>
{tokens.map((line, i) => (
<div key={i} {...getLineProps({ line })}>
{line.map((token, key) => (
<span key={key} {...getTokenProps({ token })} />
))}
</div>
))}
</pre>
)}
</Highlight>
);
};
export const DeleteConfirmButton = ({ onConfirm }: { onConfirm: () => void }) => {
const [pending, setPending] = useState(false);
const timer = useRef<ReturnType<typeof setTimeout>>(null);
useEffect(
() => () => {
if (timer.current) clearTimeout(timer.current);
},
[],
);
const handleClick = () => {
if (!pending) {
setPending(true);
timer.current = setTimeout(() => setPending(false), 3000);
return;
}
setPending(false);
onConfirm();
};
return (
<Button
variant="outline"
size="sm"
className={cn(
pending ? "border-destructive bg-destructive/10 text-destructive" : "text-destructive hover:bg-destructive/10",
)}
onClick={handleClick}
>
<IconTrash size={14} /> {pending ? "Confirm delete?" : "Delete"}
</Button>
);
};
@@ -1,542 +0,0 @@
import React, { useCallback, useEffect, useRef, useState } from "react";
import {
IconBraces,
IconCheck,
IconChevronLeft,
IconChevronRight,
IconFileUpload,
IconLoader2,
IconPalette,
IconPhoto,
IconUpload,
IconX,
} from "@tabler/icons-react";
import type { SubmissionType } from "@site/src/lib/workshop-schema";
import { validateSubmissionContent } from "@site/src/lib/workshop-schema";
import { Button } from "@/components/ui/button";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import { Input } from "@/components/ui/input";
import { Textarea } from "@/components/ui/textarea";
import { cn, errorMessage } from "@/lib/utils";
import type { SubmitInput } from "./useWorkshop";
interface Props {
onClose: () => void;
onSubmit: (input: SubmitInput) => Promise<void>;
}
const steps = ["Type", "Details", "Media"] as const;
const navDirection = [-1, 1];
const stepAnimClass = ["submit-step-from-left", "submit-step-from-right"];
const backLabels = ["Cancel", "Back"];
const submitLabels = ["Submit", "Submitting…"];
const connectorClass = ["bg-border", "bg-primary"];
const dropOverlayClass = ["pointer-events-none opacity-0", "opacity-100"];
const placeholders: Record<SubmissionType, string> = {
widget: '{\n "$schema": "homarr-custom-widget-v2",\n "name": "My widget",\n ...\n}',
css: ".grid-stack-item-content {\n border-radius: 16px;\n}",
};
const contentLabels: Record<SubmissionType, string> = {
widget: "Widget JSON (homarr-custom-widget-v2)",
css: "Custom CSS",
};
const parseJsonObject = (json: string): Record<string, unknown> | null => {
try {
const parsed = JSON.parse(json);
if (typeof parsed === "object" && parsed !== null) return parsed;
} catch {
/* invalid json */
}
return null;
};
export const SubmitForm = ({ onClose, onSubmit }: Props) => {
const [step, setStep] = useState(0);
const [direction, setDirection] = useState(1);
const [type, setType] = useState<SubmissionType | null>(null);
const [title, setTitle] = useState("");
const [description, setDescription] = useState("");
const [content, setContent] = useState("");
const [screenshots, setScreenshots] = useState<File[]>([]);
const [previews, setPreviews] = useState<string[]>([]);
const [error, setError] = useState<string | null>(null);
const [pending, setPending] = useState(false);
const [jsonDropHint, setJsonDropHint] = useState(false);
const previewUrlsRef = useRef<Set<string>>(new Set());
const canAdvance = [type !== null, title.trim().length >= 3 && content.trim().length > 0, true];
const goTo = (next: number) => {
setDirection(navDirection[Number(next > step)]);
setStep(next);
};
const setContentAndAutofill = useCallback((json: string, currentTitle: string, currentDesc: string) => {
setContent(json);
const meta = parseJsonObject(json);
if (!meta) return;
if (typeof meta.name === "string" && !currentTitle) setTitle(meta.name);
if (typeof meta.description === "string" && !currentDesc) setDescription(String(meta.description));
}, []);
const handleJsonFileDrop = useCallback(
(files: FileList | File[]) => {
const jsonFile = Array.from(files).find((f) => f.name.endsWith(".json") || f.type === "application/json");
if (!jsonFile) return false;
const reader = new FileReader();
reader.onload = (e) => {
const text = e.target?.result as string;
if (!type) setType("widget");
setContentAndAutofill(text, title, description);
if (step === 0) goTo(1);
};
reader.onerror = () => setError("Could not read the file");
reader.readAsText(jsonFile);
return true;
},
[type, step, title, description, setContentAndAutofill],
);
const addImageFiles = useCallback(
(files: FileList | File[]) => {
const incoming = Array.from(files)
.filter((f) => f.type.startsWith("image/"))
.slice(0, 5 - screenshots.length);
if (incoming.length === 0) return;
setScreenshots((prev) => [...prev, ...incoming].slice(0, 5));
const urls = incoming.map((f) => {
const url = URL.createObjectURL(f);
previewUrlsRef.current.add(url);
return url;
});
setPreviews((prev) => [...prev, ...urls].slice(0, 5));
},
[screenshots.length],
);
const removeScreenshot = (idx: number) => {
const url = previews[idx];
if (url) {
previewUrlsRef.current.delete(url);
URL.revokeObjectURL(url);
}
setScreenshots((prev) => prev.filter((_, i) => i !== idx));
setPreviews((prev) => prev.filter((_, i) => i !== idx));
};
useEffect(
() => () => {
previewUrlsRef.current.forEach(URL.revokeObjectURL);
previewUrlsRef.current.clear();
},
[],
);
const handleGlobalDrop = useCallback(
(e: React.DragEvent) => {
e.preventDefault();
setJsonDropHint(false);
if (!handleJsonFileDrop(e.dataTransfer.files) && step === 2) addImageFiles(e.dataTransfer.files);
},
[handleJsonFileDrop, step, addImageFiles],
);
const handleSubmit = async () => {
if (!type) return;
const validation = validateSubmissionContent(type, content);
if (!validation.success) {
setError(validation.error);
goTo(1);
return;
}
setError(null);
setPending(true);
try {
await onSubmit({ type, title, description, content, screenshots });
} catch (caught) {
setError(errorMessage(caught, "Submission failed"));
} finally {
setPending(false);
}
};
const onContentChange = (value: string) => {
if (type === "widget") setContentAndAutofill(value, title, description);
else setContent(value);
};
return (
<Dialog
open
onOpenChange={(open) => {
if (!open && !pending) onClose();
}}
>
{/* oxlint-disable-next-line jsx-a11y/no-noninteractive-element-interactions */}
<DialogContent
className="max-h-[90vh] overflow-y-auto sm:max-w-xl"
showCloseButton={!pending}
onDragOver={(e: React.DragEvent) => {
e.preventDefault();
setJsonDropHint(true);
}}
onDragLeave={() => setJsonDropHint(false)}
onDrop={handleGlobalDrop}
>
<div
aria-hidden={!jsonDropHint}
className={cn(
"absolute inset-0 z-50 flex flex-col items-center justify-center gap-3 rounded-xl bg-primary/10 backdrop-blur-sm transition-opacity duration-200",
dropOverlayClass[Number(jsonDropHint)],
)}
>
<div className="flex size-14 items-center justify-center rounded-2xl bg-primary text-primary-foreground">
<IconFileUpload size={28} />
</div>
<p className="text-sm font-semibold">Drop JSON file to import widget</p>
<p className="text-xs text-muted-foreground">Title and description will be auto-filled</p>
</div>
<DialogHeader>
<DialogTitle>Share with the community</DialogTitle>
<DialogDescription>Submit a custom CSS theme or widget to the Workshop.</DialogDescription>
</DialogHeader>
<div className="flex items-center gap-2">
{steps.map((label, i) => (
<React.Fragment key={label}>
{i > 0 && <div className={cn("h-px flex-1 transition-colors", connectorClass[Number(i <= step)])} />}
<button
onClick={() => {
if (i < step) goTo(i);
}}
className={cn(
"flex items-center gap-1.5 rounded-full px-3 py-1 text-xs font-medium transition-all",
i === step && "bg-primary text-primary-foreground shadow-sm",
i < step && "bg-primary/10 text-primary cursor-pointer hover:bg-primary/20",
i > step && "bg-muted text-muted-foreground",
)}
>
{i < step ? <IconCheck size={12} /> : <span className="tabular-nums">{i + 1}</span>}
{label}
</button>
</React.Fragment>
))}
</div>
{error && (
<div className="rounded-lg border border-destructive/30 bg-destructive/10 px-3 py-2 text-xs font-medium text-destructive">
{error}
</div>
)}
<div className="relative min-h-[280px] overflow-hidden">
<div key={step} className={cn("submit-step-enter", stepAnimClass[Number(direction > 0)])}>
{step === 0 && <StepType value={type} onChange={setType} onJsonDrop={handleJsonFileDrop} />}
{step === 1 && type && (
<StepDetails
type={type}
title={title}
setTitle={setTitle}
description={description}
setDescription={setDescription}
content={content}
onContentChange={onContentChange}
/>
)}
{step === 2 && (
<StepMedia previews={previews} addFiles={addImageFiles} removeScreenshot={removeScreenshot} />
)}
</div>
</div>
<DialogFooter className="sm:justify-between">
<Button
variant="ghost"
onClick={() => {
if (step === 0) onClose();
else goTo(step - 1);
}}
disabled={pending}
>
<IconChevronLeft size={14} /> {backLabels[Math.min(step, 1)]}
</Button>
<div className="flex items-center gap-2">
{step < 2 && (
<Button onClick={() => goTo(step + 1)} disabled={!canAdvance[step]}>
Next <IconChevronRight size={14} />
</Button>
)}
{step >= 2 && (
<Button onClick={() => void handleSubmit()} disabled={pending || !canAdvance[1]}>
{pending && <IconLoader2 size={14} className="animate-spin" />}
{submitLabels[Number(pending)]}
</Button>
)}
</div>
</DialogFooter>
</DialogContent>
</Dialog>
);
};
const typeCards: { type: SubmissionType; icon: typeof IconBraces; label: string; desc: string }[] = [
{
type: "widget",
icon: IconBraces,
label: "Custom Widget",
desc: "A JSON-based widget using the homarr-custom-widget-v2 schema",
},
{ type: "css", icon: IconPalette, label: "Custom CSS", desc: "A CSS theme or style override for Homarr dashboards" },
];
const StepType = ({
value,
onChange,
onJsonDrop,
}: {
value: SubmissionType | null;
onChange: (t: SubmissionType) => void;
onJsonDrop: (files: FileList | File[]) => boolean;
}) => {
const [dropActive, setDropActive] = useState(false);
return (
<div className="flex flex-col gap-3">
<div className="grid grid-cols-2 gap-3">
{typeCards.map(({ type, icon: Icon, label, desc }) => (
<button
key={type}
onClick={() => onChange(type)}
className={cn(
"group flex flex-col items-center gap-3 rounded-xl border-2 p-6 text-center transition-all hover:border-primary/50 hover:bg-primary/5",
value === type ? "border-primary bg-primary/10 ring-1 ring-primary/20" : "border-border bg-card",
)}
>
<div
className={cn(
"flex size-12 items-center justify-center rounded-xl transition-colors",
value === type
? "bg-primary text-primary-foreground"
: "bg-muted text-muted-foreground group-hover:bg-primary/10 group-hover:text-primary",
)}
>
<Icon size={24} />
</div>
<div>
<p className="font-heading text-sm font-semibold">{label}</p>
<p className="mt-1 text-xs leading-relaxed text-muted-foreground">{desc}</p>
</div>
{value === type && (
<div className="flex size-5 items-center justify-center rounded-full bg-primary text-primary-foreground">
<IconCheck size={12} />
</div>
)}
</button>
))}
</div>
{/* oxlint-disable-next-line jsx-a11y/no-noninteractive-element-interactions */}
<label
onDragOver={(e) => {
e.preventDefault();
e.stopPropagation();
setDropActive(true);
}}
onDragLeave={() => setDropActive(false)}
onDrop={(e) => {
e.preventDefault();
e.stopPropagation();
setDropActive(false);
onJsonDrop(e.dataTransfer.files);
}}
className={cn(
"flex cursor-pointer items-center gap-3 rounded-xl border border-dashed p-3 transition-all hover:border-primary/40 hover:bg-primary/5",
dropActive && "border-primary bg-primary/10",
)}
>
<div className="flex size-8 shrink-0 items-center justify-center rounded-lg bg-muted text-muted-foreground">
<IconFileUpload size={16} />
</div>
<div className="min-w-0">
<p className="text-xs font-medium">Or drop a .json file here</p>
<p className="text-xs text-muted-foreground">Auto-selects Widget type and fills title from JSON</p>
</div>
<input
type="file"
accept=".json,application/json"
className="hidden"
onChange={(e) => {
if (e.target.files) onJsonDrop(e.target.files);
}}
/>
</label>
</div>
);
};
const StepDetails = ({
type,
title,
setTitle,
description,
setDescription,
content,
onContentChange,
}: {
type: SubmissionType;
title: string;
setTitle: (v: string) => void;
description: string;
setDescription: (v: string) => void;
content: string;
onContentChange: (v: string) => void;
}) => (
<div className="flex flex-col gap-4">
<div className="flex flex-col gap-1.5">
<label htmlFor="submit-content" className="text-xs font-medium text-muted-foreground">
{contentLabels[type]} *
</label>
<Textarea
id="submit-content"
className="font-mono text-xs"
value={content}
onChange={(e) => onContentChange(e.target.value)}
placeholder={placeholders[type]}
rows={8}
required
/>
{type === "widget" && (
<p className="text-xs text-muted-foreground">
Paste your widget JSON — title and description will auto-fill from the{" "}
<code className="rounded bg-muted px-1 py-0.5 text-foreground">name</code> field.
</p>
)}
</div>
<div className="flex flex-col gap-1.5">
<label htmlFor="submit-title" className="text-xs font-medium text-muted-foreground">
Title *
</label>
<Input
id="submit-title"
value={title}
onChange={(e) => setTitle(e.target.value)}
required
minLength={3}
maxLength={100}
placeholder="My awesome theme"
/>
</div>
<div className="flex flex-col gap-1.5">
<label htmlFor="submit-description" className="text-xs font-medium text-muted-foreground">
Description
</label>
<Textarea
id="submit-description"
value={description}
onChange={(e) => setDescription(e.target.value)}
maxLength={2000}
rows={2}
placeholder="A brief description of what this does"
/>
</div>
</div>
);
const mediaDropState = {
idle: { Icon: IconUpload, title: "Drag & drop or click to upload", iconClass: "bg-muted text-muted-foreground" },
active: { Icon: IconPhoto, title: "Drop images here", iconClass: "bg-primary text-primary-foreground" },
} as const;
const StepMedia = ({
previews,
addFiles,
removeScreenshot,
}: {
previews: string[];
addFiles: (files: FileList | File[]) => void;
removeScreenshot: (idx: number) => void;
}) => {
const [dragOver, setDragOver] = useState(false);
const { Icon: DropIcon, title: dropTitle, iconClass: dropIconClass } = mediaDropState[dragOver ? "active" : "idle"];
return (
<div className="flex flex-col gap-4">
<div>
<span className="text-xs font-medium text-muted-foreground">Screenshots (optional, up to 5)</span>
<p className="mt-0.5 text-xs text-muted-foreground/70">
Add screenshots to help others preview your submission.
</p>
</div>
{/* oxlint-disable-next-line jsx-a11y/no-noninteractive-element-interactions */}
<label
onDragOver={(e) => {
e.preventDefault();
e.stopPropagation();
setDragOver(true);
}}
onDragLeave={() => setDragOver(false)}
onDrop={(e) => {
e.preventDefault();
e.stopPropagation();
setDragOver(false);
addFiles(e.dataTransfer.files);
}}
className={cn(
"flex cursor-pointer flex-col items-center gap-2 rounded-xl border-2 border-dashed border-border p-8 text-center transition-all hover:border-primary/50 hover:bg-primary/5",
dragOver && "border-primary bg-primary/10",
previews.length >= 5 && "pointer-events-none opacity-50",
)}
>
<div className={cn("flex size-10 items-center justify-center rounded-xl transition-colors", dropIconClass)}>
<DropIcon size={20} />
</div>
<div>
<p className="font-heading text-sm font-medium">{dropTitle}</p>
<p className="mt-0.5 text-xs text-muted-foreground">PNG, JPG, WebP up to 5 images</p>
</div>
<input
type="file"
accept="image/*"
multiple
className="hidden"
onChange={(e) => {
if (e.target.files) addFiles(e.target.files);
}}
/>
</label>
{previews.length > 0 && (
<div className="grid grid-cols-5 gap-2">
{previews.map((src, i) => (
<div
key={i}
className="group relative aspect-square overflow-hidden rounded-xl border border-border bg-muted"
>
<img src={src} alt={`Preview ${i + 1}`} className="h-full w-full object-cover" />
<button
onClick={() => removeScreenshot(i)}
className="absolute inset-0 flex items-center justify-center bg-black/60 opacity-0 transition-opacity group-hover:opacity-100"
aria-label={`Remove screenshot ${i + 1}`}
>
<IconX size={16} className="text-white" />
</button>
</div>
))}
</div>
)}
</div>
);
};
@@ -0,0 +1,300 @@
import React, { useCallback, useEffect, useMemo, useState } from "react";
import useDocusaurusContext from "@docusaurus/useDocusaurusContext";
import {
WORKSHOP_API_URL,
WorkshopClient,
type WorkshopAccountState,
type WorkshopModerationAction,
type WorkshopReport,
type WorkshopRole,
type WorkshopUser,
} from "@homarr/workshop";
import styles from "./workshop.module.css";
type PendingAction =
| { kind: "report"; id: string; status: "resolved" | "dismissed" }
| { kind: "state"; id: string; state: WorkshopAccountState }
| { kind: "role"; id: string; role: WorkshopRole };
export function WorkshopAdmin() {
const { siteConfig } = useDocusaurusContext();
const client = useMemo(
() => new WorkshopClient((siteConfig.customFields?.workshopApiUrl as string | undefined) ?? WORKSHOP_API_URL),
[siteConfig],
);
const [user, setUser] = useState<WorkshopUser | null>(null);
const [reports, setReports] = useState<WorkshopReport[]>([]);
const [users, setUsers] = useState<WorkshopUser[]>([]);
const [actions, setActions] = useState<WorkshopModerationAction[]>([]);
const [pending, setPending] = useState<PendingAction | null>(null);
const [error, setError] = useState("");
const load = useCallback(async () => {
const current = await client.refreshAuth();
setUser(current);
if (!current || (current.role !== "moderator" && current.role !== "admin")) return;
try {
const [nextReports, nextUsers, nextActions] = await Promise.all([
client.listReports(),
client.listUsers(),
client.listModerationActions(),
]);
setReports(nextReports);
setUsers(nextUsers);
setActions(nextActions);
} catch (caught) {
setError(caught instanceof Error ? caught.message : "Could not load moderation data");
}
}, [client]);
useEffect(() => client.subscribeToAuth(setUser), [client]);
useEffect(() => {
void load();
}, [load]);
if (!user)
return (
<main className={styles.page}>
<h1>Workshop moderation</h1>
<p>Sign in with a promoted GitHub account to continue.</p>
<button
className="button button--primary"
onClick={async () => {
await client.signInWithGitHub();
await load();
}}
>
Sign in with GitHub
</button>
</main>
);
if (user.role === "member")
return (
<main className={styles.page}>
<h1>Workshop moderation</h1>
<div className={styles.error} role="alert">
Your account does not have moderation access.
</div>
</main>
);
return (
<main className={styles.page}>
<header className={styles.hero}>
<div>
<h1>Workshop moderation</h1>
<p>Review reports and apply the smallest action needed. Every action requires a reason and is recorded.</p>
</div>
</header>
{error && (
<div className={styles.error} role="alert">
{error}
</div>
)}
<section className={styles.adminSection}>
<h2>Open reports</h2>
{reports.length === 0 ? (
<p>No open reports.</p>
) : (
<div className={styles.tableWrap}>
<table>
<thead>
<tr>
<th>Category</th>
<th>Submission</th>
<th>Details</th>
<th>Created</th>
<th>Action</th>
</tr>
</thead>
<tbody>
{reports.map((report) => (
<tr key={report.id}>
<td>{report.category}</td>
<td>{report.submission}</td>
<td>{report.explanation}</td>
<td>{new Date(report.created).toLocaleString()}</td>
<td aria-label="Report actions">
<div className={styles.rowActions}>
<button onClick={() => setPending({ kind: "report", id: report.id, status: "resolved" })}>
Resolve
</button>
<button onClick={() => setPending({ kind: "report", id: report.id, status: "dismissed" })}>
Dismiss
</button>
</div>
</td>
</tr>
))}
</tbody>
</table>
</div>
)}
</section>
<section className={styles.adminSection}>
<h2>Community accounts</h2>
<div className={styles.tableWrap}>
<table>
<thead>
<tr>
<th>User</th>
<th>Role</th>
<th>State</th>
<th>Moderate</th>
</tr>
</thead>
<tbody>
{users.map((account) => (
<tr key={account.id}>
<td>{account.displayName}</td>
<td>{account.role}</td>
<td>{account.state}</td>
<td>
{account.id === user.id ? (
"Current account"
) : (
<div className={styles.rowActions}>
<button
onClick={() =>
setPending({
kind: "state",
id: account.id,
state: account.state === "posting_banned" ? "active" : "posting_banned",
})
}
>
{account.state === "posting_banned" ? "Lift posting ban" : "Posting ban"}
</button>
<button
onClick={() =>
setPending({
kind: "state",
id: account.id,
state: account.state === "disabled" ? "active" : "disabled",
})
}
>
{account.state === "disabled" ? "Restore" : "Disable"}
</button>
{user.role === "admin" && (
<button
onClick={() =>
setPending({
kind: "role",
id: account.id,
role: account.role === "member" ? "moderator" : "member",
})
}
>
{account.role === "member" ? "Promote" : "Set member"}
</button>
)}
</div>
)}
</td>
</tr>
))}
</tbody>
</table>
</div>
</section>
<section className={styles.adminSection}>
<h2>Recent actions</h2>
<div className={styles.tableWrap}>
<table>
<thead>
<tr>
<th>Action</th>
<th>Target</th>
<th>Reason</th>
<th>Created</th>
</tr>
</thead>
<tbody>
{actions.map((action) => (
<tr key={action.id}>
<td>{action.action}</td>
<td>
{action.targetType}:{action.targetId}
</td>
<td>{action.reason}</td>
<td>{new Date(action.created).toLocaleString()}</td>
</tr>
))}
</tbody>
</table>
</div>
</section>
{pending && (
<ReasonDialog
action={pending}
onClose={() => setPending(null)}
onConfirm={async (reason) => {
if (pending.kind === "report") await client.resolveReport(pending.id, pending.status, reason);
else if (pending.kind === "state") await client.updateAccountState(pending.id, pending.state, reason);
else await client.updateRole(pending.id, pending.role, reason);
setPending(null);
await load();
}}
/>
)}
</main>
);
}
function ReasonDialog({
action,
onClose,
onConfirm,
}: {
action: PendingAction;
onClose: () => void;
onConfirm: (reason: string) => Promise<void>;
}) {
const [reason, setReason] = useState("");
const [error, setError] = useState("");
const [saving, setSaving] = useState(false);
return (
<div className={styles.overlay}>
<dialog className={styles.dialog} open aria-labelledby="moderation-reason-title">
<h2 id="moderation-reason-title">Confirm moderation action</h2>
<p>
This will apply <strong>{action.kind}</strong> to the selected record.
</p>
<form
onSubmit={async (event) => {
event.preventDefault();
setSaving(true);
try {
await onConfirm(reason);
} catch (caught) {
setError(caught instanceof Error ? caught.message : "Action failed");
setSaving(false);
}
}}
>
<label>
Reason
<textarea
required
minLength={3}
maxLength={1000}
value={reason}
onChange={(event) => setReason(event.target.value)}
/>
</label>
{error && <p className={styles.error}>{error}</p>}
<div className={styles.formActions}>
<button type="button" className="button button--secondary" onClick={onClose}>
Cancel
</button>
<button disabled={saving} className="button button--danger">
{saving ? "Applying…" : "Apply action"}
</button>
</div>
</form>
</dialog>
</div>
);
}
@@ -1,523 +0,0 @@
import React, { useEffect, useMemo, useRef, useState } from "react";
import {
IconArrowBigDown,
IconArrowBigUp,
IconBrandCss3,
IconBrandGithub,
IconCheck,
IconChevronLeft,
IconChevronRight,
IconCopy,
IconDownload,
IconFlag,
IconLogout,
IconMessage,
IconPackage,
IconPlus,
IconPuzzle,
IconSearch,
IconTrash,
IconX,
} from "@tabler/icons-react";
import { getSubmissionFileUrl, type WorkshopSubmission } from "@site/src/lib/pocketbase";
import type { SubmissionType } from "@site/src/lib/workshop-schema";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import {
Card,
CardAction,
CardContent,
CardDescription,
CardFooter,
CardHeader,
CardTitle,
} from "@/components/ui/card";
import { cn } from "@/lib/utils";
import { SubmitForm } from "./SubmitForm";
import { formatRelativeTime } from "./format";
import { downloadSubmissionJson } from "./workshop-utils";
import type { SortKey, TypeFilter } from "./useWorkshop";
import { useWorkshop } from "./useWorkshop";
const typeDotColors: Record<SubmissionType, string> = { css: "bg-blue-500", widget: "bg-yellow-500" };
const typeLabels: Record<SubmissionType, string> = { css: "CSS", widget: "Widget" };
const typeIcons: Record<SubmissionType, React.ComponentType<{ size: number; className?: string }>> = {
css: IconBrandCss3,
widget: IconPuzzle,
};
const typeBgColors: Record<SubmissionType, string> = { css: "bg-blue-500/5", widget: "bg-yellow-500/5" };
const filterActiveClass = "bg-background text-foreground shadow-sm";
const filterInactiveClass = "text-muted-foreground hover:text-foreground";
const copyState = [
{ Icon: IconCopy, label: "Copy", iconClass: "" },
{ Icon: IconCheck, label: "Copied!", iconClass: "text-green-500" },
] as const;
const emptyState = {
none: { title: "No submissions yet", hint: "Be the first to share something." },
filtered: { title: "No matching results", hint: "Try adjusting your filters or search." },
} as const;
const typeFilters: { value: TypeFilter; label: string; dot?: string }[] = [
{ value: "all", label: "All" },
{ value: "widget", label: "Widgets", dot: typeDotColors.widget },
{ value: "css", label: "CSS", dot: typeDotColors.css },
{ value: "yours", label: "Yours" },
];
const sortOptions: { value: SortKey; label: string }[] = [
{ value: "top", label: "Top rated" },
{ value: "new", label: "Newest" },
];
const stopCardNavigation = (event: React.MouseEvent<HTMLButtonElement>) => {
event.preventDefault();
event.stopPropagation();
};
export const WorkshopApp = ({ workshopUrl }: { workshopUrl: string }) => {
const workshop = useWorkshop(workshopUrl);
const [typeFilter, setTypeFilter] = useState<TypeFilter>("all");
const [sort, setSort] = useState<SortKey>("top");
const [search, setSearch] = useState("");
const [showSubmit, setShowSubmit] = useState(false);
// Stable sort order: only re-sort when filter/sort/search or submission set changes (not vote counts)
const submissionIds = useMemo(() => workshop.submissions.map((s) => s.id).join(","), [workshop.submissions]);
const sortedIds = useMemo(() => {
const q = search.toLowerCase();
return workshop.submissions
.filter((item) => {
if (typeFilter === "yours") return item.author === workshop.user?.id;
return typeFilter === "all" || item.type === typeFilter;
})
.filter((item) => !q || item.title.toLowerCase().includes(q))
.toSorted(workshop.sorters[sort])
.map((s) => s.id);
// eslint-disable-next-line react-hooks/exhaustive-deps -- intentionally keyed on submissionIds string, not the array
}, [submissionIds, workshop.sorters, typeFilter, sort, search, workshop.user?.id]);
const visible = useMemo(() => {
const byId = new Map(workshop.submissions.map((s) => [s.id, s]));
return sortedIds.map((id) => byId.get(id)).filter(Boolean) as WorkshopSubmission[];
}, [workshop.submissions, sortedIds]);
const empty = emptyState[workshop.submissions.length === 0 ? "none" : "filtered"];
return (
<div className="mx-auto max-w-7xl px-4 pb-16">
<div className="flex flex-col gap-6 py-8 sm:flex-row sm:items-end sm:justify-between">
<div>
<h1 className="text-2xl font-semibold tracking-tight">Workshop</h1>
<p className="mt-1 text-sm text-muted-foreground">Community custom widgets and CSS themes for Homarr.</p>
</div>
<div className="flex items-center gap-2">
{workshop.user ? (
<>
<Button onClick={() => setShowSubmit(true)}>
<IconPlus size={14} /> Share yours
</Button>
<Button variant="ghost" size="sm" onClick={workshop.logout}>
<IconLogout size={14} /> {workshop.user?.name || workshop.user?.username || "Account"}
</Button>
</>
) : (
<div className="flex flex-col items-end gap-0.5">
<Button onClick={() => void workshop.login()}>
<IconBrandGithub size={14} /> Sign in with GitHub
</Button>
<p className="text-xs text-muted-foreground">Vote and share your creations</p>
</div>
)}
</div>
</div>
<div className="mb-6 flex flex-wrap items-center justify-between gap-3 rounded-lg border border-border bg-card px-3 py-2">
<div className="flex items-center gap-2">
<div className="inline-flex rounded-md bg-muted p-0.5">
{typeFilters.map((opt) => (
<button
key={opt.value}
onClick={() => setTypeFilter(opt.value)}
className={cn(
"inline-flex items-center gap-1.5 rounded px-2.5 py-1 text-xs font-medium transition-colors",
typeFilter === opt.value ? filterActiveClass : filterInactiveClass,
)}
>
{opt.dot && <span className={cn("size-2 rounded-full", opt.dot)} />}
{opt.label}
</button>
))}
</div>
<select
aria-label="Sort order"
className="h-7 rounded-md border border-input bg-transparent px-2 text-xs outline-none dark:bg-input/30"
value={sort}
onChange={(e) => setSort(e.target.value as SortKey)}
>
{sortOptions.map((o) => (
<option key={o.value} value={o.value}>
{o.label}
</option>
))}
</select>
</div>
<div className="relative">
<IconSearch size={14} className="absolute left-2.5 top-1/2 -translate-y-1/2 text-muted-foreground" />
<input
className="h-7 w-48 rounded-md border border-input bg-transparent pl-8 text-xs outline-none placeholder:text-muted-foreground"
placeholder="Search…"
aria-label="Search submissions"
value={search}
onChange={(e) => setSearch(e.target.value)}
/>
</div>
</div>
{workshop.error && (
<div className="mb-4 rounded-lg border border-destructive/30 bg-destructive/10 px-4 py-2.5 text-sm text-destructive">
{workshop.error}
</div>
)}
{workshop.loading && (
<div className="columns-1 gap-4 lg:columns-2 xl:columns-3 2xl:columns-4 [&>*]:mb-4">
{Array.from({ length: 6 }, (_, i) => (
<SkeletonCard key={i} hasImage={i % 3 === 0} />
))}
</div>
)}
{!workshop.loading && visible.length === 0 && (
<div className="flex flex-col items-center gap-3 rounded-lg border border-dashed border-border py-16">
<IconPackage size={28} stroke={1.5} className="text-muted-foreground" />
<div className="text-center">
<p className="text-sm font-medium">{empty.title}</p>
<p className="mt-0.5 text-xs text-muted-foreground">{empty.hint}</p>
</div>
{workshop.user && workshop.submissions.length === 0 && (
<Button size="sm" onClick={() => setShowSubmit(true)}>
<IconPlus size={14} /> Create submission
</Button>
)}
</div>
)}
<div className="columns-1 gap-4 lg:columns-2 xl:columns-3 [&>*]:mb-4">
{visible.map((submission) => (
<SubmissionCard
key={submission.id}
submission={submission}
pb={workshop.pb}
userVote={workshop.votes[submission.id]?.value}
currentUserId={workshop.user?.id}
onVote={workshop.vote}
onReport={workshop.report}
onDelete={workshop.deleteSubmission}
/>
))}
</div>
{showSubmit && (
<SubmitForm
onClose={() => setShowSubmit(false)}
onSubmit={async (input) => {
if (await workshop.submit(input)) setShowSubmit(false);
}}
/>
)}
</div>
);
};
interface SubmissionCardProps {
submission: WorkshopSubmission;
pb: ReturnType<typeof useWorkshop>["pb"];
userVote?: 1 | -1;
currentUserId?: string;
onVote: (submissionId: string, value: 1 | -1) => void;
onReport: (submissionId: string, reason: string) => void;
onDelete: (submissionId: string) => Promise<boolean>;
}
const SubmissionCard = ({
submission,
pb,
userVote,
currentUserId,
onVote,
onReport,
onDelete,
}: SubmissionCardProps) => {
const isOwner = currentUserId === submission.author;
const [copied, setCopied] = useState(false);
const [confirmDelete, setConfirmDelete] = useState(false);
const confirmTimer = useRef<ReturnType<typeof setTimeout>>(null);
const copyTimer = useRef<ReturnType<typeof setTimeout>>(null);
const score = submission.upvotes - submission.downvotes;
const { Icon: CopyIcon, label: copyLabel, iconClass: copyIconClass } = copyState[Number(copied)];
const screenshotUrls = useMemo(
() => submission.screenshots?.map((f) => getSubmissionFileUrl(pb.baseURL, submission.id, f)) ?? [],
[submission, pb],
);
useEffect(
() => () => {
if (copyTimer.current) clearTimeout(copyTimer.current);
if (confirmTimer.current) clearTimeout(confirmTimer.current);
},
[],
);
const [copyFailed, setCopyFailed] = useState(false);
const handleCopy = async () => {
try {
await navigator.clipboard.writeText(submission.content);
setCopied(true);
setCopyFailed(false);
if (copyTimer.current) clearTimeout(copyTimer.current);
copyTimer.current = setTimeout(() => setCopied(false), 1500);
} catch {
setCopyFailed(true);
setTimeout(() => setCopyFailed(false), 2000);
}
};
const handleDelete = () => {
if (!confirmDelete) {
setConfirmDelete(true);
if (confirmTimer.current) clearTimeout(confirmTimer.current);
confirmTimer.current = setTimeout(() => setConfirmDelete(false), 3000);
return;
}
setConfirmDelete(false);
void onDelete(submission.id);
};
const hasScreenshots = screenshotUrls.length > 0;
const TypeIcon = typeIcons[submission.type];
return (
<Card className="relative transition-transform duration-150 hover:scale-[1.02]">
<a href={`/workshop/${submission.id}/`} className="block">
{hasScreenshots ? (
<div className="relative">
<Badge
variant="secondary"
className="absolute left-2 top-2 z-10 gap-1.5 bg-background/80 px-2 backdrop-blur-sm"
>
<span className={cn("size-2 rounded-full", typeDotColors[submission.type])} />
{typeLabels[submission.type]}
</Badge>
<ScreenshotGallery urls={screenshotUrls} title={submission.title} />
</div>
) : (
<div className={cn("flex items-center justify-center py-6", typeBgColors[submission.type])}>
<TypeIcon size={32} className="text-muted-foreground/20" />
</div>
)}
</a>
<CardHeader>
<div className="flex items-center gap-2">
<a href={`/workshop/${submission.id}/`} className="min-w-0 hover:underline" title={submission.title}>
<CardTitle className="truncate">{submission.title}</CardTitle>
</a>
{!hasScreenshots && (
<Badge variant="secondary" className="shrink-0 gap-1.5 px-2">
<span className={cn("size-2 rounded-full", typeDotColors[submission.type])} />
{typeLabels[submission.type]}
</Badge>
)}
</div>
<CardDescription className="text-xs">
{submission.authorName} · v{submission.version} · {formatRelativeTime(submission.created)}
</CardDescription>
<CardAction>
<div className="flex items-center gap-px rounded-md border border-border bg-muted/40 p-px">
<button
onClick={() => void onVote(submission.id, 1)}
aria-label="Upvote"
className={cn(
"flex items-center justify-center rounded-[5px] p-1 transition-colors hover:bg-accent",
userVote === 1 && "bg-primary/15 text-primary",
)}
>
<IconArrowBigUp size={14} />
</button>
<span
className={cn(
"min-w-5 text-center text-xs font-semibold tabular-nums",
score > 0 && "text-foreground",
score < 0 && "text-destructive",
)}
>
{score}
</span>
<button
onClick={() => void onVote(submission.id, -1)}
aria-label="Downvote"
className={cn(
"flex items-center justify-center rounded-[5px] p-1 transition-colors hover:bg-accent",
userVote === -1 && "bg-primary/15 text-primary",
)}
>
<IconArrowBigDown size={14} />
</button>
</div>
</CardAction>
</CardHeader>
<CardContent>
{submission.description && (
<p className="line-clamp-2 text-xs leading-relaxed text-muted-foreground">{submission.description}</p>
)}
</CardContent>
<CardFooter className="flex-col gap-0 p-0">
<div className="flex w-full flex-wrap items-center gap-0.5 px-2 py-1.5">
<Button
variant="ghost"
size="xs"
onClick={() => void handleCopy()}
className="text-muted-foreground hover:text-foreground"
>
{copyFailed ? (
<>
<IconX size={13} className="text-destructive" /> Failed
</>
) : (
<>
<CopyIcon size={13} className={copyIconClass} /> {copyLabel}
</>
)}
</Button>
{submission.type === "widget" && (
<Button
variant="ghost"
size="xs"
onClick={() => downloadSubmissionJson(submission)}
className="text-muted-foreground hover:text-foreground"
>
<IconDownload size={13} /> Download
</Button>
)}
{submission.commentCount > 0 && (
<a
href={`/workshop/${submission.id}/`}
className="inline-flex items-center gap-1 rounded px-1.5 py-0.5 text-xs text-muted-foreground hover:text-foreground"
>
<IconMessage size={13} /> {submission.commentCount}
</a>
)}
<div className="ml-auto flex items-center gap-px">
{isOwner && (
<button
className={cn(
"flex items-center gap-1 rounded px-1 py-0.5 text-xs transition-colors",
confirmDelete
? "bg-destructive/10 text-destructive"
: "text-muted-foreground/60 hover:text-destructive",
)}
onClick={handleDelete}
aria-label="Delete"
>
<IconTrash size={13} />
{confirmDelete && "Confirm?"}
</button>
)}
<button
className="flex items-center justify-center rounded p-1 text-muted-foreground/60 transition-colors hover:text-destructive"
onClick={() => void onReport(submission.id, "flagged")}
aria-label="Report"
>
<IconFlag size={13} />
</button>
</div>
</div>
</CardFooter>
</Card>
);
};
const SkeletonCard = ({ hasImage }: { hasImage: boolean }) => (
<Card>
{hasImage && <div className="aspect-video animate-pulse bg-muted" />}
<CardHeader>
<div className="flex items-center gap-2">
<div className="h-4 w-32 animate-pulse rounded bg-muted" />
<div className="h-4 w-12 animate-pulse rounded-full bg-muted" />
</div>
<div className="h-3 w-40 animate-pulse rounded bg-muted" />
</CardHeader>
<CardContent>
<div className="space-y-1.5">
<div className="h-3 w-full animate-pulse rounded bg-muted" />
<div className="h-3 w-2/3 animate-pulse rounded bg-muted" />
</div>
</CardContent>
<CardFooter className="p-0">
<div className="flex w-full gap-2 px-2 py-2">
<div className="h-5 w-12 animate-pulse rounded bg-muted" />
<div className="h-5 w-12 animate-pulse rounded bg-muted" />
<div className="h-5 w-16 animate-pulse rounded bg-muted" />
</div>
</CardFooter>
</Card>
);
const ScreenshotGallery = ({ urls, title }: { urls: string[]; title: string }) => {
const [idx, setIdx] = useState(0);
const dotClass = ["bg-white/40", "bg-white"];
return (
<div className="group/gallery relative">
<div className="aspect-video overflow-hidden bg-muted">
<img
className="h-full w-full object-cover"
src={urls[idx]}
alt={`${title} screenshot ${idx + 1}`}
loading="lazy"
/>
</div>
{urls.length > 1 && (
<>
<button
className="absolute left-2 top-1/2 flex size-6 -translate-y-1/2 items-center justify-center rounded-md bg-background/80 opacity-60 shadow transition-opacity hover:opacity-100"
onClick={(event) => {
stopCardNavigation(event);
setIdx((i) => (i - 1 + urls.length) % urls.length);
}}
aria-label="Previous screenshot"
>
<IconChevronLeft size={14} />
</button>
<button
className="absolute right-2 top-1/2 flex size-6 -translate-y-1/2 items-center justify-center rounded-md bg-background/80 opacity-60 shadow transition-opacity hover:opacity-100"
onClick={(event) => {
stopCardNavigation(event);
setIdx((i) => (i + 1) % urls.length);
}}
aria-label="Next screenshot"
>
<IconChevronRight size={14} />
</button>
<div className="absolute bottom-2 left-1/2 flex -translate-x-1/2 gap-1 rounded-full bg-black/50 px-2 py-1">
{urls.map((_, i) => (
<button
key={i}
onClick={(event) => {
stopCardNavigation(event);
setIdx(i);
}}
aria-label={`Screenshot ${i + 1}`}
className={cn("size-1.5 rounded-full transition-all", dotClass[Number(i === idx)])}
/>
))}
</div>
</>
)}
</div>
);
};
@@ -0,0 +1,16 @@
import React from "react";
import BrowserOnly from "@docusaurus/BrowserOnly";
import Layout from "@theme/Layout";
export default function WorkshopDetailRoutePage() {
return (
<Layout title="Workshop submission">
<BrowserOnly fallback={<main style={{ minHeight: "70vh" }} />}>
{() => {
const { WorkshopDetailRoute } = require("./WorkshopPage");
return <WorkshopDetailRoute />;
}}
</BrowserOnly>
</Layout>
);
}
@@ -0,0 +1,668 @@
import React, { useCallback, useEffect, useMemo, useState } from "react";
import Link from "@docusaurus/Link";
import { useHistory, useLocation } from "@docusaurus/router";
import useDocusaurusContext from "@docusaurus/useDocusaurusContext";
import {
IconAlertTriangle,
IconBrandGithub,
IconCopy,
IconDownload,
IconFlag,
IconSearch,
IconUpload,
} from "@tabler/icons-react";
import {
WORKSHOP_API_URL,
WorkshopClient,
type WorkshopSubmissionDetail,
type WorkshopSubmissionInput,
type WorkshopSubmissionSummary,
type WorkshopSubmissionType,
type WorkshopUser,
workshopExportFilename,
} from "@homarr/workshop";
import styles from "./workshop.module.css";
const useWorkshop = () => {
const { siteConfig } = useDocusaurusContext();
const apiUrl = (siteConfig.customFields?.workshopApiUrl as string | undefined) ?? WORKSHOP_API_URL;
return useMemo(() => new WorkshopClient(apiUrl), [apiUrl]);
};
const saveContent = (submission: WorkshopSubmissionDetail) => {
const url = URL.createObjectURL(
new Blob([submission.content], { type: submission.type === "css" ? "text/css" : "application/json" }),
);
const link = document.createElement("a");
link.href = url;
link.download = workshopExportFilename(submission.title, submission.type);
link.click();
URL.revokeObjectURL(url);
};
export function WorkshopApp() {
const client = useWorkshop();
const [user, setUser] = useState<WorkshopUser | null>(null);
const [items, setItems] = useState<WorkshopSubmissionSummary[]>([]);
const [page, setPage] = useState(1);
const [totalPages, setTotalPages] = useState(1);
const [type, setType] = useState<WorkshopSubmissionType | "all">("all");
const [sort, setSort] = useState<"top" | "newest">("top");
const [search, setSearch] = useState("");
const [mine, setMine] = useState(false);
const [loading, setLoading] = useState(true);
const [error, setError] = useState("");
const [showForm, setShowForm] = useState(false);
useEffect(() => client.subscribeToAuth(setUser), [client]);
const load = useCallback(async () => {
setLoading(true);
setError("");
try {
await client.refreshAuth();
const result = await client.list({ page, type, sort, search, author: mine ? client.currentUser?.id : undefined });
setItems(result.items);
setTotalPages(result.totalPages);
} catch (caught) {
setError(caught instanceof Error ? caught.message : "Workshop is unavailable");
} finally {
setLoading(false);
}
}, [client, mine, page, search, sort, type]);
useEffect(() => {
const timeout = window.setTimeout(() => void load(), 250);
return () => window.clearTimeout(timeout);
}, [load]);
const signIn = async () => {
try {
await client.signInWithGitHub();
await load();
} catch (caught) {
setError(caught instanceof Error ? caught.message : "Sign-in failed");
}
};
return (
<main className={styles.page}>
<header className={styles.hero}>
<div>
<h1>Workshop</h1>
<p>Community widgets and CSS themes, ready to inspect and use in Homarr.</p>
</div>
<div className={styles.accountActions}>
{user ? (
<>
{(user.role === "moderator" || user.role === "admin") && (
<Link className="button button--secondary" to="/workshop/admin">
Moderate
</Link>
)}
<button className="button button--primary" onClick={() => setShowForm(true)}>
<IconUpload size={17} /> Share creation
</button>
<button className="button button--secondary" onClick={() => client.signOut()}>
Sign out
</button>
</>
) : (
<button className="button button--primary" onClick={() => void signIn()}>
<IconBrandGithub size={18} /> Sign in with GitHub
</button>
)}
</div>
</header>
<section className={styles.safety} aria-label="Community content warning">
<IconAlertTriangle aria-hidden size={20} />
<p>
Workshop content is community-provided. Inspect source before installing it and keep a current Homarr backup.
</p>
</section>
<div className={styles.toolbar}>
<label className={styles.search}>
<span className="sr-only">Search Workshop</span>
<IconSearch aria-hidden size={18} />
<input
value={search}
onChange={(event) => {
setSearch(event.target.value);
setPage(1);
}}
placeholder="Search creations"
/>
</label>
<label>
Type
<select
value={type}
onChange={(event) => {
setType(event.target.value as typeof type);
setPage(1);
}}
>
<option value="all">All</option>
<option value="widget">Widgets</option>
<option value="css">CSS themes</option>
</select>
</label>
<label>
Sort
<select
value={sort}
onChange={(event) => {
setSort(event.target.value as typeof sort);
setPage(1);
}}
>
<option value="top">Top rated</option>
<option value="newest">Newest</option>
</select>
</label>
{user && (
<label className={styles.checkbox}>
<input
type="checkbox"
checked={mine}
onChange={(event) => {
setMine(event.target.checked);
setPage(1);
}}
/>{" "}
My submissions
</label>
)}
</div>
{error && (
<div className={styles.error} role="alert">
{error} <button onClick={() => void load()}>Try again</button>
</div>
)}
{loading ? (
<WorkshopSkeleton />
) : items.length ? (
<div className={styles.grid}>
{items.map((item) => (
<WorkshopCard client={client} item={item} key={item.id} />
))}
</div>
) : (
<section className={styles.empty}>
<h2>No creations found</h2>
<p>Adjust the filters, or share the first creation in this category.</p>
</section>
)}
{totalPages > 1 && (
<nav className={styles.pagination} aria-label="Workshop pages">
<button disabled={page === 1} onClick={() => setPage((value) => value - 1)}>
Previous
</button>
<span>
Page {page} of {totalPages}
</span>
<button disabled={page === totalPages} onClick={() => setPage((value) => value + 1)}>
Next
</button>
</nav>
)}
{showForm && (
<SubmissionForm
client={client}
onClose={() => setShowForm(false)}
onSaved={async () => {
setShowForm(false);
await load();
}}
/>
)}
</main>
);
}
function WorkshopCard({ client, item }: { client: WorkshopClient; item: WorkshopSubmissionSummary }) {
const image = item.screenshots[0];
return (
<article className={styles.card}>
{image ? (
<img src={client.fileUrl(item.id, image, "480x320")} alt="" loading="lazy" />
) : (
<div className={styles.cardPlaceholder} aria-hidden>
{item.type === "widget" ? "{}" : "CSS"}
</div>
)}
<div className={styles.cardBody}>
<div className={styles.cardMeta}>
<span>{item.type === "widget" ? "Widget" : "CSS theme"}</span>
<span>
{item.score >= 0 ? "+" : ""}
{item.score}
</span>
</div>
<h2>
<Link to={`/workshop/${item.id}`}>{item.title}</Link>
</h2>
<p>{item.description || "No description provided."}</p>
<small>
By {item.authorName} · revision {item.revision}
</small>
</div>
</article>
);
}
export function WorkshopDetailRoute() {
const client = useWorkshop();
const location = useLocation();
const id = location.pathname.split("/").filter(Boolean).at(-1) ?? "";
const history = useHistory();
const [user, setUser] = useState<WorkshopUser | null>(null);
const [submission, setSubmission] = useState<WorkshopSubmissionDetail | null>(null);
const [error, setError] = useState("");
const [reporting, setReporting] = useState(false);
const [editing, setEditing] = useState(false);
const [moderating, setModerating] = useState(false);
const load = useCallback(async () => {
try {
setSubmission(await client.get(id));
} catch (caught) {
setError(caught instanceof Error ? caught.message : "Submission not found");
}
}, [client, id]);
useEffect(() => client.subscribeToAuth(setUser), [client]);
useEffect(() => {
void client.refreshAuth();
void load();
}, [client, load]);
if (error)
return (
<main className={styles.page}>
<div className={styles.error} role="alert">
{error}
</div>
<Link to="/workshop">Back to Workshop</Link>
</main>
);
if (!submission) return <WorkshopSkeleton />;
const requireAuth = async () => client.currentUser ?? client.signInWithGitHub();
return (
<main className={styles.page}>
<Link to="/workshop">← Back to Workshop</Link>
<article className={styles.detail}>
<header>
<span className={styles.type}>{submission.type === "widget" ? "Widget" : "CSS theme"}</span>
<h1>{submission.title}</h1>
<p>{submission.description}</p>
<small>
By {submission.authorName} · revision {submission.revision}
</small>
</header>
{submission.screenshots.length > 0 && (
<div className={styles.gallery}>
{submission.screenshots.map((image, index) => (
<img
key={image}
src={client.fileUrl(submission.id, image, "960x640")}
alt={`${submission.title} screenshot ${index + 1}`}
/>
))}
</div>
)}
<div className={styles.detailActions}>
<button className="button button--primary" onClick={() => saveContent(submission)}>
<IconDownload size={17} /> Download {submission.type === "widget" ? "JSON" : "CSS"}
</button>
<button
className="button button--secondary"
onClick={() => void navigator.clipboard.writeText(submission.content)}
>
<IconCopy size={17} /> Copy source
</button>
<button
className="button button--secondary"
onClick={async () => {
await requireAuth();
await client.vote(submission.id, 1);
await load();
}}
>
Upvote ({submission.upvotes})
</button>
<button className="button button--secondary" onClick={() => setReporting(true)}>
<IconFlag size={16} /> Report
</button>
{user?.id === submission.author && (
<>
<button className="button button--secondary" onClick={() => setEditing(true)}>
Edit
</button>
<button
className="button button--danger"
onClick={async () => {
if (window.confirm("Delete this submission permanently?")) {
await client.delete(submission.id);
history.push("/workshop");
}
}}
>
Delete
</button>
</>
)}
{(user?.role === "moderator" || user?.role === "admin") && user.id !== submission.author && (
<button className="button button--danger" onClick={() => setModerating(true)}>
Remove submission
</button>
)}
</div>
<section>
<h2>Source</h2>
<p className={styles.sourceHint}>
Review this content before using it. Workshop content is never executed on homarr.dev.
</p>
<pre className={styles.source}>
<code>{submission.content}</code>
</pre>
</section>
{submission.changelog && (
<section>
<h2>Latest changes</h2>
<p>{submission.changelog}</p>
</section>
)}
</article>
{reporting && <ReportForm client={client} submissionId={submission.id} onClose={() => setReporting(false)} />}
{editing && (
<SubmissionForm
client={client}
existing={submission}
onClose={() => setEditing(false)}
onSaved={async () => {
setEditing(false);
await load();
}}
/>
)}
{moderating && (
<ModerationRemovalForm
client={client}
submission={submission}
onClose={() => setModerating(false)}
onRemoved={() => history.push("/workshop/admin")}
/>
)}
</main>
);
}
function ModerationRemovalForm({
client,
submission,
onClose,
onRemoved,
}: {
client: WorkshopClient;
submission: WorkshopSubmissionDetail;
onClose: () => void;
onRemoved: () => void;
}) {
const [reason, setReason] = useState("");
const [error, setError] = useState("");
const [pending, setPending] = useState(false);
return (
<div className={styles.overlay}>
<dialog className={styles.dialog} open aria-labelledby="remove-submission-title">
<h2 id="remove-submission-title">Remove {submission.title}</h2>
<p>
This permanently deletes the submission, files, votes, and reports. A metadata snapshot is retained in
moderation history.
</p>
<form
onSubmit={async (event) => {
event.preventDefault();
setPending(true);
try {
await client.removeSubmission(submission.id, reason);
onRemoved();
} catch (caught) {
setError(caught instanceof Error ? caught.message : "Removal failed");
setPending(false);
}
}}
>
<label>
Reason
<textarea
required
minLength={3}
maxLength={1000}
value={reason}
onChange={(event) => setReason(event.target.value)}
/>
</label>
{error && <p className={styles.error}>{error}</p>}
<div className={styles.formActions}>
<button type="button" className="button button--secondary" onClick={onClose}>
Cancel
</button>
<button disabled={pending} className="button button--danger">
{pending ? "Removing…" : "Remove permanently"}
</button>
</div>
</form>
</dialog>
</div>
);
}
function SubmissionForm({
client,
existing,
onClose,
onSaved,
}: {
client: WorkshopClient;
existing?: WorkshopSubmissionDetail;
onClose: () => void;
onSaved: () => void;
}) {
const [type, setType] = useState<WorkshopSubmissionType>(existing?.type ?? "widget");
const [title, setTitle] = useState(existing?.title ?? "");
const [description, setDescription] = useState(existing?.description ?? "");
const [content, setContent] = useState(existing?.content ?? "");
const [changelog, setChangelog] = useState(existing?.changelog ?? "");
const [screenshots, setScreenshots] = useState<File[]>([]);
const [removedScreenshots, setRemovedScreenshots] = useState<string[]>([]);
const [error, setError] = useState("");
const [pending, setPending] = useState(false);
const submit = async (event: React.FormEvent) => {
event.preventDefault();
setPending(true);
setError("");
const input: WorkshopSubmissionInput = { type, title, description, content, changelog };
try {
if (!client.currentUser) await client.signInWithGitHub();
if (existing) {
await client.update(existing.id, input, screenshots, removedScreenshots);
} else {
await client.create(input, screenshots);
}
await onSaved();
} catch (caught) {
setError(caught instanceof Error ? caught.message : "Could not save submission");
} finally {
setPending(false);
}
};
return (
<div
className={styles.overlay}
role="presentation"
onMouseDown={(event) => {
if (event.target === event.currentTarget) onClose();
}}
>
<dialog className={styles.dialog} open aria-labelledby="submission-form-title">
<h2 id="submission-form-title">{existing ? "Update creation" : "Share with the community"}</h2>
<form onSubmit={(event) => void submit(event)}>
<label>
Type
<select
disabled={Boolean(existing)}
value={type}
onChange={(event) => setType(event.target.value as WorkshopSubmissionType)}
>
<option value="widget">Custom widget</option>
<option value="css">CSS theme</option>
</select>
</label>
<label>
Title
<input
required
minLength={3}
maxLength={100}
value={title}
onChange={(event) => setTitle(event.target.value)}
/>
</label>
<label>
Description
<textarea maxLength={2000} value={description} onChange={(event) => setDescription(event.target.value)} />
</label>
<label>
{type === "widget" ? "Widget JSON" : "CSS source"}
<textarea
className={styles.editor}
required
value={content}
onChange={(event) => setContent(event.target.value)}
/>
</label>
<label>
Changelog
<textarea maxLength={2000} value={changelog} onChange={(event) => setChangelog(event.target.value)} />
</label>
<label>
Screenshots (PNG, JPG, or WebP, up to 5)
<input
type="file"
accept="image/png,image/jpeg,image/webp"
multiple
onChange={(event) => setScreenshots(Array.from(event.target.files ?? []).slice(0, 5))}
/>
</label>
{existing?.screenshots.map((screenshot) => (
<label className={styles.checkbox} key={screenshot}>
<input
type="checkbox"
checked={removedScreenshots.includes(screenshot)}
onChange={(event) =>
setRemovedScreenshots((current) =>
event.target.checked
? [...current, screenshot]
: current.filter((filename) => filename !== screenshot),
)
}
/>
Remove existing screenshot {screenshot}
</label>
))}
{error && (
<p className={styles.error} role="alert">
{error}
</p>
)}
<div className={styles.formActions}>
<button type="button" className="button button--secondary" onClick={onClose}>
Cancel
</button>
<button disabled={pending} className="button button--primary">
{pending ? "Saving…" : existing ? "Save update" : "Publish creation"}
</button>
</div>
</form>
</dialog>
</div>
);
}
function ReportForm({
client,
submissionId,
onClose,
}: {
client: WorkshopClient;
submissionId: string;
onClose: () => void;
}) {
const [category, setCategory] = useState<"malicious" | "spam" | "copyright" | "inappropriate" | "other">("malicious");
const [explanation, setExplanation] = useState("");
const [error, setError] = useState("");
return (
<div className={styles.overlay}>
<dialog className={styles.dialog} open aria-labelledby="report-title">
<h2 id="report-title">Report submission</h2>
<form
onSubmit={async (event) => {
event.preventDefault();
try {
if (!client.currentUser) await client.signInWithGitHub();
await client.report(submissionId, category, explanation);
onClose();
} catch (caught) {
setError(caught instanceof Error ? caught.message : "Could not send report");
}
}}
>
<label>
Reason
<select value={category} onChange={(event) => setCategory(event.target.value as typeof category)}>
<option value="malicious">Malicious</option>
<option value="spam">Spam</option>
<option value="copyright">Copyright</option>
<option value="inappropriate">Inappropriate</option>
<option value="other">Other</option>
</select>
</label>
<label>
Details
<textarea
required
minLength={3}
maxLength={1000}
value={explanation}
onChange={(event) => setExplanation(event.target.value)}
/>
</label>
{error && <p className={styles.error}>{error}</p>}
<div className={styles.formActions}>
<button type="button" className="button button--secondary" onClick={onClose}>
Cancel
</button>
<button className="button button--primary">Submit report</button>
</div>
</form>
</dialog>
</div>
);
}
function WorkshopSkeleton() {
return (
<div className={styles.grid} aria-label="Loading Workshop">
{Array.from({ length: 6 }, (_, index) => (
<div className={styles.skeleton} key={index} />
))}
</div>
);
}
@@ -1,6 +0,0 @@
import dayjs from "dayjs";
import relativeTime from "dayjs/plugin/relativeTime";
dayjs.extend(relativeTime);
export const formatRelativeTime = (date: string) => dayjs(date).fromNow();
@@ -1,349 +0,0 @@
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import dayjs from "dayjs";
import type { WorkshopComment, WorkshopSubmission, WorkshopVote } from "@site/src/lib/pocketbase";
import { getPocketBase } from "@site/src/lib/pocketbase";
import type { SubmissionType } from "@site/src/lib/workshop-schema";
import { schemaVersionByType, validateSubmissionContent } from "@site/src/lib/workshop-schema";
import { errorMessage } from "@site/src/lib/utils";
import { voteDelta } from "./workshop-utils";
export type SortKey = "top" | "new";
export type TypeFilter = "all" | "yours" | SubmissionType;
export interface SubmitInput {
type: SubmissionType;
title: string;
description: string;
content: string;
screenshots: File[];
}
export interface CommentActions {
fetch: (submissionId: string) => Promise<WorkshopComment[]>;
add: (submissionId: string, content: string) => Promise<WorkshopComment | null>;
update: (commentId: string, content: string) => Promise<WorkshopComment | null>;
delete: (commentId: string) => Promise<boolean>;
}
const sorters: Record<SortKey, (a: WorkshopSubmission, b: WorkshopSubmission) => number> = {
top: (a, b) => b.upvotes - b.downvotes - (a.upvotes - a.downvotes),
new: (a, b) => dayjs(b.created).valueOf() - dayjs(a.created).valueOf(),
};
const isNotFoundError = (error: unknown) =>
typeof error === "object" && error !== null && "status" in error && error.status === 404;
export const useWorkshop = (workshopUrl: string) => {
const pb = useMemo(() => getPocketBase(workshopUrl), [workshopUrl]);
const [submissions, setSubmissions] = useState<WorkshopSubmission[]>([]);
const [votes, setVotes] = useState<Record<string, WorkshopVote>>({});
const [user, setUser] = useState(pb.authStore.record);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const votingIds = useRef(new Set<string>());
const refreshVotes = useCallback(async () => {
if (!pb.authStore.isValid || !pb.authStore.record) {
setVotes({});
return;
}
const rows = await pb.collection("votes").getFullList<WorkshopVote>({
filter: pb.filter("user = {:id}", { id: pb.authStore.record.id }),
});
setVotes(Object.fromEntries(rows.map((row) => [row.submission, row])));
}, [pb]);
const refresh = useCallback(async () => {
setLoading(true);
setError(null);
try {
setSubmissions(await pb.collection("marketplace").getFullList<WorkshopSubmission>({ sort: "-created" }));
await refreshVotes();
} catch (caught) {
setError(errorMessage(caught, "Failed to load the workshop"));
} finally {
setLoading(false);
}
}, [pb, refreshVotes]);
useEffect(() => {
let cancelled = false;
const unsubscribe = pb.authStore.onChange(() => {
setUser(pb.authStore.record);
if (!pb.authStore.isValid) setVotes({});
});
const load = async () => {
if (pb.authStore.isValid) {
try {
await pb.collection("users").authRefresh();
} catch {
pb.authStore.clear();
}
}
if (!cancelled) await refresh();
};
void load();
return () => {
cancelled = true;
unsubscribe();
};
}, [pb, refresh]);
const ensureAuth = useCallback(async () => {
if (pb.authStore.isValid) {
try {
await pb.collection("users").authRefresh();
return true;
} catch {
pb.authStore.clear();
}
}
await pb.collection("users").authWithOAuth2({ provider: "github" });
await refreshVotes();
return pb.authStore.isValid;
}, [pb, refreshVotes]);
const requireUserId = useCallback(
async (action: string) => {
let authenticated = false;
try {
authenticated = await ensureAuth();
} catch (caught) {
setError(errorMessage(caught, `Sign in to ${action}`));
return null;
}
if (!authenticated) {
setError(`Sign in to ${action}`);
return null;
}
const userId = pb.authStore.record?.id;
if (!userId) {
setError(`Sign in to ${action}`);
return null;
}
return userId;
},
[pb, ensureAuth],
);
const login = useCallback(async () => {
try {
await ensureAuth();
setError(null);
} catch (caught) {
setError(errorMessage(caught, "Sign in failed"));
}
}, [ensureAuth]);
const vote = useCallback(
async (submissionId: string, value: 1 | -1) => {
if (votingIds.current.has(submissionId)) return;
votingIds.current.add(submissionId);
const userId = await requireUserId("vote");
if (!userId) {
votingIds.current.delete(submissionId);
return;
}
const prev = votes[submissionId];
const isToggleOff = prev?.value === value;
const [upD, downD] = voteDelta(prev?.value, value);
setVotes((v) => {
const next = { ...v };
if (isToggleOff) delete next[submissionId];
else
next[submissionId] = {
...(prev ?? { id: "", submission: submissionId, user: userId, created: "", updated: "" }),
value,
} as WorkshopVote;
return next;
});
setSubmissions((s) =>
s.map((sub) =>
sub.id === submissionId ? { ...sub, upvotes: sub.upvotes + upD, downvotes: sub.downvotes + downD } : sub,
),
);
try {
const existing = prev?.id ? prev : undefined;
if (!existing) {
const created = await pb
.collection("votes")
.create<WorkshopVote>({ submission: submissionId, value, user: userId });
setVotes((current) => ({ ...current, [submissionId]: created }));
} else if (isToggleOff) {
try {
await pb.collection("votes").delete(existing.id);
} catch (caught) {
if (!isNotFoundError(caught)) throw caught;
}
} else {
try {
const updated = await pb.collection("votes").update<WorkshopVote>(existing.id, { value });
setVotes((current) => ({ ...current, [submissionId]: updated }));
} catch (caught) {
if (!isNotFoundError(caught)) throw caught;
const created = await pb
.collection("votes")
.create<WorkshopVote>({ submission: submissionId, value, user: userId });
setVotes((current) => ({ ...current, [submissionId]: created }));
}
}
} catch (caught) {
setVotes((v) => {
const next = { ...v };
if (prev) next[submissionId] = prev;
else delete next[submissionId];
return next;
});
setSubmissions((s) =>
s.map((sub) =>
sub.id === submissionId ? { ...sub, upvotes: sub.upvotes - upD, downvotes: sub.downvotes - downD } : sub,
),
);
setError(errorMessage(caught, "Failed to register your vote"));
} finally {
votingIds.current.delete(submissionId);
}
},
[pb, votes, requireUserId],
);
const report = useCallback(
async (submissionId: string, reason: string) => {
try {
const userId = await requireUserId("report");
if (!userId) return;
await pb.collection("reports").create({ submission: submissionId, reason, user: userId });
} catch (caught) {
setError(errorMessage(caught, "Failed to submit your report"));
}
},
[pb, requireUserId],
);
const deleteSubmission = useCallback(
async (submissionId: string) => {
try {
await pb.collection("submissions").delete(submissionId);
setSubmissions((prev) => prev.filter((s) => s.id !== submissionId));
return true;
} catch (caught) {
setError(errorMessage(caught, "Failed to delete submission"));
return false;
}
},
[pb],
);
const submit = useCallback(
async (input: SubmitInput): Promise<boolean> => {
const validation = validateSubmissionContent(input.type, input.content);
if (!validation.success) throw new Error(validation.error);
if (!(await ensureAuth())) throw new Error("Sign in required to submit");
const userId = pb.authStore.record?.id;
if (!userId) throw new Error("Sign in required to submit");
const data = new FormData();
data.set("type", input.type);
data.set("title", input.title);
data.set("description", input.description);
data.set("schemaVersion", schemaVersionByType[input.type]);
data.set("content", input.content);
data.set("author", userId);
for (const file of input.screenshots) data.append("screenshots", file);
await pb.collection("submissions").create(data);
await refresh();
return true;
},
[pb, ensureAuth, refresh],
);
const fetchComments = useCallback(
(submissionId: string) =>
pb.collection("comments").getFullList<WorkshopComment>({
filter: pb.filter("submission = {:id}", { id: submissionId }),
sort: "-created",
expand: "author",
}),
[pb],
);
const addComment = useCallback(
async (submissionId: string, content: string) => {
const userId = await requireUserId("comment");
if (!userId) return null;
try {
return await pb
.collection("comments")
.create<WorkshopComment>({ submission: submissionId, content, author: userId }, { expand: "author" });
} catch (caught) {
setError(errorMessage(caught, "Failed to post comment"));
return null;
}
},
[pb, requireUserId],
);
const updateComment = useCallback(
async (commentId: string, content: string) => {
try {
return await pb.collection("comments").update<WorkshopComment>(commentId, { content }, { expand: "author" });
} catch (caught) {
setError(errorMessage(caught, "Failed to update comment"));
return null;
}
},
[pb],
);
const deleteComment = useCallback(
async (commentId: string) => {
try {
await pb.collection("comments").delete(commentId);
return true;
} catch (caught) {
setError(errorMessage(caught, "Failed to delete comment"));
return false;
}
},
[pb],
);
const logout = useCallback(() => pb.authStore.clear(), [pb]);
const comments = useMemo<CommentActions>(
() => ({
fetch: fetchComments,
add: addComment,
update: updateComment,
delete: deleteComment,
}),
[fetchComments, addComment, updateComment, deleteComment],
);
return {
pb,
submissions,
votes,
user,
loading,
error,
sorters,
comments,
refresh,
login,
logout,
vote,
report,
submit,
deleteSubmission,
};
};
@@ -1,16 +0,0 @@
import type { WorkshopSubmission } from "@site/src/lib/pocketbase";
export const downloadSubmissionJson = (submission: WorkshopSubmission) => {
const url = URL.createObjectURL(new Blob([submission.content], { type: "application/json" }));
Object.assign(document.createElement("a"), {
href: url,
download: `${submission.title.replace(/[^a-z0-9-_]+/gi, "-").toLowerCase()}.json`,
}).click();
URL.revokeObjectURL(url);
};
export const voteDelta = (prev: 1 | -1 | undefined, next: 1 | -1): [up: number, down: number] => {
if (!prev) return next === 1 ? [1, 0] : [0, 1];
if (prev === next) return next === 1 ? [-1, 0] : [0, -1];
return next === 1 ? [1, -1] : [-1, 1];
};
@@ -0,0 +1,299 @@
.page {
max-width: 1240px;
min-height: 70vh;
margin: 0 auto;
padding: 2.5rem 1.25rem 5rem;
}
.hero {
display: flex;
align-items: flex-end;
justify-content: space-between;
gap: 2rem;
margin-bottom: 1.5rem;
}
.hero h1,
.detail h1 {
margin: 0;
font-size: 2.25rem;
letter-spacing: -0.025em;
}
.hero p {
max-width: 65ch;
margin: 0.5rem 0 0;
color: var(--ifm-color-emphasis-700);
}
.accountActions,
.detailActions,
.formActions {
display: flex;
align-items: center;
flex-wrap: wrap;
gap: 0.75rem;
}
.accountActions :global(.button),
.detailActions :global(.button) {
display: inline-flex;
align-items: center;
gap: 0.4rem;
}
.safety {
display: flex;
align-items: flex-start;
gap: 0.75rem;
padding: 0.85rem 1rem;
margin-bottom: 1.5rem;
border: 1px solid var(--ifm-color-warning-dark);
border-radius: 10px;
background: color-mix(in srgb, var(--ifm-color-warning) 10%, transparent);
}
.safety p {
margin: 0;
}
.toolbar {
display: flex;
align-items: end;
flex-wrap: wrap;
gap: 0.75rem;
padding: 0.75rem;
margin-bottom: 1.5rem;
border: 1px solid var(--ifm-color-emphasis-300);
border-radius: 10px;
}
.toolbar label,
.dialog label {
display: grid;
gap: 0.3rem;
font-size: 0.8rem;
font-weight: 600;
}
.toolbar input,
.toolbar select,
.dialog input,
.dialog select,
.dialog textarea {
min-height: 44px;
padding: 0.55rem 0.7rem;
border: 1px solid var(--ifm-color-emphasis-400);
border-radius: 7px;
color: var(--ifm-font-color-base);
background: var(--ifm-background-surface-color);
font: inherit;
}
.search {
position: relative;
flex: 1 1 280px;
}
.search svg {
position: absolute;
left: 0.75rem;
bottom: 0.8rem;
color: var(--ifm-color-emphasis-600);
}
.search input {
width: 100%;
padding-left: 2.25rem;
}
.checkbox {
display: flex !important;
grid-auto-flow: column;
align-items: center;
min-height: 44px;
}
.checkbox input {
min-height: auto;
}
.grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(270px, 1fr));
gap: 1rem;
}
.card {
overflow: hidden;
border: 1px solid var(--ifm-color-emphasis-300);
border-radius: 12px;
background: var(--ifm-background-surface-color);
}
.card img,
.cardPlaceholder {
width: 100%;
aspect-ratio: 3 / 2;
object-fit: cover;
}
.cardPlaceholder {
display: grid;
place-items: center;
background: var(--ifm-color-emphasis-100);
color: var(--ifm-color-emphasis-600);
font: 700 1.5rem var(--ifm-font-family-monospace);
}
.cardBody {
padding: 1rem;
}
.cardBody h2 {
margin: 0.4rem 0;
font-size: 1.08rem;
}
.cardBody p {
min-height: 2.7em;
margin: 0 0 0.65rem;
color: var(--ifm-color-emphasis-700);
font-size: 0.9rem;
}
.cardMeta {
display: flex;
justify-content: space-between;
color: var(--ifm-color-emphasis-700);
font-size: 0.75rem;
font-weight: 700;
}
.error {
padding: 0.85rem 1rem;
margin: 1rem 0;
border-radius: 8px;
color: var(--ifm-color-danger-contrast-foreground);
background: var(--ifm-color-danger-contrast-background);
}
.empty {
padding: 4rem 1rem;
text-align: center;
border: 1px dashed var(--ifm-color-emphasis-400);
border-radius: 12px;
}
.pagination {
display: flex;
justify-content: center;
align-items: center;
gap: 1rem;
margin-top: 2rem;
}
.pagination button {
min-height: 44px;
padding: 0.5rem 0.8rem;
}
.skeleton {
min-height: 310px;
border-radius: 12px;
background: var(--ifm-color-emphasis-200);
animation: pulse 1.5s ease-in-out infinite;
}
.detail {
margin-top: 1.5rem;
}
.detail header {
max-width: 75ch;
}
.type {
display: inline-block;
margin-bottom: 0.5rem;
color: var(--ifm-color-primary);
font-weight: 700;
}
.gallery {
display: flex;
gap: 1rem;
overflow-x: auto;
margin: 1.5rem 0;
scroll-snap-type: x mandatory;
}
.gallery img {
width: min(720px, 85vw);
max-height: 480px;
object-fit: contain;
border-radius: 10px;
background: var(--ifm-color-emphasis-100);
scroll-snap-align: start;
}
.detailActions {
margin: 1.5rem 0 2rem;
}
.sourceHint {
color: var(--ifm-color-emphasis-700);
}
.source {
max-height: 560px;
overflow: auto;
padding: 1rem;
border-radius: 8px;
font-size: 0.78rem;
}
.overlay {
position: fixed;
z-index: 500;
inset: 0;
display: grid;
place-items: center;
padding: 1rem;
background: rgb(0 0 0 / 0.7);
}
.dialog {
width: min(680px, 100%);
max-height: 90vh;
overflow-y: auto;
padding: 1.25rem;
border-radius: 12px;
background: var(--ifm-background-color);
}
.dialog form {
display: grid;
gap: 1rem;
}
.dialog textarea {
min-height: 88px;
resize: vertical;
}
.dialog .editor {
min-height: 240px;
font-family: var(--ifm-font-family-monospace);
font-size: 0.78rem;
}
.formActions {
justify-content: flex-end;
}
@keyframes pulse {
50% {
opacity: 0.55;
}
}
@media (max-width: 700px) {
.hero {
align-items: stretch;
flex-direction: column;
}
.accountActions > * {
flex: 1;
justify-content: center;
}
.toolbar > label {
flex: 1 1 100%;
}
}
@media (prefers-reduced-motion: reduce) {
.skeleton {
animation: none;
}
}
.adminSection {
margin: 2rem 0;
}
.tableWrap {
overflow-x: auto;
}
.tableWrap table {
width: 100%;
border-collapse: collapse;
}
.tableWrap th,
.tableWrap td {
padding: 0.7rem;
border-bottom: 1px solid var(--ifm-color-emphasis-300);
text-align: left;
vertical-align: top;
}
.rowActions {
display: flex;
flex-wrap: wrap;
gap: 0.4rem;
}
.rowActions button {
min-height: 36px;
}
+14 -179
View File
@@ -4,53 +4,9 @@
@custom-variant dark (&:where([data-theme="dark"], [data-theme="dark"] *));
/* shadcn preset b4gOK4dHc — base-nova style, mist base color */
@theme inline {
@theme {
--spacing-128: 32rem;
--breakpoint-3xl: 1697px;
--font-heading: var(--font-sans);
--font-sans: "Work Sans", sans-serif;
--color-background: var(--background);
--color-foreground: var(--foreground);
--color-card: var(--card);
--color-card-foreground: var(--card-foreground);
--color-popover: var(--popover);
--color-popover-foreground: var(--popover-foreground);
--color-primary: var(--primary);
--color-primary-foreground: var(--primary-foreground);
--color-secondary: var(--secondary);
--color-secondary-foreground: var(--secondary-foreground);
--color-muted: var(--muted);
--color-muted-foreground: var(--muted-foreground);
--color-accent: var(--accent);
--color-accent-foreground: var(--accent-foreground);
--color-destructive: var(--destructive);
--color-border: var(--border);
--color-input: var(--input);
--color-ring: var(--ring);
--color-chart-1: var(--chart-1);
--color-chart-2: var(--chart-2);
--color-chart-3: var(--chart-3);
--color-chart-4: var(--chart-4);
--color-chart-5: var(--chart-5);
--color-sidebar: var(--sidebar);
--color-sidebar-foreground: var(--sidebar-foreground);
--color-sidebar-primary: var(--sidebar-primary);
--color-sidebar-primary-foreground: var(--sidebar-primary-foreground);
--color-sidebar-accent: var(--sidebar-accent);
--color-sidebar-accent-foreground: var(--sidebar-accent-foreground);
--color-sidebar-border: var(--sidebar-border);
--color-sidebar-ring: var(--sidebar-ring);
--radius-sm: calc(var(--radius) * 0.6);
--radius-md: calc(var(--radius) * 0.8);
--radius-lg: var(--radius);
--radius-xl: calc(var(--radius) * 1.4);
--radius-2xl: calc(var(--radius) * 1.8);
--radius-3xl: calc(var(--radius) * 2.2);
--radius-4xl: calc(var(--radius) * 2.6);
}
:root {
@@ -67,52 +23,14 @@
--aa-primary-color-rgb: #fa5252 !important;
--aa-muted-color-rgb: #fa5252 !important;
--aa-background-color-rgb: 200, 200, 200 !important;
--aa-background-color-rgb: 200,200,200 !important;
--ifm-font-family-base: "Work Sans", sans-serif;
--ifm-font-family-base: 'Work Sans', sans-serif;
--ifm-color-content-inverse: var(--ifm-color-emphasis-100);
/* Bridge Docusaurus to shadcn theme */
--ifm-background-color: var(--background);
--ifm-font-color-base: var(--foreground);
--ifm-navbar-background-color: var(--card);
--ifm-footer-background-color: var(--card);
--radius: 0.45rem;
--background: oklch(1 0 0);
--foreground: oklch(0.148 0.004 228.8);
--card: oklch(1 0 0);
--card-foreground: oklch(0.148 0.004 228.8);
--popover: oklch(1 0 0);
--popover-foreground: oklch(0.148 0.004 228.8);
--primary: oklch(0.514 0.222 16.935);
--primary-foreground: oklch(0.969 0.015 12.422);
--secondary: oklch(0.967 0.001 286.375);
--secondary-foreground: oklch(0.21 0.006 285.885);
--muted: oklch(0.963 0.002 197.1);
--muted-foreground: oklch(0.56 0.021 213.5);
--accent: oklch(0.963 0.002 197.1);
--accent-foreground: oklch(0.218 0.008 223.9);
--destructive: oklch(0.577 0.245 27.325);
--border: oklch(0.925 0.005 214.3);
--input: oklch(0.925 0.005 214.3);
--ring: oklch(0.723 0.014 214.4);
--chart-1: oklch(0.808 0.114 19.571);
--chart-2: oklch(0.637 0.237 25.331);
--chart-3: oklch(0.577 0.245 27.325);
--chart-4: oklch(0.505 0.213 27.518);
--chart-5: oklch(0.444 0.177 26.899);
--sidebar: oklch(0.987 0.002 197.1);
--sidebar-foreground: oklch(0.148 0.004 228.8);
--sidebar-primary: oklch(0.586 0.253 17.585);
--sidebar-primary-foreground: oklch(0.969 0.015 12.422);
--sidebar-accent: oklch(0.963 0.002 197.1);
--sidebar-accent-foreground: oklch(0.218 0.008 223.9);
--sidebar-border: oklch(0.925 0.005 214.3);
--sidebar-ring: oklch(0.723 0.014 214.4);
}
[data-theme="dark"] {
/* For readability concerns, you should choose a lighter palette in dark mode. */
[data-theme='dark'] {
--ifm-color-primary: #fa5252;
--ifm-color-primary-dark: #f93232;
--ifm-color-primary-darker: #f92222;
@@ -121,68 +39,13 @@
--ifm-color-primary-lighter: #fb8282;
--ifm-color-primary-lightest: #fdc3c3;
--docusaurus-highlighted-code-line-bg: rgba(0, 0, 0, 0.3);
--homarr-secondary-bg: oklch(0.218 0.008 223.9);
--homarr-secondary-bg: #181818;
--ifm-color-content-inverse: var(--ifm-color-emphasis-100);
--aa-primary-color-rgb: #fa5252 !important;
--aa-muted-color-rgb: #fa5252 !important;
--aa-background-color-rgb: 24, 24, 24 !important;
--background: oklch(0.148 0.004 228.8);
--foreground: oklch(0.987 0.002 197.1);
--card: oklch(0.218 0.008 223.9);
--card-foreground: oklch(0.987 0.002 197.1);
--popover: oklch(0.218 0.008 223.9);
--popover-foreground: oklch(0.987 0.002 197.1);
--primary: oklch(0.455 0.188 13.697);
--primary-foreground: oklch(0.969 0.015 12.422);
--secondary: oklch(0.274 0.006 286.033);
--secondary-foreground: oklch(0.985 0 0);
--muted: oklch(0.275 0.011 216.9);
--muted-foreground: oklch(0.723 0.014 214.4);
--accent: oklch(0.275 0.011 216.9);
--accent-foreground: oklch(0.987 0.002 197.1);
--destructive: oklch(0.704 0.191 22.216);
--border: oklch(1 0 0 / 10%);
--input: oklch(1 0 0 / 15%);
--ring: oklch(0.56 0.021 213.5);
--chart-1: oklch(0.808 0.114 19.571);
--chart-2: oklch(0.637 0.237 25.331);
--chart-3: oklch(0.577 0.245 27.325);
--chart-4: oklch(0.505 0.213 27.518);
--chart-5: oklch(0.444 0.177 26.899);
--sidebar: oklch(0.21 0.006 285.885);
--sidebar-foreground: oklch(0.987 0.002 197.1);
--sidebar-primary: oklch(0.645 0.246 16.439);
--sidebar-primary-foreground: oklch(0.969 0.015 12.422);
--sidebar-accent: oklch(0.275 0.011 216.9);
--sidebar-accent-foreground: oklch(0.987 0.002 197.1);
--sidebar-border: oklch(1 0 0 / 10%);
--sidebar-ring: oklch(0.56 0.021 213.5);
}
/* Reset form elements that don't inherit by default (no preflight in Docusaurus).
Must live inside @layer theme so layer(utilities) wins in the cascade. */
@layer theme {
:where(main.marketplace, [data-slot="dialog-portal"]) :where(button, input, select, textarea) {
font-family: inherit;
font-size: inherit;
line-height: inherit;
color: inherit;
background: transparent;
border: none;
padding: 0;
margin: 0;
}
:where(main.marketplace, [data-slot="dialog-portal"]) :where(button) {
cursor: pointer;
}
:where([data-slot="dialog-portal"]) {
font-family: var(--ifm-font-family-base);
color: var(--foreground);
}
--aa-background-color-rgb: 24,24,24 !important;
}
.aa-DetachedOverlay {
@@ -217,7 +80,7 @@
}
.footer__copyright span.copyright_text {
opacity: 0.6;
opacity: .6;
}
.medium-zoom-overlay {
@@ -234,7 +97,7 @@
.announcementBar_node_modules-\@docusaurus-theme-classic-lib-theme-AnnouncementBar-styles-module {
background-color: transparent !important;
background-image: radial-gradient(circle, rgb(253, 126, 20) 0%, rgb(250, 82, 82) 100%) !important;
background-image: radial-gradient(circle, rgb(253, 126, 20) 0%, rgb(250, 82, 82) 100%) !important;
font-weight: 600;
}
@@ -272,7 +135,7 @@ html[data-theme="dark"] div#__docusaurus div[role="banner"] button svg {
color: var(--ifm-color-primary);
}
:is(p, h3, h4, h5, h6):has(+ :is(ul, ol)) {
:is(p, h3, h4, h5, h6):has(+ :is(ul, ol)){
margin-bottom: 4px !important;
}
@@ -301,6 +164,7 @@ html[data-theme="dark"] div#__docusaurus div[role="banner"] button svg {
padding: 6px 12px;
}
.hero-cards {
transform: rotate(20deg) scale(0.8) skew(21deg);
-webkit-transform: rotate(20deg) scale(0.8) skew(21deg);
@@ -316,7 +180,7 @@ html[data-theme="dark"] div#__docusaurus div[role="banner"] button svg {
}
.app-card img {
transition: transform 0.1s ease-in-out;
transition: transform 0.1s ease-in-out;
}
.app-card:hover img {
@@ -324,7 +188,7 @@ html[data-theme="dark"] div#__docusaurus div[role="banner"] button svg {
}
.hover-animation {
animation: hover-animation 5s ease-in-out forwards;
animation: hover-animation 5s ease-in-out forwards;
animation-iteration-count: infinite;
}
@@ -339,32 +203,3 @@ html[data-theme="dark"] div#__docusaurus div[role="banner"] button svg {
transform: translateY(0);
}
}
.submit-step-enter {
animation: submit-step-enter 0.2s ease-in-out forwards;
}
.submit-step-from-right {
--submit-step-x: 80px;
}
.submit-step-from-left {
--submit-step-x: -80px;
}
@media (prefers-reduced-motion: reduce) {
.submit-step-enter {
animation: none;
}
}
@keyframes submit-step-enter {
from {
transform: translateX(var(--submit-step-x, 80px));
opacity: 0;
}
to {
transform: translateX(0);
opacity: 1;
}
}
-52
View File
@@ -1,52 +0,0 @@
import PocketBase from "pocketbase";
let client: PocketBase | undefined;
let clientUrl: string | undefined;
export const getPocketBase = (url: string): PocketBase => {
if (!client || clientUrl !== url) {
client = new PocketBase(url);
clientUrl = url;
}
return client;
};
export const getSubmissionFileUrl = (baseUrl: string, submissionId: string, filename: string) =>
`${baseUrl}/api/files/submissions/${submissionId}/${encodeURIComponent(filename)}`;
export interface WorkshopSubmission {
id: string;
collectionId: string;
collectionName: string;
type: "css" | "widget";
title: string;
description: string;
schemaVersion: string;
content: string;
screenshots: string[];
upvotes: number;
downvotes: number;
commentCount: number;
version: number;
changelog: string;
author: string;
authorName: string;
created: string;
}
export interface WorkshopVote {
id: string;
submission: string;
user: string;
value: 1 | -1;
}
export interface WorkshopComment {
id: string;
submission: string;
author: string;
content: string;
created: string;
updated: string;
expand?: { author?: { id: string; name?: string; username?: string } };
}
-11
View File
@@ -1,11 +0,0 @@
import type { ClassValue } from "clsx";
import { clsx } from "clsx";
import { twMerge } from "tailwind-merge";
export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs));
}
export function errorMessage(caught: unknown, fallback: string) {
return caught instanceof Error ? caught.message : fallback;
}
-49
View File
@@ -1,49 +0,0 @@
import { describe, expect, it } from "vitest";
import { validateSubmissionContent } from "./workshop-schema";
const validWidget = {
$schema: "homarr-custom-widget-v2",
name: "Example",
url: "https://example.com/api",
authType: "none",
method: "GET",
displayType: "singleValue",
displayConfig: { type: "singleValue", jsonPath: "$.count", label: "Count", unit: "" },
};
describe("validateSubmissionContent", () => {
it("accepts a valid homarr-custom-widget-v2 JSON", () => {
expect(validateSubmissionContent("widget", JSON.stringify(validWidget)).success).toBe(true);
});
it("rejects an old/unknown widget schema version", () => {
const result = validateSubmissionContent(
"widget",
JSON.stringify({ ...validWidget, $schema: "homarr-custom-widget-v1" }),
);
expect(result.success).toBe(false);
});
it("rejects widgets without a schema version", () => {
const widgetWithoutSchema = {
name: validWidget.name,
url: validWidget.url,
authType: validWidget.authType,
method: validWidget.method,
displayType: validWidget.displayType,
displayConfig: validWidget.displayConfig,
};
expect(validateSubmissionContent("widget", JSON.stringify(widgetWithoutSchema)).success).toBe(false);
});
it("rejects malformed widget JSON", () => {
expect(validateSubmissionContent("widget", "{ not json").success).toBe(false);
});
it("accepts non-empty CSS and rejects empty CSS", () => {
expect(validateSubmissionContent("css", ".card { color: red; }").success).toBe(true);
expect(validateSubmissionContent("css", " ").success).toBe(false);
});
});
-78
View File
@@ -1,78 +0,0 @@
import { z } from "zod";
export type SubmissionType = "css" | "widget";
export const WIDGET_SCHEMA_VERSION = "homarr-custom-widget-v2";
export const CSS_SCHEMA_VERSION = "homarr-custom-css-v1";
export const MAX_CSS_LENGTH = 16384;
export type WorkshopValidationResult = { success: true; data: unknown } | { success: false; error: string };
const authTypes = ["none", "bearer", "basic", "apiKeyHeader", "apiKeyQuery"] as const;
const methods = ["GET", "POST", "PUT", "DELETE", "PATCH"] as const;
const displayTypes = [
"singleValue",
"keyValue",
"table",
"statGrid",
"progressBars",
"statusIndicator",
"countGrid",
"raw",
"actionButton",
"customJsx",
] as const;
const widgetSchema = z
.object({
$schema: z.literal(WIDGET_SCHEMA_VERSION),
name: z.string().min(1).max(128),
url: z.string().min(1),
authType: z.enum(authTypes),
method: z.enum(methods),
displayType: z.enum(displayTypes),
displayConfig: z.object({ type: z.enum(displayTypes) }),
})
.refine((widget) => widget.displayConfig.type === widget.displayType, {
message: "displayConfig.type must match displayType",
});
const validateWidgetContent = (raw: string): WorkshopValidationResult => {
let parsed: unknown;
try {
parsed = JSON.parse(raw);
} catch {
return { success: false, error: "Content is not valid JSON" };
}
const result = widgetSchema.safeParse(parsed);
if (!result.success) {
return { success: false, error: result.error.issues[0]?.message ?? "Invalid custom widget JSON" };
}
return { success: true, data: result.data };
};
const validateCssContent = (raw: string): WorkshopValidationResult => {
if (raw.trim().length === 0) {
return { success: false, error: "CSS cannot be empty" };
}
if (raw.length > MAX_CSS_LENGTH) {
return { success: false, error: `CSS must be at most ${MAX_CSS_LENGTH} characters` };
}
return { success: true, data: raw };
};
export const submissionValidators = {
widget: validateWidgetContent,
css: validateCssContent,
} satisfies Record<SubmissionType, (raw: string) => WorkshopValidationResult>;
export const schemaVersionByType = {
widget: WIDGET_SCHEMA_VERSION,
css: CSS_SCHEMA_VERSION,
} satisfies Record<SubmissionType, string>;
export const validateSubmissionContent = (type: SubmissionType, raw: string): WorkshopValidationResult =>
submissionValidators[type](raw);
@@ -0,0 +1,16 @@
import React from "react";
import BrowserOnly from "@docusaurus/BrowserOnly";
import Layout from "@theme/Layout";
export default function WorkshopAdminPage() {
return (
<Layout title="Workshop moderation" noFooter>
<BrowserOnly fallback={<main style={{ minHeight: "70vh" }} />}>
{() => {
const { WorkshopAdmin } = require("@site/src/components/workshop/WorkshopAdmin");
return <WorkshopAdmin />;
}}
</BrowserOnly>
</Layout>
);
}
+7 -13
View File
@@ -1,22 +1,16 @@
import React from "react";
import BrowserOnly from "@docusaurus/BrowserOnly";
import useDocusaurusContext from "@docusaurus/useDocusaurusContext";
import Layout from "@theme/Layout";
export default function WorkshopPage() {
const { siteConfig } = useDocusaurusContext();
const configuredWorkshopUrl = (siteConfig.customFields?.workshopUrl as string | undefined) ?? "";
return (
<Layout title="Workshop" description="Community custom CSS and custom widgets for Homarr">
<main className="marketplace bg-background text-foreground min-h-[80vh]">
<BrowserOnly fallback={<div style={{ minHeight: "50vh" }} />}>
{() => {
const { WorkshopApp } = require("@site/src/components/workshop/WorkshopApp");
return <WorkshopApp workshopUrl={configuredWorkshopUrl || window.location.origin} />;
}}
</BrowserOnly>
</main>
<Layout title="Workshop" description="Community widgets and CSS themes for Homarr">
<BrowserOnly fallback={<main style={{ minHeight: "70vh" }} />}>
{() => {
const { WorkshopApp } = require("@site/src/components/workshop/WorkshopPage");
return <WorkshopApp />;
}}
</BrowserOnly>
</Layout>
);
}
-1
View File
@@ -15,7 +15,6 @@
"jsx": "preserve",
"incremental": true,
"paths": {
"@/*": ["./src/*"],
"@site/*": ["./*"]
},
},
+1
View File
@@ -50,6 +50,7 @@
"@homarr/validation": "workspace:^0.1.0",
"@homarr/websocket": "workspace:^0.1.0",
"@homarr/widgets": "workspace:^0.1.0",
"@homarr/workshop": "workspace:^0.1.0",
"@mantine/colors-generator": "catalog:",
"@mantine/core": "catalog:",
"@mantine/dropzone": "catalog:",
@@ -11,7 +11,9 @@ import { IconInfoCircle } from "@tabler/icons-react";
import { useForm } from "@homarr/form";
import { useI18n, useScopedI18n } from "@homarr/translation/client";
import { InstallCssFromWorkshopButton } from "~/components/workshop-install/install-from-workshop-buttons";
import { PublishToWorkshopButton } from "~/components/workshop/workshop-browser";
import { UseCssFromWorkshopButton } from "~/components/workshop/workshop-actions";
import type { Board } from "../../_types";
import { useSavePartialSettingsMutation } from "./_shared";
import classes from "./customcss.module.css";
@@ -47,7 +49,10 @@ export const CustomCssSettingsContent = ({ board }: Props) => {
</Alert>
<Group justify="space-between">
<InstallCssFromWorkshopButton onSelect={(css) => form.setFieldValue("customCss", css)} />
<Group>
<UseCssFromWorkshopButton onUse={(css) => form.setFieldValue("customCss", css)} />
<PublishToWorkshopButton type="css" defaultTitle={board.name} getContent={() => form.values.customCss} />
</Group>
<Button type="submit" loading={isPending}>
{t("common.action.saveChanges")}
</Button>
@@ -1,6 +1,5 @@
"use client";
import { useEffect, useState } from "react";
import { Button, Popover, Stack, Text } from "@mantine/core";
import { useDisclosure } from "@mantine/hooks";
import { IconCopy, IconSparkles } from "@tabler/icons-react";
@@ -297,11 +296,8 @@ interface CopyAiPromptButtonProps {
export const CopyAiPromptButton = ({ rawResponse, currentConfig }: CopyAiPromptButtonProps) => {
const t = useScopedI18n("customWidget");
const [opened, { open, close }] = useDisclosure(false);
const [mounted, setMounted] = useState(false);
const { data: schema, isLoading } = clientApi.customWidget.schema.useQuery();
useEffect(() => setMounted(true), []);
const handleCopy = async () => {
if (!schema) return;
const prompt = buildAiPrompt(schema, rawResponse, currentConfig);
@@ -321,8 +317,8 @@ export const CopyAiPromptButton = ({ rawResponse, currentConfig }: CopyAiPromptB
variant="light"
leftSection={<IconSparkles size={16} />}
onClick={open}
loading={!mounted || isLoading}
disabled={!mounted || isLoading || !schema}
loading={isLoading}
disabled={!schema}
fullWidth
size="sm"
>
@@ -6,6 +6,7 @@ import {
IconCopy,
IconDots,
IconDownload,
IconShare3,
IconToggleLeft,
IconToggleRight,
IconTrash,
@@ -19,6 +20,7 @@ import { showErrorNotification, showSuccessNotification } from "@homarr/notifica
import { useScopedI18n } from "@homarr/translation/client";
import { MobileAffixButton } from "~/components/manage/mobile-affix-button";
import { PublishToWorkshopButton } from "~/components/workshop/workshop-browser";
const iconProps = { size: 16, stroke: 1.5 };
@@ -30,6 +32,7 @@ interface WidgetRef {
export const CustomWidgetRowActions = ({ widget }: { widget: WidgetRef }) => {
const t = useScopedI18n("customWidget");
const workshopT = useScopedI18n("workshop");
const { openConfirmModal } = useConfirmModal();
const deleteMutation = clientApi.customWidget.delete.useMutation();
const duplicateMutation = clientApi.customWidget.duplicate.useMutation();
@@ -141,6 +144,16 @@ export const CustomWidgetRowActions = ({ widget }: { widget: WidgetRef }) => {
<Menu.Item onClick={() => void handleExport()} leftSection={<IconDownload {...iconProps} />}>
{t("action.export")}
</Menu.Item>
<PublishToWorkshopButton
type="widget"
defaultTitle={widget.name}
getContent={async () => JSON.stringify(await utils.customWidget.export.fetch({ id: widget.id }), null, 2)}
renderTrigger={(open) => (
<Menu.Item onClick={open} leftSection={<IconShare3 {...iconProps} />}>
{workshopT("action.share")}
</Menu.Item>
)}
/>
<Menu.Divider />
<Menu.Item
color="red"
@@ -30,11 +30,9 @@ import { useScopedI18n } from "@homarr/translation/client";
import type {
CustomWidgetAuthType,
CustomWidgetDisplayType,
CustomWidgetImport,
CustomWidgetMethod,
CustomWidgetSecretKind,
} from "@homarr/validation/custom-widget";
import { customWidgetImportSchema } from "@homarr/validation/custom-widget";
import type { displayConfigSchema } from "@homarr/validation/custom-widget";
import { JsonPathTreePicker } from "@homarr/widgets/_inputs/json-path-tree-picker";
@@ -469,90 +467,6 @@ const serverToFormFieldMap: Record<string, Record<string, string>> = {
customJsx: { template: "template" },
};
const importToFormValues: Record<string, (config: Record<string, unknown>) => Record<string, unknown>> = {
singleValue: (c) => ({
jsonPath: c.jsonPath,
label: c.label ?? "",
unit: c.unit ?? "",
valueSize: c.valueSize ?? "lg",
labelPosition: c.labelPosition ?? "below",
}),
keyValue: (c) => ({
mappings:
(c.mappings as Array<Record<string, string>>)?.map((m) => ({
label: m.label,
jsonPath: m.jsonPath,
unit: m.unit ?? "",
})) ?? [],
kvLayout: c.layout ?? "list",
kvColumns: c.columns ?? 2,
}),
table: (c) => ({
tablePath: c.tablePath,
columns: c.columns ?? [],
striped: c.striped ?? true,
compact: c.compact ?? false,
}),
statGrid: (c) => ({
statGridItems: c.items ?? [],
statGridColumns: c.columns ?? 2,
cardStyle: c.cardStyle ?? "filled",
}),
progressBars: (c) => ({
progressBars:
(c.bars as Array<Record<string, unknown>>)?.map((b) => ({
label: b.label ?? "",
valuePath: b.valuePath ?? "",
maxPath: b.maxPath ?? "",
unit: b.unit ?? "",
color: b.color ?? "blue",
})) ?? [],
showPercentage: c.showPercentage ?? true,
barSize: c.barSize ?? "md",
}),
statusIndicator: (c) => ({
statusItems:
(c.items as Array<Record<string, unknown>>)?.map((i) => ({
label: i.label ?? "",
jsonPath: i.jsonPath ?? "",
goodValues: Array.isArray(i.goodValues) ? (i.goodValues as string[]).join(", ") : String(i.goodValues ?? ""),
})) ?? [],
statusLayout: c.layout ?? "list",
dotSize: c.dotSize ?? "md",
}),
countGrid: (c) => ({
countGridItems: c.items ?? [],
countGridColumns: c.columns ?? 2,
countValueSize: c.valueSize ?? "md",
}),
raw: (c) => ({ rawJsonPath: c.jsonPath ?? "$", rawMaxHeight: c.maxHeight ?? 300 }),
actionButton: (c) => ({
buttonLabel: c.buttonLabel ?? "",
buttonColor: c.buttonColor ?? "blue",
confirmText: c.confirmText ?? "",
successMessage: c.successMessage ?? "",
}),
customJsx: (c) => ({ template: c.template ?? "" }),
};
function importJsonToFormValues(imported: CustomWidgetImport): Partial<z.infer<typeof formSchema>> {
const displayFields =
importToFormValues[imported.displayType]?.(imported.displayConfig as unknown as Record<string, unknown>) ?? {};
return {
name: imported.name,
description: imported.description ?? "",
iconUrl: imported.iconUrl ?? "",
url: imported.url,
authType: imported.authType,
headerName: imported.headerName ?? "",
method: imported.method,
requestBody: imported.requestBody ?? "",
displayType: imported.displayType,
...displayFields,
secrets: [],
};
}
function extractServerErrors(err: unknown, displayType: string): Record<string, string> {
const errors: Record<string, string> = {};
const trpcErr = err as {
@@ -767,32 +681,6 @@ export function CustomWidgetForm({ mode, initialValues, definitionId }: CustomWi
}
}, [getPreviewInput, previewMutation]);
useEffect(() => {
if (mode !== "create") return;
const onPaste = (e: ClipboardEvent) => {
const raw = e.clipboardData?.getData("text/plain");
if (!raw) return;
let parsed: unknown;
try {
parsed = JSON.parse(raw);
} catch {
return;
}
const result = customWidgetImportSchema.safeParse(parsed);
if (!result.success) return;
e.preventDefault();
const formValues = importJsonToFormValues(result.data);
form.setValues({ ...defaultCreateValues, ...formValues });
form.clearErrors();
showSuccessNotification({
title: t("action.paste"),
message: t("notification.imported", { name: result.data.name }),
});
};
document.addEventListener("paste", onPaste);
return () => document.removeEventListener("paste", onPaste);
}, [form, t, mode]);
useEffect(() => {
if (previewRefreshSignal > 0 && hasTestedRef.current) {
void handlePreviewTest();
@@ -1,16 +1,11 @@
"use client";
import { useEffect } from "react";
import { ActionIcon, Avatar, Badge, Group, Stack, Table, Text } from "@mantine/core";
import { IconApi, IconPencil } from "@tabler/icons-react";
import type { RouterOutputs } from "@homarr/api";
import { clientApi } from "@homarr/api/client";
import { revalidatePathActionAsync } from "@homarr/common/client";
import { showErrorNotification, showSuccessNotification } from "@homarr/notifications";
import { useScopedI18n } from "@homarr/translation/client";
import { Link } from "@homarr/ui";
import { customWidgetImportSchema } from "@homarr/validation/custom-widget";
import { NoResults } from "~/components/no-results";
import { CustomWidgetRowActions } from "./_custom-widget-actions";
@@ -36,36 +31,6 @@ interface CustomWidgetListProps {
export const CustomWidgetList = ({ definitions }: CustomWidgetListProps) => {
const t = useScopedI18n("customWidget");
const utils = clientApi.useUtils();
const importMutation = clientApi.customWidget.import.useMutation({
onSuccess: () => {
showSuccessNotification({ title: t("action.paste"), message: "Custom widget imported" });
void utils.customWidget.all.invalidate();
void revalidatePathActionAsync("/manage/custom-widgets");
},
onError: (err) => {
showErrorNotification({ title: t("action.paste"), message: err.message || "Failed to import" });
},
});
useEffect(() => {
const onPaste = (e: ClipboardEvent) => {
const raw = e.clipboardData?.getData("text/plain");
if (!raw) return;
let parsed: unknown;
try {
parsed = JSON.parse(raw);
} catch {
return;
}
const result = customWidgetImportSchema.safeParse(parsed);
if (!result.success) return;
e.preventDefault();
importMutation.mutate(result.data);
};
document.addEventListener("paste", onPaste);
return () => document.removeEventListener("paste", onPaste);
}, [importMutation.mutate]);
if (definitions.length === 0) {
return (
@@ -79,40 +44,36 @@ export const CustomWidgetList = ({ definitions }: CustomWidgetListProps) => {
return (
<Stack gap="md">
<Table striped highlightOnHover withTableBorder layout="fixed">
<Table striped highlightOnHover withTableBorder>
<Table.Thead>
<Table.Tr>
<Table.Th>{t("table.name")}</Table.Th>
<Table.Th w="30%" visibleFrom="sm">
{t("table.url")}
</Table.Th>
<Table.Th w={120} visibleFrom="xs">
{t("table.display")}
</Table.Th>
<Table.Th>{t("table.url")}</Table.Th>
<Table.Th>{t("table.display")}</Table.Th>
<Table.Th w={90} />
</Table.Tr>
</Table.Thead>
<Table.Tbody>
{definitions.map((def) => (
<Table.Tr key={def.id} style={{ opacity: def.enabled ? undefined : 0.5 }}>
<Table.Td style={{ overflow: "hidden" }}>
<Table.Td>
<Group gap="xs" wrap="nowrap">
{def.iconUrl ? (
<Avatar size={20} radius="sm" src={def.iconUrl} styles={{ image: { objectFit: "contain" } }} />
) : (
<IconApi size={16} style={{ flexShrink: 0 }} />
<IconApi size={16} />
)}
<Text size="sm" fw={500} truncate c={def.enabled ? undefined : "dimmed"}>
<Text size="sm" fw={500} lineClamp={1} c={def.enabled ? undefined : "dimmed"} style={{ minWidth: 0 }}>
{def.name}
</Text>
</Group>
</Table.Td>
<Table.Td style={{ overflow: "hidden" }} visibleFrom="sm">
<Text size="sm" c="dimmed" truncate>
<Table.Td>
<Text size="sm" c="dimmed" lineClamp={1}>
{def.url}
</Text>
</Table.Td>
<Table.Td visibleFrom="xs">
<Table.Td>
<Badge color={displayTypeBadgeColors[def.displayType] ?? "gray"} size="sm">
{t(`displayType.${def.displayType}` as never)}
</Badge>
@@ -9,7 +9,7 @@ import { Link } from "@homarr/ui";
import { ManagePageLayout } from "~/components/manage/manage-page-layout";
import { MobileAffixButton } from "~/components/manage/mobile-affix-button";
import { InstallWidgetFromWorkshopButton } from "~/components/workshop-install/install-from-workshop-buttons";
import { InstallWidgetFromWorkshopButton } from "~/components/workshop/workshop-actions";
import { CustomWidgetBetaBanner } from "./_beta-banner";
import { ImportCustomWidgetButton } from "./_custom-widget-actions";
import { CustomWidgetList } from "./_custom-widget-list";
@@ -18,6 +18,7 @@ import {
IconHomeFilled,
IconLayoutDashboardFilled,
IconMailForward,
IconBuildingStore,
IconPhotoFilled,
IconPointerFilled,
IconSearch,
@@ -80,6 +81,12 @@ export default async function ManageLayout({ children }: PropsWithChildren) {
label: t("items.customWidgets"),
hidden: !session,
},
{
icon: IconBuildingStore,
href: "/manage/workshop",
label: t("items.workshop"),
hidden: !session?.user.permissions.includes("admin"),
},
{
icon: IconSearch,
href: "/manage/search-engines",
@@ -0,0 +1,18 @@
import { redirect } from "next/navigation";
import { auth } from "@homarr/auth/next";
import { getScopedI18n } from "@homarr/translation/server";
import { ManagePageLayout } from "~/components/manage/manage-page-layout";
import { WorkshopBrowser } from "~/components/workshop/workshop-browser";
export default async function WorkshopPage() {
const session = await auth();
if (!session?.user.permissions.includes("admin")) redirect(session ? "/" : "/auth/login");
const t = await getScopedI18n("workshop");
return (
<ManagePageLayout title={t("title")} size="xl">
<WorkshopBrowser />
</ManagePageLayout>
);
}
@@ -1,98 +0,0 @@
"use client";
import { useState } from "react";
import { Button } from "@mantine/core";
import { useDisclosure } from "@mantine/hooks";
import { IconUsersGroup } from "@tabler/icons-react";
import { clientApi } from "@homarr/api/client";
import { revalidatePathActionAsync } from "@homarr/common/client";
import { showErrorNotification, showSuccessNotification } from "@homarr/notifications";
import { useScopedI18n } from "@homarr/translation/client";
import { customWidgetImportSchema } from "@homarr/validation/custom-widget";
import { MobileAffixButton } from "~/components/manage/mobile-affix-button";
import { WorkshopBrowserModal } from "./workshop-browser-modal";
function parseWidgetContent(content: string) {
let parsed: unknown;
try {
parsed = JSON.parse(content);
} catch {
return { ok: false as const, message: "Submission content is not valid JSON" };
}
const result = customWidgetImportSchema.safeParse(parsed);
if (!result.success) {
return { ok: false as const, message: result.error.issues[0]?.message ?? "Invalid widget schema" };
}
return { ok: true as const, data: result.data };
}
export const InstallWidgetFromWorkshopButton = () => {
const t = useScopedI18n("customWidget.workshop");
const [opened, { open, close }] = useDisclosure(false);
const [pendingId, setPendingId] = useState<string | null>(null);
const utils = clientApi.useUtils();
const importMutation = clientApi.customWidget.import.useMutation({
onSuccess: () => {
showSuccessNotification({ title: t("title"), message: t("notification.widgetInstalled") });
void utils.customWidget.all.invalidate();
void revalidatePathActionAsync("/manage/custom-widgets");
setPendingId(null);
close();
},
onError: (err) => {
showErrorNotification({ title: t("title"), message: err.message || t("notification.widgetInstallFailed") });
setPendingId(null);
},
});
const handleSelect = (submission: { id: string; content: string }) => {
const parsed = parseWidgetContent(submission.content);
if (!parsed.ok) {
showErrorNotification({ title: t("title"), message: parsed.message });
return;
}
setPendingId(submission.id);
importMutation.mutate(parsed.data);
};
return (
<>
<MobileAffixButton variant="default" leftSection={<IconUsersGroup size={16} />} onClick={open}>
{t("action.installFromWorkshop")}
</MobileAffixButton>
<WorkshopBrowserModal
type="widget"
opened={opened}
onClose={close}
actionLabel={t("action.install")}
pendingId={pendingId}
onSelect={handleSelect}
/>
</>
);
};
export const InstallCssFromWorkshopButton = ({ onSelect }: { onSelect: (css: string) => void }) => {
const t = useScopedI18n("customWidget.workshop");
const [opened, { open, close }] = useDisclosure(false);
return (
<>
<Button variant="default" leftSection={<IconUsersGroup size={16} />} onClick={open}>
{t("action.installFromWorkshop")}
</Button>
<WorkshopBrowserModal
type="css"
opened={opened}
onClose={close}
actionLabel={t("action.use")}
onSelect={(submission) => {
onSelect(submission.content);
close();
}}
/>
</>
);
};
@@ -1,47 +0,0 @@
export const WORKSHOP_API_URL = process.env.NEXT_PUBLIC_WORKSHOP_API_URL || "https://workshop.homarr.dev";
export interface WorkshopSubmission {
id: string;
collectionId: string;
collectionName: string;
type: "css" | "widget";
title: string;
description: string;
schemaVersion: string;
content: string;
screenshots: string[];
upvotes: number;
downvotes: number;
version: number;
author: string;
authorName: string;
commentCount: number;
created: string;
}
interface PBListResponse {
page: number;
perPage: number;
totalPages: number;
totalItems: number;
items: WorkshopSubmission[];
}
export const getWorkshopFileUrl = (submission: WorkshopSubmission, filename: string) =>
`${WORKSHOP_API_URL}/api/files/submissions/${submission.id}/${encodeURIComponent(filename)}`;
export const fetchWorkshopSubmissions = async (
type: "css" | "widget",
page = 1,
perPage = 50,
): Promise<PBListResponse> => {
const params = new URLSearchParams({
page: String(page),
perPage: String(perPage),
sort: "-upvotes",
filter: `(type='${type}')`,
});
const response = await fetch(`${WORKSHOP_API_URL}/api/collections/marketplace/records?${params}`);
if (!response.ok) throw new Error(`Workshop request failed (${response.status})`);
return response.json() as Promise<PBListResponse>;
};
@@ -1,221 +0,0 @@
"use client";
import { useState } from "react";
import {
ActionIcon,
Anchor,
Alert,
Badge,
Box,
Button,
Card,
Center,
Group,
Image,
Loader,
Modal,
Pagination,
ScrollArea,
SimpleGrid,
Stack,
Text,
} from "@mantine/core";
import { IconChevronLeft, IconChevronRight, IconExternalLink } from "@tabler/icons-react";
import { useQuery } from "@tanstack/react-query";
import type { WorkshopSubmission } from "./workshop-api";
import { fetchWorkshopSubmissions, getWorkshopFileUrl } from "./workshop-api";
const WORKSHOP_URL = process.env.NEXT_PUBLIC_WORKSHOP_URL || "https://homarr.dev/workshop";
interface Props {
type: "css" | "widget";
opened: boolean;
onClose: () => void;
onSelect: (submission: WorkshopSubmission) => void;
actionLabel: string;
pendingId?: string | null;
}
const displayName = (submission: WorkshopSubmission) => submission.authorName;
export const WorkshopBrowserModal = ({ type, opened, onClose, onSelect, actionLabel, pendingId }: Props) => {
const [page, setPage] = useState(1);
const handleClose = () => {
setPage(1);
onClose();
};
const { data, isLoading, error } = useQuery({
queryKey: ["workshop-submissions", type, page],
queryFn: () => fetchWorkshopSubmissions(type, page),
enabled: opened,
});
const errorText = error instanceof Error ? error.message : "unknown error";
return (
<Modal
opened={opened}
onClose={handleClose}
title={
<Group gap="sm">
<Text fw={600}>Install from Workshop</Text>
<Anchor
href={WORKSHOP_URL}
target="_blank"
rel="noopener noreferrer"
size="xs"
style={{ display: "inline-flex", alignItems: "center", gap: 4 }}
>
Open Workshop <IconExternalLink size={12} />
</Anchor>
</Group>
}
size={1200}
scrollAreaComponent={ScrollArea.Autosize}
>
<Stack gap="md">
{isLoading && (
<Center py="xl">
<Loader />
</Center>
)}
{error && <Alert color="red">Failed to load the workshop: {errorText}</Alert>}
{data && data.items.length === 0 && (
<Text c="dimmed" ta="center" py="xl">
No submissions available yet.
</Text>
)}
{data && data.items.length > 0 && (
<>
<SimpleGrid cols={{ base: 1, sm: 2, lg: 3 }} spacing="md">
{data.items.map((submission) => (
<SubmissionCard
key={submission.id}
submission={submission}
actionLabel={actionLabel}
pending={pendingId === submission.id}
onSelect={() => onSelect(submission)}
/>
))}
</SimpleGrid>
{data.totalPages > 1 && (
<Center>
<Pagination total={data.totalPages} value={page} onChange={setPage} />
</Center>
)}
</>
)}
</Stack>
</Modal>
);
};
const SubmissionCard = ({
submission,
actionLabel,
pending,
onSelect,
}: {
submission: WorkshopSubmission;
actionLabel: string;
pending: boolean;
onSelect: () => void;
}) => {
const screenshots = submission.screenshots ?? [];
const [imgIdx, setImgIdx] = useState(0);
const score = submission.upvotes - submission.downvotes;
const scorePrefix = score > 0 ? "+" : "";
return (
<Card withBorder padding={0}>
{screenshots.length > 0 && (
<Box pos="relative">
<Image
src={screenshots[imgIdx] ? getWorkshopFileUrl(submission, screenshots[imgIdx]) : undefined}
alt={submission.title}
h={180}
fit="cover"
/>
{screenshots.length > 1 && (
<>
<ActionIcon
variant="filled"
color="dark"
size="xs"
pos="absolute"
top="50%"
left={4}
style={{ transform: "translateY(-50%)" }}
onClick={() => setImgIdx((i) => (i - 1 + screenshots.length) % screenshots.length)}
>
<IconChevronLeft size={12} />
</ActionIcon>
<ActionIcon
variant="filled"
color="dark"
size="xs"
pos="absolute"
top="50%"
right={4}
style={{ transform: "translateY(-50%)" }}
onClick={() => setImgIdx((i) => (i + 1) % screenshots.length)}
>
<IconChevronRight size={12} />
</ActionIcon>
</>
)}
</Box>
)}
<Stack gap={4} p="sm">
<Group gap="xs" wrap="nowrap">
<Box
w={8}
h={8}
bg={submission.type === "css" ? "blue.5" : "yellow.5"}
style={{ borderRadius: "50%", flexShrink: 0 }}
/>
<Text fw={600} size="sm" truncate style={{ flex: 1 }}>
{submission.title}
</Text>
<Badge variant="light" size="sm" style={{ flexShrink: 0 }}>
{scorePrefix}
{score}
</Badge>
</Group>
<Text size="xs" c="dimmed">
{displayName(submission)} · v{submission.version}
</Text>
{submission.description && (
<Text size="xs" c="dimmed" lineClamp={2}>
{submission.description}
</Text>
)}
<Group gap="xs" mt={4}>
<Button size="xs" style={{ flex: 1 }} loading={pending} onClick={onSelect}>
{actionLabel}
</Button>
<ActionIcon
variant="light"
size="sm"
component="a"
href={`${WORKSHOP_URL}/${submission.id}/`}
target="_blank"
rel="noopener noreferrer"
aria-label="View in workshop"
>
<IconExternalLink size={14} />
</ActionIcon>
</Group>
</Stack>
</Card>
);
};
@@ -0,0 +1,72 @@
"use client";
import { Button, Modal } from "@mantine/core";
import { useDisclosure } from "@mantine/hooks";
import { IconBuildingStore } from "@tabler/icons-react";
import { clientApi } from "@homarr/api/client";
import { revalidatePathActionAsync } from "@homarr/common/client";
import { showErrorNotification, showSuccessNotification } from "@homarr/notifications";
import { useScopedI18n } from "@homarr/translation/client";
import { customWidgetImportSchema } from "@homarr/validation/custom-widget";
import { validateWorkshopContent } from "@homarr/workshop";
import { WorkshopBrowser } from "./workshop-browser";
export function InstallWidgetFromWorkshopButton() {
const t = useScopedI18n("workshop");
const [opened, { open, close }] = useDisclosure(false);
const utils = clientApi.useUtils();
const mutation = clientApi.customWidget.import.useMutation({
onSuccess: () => {
showSuccessNotification({ title: t("title"), message: t("action.install") });
void utils.customWidget.all.invalidate();
void revalidatePathActionAsync("/manage/custom-widgets");
close();
},
onError: (error) => showErrorNotification({ title: t("title"), message: error.message }),
});
return (
<>
<Button variant="default" leftSection={<IconBuildingStore size={16} />} onClick={open}>
{t("action.install")}
</Button>
<Modal opened={opened} onClose={close} title={t("title")} size="90%" fullScreen={false}>
<WorkshopBrowser
initialType="widget"
lockedType="widget"
useLabel={t("action.install")}
onUse={async (submission) => {
const parsed = customWidgetImportSchema.parse(JSON.parse(submission.content));
await mutation.mutateAsync(parsed);
}}
/>
</Modal>
</>
);
}
export function UseCssFromWorkshopButton({ onUse }: { onUse: (css: string) => void }) {
const t = useScopedI18n("workshop");
const [opened, { open, close }] = useDisclosure(false);
return (
<>
<Button variant="default" leftSection={<IconBuildingStore size={16} />} onClick={open}>
{t("action.useCss")}
</Button>
<Modal opened={opened} onClose={close} title={t("title")} size="90%">
<WorkshopBrowser
initialType="css"
lockedType="css"
useLabel={t("action.useCss")}
onUse={(submission) => {
const validation = validateWorkshopContent("css", submission.content);
if (!validation.success) throw new Error(validation.error);
onUse(submission.content);
close();
}}
/>
</Modal>
</>
);
}
@@ -0,0 +1,339 @@
"use client";
import type { ReactNode } from "react";
import { useEffect, useMemo, useState } from "react";
import {
Alert,
Badge,
Box,
Button,
Card,
Code,
Group,
Image,
Modal,
Pagination,
Select,
SimpleGrid,
Skeleton,
Stack,
Text,
TextInput,
Textarea,
} from "@mantine/core";
import { useDebouncedValue, useDisclosure } from "@mantine/hooks";
import { IconAlertTriangle, IconBrandGithub, IconDownload, IconExternalLink, IconSearch } from "@tabler/icons-react";
import { useQuery } from "@tanstack/react-query";
import type { WorkshopSubmissionDetail, WorkshopSubmissionType } from "@homarr/workshop";
import { WORKSHOP_API_URL, WORKSHOP_WEB_URL, WorkshopClient, workshopExportFilename } from "@homarr/workshop";
import { useScopedI18n } from "@homarr/translation/client";
const apiUrl = process.env.NEXT_PUBLIC_WORKSHOP_API_URL ?? WORKSHOP_API_URL;
export interface WorkshopBrowserProps {
initialType?: WorkshopSubmissionType | "all";
lockedType?: WorkshopSubmissionType;
onUse?: (submission: WorkshopSubmissionDetail) => Promise<void> | void;
useLabel?: string;
}
export function WorkshopBrowser({ initialType = "all", lockedType, onUse, useLabel }: WorkshopBrowserProps) {
const t = useScopedI18n("workshop");
const client = useMemo(() => new WorkshopClient(apiUrl), []);
const [page, setPage] = useState(1);
const [type, setType] = useState<WorkshopSubmissionType | "all">(initialType);
const [sort, setSort] = useState<"top" | "newest">("top");
const [search, setSearch] = useState("");
const [debouncedSearch] = useDebouncedValue(search, 250);
const [selected, setSelected] = useState<WorkshopSubmissionDetail | null>(null);
const [detailLoading, setDetailLoading] = useState(false);
const [usePending, setUsePending] = useState(false);
const [useError, setUseError] = useState("");
const query = useQuery({
queryKey: ["workshop", "list", page, type, sort, debouncedSearch],
queryFn: () => client.list({ page, type, sort, search: debouncedSearch, perPage: 12 }),
});
const openDetail = async (id: string) => {
setDetailLoading(true);
try {
setSelected(await client.get(id));
} finally {
setDetailLoading(false);
}
};
const download = (submission: WorkshopSubmissionDetail) => {
const url = URL.createObjectURL(
new Blob([submission.content], { type: submission.type === "css" ? "text/css" : "application/json" }),
);
const anchor = document.createElement("a");
anchor.href = url;
anchor.download = workshopExportFilename(submission.title, submission.type);
anchor.click();
URL.revokeObjectURL(url);
};
return (
<Stack gap="lg">
<Alert icon={<IconAlertTriangle size={18} />} color="yellow" title={t("safety.title")}>
{t("safety.description")}
</Alert>
<Group align="end">
<TextInput
label={t("search.label")}
placeholder={t("search.placeholder")}
leftSection={<IconSearch size={16} />}
value={search}
onChange={(event) => {
setSearch(event.currentTarget.value);
setPage(1);
}}
style={{ flex: 1 }}
/>
<Select
label={t("filter.type")}
value={type}
disabled={Boolean(lockedType)}
onChange={(value) => {
setType((value ?? "all") as typeof type);
setPage(1);
}}
data={[
{ value: "all", label: t("filter.all") },
{ value: "widget", label: t("filter.widgets") },
{ value: "css", label: t("filter.css") },
]}
/>
<Select
label={t("filter.sort")}
value={sort}
onChange={(value) => {
setSort((value ?? "top") as typeof sort);
setPage(1);
}}
data={[
{ value: "top", label: t("filter.top") },
{ value: "newest", label: t("filter.newest") },
]}
/>
</Group>
{query.error && (
<Alert color="red" title={t("error.loadTitle")}>
{query.error.message}
<Button variant="subtle" onClick={() => void query.refetch()}>
{t("action.retry")}
</Button>
</Alert>
)}
{query.isLoading ? (
<SimpleGrid cols={{ base: 1, sm: 2, lg: 3 }}>
<Skeleton h={280} />
<Skeleton h={280} />
<Skeleton h={280} />
</SimpleGrid>
) : query.data?.items.length ? (
<SimpleGrid cols={{ base: 1, sm: 2, lg: 3 }}>
{query.data.items.map((item) => (
<Card key={item.id} withBorder padding="md">
{item.screenshots[0] ? (
<Card.Section>
<Image src={client.fileUrl(item.id, item.screenshots[0], "480x320")} h={170} alt="" />
</Card.Section>
) : (
<Card.Section>
<Box
h={170}
bg="var(--mantine-color-default-hover)"
style={{ display: "grid", placeItems: "center" }}
>
<Text ff="monospace" fw={700}>
{item.type === "widget" ? "{}" : "CSS"}
</Text>
</Box>
</Card.Section>
)}
<Stack gap="xs" mt="md">
<Group justify="space-between">
<Badge variant="light">{item.type === "widget" ? t("type.widget") : t("type.css")}</Badge>
<Text size="sm" c="dimmed">
{item.score >= 0 ? "+" : ""}
{item.score}
</Text>
</Group>
<Text fw={600} lineClamp={1}>
{item.title}
</Text>
<Text size="sm" c="dimmed" lineClamp={2}>
{item.description || t("empty.description")}
</Text>
<Text size="xs" c="dimmed">
{t("by", { name: item.authorName, revision: item.revision })}
</Text>
<Button variant="light" onClick={() => void openDetail(item.id)}>
{t("action.inspect")}
</Button>
</Stack>
</Card>
))}
</SimpleGrid>
) : (
<Stack align="center" py="xl">
<Text fw={600}>{t("empty.title")}</Text>
<Text c="dimmed">{t("empty.description")}</Text>
</Stack>
)}
{(query.data?.totalPages ?? 0) > 1 && (
<Pagination total={query.data?.totalPages ?? 1} value={page} onChange={setPage} mx="auto" />
)}
<Modal
opened={Boolean(selected) || detailLoading}
onClose={() => setSelected(null)}
title={selected?.title ?? t("loading")}
size="xl"
>
{detailLoading || !selected ? (
<Skeleton h={400} />
) : (
<Stack>
<Group>
<Badge>{selected.type === "widget" ? t("type.widget") : t("type.css")}</Badge>
<Text size="sm" c="dimmed">
{t("by", { name: selected.authorName, revision: selected.revision })}
</Text>
</Group>
<Text>{selected.description}</Text>
<Alert icon={<IconAlertTriangle size={18} />} color="yellow">
{t("safety.inspect")}
</Alert>
<Code block mah={360} style={{ overflow: "auto", whiteSpace: "pre" }}>
{selected.content}
</Code>
<Group justify="space-between">
<Group>
<Button variant="default" leftSection={<IconDownload size={16} />} onClick={() => download(selected)}>
{t("action.download")}
</Button>
<Button
component="a"
href={`${WORKSHOP_WEB_URL}/${selected.id}`}
target="_blank"
variant="subtle"
leftSection={<IconExternalLink size={16} />}
>
{t("action.website")}
</Button>
</Group>
{onUse && (
<Button
loading={usePending}
onClick={async () => {
setUsePending(true);
setUseError("");
try {
if (lockedType && selected.type !== lockedType) throw new Error(t("error.wrongType"));
await onUse(selected);
setSelected(null);
} catch (caught) {
setUseError(caught instanceof Error ? caught.message : t("error.install"));
} finally {
setUsePending(false);
}
}}
>
{useLabel ?? t("action.use")}
</Button>
)}
</Group>
{useError && <Alert color="red">{useError}</Alert>}
</Stack>
)}
</Modal>
</Stack>
);
}
export function PublishToWorkshopButton({
type,
getContent,
defaultTitle,
compact = false,
renderTrigger,
}: {
type: WorkshopSubmissionType;
getContent: () => Promise<string> | string;
defaultTitle: string;
compact?: boolean;
renderTrigger?: (open: () => void) => ReactNode;
}) {
const t = useScopedI18n("workshop");
const client = useMemo(() => new WorkshopClient(apiUrl), []);
const [opened, { open, close }] = useDisclosure(false);
const [title, setTitle] = useState(defaultTitle);
const [description, setDescription] = useState("");
const [changelog, setChangelog] = useState("");
const [error, setError] = useState("");
const [pending, setPending] = useState(false);
useEffect(() => setTitle(defaultTitle), [defaultTitle]);
const publish = async () => {
setPending(true);
setError("");
try {
if (!client.currentUser) await client.signInWithGitHub();
await client.create({ type, title, description, changelog, content: await getContent() });
close();
} catch (caught) {
setError(caught instanceof Error ? caught.message : t("error.publish"));
} finally {
setPending(false);
}
};
return (
<>
{renderTrigger ? (
renderTrigger(open)
) : (
<Button
size={compact ? "xs" : undefined}
variant="default"
leftSection={<IconBrandGithub size={16} />}
onClick={open}
>
{t("action.share")}
</Button>
)}
<Modal opened={opened} onClose={close} title={t("publish.title")}>
<Stack>
<TextInput
required
label={t("publish.name")}
value={title}
onChange={(event) => setTitle(event.currentTarget.value)}
/>
<Textarea
label={t("publish.description")}
value={description}
onChange={(event) => setDescription(event.currentTarget.value)}
/>
<Textarea
label={t("publish.changelog")}
value={changelog}
onChange={(event) => setChangelog(event.currentTarget.value)}
/>
{error && <Alert color="red">{error}</Alert>}
<Group justify="end">
<Button variant="default" onClick={close}>
{t("action.cancel")}
</Button>
<Button loading={pending} disabled={title.trim().length < 3} onClick={() => void publish()}>
{t("action.publish")}
</Button>
</Group>
</Stack>
</Modal>
</>
);
}
+5
View File
@@ -0,0 +1,5 @@
GITHUB_CLIENT_ID=
GITHUB_CLIENT_SECRET=
PB_SUPERUSER_EMAIL=
PB_SUPERUSER_PASSWORD=
PB_EXPOSE_PORT=8090
+4
View File
@@ -0,0 +1,4 @@
.env
.env.*
!.env.example
pb_data/
+31
View File
@@ -0,0 +1,31 @@
FROM alpine:3.22 AS download
ARG TARGETARCH
ARG POCKETBASE_VERSION=0.39.6
RUN apk add --no-cache ca-certificates curl unzip
RUN case "${TARGETARCH}" in \
amd64) PB_ARCH=amd64 ;; \
arm64) PB_ARCH=arm64 ;; \
arm) PB_ARCH=armv7 ;; \
*) echo "Unsupported architecture: ${TARGETARCH}" >&2; exit 1 ;; \
esac && \
BASE_URL="https://github.com/pocketbase/pocketbase/releases/download/v${POCKETBASE_VERSION}" && \
ARCHIVE="pocketbase_${POCKETBASE_VERSION}_linux_${PB_ARCH}.zip" && \
curl --fail --location --silent --show-error "${BASE_URL}/${ARCHIVE}" --output "/tmp/${ARCHIVE}" && \
curl --fail --location --silent --show-error "${BASE_URL}/checksums.txt" --output /tmp/checksums.txt && \
cd /tmp && grep " ${ARCHIVE}$" checksums.txt | sha256sum -c - && \
unzip "/tmp/${ARCHIVE}" pocketbase -d /out
FROM alpine:3.22
RUN apk add --no-cache ca-certificates su-exec tzdata && addgroup -S pocketbase && adduser -S -G pocketbase pocketbase
COPY --from=download /out/pocketbase /usr/local/bin/pocketbase
COPY entrypoint.sh /usr/local/bin/workshop-entrypoint
RUN chmod +x /usr/local/bin/workshop-entrypoint && mkdir -p /pb_data /pb_hooks /pb_migrations && chown -R pocketbase:pocketbase /pb_data /pb_hooks /pb_migrations
USER root
EXPOSE 8090
HEALTHCHECK --interval=30s --timeout=5s --start-period=10s --retries=3 CMD wget -q --spider http://127.0.0.1:8090/api/health || exit 1
ENTRYPOINT ["workshop-entrypoint"]
+45
View File
@@ -0,0 +1,45 @@
# Homarr Workshop backend
PocketBase provides the central Workshop API at `store.homarr.dev`. It is not a per-instance Homarr service. Local deployment is for development and integration testing.
## Local startup
1. Copy `.env.example` to `.env` and create a GitHub OAuth app.
2. Set its callback URL to `http://localhost:8090/api/oauth2-redirect`.
3. Set `GITHUB_CLIENT_ID`, `GITHUB_CLIENT_SECRET`, `PB_SUPERUSER_EMAIL`, and `PB_SUPERUSER_PASSWORD`.
4. Run `docker compose up --build` from this directory.
The API is at `http://localhost:8090`; the emergency PocketBase dashboard is at `http://localhost:8090/_/`. Day-to-day moderation belongs in `/workshop/admin`, not the PocketBase dashboard.
The image downloads the official PocketBase `0.39.6` release and verifies it against that release's `checksums.txt`. Migrations and hooks are mounted read-only.
## Roles and account states
- `member`: publish, update/delete owned submissions, vote, and report.
- `moderator`: report queue, submission removal, posting bans, and account disable/restore.
- `admin`: moderator actions plus role changes.
- `posting_banned`: cannot create or update submissions; can browse, vote, and report.
- `disabled`: cannot authenticate or write.
Bootstrap the first admin through the PocketBase dashboard by changing a GitHub-authenticated user's role. All later staff actions use the restricted Workshop back office and are recorded in `moderation_actions`.
## Backup and restore
PocketBase stores records and uploads in `/pb_data`. Stop writes or stop the container, then archive the named volume:
```sh
docker compose stop workshop
docker run --rm -v store_workshop_data:/data -v "$PWD":/backup alpine tar czf /backup/workshop-backup.tgz -C /data .
docker compose start workshop
```
Restore only into the same PocketBase version after preserving the current volume. Extract the archive into an empty `workshop_data` volume, start the service, and verify `/api/health`, collection migrations, a listing request, and an authenticated write.
## Production checklist
- Terminate TLS at the reverse proxy and forward the real client IP.
- Keep superuser credentials outside Compose and rotate them after bootstrap.
- Restrict the dashboard path to operators; GitHub users never need it.
- Review the versioned default rate limits for OAuth, submissions, votes, reports, moderation, and general API traffic against production load before launch.
- Back up `/pb_data` before every PocketBase or migration upgrade and test restore regularly.
- Monitor health, authentication failures, 429/5xx responses, moderation actions, disk use, and backup age.
+24
View File
@@ -0,0 +1,24 @@
services:
workshop:
build:
context: .
args:
POCKETBASE_VERSION: 0.39.6
image: homarr/workshop-pocketbase:0.39.6
restart: unless-stopped
environment:
GITHUB_CLIENT_ID: ${GITHUB_CLIENT_ID:-}
GITHUB_CLIENT_SECRET: ${GITHUB_CLIENT_SECRET:-}
PB_SUPERUSER_EMAIL: ${PB_SUPERUSER_EMAIL:-}
PB_SUPERUSER_PASSWORD: ${PB_SUPERUSER_PASSWORD:-}
ports:
- "${PB_EXPOSE_PORT:-8090}:8090"
volumes:
- workshop_data:/pb_data
- ./pb_hooks:/pb_hooks:ro
- ./pb_migrations:/pb_migrations:ro
security_opt:
- no-new-privileges:true
volumes:
workshop_data:
+14
View File
@@ -0,0 +1,14 @@
#!/bin/sh
set -eu
chown -R pocketbase:pocketbase /pb_data
if [ -n "${PB_SUPERUSER_EMAIL:-}" ] && [ -n "${PB_SUPERUSER_PASSWORD:-}" ]; then
su-exec pocketbase pocketbase superuser upsert "${PB_SUPERUSER_EMAIL}" "${PB_SUPERUSER_PASSWORD}" --dir=/pb_data
fi
exec su-exec pocketbase pocketbase serve \
--http=0.0.0.0:8090 \
--dir=/pb_data \
--hooksDir=/pb_hooks \
--migrationsDir=/pb_migrations
+102
View File
@@ -0,0 +1,102 @@
const WIDGET_SCHEMA = "homarr-custom-widget-v2";
const CSS_SCHEMA = "homarr-custom-css-v1";
const CSS_LIMIT = 16384;
const WIDGET_AUTH_TYPES = ["none", "bearer", "basic", "apiKeyHeader", "apiKeyQuery"];
const WIDGET_METHODS = ["GET", "POST", "PUT", "DELETE", "PATCH"];
const WIDGET_DISPLAY_TYPES = [
"singleValue",
"keyValue",
"table",
"statGrid",
"progressBars",
"statusIndicator",
"countGrid",
"raw",
"actionButton",
"customJsx",
];
function fail(ErrorType, _code, message) {
throw new ErrorType(message);
}
function requireWritableAccount(app, auth, posting) {
if (!auth) fail(UnauthorizedError, "authentication_required", "Authentication is required");
const account = app.findRecordById("users", auth.id);
if (account.getString("state") === "disabled") fail(ForbiddenError, "account_disabled", "Account is disabled");
if (posting && account.getString("state") === "posting_banned")
fail(ForbiddenError, "posting_banned", "Account cannot publish submissions");
return account;
}
function validateSubmission(record) {
const type = record.getString("type");
const content = record.getString("content");
if (type === "css") {
if (!content.trim()) fail(BadRequestError, "invalid_submission", "CSS cannot be empty");
if (content.length > CSS_LIMIT) fail(BadRequestError, "invalid_submission", "CSS is too large");
record.set("schemaVersion", CSS_SCHEMA);
return;
}
if (type !== "widget") fail(BadRequestError, "invalid_submission", "Unsupported submission type");
let widget;
try {
widget = JSON.parse(content);
} catch {
fail(BadRequestError, "invalid_submission", "Widget content is not valid JSON");
}
if (
!widget ||
widget.$schema !== WIDGET_SCHEMA ||
typeof widget.name !== "string" ||
widget.name.length < 1 ||
widget.name.length > 128 ||
typeof widget.url !== "string" ||
widget.url.length < 1 ||
!WIDGET_AUTH_TYPES.includes(widget.authType) ||
!WIDGET_METHODS.includes(widget.method) ||
!WIDGET_DISPLAY_TYPES.includes(widget.displayType) ||
typeof widget.displayConfig !== "object" ||
Array.isArray(widget.displayConfig) ||
Object.prototype.hasOwnProperty.call(widget, "secrets")
) {
fail(BadRequestError, "invalid_submission", "Widget does not match homarr-custom-widget-v2");
}
if (!widget.displayConfig || widget.displayConfig.type !== widget.displayType) {
fail(BadRequestError, "invalid_submission", "Widget displayConfig.type must match displayType");
}
record.set("schemaVersion", WIDGET_SCHEMA);
}
function snapshot(record) {
return JSON.stringify(record.publicExport());
}
function requireStaff(app, auth) {
const account = requireWritableAccount(app, auth, false);
const role = account.getString("role");
if (role !== "moderator" && role !== "admin") fail(ForbiddenError, "forbidden", "Staff access is required");
return role;
}
function body(e) {
const data = new DynamicModel({ reason: "", state: "", role: "", status: "" });
e.bindBody(data);
if (!data.reason || data.reason.trim().length < 3)
fail(BadRequestError, "invalid_submission", "A reason is required");
return data;
}
function audit(app, actor, action, targetType, targetId, reason, targetSnapshot) {
const collection = app.findCollectionByNameOrId("moderation_actions");
const record = new Record(collection);
record.set("actor", actor.id);
record.set("action", action);
record.set("targetType", targetType);
record.set("targetId", targetId);
record.set("reason", reason.trim());
record.set("snapshot", targetSnapshot || "");
app.save(record);
}
module.exports = { audit, body, fail, requireStaff, requireWritableAccount, snapshot, validateSubmission };
+168
View File
@@ -0,0 +1,168 @@
/// <reference path="../pb_data/types.d.ts" />
onRecordAuthWithOAuth2Request((e) => {
const utils = require(`${__hooks}/workshop-utils.js`);
if (e.record && e.record.getString("state") === "disabled")
utils.fail(ForbiddenError, "account_disabled", "Account is disabled");
if (e.isNewRecord) {
e.createData.displayName = e.oAuth2User.name || e.oAuth2User.username || "Community member";
e.createData.avatarUrl = e.oAuth2User.avatarUrl || "";
e.createData.role = "member";
e.createData.state = "active";
}
e.next();
}, "users");
onRecordAuthRequest((e) => {
const utils = require(`${__hooks}/workshop-utils.js`);
if (e.record.getString("state") === "disabled") utils.fail(ForbiddenError, "account_disabled", "Account is disabled");
e.next();
}, "users");
onRecordUpdateRequest((e) => {
const original = e.app.findRecordById("users", e.record.id);
e.record.set("role", original.get("role"));
e.record.set("state", original.get("state"));
e.record.set("moderationReason", original.get("moderationReason"));
e.next();
}, "users");
onRecordCreateRequest((e) => {
const utils = require(`${__hooks}/workshop-utils.js`);
const account = utils.requireWritableAccount(e.app, e.auth, true);
utils.validateSubmission(e.record);
e.record.set("author", e.auth.id);
e.record.set("authorName", account.getString("displayName"));
e.record.set("revision", 1);
e.next();
}, "submissions");
onRecordUpdateRequest((e) => {
const utils = require(`${__hooks}/workshop-utils.js`);
utils.requireWritableAccount(e.app, e.auth, true);
const original = e.app.findRecordById("submissions", e.record.id);
e.record.set("author", original.get("author"));
e.record.set("authorName", original.get("authorName"));
e.record.set("type", original.get("type"));
e.record.set("schemaVersion", original.get("schemaVersion"));
e.record.set(
"revision",
original.getInt("revision") + (e.record.getString("content") !== original.getString("content") ? 1 : 0),
);
utils.validateSubmission(e.record);
e.next();
}, "submissions");
onRecordCreateRequest((e) => {
const utils = require(`${__hooks}/workshop-utils.js`);
utils.requireWritableAccount(e.app, e.auth, false);
const value = e.record.getInt("value");
if (value !== 1 && value !== -1) throw new BadRequestError("Vote must be 1 or -1");
e.record.set("user", e.auth.id);
e.next();
}, "votes");
onRecordUpdateRequest((e) => {
const utils = require(`${__hooks}/workshop-utils.js`);
utils.requireWritableAccount(e.app, e.auth, false);
const original = e.app.findRecordById("votes", e.record.id);
const value = e.record.getInt("value");
if (value !== 1 && value !== -1) throw new BadRequestError("Vote must be 1 or -1");
e.record.set("user", original.get("user"));
e.record.set("submission", original.get("submission"));
e.next();
}, "votes");
onRecordCreateRequest((e) => {
const utils = require(`${__hooks}/workshop-utils.js`);
utils.requireWritableAccount(e.app, e.auth, false);
e.record.set("reporter", e.auth.id);
e.record.set("status", "open");
e.next();
}, "reports");
routerAdd(
"POST",
"/api/workshop/moderation/submissions/{id}/remove",
(e) => {
const utils = require(`${__hooks}/workshop-utils.js`);
utils.requireStaff(e.app, e.auth);
const data = utils.body(e);
const id = e.request.pathValue("id");
e.app.runInTransaction((tx) => {
const target = tx.findRecordById("submissions", id);
utils.audit(tx, e.auth, "remove_submission", "submission", id, data.reason, utils.snapshot(target));
tx.delete(target);
});
return e.json(200, { success: true });
},
$apis.requireAuth("users"),
);
routerAdd(
"POST",
"/api/workshop/moderation/users/{id}/state",
(e) => {
const utils = require(`${__hooks}/workshop-utils.js`);
const actorRole = utils.requireStaff(e.app, e.auth);
const data = utils.body(e);
if (!["active", "posting_banned", "disabled"].includes(data.state))
throw new BadRequestError("Invalid account state");
const id = e.request.pathValue("id");
if (id === e.auth.id) throw new BadRequestError("You cannot moderate your own account");
e.app.runInTransaction((tx) => {
const target = tx.findRecordById("users", id);
if (target.getString("role") === "admin" && actorRole !== "admin") throw new ForbiddenError("forbidden");
const previous = utils.snapshot(target);
target.set("state", data.state);
target.set("moderationReason", data.reason);
tx.save(target);
utils.audit(tx, e.auth, "set_account_state", "user", id, data.reason, previous);
});
return e.json(200, { success: true });
},
$apis.requireAuth("users"),
);
routerAdd(
"POST",
"/api/workshop/moderation/users/{id}/role",
(e) => {
const utils = require(`${__hooks}/workshop-utils.js`);
if (utils.requireStaff(e.app, e.auth) !== "admin") throw new ForbiddenError("forbidden");
const data = utils.body(e);
if (!["member", "moderator", "admin"].includes(data.role)) throw new BadRequestError("Invalid role");
const id = e.request.pathValue("id");
if (id === e.auth.id) throw new BadRequestError("You cannot change your own role");
e.app.runInTransaction((tx) => {
const target = tx.findRecordById("users", id);
const previous = utils.snapshot(target);
target.set("role", data.role);
tx.save(target);
utils.audit(tx, e.auth, "set_role", "user", id, data.reason, previous);
});
return e.json(200, { success: true });
},
$apis.requireAuth("users"),
);
routerAdd(
"POST",
"/api/workshop/moderation/reports/{id}/resolve",
(e) => {
const utils = require(`${__hooks}/workshop-utils.js`);
utils.requireStaff(e.app, e.auth);
const data = utils.body(e);
if (!["resolved", "dismissed"].includes(data.status)) throw new BadRequestError("Invalid report status");
const id = e.request.pathValue("id");
e.app.runInTransaction((tx) => {
const target = tx.findRecordById("reports", id);
const previous = utils.snapshot(target);
target.set("status", data.status);
tx.save(target);
utils.audit(tx, e.auth, "resolve_report", "report", id, data.reason, previous);
});
return e.json(200, { success: true });
},
$apis.requireAuth("users"),
);
@@ -0,0 +1,248 @@
/// <reference path="../pb_data/types.d.ts" />
migrate(
(app) => {
let users;
try {
users = app.findCollectionByNameOrId("users");
} catch {
users = new Collection({
type: "auth",
name: "users",
listRule: "@request.auth.role = 'moderator' || @request.auth.role = 'admin'",
viewRule: "id = @request.auth.id || @request.auth.role = 'moderator' || @request.auth.role = 'admin'",
updateRule: "id = @request.auth.id && @request.auth.state != 'disabled'",
deleteRule: null,
passwordAuth: { enabled: false },
});
}
// PocketBase creates a default users auth collection on first boot, so
// Workshop fields must be added to the existing collection explicitly.
users.fields.add(
new TextField({ name: "displayName", required: true, min: 1, max: 100 }),
new URLField({ name: "avatarUrl" }),
new SelectField({ name: "role", required: true, maxSelect: 1, values: ["member", "moderator", "admin"] }),
new SelectField({
name: "state",
required: true,
maxSelect: 1,
values: ["active", "posting_banned", "disabled"],
}),
new TextField({ name: "moderationReason", max: 1000 }),
);
const githubClientId = $os.getenv("GITHUB_CLIENT_ID");
const githubClientSecret = $os.getenv("GITHUB_CLIENT_SECRET");
users.oauth2.enabled = Boolean(githubClientId && githubClientSecret);
users.oauth2.providers =
githubClientId && githubClientSecret
? [{ name: "github", clientId: githubClientId, clientSecret: githubClientSecret }]
: [];
app.save(users);
const staffRule = "@request.auth.role = 'moderator' || @request.auth.role = 'admin'";
const writableRule = "@request.auth.id != '' && @request.auth.state != 'disabled'";
const authorRule = "author = @request.auth.id && @request.auth.state = 'active'";
const submissions = new Collection({
type: "base",
name: "submissions",
// Full source is intentionally available only through getOne; discovery
// must use workshop_listings so list payloads never contain content.
listRule: null,
viewRule: "",
createRule: null,
updateRule: null,
deleteRule: null,
fields: [
{ type: "select", name: "type", required: true, maxSelect: 1, values: ["widget", "css"] },
{ type: "text", name: "title", required: true, min: 3, max: 100 },
{ type: "text", name: "description", max: 2000 },
{ type: "text", name: "schemaVersion", required: true, max: 100 },
{ type: "text", name: "content", required: true, max: 1000000 },
{
type: "file",
name: "screenshots",
maxSelect: 5,
maxSize: 5242880,
mimeTypes: ["image/png", "image/jpeg", "image/webp"],
thumbs: ["480x320", "960x640"],
},
{
type: "relation",
name: "author",
required: true,
maxSelect: 1,
collectionId: users.id,
cascadeDelete: true,
},
{ type: "text", name: "authorName", required: true, max: 100 },
{ type: "number", name: "revision", required: true, onlyInt: true, min: 1 },
{ type: "text", name: "changelog", max: 2000 },
{ type: "autodate", name: "created", onCreate: true },
{ type: "autodate", name: "updated", onCreate: true, onUpdate: true },
],
});
app.save(submissions);
submissions.createRule = "@request.auth.id != ''";
submissions.updateRule = authorRule;
submissions.deleteRule = authorRule;
submissions.addIndex("idx_submissions_author", false, "author", "");
submissions.addIndex("idx_submissions_type_created", false, "type, created", "");
app.save(submissions);
const votes = new Collection({
type: "base",
name: "votes",
listRule: null,
viewRule: null,
createRule: null,
updateRule: null,
deleteRule: null,
fields: [
{
type: "relation",
name: "submission",
required: true,
maxSelect: 1,
collectionId: submissions.id,
cascadeDelete: true,
},
{ type: "relation", name: "user", required: true, maxSelect: 1, collectionId: users.id, cascadeDelete: true },
{ type: "number", name: "value", required: true, onlyInt: true, min: -1, max: 1 },
{ type: "autodate", name: "created", onCreate: true },
{ type: "autodate", name: "updated", onCreate: true, onUpdate: true },
],
});
app.save(votes);
votes.listRule = "user = @request.auth.id";
votes.viewRule = "user = @request.auth.id";
votes.createRule = writableRule;
votes.updateRule = "user = @request.auth.id && @request.auth.state != 'disabled'";
votes.deleteRule = "user = @request.auth.id && @request.auth.state != 'disabled'";
votes.addIndex("idx_votes_user_submission", true, "user, submission", "");
votes.addIndex("idx_votes_submission", false, "submission", "");
app.save(votes);
const reports = new Collection({
type: "base",
name: "reports",
listRule: null,
viewRule: null,
createRule: null,
updateRule: null,
deleteRule: null,
fields: [
{
type: "relation",
name: "submission",
required: true,
maxSelect: 1,
collectionId: submissions.id,
cascadeDelete: true,
},
{
type: "relation",
name: "reporter",
required: true,
maxSelect: 1,
collectionId: users.id,
cascadeDelete: true,
},
{
type: "select",
name: "category",
required: true,
maxSelect: 1,
values: ["malicious", "spam", "copyright", "inappropriate", "other"],
},
{ type: "text", name: "explanation", required: true, min: 3, max: 1000 },
{ type: "select", name: "status", required: true, maxSelect: 1, values: ["open", "resolved", "dismissed"] },
{ type: "autodate", name: "created", onCreate: true },
{ type: "autodate", name: "updated", onCreate: true, onUpdate: true },
],
});
app.save(reports);
reports.listRule = staffRule;
reports.viewRule = staffRule;
reports.createRule = writableRule;
reports.addIndex("idx_reports_reporter_submission", true, "reporter, submission", "");
reports.addIndex("idx_reports_status_created", false, "status, created", "");
app.save(reports);
const moderationActions = new Collection({
type: "base",
name: "moderation_actions",
listRule: staffRule,
viewRule: staffRule,
createRule: null,
updateRule: null,
deleteRule: null,
fields: [
{ type: "relation", name: "actor", required: true, maxSelect: 1, collectionId: users.id },
{
type: "select",
name: "action",
required: true,
maxSelect: 1,
values: ["remove_submission", "set_account_state", "set_role", "resolve_report"],
},
{ type: "text", name: "targetType", required: true, max: 30 },
{ type: "text", name: "targetId", required: true, max: 30 },
{ type: "text", name: "reason", required: true, min: 3, max: 1000 },
{ type: "text", name: "snapshot", max: 100000 },
{ type: "autodate", name: "created", onCreate: true },
],
});
app.save(moderationActions);
moderationActions.addIndex("idx_moderation_actions_created", false, "created", "");
app.save(moderationActions);
const listings = new Collection({
type: "view",
name: "workshop_listings",
listRule: "",
viewRule: "",
viewQuery: `
SELECT s.id, s.type, s.title, s.description, s.schemaVersion, s.screenshots,
s.revision, s.changelog, s.author, s.authorName, s.created, s.updated,
COALESCE(SUM(CASE WHEN v.value = 1 THEN 1 ELSE 0 END), 0) AS upvotes,
COALESCE(SUM(CASE WHEN v.value = -1 THEN 1 ELSE 0 END), 0) AS downvotes,
COALESCE(SUM(v.value), 0) AS score
FROM submissions s
LEFT JOIN votes v ON v.submission = s.id
GROUP BY s.id
`,
});
app.save(listings);
const settings = app.settings();
settings.rateLimits.enabled = true;
settings.rateLimits.rules = [
{ label: "*:auth", audience: "", duration: 60, maxRequests: 10 },
{ label: "submissions:create", audience: "@auth", duration: 60, maxRequests: 10 },
{ label: "votes:create", audience: "@auth", duration: 10, maxRequests: 20 },
{ label: "reports:create", audience: "@auth", duration: 60, maxRequests: 5 },
{ label: "/api/workshop/moderation/", audience: "@auth", duration: 60, maxRequests: 30 },
{ label: "/api/", audience: "", duration: 10, maxRequests: 300 },
];
settings.logs.maxDays = 30;
settings.logs.logAuthId = true;
settings.logs.logIP = true;
app.save(settings);
},
(app) => {
// PocketBase creates the users auth collection before this migration. Keep
// it intact on rollback so reverting Workshop can never delete accounts.
for (const name of ["workshop_listings", "moderation_actions", "reports", "votes", "submissions"]) {
try {
app.delete(app.findCollectionByNameOrId(name));
} catch {
// Collection was not created or was already removed.
}
}
const settings = app.settings();
settings.rateLimits.enabled = false;
settings.rateLimits.rules = [];
app.save(settings);
},
);
+26
View File
@@ -0,0 +1,26 @@
#!/bin/sh
set -eu
project="homarr-workshop-integration"
port="${WORKSHOP_TEST_PORT:-18091}"
email="integration@homarr.test"
password="WorkshopIntegrationPassword123!"
cleanup() {
PB_EXPOSE_PORT="$port" docker compose --file apps/store/docker-compose.yml --project-name "$project" down --volumes >/dev/null 2>&1 || true
}
trap cleanup EXIT INT TERM
cleanup
PB_EXPOSE_PORT="$port" \
PB_SUPERUSER_EMAIL="$email" \
PB_SUPERUSER_PASSWORD="$password" \
docker compose --file apps/store/docker-compose.yml --project-name "$project" up --detach --build --wait
if ! WORKSHOP_TEST_URL="http://127.0.0.1:$port" \
WORKSHOP_TEST_SUPERUSER_EMAIL="$email" \
WORKSHOP_TEST_SUPERUSER_PASSWORD="$password" \
node apps/store/tests/workshop.integration.mjs; then
docker compose --file apps/store/docker-compose.yml --project-name "$project" logs --no-color workshop
exit 1
fi
+165
View File
@@ -0,0 +1,165 @@
import assert from "node:assert/strict";
import PocketBase, { ClientResponseError } from "pocketbase";
const baseUrl = process.env.WORKSHOP_TEST_URL;
const superuserEmail = process.env.WORKSHOP_TEST_SUPERUSER_EMAIL;
const superuserPassword = process.env.WORKSHOP_TEST_SUPERUSER_PASSWORD;
assert(baseUrl && superuserEmail && superuserPassword, "Workshop integration test environment is incomplete");
const root = new PocketBase(baseUrl);
await root.collection("_superusers").authWithPassword(superuserEmail, superuserPassword);
const createUser = async (name, role = "member", state = "active") => {
const password = `Workshop${name}Password123!`;
return root.collection("users").create({
email: `${name.toLowerCase()}@homarr.test`,
password,
passwordConfirm: password,
verified: true,
displayName: name,
role,
state,
});
};
const expectStatus = async (operation, status) => {
const statuses = Array.isArray(status) ? status : [status];
await assert.rejects(operation, (error) => error instanceof ClientResponseError && statuses.includes(error.status));
};
const memberRecord = await createUser("Member");
const secondMemberRecord = await createUser("SecondMember");
const bannedRecord = await createUser("Banned", "member", "posting_banned");
const disabledRecord = await createUser("Disabled");
const moderatorRecord = await createUser("Moderator", "moderator");
const adminRecord = await createUser("Admin", "admin");
const member = await root.collection("users").impersonate(memberRecord.id, 3600);
const secondMember = await root.collection("users").impersonate(secondMemberRecord.id, 3600);
const banned = await root.collection("users").impersonate(bannedRecord.id, 3600);
const disabled = await root.collection("users").impersonate(disabledRecord.id, 3600);
const moderator = await root.collection("users").impersonate(moderatorRecord.id, 3600);
const admin = await root.collection("users").impersonate(adminRecord.id, 3600);
const forgedMember = await member.collection("users").update(memberRecord.id, {
role: "admin",
state: "posting_banned",
moderationReason: "self escalation",
});
assert.equal(forgedMember.role, "member", "members must not change their own role");
assert.equal(forgedMember.state, "active", "members must not change their own account state");
assert.equal(forgedMember.moderationReason, "", "members must not change protected moderation fields");
await admin.send(`/api/workshop/moderation/users/${disabledRecord.id}/state`, {
method: "POST",
body: { state: "disabled", reason: "Integration disable" },
});
const submission = await member.collection("submissions").create({
type: "css",
title: "Integration theme",
description: "A backend integration fixture",
schemaVersion: "forged-version",
content: ":root { --homarr-test: #fff; }",
author: secondMemberRecord.id,
authorName: "Forged",
revision: 99,
});
assert.equal(submission.author, memberRecord.id, "server must enforce ownership");
assert.equal(submission.authorName, "Member", "server must snapshot the authenticated author name");
assert.equal(submission.revision, 1, "server must own the initial revision");
assert.equal(submission.schemaVersion, "homarr-custom-css-v1", "server must own schemaVersion");
await expectStatus(
() => banned.collection("submissions").create({ type: "css", title: "Blocked theme", content: "body {}" }),
403,
);
await expectStatus(() => disabled.collection("votes").create({ submission: submission.id, value: 1 }), [400, 403]);
await expectStatus(
() =>
member.collection("submissions").create({
type: "widget",
title: "Invalid widget",
content: JSON.stringify({ $schema: "homarr-custom-widget-v2", name: "Missing contract" }),
}),
400,
);
const vote = await member.collection("votes").create({ submission: submission.id, value: 1 });
await expectStatus(() => member.collection("votes").create({ submission: submission.id, value: -1 }), 400);
assert.equal(
(await member.collection("votes").update(vote.id, { submission: "forged", user: "forged", value: -1 })).submission,
submission.id,
);
const report = await secondMember.collection("reports").create({
submission: submission.id,
reporter: memberRecord.id,
category: "spam",
explanation: "Integration report",
status: "dismissed",
});
assert.equal(report.reporter, secondMemberRecord.id);
assert.equal(report.status, "open");
assert.equal((await member.collection("reports").getList()).totalItems, 0, "reports must remain private from members");
assert.equal((await moderator.collection("reports").getList()).items.length, 1);
await expectStatus(
() =>
member.send(`/api/workshop/moderation/users/${secondMemberRecord.id}/state`, {
method: "POST",
body: { state: "posting_banned", reason: "No permission" },
}),
403,
);
await expectStatus(
() =>
moderator.send(`/api/workshop/moderation/users/${adminRecord.id}/state`, {
method: "POST",
body: { state: "disabled", reason: "Cannot alter admin" },
}),
403,
);
await expectStatus(
() =>
moderator.send(`/api/workshop/moderation/users/${secondMemberRecord.id}/role`, {
method: "POST",
body: { role: "moderator", reason: "Cannot promote" },
}),
403,
);
await admin.send(`/api/workshop/moderation/users/${secondMemberRecord.id}/role`, {
method: "POST",
body: { role: "moderator", reason: "Integration promotion" },
});
assert.equal((await root.collection("users").getOne(secondMemberRecord.id)).role, "moderator");
await moderator.send(`/api/workshop/moderation/reports/${report.id}/resolve`, {
method: "POST",
body: { status: "resolved", reason: "Integration resolution" },
});
assert.equal((await root.collection("reports").getOne(report.id)).status, "resolved");
await moderator.send(`/api/workshop/moderation/submissions/${submission.id}/remove`, {
method: "POST",
body: { reason: "Integration removal" },
});
await expectStatus(() => root.collection("submissions").getOne(submission.id), 404);
await expectStatus(() => root.collection("votes").getOne(vote.id), 404);
await expectStatus(() => root.collection("reports").getOne(report.id), 404);
const actions = await root.collection("moderation_actions").getFullList({ sort: "created" });
assert.deepEqual(
actions.map((action) => action.action),
["set_account_state", "set_role", "resolve_report", "remove_submission"],
);
assert(actions.at(-1)?.snapshot.includes("Integration theme"), "removal audit must retain a target snapshot");
const anonymous = new PocketBase(baseUrl);
const listings = await anonymous.collection("workshop_listings").getList();
assert.equal(listings.totalItems, 0);
await expectStatus(() => anonymous.collection("submissions").getList(), 403);
console.log("Workshop backend integration matrix passed");
-3
View File
@@ -1,3 +0,0 @@
pb_data/
.env
.env.local
-54
View File
@@ -1,54 +0,0 @@
# syntax=docker/dockerfile:1.10
FROM node:24.18.0-alpine AS docs
WORKDIR /app
RUN corepack enable
COPY . .
RUN --mount=type=cache,id=workshop-pnpm,target=/pnpm/store \
pnpm config set store-dir /pnpm/store && \
pnpm install --frozen-lockfile --filter @homarr/docs...
RUN WORKSHOP_URL="" pnpm --filter @homarr/docs build
FROM alpine:3.22 AS pocketbase
ARG POCKETBASE_VERSION=0.39.6
ARG TARGETARCH
RUN apk add --no-cache ca-certificates curl unzip
RUN case "${TARGETARCH}" in \
amd64) PB_ARCH=amd64 ;; \
arm64) PB_ARCH=arm64 ;; \
arm) PB_ARCH=armv7 ;; \
*) echo "Unsupported architecture: ${TARGETARCH}" >&2; exit 1 ;; \
esac && \
BASE_URL="https://github.com/pocketbase/pocketbase/releases/download/v${POCKETBASE_VERSION}" && \
ARCHIVE="pocketbase_${POCKETBASE_VERSION}_linux_${PB_ARCH}.zip" && \
curl --fail --location --silent --show-error "${BASE_URL}/${ARCHIVE}" --output "/tmp/${ARCHIVE}" && \
curl --fail --location --silent --show-error "${BASE_URL}/checksums.txt" --output /tmp/checksums.txt && \
cd /tmp && \
grep " ${ARCHIVE}$" checksums.txt | sha256sum -c - && \
unzip "/tmp/${ARCHIVE}" pocketbase -d /out
FROM alpine:3.22 AS runtime
RUN apk add --no-cache ca-certificates tzdata
COPY --from=pocketbase /out/pocketbase /usr/local/bin/pocketbase
COPY --from=docs /app/apps/docs/build /pb_public
COPY apps/workshop/pb_hooks /pb_hooks
COPY apps/workshop/pb_migrations /pb_migrations
RUN addgroup -S workshop && \
adduser -S -G workshop -h /home/workshop workshop && \
mkdir -p /pb_data && \
chown -R workshop:workshop /pb_data /pb_public /pb_hooks /pb_migrations
USER workshop
EXPOSE 8090
VOLUME ["/pb_data"]
HEALTHCHECK --interval=10s --timeout=3s --start-period=10s --retries=10 \
CMD wget --quiet --spider http://127.0.0.1:8090/api/health || exit 1
ENTRYPOINT ["/usr/local/bin/pocketbase"]
CMD ["serve", "--http=0.0.0.0:8090", "--dir=/pb_data", "--publicDir=/pb_public", "--hooksDir=/pb_hooks", "--migrationsDir=/pb_migrations", "--indexFallback=true"]
-15
View File
@@ -1,15 +0,0 @@
.git
.github
.env
.env.*
!.env.example
**/.docusaurus
**/.next
**/.turbo
**/build
**/coverage
**/dist
**/node_modules
**/pb_data
.pnpm-store
e2e
-80
View File
@@ -1,80 +0,0 @@
# Homarr Workshop
Workshop ships as one container. Its build stage compiles the current Docusaurus site, and its runtime stage serves those static files and the PocketBase API from the same origin.
## Local production-like run
Configure optional values in the repository-root `.env`, then run:
```sh
pnpm dev:workshop
```
Open:
- documentation: `http://localhost:8090/`;
- Workshop: `http://localhost:8090/workshop/`;
- PocketBase API: `http://localhost:8090/api/`;
- PocketBase dashboard: `http://localhost:8090/_/`.
The `homarr_workshop_deployment_data` volume persists records and uploaded files across image rebuilds. Migrations run automatically when PocketBase starts. It is intentionally separate from volumes created by earlier prototypes with incompatible schemas.
## GitHub OAuth
Create a GitHub OAuth application with these local values:
- homepage: `http://localhost:8090`;
- callback: `http://localhost:8090/api/oauth2-redirect`.
Set these values only in the repository-root `.env`:
```dotenv
GITHUB_CLIENT_ID=
GITHUB_CLIENT_SECRET=
```
For production, use `https://workshop.homarr.dev` and `https://workshop.homarr.dev/api/oauth2-redirect`. Create the first PocketBase superuser through the installer link printed on first startup or through the PocketBase CLI.
## Local image validation
Build and test the exact runtime shape:
```sh
docker build -f apps/workshop/Dockerfile -t homarr/workshop:ci .
pnpm test:workshop-image
```
The smoke test starts a temporary container and verifies the documentation homepage, `/workshop/`, the migrated public collection, and `/api/health`.
## GitHub Actions and ACT
`.github/workflows/deployment-workshop.yml` runs only for changes below `apps/docs/` or `apps/workshop/`, plus manual dispatches. It builds and tests the combined image, then publishes:
- `ghcr.io/homarr-labs/workshop:latest`;
- `ghcr.io/homarr-labs/workshop:sha-<commit>`.
Run the same build and smoke test locally without registry credentials:
```sh
pnpm ci:act:workshop
```
See `.github/act/workshop.md` for the direct ACT command and Docker socket behavior.
## VPS deployment
Copy `apps/workshop/docker-compose.yml` to the checkout on the VPS, authenticate Docker to GHCR if the package is private, and run from the repository root. Compose reads the optional root `.env` automatically:
```sh
docker compose -f apps/workshop/docker-compose.yml pull
docker compose -f apps/workshop/docker-compose.yml up -d --no-build
```
The service binds to `127.0.0.1:8090` by default. Put a TLS reverse proxy in front of it for `workshop.homarr.dev`, forward the original scheme and host, and persist the `homarr_workshop_deployment_data` volume. Back up that volume before PocketBase upgrades.
## Content schema
- Custom widgets use `homarr-custom-widget-v2` JSON.
- Custom CSS uses raw CSS tagged as `homarr-custom-css-v1`.
Client-side validation lives in `apps/docs/src/lib/workshop-schema.ts`. PocketBase collection rules and hooks remain in this directory.
-28
View File
@@ -1,28 +0,0 @@
name: homarr-workshop
services:
workshop:
image: ghcr.io/homarr-labs/workshop:${WORKSHOP_TAG:-latest}
build:
context: ../..
dockerfile: apps/workshop/Dockerfile
restart: unless-stopped
environment:
GITHUB_CLIENT_ID: ${GITHUB_CLIENT_ID:-}
GITHUB_CLIENT_SECRET: ${GITHUB_CLIENT_SECRET:-}
ports:
- "127.0.0.1:${WORKSHOP_PORT:-8090}:8090"
volumes:
- workshop_data:/pb_data
healthcheck:
test: ["CMD", "wget", "--quiet", "--spider", "http://127.0.0.1:8090/api/health"]
interval: 10s
timeout: 3s
retries: 10
start_period: 10s
security_opt:
- no-new-privileges:true
volumes:
workshop_data:
name: homarr_workshop_deployment_data
-75
View File
@@ -1,75 +0,0 @@
/// <reference path="../pb_data/types.d.ts" />
// --- Submissions ---
onRecordCreateRequest((e) => {
e.record.set("author", e.auth?.id);
e.record.set("upvotes", 0);
e.record.set("downvotes", 0);
e.record.set("version", 1);
e.next();
}, "submissions");
onRecordAfterCreateSuccess((e) => {
try {
var votes = e.app.findCollectionByNameOrId("votes");
var vote = new Record(votes);
vote.set("submission", e.record.id);
vote.set("user", e.record.get("author"));
vote.set("value", 1);
e.app.save(vote);
} catch (err) {
console.log("auto-upvote failed for submission " + e.record.id + ": " + err);
}
e.next();
}, "submissions");
onRecordUpdateRequest((e) => {
var original = e.app.findRecordById("submissions", e.record.id);
e.record.set("author", original.get("author"));
e.record.set("upvotes", original.get("upvotes"));
e.record.set("downvotes", original.get("downvotes"));
e.record.set("type", original.get("type"));
e.record.set("schemaVersion", original.get("schemaVersion"));
e.record.set("version", original.get("version"));
e.next();
}, "submissions");
// --- Votes ---
onRecordCreateRequest((e) => {
var v = +e.record.get("value");
if (v !== 1 && v !== -1) throw new BadRequestError("value must be 1 or -1");
e.record.set("user", e.auth?.id);
e.next();
}, "votes");
onRecordUpdateRequest((e) => {
var v = +e.record.get("value");
if (v !== 1 && v !== -1) throw new BadRequestError("value must be 1 or -1");
var original = e.app.findRecordById("votes", e.record.id);
e.record.set("user", original.get("user"));
e.record.set("submission", original.get("submission"));
e.next();
}, "votes");
// Vote count hooks removed — the `marketplace` view computes upvotes/downvotes via SQL
// --- Reports & Comments ---
onRecordCreateRequest((e) => {
e.record.set("user", e.auth?.id);
e.next();
}, "reports");
onRecordCreateRequest((e) => {
e.record.set("author", e.auth?.id);
e.next();
}, "comments");
onRecordUpdateRequest((e) => {
var original = e.app.findRecordById("comments", e.record.id);
e.record.set("author", original.get("author"));
e.record.set("submission", original.get("submission"));
e.next();
}, "comments");
@@ -1,172 +0,0 @@
/// <reference path="../pb_data/types.d.ts" />
migrate(
(app) => {
const users = app.findCollectionByNameOrId("users");
const githubClientId = $os.getenv("GITHUB_CLIENT_ID");
const githubClientSecret = $os.getenv("GITHUB_CLIENT_SECRET");
if (githubClientId && githubClientSecret) {
users.oauth2.enabled = true;
users.oauth2.providers = [
{
name: "github",
clientId: githubClientId,
clientSecret: githubClientSecret,
},
];
}
app.save(users);
let submissions;
try {
submissions = app.findCollectionByNameOrId("submissions");
} catch {
submissions = new Collection({
type: "base",
name: "submissions",
listRule: "",
viewRule: "",
createRule: "@request.auth.id != ''",
updateRule: "author = @request.auth.id",
deleteRule: "author = @request.auth.id",
fields: [
{ name: "type", type: "select", required: true, maxSelect: 1, values: ["css", "widget"] },
{ name: "title", type: "text", required: true, min: 3, max: 100 },
{ name: "description", type: "text", max: 2000 },
{ name: "schemaVersion", type: "text", required: true, max: 100 },
{ name: "content", type: "text", required: true, max: 1000000 },
{
name: "screenshots",
type: "file",
maxSelect: 5,
maxSize: 5242880,
mimeTypes: ["image/png", "image/jpeg", "image/webp", "image/gif"],
},
{
name: "author",
type: "relation",
required: true,
maxSelect: 1,
collectionId: users.id,
cascadeDelete: true,
},
{ name: "upvotes", type: "number", onlyInt: true },
{ name: "downvotes", type: "number", onlyInt: true },
{ name: "version", type: "number", onlyInt: true },
{ name: "changelog", type: "text", max: 2000 },
{ name: "created", type: "autodate", onCreate: true },
{ name: "updated", type: "autodate", onCreate: true, onUpdate: true },
],
});
app.save(submissions);
}
let votes;
try {
votes = app.findCollectionByNameOrId("votes");
} catch {
votes = new Collection({
type: "base",
name: "votes",
listRule: "user = @request.auth.id",
viewRule: "user = @request.auth.id",
createRule: "@request.auth.id != ''",
updateRule: "user = @request.auth.id",
deleteRule: "user = @request.auth.id",
fields: [
{
name: "submission",
type: "relation",
required: true,
maxSelect: 1,
collectionId: submissions.id,
cascadeDelete: true,
},
{ name: "user", type: "relation", required: true, maxSelect: 1, collectionId: users.id, cascadeDelete: true },
{ name: "value", type: "number", required: true, onlyInt: true, min: -1, max: 1 },
{ name: "created", type: "autodate", onCreate: true },
{ name: "updated", type: "autodate", onCreate: true, onUpdate: true },
],
});
votes.addIndex("idx_votes_unique_user_submission", true, "submission, user", "");
app.save(votes);
}
let reports;
try {
reports = app.findCollectionByNameOrId("reports");
} catch {
reports = new Collection({
type: "base",
name: "reports",
listRule: null,
viewRule: null,
createRule: "@request.auth.id != ''",
updateRule: null,
deleteRule: null,
fields: [
{
name: "submission",
type: "relation",
required: true,
maxSelect: 1,
collectionId: submissions.id,
cascadeDelete: true,
},
{ name: "user", type: "relation", required: true, maxSelect: 1, collectionId: users.id, cascadeDelete: true },
{ name: "reason", type: "text", required: true, min: 3, max: 1000 },
{ name: "created", type: "autodate", onCreate: true },
],
});
app.save(reports);
}
let comments;
try {
comments = app.findCollectionByNameOrId("comments");
} catch {
comments = new Collection({
type: "base",
name: "comments",
listRule: "",
viewRule: "",
createRule: "@request.auth.id != ''",
updateRule: "author = @request.auth.id",
deleteRule: "author = @request.auth.id",
fields: [
{
name: "submission",
type: "relation",
required: true,
maxSelect: 1,
collectionId: submissions.id,
cascadeDelete: true,
},
{
name: "author",
type: "relation",
required: true,
maxSelect: 1,
collectionId: users.id,
cascadeDelete: true,
},
{ name: "content", type: "text", required: true, min: 1, max: 2000 },
{ name: "created", type: "autodate", onCreate: true },
{ name: "updated", type: "autodate", onCreate: true, onUpdate: true },
],
});
comments.addIndex("idx_comments_submission", false, "submission", "");
app.save(comments);
}
},
(app) => {
for (const name of ["comments", "reports", "votes", "submissions"]) {
try {
app.delete(app.findCollectionByNameOrId(name));
} catch {
// already gone
}
}
},
);
@@ -1,49 +0,0 @@
/// <reference path="../pb_data/types.d.ts" />
migrate(
(app) => {
// Keep databases created by the original prototype valid after the
// migration file was renamed with the feature.
try {
app.findCollectionByNameOrId("marketplace");
return;
} catch {
// Fresh database; create the public view below.
}
const collection = new Collection({
name: "marketplace",
type: "view",
listRule: "",
viewRule: "",
viewQuery: `
SELECT
s.id,
s.type,
s.title,
s.description,
s.schemaVersion,
s.content,
s.screenshots,
s.version,
s.changelog,
s.author,
s.created,
s.updated,
COALESCE(NULLIF(u.name, ''), 'unknown') as authorName,
COALESCE(SUM(CASE WHEN v.value = 1 THEN 1 ELSE 0 END), 0) as upvotes,
COALESCE(SUM(CASE WHEN v.value = -1 THEN 1 ELSE 0 END), 0) as downvotes,
(SELECT COUNT(*) FROM comments c WHERE c.submission = s.id) as commentCount
FROM submissions s
LEFT JOIN users u ON u.id = s.author
LEFT JOIN votes v ON v.submission = s.id
GROUP BY s.id
`,
});
app.save(collection);
},
(app) => {
const collection = app.findCollectionByNameOrId("marketplace");
app.delete(collection);
},
);
-30
View File
@@ -1,30 +0,0 @@
#!/bin/sh
set -eu
image="${1:-homarr/workshop:ci}"
container="homarr-workshop-smoke-${$}"
port="${WORKSHOP_SMOKE_PORT:-18090}"
cleanup() {
docker rm --force "$container" >/dev/null 2>&1 || true
}
trap cleanup EXIT INT TERM
docker run --detach --name "$container" --publish "127.0.0.1:${port}:8090" "$image" >/dev/null
attempt=0
until curl --fail --silent "http://127.0.0.1:${port}/api/health" >/dev/null; do
attempt=$((attempt + 1))
if [ "$attempt" -ge 30 ]; then
docker logs "$container"
exit 1
fi
sleep 1
done
curl --fail --silent "http://127.0.0.1:${port}/" | grep --quiet "Homarr"
curl --fail --silent "http://127.0.0.1:${port}/workshop/" | grep --quiet "Workshop"
curl --fail --silent "http://127.0.0.1:${port}/api/collections/marketplace/records?page=1&perPage=1" >/dev/null
curl --fail --silent "http://127.0.0.1:${port}/api/health" | grep --quiet '"code":200'
echo "Workshop image serves Docusaurus and PocketBase successfully"
+78
View File
@@ -0,0 +1,78 @@
# Workshop V2 audit
## Purpose
The Workshop is Homarr's central community catalog for custom widgets and CSS themes. It must let people discover, inspect, export, publish, update, report, and install creations from homarr.dev and from Homarr itself without requiring raw copy-and-paste as the default workflow.
### Feature North Star
A Homarr user can move from a useful local creation to a safe community submission, or from a community listing to an installed local creation, in one understandable flow with source visibility and an explicit confirmation boundary.
### Developer-experience North Star
Workshop behavior is defined once: one typed contract, one client, versioned PocketBase migrations, deterministic local startup, shared validation fixtures, typed failures, and UI components that contain presentation rather than PocketBase queries or authorization rules.
## Baseline audit
The abandoned prototype is preserved at `origin/feat/widget-store-prototype-backup` (`89260b875`). This rewrite starts at `dev` commit `ba7718bde`.
| Area | Severity | Baseline finding | V2 resolution |
| ------------- | -------- | ----------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------ |
| Branch health | P0 | PR #5984 was based on an obsolete `dev` commit and conflicted with the current branch. | Rebuild from current `dev`; keep the old head on a backup ref. |
| Security | P0 | Submission validation ran in browsers and could be bypassed with direct PocketBase writes. | Enforce validation, ownership, account state, immutable fields, and upload limits in PocketBase hooks and rules. |
| Moderation | P0 | There were no moderator roles, posting bans, disabled accounts, least-privilege actions, or durable moderation history. | Add roles, account states, explicit moderation routes, a staff UI, and append-only actions. |
| Architecture | P1 | Docs and Next.js defined separate record types, queries, mutations, sorting, voting, and error behavior. | Add `@homarr/workshop` as the only public contract and API client. |
| Performance | P1 | The browser downloaded every submission and full content, then searched, sorted, and filtered locally. | Server-side pagination/filtering/sorting; summary views exclude content. |
| Product | P1 | Website and app flows were disconnected and sharing still depended on manual content transfer. | Add contextual install and direct share actions backed by canonical exports. |
| UX/UI | P1 | A large bespoke Docusaurus component set and a separate Mantine modal produced inconsistent interaction and state behavior. | Use each host's native components with shared flows, copy rules, contracts, and states. |
| Accessibility | P1 | Several custom interactive elements bypassed semantic controls and touch targets; focus and reduced-motion behavior was incomplete. | Require semantic controls, visible focus, keyboard operation, 44px touch targets, AA contrast, and reduced motion. |
| Operations | P1 | A third-party PocketBase image and incomplete bootstrap docs made builds and recovery non-deterministic. | Build a pinned official binary with checksum verification; document health, backup, restore, OAuth, and upgrades. |
| Testing | P1 | Tests covered a narrow client schema and did not exercise API rules, roles, auth states, moderation, or cross-surface flows. | Add contract, backend permission-matrix, UI, and end-to-end coverage. |
| Documentation | P2 | Documentation advertised a supported self-hosted Workshop even though V2 is a central Homarr service. | Document the central service and local-development overrides only. |
## Decisions
- Public name: **Workshop**.
- Launch content: custom widgets (`homarr-custom-widget-v2`) and CSS (`homarr-custom-css-v1`).
- Identity: GitHub OAuth only.
- Community features: votes and reports; comments are removed.
- Hosting: one Homarr-operated service at `store.homarr.dev`; overrides are development-only.
- Publishing: immediate, with source inspection, reporting, and staff removal.
- Staff: GitHub-authenticated moderators/admins use `/workshop/admin`; PocketBase superusers are bootstrap/emergency access.
## Launch acceptance
- [x] Anonymous users can browse, search, filter, inspect, copy, and download without receiving full content in listing responses.
- [x] Members can publish, update, delete, vote, and report; ownership and schemas are enforced server-side.
- [x] Posting-banned users cannot create or update submissions but can browse, vote, and report.
- [x] Disabled accounts cannot authenticate or write, including with an existing token.
- [x] Moderators can resolve reports, remove submissions, posting-ban users, and disable/restore non-admin users.
- [x] Only admins can change roles; staff actions are append-only and attributable.
- [x] Homarr can export/share local widgets and CSS and validate/confirm Workshop installs.
- [x] Website and app implement loading, empty, offline, validation, permission, and destructive-action states.
- [x] Keyboard semantics, mobile layout, dark mode, reduced motion, and horizontal overflow received a browser smoke test.
- [x] Clean PocketBase startup, migration, health, backup, and restore procedures are reproducible and documented.
- [ ] Add automated authenticated cross-surface E2E coverage before changing the PR from draft to ready.
## Deferred after V2
Comments, private Workshop deployments, automated approval queues, follows, collections, notifications, arbitrary package types, historical version installation, and personalized recommendations.
## Exit scorecard
The baseline has three P0, seven P1, and one P2 findings. The rewrite closes every product, security, architecture, moderation, performance, operations, documentation, UX, and DX finding. One P1 release-verification item remains: automated authenticated E2E across both public and in-app surfaces. Keep PR #5984 in draft until it lands.
| Gate | Result | Evidence |
| --------------------------- | ------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| Branch ancestry | Pass | Rebuilt from `dev` at `ba7718bde`; prototype preserved at `origin/feat/widget-store-prototype-backup`. |
| Contract | Pass | Five Vitest cases cover widget/CSS fixtures, filenames, limits, screenshot validation, and round trips. |
| Backend | Pass | Disposable PocketBase integration matrix covers roles, account states, ownership forgery, schema rejection, duplicate votes, private reports, escalation, deletion cascades, and audit snapshots. |
| Backend operations | Pass | PocketBase 0.39.6 image builds with checksum verification; clean migration, health, and listing/detail access boundaries pass. |
| Type safety | Pass | Scoped Turbo typecheck passes for Workshop, Docs, and Next.js. |
| Lint | Pass | Scoped lint exits clean; remaining warnings predate the rewrite. Workshop has zero warnings. |
| Format | Pass | Every changed supported file passes `oxfmt --check`; repository-wide Docs formatting still reports pre-existing drift. |
| Production builds | Pass | Docusaurus and Next.js production builds pass under Node 24.18.0. |
| Browser smoke | Pass | Desktop and 390px mobile discovery render without console errors or horizontal overflow; semantic labels, skip link, dark mode, and reduced motion are active. |
| Automated cross-surface E2E | Open P1 | Authenticated website-to-Homarr publish/install and moderator flows still need browser automation. |
Final implementation scores (10 is best): security 9, architecture/DX 9, moderation 9, performance 9, responsive UI 9, theming 9, accessibility 8, automated testing 7. Accessibility remains below 9 until authenticated dialogs and destructive flows receive a full keyboard/screen-reader pass.
+6 -3
View File
@@ -6,6 +6,10 @@
"build": "cross-env CI=true turbo build",
"clean": "git clean -xdf node_modules",
"clean:workspaces": "turbo clean",
"ci:act:all": "bash scripts/ci/act.sh all",
"ci:act:cleanup": "bash scripts/ci/act.sh cleanup",
"ci:act:docker": "bash scripts/ci/act.sh docker",
"ci:act:fast": "bash scripts/ci/act.sh fast",
"cli": "pnpm with-env tsx packages/cli/index.ts",
"db:migration:mysql:generate": "pnpm -F db migration:mysql:generate",
"db:migration:mysql:run": "pnpm -F db migration:mysql:run",
@@ -17,9 +21,7 @@
"db:studio": "pnpm -F db studio",
"dev": "pnpm -F nextjs dev",
"dev:docs": "pnpm -F @homarr/docs dev",
"dev:workshop": "docker compose -f ./apps/workshop/docker-compose.yml up --build",
"test:workshop-image": "bash ./apps/workshop/scripts/smoke-test.sh",
"ci:act:workshop": "bash ./scripts/ci/workshop-act.sh",
"dev:workshop": "docker compose -f apps/store/docker-compose.yml up --build",
"docker:dev": "docker compose -f ./development/development.docker-compose.yml up",
"format": "turbo format --continue -- --check",
"format:fix": "turbo format --continue",
@@ -32,6 +34,7 @@
"start": "concurrently \"pnpm with-env node apps/tasks/tasks.cjs\" \"pnpm with-env node apps/websocket/wssServer.cjs\" \"pnpm -F nextjs start\"",
"test": "cross-env NODE_ENV=development CI=true vitest run --exclude e2e --coverage.enabled ",
"test:e2e": "cross-env NODE_ENV=development CI=true vitest e2e",
"test:workshop-backend": "bash apps/store/tests/run-integration.sh",
"test:ui": "cross-env NODE_ENV=development CI=true vitest --exclude e2e --ui --coverage.enabled",
"typecheck": "turbo typecheck",
"with-env": "dotenv -e .env --"
+57 -17
View File
@@ -699,18 +699,6 @@
"title": "Edit Custom Widget"
}
},
"workshop": {
"title": "Workshop",
"action": {
"installFromWorkshop": "Install from Workshop",
"install": "Install",
"use": "Use"
},
"notification": {
"widgetInstalled": "Custom widget installed",
"widgetInstallFailed": "Failed to install the custom widget"
}
},
"action": {
"create": "Create",
"import": "Import",
@@ -728,7 +716,6 @@
"addBar": "Add Bar",
"duplicate": "Duplicate",
"copyAiPrompt": "Copy AI prompt",
"paste": "Paste JSON",
"enable": "Enable",
"disable": "Disable",
"menu": "Actions"
@@ -875,15 +862,12 @@
"created": "Widget \"{name}\" created successfully.",
"updated": "Widget \"{name}\" updated successfully.",
"deleted": "Widget \"{name}\" deleted.",
"imported": "Widget \"{name}\" imported into form.",
"imported": "Widget imported successfully.",
"aiPromptCopied": "AI prompt copied to clipboard",
"aiPromptDescription": "Copy a pre-built prompt to paste into any AI chatbot (ChatGPT, Claude, Gemini...). It includes the full widget schema and your API response so the AI can generate a ready-to-import JSON config for you.",
"aiPromptCopy": "Copy to clipboard",
"aiPromptNoResponse": "Tip: Run a test first to include your API response in the prompt for better results.",
"aiPromptCopyError": "Failed to copy prompt to clipboard. Your browser may not support this feature.",
"clipboardError": "Could not read clipboard. Please paste manually.",
"invalidJson": "Clipboard content is not valid JSON.",
"invalidSchema": "The JSON does not match the custom widget schema.",
"createError": "Failed to create widget. Check your API endpoint is reachable.",
"updateError": "Failed to update widget. Your changes were not saved.",
"importError": "Failed to import widget. Ensure the file is a valid custom widget JSON export.",
@@ -4470,6 +4454,61 @@
}
}
},
"workshop": {
"title": "Workshop",
"loading": "Loading submission",
"by": "By {name} · revision {revision}",
"type": {
"widget": "Widget",
"css": "CSS theme"
},
"search": {
"label": "Search",
"placeholder": "Search community creations"
},
"filter": {
"type": "Type",
"sort": "Sort",
"all": "All",
"widgets": "Widgets",
"css": "CSS themes",
"top": "Top rated",
"newest": "Newest"
},
"safety": {
"title": "Community content",
"description": "Workshop content is provided by community members. Inspect it before installing and keep a current backup.",
"inspect": "Review this source before using it. Widget endpoints and CSS can change how your instance behaves."
},
"empty": {
"title": "No creations found",
"description": "Adjust the filters or check back after the community has shared more."
},
"error": {
"loadTitle": "Workshop could not load",
"publish": "The creation could not be published",
"install": "The creation could not be used",
"wrongType": "This creation does not match the requested type"
},
"publish": {
"title": "Share in Workshop",
"name": "Title",
"description": "Description",
"changelog": "Changelog"
},
"action": {
"retry": "Try again",
"inspect": "Inspect source",
"download": "Download",
"website": "Open website",
"use": "Use creation",
"share": "Share in Workshop",
"cancel": "Cancel",
"publish": "Publish creation",
"install": "Install widget",
"useCss": "Use CSS"
}
},
"management": {
"metaTitle": "Management",
"title": {
@@ -4493,6 +4532,7 @@
"apps": "Apps",
"integrations": "Integrations",
"customWidgets": "Custom Widgets",
"workshop": "Workshop",
"searchEngies": "Search engines",
"medias": "Medias",
"users": {
+1
View File
@@ -0,0 +1 @@
dist/
+46
View File
@@ -0,0 +1,46 @@
{
"name": "@homarr/workshop",
"version": "0.1.0",
"private": true,
"license": "Apache-2.0",
"type": "module",
"typesVersions": {
"*": {
"*": [
"src/*"
]
}
},
"exports": {
".": {
"types": "./src/index.ts",
"import": "./dist/index.js"
},
"./client": {
"types": "./src/client.ts",
"import": "./dist/client.js"
},
"./schema": {
"types": "./src/schema.ts",
"import": "./dist/schema.js"
}
},
"scripts": {
"build": "esbuild src/index.ts src/client.ts src/schema.ts --bundle --format=esm --platform=browser --external:pocketbase --external:zod --external:zod/* --outdir=dist",
"clean": "rm -rf .turbo node_modules",
"format": "oxfmt .",
"lint": "oxlint",
"postinstall": "pnpm build",
"typecheck": "tsc --noEmit"
},
"dependencies": {
"@homarr/validation": "workspace:^0.1.0",
"pocketbase": "catalog:",
"zod": "catalog:"
},
"devDependencies": {
"@homarr/tsconfig": "workspace:^0.1.0",
"esbuild": "catalog:",
"typescript": "catalog:"
}
}
+289
View File
@@ -0,0 +1,289 @@
import PocketBase, { ClientResponseError } from "pocketbase";
import type {
WorkshopAccountState,
WorkshopModerationAction,
WorkshopReport,
WorkshopRole,
WorkshopSubmissionDetail,
WorkshopSubmissionInput,
WorkshopSubmissionSummary,
WorkshopSubmissionType,
WorkshopUser,
WorkshopVote,
} from "./schema";
import {
WORKSHOP_API_URL,
WorkshopError,
schemaVersionForType,
workshopAccountStateSchema,
workshopErrorCodeSchema,
workshopModerationActionSchema,
workshopReportSchema,
workshopRoleSchema,
workshopSubmissionDetailSchema,
workshopSubmissionInputSchema,
workshopSubmissionSummarySchema,
workshopScreenshotsSchema,
workshopUserSchema,
workshopVoteSchema,
} from "./schema";
export interface WorkshopListOptions {
page?: number;
perPage?: number;
type?: WorkshopSubmissionType | "all";
sort?: "top" | "newest";
search?: string;
author?: string;
}
export interface WorkshopPage<T> {
page: number;
perPage: number;
totalPages: number;
totalItems: number;
items: T[];
}
const errorCodeForStatus = (status: number) => {
if (status === 401) return "authentication_required" as const;
if (status === 403) return "forbidden" as const;
if (status === 404) return "not_found" as const;
if (status === 409) return "conflict" as const;
if (status === 429) return "rate_limited" as const;
if (status >= 500) return "unavailable" as const;
return "unknown" as const;
};
const asWorkshopError = (error: unknown, fallback: string) => {
if (error instanceof WorkshopError) return error;
if (error instanceof ClientResponseError) {
const message = error.data?.message || error.message || fallback;
const messageCode =
message === "Account is disabled"
? "account_disabled"
: message === "Account cannot publish submissions"
? "posting_banned"
: undefined;
const parsedCode = workshopErrorCodeSchema.safeParse(messageCode ?? error.data?.data?.code ?? error.data?.code);
const code = parsedCode.success ? parsedCode.data : errorCodeForStatus(error.status);
return new WorkshopError(code, message, error.status);
}
return new WorkshopError("unknown", error instanceof Error ? error.message : fallback);
};
export class WorkshopClient {
readonly pocketBase: PocketBase;
constructor(readonly baseUrl = WORKSHOP_API_URL) {
this.pocketBase = new PocketBase(baseUrl.replace(/\/$/, ""));
}
get currentUser(): WorkshopUser | null {
const record = this.pocketBase.authStore.record;
if (!record) return null;
return workshopUserSchema.parse({
id: record.id,
displayName: record.displayName || record.name || record.username || "Community member",
avatarUrl: record.avatarUrl || record.avatar || "",
role: workshopRoleSchema.catch("member").parse(record.role),
state: workshopAccountStateSchema.catch("active").parse(record.state),
});
}
subscribeToAuth(listener: (user: WorkshopUser | null) => void) {
return this.pocketBase.authStore.onChange(() => listener(this.currentUser), true);
}
async refreshAuth() {
if (!this.pocketBase.authStore.isValid) return null;
try {
await this.pocketBase.collection("users").authRefresh();
return this.currentUser;
} catch {
this.pocketBase.authStore.clear();
return null;
}
}
async signInWithGitHub() {
try {
await this.pocketBase.collection("users").authWithOAuth2({ provider: "github" });
return this.currentUser;
} catch (error) {
throw asWorkshopError(error, "GitHub sign-in failed");
}
}
signOut() {
this.pocketBase.authStore.clear();
}
async list(options: WorkshopListOptions = {}): Promise<WorkshopPage<WorkshopSubmissionSummary>> {
const filters: string[] = [];
if (options.type && options.type !== "all")
filters.push(this.pocketBase.filter("type = {:type}", { type: options.type }));
if (options.search?.trim())
filters.push(this.pocketBase.filter("title ~ {:search}", { search: options.search.trim() }));
if (options.author) filters.push(this.pocketBase.filter("author = {:author}", { author: options.author }));
try {
const result = await this.pocketBase
.collection("workshop_listings")
.getList(options.page ?? 1, options.perPage ?? 24, {
filter: filters.join(" && "),
sort: options.sort === "newest" ? "-created" : "-score,-created",
});
return { ...result, items: result.items.map((item) => workshopSubmissionSummarySchema.parse(item)) };
} catch (error) {
throw asWorkshopError(error, "Failed to load Workshop submissions");
}
}
async get(id: string): Promise<WorkshopSubmissionDetail> {
try {
return workshopSubmissionDetailSchema.parse(await this.pocketBase.collection("submissions").getOne(id));
} catch (error) {
throw asWorkshopError(error, "Submission not found");
}
}
fileUrl(submissionId: string, filename: string, thumb?: string) {
const query = thumb ? `?thumb=${encodeURIComponent(thumb)}` : "";
return `${this.baseUrl}/api/files/submissions/${submissionId}/${encodeURIComponent(filename)}${query}`;
}
async create(input: WorkshopSubmissionInput, screenshots: File[] = []) {
const parsed = workshopSubmissionInputSchema.parse(input);
workshopScreenshotsSchema.parse(screenshots);
const data = new FormData();
Object.entries(parsed).forEach(([key, value]) => data.set(key, value));
data.set("schemaVersion", schemaVersionForType(parsed.type));
screenshots.forEach((file) => data.append("screenshots", file));
try {
const result = await this.pocketBase.collection("submissions").create(data);
return this.get(result.id);
} catch (error) {
throw asWorkshopError(error, "Failed to publish submission");
}
}
async update(
id: string,
input: WorkshopSubmissionInput,
screenshots: File[] = [],
removedScreenshots: string[] = [],
) {
const parsed = workshopSubmissionInputSchema.parse(input);
workshopScreenshotsSchema.parse(screenshots);
const data = new FormData();
Object.entries(parsed).forEach(([key, value]) => data.set(key, value));
screenshots.forEach((file) => data.append("screenshots+", file));
removedScreenshots.forEach((filename) => data.append("screenshots-", filename));
try {
await this.pocketBase.collection("submissions").update(id, data);
return this.get(id);
} catch (error) {
throw asWorkshopError(error, "Failed to update submission");
}
}
async delete(id: string) {
try {
await this.pocketBase.collection("submissions").delete(id);
} catch (error) {
throw asWorkshopError(error, "Failed to delete submission");
}
}
async vote(submissionId: string, value: 1 | -1): Promise<WorkshopVote | null> {
const filter = this.pocketBase.filter("submission = {:submission} && user = {:user}", {
submission: submissionId,
user: this.currentUser?.id ?? "",
});
try {
const existing = await this.pocketBase
.collection("votes")
.getFirstListItem(filter)
.catch((error: unknown) => {
if (error instanceof ClientResponseError && error.status === 404) return null;
throw error;
});
if (existing?.value === value) {
await this.pocketBase.collection("votes").delete(existing.id);
return null;
}
const record = existing
? await this.pocketBase.collection("votes").update(existing.id, { value })
: await this.pocketBase.collection("votes").create({ submission: submissionId, value });
return workshopVoteSchema.parse(record);
} catch (error) {
throw asWorkshopError(error, "Failed to save vote");
}
}
async report(submission: string, category: WorkshopReport["category"], explanation: string) {
try {
return workshopReportSchema.parse(
await this.pocketBase.collection("reports").create({ submission, category, explanation }),
);
} catch (error) {
throw asWorkshopError(error, "Failed to submit report");
}
}
async moderate(path: string, body: Record<string, unknown>) {
try {
return await this.pocketBase.send(`/api/workshop/moderation/${path}`, { method: "POST", body });
} catch (error) {
throw asWorkshopError(error, "Moderation action failed");
}
}
removeSubmission(id: string, reason: string) {
return this.moderate(`submissions/${id}/remove`, { reason });
}
updateAccountState(id: string, state: WorkshopAccountState, reason: string) {
return this.moderate(`users/${id}/state`, { state, reason });
}
updateRole(id: string, role: WorkshopRole, reason: string) {
return this.moderate(`users/${id}/role`, { role, reason });
}
resolveReport(id: string, status: "resolved" | "dismissed", reason: string) {
return this.moderate(`reports/${id}/resolve`, { status, reason });
}
async listReports(status: "open" | "resolved" | "dismissed" = "open") {
try {
const result = await this.pocketBase.collection("reports").getList(1, 100, {
filter: this.pocketBase.filter("status = {:status}", { status }),
sort: "-created",
});
return result.items.map((item) => workshopReportSchema.parse(item));
} catch (error) {
throw asWorkshopError(error, "Failed to load reports");
}
}
async listUsers() {
try {
const result = await this.pocketBase.collection("users").getList(1, 200, { sort: "displayName" });
return result.items.map((item) => workshopUserSchema.parse(item));
} catch (error) {
throw asWorkshopError(error, "Failed to load users");
}
}
async listModerationActions(): Promise<WorkshopModerationAction[]> {
try {
const result = await this.pocketBase.collection("moderation_actions").getList(1, 100, { sort: "-created" });
return result.items.map((item) => workshopModerationActionSchema.parse(item));
} catch (error) {
throw asWorkshopError(error, "Failed to load moderation history");
}
}
}
+2
View File
@@ -0,0 +1,2 @@
export * from "./client";
export * from "./schema";
+67
View File
@@ -0,0 +1,67 @@
import { describe, expect, it } from "vitest";
import {
CSS_SCHEMA_VERSION,
WIDGET_SCHEMA_VERSION,
schemaVersionForType,
validateWorkshopContent,
workshopExportFilename,
workshopScreenshotsSchema,
workshopSubmissionInputSchema,
} from "./schema";
const validWidget = {
$schema: WIDGET_SCHEMA_VERSION,
name: "Status",
url: "https://example.com/api/status",
authType: "none",
method: "GET",
displayType: "singleValue",
displayConfig: {
type: "singleValue",
jsonPath: "$.value",
label: "Status",
unit: "",
valueSize: "md",
labelPosition: "above",
},
};
describe("Workshop contracts", () => {
it("accepts the canonical custom widget export", () => {
expect(validateWorkshopContent("widget", JSON.stringify(validWidget)).success).toBe(true);
});
it("rejects missing and mismatched widget schemas", () => {
const { $schema: _, ...missing } = validWidget;
expect(validateWorkshopContent("widget", JSON.stringify(missing)).success).toBe(false);
expect(
validateWorkshopContent("widget", JSON.stringify({ ...validWidget, displayConfig: { type: "raw" } })).success,
).toBe(false);
});
it("enforces CSS and input limits", () => {
expect(validateWorkshopContent("css", " ").success).toBe(false);
expect(
workshopSubmissionInputSchema.safeParse({
type: "css",
title: "Theme",
description: "",
content: "body { color: red; }",
changelog: "",
}).success,
).toBe(true);
});
it("returns stable schema versions and safe filenames", () => {
expect(schemaVersionForType("widget")).toBe(WIDGET_SCHEMA_VERSION);
expect(schemaVersionForType("css")).toBe(CSS_SCHEMA_VERSION);
expect(workshopExportFilename("My / Theme!", "css")).toBe("my-theme.css");
});
it("validates screenshot count, MIME type, and size", () => {
expect(workshopScreenshotsSchema.safeParse([{ size: 1024, type: "image/png" }]).success).toBe(true);
expect(workshopScreenshotsSchema.safeParse([{ size: 1024, type: "image/svg+xml" }]).success).toBe(false);
expect(workshopScreenshotsSchema.safeParse([{ size: 6 * 1024 * 1024, type: "image/webp" }]).success).toBe(false);
});
});
+175
View File
@@ -0,0 +1,175 @@
import { customWidgetImportSchema } from "@homarr/validation/custom-widget";
import { z } from "zod";
export const WORKSHOP_API_URL = "https://store.homarr.dev";
export const WORKSHOP_WEB_URL = "https://homarr.dev/workshop";
export const WIDGET_SCHEMA_VERSION = "homarr-custom-widget-v2";
export const CSS_SCHEMA_VERSION = "homarr-custom-css-v1";
export const MAX_CSS_LENGTH = 16_384;
export const MAX_CONTENT_LENGTH = 1_000_000;
export const MAX_SCREENSHOTS = 5;
export const MAX_SCREENSHOT_BYTES = 5 * 1024 * 1024;
export const WORKSHOP_SCREENSHOT_MIME_TYPES = ["image/png", "image/jpeg", "image/webp"] as const;
export const workshopSubmissionTypeSchema = z.enum(["widget", "css"]);
export type WorkshopSubmissionType = z.infer<typeof workshopSubmissionTypeSchema>;
export const workshopRoleSchema = z.enum(["member", "moderator", "admin"]);
export type WorkshopRole = z.infer<typeof workshopRoleSchema>;
export const workshopAccountStateSchema = z.enum(["active", "posting_banned", "disabled"]);
export type WorkshopAccountState = z.infer<typeof workshopAccountStateSchema>;
export const workshopReportCategorySchema = z.enum(["malicious", "spam", "copyright", "inappropriate", "other"]);
export const workshopReportStatusSchema = z.enum(["open", "resolved", "dismissed"]);
export const workshopUserSchema = z.object({
id: z.string(),
displayName: z.string(),
avatarUrl: z.string().optional().default(""),
role: workshopRoleSchema,
state: workshopAccountStateSchema,
});
export type WorkshopUser = z.infer<typeof workshopUserSchema>;
const submissionBaseSchema = z.object({
id: z.string(),
type: workshopSubmissionTypeSchema,
title: z.string(),
description: z.string().default(""),
schemaVersion: z.string(),
screenshots: z.array(z.string()).default([]),
revision: z.number().int().positive(),
changelog: z.string().default(""),
author: z.string(),
authorName: z.string(),
score: z.number().int(),
upvotes: z.number().int(),
downvotes: z.number().int(),
created: z.string(),
updated: z.string(),
});
export const workshopSubmissionSummarySchema = submissionBaseSchema;
export type WorkshopSubmissionSummary = z.infer<typeof workshopSubmissionSummarySchema>;
export const workshopSubmissionDetailSchema = submissionBaseSchema.extend({ content: z.string() });
export type WorkshopSubmissionDetail = z.infer<typeof workshopSubmissionDetailSchema>;
export const workshopSubmissionInputSchema = z
.object({
type: workshopSubmissionTypeSchema,
title: z.string().trim().min(3).max(100),
description: z.string().trim().max(2_000).default(""),
content: z.string().min(1).max(MAX_CONTENT_LENGTH),
changelog: z.string().trim().max(2_000).default(""),
})
.superRefine((input, context) => {
const result = validateWorkshopContent(input.type, input.content);
if (!result.success) context.addIssue({ code: "custom", message: result.error, path: ["content"] });
});
export type WorkshopSubmissionInput = z.infer<typeof workshopSubmissionInputSchema>;
export const workshopScreenshotSchema = z.object({
size: z.number().max(MAX_SCREENSHOT_BYTES, "Each screenshot must be at most 5 MiB"),
type: z.enum(WORKSHOP_SCREENSHOT_MIME_TYPES, { error: "Screenshots must be PNG, JPEG, or WebP" }),
});
export const workshopScreenshotsSchema = z.array(workshopScreenshotSchema).max(MAX_SCREENSHOTS);
export const workshopVoteSchema = z.object({
id: z.string(),
submission: z.string(),
user: z.string(),
value: z.union([z.literal(1), z.literal(-1)]),
});
export type WorkshopVote = z.infer<typeof workshopVoteSchema>;
export const workshopReportSchema = z.object({
id: z.string(),
submission: z.string(),
reporter: z.string(),
category: workshopReportCategorySchema,
explanation: z.string(),
status: workshopReportStatusSchema,
created: z.string(),
updated: z.string(),
});
export type WorkshopReport = z.infer<typeof workshopReportSchema>;
export const workshopModerationActionSchema = z.object({
id: z.string(),
actor: z.string(),
action: z.enum(["remove_submission", "set_account_state", "set_role", "resolve_report"]),
targetType: z.string(),
targetId: z.string(),
reason: z.string(),
snapshot: z.string().default(""),
created: z.string(),
});
export type WorkshopModerationAction = z.infer<typeof workshopModerationActionSchema>;
export const workshopErrorCodeSchema = z.enum([
"authentication_required",
"account_disabled",
"posting_banned",
"forbidden",
"not_found",
"invalid_submission",
"conflict",
"rate_limited",
"unavailable",
"unknown",
]);
export type WorkshopErrorCode = z.infer<typeof workshopErrorCodeSchema>;
export class WorkshopError extends Error {
constructor(
public readonly code: WorkshopErrorCode,
message: string,
public readonly status?: number,
) {
super(message);
this.name = "WorkshopError";
}
}
export type WorkshopValidationResult = { success: true; data: unknown } | { success: false; error: string };
export function validateWorkshopContent(type: WorkshopSubmissionType, content: string): WorkshopValidationResult {
if (type === "css") {
if (!content.trim()) return { success: false, error: "CSS cannot be empty" };
if (content.length > MAX_CSS_LENGTH) {
return { success: false, error: `CSS must be at most ${MAX_CSS_LENGTH} characters` };
}
return { success: true, data: content };
}
try {
const parsed: unknown = JSON.parse(content);
const result = customWidgetImportSchema.safeParse(parsed);
if (!result.success || result.data.$schema !== WIDGET_SCHEMA_VERSION) {
return {
success: false,
error: result.success
? "Widget schema identifier is required"
: (result.error.issues[0]?.message ?? "Invalid widget"),
};
}
return { success: true, data: result.data };
} catch {
return { success: false, error: "Widget content is not valid JSON" };
}
}
export const schemaVersionForType = (type: WorkshopSubmissionType) =>
type === "widget" ? WIDGET_SCHEMA_VERSION : CSS_SCHEMA_VERSION;
export function workshopExportFilename(title: string, type: WorkshopSubmissionType) {
const safeTitle =
title
.trim()
.toLowerCase()
.replace(/[^a-z0-9_-]+/g, "-")
.replace(/^-+|-+$/g, "") || "homarr-workshop";
return `${safeTitle}.${type === "widget" ? "json" : "css"}`;
}
+8
View File
@@ -0,0 +1,8 @@
{
"extends": "@homarr/tsconfig/base.json",
"compilerOptions": {
"tsBuildInfoFile": "node_modules/.cache/tsbuildinfo.json"
},
"include": ["*.ts", "src"],
"exclude": ["node_modules"]
}
+33 -88
View File
@@ -12,9 +12,6 @@ catalogs:
'@auth/drizzle-adapter':
specifier: ^1.11.2
version: 1.11.2
'@base-ui/react':
specifier: ^1.5.0
version: 1.6.0
'@ctrl/deluge':
specifier: ^8.0.0
version: 8.0.0
@@ -303,9 +300,6 @@ catalogs:
chroma-js:
specifier: ^3.2.0
version: 3.2.0
class-variance-authority:
specifier: ^0.7.1
version: 0.7.1
clsx:
specifier: ^2.1.1
version: 2.1.1
@@ -504,9 +498,6 @@ catalogs:
svgson:
specifier: ^5.3.1
version: 5.3.1
tailwind-merge:
specifier: ^3.6.0
version: 3.6.0
testcontainers:
specifier: ^12.0.4
version: 12.0.4
@@ -572,8 +563,8 @@ catalogs:
version: 5.0.0
overrides:
'@babel/helpers@<7.26.10': ^7.29.7
'@babel/runtime@<7.26.10': ^7.29.7
'@babel/helpers@<7.26.10': '>=7.29.7'
'@babel/runtime@<7.26.10': '>=7.29.7'
axios@>=1.0.0 <1.8.2: '>=1.18.1'
brace-expansion@>=2.0.0 <=2.0.1: '>=5.0.7'
brace-expansion@>=1.0.0 <=1.1.11: '>=5.0.7'
@@ -682,9 +673,6 @@ importers:
apps/docs:
dependencies:
'@base-ui/react':
specifier: 'catalog:'
version: 1.6.0(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)
'@docusaurus/core':
specifier: ^3.10.1
version: 3.10.1(@docusaurus/faster@3.10.1(@docusaurus/types@3.10.1(@swc/core@1.15.3(@swc/helpers@0.5.15))(esbuild@0.28.1)(postcss@8.5.16)(react-dom@19.2.7(react@19.2.7))(react@19.2.7))(@swc/helpers@0.5.15)(esbuild@0.28.1)(postcss@8.5.16))(@mdx-js/react@3.1.1(@types/react@19.2.17)(react@19.2.7))(@rspack/core@1.7.11(@swc/helpers@0.5.15))(@swc/core@1.15.3(@swc/helpers@0.5.15))(esbuild@0.28.1)(postcss@8.5.16)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)(typescript@6.0.3)
@@ -709,6 +697,9 @@ importers:
'@homarr/definitions':
specifier: workspace:^
version: link:../../packages/definitions
'@homarr/workshop':
specifier: workspace:^0.1.0
version: link:../../packages/workshop
'@mdx-js/react':
specifier: ^3.1.1
version: 3.1.1(@types/react@19.2.17)(react@19.2.7)
@@ -727,9 +718,6 @@ importers:
'@tailwindcss/postcss':
specifier: ^4.3.2
version: 4.3.2
class-variance-authority:
specifier: 'catalog:'
version: 0.7.1
clsx:
specifier: 'catalog:'
version: 2.1.1
@@ -742,9 +730,6 @@ importers:
leader-line-new:
specifier: ^1.1.9
version: 1.1.9
pocketbase:
specifier: 'catalog:'
version: 0.26.9
postcss:
specifier: ^8.5.16
version: 8.5.16
@@ -766,15 +751,9 @@ importers:
swiper:
specifier: ^14.0.2
version: 14.0.2
tailwind-merge:
specifier: 'catalog:'
version: 3.6.0
tailwindcss:
specifier: ^4.3.2
version: 4.3.2
zod:
specifier: 'catalog:'
version: 4.4.3
devDependencies:
'@docusaurus/module-type-aliases':
specifier: ^3.10.1
@@ -803,6 +782,9 @@ importers:
typescript:
specifier: 'catalog:'
version: 6.0.3
zod:
specifier: 'catalog:'
version: 4.4.3
apps/nextjs:
dependencies:
@@ -914,6 +896,9 @@ importers:
'@homarr/widgets':
specifier: workspace:^0.1.0
version: link:../../packages/widgets
'@homarr/workshop':
specifier: workspace:^0.1.0
version: link:../../packages/workshop
'@mantine/colors-generator':
specifier: 'catalog:'
version: 9.4.1(chroma-js@3.2.0)
@@ -2724,6 +2709,28 @@ importers:
specifier: 'catalog:'
version: 6.0.3
packages/workshop:
dependencies:
'@homarr/validation':
specifier: workspace:^0.1.0
version: link:../validation
pocketbase:
specifier: 'catalog:'
version: 0.26.9
zod:
specifier: 'catalog:'
version: 4.4.3
devDependencies:
'@homarr/tsconfig':
specifier: workspace:^0.1.0
version: link:../../tooling/typescript
esbuild:
specifier: 'catalog:'
version: 0.28.0
typescript:
specifier: 'catalog:'
version: 6.0.3
tooling/github: {}
tooling/typescript: {}
@@ -3495,33 +3502,6 @@ packages:
'@balena/dockerignore@1.0.2':
resolution: {integrity: sha512-wMue2Sy4GAVTk6Ic4tJVcnfdau+gx2EnG7S+uAEe+TWJFqE4YoWN4/H8MSLj4eYJKxGg26lZwboEniNiNwZQ6Q==}
'@base-ui/react@1.6.0':
resolution: {integrity: sha512-/jzjTWJYXhRFO45Bev9lc3cHbmjzCMpUqbMZ2AgKy/z25mY9B6shGSNcXcjQar9n5doM0KYW1W8fcFv2jZBuMw==}
engines: {node: '>=14.0.0'}
peerDependencies:
'@date-fns/tz': ^1.2.0
'@types/react': ^17 || ^18 || ^19
date-fns: ^4.0.0
react: ^17 || ^18 || ^19
react-dom: ^17 || ^18 || ^19
peerDependenciesMeta:
'@date-fns/tz':
optional: true
'@types/react':
optional: true
date-fns:
optional: true
'@base-ui/utils@0.3.1':
resolution: {integrity: sha512-gFFiltORVmW/N6IILTGxizP3PBpVpysqML1ALY5Vk0mH+7faVkCknOU31goYHN5Aoek2dkjxva1XOD2Ce9WuIg==}
peerDependencies:
'@types/react': ^17 || ^18 || ^19
react: ^17 || ^18 || ^19
react-dom: ^17 || ^18 || ^19
peerDependenciesMeta:
'@types/react':
optional: true
'@bcoe/v8-coverage@1.0.2':
resolution: {integrity: sha512-6zABk/ECA/QYSCQ1NGiVwwbQerUCZ+TQbp64Q3AgmfNvurHH0j8TtXa1qbShXA6qqkpAj4V5W8pP6mLe1mcMqA==}
engines: {node: '>=18'}
@@ -8947,9 +8927,6 @@ packages:
resolution: {integrity: sha512-NIxF55hv4nSqQswkAeiOi1r83xy8JldOFDTWiug55KBu9Jnblncd2U6ViHmYgHf01TPZS77NJBhBMKdWj9HQMQ==}
engines: {node: '>=8'}
class-variance-authority@0.7.1:
resolution: {integrity: sha512-Ka+9Trutv7G8M6WT6SeiRWz792K5qEqIGEGzXKhAE6xOWAY6pPH8U+9IY3oCMv6kqTmLsv7Xh/2w2RigkePMsg==}
clean-css@5.3.3:
resolution: {integrity: sha512-D5J+kHaVb/wKSFcyyV75uCn8fiY4sV38XJoe4CUyGQ+mOU/fMVYUdH1hJC+CJQ5uY3EnW27SbJYS4X8BiLrAFg==}
engines: {node: '>= 10.0'}
@@ -14383,9 +14360,6 @@ packages:
tailwind-merge@3.5.0:
resolution: {integrity: sha512-I8K9wewnVDkL1NTGoqWmVEIlUcB9gFriAEkXkfCjX5ib8ezGxtR3xD7iZIxrfArjEsH7F1CHD4RFUtxefdqV/A==}
tailwind-merge@3.6.0:
resolution: {integrity: sha512-uxL7qAVQriqRQPAyK3pj66VqskWqoZ37PW94jwOTwNfq/z9oyu1V+eqrZqtR2+fCiXdYOZe/Modt8GtvqNzu+w==}
tailwindcss@4.3.2:
resolution: {integrity: sha512-WtctNNSH8A9jlMIqxzuYumOHU5uGZyRv0Q5svQl+oEPy5w84YpBxdb7MdqyiSPQge5jTJ6zFQLq0PFygdccSBA==}
@@ -16430,29 +16404,6 @@ snapshots:
'@balena/dockerignore@1.0.2': {}
'@base-ui/react@1.6.0(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)':
dependencies:
'@babel/runtime': 7.29.7
'@base-ui/utils': 0.3.1(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)
'@floating-ui/react-dom': 2.1.8(react-dom@19.2.7(react@19.2.7))(react@19.2.7)
'@floating-ui/utils': 0.2.11
react: 19.2.7
react-dom: 19.2.7(react@19.2.7)
use-sync-external-store: 1.6.0(react@19.2.7)
optionalDependencies:
'@types/react': 19.2.17
'@base-ui/utils@0.3.1(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)':
dependencies:
'@babel/runtime': 7.29.7
'@floating-ui/utils': 0.2.11
react: 19.2.7
react-dom: 19.2.7(react@19.2.7)
reselect: 5.2.0
use-sync-external-store: 1.6.0(react@19.2.7)
optionalDependencies:
'@types/react': 19.2.17
'@bcoe/v8-coverage@1.0.2': {}
'@better-auth/core@1.6.15(@better-auth/utils@0.4.1)(@better-fetch/fetch@1.1.21)(@opentelemetry/api@1.9.0)(better-call@1.3.5(zod@4.4.3))(jose@6.2.3)(kysely@0.29.2)(nanostores@1.3.0)':
@@ -22799,10 +22750,6 @@ snapshots:
ci-info@3.9.0: {}
class-variance-authority@0.7.1:
dependencies:
clsx: 2.1.1
clean-css@5.3.3:
dependencies:
source-map: 0.6.1
@@ -29110,8 +29057,6 @@ snapshots:
tailwind-merge@3.5.0: {}
tailwind-merge@3.6.0: {}
tailwindcss@4.3.2: {}
tapable@2.3.3: {}
+4 -8
View File
@@ -95,6 +95,7 @@ catalog:
"@types/xml2js": ^0.4.14
posthog-js: ^1.398.2
posthog-node: ^5.39.2
pocketbase: ^0.26.2
"@vitejs/plugin-react": ^6.0.3
"@vitest/coverage-v8": ^4.1.9
"@vitest/ui": ^4.1.9
@@ -154,7 +155,6 @@ catalog:
oxfmt: ^0.58.0
oxlint: ^1.72.0
pg: ^8.22.0
pocketbase: ^0.26.2
postcss-preset-mantine: ^1.18.0
prismjs: ^1.30.0
proxmox-api: 1.1.1
@@ -192,10 +192,7 @@ catalog:
zod: ^4.4.3
zod-form-data: ^3.0.2
zod-validation-error: ^5.0.0
class-variance-authority: ^0.7.1
tailwind-merge: ^3.6.0
framer-motion: ^12.40.0
"@base-ui/react": ^1.5.0
catalogMode: strict
nodeLinker: hoisted
strictPeerDependencies: false
@@ -215,8 +212,8 @@ minimumReleaseAgeExclude:
# Renovate security update: esbuild@0.28.1
- esbuild@0.28.1
overrides:
"@babel/helpers@<7.26.10": "^7.29.7"
"@babel/runtime@<7.26.10": "^7.29.7"
"@babel/helpers@<7.26.10": ">=7.29.7"
"@babel/runtime@<7.26.10": ">=7.29.7"
"axios@>=1.0.0 <1.8.2": ">=1.18.1"
"brace-expansion@>=2.0.0 <=2.0.1": ">=5.0.7"
"brace-expansion@>=1.0.0 <=1.1.11": ">=5.0.7"
@@ -256,4 +253,3 @@ allowBuilds:
msw: false
protobufjs: false
vue-demi: false
"@parcel/watcher": true
+97
View File
@@ -0,0 +1,97 @@
#!/usr/bin/env bash
set -euo pipefail
mode="${1:-all}"
case "$mode" in
fast) job="fast-gate" ;;
docker) job="container" ;;
all) job="" ;;
cleanup) job="cleanup" ;;
*)
echo "Usage: $0 {fast|docker|all|cleanup}" >&2
exit 2
;;
esac
command -v act >/dev/null 2>&1 || {
echo "act is required: https://nektosact.com/installation/index.html" >&2
exit 1
}
docker info >/dev/null 2>&1 || {
echo "Docker is not running" >&2
exit 1
}
if [[ -z "${DOCKER_HOST:-}" ]]; then
DOCKER_HOST=$(docker context inspect --format '{{.Endpoints.docker.Host}}' 2>/dev/null || true)
export DOCKER_HOST
fi
head_sha=$(git rev-parse HEAD)
base_sha=$(git rev-parse HEAD~1 2>/dev/null || echo "$head_sha")
head_ref=$(git branch --show-current)
head_ref=${head_ref:-act-local}
event_file=$(mktemp "${TMPDIR:-/tmp}/homarr-act-event.XXXXXX")
trap 'rm -f "$event_file"' EXIT
json_escape() { printf '%s' "$1" | sed 's/\\/\\\\/g; s/"/\\"/g; s/\t/\\t/g'; }
if [[ "$mode" == "cleanup" ]]; then
event_name="workflow_dispatch"
cat >"$event_file" <<EOF
{
"inputs": { "operation": "cleanup", "pr_number": "1", "ref": "" },
"repository": { "full_name": "homarr-labs/homarr" }
}
EOF
else
event_name="pull_request"
safe_ref=$(json_escape "$head_ref")
cat >"$event_file" <<EOF
{
"action": "synchronize",
"number": 1,
"repository": { "full_name": "homarr-labs/homarr" },
"pull_request": {
"number": 1,
"draft": false,
"base": { "sha": "$base_sha", "ref": "dev" },
"head": {
"sha": "$head_sha",
"ref": "$safe_ref",
"repo": { "full_name": "homarr-labs/homarr" }
}
}
}
EOF
fi
runner_image="${ACT_RUNNER_IMAGE:-catthehacker/ubuntu:act-latest}"
args=(
"$event_name"
--workflows .github/workflows/ci.yml
--eventpath "$event_file"
--platform "ubuntu-latest=$runner_image"
--container-architecture linux/amd64
--bind
)
if [[ -n "$job" ]]; then
args+=(--job "$job")
fi
git_common_dir=$(cd "$(git rev-parse --git-common-dir)" && pwd)
container_options="--volume homarr-act-node-modules:$PWD/node_modules --volume homarr-act-pnpm-store:$PWD/.pnpm-store"
case "$git_common_dir" in
"$PWD" | "$PWD"/*) ;;
*) container_options="$container_options --volume $git_common_dir:$git_common_dir:ro" ;;
esac
args+=(--container-options "$container_options")
if [[ -n "${ACT_SECRET_FILE:-}" ]]; then
args+=(--secret-file "$ACT_SECRET_FILE")
fi
exec act "${args[@]}"
-22
View File
@@ -1,22 +0,0 @@
#!/bin/sh
set -eu
if ! command -v act >/dev/null 2>&1; then
echo "act is required: https://nektosact.com/installation/" >&2
exit 1
fi
socket="${DOCKER_HOST:-}"
if [ -z "$socket" ]; then
socket="$(docker context inspect --format '{{ (index .Endpoints "docker").Host }}' 2>/dev/null || true)"
fi
if [ -z "$socket" ]; then
socket="unix:///var/run/docker.sock"
fi
exec act workflow_dispatch \
--workflows .github/workflows/deployment-workshop.yml \
--job deploy \
--env ACT=true \
--container-daemon-socket "$socket" \
--container-architecture linux/amd64
+2 -1
View File
@@ -1,6 +1,6 @@
{
"$schema": "https://turborepo.org/schema.json",
"remoteCache": { "signature": false },
"remoteCache": { "signature": true },
"globalEnv": ["CI", "NODE_ENV", "SKIP_ENV_VALIDATION"],
"ui": "stream",
"futureFlags": {
@@ -13,6 +13,7 @@
},
"build": {
"dependsOn": ["^build"],
"env": ["TURBO_PLATFORM"],
"inputs": [
"$TURBO_DEFAULT$",
"!**/*.test.ts",