Compare commits
28
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
0ed5636782 | ||
|
|
fd28289f40 | ||
|
|
121a83040d | ||
|
|
89260b8754 | ||
|
|
8bf2171eaa | ||
|
|
420633f125 | ||
|
|
54871bf22e | ||
|
|
e6f8174a44 | ||
|
|
74289b8b01 | ||
|
|
4e2813d23e | ||
|
|
8938cb6cf7 | ||
|
|
2fb92b7816 | ||
|
|
cff2e08b92 | ||
|
|
f1e4866a8b | ||
|
|
b0429f9287 | ||
|
|
d41e5daa71 | ||
|
|
5474181e2e | ||
|
|
cb351f5b69 | ||
|
|
9301718974 | ||
|
|
8841aac40e | ||
|
|
568a7a87a5 | ||
|
|
e37bd38f34 | ||
|
|
1cafb5edac | ||
|
|
cc89beca31 | ||
|
|
92d9cdc4b7 | ||
|
|
20942d47c6 | ||
|
|
de8d20a030 | ||
|
|
1fb7e16d42 |
No files matched your search
@@ -6,14 +6,7 @@ npm-debug.log
|
||||
README.md
|
||||
.next
|
||||
.turbo
|
||||
.pnpm-store
|
||||
**/.pnpm-store
|
||||
coverage
|
||||
**/coverage
|
||||
**/.docusaurus
|
||||
**/build
|
||||
.git
|
||||
.github
|
||||
dev
|
||||
.build
|
||||
e2e
|
||||
|
||||
+6
-11
@@ -67,15 +67,10 @@ 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
|
||||
# Central Workshop API is https://homarr.dev. Override only when developing
|
||||
# the standalone Docusaurus or Homarr clients against a local backend.
|
||||
# NEXT_PUBLIC_WORKSHOP_API_URL=http://localhost:8090
|
||||
# WORKSHOP_API_URL=http://localhost:8090
|
||||
|
||||
# Workshop development ports. PocketBase superusers and GitHub OAuth are
|
||||
# configured once through the persistent PocketBase dashboard, not environment variables.
|
||||
PB_EXPOSE_PORT=8090
|
||||
WORKSHOP_WEB_PORT=3003
|
||||
# Workshop is intentionally consumable by arbitrary self-hosted Homarr origins.
|
||||
# PocketBase is stateless and authorizes writes with bearer tokens, not cookies.
|
||||
PB_ALLOWED_ORIGINS=*
|
||||
# Community workshop (PocketBase) base URL used by the in-app "Install from Workshop" buttons
|
||||
# Workshop API override for local Homarr development:
|
||||
# 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
|
||||
@@ -1,2 +0,0 @@
|
||||
.secrets
|
||||
!.secrets.example
|
||||
@@ -1,4 +0,0 @@
|
||||
TURBO_API=
|
||||
TURBO_TEAM=
|
||||
TURBO_TOKEN=
|
||||
TURBO_REMOTE_CACHE_SIGNATURE_KEY=
|
||||
@@ -1,36 +0,0 @@
|
||||
# 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.
|
||||
@@ -0,0 +1,19 @@
|
||||
# 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.
|
||||
@@ -1,48 +0,0 @@
|
||||
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"
|
||||
@@ -1,347 +0,0 @@
|
||||
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@v6
|
||||
with:
|
||||
path: ~/.cache/ms-playwright
|
||||
key: playwright-${{ runner.os }}-${{ hashFiles('pnpm-lock.yaml') }}
|
||||
|
||||
- name: Install Playwright Chromium
|
||||
if: steps.playwright-cache.outputs.cache-hit != 'true' || env.ACT
|
||||
run: pnpm exec playwright install --with-deps chromium
|
||||
|
||||
- name: Install Playwright system deps
|
||||
if: steps.playwright-cache.outputs.cache-hit == 'true'
|
||||
run: pnpm exec playwright install-deps chromium
|
||||
|
||||
- name: Run E2E tests
|
||||
run: pnpm test:e2e
|
||||
|
||||
- name: Upload E2E artifacts
|
||||
if: failure() && !env.ACT
|
||||
uses: actions/upload-artifact@v7
|
||||
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."
|
||||
@@ -0,0 +1,165 @@
|
||||
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,11 +148,6 @@ 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:
|
||||
@@ -192,11 +187,6 @@ 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:
|
||||
|
||||
@@ -0,0 +1,47 @@
|
||||
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"
|
||||
@@ -1,50 +0,0 @@
|
||||
name: Workshop
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
types: [opened, synchronize, reopened, ready_for_review]
|
||||
paths: &workshop-paths
|
||||
- "apps/workshop/**"
|
||||
- "apps/docs/docusaurus.config.ts"
|
||||
- "apps/docs/package.json"
|
||||
- "apps/docs/src/components/workshop/**"
|
||||
- "apps/docs/src/pages/workshop/**"
|
||||
- "apps/docs/src/theme/Root.tsx"
|
||||
- "apps/docs/static/workshop-*"
|
||||
- "apps/docs/docs/management/workshop/**"
|
||||
- "packages/workshop/**"
|
||||
- "packages/validation/**"
|
||||
- "packages/definitions/**"
|
||||
- "patches/**"
|
||||
- "package.json"
|
||||
- "pnpm-lock.yaml"
|
||||
- "pnpm-workspace.yaml"
|
||||
- "turbo.json"
|
||||
- ".github/workflows/workshop.yml"
|
||||
push:
|
||||
branches: [dev]
|
||||
paths: *workshop-paths
|
||||
workflow_dispatch:
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
concurrency:
|
||||
group: workshop-${{ github.ref }}-${{ github.event.pull_request.number || github.sha }}
|
||||
cancel-in-progress: true
|
||||
|
||||
jobs:
|
||||
production-preview:
|
||||
name: Production preview
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 20
|
||||
steps:
|
||||
- uses: actions/checkout@v7
|
||||
- name: Setup
|
||||
uses: ./tooling/github/setup
|
||||
- name: Copy environment defaults
|
||||
run: cp .env.example .env
|
||||
- name: Test Workshop backend
|
||||
run: pnpm test:workshop-backend
|
||||
- name: Build and smoke-test Workshop
|
||||
run: pnpm test:workshop-preview
|
||||
@@ -1,87 +0,0 @@
|
||||
---
|
||||
score: 9
|
||||
p0: 0
|
||||
p1: 1
|
||||
timestamp: 2026-07-13T21-26-11Z
|
||||
slug: apps-docs-src-components-workshop
|
||||
---
|
||||
# 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 image and origin at `homarr.dev`; PocketBase serves both compiled docs and `/api`.
|
||||
- Resilience: the public shell and successfully loaded query data are cached independently; the rest of Homarr never depends on Workshop availability.
|
||||
- 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.
|
||||
- [x] Documentation and Workshop API are built into one reproducible image and served from `homarr.dev` without production cross-origin requests.
|
||||
- [ ] 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 runs unprivileged and builds with checksum verification; clean boot and a cloned legacy SQLite volume both migrate and become healthy. |
|
||||
| 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/mobile discovery and the disconnected API state render without uncaught errors; cached data remains readable and retry is explicit. |
|
||||
| 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.
|
||||
@@ -0,0 +1,28 @@
|
||||
# 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.
|
||||
@@ -0,0 +1,9 @@
|
||||
# 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.
|
||||
@@ -81,3 +81,5 @@ 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.
|
||||
@@ -1,23 +0,0 @@
|
||||
# Workshop design system
|
||||
|
||||
Workshop is a trustworthy community workbench inside Homarr, not a separate storefront brand.
|
||||
|
||||
## Visual direction
|
||||
|
||||
- Reuse the host surface: Docusaurus tokens on `homarr.dev`, Mantine components inside Homarr.
|
||||
- Use Homarr red only for primary action and focus; warnings use restrained amber and errors use red with explanatory text.
|
||||
- Keep surfaces flat, bordered, and readable. Cards may lift by 3px on pointer hover; no gradients, glass panels, or decorative dashboards.
|
||||
- Typography follows each host. Headings are compact and confident; body copy stays warm, concrete, and short.
|
||||
- Spacing follows an 8px rhythm, 12-16px corners, and 44px minimum controls.
|
||||
|
||||
## Interaction direction
|
||||
|
||||
- The catalog progression is discover → inspect → confirm → use.
|
||||
- Every remote state has a named loading, empty, cached, unavailable, validation, permission, and success outcome.
|
||||
- Community source is never executed on the website. Installation always follows source inspection and local validation.
|
||||
- Motion is limited to 150-200ms hover/state feedback and skeleton loading, with reduced-motion fallbacks.
|
||||
- Native dialogs or Mantine modals own focus, Escape, backdrop dismissal, and keyboard order.
|
||||
|
||||
## Voice
|
||||
|
||||
Clear, dependable, and community-minded. Explain what remains usable during failures and provide one direct recovery action. Avoid playful language during moderation, destructive actions, or service failures.
|
||||
+13
-32
@@ -1,44 +1,24 @@
|
||||
# syntax=docker/dockerfile:1.25
|
||||
|
||||
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
|
||||
ARG CI='true'
|
||||
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
|
||||
# Workaround for pnpm/pnpm#5268: pnpm fetch crashes when patchedDependencies
|
||||
# are configured with nodeLinker: hoisted. The applyPatchToDir function tries
|
||||
# to chdir into node_modules/<pkg> which doesn't exist during fetch (only the
|
||||
# content-addressable store is populated). By temporarily switching to the
|
||||
# isolated linker, patches apply inside the virtual store (node_modules/.pnpm/...)
|
||||
# which IS created by pnpm fetch. The original hoisted linker is restored
|
||||
# before the install step so the final node_modules layout stays flat.
|
||||
RUN --mount=type=cache,id=pnpm-store,target=/pnpm/store \
|
||||
pnpm config set store-dir /pnpm/store && \
|
||||
sed -i 's/nodeLinker: hoisted/nodeLinker: isolated/' pnpm-workspace.yaml && \
|
||||
pnpm fetch && \
|
||||
sed -i 's/nodeLinker: isolated/nodeLinker: hoisted/' pnpm-workspace.yaml
|
||||
|
||||
RUN apk add --no-cache libc6-compat curl bash
|
||||
RUN apk update
|
||||
COPY . .
|
||||
# Follow the pnpm fetch pattern from https://pnpm.io/cli/fetch
|
||||
# --offline enforces no registry access (all packages are in the store from fetch)
|
||||
# --frozen-lockfile is omitted as recommended by the pnpm fetch docs
|
||||
RUN --mount=type=cache,id=pnpm-store,target=/pnpm/store \
|
||||
pnpm install --recursive --offline
|
||||
|
||||
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 --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
|
||||
RUN corepack enable pnpm && pnpm build
|
||||
|
||||
FROM base AS runner
|
||||
WORKDIR /app
|
||||
@@ -82,6 +62,7 @@ COPY nginx.conf /etc/nginx/templates/nginx.conf
|
||||
ENV DB_URL='/appdata/db/db.sqlite'
|
||||
ENV DB_DIALECT='sqlite'
|
||||
ENV DB_DRIVER='better-sqlite3'
|
||||
ENV AUTH_PROVIDERS='credentials'
|
||||
ENV REDIS_IS_EXTERNAL='false'
|
||||
ENV NODE_ENV='production'
|
||||
|
||||
|
||||
+13
-11
@@ -2,32 +2,34 @@
|
||||
|
||||
## Register
|
||||
|
||||
product
|
||||
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
|
||||
|
||||
Homarr serves people who organize and operate self-hosted services, from individual home-lab users to administrators managing shared instances. Workshop also serves community creators who publish and install custom widgets and CSS themes. Users need the core dashboard to remain useful even when optional community services are offline.
|
||||
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 provides a dependable dashboard for discovering, organizing, and operating applications and integrations. Workshop adds a central community catalog where users can inspect, export, publish, report, and install creations with clear source visibility and explicit confirmation.
|
||||
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
|
||||
|
||||
Clear, dependable, and community-minded. Interface copy should be direct and practical, with enough context for users to recover from failures without exposing internal implementation details.
|
||||
Practical, capable, community-driven.
|
||||
|
||||
## Anti-references
|
||||
|
||||
Avoid fragmented feature surfaces, generic failure messages, decorative dashboard patterns, unexpected network dependencies, and optional services that can crash or block core Homarr workflows.
|
||||
- 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
|
||||
|
||||
- Keep core Homarr workflows available when optional services fail.
|
||||
- Explain failures in user terms and always offer a useful recovery action.
|
||||
- Use native Homarr patterns and shared contracts instead of feature-specific interaction systems.
|
||||
- Make community content inspectable and require explicit confirmation before installation.
|
||||
- Keep operational behavior deterministic for contributors and instance administrators.
|
||||
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
|
||||
|
||||
Meet WCAG 2.1 AA for contrast, focus, semantics, and keyboard operation. Respect reduced-motion preferences, preserve useful layouts at 200% zoom and on mobile, and ensure status changes and recovery actions are understandable without relying on color alone.
|
||||
WCAG 2.1 AA. Support for reduced motion. Sufficient contrast ratios (4.5:1 body text, 3:1 large text). Keyboard navigable.
|
||||
@@ -0,0 +1,24 @@
|
||||
{
|
||||
"$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"
|
||||
}
|
||||
@@ -24,10 +24,10 @@ Navigate to `Management` → `Tools` → `Backup`. You must have **admin** permi
|
||||
|
||||
Click the **Export** button in the export card. You'll be prompted to type `I understand` to confirm. Once confirmed, a ZIP file downloads containing:
|
||||
|
||||
| File | Description |
|
||||
| --------------- | ------------------------------------------------------------------- |
|
||||
| `db.sqlite` | Full SQLite database with all entities |
|
||||
| `metadata.json` | Backup metadata: Homarr version, creation timestamp, encryption key |
|
||||
| File | Description |
|
||||
| -------------------- | ------------------------------------------------------------------- |
|
||||
| `db.sqlite` | Full SQLite database with all entities |
|
||||
| `metadata.json` | Backup metadata: Homarr version, creation timestamp, encryption key |
|
||||
|
||||
The backup includes everything: apps, integrations, secrets, boards, users, groups, permissions, search engines, custom widgets, and server settings. (It just backs-up the whole database)
|
||||
|
||||
@@ -54,7 +54,6 @@ Type `I understand` to acknowledge that restoring will replace your current data
|
||||
The backup ZIP is sent to the server, validated, and applied. After a successful restore:
|
||||
|
||||
- **The Next.js server restarts automatically** to reload the new database state
|
||||
- **Server-side database migrations run before Next.js restarts**, including migrations added after the backup was created
|
||||
- Nginx and Redis (if running in Docker) stay up - only the Next.js process restarts
|
||||
- The page reloads once the server is ready
|
||||
|
||||
|
||||
@@ -82,10 +82,6 @@ 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. To publish your own widget, export it from Homarr and upload the JSON file in the Workshop submission form. 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.
|
||||
|
||||
@@ -7,40 +7,53 @@ tags:
|
||||
|
||||
# Community Workshop
|
||||
|
||||
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.
|
||||
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.
|
||||
|
||||
:::warning Community content
|
||||
:::warning
|
||||
|
||||
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.
|
||||
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.
|
||||
|
||||
:::
|
||||
|
||||
## Discover and install
|
||||
## Browsing and voting
|
||||
|
||||
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.
|
||||
- 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.
|
||||
|
||||
Recently loaded listings and details are saved in the browser for up to 24 hours. If Workshop is temporarily unreachable, Homarr shows the saved catalog with a clear offline notice; browsing the rest of Homarr is unaffected. Publishing, voting, reporting, and installation remain unavailable until the connection returns.
|
||||
## Submitting
|
||||
|
||||
The public site also caches pages you have visited. If your network drops, a previously opened Workshop or documentation page can still load while the saved catalog remains on the device. Use **Check again** after connectivity returns.
|
||||
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.
|
||||
|
||||
- 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.
|
||||
Submissions are published immediately. Reported entries are reviewed and may be removed.
|
||||
|
||||
## Share a creation
|
||||
## Submission guidelines
|
||||
|
||||
Export a custom widget from Homarr, then choose or drop the JSON file in the Workshop submission form. CSS themes can be pasted into the same form. Sign in with GitHub, provide a public title and description, inspect the source, and publish it.
|
||||
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.
|
||||
|
||||
Submissions are public immediately. Authors can update or delete their own submissions. Updating content increases its revision and shows the supplied changelog.
|
||||
## Installing into Homarr
|
||||
|
||||
## Votes and reports
|
||||
You can install workshop entries without leaving 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.
|
||||
- **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.
|
||||
|
||||
## Accounts and moderation
|
||||
Alternatively, use **Copy** (or **Download** for widgets) on any submission and import it
|
||||
manually.
|
||||
|
||||
Workshop accounts use GitHub OAuth and are separate from users in a self-hosted Homarr instance. PocketBase creates OAuth accounts directly; staff privileges come only from the server-managed `workshop_staff` collection. Workshop moderators can remove submissions, disable an account, and resolve reports. These actions require a reason and are recorded.
|
||||
## Workshop deployment
|
||||
|
||||
The Workshop is a central service operated by Homarr at `homarr.dev`. The documentation website and Workshop API ship in one image and use the same origin. Running it locally is supported for contributors developing or testing this feature, not as a private production marketplace.
|
||||
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.
|
||||
|
||||
The service image is built directly from the Homarr repository rather than published separately. Contributors can run `pnpm dev:workshop` to start PocketBase in Docker while Docusaurus and the Workshop contracts watch on the host. Use `pnpm preview:workshop` to build the combined production image, or `pnpm reset:workshop` to explicitly clear the gitignored local PocketBase database. Superusers and GitHub OAuth are configured once in the persistent dashboard instead of being passed as environment variables.
|
||||
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.
|
||||
@@ -3,10 +3,6 @@ import type { Config } from "@docusaurus/types";
|
||||
import { themes as prismThemes } from "prism-react-renderer";
|
||||
const a11yEmoji = require("@fec/remark-a11y-emoji");
|
||||
|
||||
// Production uses PocketBase's own origin. The explicit override exists only
|
||||
// for the standalone Docusaurus development server.
|
||||
const workshopApiUrl = process.env.WORKSHOP_API_URL ?? process.env.NEXT_PUBLIC_WORKSHOP_API_URL ?? "";
|
||||
|
||||
const config: Config = {
|
||||
title: "Homarr documentation",
|
||||
tagline: "A simple yet powerful dashboard for your server.",
|
||||
@@ -20,13 +16,15 @@ const config: Config = {
|
||||
defaultLocale: "en",
|
||||
locales: ["en"],
|
||||
},
|
||||
customFields: {
|
||||
workshopApiUrl,
|
||||
},
|
||||
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,
|
||||
@@ -290,7 +288,12 @@ const config: Config = {
|
||||
return {
|
||||
name: "resolve-homarr-packages",
|
||||
configureWebpack() {
|
||||
return { resolve: { symlinks: false } };
|
||||
return {
|
||||
resolve: {
|
||||
symlinks: false,
|
||||
alias: { "@": require("path").resolve(__dirname, "src") },
|
||||
},
|
||||
};
|
||||
},
|
||||
};
|
||||
},
|
||||
@@ -309,7 +312,6 @@ const config: Config = {
|
||||
],
|
||||
"docusaurus-plugin-image-zoom",
|
||||
require.resolve("./plugins/validate-docs-coverage"),
|
||||
require.resolve("./plugins/offline-manifest.mjs"),
|
||||
function disableExpensiveBundlerOptimizationPlugin() {
|
||||
return {
|
||||
name: "disable-expensive-bundler-optimizations",
|
||||
@@ -322,13 +324,13 @@ const config: Config = {
|
||||
},
|
||||
};
|
||||
},
|
||||
function workshopRoutesPlugin() {
|
||||
function workshopDetailRoutePlugin() {
|
||||
return {
|
||||
name: "workshop-routes",
|
||||
name: "workshop-detail-route",
|
||||
async contentLoaded({ actions }) {
|
||||
actions.addRoute({
|
||||
path: "/workshop/:id",
|
||||
component: "@site/src/components/workshop/WorkshopDetailRoutePage",
|
||||
component: "@site/src/components/workshop/DetailPage",
|
||||
exact: true,
|
||||
});
|
||||
},
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
"license": "Apache-2.0",
|
||||
"scripts": {
|
||||
"docusaurus": "docusaurus",
|
||||
"dev": "docusaurus start --no-open --host 0.0.0.0 --port 3003",
|
||||
"dev": "docusaurus start --no-open --port 3003",
|
||||
"start": "docusaurus start --port 3003",
|
||||
"build": "docusaurus build",
|
||||
"serve": "docusaurus serve",
|
||||
@@ -21,26 +21,25 @@
|
||||
"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",
|
||||
"@tanstack/query-async-storage-persister": "catalog:",
|
||||
"@tanstack/react-query": "catalog:",
|
||||
"@tanstack/react-query-persist-client": "catalog:",
|
||||
"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",
|
||||
@@ -48,7 +47,9 @@
|
||||
"react": "catalog:",
|
||||
"react-dom": "catalog:",
|
||||
"swiper": "^14.0.2",
|
||||
"tailwindcss": "^4.3.2"
|
||||
"tailwind-merge": "catalog:",
|
||||
"tailwindcss": "^4.3.2",
|
||||
"zod": "catalog:"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@docusaurus/module-type-aliases": "^3.10.1",
|
||||
@@ -59,8 +60,7 @@
|
||||
"@types/react": "catalog:",
|
||||
"@types/react-dom": "catalog:",
|
||||
"cheerio": "^1.2.0",
|
||||
"typescript": "catalog:",
|
||||
"zod": "catalog:"
|
||||
"typescript": "catalog:"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@docusaurus/theme-common": "^3.10.1",
|
||||
|
||||
@@ -1,26 +0,0 @@
|
||||
import { readdir, writeFile } from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
|
||||
const collectFiles = async (directory, root = directory) => {
|
||||
const entries = await readdir(directory, { withFileTypes: true });
|
||||
const files = await Promise.all(
|
||||
entries.map((entry) => {
|
||||
const absolutePath = path.join(directory, entry.name);
|
||||
return entry.isDirectory() ? collectFiles(absolutePath, root) : path.relative(root, absolutePath);
|
||||
}),
|
||||
);
|
||||
return files.flat();
|
||||
};
|
||||
|
||||
export default function offlineManifestPlugin() {
|
||||
return {
|
||||
name: "workshop-offline-manifest",
|
||||
async postBuild({ outDir }) {
|
||||
const assetsDirectory = path.join(outDir, "assets");
|
||||
const assets = (await collectFiles(assetsDirectory))
|
||||
.filter((file) => /\.(?:css|js|woff2?)$/.test(file))
|
||||
.map((file) => `/assets/${file.split(path.sep).join("/")}`);
|
||||
await writeFile(path.join(outDir, "workshop-assets.json"), JSON.stringify([...assets, "/img/logo.png"]));
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -1,13 +1,49 @@
|
||||
import { ReactNode } from "react";
|
||||
import { mergeProps } from "@base-ui/react/merge-props";
|
||||
import { useRender } from "@base-ui/react/use-render";
|
||||
import { cva, type VariantProps } from "class-variance-authority";
|
||||
|
||||
interface BadgeProps {
|
||||
children: ReactNode;
|
||||
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,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
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>
|
||||
);
|
||||
};
|
||||
export { Badge, badgeVariants };
|
||||
@@ -0,0 +1,50 @@
|
||||
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 };
|
||||
@@ -0,0 +1,70 @@
|
||||
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 };
|
||||
@@ -0,0 +1,134 @@
|
||||
"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,
|
||||
};
|
||||
@@ -1,4 +1,4 @@
|
||||
import { ReactNode } from "react";
|
||||
import type { 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>{category}</Badge>
|
||||
<Badge key={category}>{category}</Badge>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,20 @@
|
||||
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 };
|
||||
@@ -0,0 +1,167 @@
|
||||
"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,
|
||||
};
|
||||
@@ -0,0 +1,19 @@
|
||||
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 };
|
||||
@@ -0,0 +1,18 @@
|
||||
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 };
|
||||
@@ -0,0 +1,178 @@
|
||||
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>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,361 @@
|
||||
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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,123 @@
|
||||
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>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,542 @@
|
||||
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>
|
||||
);
|
||||
};
|
||||
@@ -1,330 +0,0 @@
|
||||
import React, { useEffect, useMemo, useState } from "react";
|
||||
import useDocusaurusContext from "@docusaurus/useDocusaurusContext";
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
|
||||
import { WorkshopClient, type WorkshopAccountState, type WorkshopRole, type WorkshopUser } from "@homarr/workshop";
|
||||
|
||||
import { WorkshopQueryProvider } from "./WorkshopQueryProvider";
|
||||
import { WorkshopDialog, WorkshopServiceState } from "./WorkshopUi";
|
||||
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() {
|
||||
return (
|
||||
<WorkshopQueryProvider>
|
||||
<WorkshopAdminContent />
|
||||
</WorkshopQueryProvider>
|
||||
);
|
||||
}
|
||||
|
||||
function WorkshopAdminContent() {
|
||||
const { siteConfig } = useDocusaurusContext();
|
||||
const client = useMemo(
|
||||
() => new WorkshopClient((siteConfig.customFields?.workshopApiUrl as string | undefined) || window.location.origin),
|
||||
[siteConfig],
|
||||
);
|
||||
const queryClient = useQueryClient();
|
||||
const [user, setUser] = useState<WorkshopUser | null>(null);
|
||||
const [pending, setPending] = useState<PendingAction | null>(null);
|
||||
const [error, setError] = useState("");
|
||||
const [authLoading, setAuthLoading] = useState(true);
|
||||
|
||||
const canModerate = user?.role === "moderator" || user?.role === "admin";
|
||||
const moderationQuery = useQuery({
|
||||
queryKey: ["workshop", "moderation"],
|
||||
queryFn: async () => {
|
||||
const [reports, users, actions] = await Promise.all([
|
||||
client.listReports(),
|
||||
client.listUsers(),
|
||||
client.listModerationActions(),
|
||||
]);
|
||||
return { reports, users, actions };
|
||||
},
|
||||
enabled: canModerate,
|
||||
});
|
||||
const reports = moderationQuery.data?.reports ?? [];
|
||||
const users = moderationQuery.data?.users ?? [];
|
||||
const actions = moderationQuery.data?.actions ?? [];
|
||||
const actionMutation = useMutation({
|
||||
mutationKey: ["workshop", "moderation", "action"],
|
||||
mutationFn: async ({ action, reason }: { action: PendingAction; reason: string }) => {
|
||||
if (action.kind === "report") await client.resolveReport(action.id, action.status, reason);
|
||||
else if (action.kind === "state") await client.updateAccountState(action.id, action.state, reason);
|
||||
else await client.updateRole(action.id, action.role, reason);
|
||||
},
|
||||
onSuccess: async () => {
|
||||
setPending(null);
|
||||
await queryClient.invalidateQueries({ queryKey: ["workshop", "moderation"] });
|
||||
},
|
||||
});
|
||||
const signInMutation = useMutation({
|
||||
mutationKey: ["workshop", "auth", "moderator"],
|
||||
mutationFn: () => client.signInWithGitHub(),
|
||||
onSuccess: setUser,
|
||||
onError: (caught) => setError(caught instanceof Error ? caught.message : "GitHub sign-in is unavailable"),
|
||||
});
|
||||
|
||||
useEffect(() => client.subscribeToAuth(setUser), [client]);
|
||||
useEffect(() => {
|
||||
void client
|
||||
.refreshAuth()
|
||||
.then(setUser)
|
||||
.catch((caught: unknown) =>
|
||||
setError(caught instanceof Error ? caught.message : "Workshop authentication is unavailable"),
|
||||
)
|
||||
.finally(() => setAuthLoading(false));
|
||||
}, [client]);
|
||||
|
||||
if (authLoading)
|
||||
return (
|
||||
<main className={styles.page}>
|
||||
<WorkshopAdminSkeleton />
|
||||
</main>
|
||||
);
|
||||
if (!user)
|
||||
return (
|
||||
<main className={styles.page}>
|
||||
<h1>Workshop moderation</h1>
|
||||
<p>Sign in with a promoted GitHub account to continue.</p>
|
||||
{error && (
|
||||
<div className={styles.error} role="alert">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
<button
|
||||
className="button button--primary"
|
||||
disabled={signInMutation.isPending}
|
||||
onClick={() => {
|
||||
setError("");
|
||||
signInMutation.mutate();
|
||||
}}
|
||||
>
|
||||
{signInMutation.isPending ? "Connecting…" : "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 || moderationQuery.isError) && (
|
||||
<WorkshopServiceState
|
||||
cached={Boolean(moderationQuery.data)}
|
||||
message={error || "Moderation data is unavailable. No action can be applied until Workshop reconnects."}
|
||||
onRetry={() => void moderationQuery.refetch()}
|
||||
/>
|
||||
)}
|
||||
<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 === "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={(reason) => actionMutation.mutateAsync({ action: pending, reason })}
|
||||
/>
|
||||
)}
|
||||
</main>
|
||||
);
|
||||
}
|
||||
|
||||
function WorkshopAdminSkeleton() {
|
||||
return (
|
||||
<div aria-label="Loading Workshop moderation">
|
||||
<h1>Workshop moderation</h1>
|
||||
<p>Loading account and moderation data…</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
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 (
|
||||
<WorkshopDialog titleId="moderation-reason-title" onClose={onClose}>
|
||||
<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>
|
||||
</WorkshopDialog>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,523 @@
|
||||
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>
|
||||
);
|
||||
};
|
||||
@@ -1,16 +0,0 @@
|
||||
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>
|
||||
);
|
||||
}
|
||||
@@ -1,740 +0,0 @@
|
||||
import React, { 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,
|
||||
IconHeart,
|
||||
IconPackage,
|
||||
IconSearch,
|
||||
IconSparkles,
|
||||
IconUpload,
|
||||
} from "@tabler/icons-react";
|
||||
|
||||
import {
|
||||
MAX_CONTENT_LENGTH,
|
||||
MAX_SCREENSHOTS,
|
||||
WorkshopClient,
|
||||
type WorkshopSubmissionDetail,
|
||||
type WorkshopSubmissionInput,
|
||||
type WorkshopSubmissionSummary,
|
||||
type WorkshopSubmissionType,
|
||||
type WorkshopUser,
|
||||
workshopExportFilename,
|
||||
} from "@homarr/workshop";
|
||||
import { keepPreviousData, useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
|
||||
import { WorkshopQueryProvider } from "./WorkshopQueryProvider";
|
||||
import { WorkshopDialog, WorkshopServiceState } from "./WorkshopUi";
|
||||
import styles from "./workshop.module.css";
|
||||
|
||||
const useWorkshop = () => {
|
||||
const { siteConfig } = useDocusaurusContext();
|
||||
const apiUrl = (siteConfig.customFields?.workshopApiUrl as string | undefined) || window.location.origin;
|
||||
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() {
|
||||
return (
|
||||
<WorkshopQueryProvider>
|
||||
<WorkshopAppContent />
|
||||
</WorkshopQueryProvider>
|
||||
);
|
||||
}
|
||||
|
||||
function WorkshopAppContent() {
|
||||
const client = useWorkshop();
|
||||
const queryClient = useQueryClient();
|
||||
const [user, setUser] = useState<WorkshopUser | null>(null);
|
||||
const [page, setPage] = useState(1);
|
||||
const [type, setType] = useState<WorkshopSubmissionType | "all">("all");
|
||||
const [sort, setSort] = useState<"top" | "newest">("top");
|
||||
const [search, setSearch] = useState("");
|
||||
const [debouncedSearch, setDebouncedSearch] = useState("");
|
||||
const [mine, setMine] = useState(false);
|
||||
const [error, setError] = useState("");
|
||||
const [showForm, setShowForm] = useState(false);
|
||||
const signInMutation = useMutation({
|
||||
mutationKey: ["workshop", "auth", "sign-in"],
|
||||
mutationFn: () => client.signInWithGitHub(),
|
||||
onSuccess: async () => queryClient.invalidateQueries({ queryKey: ["workshop"] }),
|
||||
onError: (caught) => setError(caught instanceof Error ? caught.message : "Sign-in failed"),
|
||||
});
|
||||
|
||||
useEffect(() => client.subscribeToAuth(setUser), [client]);
|
||||
useEffect(() => {
|
||||
void client.refreshAuth().catch(() => undefined);
|
||||
}, [client]);
|
||||
useEffect(() => {
|
||||
const timeout = window.setTimeout(() => setDebouncedSearch(search), 250);
|
||||
return () => window.clearTimeout(timeout);
|
||||
}, [search]);
|
||||
|
||||
const listQuery = useQuery({
|
||||
queryKey: ["workshop", "list", page, type, sort, debouncedSearch, mine ? user?.id : null],
|
||||
queryFn: ({ signal }) =>
|
||||
client.list({
|
||||
page,
|
||||
type,
|
||||
sort,
|
||||
search: debouncedSearch,
|
||||
author: mine ? user?.id : undefined,
|
||||
signal,
|
||||
}),
|
||||
placeholderData: keepPreviousData,
|
||||
});
|
||||
const items = listQuery.data?.items ?? [];
|
||||
const totalPages = listQuery.data?.totalPages ?? 1;
|
||||
|
||||
return (
|
||||
<main className={styles.page}>
|
||||
<header className={styles.hero}>
|
||||
<div>
|
||||
<span className={styles.eyebrow}>Made by the Homarr community</span>
|
||||
<h1>Workshop</h1>
|
||||
<p>Discover useful creations, inspect every line, then bring the ones you trust into Homarr.</p>
|
||||
<ul className={styles.heroPromises} aria-label="Workshop principles">
|
||||
<li>
|
||||
<IconSparkles size={16} /> Community-made
|
||||
</li>
|
||||
<li>
|
||||
<IconPackage size={16} /> Ready to export
|
||||
</li>
|
||||
<li>
|
||||
<IconHeart size={16} /> Ranked by members
|
||||
</li>
|
||||
</ul>
|
||||
</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"
|
||||
disabled={signInMutation.isPending}
|
||||
onClick={() => signInMutation.mutate()}
|
||||
>
|
||||
<IconBrandGithub size={18} /> {signInMutation.isPending ? "Connecting…" : "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 || listQuery.isError || listQuery.fetchStatus === "paused") && (
|
||||
<WorkshopServiceState
|
||||
cached={items.length > 0}
|
||||
message={error || undefined}
|
||||
onRetry={() => void listQuery.refetch()}
|
||||
/>
|
||||
)}
|
||||
{listQuery.isPending ? (
|
||||
<WorkshopSkeleton />
|
||||
) : items.length ? (
|
||||
<div className={styles.grid}>
|
||||
{items.map((item) => (
|
||||
<WorkshopCard client={client} item={item} key={item.id} />
|
||||
))}
|
||||
</div>
|
||||
) : listQuery.isError || listQuery.fetchStatus === "paused" ? null : (
|
||||
<section className={styles.empty}>
|
||||
<IconSearch size={32} aria-hidden />
|
||||
<h2>No creations found</h2>
|
||||
<p>Try a broader search or clear the filters to explore everything the community has shared.</p>
|
||||
<button
|
||||
className="button button--secondary button--sm"
|
||||
onClick={() => {
|
||||
setSearch("");
|
||||
setType("all");
|
||||
setSort("top");
|
||||
setPage(1);
|
||||
}}
|
||||
>
|
||||
Clear filters
|
||||
</button>
|
||||
</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 queryClient.invalidateQueries({ queryKey: ["workshop"] });
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</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 className={styles.cardLink} 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() {
|
||||
return (
|
||||
<WorkshopQueryProvider>
|
||||
<WorkshopDetailRouteContent />
|
||||
</WorkshopQueryProvider>
|
||||
);
|
||||
}
|
||||
|
||||
function WorkshopDetailRouteContent() {
|
||||
const client = useWorkshop();
|
||||
const queryClient = useQueryClient();
|
||||
const location = useLocation();
|
||||
const id = location.pathname.split("/").filter(Boolean).at(-1) ?? "";
|
||||
const history = useHistory();
|
||||
const [user, setUser] = useState<WorkshopUser | null>(null);
|
||||
const [reporting, setReporting] = useState(false);
|
||||
const [editing, setEditing] = useState(false);
|
||||
const [moderating, setModerating] = useState(false);
|
||||
const [actionError, setActionError] = useState("");
|
||||
|
||||
const detailQuery = useQuery({
|
||||
queryKey: ["workshop", "detail", id],
|
||||
queryFn: ({ signal }) => client.get(id, signal),
|
||||
enabled: id.length > 0,
|
||||
});
|
||||
const voteMutation = useMutation({
|
||||
mutationKey: ["workshop", "vote", id],
|
||||
mutationFn: async () => {
|
||||
await (client.currentUser ?? client.signInWithGitHub());
|
||||
return client.vote(id, 1);
|
||||
},
|
||||
onSuccess: async () => queryClient.invalidateQueries({ queryKey: ["workshop", "detail", id] }),
|
||||
onError: (caught) => setActionError(caught instanceof Error ? caught.message : "The vote could not be saved."),
|
||||
});
|
||||
const deleteMutation = useMutation({
|
||||
mutationKey: ["workshop", "delete", id],
|
||||
mutationFn: () => client.delete(id),
|
||||
onSuccess: () => history.push("/workshop"),
|
||||
onError: (caught) =>
|
||||
setActionError(caught instanceof Error ? caught.message : "The submission could not be deleted."),
|
||||
});
|
||||
useEffect(() => client.subscribeToAuth(setUser), [client]);
|
||||
useEffect(() => {
|
||||
void client.refreshAuth();
|
||||
}, [client]);
|
||||
|
||||
if (detailQuery.isError)
|
||||
return (
|
||||
<main className={styles.page}>
|
||||
<div className={styles.error} role="alert">
|
||||
This creation is not saved on this device and Workshop could not be reached.{" "}
|
||||
<button onClick={() => void detailQuery.refetch()}>Try again</button>
|
||||
</div>
|
||||
<Link to="/workshop">Back to Workshop</Link>
|
||||
</main>
|
||||
);
|
||||
if (!detailQuery.data) return <WorkshopSkeleton />;
|
||||
|
||||
const submission = detailQuery.data;
|
||||
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"
|
||||
disabled={voteMutation.isPending}
|
||||
onClick={() => {
|
||||
setActionError("");
|
||||
voteMutation.mutate();
|
||||
}}
|
||||
>
|
||||
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"
|
||||
disabled={deleteMutation.isPending}
|
||||
onClick={() => {
|
||||
if (window.confirm("Delete this submission permanently?")) {
|
||||
setActionError("");
|
||||
deleteMutation.mutate();
|
||||
}
|
||||
}}
|
||||
>
|
||||
Delete
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
{(user?.role === "moderator" || user?.role === "admin") && user.id !== submission.author && (
|
||||
<button className="button button--danger" onClick={() => setModerating(true)}>
|
||||
Remove submission
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
{actionError && (
|
||||
<div className={styles.error} role="alert">
|
||||
{actionError}
|
||||
</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 queryClient.invalidateQueries({ queryKey: ["workshop", "detail", id] });
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
{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 removeMutation = useMutation({
|
||||
mutationKey: ["workshop", "moderation", "remove", submission.id],
|
||||
mutationFn: () => client.removeSubmission(submission.id, reason),
|
||||
onSuccess: onRemoved,
|
||||
});
|
||||
return (
|
||||
<WorkshopDialog titleId="remove-submission-title" onClose={onClose}>
|
||||
<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={(event) => {
|
||||
event.preventDefault();
|
||||
removeMutation.mutate();
|
||||
}}
|
||||
>
|
||||
<label>
|
||||
Reason
|
||||
<textarea
|
||||
required
|
||||
minLength={3}
|
||||
maxLength={1000}
|
||||
value={reason}
|
||||
onChange={(event) => setReason(event.target.value)}
|
||||
/>
|
||||
</label>
|
||||
{removeMutation.error && (
|
||||
<p className={styles.error} role="alert">
|
||||
{removeMutation.error instanceof Error ? removeMutation.error.message : "Removal failed"}
|
||||
</p>
|
||||
)}
|
||||
<div className={styles.formActions}>
|
||||
<button type="button" className="button button--secondary" onClick={onClose}>
|
||||
Cancel
|
||||
</button>
|
||||
<button disabled={removeMutation.isPending} className="button button--danger">
|
||||
{removeMutation.isPending ? "Removing…" : "Remove permanently"}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</WorkshopDialog>
|
||||
);
|
||||
}
|
||||
|
||||
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 retainedScreenshotCount = (existing?.screenshots.length ?? 0) - removedScreenshots.length;
|
||||
const remainingScreenshotSlots = Math.max(0, MAX_SCREENSHOTS - retainedScreenshotCount);
|
||||
useEffect(() => {
|
||||
setScreenshots((current) => current.slice(0, remainingScreenshotSlots));
|
||||
}, [remainingScreenshotSlots]);
|
||||
const saveMutation = useMutation({
|
||||
mutationKey: ["workshop", existing ? "update" : "create", existing?.id],
|
||||
mutationFn: async (input: WorkshopSubmissionInput) => {
|
||||
if (!client.currentUser) await client.signInWithGitHub();
|
||||
if (existing) {
|
||||
return client.update(existing.id, input, screenshots, removedScreenshots);
|
||||
}
|
||||
return client.create(input, screenshots);
|
||||
},
|
||||
onSuccess: onSaved,
|
||||
});
|
||||
return (
|
||||
<WorkshopDialog titleId="submission-form-title" onClose={onClose}>
|
||||
<h2 id="submission-form-title">{existing ? "Update creation" : "Share with the community"}</h2>
|
||||
<form
|
||||
onSubmit={(event) => {
|
||||
event.preventDefault();
|
||||
saveMutation.mutate({ type, title, description, content, changelog });
|
||||
}}
|
||||
>
|
||||
<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
|
||||
maxLength={MAX_CONTENT_LENGTH}
|
||||
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, {remainingScreenshotSlots} available)
|
||||
<input
|
||||
type="file"
|
||||
accept="image/png,image/jpeg,image/webp"
|
||||
multiple
|
||||
onChange={(event) =>
|
||||
setScreenshots(Array.from(event.target.files ?? []).slice(0, remainingScreenshotSlots))
|
||||
}
|
||||
/>
|
||||
</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>
|
||||
))}
|
||||
{saveMutation.error && (
|
||||
<p className={styles.error} role="alert">
|
||||
{saveMutation.error instanceof Error ? saveMutation.error.message : "Could not save submission"}
|
||||
</p>
|
||||
)}
|
||||
<div className={styles.formActions}>
|
||||
<button type="button" className="button button--secondary" onClick={onClose}>
|
||||
Cancel
|
||||
</button>
|
||||
<button disabled={saveMutation.isPending} className="button button--primary">
|
||||
{saveMutation.isPending ? "Saving…" : existing ? "Save update" : "Publish creation"}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</WorkshopDialog>
|
||||
);
|
||||
}
|
||||
|
||||
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 reportMutation = useMutation({
|
||||
mutationKey: ["workshop", "report", submissionId],
|
||||
mutationFn: async () => {
|
||||
if (!client.currentUser) await client.signInWithGitHub();
|
||||
return client.report(submissionId, category, explanation);
|
||||
},
|
||||
onSuccess: onClose,
|
||||
});
|
||||
return (
|
||||
<WorkshopDialog titleId="report-title" onClose={onClose}>
|
||||
<h2 id="report-title">Report submission</h2>
|
||||
<form
|
||||
onSubmit={(event) => {
|
||||
event.preventDefault();
|
||||
reportMutation.mutate();
|
||||
}}
|
||||
>
|
||||
<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>
|
||||
{reportMutation.error && (
|
||||
<p className={styles.error} role="alert">
|
||||
{reportMutation.error instanceof Error ? reportMutation.error.message : "Could not send report"}
|
||||
</p>
|
||||
)}
|
||||
<div className={styles.formActions}>
|
||||
<button type="button" className="button button--secondary" onClick={onClose}>
|
||||
Cancel
|
||||
</button>
|
||||
<button disabled={reportMutation.isPending} className="button button--primary">
|
||||
{reportMutation.isPending ? "Sending…" : "Submit report"}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</WorkshopDialog>
|
||||
);
|
||||
}
|
||||
|
||||
function WorkshopSkeleton() {
|
||||
return (
|
||||
<div className={styles.grid} aria-label="Loading Workshop">
|
||||
{Array.from({ length: 6 }, (_, index) => (
|
||||
<div className={styles.skeleton} key={index} />
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,43 +0,0 @@
|
||||
import React, { type PropsWithChildren, useState } from "react";
|
||||
import { createAsyncStoragePersister } from "@tanstack/query-async-storage-persister";
|
||||
import { QueryClient } from "@tanstack/react-query";
|
||||
import { PersistQueryClientProvider } from "@tanstack/react-query-persist-client";
|
||||
|
||||
const maxAge = 1000 * 60 * 60 * 24;
|
||||
|
||||
export function WorkshopQueryProvider({ children }: PropsWithChildren) {
|
||||
const [client] = useState(
|
||||
() =>
|
||||
new QueryClient({
|
||||
defaultOptions: {
|
||||
queries: {
|
||||
gcTime: maxAge,
|
||||
networkMode: "offlineFirst",
|
||||
retry: 1,
|
||||
staleTime: 1000 * 60 * 5,
|
||||
},
|
||||
mutations: { networkMode: "online", retry: false },
|
||||
},
|
||||
}),
|
||||
);
|
||||
const [persister] = useState(() =>
|
||||
createAsyncStoragePersister({
|
||||
key: "homarr-workshop-website-cache-v1",
|
||||
storage: window.localStorage,
|
||||
}),
|
||||
);
|
||||
|
||||
return (
|
||||
<PersistQueryClientProvider
|
||||
client={client}
|
||||
persistOptions={{
|
||||
persister,
|
||||
maxAge,
|
||||
buster: "workshop-v1",
|
||||
dehydrateOptions: { shouldDehydrateQuery: (query) => query.state.status === "success" },
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
</PersistQueryClientProvider>
|
||||
);
|
||||
}
|
||||
@@ -1,69 +0,0 @@
|
||||
import React, { type PropsWithChildren, useEffect, useRef } from "react";
|
||||
import Link from "@docusaurus/Link";
|
||||
import { IconBooks, IconCloudOff, IconRefresh } from "@tabler/icons-react";
|
||||
|
||||
import styles from "./workshop.module.css";
|
||||
|
||||
export function WorkshopServiceState({
|
||||
cached,
|
||||
message,
|
||||
onRetry,
|
||||
}: {
|
||||
cached: boolean;
|
||||
message?: string;
|
||||
onRetry: () => void;
|
||||
}) {
|
||||
return (
|
||||
<section className={styles.serviceState} aria-live="polite">
|
||||
<span className={styles.serviceStateIcon} aria-hidden>
|
||||
<IconCloudOff size={24} />
|
||||
</span>
|
||||
<div>
|
||||
<h2>{cached ? "Browsing saved creations" : "Workshop is taking a break"}</h2>
|
||||
<p>
|
||||
{message ??
|
||||
(cached
|
||||
? "The catalog saved on this device is still available. Live details and community actions will return when the service reconnects."
|
||||
: "Community creations cannot be reached right now. Homarr and its documentation remain fully available.")}
|
||||
</p>
|
||||
<div className={styles.serviceStateActions}>
|
||||
<button className="button button--primary button--sm" onClick={onRetry}>
|
||||
<IconRefresh size={16} /> Check again
|
||||
</button>
|
||||
<Link className="button button--secondary button--sm" to="/docs/management/workshop">
|
||||
<IconBooks size={16} /> Read the guide
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
export function WorkshopDialog({
|
||||
titleId,
|
||||
onClose,
|
||||
children,
|
||||
}: PropsWithChildren<{ titleId: string; onClose: () => void }>) {
|
||||
const ref = useRef<HTMLDialogElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
const dialog = ref.current;
|
||||
if (!dialog) return;
|
||||
dialog.showModal();
|
||||
return () => dialog.close();
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<dialog
|
||||
ref={ref}
|
||||
className={styles.dialog}
|
||||
aria-labelledby={titleId}
|
||||
onCancel={(event) => {
|
||||
event.preventDefault();
|
||||
onClose();
|
||||
}}
|
||||
>
|
||||
<div className={styles.dialogSurface}>{children}</div>
|
||||
</dialog>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
import dayjs from "dayjs";
|
||||
import relativeTime from "dayjs/plugin/relativeTime";
|
||||
|
||||
dayjs.extend(relativeTime);
|
||||
|
||||
export const formatRelativeTime = (date: string) => dayjs(date).fromNow();
|
||||
@@ -0,0 +1,349 @@
|
||||
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,
|
||||
};
|
||||
};
|
||||
@@ -0,0 +1,16 @@
|
||||
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];
|
||||
};
|
||||
@@ -1,592 +0,0 @@
|
||||
.page {
|
||||
--workshop-ease: cubic-bezier(0.25, 1, 0.5, 1);
|
||||
max-width: 1180px;
|
||||
min-height: 70vh;
|
||||
margin: 0 auto;
|
||||
padding: clamp(2rem, 5vw, 4.5rem) 1.25rem 5rem;
|
||||
}
|
||||
|
||||
.hero {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1fr) auto;
|
||||
align-items: center;
|
||||
gap: 2rem;
|
||||
margin-bottom: 2rem;
|
||||
}
|
||||
|
||||
.eyebrow {
|
||||
display: block;
|
||||
margin-bottom: 0.45rem;
|
||||
color: var(--ifm-color-primary);
|
||||
font-size: 0.76rem;
|
||||
font-weight: 750;
|
||||
letter-spacing: 0.09em;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.hero h1,
|
||||
.detail h1 {
|
||||
margin: 0;
|
||||
font-size: clamp(2.35rem, 6vw, 4.25rem);
|
||||
line-height: 0.98;
|
||||
letter-spacing: -0.045em;
|
||||
}
|
||||
|
||||
.hero p {
|
||||
max-width: 62ch;
|
||||
margin: 0.85rem 0 0;
|
||||
color: var(--ifm-color-emphasis-700);
|
||||
font-size: clamp(1rem, 2vw, 1.12rem);
|
||||
line-height: 1.65;
|
||||
}
|
||||
|
||||
.heroPromises {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.55rem 1rem;
|
||||
padding: 0;
|
||||
margin: 1.1rem 0 0;
|
||||
color: var(--ifm-color-emphasis-700);
|
||||
font-size: 0.82rem;
|
||||
list-style: none;
|
||||
}
|
||||
|
||||
.heroPromises li,
|
||||
.accountActions,
|
||||
.detailActions,
|
||||
.formActions,
|
||||
.serviceStateActions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.heroPromises li {
|
||||
gap: 0.35rem;
|
||||
}
|
||||
|
||||
.accountActions,
|
||||
.detailActions,
|
||||
.formActions,
|
||||
.serviceStateActions {
|
||||
flex-wrap: wrap;
|
||||
gap: 0.7rem;
|
||||
}
|
||||
|
||||
.accountActions :global(.button),
|
||||
.detailActions :global(.button),
|
||||
.serviceStateActions :global(.button) {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 0.4rem;
|
||||
}
|
||||
|
||||
.safety {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 0.75rem;
|
||||
padding: 0.9rem 1rem;
|
||||
margin-bottom: 1rem;
|
||||
border: 1px solid color-mix(in srgb, var(--ifm-color-warning) 55%, var(--ifm-color-emphasis-300));
|
||||
border-radius: 12px;
|
||||
background: color-mix(in srgb, var(--ifm-color-warning) 8%, var(--ifm-background-surface-color));
|
||||
}
|
||||
|
||||
.safety svg {
|
||||
flex: none;
|
||||
margin-top: 0.1rem;
|
||||
}
|
||||
|
||||
.safety p {
|
||||
margin: 0;
|
||||
color: var(--ifm-color-emphasis-800);
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
.toolbar {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(260px, 1fr) 150px 150px auto;
|
||||
align-items: end;
|
||||
gap: 0.75rem;
|
||||
padding: 0.85rem;
|
||||
margin-bottom: 1.25rem;
|
||||
border: 1px solid var(--ifm-color-emphasis-300);
|
||||
border-radius: 14px;
|
||||
background: var(--ifm-background-surface-color);
|
||||
box-shadow: 0 8px 28px rgb(0 0 0 / 0.04);
|
||||
}
|
||||
|
||||
.toolbar label,
|
||||
.dialog label {
|
||||
display: grid;
|
||||
gap: 0.35rem;
|
||||
font-size: 0.78rem;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.toolbar input,
|
||||
.toolbar select,
|
||||
.dialog input,
|
||||
.dialog select,
|
||||
.dialog textarea {
|
||||
width: 100%;
|
||||
min-height: 44px;
|
||||
padding: 0.6rem 0.72rem;
|
||||
border: 1px solid var(--ifm-color-emphasis-400);
|
||||
border-radius: 9px;
|
||||
color: var(--ifm-font-color-base);
|
||||
background: var(--ifm-background-color);
|
||||
font: inherit;
|
||||
transition:
|
||||
border-color 150ms ease,
|
||||
box-shadow 150ms ease;
|
||||
}
|
||||
|
||||
.toolbar input:focus-visible,
|
||||
.toolbar select:focus-visible,
|
||||
.dialog input:focus-visible,
|
||||
.dialog select:focus-visible,
|
||||
.dialog textarea:focus-visible,
|
||||
.pagination button:focus-visible,
|
||||
.rowActions button:focus-visible {
|
||||
outline: 2px solid var(--ifm-color-primary);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
|
||||
.search {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.search svg {
|
||||
position: absolute;
|
||||
z-index: 1;
|
||||
left: 0.8rem;
|
||||
bottom: 0.82rem;
|
||||
color: var(--ifm-color-emphasis-600);
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.search input {
|
||||
padding-left: 2.35rem;
|
||||
}
|
||||
|
||||
.checkbox {
|
||||
display: flex !important;
|
||||
grid-auto-flow: column;
|
||||
align-items: center;
|
||||
min-height: 44px;
|
||||
}
|
||||
|
||||
.checkbox input {
|
||||
width: auto;
|
||||
min-height: auto;
|
||||
}
|
||||
|
||||
.serviceState {
|
||||
display: grid;
|
||||
grid-template-columns: auto minmax(0, 1fr);
|
||||
gap: 1rem;
|
||||
padding: clamp(1.1rem, 3vw, 1.5rem);
|
||||
margin: 1.25rem 0;
|
||||
border: 1px solid color-mix(in srgb, var(--ifm-color-warning) 45%, var(--ifm-color-emphasis-300));
|
||||
border-radius: 14px;
|
||||
background: color-mix(in srgb, var(--ifm-color-warning) 7%, var(--ifm-background-surface-color));
|
||||
}
|
||||
|
||||
.serviceStateIcon {
|
||||
display: grid;
|
||||
width: 44px;
|
||||
height: 44px;
|
||||
place-items: center;
|
||||
border-radius: 10px;
|
||||
color: var(--ifm-color-warning-darkest);
|
||||
background: color-mix(in srgb, var(--ifm-color-warning) 18%, transparent);
|
||||
}
|
||||
|
||||
.serviceState h2 {
|
||||
margin: 0 0 0.25rem;
|
||||
font-size: 1.05rem;
|
||||
}
|
||||
|
||||
.serviceState p {
|
||||
max-width: 70ch;
|
||||
margin: 0 0 0.85rem;
|
||||
color: var(--ifm-color-emphasis-700);
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
.grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(min(100%, 270px), 1fr));
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.card {
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
border: 1px solid var(--ifm-color-emphasis-300);
|
||||
border-radius: 14px;
|
||||
background: var(--ifm-background-surface-color);
|
||||
box-shadow: 0 4px 14px rgb(0 0 0 / 0.035);
|
||||
transition:
|
||||
transform 200ms var(--workshop-ease),
|
||||
border-color 200ms ease,
|
||||
box-shadow 200ms var(--workshop-ease);
|
||||
}
|
||||
|
||||
.card:hover,
|
||||
.card:focus-within {
|
||||
border-color: color-mix(in srgb, var(--ifm-color-primary) 55%, var(--ifm-color-emphasis-300));
|
||||
box-shadow: 0 14px 34px rgb(0 0 0 / 0.1);
|
||||
transform: translateY(-3px);
|
||||
}
|
||||
|
||||
.card img,
|
||||
.cardPlaceholder {
|
||||
width: 100%;
|
||||
aspect-ratio: 3 / 2;
|
||||
object-fit: cover;
|
||||
}
|
||||
|
||||
.cardPlaceholder {
|
||||
display: grid;
|
||||
place-items: center;
|
||||
color: var(--ifm-color-emphasis-600);
|
||||
background: color-mix(in srgb, var(--ifm-color-primary) 6%, var(--ifm-color-emphasis-100));
|
||||
font: 750 1.45rem var(--ifm-font-family-monospace);
|
||||
}
|
||||
|
||||
.cardBody {
|
||||
padding: 1rem;
|
||||
}
|
||||
|
||||
.cardBody h2 {
|
||||
margin: 0.45rem 0;
|
||||
font-size: 1.08rem;
|
||||
}
|
||||
|
||||
.cardLink::after {
|
||||
position: absolute;
|
||||
content: "";
|
||||
inset: 0;
|
||||
}
|
||||
|
||||
.cardLink:focus-visible {
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.card:has(.cardLink:focus-visible) {
|
||||
outline: 3px solid var(--ifm-color-primary);
|
||||
outline-offset: 3px;
|
||||
}
|
||||
|
||||
.cardBody p {
|
||||
min-height: 2.7em;
|
||||
margin: 0 0 0.7rem;
|
||||
color: var(--ifm-color-emphasis-700);
|
||||
font-size: 0.88rem;
|
||||
}
|
||||
|
||||
.cardBody small {
|
||||
color: var(--ifm-color-emphasis-600);
|
||||
}
|
||||
|
||||
.cardMeta {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
color: var(--ifm-color-emphasis-700);
|
||||
font-size: 0.72rem;
|
||||
font-weight: 750;
|
||||
letter-spacing: 0.035em;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.error {
|
||||
padding: 0.85rem 1rem;
|
||||
margin: 1rem 0;
|
||||
border: 1px solid color-mix(in srgb, var(--ifm-color-danger) 45%, transparent);
|
||||
border-radius: 10px;
|
||||
color: var(--ifm-color-danger-contrast-foreground);
|
||||
background: var(--ifm-color-danger-contrast-background);
|
||||
}
|
||||
|
||||
.empty {
|
||||
display: grid;
|
||||
max-width: 620px;
|
||||
min-height: 310px;
|
||||
place-items: center;
|
||||
align-content: center;
|
||||
gap: 0.45rem;
|
||||
padding: 3rem 1.25rem;
|
||||
margin: 0 auto;
|
||||
text-align: center;
|
||||
border: 1px dashed var(--ifm-color-emphasis-400);
|
||||
border-radius: 16px;
|
||||
color: var(--ifm-color-emphasis-700);
|
||||
}
|
||||
|
||||
.empty h2,
|
||||
.empty p {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.empty svg {
|
||||
color: var(--ifm-color-primary);
|
||||
}
|
||||
|
||||
.empty :global(.button) {
|
||||
margin-top: 0.75rem;
|
||||
}
|
||||
|
||||
.pagination {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
gap: 1rem;
|
||||
margin-top: 2rem;
|
||||
}
|
||||
|
||||
.pagination button {
|
||||
min-height: 44px;
|
||||
padding: 0.5rem 0.85rem;
|
||||
}
|
||||
|
||||
.skeleton {
|
||||
min-height: 310px;
|
||||
border-radius: 14px;
|
||||
background: var(--ifm-color-emphasis-200);
|
||||
animation: pulse 1.5s ease-in-out infinite;
|
||||
}
|
||||
|
||||
.detail {
|
||||
margin-top: 1.75rem;
|
||||
}
|
||||
|
||||
.detail header {
|
||||
max-width: 75ch;
|
||||
}
|
||||
|
||||
.detail h1 {
|
||||
margin-top: 0.5rem;
|
||||
font-size: clamp(2.1rem, 5vw, 3.6rem);
|
||||
}
|
||||
|
||||
.type {
|
||||
display: inline-block;
|
||||
color: var(--ifm-color-primary);
|
||||
font-size: 0.78rem;
|
||||
font-weight: 750;
|
||||
letter-spacing: 0.07em;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.gallery {
|
||||
display: flex;
|
||||
gap: 1rem;
|
||||
overflow-x: auto;
|
||||
padding-bottom: 0.6rem;
|
||||
margin: 1.5rem 0;
|
||||
scroll-snap-type: x mandatory;
|
||||
}
|
||||
|
||||
.gallery img {
|
||||
width: min(720px, 85vw);
|
||||
max-height: 480px;
|
||||
object-fit: contain;
|
||||
border: 1px solid var(--ifm-color-emphasis-300);
|
||||
border-radius: 12px;
|
||||
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: 1px solid var(--ifm-color-emphasis-300);
|
||||
border-radius: 10px;
|
||||
font-size: 0.78rem;
|
||||
}
|
||||
|
||||
.dialog {
|
||||
width: min(700px, calc(100vw - 2rem));
|
||||
max-height: min(90vh, 900px);
|
||||
padding: 0;
|
||||
overflow: visible;
|
||||
border: 0;
|
||||
border-radius: 16px;
|
||||
color: var(--ifm-font-color-base);
|
||||
background: transparent;
|
||||
box-shadow: 0 24px 80px rgb(0 0 0 / 0.35);
|
||||
}
|
||||
|
||||
.dialog::backdrop {
|
||||
background: rgb(0 0 0 / 0.68);
|
||||
backdrop-filter: blur(3px);
|
||||
}
|
||||
|
||||
.dialogSurface {
|
||||
max-height: min(90vh, 900px);
|
||||
overflow-y: auto;
|
||||
padding: clamp(1.15rem, 4vw, 1.6rem);
|
||||
border: 1px solid var(--ifm-color-emphasis-300);
|
||||
border-radius: 16px;
|
||||
background: var(--ifm-background-color);
|
||||
}
|
||||
|
||||
.dialogSurface > h2 {
|
||||
margin-top: 0;
|
||||
}
|
||||
|
||||
.dialog form {
|
||||
display: grid;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.dialog textarea {
|
||||
min-height: 96px;
|
||||
resize: vertical;
|
||||
}
|
||||
|
||||
.dialog .editor {
|
||||
min-height: 240px;
|
||||
font-family: var(--ifm-font-family-monospace);
|
||||
font-size: 0.78rem;
|
||||
}
|
||||
|
||||
.formActions {
|
||||
justify-content: flex-end;
|
||||
}
|
||||
|
||||
.adminSection {
|
||||
margin: 2rem 0;
|
||||
}
|
||||
|
||||
.tableWrap {
|
||||
overflow-x: auto;
|
||||
border: 1px solid var(--ifm-color-emphasis-300);
|
||||
border-radius: 12px;
|
||||
}
|
||||
|
||||
.tableWrap table {
|
||||
width: 100%;
|
||||
margin: 0;
|
||||
border-collapse: collapse;
|
||||
}
|
||||
|
||||
.tableWrap th,
|
||||
.tableWrap td {
|
||||
padding: 0.75rem;
|
||||
border: 0;
|
||||
border-bottom: 1px solid var(--ifm-color-emphasis-300);
|
||||
text-align: left;
|
||||
vertical-align: top;
|
||||
}
|
||||
|
||||
.tableWrap tr:last-child td {
|
||||
border-bottom: 0;
|
||||
}
|
||||
|
||||
.rowActions {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.4rem;
|
||||
}
|
||||
|
||||
.rowActions button {
|
||||
min-height: 36px;
|
||||
}
|
||||
|
||||
@keyframes pulse {
|
||||
50% {
|
||||
opacity: 0.55;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 820px) {
|
||||
.hero {
|
||||
grid-template-columns: 1fr;
|
||||
gap: 1.4rem;
|
||||
}
|
||||
|
||||
.accountActions > * {
|
||||
flex: 1 1 auto;
|
||||
}
|
||||
|
||||
.toolbar {
|
||||
grid-template-columns: 1fr 1fr;
|
||||
}
|
||||
|
||||
.search {
|
||||
grid-column: 1 / -1;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 520px) {
|
||||
.page {
|
||||
padding-inline: 1rem;
|
||||
}
|
||||
|
||||
.hero h1 {
|
||||
font-size: 2.75rem;
|
||||
}
|
||||
|
||||
.heroPromises {
|
||||
display: grid;
|
||||
}
|
||||
|
||||
.accountActions,
|
||||
.detailActions,
|
||||
.formActions,
|
||||
.serviceStateActions {
|
||||
align-items: stretch;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.accountActions > *,
|
||||
.detailActions > *,
|
||||
.formActions > *,
|
||||
.serviceStateActions > * {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.toolbar {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.search {
|
||||
grid-column: auto;
|
||||
}
|
||||
|
||||
.serviceState {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.pagination {
|
||||
justify-content: space-between;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.skeleton {
|
||||
animation: none;
|
||||
}
|
||||
|
||||
.card {
|
||||
transition: none;
|
||||
}
|
||||
|
||||
.card:hover,
|
||||
.card:focus-within {
|
||||
transform: none;
|
||||
}
|
||||
}
|
||||
+179
-14
@@ -4,9 +4,53 @@
|
||||
|
||||
@custom-variant dark (&:where([data-theme="dark"], [data-theme="dark"] *));
|
||||
|
||||
@theme {
|
||||
/* shadcn preset b4gOK4dHc — base-nova style, mist base color */
|
||||
@theme inline {
|
||||
--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 {
|
||||
@@ -23,14 +67,52 @@
|
||||
|
||||
--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);
|
||||
}
|
||||
|
||||
/* For readability concerns, you should choose a lighter palette in dark mode. */
|
||||
[data-theme='dark'] {
|
||||
[data-theme="dark"] {
|
||||
--ifm-color-primary: #fa5252;
|
||||
--ifm-color-primary-dark: #f93232;
|
||||
--ifm-color-primary-darker: #f92222;
|
||||
@@ -39,13 +121,68 @@
|
||||
--ifm-color-primary-lighter: #fb8282;
|
||||
--ifm-color-primary-lightest: #fdc3c3;
|
||||
--docusaurus-highlighted-code-line-bg: rgba(0, 0, 0, 0.3);
|
||||
--homarr-secondary-bg: #181818;
|
||||
--homarr-secondary-bg: oklch(0.218 0.008 223.9);
|
||||
--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;
|
||||
--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-DetachedOverlay {
|
||||
@@ -80,7 +217,7 @@
|
||||
}
|
||||
|
||||
.footer__copyright span.copyright_text {
|
||||
opacity: .6;
|
||||
opacity: 0.6;
|
||||
}
|
||||
|
||||
.medium-zoom-overlay {
|
||||
@@ -97,7 +234,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;
|
||||
}
|
||||
|
||||
@@ -135,7 +272,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;
|
||||
}
|
||||
|
||||
@@ -164,7 +301,6 @@ 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);
|
||||
@@ -180,7 +316,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 {
|
||||
@@ -188,7 +324,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;
|
||||
}
|
||||
|
||||
@@ -203,3 +339,32 @@ 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;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
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 } };
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
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;
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
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);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,78 @@
|
||||
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);
|
||||
@@ -1,16 +0,0 @@
|
||||
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>
|
||||
);
|
||||
}
|
||||
@@ -1,16 +1,22 @@
|
||||
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 widgets and CSS themes for Homarr">
|
||||
<BrowserOnly fallback={<main style={{ minHeight: "70vh" }} />}>
|
||||
{() => {
|
||||
const { WorkshopApp } = require("@site/src/components/workshop/WorkshopPage");
|
||||
return <WorkshopApp />;
|
||||
}}
|
||||
</BrowserOnly>
|
||||
<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>
|
||||
);
|
||||
}
|
||||
@@ -1,11 +0,0 @@
|
||||
import React, { type PropsWithChildren, useEffect } from "react";
|
||||
|
||||
export default function Root({ children }: PropsWithChildren) {
|
||||
useEffect(() => {
|
||||
if (process.env.NODE_ENV === "production" && "serviceWorker" in navigator) {
|
||||
void navigator.serviceWorker.register("/workshop-sw.js", { scope: "/" });
|
||||
}
|
||||
}, []);
|
||||
|
||||
return children;
|
||||
}
|
||||
Vendored
-46
@@ -1,46 +0,0 @@
|
||||
const CACHE_NAME = "homarr-site-v2";
|
||||
const SHELL_PATHS = ["/", "/workshop/", "/docs/management/workshop/", "/img/logo.png"];
|
||||
|
||||
self.addEventListener("install", (event) => {
|
||||
event.waitUntil(
|
||||
caches.open(CACHE_NAME).then(async (cache) => {
|
||||
const manifest = await fetch("/workshop-assets.json")
|
||||
.then((response) => response.json())
|
||||
.catch(() => []);
|
||||
await Promise.allSettled([...SHELL_PATHS, ...manifest].map((path) => cache.add(path)));
|
||||
}),
|
||||
);
|
||||
self.skipWaiting();
|
||||
});
|
||||
|
||||
self.addEventListener("activate", (event) => {
|
||||
event.waitUntil(
|
||||
caches
|
||||
.keys()
|
||||
.then((keys) => Promise.all(keys.filter((key) => key !== CACHE_NAME).map((key) => caches.delete(key))))
|
||||
.then(() => self.clients.claim()),
|
||||
);
|
||||
});
|
||||
|
||||
self.addEventListener("fetch", (event) => {
|
||||
const request = event.request;
|
||||
const url = new URL(request.url);
|
||||
if (request.method !== "GET" || url.origin !== self.location.origin || url.pathname.startsWith("/api/")) return;
|
||||
|
||||
event.respondWith(
|
||||
fetch(request)
|
||||
.then((response) => {
|
||||
if (response.ok) {
|
||||
const copy = response.clone();
|
||||
event.waitUntil(caches.open(CACHE_NAME).then((cache) => cache.put(request, copy)));
|
||||
}
|
||||
return response;
|
||||
})
|
||||
.catch(async () => {
|
||||
const cached = await caches.match(request);
|
||||
if (cached) return cached;
|
||||
if (request.mode === "navigate") return (await caches.match("/workshop/")) ?? Response.error();
|
||||
return Response.error();
|
||||
}),
|
||||
);
|
||||
});
|
||||
@@ -15,6 +15,7 @@
|
||||
"jsx": "preserve",
|
||||
"incremental": true,
|
||||
"paths": {
|
||||
"@/*": ["./src/*"],
|
||||
"@site/*": ["./*"]
|
||||
},
|
||||
},
|
||||
|
||||
@@ -50,7 +50,6 @@
|
||||
"@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,8 +11,7 @@ import { IconInfoCircle } from "@tabler/icons-react";
|
||||
import { useForm } from "@homarr/form";
|
||||
import { useI18n, useScopedI18n } from "@homarr/translation/client";
|
||||
|
||||
import { UseCssFromWorkshopButton } from "~/components/workshop/workshop-actions";
|
||||
|
||||
import { InstallCssFromWorkshopButton } from "~/components/workshop-install/install-from-workshop-buttons";
|
||||
import type { Board } from "../../_types";
|
||||
import { useSavePartialSettingsMutation } from "./_shared";
|
||||
import classes from "./customcss.module.css";
|
||||
@@ -48,7 +47,7 @@ export const CustomCssSettingsContent = ({ board }: Props) => {
|
||||
</Alert>
|
||||
|
||||
<Group justify="space-between">
|
||||
<UseCssFromWorkshopButton onUse={(css) => form.setFieldValue("customCss", css)} />
|
||||
<InstallCssFromWorkshopButton onSelect={(css) => form.setFieldValue("customCss", css)} />
|
||||
<Button type="submit" loading={isPending}>
|
||||
{t("common.action.saveChanges")}
|
||||
</Button>
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
"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";
|
||||
@@ -296,8 +297,11 @@ 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);
|
||||
@@ -317,8 +321,8 @@ export const CopyAiPromptButton = ({ rawResponse, currentConfig }: CopyAiPromptB
|
||||
variant="light"
|
||||
leftSection={<IconSparkles size={16} />}
|
||||
onClick={open}
|
||||
loading={isLoading}
|
||||
disabled={!schema}
|
||||
loading={!mounted || isLoading}
|
||||
disabled={!mounted || isLoading || !schema}
|
||||
fullWidth
|
||||
size="sm"
|
||||
>
|
||||
|
||||
@@ -19,6 +19,7 @@ import { showErrorNotification, showSuccessNotification } from "@homarr/notifica
|
||||
import { useScopedI18n } from "@homarr/translation/client";
|
||||
|
||||
import { MobileAffixButton } from "~/components/manage/mobile-affix-button";
|
||||
|
||||
const iconProps = { size: 16, stroke: 1.5 };
|
||||
|
||||
interface WidgetRef {
|
||||
|
||||
@@ -30,9 +30,11 @@ 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";
|
||||
|
||||
@@ -467,6 +469,90 @@ 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 {
|
||||
@@ -681,6 +767,32 @@ 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,11 +1,16 @@
|
||||
"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";
|
||||
@@ -31,6 +36,36 @@ 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 (
|
||||
@@ -44,36 +79,40 @@ export const CustomWidgetList = ({ definitions }: CustomWidgetListProps) => {
|
||||
|
||||
return (
|
||||
<Stack gap="md">
|
||||
<Table striped highlightOnHover withTableBorder>
|
||||
<Table striped highlightOnHover withTableBorder layout="fixed">
|
||||
<Table.Thead>
|
||||
<Table.Tr>
|
||||
<Table.Th>{t("table.name")}</Table.Th>
|
||||
<Table.Th>{t("table.url")}</Table.Th>
|
||||
<Table.Th>{t("table.display")}</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 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>
|
||||
<Table.Td style={{ overflow: "hidden" }}>
|
||||
<Group gap="xs" wrap="nowrap">
|
||||
{def.iconUrl ? (
|
||||
<Avatar size={20} radius="sm" src={def.iconUrl} styles={{ image: { objectFit: "contain" } }} />
|
||||
) : (
|
||||
<IconApi size={16} />
|
||||
<IconApi size={16} style={{ flexShrink: 0 }} />
|
||||
)}
|
||||
<Text size="sm" fw={500} lineClamp={1} c={def.enabled ? undefined : "dimmed"} style={{ minWidth: 0 }}>
|
||||
<Text size="sm" fw={500} truncate c={def.enabled ? undefined : "dimmed"}>
|
||||
{def.name}
|
||||
</Text>
|
||||
</Group>
|
||||
</Table.Td>
|
||||
<Table.Td>
|
||||
<Text size="sm" c="dimmed" lineClamp={1}>
|
||||
<Table.Td style={{ overflow: "hidden" }} visibleFrom="sm">
|
||||
<Text size="sm" c="dimmed" truncate>
|
||||
{def.url}
|
||||
</Text>
|
||||
</Table.Td>
|
||||
<Table.Td>
|
||||
<Table.Td visibleFrom="xs">
|
||||
<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/workshop-actions";
|
||||
import { InstallWidgetFromWorkshopButton } from "~/components/workshop-install/install-from-workshop-buttons";
|
||||
import { CustomWidgetBetaBanner } from "./_beta-banner";
|
||||
import { ImportCustomWidgetButton } from "./_custom-widget-actions";
|
||||
import { CustomWidgetList } from "./_custom-widget-list";
|
||||
|
||||
@@ -6,7 +6,6 @@ import {
|
||||
AccordionPanel,
|
||||
ActionIcon,
|
||||
ActionIconGroup,
|
||||
Alert,
|
||||
Anchor,
|
||||
Divider,
|
||||
Group,
|
||||
@@ -19,14 +18,14 @@ import {
|
||||
TableTr,
|
||||
Text,
|
||||
} from "@mantine/core";
|
||||
import { IconAlertTriangle, IconPencil, IconPlugX } from "@tabler/icons-react";
|
||||
import { IconPencil, IconPlugX } from "@tabler/icons-react";
|
||||
|
||||
import type { RouterOutputs } from "@homarr/api";
|
||||
import { api } from "@homarr/api/server";
|
||||
import { auth } from "@homarr/auth/next";
|
||||
import { objectEntries } from "@homarr/common";
|
||||
import type { IntegrationKind } from "@homarr/definitions";
|
||||
import { getIntegrationName, isIntegrationKind } from "@homarr/definitions";
|
||||
import { getIntegrationName } from "@homarr/definitions";
|
||||
import { getScopedI18n } from "@homarr/translation/server";
|
||||
import { CountBadge, IntegrationAvatar, Link } from "@homarr/ui";
|
||||
|
||||
@@ -88,14 +87,11 @@ const IntegrationList = async ({ integrations, activeTab }: IntegrationListProps
|
||||
const session = await auth();
|
||||
const hasFullAccess = session?.user.permissions.includes("integration-full-all") ?? false;
|
||||
|
||||
const supportedIntegrations = integrations.filter((integration) => isIntegrationKind(integration.kind));
|
||||
const unsupportedCount = integrations.length - supportedIntegrations.length;
|
||||
|
||||
if (supportedIntegrations.length === 0 && unsupportedCount === 0) {
|
||||
if (integrations.length === 0) {
|
||||
return <NoResults icon={IconPlugX} title={t("page.list.noResults.title")} />;
|
||||
}
|
||||
|
||||
const groupedIntegrations = supportedIntegrations.reduce(
|
||||
const groupedIntegrations = integrations.reduce(
|
||||
(acc, integration) => {
|
||||
// eslint-disable-next-line @typescript-eslint/no-unnecessary-condition
|
||||
if (!acc[integration.kind]) {
|
||||
@@ -112,105 +108,88 @@ const IntegrationList = async ({ integrations, activeTab }: IntegrationListProps
|
||||
const entries = objectEntries(groupedIntegrations);
|
||||
|
||||
return (
|
||||
<Stack>
|
||||
{unsupportedCount > 0 && (
|
||||
<Alert color="yellow" icon={<IconAlertTriangle size={18} />} title={t("page.list.outdated.title")}>
|
||||
{t("page.list.outdated.description", { count: unsupportedCount })}
|
||||
</Alert>
|
||||
)}
|
||||
{supportedIntegrations.length === 0 ? (
|
||||
<NoResults icon={IconPlugX} title={t("page.list.noResults.title")} />
|
||||
) : (
|
||||
<ActiveTabAccordion defaultValue={activeTab} radius="lg" classNames={classes}>
|
||||
{entries.map(([kind, kindIntegrations], index) => (
|
||||
<AccordionItem key={kind} value={kind} data-first={index === 0} data-last={index === entries.length - 1}>
|
||||
<AccordionControl icon={<IntegrationAvatar size="sm" kind={kind} radius="sm" />}>
|
||||
<Group>
|
||||
<Text>{getIntegrationName(kind)}</Text>
|
||||
<CountBadge count={kindIntegrations.length} />
|
||||
</Group>
|
||||
</AccordionControl>
|
||||
<AccordionPanel>
|
||||
<Table visibleFrom="md">
|
||||
<TableThead>
|
||||
<TableTr>
|
||||
<TableTh>{t("field.name.label")}</TableTh>
|
||||
<TableTh>{t("field.url.label")}</TableTh>
|
||||
<TableTh />
|
||||
</TableTr>
|
||||
</TableThead>
|
||||
<TableTbody>
|
||||
{kindIntegrations.map((integration) => (
|
||||
<TableTr key={integration.id}>
|
||||
<TableTd>{integration.name}</TableTd>
|
||||
<TableTd>
|
||||
<Anchor href={integration.url} target="_blank" rel="noreferrer" size="sm">
|
||||
{integration.url}
|
||||
</Anchor>
|
||||
</TableTd>
|
||||
<TableTd>
|
||||
<Group justify="end">
|
||||
{(hasFullAccess || integration.permissions.hasFullAccess) && (
|
||||
<ActionIconGroup>
|
||||
<ActionIcon
|
||||
component={Link}
|
||||
href={`/manage/integrations/edit/${integration.id}`}
|
||||
variant="subtle"
|
||||
color="gray"
|
||||
aria-label={t("page.edit.title", { name: getIntegrationName(integration.kind) })}
|
||||
>
|
||||
<IconPencil size={16} stroke={1.5} />
|
||||
</ActionIcon>
|
||||
<DeleteIntegrationActionButton
|
||||
integration={integration}
|
||||
count={kindIntegrations.length}
|
||||
/>
|
||||
</ActionIconGroup>
|
||||
)}
|
||||
</Group>
|
||||
</TableTd>
|
||||
</TableTr>
|
||||
))}
|
||||
</TableTbody>
|
||||
</Table>
|
||||
<ActiveTabAccordion defaultValue={activeTab} radius="lg" classNames={classes}>
|
||||
{entries.map(([kind, kindIntegrations], index) => (
|
||||
<AccordionItem key={kind} value={kind} data-first={index === 0} data-last={index === entries.length - 1}>
|
||||
<AccordionControl icon={<IntegrationAvatar size="sm" kind={kind} radius="sm" />}>
|
||||
<Group>
|
||||
<Text>{getIntegrationName(kind)}</Text>
|
||||
<CountBadge count={kindIntegrations.length} />
|
||||
</Group>
|
||||
</AccordionControl>
|
||||
<AccordionPanel>
|
||||
<Table visibleFrom="md">
|
||||
<TableThead>
|
||||
<TableTr>
|
||||
<TableTh>{t("field.name.label")}</TableTh>
|
||||
<TableTh>{t("field.url.label")}</TableTh>
|
||||
<TableTh />
|
||||
</TableTr>
|
||||
</TableThead>
|
||||
<TableTbody>
|
||||
{kindIntegrations.map((integration) => (
|
||||
<TableTr key={integration.id}>
|
||||
<TableTd>{integration.name}</TableTd>
|
||||
<TableTd>
|
||||
<Anchor href={integration.url} target="_blank" rel="noreferrer" size="sm">
|
||||
{integration.url}
|
||||
</Anchor>
|
||||
</TableTd>
|
||||
<TableTd>
|
||||
<Group justify="end">
|
||||
{(hasFullAccess || integration.permissions.hasFullAccess) && (
|
||||
<ActionIconGroup>
|
||||
<ActionIcon
|
||||
component={Link}
|
||||
href={`/manage/integrations/edit/${integration.id}`}
|
||||
variant="subtle"
|
||||
color="gray"
|
||||
aria-label={t("page.edit.title", { name: getIntegrationName(integration.kind) })}
|
||||
>
|
||||
<IconPencil size={16} stroke={1.5} />
|
||||
</ActionIcon>
|
||||
<DeleteIntegrationActionButton integration={integration} count={kindIntegrations.length} />
|
||||
</ActionIconGroup>
|
||||
)}
|
||||
</Group>
|
||||
</TableTd>
|
||||
</TableTr>
|
||||
))}
|
||||
</TableTbody>
|
||||
</Table>
|
||||
|
||||
<Stack gap="xs" hiddenFrom="md">
|
||||
{kindIntegrations.map((integration, integrationIndex) => (
|
||||
<Fragment key={integration.id}>
|
||||
{integrationIndex !== 0 && <Divider />}
|
||||
<Stack gap={0}>
|
||||
<Group justify="space-between" align="center" wrap="nowrap">
|
||||
<Text>{integration.name}</Text>
|
||||
{(hasFullAccess || integration.permissions.hasFullAccess) && (
|
||||
<ActionIconGroup>
|
||||
<ActionIcon
|
||||
component={Link}
|
||||
href={`/manage/integrations/edit/${integration.id}`}
|
||||
variant="subtle"
|
||||
color="gray"
|
||||
aria-label={t("page.edit.title", { name: getIntegrationName(integration.kind) })}
|
||||
>
|
||||
<IconPencil size={16} stroke={1.5} />
|
||||
</ActionIcon>
|
||||
<DeleteIntegrationActionButton
|
||||
integration={integration}
|
||||
count={kindIntegrations.length}
|
||||
/>
|
||||
</ActionIconGroup>
|
||||
)}
|
||||
</Group>
|
||||
<Anchor href={integration.url} target="_blank" rel="noreferrer" size="sm">
|
||||
{integration.url}
|
||||
</Anchor>
|
||||
</Stack>
|
||||
</Fragment>
|
||||
))}
|
||||
</Stack>
|
||||
</AccordionPanel>
|
||||
</AccordionItem>
|
||||
))}
|
||||
</ActiveTabAccordion>
|
||||
)}
|
||||
</Stack>
|
||||
<Stack gap="xs" hiddenFrom="md">
|
||||
{kindIntegrations.map((integration, integrationIndex) => (
|
||||
<Fragment key={integration.id}>
|
||||
{integrationIndex !== 0 && <Divider />}
|
||||
<Stack gap={0}>
|
||||
<Group justify="space-between" align="center" wrap="nowrap">
|
||||
<Text>{integration.name}</Text>
|
||||
{(hasFullAccess || integration.permissions.hasFullAccess) && (
|
||||
<ActionIconGroup>
|
||||
<ActionIcon
|
||||
component={Link}
|
||||
href={`/manage/integrations/edit/${integration.id}`}
|
||||
variant="subtle"
|
||||
color="gray"
|
||||
aria-label={t("page.edit.title", { name: getIntegrationName(integration.kind) })}
|
||||
>
|
||||
<IconPencil size={16} stroke={1.5} />
|
||||
</ActionIcon>
|
||||
<DeleteIntegrationActionButton integration={integration} count={kindIntegrations.length} />
|
||||
</ActionIconGroup>
|
||||
)}
|
||||
</Group>
|
||||
<Anchor href={integration.url} target="_blank" rel="noreferrer" size="sm">
|
||||
{integration.url}
|
||||
</Anchor>
|
||||
</Stack>
|
||||
</Fragment>
|
||||
))}
|
||||
</Stack>
|
||||
</AccordionPanel>
|
||||
</AccordionItem>
|
||||
))}
|
||||
</ActiveTabAccordion>
|
||||
);
|
||||
};
|
||||
@@ -18,7 +18,6 @@ import {
|
||||
IconHomeFilled,
|
||||
IconLayoutDashboardFilled,
|
||||
IconMailForward,
|
||||
IconBuildingStore,
|
||||
IconPhotoFilled,
|
||||
IconPointerFilled,
|
||||
IconSearch,
|
||||
@@ -81,12 +80,6 @@ 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",
|
||||
|
||||
@@ -1,18 +0,0 @@
|
||||
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>
|
||||
);
|
||||
}
|
||||
@@ -179,8 +179,8 @@ export async function POST(req: Request) {
|
||||
}
|
||||
|
||||
setTimeout(() => {
|
||||
console.log("Database restored, restarting server and applying migrations...");
|
||||
process.exit(101);
|
||||
console.log("Database restored, restarting server...");
|
||||
process.exit(0);
|
||||
}, 500);
|
||||
|
||||
return NextResponse.json({ success: true, message: "Database restored. Server is restarting..." });
|
||||
|
||||
@@ -0,0 +1,98 @@
|
||||
"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();
|
||||
}}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,47 @@
|
||||
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>;
|
||||
};
|
||||
@@ -0,0 +1,221 @@
|
||||
"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>
|
||||
);
|
||||
};
|
||||
@@ -1,72 +0,0 @@
|
||||
"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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -1,354 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import {
|
||||
Alert,
|
||||
Badge,
|
||||
Box,
|
||||
Button,
|
||||
Card,
|
||||
Code,
|
||||
Group,
|
||||
Image,
|
||||
Modal,
|
||||
Paper,
|
||||
Pagination,
|
||||
Select,
|
||||
SimpleGrid,
|
||||
Skeleton,
|
||||
Stack,
|
||||
Text,
|
||||
TextInput,
|
||||
} from "@mantine/core";
|
||||
import { useDebouncedValue } from "@mantine/hooks";
|
||||
import {
|
||||
IconAlertTriangle,
|
||||
IconBuildingStore,
|
||||
IconDownload,
|
||||
IconExternalLink,
|
||||
IconSearch,
|
||||
IconWifiOff,
|
||||
} from "@tabler/icons-react";
|
||||
import { keepPreviousData, useMutation, 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";
|
||||
|
||||
import { WorkshopQueryProvider } from "./workshop-query-provider";
|
||||
|
||||
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(props: WorkshopBrowserProps) {
|
||||
return (
|
||||
<WorkshopQueryProvider>
|
||||
<WorkshopBrowserContent {...props} />
|
||||
</WorkshopQueryProvider>
|
||||
);
|
||||
}
|
||||
|
||||
function WorkshopBrowserContent({ 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 [selectedId, setSelectedId] = useState<string | null>(null);
|
||||
const [online, setOnline] = useState(true);
|
||||
|
||||
useEffect(() => {
|
||||
const updateOnline = () => setOnline(window.navigator.onLine);
|
||||
updateOnline();
|
||||
window.addEventListener("online", updateOnline);
|
||||
window.addEventListener("offline", updateOnline);
|
||||
return () => {
|
||||
window.removeEventListener("online", updateOnline);
|
||||
window.removeEventListener("offline", updateOnline);
|
||||
};
|
||||
}, []);
|
||||
|
||||
const query = useQuery({
|
||||
queryKey: ["workshop", "list", page, type, sort, debouncedSearch],
|
||||
queryFn: ({ signal }) => client.list({ page, type, sort, search: debouncedSearch, perPage: 12, signal }),
|
||||
placeholderData: keepPreviousData,
|
||||
});
|
||||
const detailQuery = useQuery({
|
||||
queryKey: ["workshop", "detail", selectedId],
|
||||
queryFn: ({ signal }) => client.get(selectedId ?? "", signal),
|
||||
enabled: selectedId !== null,
|
||||
});
|
||||
const useMutationState = useMutation({
|
||||
mutationKey: ["workshop", "use"],
|
||||
mutationFn: async (submission: WorkshopSubmissionDetail) => {
|
||||
if (lockedType && submission.type !== lockedType) throw new Error(t("error.wrongType"));
|
||||
if (!onUse) return;
|
||||
await onUse(submission);
|
||||
},
|
||||
onSuccess: () => setSelectedId(null),
|
||||
});
|
||||
const selected = detailQuery.data ?? null;
|
||||
const hasCachedResults = Boolean(query.data?.items.length);
|
||||
|
||||
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">
|
||||
<Paper withBorder p="lg" radius="md">
|
||||
<Group justify="space-between" align="flex-start" wrap="wrap">
|
||||
<Group align="flex-start" wrap="nowrap">
|
||||
<Box
|
||||
p="sm"
|
||||
bg="var(--mantine-color-default-hover)"
|
||||
style={{ borderRadius: "var(--mantine-radius-md)", lineHeight: 0 }}
|
||||
>
|
||||
<IconBuildingStore size={24} aria-hidden />
|
||||
</Box>
|
||||
<Box>
|
||||
<Text fw={700} size="lg">
|
||||
{t("intro.title")}
|
||||
</Text>
|
||||
<Text c="dimmed" size="sm" maw={620}>
|
||||
{t("intro.description")}
|
||||
</Text>
|
||||
</Box>
|
||||
</Group>
|
||||
<Badge color={online ? "green" : "yellow"} variant="light">
|
||||
{online ? t("status.connected") : t("status.offline")}
|
||||
</Badge>
|
||||
</Group>
|
||||
</Paper>
|
||||
<Alert icon={<IconAlertTriangle size={18} />} color="yellow" title={t("safety.title")}>
|
||||
{t("safety.description")}
|
||||
</Alert>
|
||||
<SimpleGrid cols={{ base: 1, sm: lockedType ? 2 : 3 }} spacing="sm">
|
||||
<TextInput
|
||||
label={t("search.label")}
|
||||
placeholder={t("search.placeholder")}
|
||||
leftSection={<IconSearch size={16} />}
|
||||
value={search}
|
||||
onChange={(event) => {
|
||||
setSearch(event.currentTarget.value);
|
||||
setPage(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") },
|
||||
]}
|
||||
/>
|
||||
</SimpleGrid>
|
||||
{(query.isError || query.fetchStatus === "paused") && (
|
||||
<Alert
|
||||
icon={<IconWifiOff size={18} />}
|
||||
color={hasCachedResults ? "yellow" : "red"}
|
||||
title={hasCachedResults ? t("offline.cachedTitle") : t("offline.title")}
|
||||
>
|
||||
<Stack gap="sm">
|
||||
<Text size="sm">
|
||||
{hasCachedResults
|
||||
? t("offline.cachedDescription")
|
||||
: online
|
||||
? t("offline.unavailableDescription")
|
||||
: t("offline.description")}
|
||||
</Text>
|
||||
<Group wrap="wrap">
|
||||
<Button size="xs" variant="light" onClick={() => void query.refetch()}>
|
||||
{t("action.retry")}
|
||||
</Button>
|
||||
<Button
|
||||
size="xs"
|
||||
variant="subtle"
|
||||
component="a"
|
||||
href="https://homarr.dev/docs/management/workshop"
|
||||
target="_blank"
|
||||
>
|
||||
{t("action.website")}
|
||||
</Button>
|
||||
</Group>
|
||||
</Stack>
|
||||
</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={() => setSelectedId(item.id)}>
|
||||
{t("action.inspect")}
|
||||
</Button>
|
||||
</Stack>
|
||||
</Card>
|
||||
))}
|
||||
</SimpleGrid>
|
||||
) : query.isError || query.fetchStatus === "paused" ? null : (
|
||||
<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={selectedId !== null}
|
||||
onClose={() => {
|
||||
setSelectedId(null);
|
||||
useMutationState.reset();
|
||||
}}
|
||||
title={selected?.title ?? t("loading")}
|
||||
size="xl"
|
||||
>
|
||||
{detailQuery.isPending ? (
|
||||
<Skeleton h={400} />
|
||||
) : detailQuery.isError || !selected ? (
|
||||
<Alert icon={<IconWifiOff size={18} />} color="red" title={t("error.detailTitle")}>
|
||||
<Stack gap="sm">
|
||||
<Text size="sm">{t("error.detailDescription")}</Text>
|
||||
<Button size="xs" variant="light" onClick={() => void detailQuery.refetch()}>
|
||||
{t("action.retry")}
|
||||
</Button>
|
||||
</Stack>
|
||||
</Alert>
|
||||
) : (
|
||||
<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>
|
||||
{selected.screenshots.length > 0 && (
|
||||
<SimpleGrid cols={{ base: 1, sm: 2 }}>
|
||||
{selected.screenshots.map((screenshot, index) => (
|
||||
<Image
|
||||
key={screenshot}
|
||||
src={client.fileUrl(selected.id, screenshot, "960x640")}
|
||||
radius="sm"
|
||||
alt={t("screenshotAlt", { title: selected.title, number: index + 1 })}
|
||||
/>
|
||||
))}
|
||||
</SimpleGrid>
|
||||
)}
|
||||
<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={useMutationState.isPending}
|
||||
disabled={!online}
|
||||
onClick={() => useMutationState.mutate(selected)}
|
||||
>
|
||||
{useLabel ?? t("action.use")}
|
||||
</Button>
|
||||
)}
|
||||
</Group>
|
||||
{!online && onUse && <Alert color="yellow">{t("offline.actionUnavailable")}</Alert>}
|
||||
{useMutationState.error && (
|
||||
<Alert color="red">
|
||||
{useMutationState.error instanceof Error ? useMutationState.error.message : t("error.install")}
|
||||
</Alert>
|
||||
)}
|
||||
</Stack>
|
||||
)}
|
||||
</Modal>
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
@@ -1,51 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import type { PropsWithChildren } from "react";
|
||||
import { useState } from "react";
|
||||
import { createAsyncStoragePersister } from "@tanstack/query-async-storage-persister";
|
||||
import { QueryClient } from "@tanstack/react-query";
|
||||
import { PersistQueryClientProvider } from "@tanstack/react-query-persist-client";
|
||||
|
||||
const workshopCacheMaxAge = 1000 * 60 * 60 * 24;
|
||||
|
||||
export function WorkshopQueryProvider({ children }: PropsWithChildren) {
|
||||
const [queryClient] = useState(
|
||||
() =>
|
||||
new QueryClient({
|
||||
defaultOptions: {
|
||||
queries: {
|
||||
gcTime: workshopCacheMaxAge,
|
||||
networkMode: "offlineFirst",
|
||||
refetchOnReconnect: true,
|
||||
retry: 1,
|
||||
staleTime: 1000 * 60 * 5,
|
||||
},
|
||||
mutations: {
|
||||
networkMode: "online",
|
||||
retry: false,
|
||||
},
|
||||
},
|
||||
}),
|
||||
);
|
||||
const [persister] = useState(() =>
|
||||
createAsyncStoragePersister({
|
||||
key: "homarr-workshop-cache-v1",
|
||||
storage: typeof window === "undefined" ? undefined : window.localStorage,
|
||||
throttleTime: 1000,
|
||||
}),
|
||||
);
|
||||
|
||||
return (
|
||||
<PersistQueryClientProvider
|
||||
client={queryClient}
|
||||
persistOptions={{
|
||||
persister,
|
||||
maxAge: workshopCacheMaxAge,
|
||||
buster: "workshop-v1",
|
||||
dehydrateOptions: { shouldDehydrateQuery: (query) => query.state.status === "success" },
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
</PersistQueryClientProvider>
|
||||
);
|
||||
}
|
||||
@@ -1 +1,3 @@
|
||||
pb_data/
|
||||
.env
|
||||
.env.local
|
||||
+24
-47
@@ -1,35 +1,19 @@
|
||||
# syntax=docker/dockerfile:1.10
|
||||
|
||||
FROM node:24.18.0-alpine AS dependencies
|
||||
FROM node:24.18.0-alpine AS docs
|
||||
WORKDIR /app
|
||||
ENV COREPACK_HOME=/corepack
|
||||
|
||||
RUN mkdir -p /corepack && \
|
||||
corepack enable pnpm && \
|
||||
corepack install --global pnpm@11.10.0
|
||||
COPY pnpm-lock.yaml pnpm-workspace.yaml package.json turbo.json ./
|
||||
COPY patches ./patches
|
||||
RUN --mount=type=cache,id=workshop-pnpm-store,target=/pnpm/store \
|
||||
RUN corepack enable
|
||||
|
||||
COPY . .
|
||||
RUN --mount=type=cache,id=workshop-pnpm,target=/pnpm/store \
|
||||
pnpm config set store-dir /pnpm/store && \
|
||||
pnpm --config.node-linker=isolated fetch --ignore-scripts && \
|
||||
rm -rf node_modules
|
||||
|
||||
COPY apps/docs ./apps/docs
|
||||
COPY packages/definitions ./packages/definitions
|
||||
COPY packages/validation ./packages/validation
|
||||
COPY packages/workshop ./packages/workshop
|
||||
COPY tooling/typescript ./tooling/typescript
|
||||
RUN --mount=type=cache,id=workshop-pnpm-store,target=/pnpm/store \
|
||||
pnpm install --recursive --offline --frozen-lockfile
|
||||
|
||||
FROM dependencies AS website
|
||||
|
||||
RUN CI=true SKIP_ENV_VALIDATION=true pnpm turbo build --filter=@homarr/docs
|
||||
pnpm install --frozen-lockfile --filter @homarr/docs...
|
||||
RUN WORKSHOP_URL="" pnpm --filter @homarr/docs build
|
||||
|
||||
FROM alpine:3.22 AS pocketbase
|
||||
|
||||
ARG TARGETARCH
|
||||
ARG POCKETBASE_VERSION=0.39.6
|
||||
ARG TARGETARCH
|
||||
|
||||
RUN apk add --no-cache ca-certificates curl unzip
|
||||
RUN case "${TARGETARCH}" in \
|
||||
@@ -42,36 +26,29 @@ RUN case "${TARGETARCH}" in \
|
||||
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 - && \
|
||||
cd /tmp && \
|
||||
grep " ${ARCHIVE}$" checksums.txt | sha256sum -c - && \
|
||||
unzip "/tmp/${ARCHIVE}" pocketbase -d /out
|
||||
|
||||
FROM alpine:3.22 AS development
|
||||
|
||||
FROM alpine:3.22 AS runtime
|
||||
RUN apk add --no-cache ca-certificates tzdata
|
||||
|
||||
COPY --from=pocketbase /out/pocketbase /usr/local/bin/pocketbase
|
||||
COPY apps/workshop/entrypoint.dev.sh /usr/local/bin/workshop-dev-entrypoint
|
||||
COPY apps/workshop/dev-public /pb_public
|
||||
RUN chmod +x /usr/local/bin/workshop-dev-entrypoint && \
|
||||
mkdir -p /pb_data /pb_hooks /pb_migrations
|
||||
|
||||
EXPOSE 8090
|
||||
HEALTHCHECK --interval=10s --timeout=5s --start-period=15s --retries=6 CMD wget -q --spider http://127.0.0.1:8090/api/health || exit 1
|
||||
ENTRYPOINT ["workshop-dev-entrypoint"]
|
||||
|
||||
FROM alpine:3.22 AS production
|
||||
|
||||
RUN apk add --no-cache ca-certificates tzdata && addgroup -S pocketbase && adduser -S -G pocketbase pocketbase
|
||||
COPY --from=pocketbase /out/pocketbase /usr/local/bin/pocketbase
|
||||
COPY --from=website /app/apps/docs/build /pb_public
|
||||
COPY apps/workshop/entrypoint.sh /usr/local/bin/workshop-entrypoint
|
||||
COPY --from=docs /app/apps/docs/build /pb_public
|
||||
COPY apps/workshop/pb_hooks /pb_hooks
|
||||
COPY apps/workshop/pb_migrations /pb_migrations
|
||||
|
||||
RUN chmod +x /usr/local/bin/workshop-entrypoint && \
|
||||
RUN addgroup -S workshop && \
|
||||
adduser -S -G workshop -h /home/workshop workshop && \
|
||||
mkdir -p /pb_data && \
|
||||
chown -R pocketbase:pocketbase /pb_data /pb_hooks /pb_migrations /pb_public
|
||||
chown -R workshop:workshop /pb_data /pb_public /pb_hooks /pb_migrations
|
||||
|
||||
USER pocketbase
|
||||
USER workshop
|
||||
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"]
|
||||
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"]
|
||||
@@ -1,21 +1,15 @@
|
||||
**
|
||||
!package.json
|
||||
!pnpm-lock.yaml
|
||||
!pnpm-workspace.yaml
|
||||
!turbo.json
|
||||
!patches/**
|
||||
!apps/docs/**
|
||||
!apps/workshop/entrypoint.sh
|
||||
!apps/workshop/entrypoint.dev.sh
|
||||
!apps/workshop/dev-public/**
|
||||
!apps/workshop/pb_hooks/**
|
||||
!apps/workshop/pb_migrations/**
|
||||
!packages/definitions/**
|
||||
!packages/validation/**
|
||||
!packages/workshop/**
|
||||
!tooling/typescript/**
|
||||
**/node_modules
|
||||
.git
|
||||
.github
|
||||
.env
|
||||
.env.*
|
||||
!.env.example
|
||||
**/.docusaurus
|
||||
**/build
|
||||
**/dist
|
||||
**/.next
|
||||
**/.turbo
|
||||
**/build
|
||||
**/coverage
|
||||
**/dist
|
||||
**/node_modules
|
||||
**/pb_data
|
||||
.pnpm-store
|
||||
e2e
|
||||
+42
-36
@@ -1,74 +1,80 @@
|
||||
# Homarr Workshop service
|
||||
# Homarr Workshop
|
||||
|
||||
Workshop combines the public Docusaurus experience with a PocketBase community API. Development keeps the two processes separate; the production image serves both from one origin.
|
||||
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.
|
||||
|
||||
## Development
|
||||
## Local production-like run
|
||||
|
||||
From the repository root, run:
|
||||
Configure optional values in the repository-root `.env`, then run:
|
||||
|
||||
```sh
|
||||
pnpm dev:workshop
|
||||
```
|
||||
|
||||
This starts:
|
||||
Open:
|
||||
|
||||
- PocketBase in Docker with `--dev` at `http://localhost:8090`;
|
||||
- the PocketBase dashboard at `http://localhost:8090/_/`;
|
||||
- `@homarr/workshop` in watch mode on the host;
|
||||
- Docusaurus with HMR at `http://localhost:3003/workshop`.
|
||||
- documentation: `http://localhost:8090/`;
|
||||
- Workshop: `http://localhost:8090/workshop/`;
|
||||
- PocketBase API: `http://localhost:8090/api/`;
|
||||
- PocketBase dashboard: `http://localhost:8090/_/`.
|
||||
|
||||
PocketBase data is bind-mounted at `apps/workshop/pb_data/`. The directory is gitignored and survives container rebuilds. Hooks and migrations are mounted read-only and PocketBase reloads them in development mode.
|
||||
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.
|
||||
|
||||
No users, superusers, OAuth providers, or submissions are seeded. Create the first superuser manually in the PocketBase dashboard, configure GitHub OAuth on the `users` collection, and use `http://localhost:8090/api/oauth2-redirect` as the local callback URL. OAuth account creation is handled by PocketBase directly. Promote moderators and administrators by creating a `workshop_staff` record for the user; that record is the authorization source of truth.
|
||||
## GitHub OAuth
|
||||
|
||||
Optional ports and CORS behavior are configured only in the repository-root `.env`:
|
||||
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
|
||||
PB_EXPOSE_PORT=8090
|
||||
WORKSHOP_WEB_PORT=3003
|
||||
PB_ALLOWED_ORIGINS=*
|
||||
GITHUB_CLIENT_ID=
|
||||
GITHUB_CLIENT_SECRET=
|
||||
```
|
||||
|
||||
## Production-like preview
|
||||
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.
|
||||
|
||||
Run the exact combined image shape used for deployment:
|
||||
## Local image validation
|
||||
|
||||
Build and test the exact runtime shape:
|
||||
|
||||
```sh
|
||||
pnpm preview:workshop
|
||||
docker build -f apps/workshop/Dockerfile -t homarr/workshop:ci .
|
||||
pnpm test:workshop-image
|
||||
```
|
||||
|
||||
The multi-stage image builds Docusaurus, verifies and installs PocketBase `0.39.6`, copies the hooks and migrations, and serves everything from `http://localhost:8090`:
|
||||
The smoke test starts a temporary container and verifies the documentation homepage, `/workshop/`, the migrated public collection, and `/api/health`.
|
||||
|
||||
- website: `http://localhost:8090/workshop`;
|
||||
- API: `http://localhost:8090/api`;
|
||||
- dashboard: `http://localhost:8090/_/`.
|
||||
## GitHub Actions and ACT
|
||||
|
||||
The preview reuses `apps/workshop/pb_data/`, so OAuth configuration and local records remain available when switching between development and preview. `--remove-orphans` replaces the previous service shape without deleting data.
|
||||
`.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:
|
||||
|
||||
## Reset local data
|
||||
- `ghcr.io/homarr-labs/workshop:latest`;
|
||||
- `ghcr.io/homarr-labs/workshop:sha-<commit>`.
|
||||
|
||||
Resetting is always explicit:
|
||||
Run the same build and smoke test locally without registry credentials:
|
||||
|
||||
```sh
|
||||
pnpm reset:workshop
|
||||
pnpm ci:act:workshop
|
||||
```
|
||||
|
||||
The command asks for confirmation, stops both service variants, and clears `apps/workshop/pb_data/`. For automation, use `pnpm reset:workshop -- --force`.
|
||||
See `.github/act/workshop.md` for the direct ACT command and Docker socket behavior.
|
||||
|
||||
Authentication failures for an old email are requests made by the PocketBase dashboard against the persisted database; Workshop startup never attempts to authenticate or seed that account.
|
||||
## VPS deployment
|
||||
|
||||
## Build and validation
|
||||
|
||||
`pnpm build` builds both `@homarr/workshop` and Docusaurus as part of the monorepo build. The production image can be validated locally with:
|
||||
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
|
||||
pnpm test:workshop-preview
|
||||
docker compose -f apps/workshop/docker-compose.yml pull
|
||||
docker compose -f apps/workshop/docker-compose.yml up -d --no-build
|
||||
```
|
||||
|
||||
The test uses a temporary database, builds and starts the production image, checks the website, API, service worker, and migrated collections, then removes all test state. The dedicated Workshop CI workflow runs this test only when Workshop-related sources or build inputs change; it does not publish an image.
|
||||
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.
|
||||
|
||||
## Operations
|
||||
## Content schema
|
||||
|
||||
The live service uses the production target behind the `homarr.dev` TLS proxy. PocketBase superuser access is bootstrap/emergency access only; normal moderation belongs in `/workshop/admin`.
|
||||
- Custom widgets use `homarr-custom-widget-v2` JSON.
|
||||
- Custom CSS uses raw CSS tagged as `homarr-custom-css-v1`.
|
||||
|
||||
Back up `/pb_data` before PocketBase or schema upgrades. It contains records, uploads, OAuth settings, and credentials. Production must also restrict `/_/`, monitor health and authentication failures, and validate backup restoration regularly.
|
||||
Client-side validation lives in `apps/docs/src/lib/workshop-schema.ts`. PocketBase collection rules and hooks remain in this directory.
|
||||
@@ -1,10 +0,0 @@
|
||||
self.addEventListener("install", () => self.skipWaiting());
|
||||
|
||||
self.addEventListener("activate", (event) => {
|
||||
event.waitUntil(
|
||||
Promise.all([
|
||||
caches.keys().then((keys) => Promise.all(keys.map((key) => caches.delete(key)))),
|
||||
self.registration.unregister(),
|
||||
]),
|
||||
);
|
||||
});
|
||||
@@ -1,19 +0,0 @@
|
||||
name: homarr-workshop
|
||||
|
||||
services:
|
||||
workshop:
|
||||
build:
|
||||
context: ../..
|
||||
dockerfile: apps/workshop/Dockerfile
|
||||
target: production
|
||||
args:
|
||||
POCKETBASE_VERSION: 0.39.6
|
||||
restart: unless-stopped
|
||||
environment:
|
||||
PB_ALLOWED_ORIGINS: ${PB_ALLOWED_ORIGINS:-*}
|
||||
ports:
|
||||
- "127.0.0.1:${PB_EXPOSE_PORT:-8090}:8090"
|
||||
volumes:
|
||||
- ${WORKSHOP_DATA_DIR:-./pb_data}:/pb_data
|
||||
security_opt:
|
||||
- no-new-privileges:true
|
||||
@@ -2,20 +2,27 @@ name: homarr-workshop
|
||||
|
||||
services:
|
||||
workshop:
|
||||
image: ghcr.io/homarr-labs/workshop:${WORKSHOP_TAG:-latest}
|
||||
build:
|
||||
context: ../..
|
||||
dockerfile: apps/workshop/Dockerfile
|
||||
target: development
|
||||
args:
|
||||
POCKETBASE_VERSION: 0.39.6
|
||||
restart: unless-stopped
|
||||
environment:
|
||||
PB_ALLOWED_ORIGINS: ${PB_ALLOWED_ORIGINS:-*}
|
||||
GITHUB_CLIENT_ID: ${GITHUB_CLIENT_ID:-}
|
||||
GITHUB_CLIENT_SECRET: ${GITHUB_CLIENT_SECRET:-}
|
||||
ports:
|
||||
- "127.0.0.1:${PB_EXPOSE_PORT:-8090}:8090"
|
||||
- "127.0.0.1:${WORKSHOP_PORT:-8090}:8090"
|
||||
volumes:
|
||||
- ${WORKSHOP_DATA_DIR:-./pb_data}:/pb_data
|
||||
- ./pb_hooks:/pb_hooks:ro
|
||||
- ./pb_migrations:/pb_migrations:ro
|
||||
- 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
|
||||
@@ -1,16 +0,0 @@
|
||||
#!/bin/sh
|
||||
set -eu
|
||||
|
||||
echo "Workshop development API: http://localhost:8090/api"
|
||||
echo "PocketBase dashboard: http://localhost:8090/_/"
|
||||
|
||||
exec pocketbase serve \
|
||||
--dev \
|
||||
--automigrate=false \
|
||||
--http=0.0.0.0:8090 \
|
||||
--origins="${PB_ALLOWED_ORIGINS:-*}" \
|
||||
--dir=/pb_data \
|
||||
--hooksDir=/pb_hooks \
|
||||
--migrationsDir=/pb_migrations \
|
||||
--publicDir=/pb_public \
|
||||
--indexFallback=true
|
||||
@@ -1,14 +0,0 @@
|
||||
#!/bin/sh
|
||||
set -eu
|
||||
|
||||
echo "Workshop is listening on port 8090"
|
||||
echo "Workshop API is available at /api"
|
||||
|
||||
exec pocketbase serve \
|
||||
--http=0.0.0.0:8090 \
|
||||
--origins="${PB_ALLOWED_ORIGINS:-*}" \
|
||||
--dir=/pb_data \
|
||||
--hooksDir=/pb_hooks \
|
||||
--migrationsDir=/pb_migrations \
|
||||
--publicDir=/pb_public \
|
||||
--indexFallback=true
|
||||
@@ -1,135 +0,0 @@
|
||||
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) {
|
||||
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");
|
||||
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) {
|
||||
const exported = record.publicExport();
|
||||
if (typeof exported.content === "string") {
|
||||
exported.contentLength = exported.content.length;
|
||||
delete exported.content;
|
||||
}
|
||||
return JSON.stringify(exported);
|
||||
}
|
||||
|
||||
function requireStaff(app, auth) {
|
||||
requireWritableAccount(app, auth);
|
||||
let staff;
|
||||
try {
|
||||
staff = app.findFirstRecordByFilter("workshop_staff", "user = {:user}", { user: auth.id });
|
||||
} catch {
|
||||
fail(ForbiddenError, "forbidden", "Staff access is required");
|
||||
}
|
||||
return staff.getString("role");
|
||||
}
|
||||
|
||||
function setStaffRole(app, user, role) {
|
||||
let staff = null;
|
||||
try {
|
||||
staff = app.findFirstRecordByFilter("workshop_staff", "user = {:user}", { user: user.id });
|
||||
} catch {
|
||||
// Members do not have a staff record.
|
||||
}
|
||||
if (role === "member") {
|
||||
if (staff) app.delete(staff);
|
||||
return;
|
||||
}
|
||||
if (!staff) staff = new Record(app.findCollectionByNameOrId("workshop_staff"));
|
||||
staff.set("user", user.id);
|
||||
staff.set("role", role);
|
||||
app.save(staff);
|
||||
}
|
||||
|
||||
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,
|
||||
setStaffRole,
|
||||
snapshot,
|
||||
validateSubmission,
|
||||
};
|
||||
@@ -1,141 +1,75 @@
|
||||
/// <reference path="../pb_data/types.d.ts" />
|
||||
|
||||
// --- Submissions ---
|
||||
|
||||
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.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) => {
|
||||
const utils = require(`${__hooks}/workshop-utils.js`);
|
||||
utils.requireWritableAccount(e.app, e.auth, true);
|
||||
const original = e.app.findRecordById("submissions", e.record.id);
|
||||
var 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("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(
|
||||
"revision",
|
||||
original.getInt("revision") + (e.record.getString("content") !== original.getString("content") ? 1 : 0),
|
||||
);
|
||||
utils.validateSubmission(e.record);
|
||||
e.record.set("version", original.get("version"));
|
||||
e.next();
|
||||
}, "submissions");
|
||||
|
||||
// --- Votes ---
|
||||
|
||||
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);
|
||||
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) => {
|
||||
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");
|
||||
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) => {
|
||||
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.record.set("user", e.auth?.id);
|
||||
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"),
|
||||
);
|
||||
onRecordCreateRequest((e) => {
|
||||
e.record.set("author", e.auth?.id);
|
||||
e.next();
|
||||
}, "comments");
|
||||
|
||||
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", "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.setStaffRole(tx, target, data.role);
|
||||
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"),
|
||||
);
|
||||
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");
|
||||
@@ -0,0 +1,172 @@
|
||||
/// <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
|
||||
}
|
||||
}
|
||||
},
|
||||
);
|
||||
@@ -0,0 +1,49 @@
|
||||
/// <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);
|
||||
},
|
||||
);
|
||||
@@ -1,286 +0,0 @@
|
||||
/// <reference path="../pb_data/types.d.ts" />
|
||||
|
||||
migrate(
|
||||
(app) => {
|
||||
let users;
|
||||
try {
|
||||
users = app.findCollectionByNameOrId("users");
|
||||
} catch {
|
||||
users = new Collection({
|
||||
type: "auth",
|
||||
name: "users",
|
||||
});
|
||||
}
|
||||
users.listRule = "@request.auth.role = 'moderator' || @request.auth.role = 'admin'";
|
||||
users.viewRule = "id = @request.auth.id || @request.auth.role = 'moderator' || @request.auth.role = 'admin'";
|
||||
users.updateRule =
|
||||
"id = @request.auth.id && @request.auth.state != 'disabled' && @request.body.role:changed = false && @request.body.state:changed = false && @request.body.moderationReason:changed = false";
|
||||
users.deleteRule = null;
|
||||
users.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", "disabled"],
|
||||
}),
|
||||
new TextField({ name: "moderationReason", max: 1000 }),
|
||||
);
|
||||
app.save(users);
|
||||
|
||||
const workshopStaff = new Collection({
|
||||
type: "base",
|
||||
name: "workshop_staff",
|
||||
listRule: "user = @request.auth.id",
|
||||
viewRule: "user = @request.auth.id",
|
||||
createRule: null,
|
||||
updateRule: null,
|
||||
deleteRule: null,
|
||||
fields: [
|
||||
{ type: "relation", name: "user", required: true, maxSelect: 1, collectionId: users.id, cascadeDelete: true },
|
||||
{ type: "select", name: "role", required: true, maxSelect: 1, values: ["moderator", "admin"] },
|
||||
{ type: "autodate", name: "created", onCreate: true },
|
||||
{ type: "autodate", name: "updated", onCreate: true, onUpdate: true },
|
||||
],
|
||||
});
|
||||
workshopStaff.addIndex("idx_workshop_staff_user", true, "user", "");
|
||||
app.save(workshopStaff);
|
||||
|
||||
const staffRule = "@collection.workshop_staff.user ?= @request.auth.id";
|
||||
users.listRule = staffRule;
|
||||
users.viewRule = `id = @request.auth.id || ${staffRule}`;
|
||||
app.save(users);
|
||||
|
||||
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();
|
||||
const workshopRateLimitLabels = [
|
||||
"submissions:create",
|
||||
"votes:create",
|
||||
"reports:create",
|
||||
"/api/workshop/moderation/",
|
||||
];
|
||||
const workshopRateLimits = [
|
||||
{ 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 },
|
||||
];
|
||||
settings.rateLimits.enabled = true;
|
||||
settings.rateLimits.rules = [
|
||||
...settings.rateLimits.rules.filter((rule) => !workshopRateLimitLabels.includes(rule.label)),
|
||||
...workshopRateLimits,
|
||||
];
|
||||
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",
|
||||
"workshop_staff",
|
||||
]) {
|
||||
try {
|
||||
app.delete(app.findCollectionByNameOrId(name));
|
||||
} catch {
|
||||
// Collection was not created or was already removed.
|
||||
}
|
||||
}
|
||||
const settings = app.settings();
|
||||
const workshopRateLimitLabels = [
|
||||
"submissions:create",
|
||||
"votes:create",
|
||||
"reports:create",
|
||||
"/api/workshop/moderation/",
|
||||
];
|
||||
settings.rateLimits.rules = settings.rateLimits.rules.filter(
|
||||
(rule) => !workshopRateLimitLabels.includes(rule.label),
|
||||
);
|
||||
app.save(settings);
|
||||
},
|
||||
);
|
||||
@@ -1,73 +0,0 @@
|
||||
/// <reference path="../pb_data/types.d.ts" />
|
||||
|
||||
migrate(
|
||||
(app) => {
|
||||
const users = app.findCollectionByNameOrId("users");
|
||||
const state = users.fields.getByName("state");
|
||||
if (state) state.values = ["active", "disabled"];
|
||||
|
||||
let staff;
|
||||
try {
|
||||
staff = app.findCollectionByNameOrId("workshop_staff");
|
||||
} catch {
|
||||
staff = new Collection({
|
||||
type: "base",
|
||||
name: "workshop_staff",
|
||||
listRule: "user = @request.auth.id",
|
||||
viewRule: "user = @request.auth.id",
|
||||
createRule: null,
|
||||
updateRule: null,
|
||||
deleteRule: null,
|
||||
fields: [
|
||||
{
|
||||
type: "relation",
|
||||
name: "user",
|
||||
required: true,
|
||||
maxSelect: 1,
|
||||
collectionId: users.id,
|
||||
cascadeDelete: true,
|
||||
},
|
||||
{ type: "select", name: "role", required: true, maxSelect: 1, values: ["moderator", "admin"] },
|
||||
{ type: "autodate", name: "created", onCreate: true },
|
||||
{ type: "autodate", name: "updated", onCreate: true, onUpdate: true },
|
||||
],
|
||||
});
|
||||
staff.addIndex("idx_workshop_staff_user", true, "user", "");
|
||||
app.save(staff);
|
||||
}
|
||||
|
||||
for (const user of app.findRecordsByFilter("users", "role = 'moderator' || role = 'admin'", "", 0, 0)) {
|
||||
if (!user) continue;
|
||||
let record;
|
||||
try {
|
||||
record = app.findFirstRecordByFilter("workshop_staff", "user = {:user}", { user: user.id });
|
||||
} catch {
|
||||
record = new Record(staff);
|
||||
record.set("user", user.id);
|
||||
}
|
||||
record.set("role", user.getString("role"));
|
||||
app.save(record);
|
||||
}
|
||||
|
||||
const staffRule = "@collection.workshop_staff.user ?= @request.auth.id";
|
||||
users.listRule = staffRule;
|
||||
users.viewRule = `id = @request.auth.id || ${staffRule}`;
|
||||
users.updateRule =
|
||||
"id = @request.auth.id && @request.auth.state != 'disabled' && @request.body.role:changed = false && @request.body.state:changed = false && @request.body.moderationReason:changed = false";
|
||||
app.save(users);
|
||||
},
|
||||
(app) => {
|
||||
const users = app.findCollectionByNameOrId("users");
|
||||
users.listRule = "@request.auth.role = 'moderator' || @request.auth.role = 'admin'";
|
||||
users.viewRule = "id = @request.auth.id || @request.auth.role = 'moderator' || @request.auth.role = 'admin'";
|
||||
users.updateRule = "id = @request.auth.id && @request.auth.state != 'disabled'";
|
||||
const state = users.fields.getByName("state");
|
||||
if (state) state.values = ["active", "posting_banned", "disabled"];
|
||||
app.save(users);
|
||||
try {
|
||||
app.delete(app.findCollectionByNameOrId("workshop_staff"));
|
||||
} catch {
|
||||
// Already removed.
|
||||
}
|
||||
},
|
||||
);
|
||||
@@ -1,20 +0,0 @@
|
||||
#!/bin/sh
|
||||
set -eu
|
||||
|
||||
data_dir="${WORKSHOP_DATA_DIR:-apps/workshop/pb_data}"
|
||||
|
||||
if [ "${1:-}" != "--force" ]; then
|
||||
printf "Delete the local Workshop database at %s? [y/N] " "$data_dir"
|
||||
read -r answer
|
||||
case "$answer" in
|
||||
y|Y|yes|YES) ;;
|
||||
*) echo "Reset cancelled"; exit 0 ;;
|
||||
esac
|
||||
fi
|
||||
|
||||
docker compose --env-file .env --file apps/workshop/docker-compose.yml down --remove-orphans >/dev/null 2>&1 || true
|
||||
docker compose --env-file .env --file apps/workshop/docker-compose.preview.yml down --remove-orphans >/dev/null 2>&1 || true
|
||||
|
||||
mkdir -p "$data_dir"
|
||||
docker run --rm --volume "$(cd "$data_dir" && pwd):/data" alpine:3.22 sh -c 'rm -rf /data/.[!.]* /data/..?* /data/*'
|
||||
echo "Workshop database reset"
|
||||
Executable
+30
@@ -0,0 +1,30 @@
|
||||
#!/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"
|
||||
@@ -1,53 +0,0 @@
|
||||
#!/bin/sh
|
||||
set -eu
|
||||
|
||||
project="homarr-workshop-integration"
|
||||
port="${WORKSHOP_TEST_PORT:-18091}"
|
||||
email="integration@homarr.test"
|
||||
password="${WORKSHOP_TEST_PASSWORD:-WorkshopIntegrationPassword123!}"
|
||||
data_dir="$(mktemp -d)"
|
||||
|
||||
cleanup() {
|
||||
PB_EXPOSE_PORT="$port" WORKSHOP_DATA_DIR="$data_dir" docker compose --env-file .env.example --file apps/workshop/docker-compose.yml --project-name "$project" down --remove-orphans >/dev/null 2>&1 || true
|
||||
docker run --rm --volume "$data_dir:/data" alpine:3.22 sh -c 'rm -rf /data/.[!.]* /data/..?* /data/*' >/dev/null 2>&1 || true
|
||||
rmdir "$data_dir" >/dev/null 2>&1 || true
|
||||
}
|
||||
trap cleanup EXIT INT TERM
|
||||
PB_EXPOSE_PORT="$port" WORKSHOP_DATA_DIR="$data_dir" docker compose --env-file .env.example --file apps/workshop/docker-compose.yml --project-name "$project" down --remove-orphans >/dev/null 2>&1 || true
|
||||
|
||||
PB_EXPOSE_PORT="$port" WORKSHOP_DATA_DIR="$data_dir" docker compose \
|
||||
--env-file .env.example \
|
||||
--file apps/workshop/docker-compose.yml \
|
||||
--project-name "$project" \
|
||||
up --detach --build --wait
|
||||
|
||||
WORKSHOP_DATA_DIR="$data_dir" docker compose \
|
||||
--env-file .env.example \
|
||||
--file apps/workshop/docker-compose.yml \
|
||||
--project-name "$project" \
|
||||
exec --no-TTY workshop pocketbase superuser upsert "$email" "$password" --dir=/pb_data
|
||||
|
||||
if ! WORKSHOP_TEST_URL="http://127.0.0.1:$port" \
|
||||
WORKSHOP_TEST_SUPERUSER_EMAIL="$email" \
|
||||
WORKSHOP_TEST_SUPERUSER_PASSWORD="$password" \
|
||||
node apps/workshop/tests/workshop.integration.mjs; then
|
||||
PB_EXPOSE_PORT="$port" WORKSHOP_DATA_DIR="$data_dir" docker compose --env-file .env.example --file apps/workshop/docker-compose.yml --project-name "$project" logs --no-color workshop
|
||||
exit 1
|
||||
fi
|
||||
|
||||
PB_EXPOSE_PORT="$port" WORKSHOP_DATA_DIR="$data_dir" docker compose \
|
||||
--env-file .env.example \
|
||||
--file apps/workshop/docker-compose.yml \
|
||||
--project-name "$project" \
|
||||
restart workshop >/dev/null
|
||||
PB_EXPOSE_PORT="$port" WORKSHOP_DATA_DIR="$data_dir" docker compose \
|
||||
--env-file .env.example \
|
||||
--file apps/workshop/docker-compose.yml \
|
||||
--project-name "$project" \
|
||||
up --detach --wait >/dev/null
|
||||
|
||||
WORKSHOP_TEST_URL="http://127.0.0.1:$port" \
|
||||
WORKSHOP_TEST_SUPERUSER_EMAIL="$email" \
|
||||
WORKSHOP_TEST_SUPERUSER_PASSWORD="$password" \
|
||||
WORKSHOP_VERIFY_CONFIGURATION_ONLY=true \
|
||||
node apps/workshop/tests/workshop.integration.mjs
|
||||
@@ -1,51 +0,0 @@
|
||||
#!/bin/sh
|
||||
set -eu
|
||||
|
||||
project="homarr-workshop-preview-test"
|
||||
port="${WORKSHOP_PREVIEW_TEST_PORT:-18092}"
|
||||
email="preview@homarr.test"
|
||||
password="${WORKSHOP_PREVIEW_TEST_PASSWORD:-WorkshopPreviewPassword123!}"
|
||||
data_dir="$(mktemp -d)"
|
||||
base_url="http://127.0.0.1:$port"
|
||||
|
||||
cleanup() {
|
||||
PB_EXPOSE_PORT="$port" WORKSHOP_DATA_DIR="$data_dir" docker compose --env-file .env.example --file apps/workshop/docker-compose.preview.yml --project-name "$project" down --remove-orphans >/dev/null 2>&1 || true
|
||||
docker run --rm --volume "$data_dir:/data" alpine:3.22 sh -c 'rm -rf /data/.[!.]* /data/..?* /data/*' >/dev/null 2>&1 || true
|
||||
rmdir "$data_dir" >/dev/null 2>&1 || true
|
||||
}
|
||||
trap cleanup EXIT INT TERM
|
||||
PB_EXPOSE_PORT="$port" WORKSHOP_DATA_DIR="$data_dir" docker compose --env-file .env.example --file apps/workshop/docker-compose.preview.yml --project-name "$project" down --remove-orphans >/dev/null 2>&1 || true
|
||||
|
||||
PB_EXPOSE_PORT="$port" WORKSHOP_DATA_DIR="$data_dir" docker compose \
|
||||
--env-file .env.example \
|
||||
--file apps/workshop/docker-compose.preview.yml \
|
||||
--project-name "$project" \
|
||||
up --detach --build --wait
|
||||
|
||||
curl --fail --silent --show-error "$base_url/api/health" >/dev/null
|
||||
curl --fail --silent --show-error --location "$base_url/workshop" >/dev/null
|
||||
curl --fail --silent --show-error "$base_url/workshop-sw.js" >/dev/null
|
||||
|
||||
PB_EXPOSE_PORT="$port" WORKSHOP_DATA_DIR="$data_dir" docker compose \
|
||||
--env-file .env.example \
|
||||
--file apps/workshop/docker-compose.preview.yml \
|
||||
--project-name "$project" \
|
||||
exec --no-TTY workshop pocketbase superuser upsert "$email" "$password" --dir=/pb_data >/dev/null
|
||||
|
||||
token="$(curl --fail --silent --show-error \
|
||||
--header 'Content-Type: application/json' \
|
||||
--data "{\"identity\":\"$email\",\"password\":\"$password\"}" \
|
||||
"$base_url/api/collections/_superusers/auth-with-password" | jq --raw-output '.token')"
|
||||
|
||||
collections="$(curl --fail --silent --show-error \
|
||||
--header "Authorization: $token" \
|
||||
"$base_url/api/collections?perPage=200")"
|
||||
|
||||
for collection in users submissions votes reports moderation_actions workshop_listings; do
|
||||
echo "$collections" | jq --exit-status --arg name "$collection" '.items | any(.name == $name)' >/dev/null || {
|
||||
echo "Missing Workshop collection: $collection" >&2
|
||||
exit 1
|
||||
}
|
||||
done
|
||||
|
||||
echo "Workshop production preview passed"
|
||||
@@ -1,200 +0,0 @@
|
||||
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;
|
||||
const verifyConfigurationOnly = process.env.WORKSHOP_VERIFY_CONFIGURATION_ONLY === "true";
|
||||
assert(baseUrl && superuserEmail && superuserPassword, "Workshop integration test environment is incomplete");
|
||||
|
||||
const root = new PocketBase(baseUrl);
|
||||
await root.collection("_superusers").authWithPassword(superuserEmail, superuserPassword);
|
||||
if (!verifyConfigurationOnly) {
|
||||
await root.collections.update("users", {
|
||||
oauth2: {
|
||||
enabled: true,
|
||||
providers: [{ name: "github", clientId: "integration-client", clientSecret: "integration-secret" }],
|
||||
},
|
||||
});
|
||||
}
|
||||
const authMethods = await root.collection("users").listAuthMethods();
|
||||
assert.equal(authMethods.oauth2.enabled, true, "GitHub OAuth must retain its persisted PocketBase configuration");
|
||||
assert.equal(authMethods.password.enabled, false, "password authentication must remain disabled");
|
||||
assert.deepEqual(
|
||||
authMethods.oauth2.providers.map((provider) => provider.name),
|
||||
["github"],
|
||||
);
|
||||
|
||||
if (verifyConfigurationOnly) {
|
||||
console.log("Workshop PocketBase configuration persisted across restart");
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
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 disabledRecord = await createUser("Disabled");
|
||||
const moderatorRecord = await createUser("Moderator", "moderator");
|
||||
const adminRecord = await createUser("Admin", "admin");
|
||||
await root.collection("workshop_staff").create({ user: moderatorRecord.id, role: "moderator" });
|
||||
await root.collection("workshop_staff").create({ user: adminRecord.id, role: "admin" });
|
||||
|
||||
const member = await root.collection("users").impersonate(memberRecord.id, 3600);
|
||||
const secondMember = await root.collection("users").impersonate(secondMemberRecord.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);
|
||||
|
||||
assert.equal((await member.collection("users").getList()).totalItems, 0, "members must not list Workshop accounts");
|
||||
|
||||
await expectStatus(
|
||||
() =>
|
||||
member.collection("users").update(memberRecord.id, {
|
||||
role: "admin",
|
||||
state: "disabled",
|
||||
moderationReason: "self escalation",
|
||||
}),
|
||||
[403, 404],
|
||||
);
|
||||
|
||||
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: "widget",
|
||||
title: "Integration widget",
|
||||
description: "A backend integration fixture",
|
||||
schemaVersion: "forged-version",
|
||||
content: JSON.stringify({
|
||||
$schema: "homarr-custom-widget-v2",
|
||||
name: "Integration widget",
|
||||
url: "https://example.com/status",
|
||||
authType: "none",
|
||||
method: "GET",
|
||||
displayType: "raw",
|
||||
displayConfig: { type: "raw" },
|
||||
integrationTestPadding: "x".repeat(150_000),
|
||||
}),
|
||||
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-widget-v2", "server must own schemaVersion");
|
||||
|
||||
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: "disabled", 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");
|
||||
assert.equal(
|
||||
(await root.collection("workshop_staff").getFirstListItem(`user = '${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 widget"), "removal audit must retain a target snapshot");
|
||||
assert(!actions.at(-1)?.snapshot.includes("integrationTestPadding"), "removal audit must omit large source content");
|
||||
assert(actions.at(-1)?.snapshot.includes('"contentLength"'), "removal audit must retain the source length");
|
||||
|
||||
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");
|
||||
@@ -1,83 +0,0 @@
|
||||
# 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`). The rewritten branch is rebased onto the current `origin/dev` before each final push.
|
||||
|
||||
| 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 image and origin at `homarr.dev`; PocketBase serves both compiled docs and `/api`.
|
||||
- Deployment: the Workshop image is built locally from this repository by Compose or the Homarr deployment pipeline; no separate registry publication is maintained.
|
||||
- Configuration: PocketBase superusers and GitHub OAuth are configured once in each persistent volume and are never rewritten from startup environment variables.
|
||||
- Local development: PocketBase runs alone in Docker with a gitignored bind-mounted database; Docusaurus and contract watchers run on the host, and the production-like preview reuses the same data.
|
||||
- Resilience: the public shell and successfully loaded query data are cached independently; the rest of Homarr never depends on Workshop availability.
|
||||
- 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.
|
||||
- [x] Documentation and Workshop API are built into one reproducible image and served from `homarr.dev` without production cross-origin requests.
|
||||
- [ ] 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 | Current `origin/dev` is an ancestor; the prototype remains 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 builds with checksum verification; clean dev boot, persistence, disposable integration tests, and the combined production preview migrate and become healthy. |
|
||||
| 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, Workshop contracts, the combined PocketBase preview image, and Next.js build under Node 24.18.0; path-filtered CI smoke-tests the combined image. |
|
||||
| Browser smoke | Pass | Desktop/mobile discovery, disconnected API recovery, and a first-visit online-to-offline reload render without uncaught errors. |
|
||||
| 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.
|
||||
+3
-12
@@ -6,10 +6,6 @@
|
||||
"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",
|
||||
@@ -21,9 +17,9 @@
|
||||
"db:studio": "pnpm -F db studio",
|
||||
"dev": "pnpm -F nextjs dev",
|
||||
"dev:docs": "pnpm -F @homarr/docs dev",
|
||||
"dev:workshop": "pnpm -F @homarr/workshop build && concurrently --kill-others-on-fail --names pocketbase,contracts,website \"pnpm dev:workshop:backend\" \"pnpm -F @homarr/workshop dev\" \"pnpm dev:workshop:website\"",
|
||||
"dev:workshop:backend": "docker compose --env-file .env --file apps/workshop/docker-compose.yml up --build --remove-orphans",
|
||||
"dev:workshop:website": "pnpm with-env sh -c 'WORKSHOP_API_URL=${WORKSHOP_API_URL:-http://localhost:${PB_EXPOSE_PORT:-8090}} pnpm -F @homarr/docs docusaurus start --no-open --host 0.0.0.0 --port ${WORKSHOP_WEB_PORT:-3003}'",
|
||||
"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",
|
||||
"docker:dev": "docker compose -f ./development/development.docker-compose.yml up",
|
||||
"format": "turbo format --continue -- --check",
|
||||
"format:fix": "turbo format --continue",
|
||||
@@ -31,15 +27,11 @@
|
||||
"lint:fix": "turbo lint --continue -- --fix",
|
||||
"lint:ws": "pnpm dlx sherif@latest",
|
||||
"release": "semantic-release",
|
||||
"preview:workshop": "docker compose --env-file .env --file apps/workshop/docker-compose.preview.yml up --build --remove-orphans",
|
||||
"reset:workshop": "sh apps/workshop/reset.sh",
|
||||
"scripts:update-bug-report-template": "tsx ./scripts/update-bug-report-template.mts",
|
||||
"scripts:update-readme-integrations": "tsx ./scripts/update-integration-list.mts",
|
||||
"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/workshop/tests/run-integration.sh",
|
||||
"test:workshop-preview": "bash apps/workshop/tests/run-preview.sh",
|
||||
"test:ui": "cross-env NODE_ENV=development CI=true vitest --exclude e2e --ui --coverage.enabled",
|
||||
"typecheck": "turbo typecheck",
|
||||
"with-env": "dotenv -e .env --"
|
||||
@@ -60,7 +52,6 @@
|
||||
"@vitejs/plugin-react": "catalog:",
|
||||
"@vitest/coverage-v8": "catalog:",
|
||||
"@vitest/ui": "catalog:",
|
||||
"concurrently": "catalog:",
|
||||
"conventional-changelog-conventionalcommits": "catalog:",
|
||||
"cross-env": "catalog:",
|
||||
"jsdom": "catalog:",
|
||||
|
||||
@@ -497,9 +497,6 @@ export const integrationDefs = {
|
||||
|
||||
export const integrationKinds = objectKeys(integrationDefs) as AtLeastOneOf<IntegrationKind>;
|
||||
|
||||
export const isIntegrationKind = (integration: string): integration is IntegrationKind =>
|
||||
Object.hasOwn(integrationDefs, integration);
|
||||
|
||||
export const getIconUrl = (integration: IntegrationKind) => integrationDefs[integration].iconUrl;
|
||||
|
||||
export const getIntegrationName = (integration: IntegrationKind) => integrationDefs[integration].name;
|
||||
|
||||
@@ -699,6 +699,18 @@
|
||||
"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",
|
||||
@@ -716,6 +728,7 @@
|
||||
"addBar": "Add Bar",
|
||||
"duplicate": "Duplicate",
|
||||
"copyAiPrompt": "Copy AI prompt",
|
||||
"paste": "Paste JSON",
|
||||
"enable": "Enable",
|
||||
"disable": "Disable",
|
||||
"menu": "Actions"
|
||||
@@ -862,12 +875,15 @@
|
||||
"created": "Widget \"{name}\" created successfully.",
|
||||
"updated": "Widget \"{name}\" updated successfully.",
|
||||
"deleted": "Widget \"{name}\" deleted.",
|
||||
"imported": "Widget imported successfully.",
|
||||
"imported": "Widget \"{name}\" imported into form.",
|
||||
"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.",
|
||||
@@ -896,10 +912,6 @@
|
||||
"search": "Search integrations",
|
||||
"noResults": {
|
||||
"title": "There are no integrations yet"
|
||||
},
|
||||
"outdated": {
|
||||
"title": "Some integrations need a database update",
|
||||
"description": "{count} outdated integrations were hidden to keep this page available. Restart Homarr to apply pending data migrations."
|
||||
}
|
||||
},
|
||||
"create": {
|
||||
@@ -4458,70 +4470,6 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"workshop": {
|
||||
"title": "Workshop",
|
||||
"loading": "Loading submission",
|
||||
"intro": {
|
||||
"title": "Community Workshop",
|
||||
"description": "Discover widgets and CSS themes made by other Homarr users. Inspect the source before bringing anything into this instance."
|
||||
},
|
||||
"status": {
|
||||
"connected": "Connected",
|
||||
"offline": "Saved catalog"
|
||||
},
|
||||
"screenshotAlt": "{title} screenshot {number}",
|
||||
"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."
|
||||
},
|
||||
"offline": {
|
||||
"title": "Workshop is offline",
|
||||
"description": "You can keep using Homarr. Reconnect to browse or install community creations.",
|
||||
"unavailableDescription": "Homarr could not reach Workshop. The rest of your instance is still available.",
|
||||
"cachedTitle": "Showing saved Workshop results",
|
||||
"cachedDescription": "These results were saved on this device. Some details and actions may be unavailable until Workshop reconnects.",
|
||||
"actionUnavailable": "Reconnect before installing or applying this creation."
|
||||
},
|
||||
"error": {
|
||||
"loadTitle": "Workshop could not load",
|
||||
"detailTitle": "This creation is unavailable",
|
||||
"detailDescription": "Its source is not saved on this device and Workshop could not be reached.",
|
||||
"install": "The creation could not be used",
|
||||
"wrongType": "This creation does not match the requested type"
|
||||
},
|
||||
"action": {
|
||||
"retry": "Try again",
|
||||
"inspect": "Inspect source",
|
||||
"download": "Download",
|
||||
"website": "Open website",
|
||||
"use": "Use creation",
|
||||
"install": "Install widget",
|
||||
"useCss": "Use CSS"
|
||||
}
|
||||
},
|
||||
"management": {
|
||||
"metaTitle": "Management",
|
||||
"title": {
|
||||
@@ -4545,7 +4493,6 @@
|
||||
"apps": "Apps",
|
||||
"integrations": "Integrations",
|
||||
"customWidgets": "Custom Widgets",
|
||||
"workshop": "Workshop",
|
||||
"searchEngies": "Search engines",
|
||||
"medias": "Medias",
|
||||
"users": {
|
||||
@@ -5729,9 +5676,6 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"workshop": {
|
||||
"label": "Workshop"
|
||||
},
|
||||
"users": {
|
||||
"label": "Users",
|
||||
"create": {
|
||||
|
||||
Loaded 100 of 113 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user