Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
6040d09630 | ||
|
|
aff5a84a1d | ||
|
|
8cdc4f8030 | ||
|
|
c6e0cb27e7 | ||
|
|
9f979f683a | ||
|
|
066090d99e | ||
|
|
31e2e5ac7f | ||
|
|
699134a576 | ||
|
|
55ba12473c | ||
|
|
c72e4d6dfe | ||
|
|
a8644c4c6b | ||
|
|
c5e8b83d8a | ||
|
|
d52d9cfed2 | ||
|
|
93325642aa | ||
|
|
165e94abbc | ||
|
|
2e3127e766 | ||
|
|
d37326d905 | ||
|
|
e51e39ff38 | ||
|
|
6c075b6d74 | ||
|
|
b2f63c6e0f | ||
|
|
8c2d73e8af | ||
|
|
1c0ff8930e | ||
|
|
e2b8355639 |
No files matched your search
+11
-1
@@ -67,5 +67,15 @@ 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 Homarr Workshop API. Override only when developing the Workshop locally.
|
||||
# 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=*
|
||||
@@ -167,7 +167,7 @@ jobs:
|
||||
- name: Cache Playwright browsers
|
||||
if: '!env.ACT'
|
||||
id: playwright-cache
|
||||
uses: actions/cache@v4
|
||||
uses: actions/cache@v6
|
||||
with:
|
||||
path: ~/.cache/ms-playwright
|
||||
key: playwright-${{ runner.os }}-${{ hashFiles('pnpm-lock.yaml') }}
|
||||
@@ -185,7 +185,7 @@ jobs:
|
||||
|
||||
- name: Upload E2E artifacts
|
||||
if: failure() && !env.ACT
|
||||
uses: actions/upload-artifact@v4
|
||||
uses: actions/upload-artifact@v7
|
||||
with:
|
||||
name: playwright-report
|
||||
path: e2e/test-results/
|
||||
|
||||
@@ -1,56 +0,0 @@
|
||||
name: "[Deployment] Documentation"
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: ["dev"]
|
||||
paths:
|
||||
- "apps/docs/**"
|
||||
- "packages/definitions/src/docs/**"
|
||||
- ".github/workflows/deployment-docs.yml"
|
||||
workflow_dispatch:
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
pages: write
|
||||
id-token: write
|
||||
|
||||
concurrency:
|
||||
group: "pages"
|
||||
cancel-in-progress: false
|
||||
|
||||
env:
|
||||
SKIP_ENV_VALIDATION: true
|
||||
TURBO_API: ${{ secrets.TURBO_API }}
|
||||
TURBO_TEAM: ${{ secrets.TURBO_TEAM }}
|
||||
TURBO_TOKEN: ${{ secrets.TURBO_TOKEN }}
|
||||
TURBO_REMOTE_CACHE_SIGNATURE_KEY: ${{ secrets.TURBO_REMOTE_CACHE_SIGNATURE_KEY }}
|
||||
|
||||
jobs:
|
||||
build:
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 10
|
||||
steps:
|
||||
- uses: actions/checkout@v7
|
||||
- name: Setup
|
||||
uses: ./tooling/github/setup
|
||||
- name: Build docs
|
||||
run: pnpm turbo build --filter=@homarr/docs
|
||||
env:
|
||||
CI: "true"
|
||||
- name: Setup Pages
|
||||
uses: actions/configure-pages@v6
|
||||
- name: Upload pages artifact
|
||||
uses: actions/upload-pages-artifact@v5
|
||||
with:
|
||||
path: "apps/docs/build"
|
||||
|
||||
deploy:
|
||||
runs-on: ubuntu-latest
|
||||
needs: build
|
||||
environment:
|
||||
name: github-pages
|
||||
url: ${{ steps.deployment.outputs.page_url }}
|
||||
steps:
|
||||
- name: Deploy to GitHub Pages
|
||||
id: deployment
|
||||
uses: actions/deploy-pages@v5
|
||||
@@ -0,0 +1,50 @@
|
||||
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
|
||||
@@ -0,0 +1,87 @@
|
||||
---
|
||||
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,23 @@
|
||||
# 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.
|
||||
+17
-5
@@ -1,23 +1,36 @@
|
||||
# syntax=docker/dockerfile:1.10
|
||||
# syntax=docker/dockerfile:1.25
|
||||
|
||||
FROM node:24.18.0-alpine AS base
|
||||
|
||||
FROM base AS builder
|
||||
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 && pnpm fetch
|
||||
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
|
||||
|
||||
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 --frozen-lockfile
|
||||
pnpm install --recursive --offline
|
||||
|
||||
ARG SKIP_ENV_VALIDATION='true'
|
||||
ARG CI='true'
|
||||
ARG DISABLE_REDIS_LOGS='true'
|
||||
ARG TARGETPLATFORM
|
||||
|
||||
@@ -69,7 +82,6 @@ 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'
|
||||
|
||||
|
||||
+33
@@ -0,0 +1,33 @@
|
||||
# Product
|
||||
|
||||
## Register
|
||||
|
||||
product
|
||||
|
||||
## 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.
|
||||
|
||||
## 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.
|
||||
|
||||
## 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.
|
||||
|
||||
## 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.
|
||||
|
||||
## 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.
|
||||
|
||||
## 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.
|
||||
@@ -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,6 +54,7 @@ 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
|
||||
|
||||
|
||||
@@ -84,7 +84,7 @@ The import format uses `"$schema": "homarr-custom-widget-v2"` to identify the wi
|
||||
|
||||
## Community Workshop
|
||||
|
||||
Use **Install widget** on **Management → Workshop** to inspect and import community widgets. Use **Share in Workshop** from a widget's action menu to publish its canonical export without manually copying JSON. Secrets are never included in exports and must be configured on each Homarr instance.
|
||||
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
|
||||
|
||||
|
||||
@@ -19,13 +19,17 @@ Workshop entries are created by community members. Review widget endpoints, temp
|
||||
|
||||
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.
|
||||
|
||||
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.
|
||||
|
||||
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.
|
||||
|
||||
- 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.
|
||||
|
||||
## Share a creation
|
||||
|
||||
Use **Share in Workshop** beside a custom widget or in a board's custom CSS settings. Sign in with GitHub, provide a public title and description, inspect the exported source, and publish it. You can also publish from the Workshop website by pasting an exported widget or CSS source.
|
||||
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 are public immediately. Authors can update or delete their own submissions. Updating content increases its revision and shows the supplied changelog.
|
||||
|
||||
@@ -35,6 +39,8 @@ Signed-in members can vote to help useful creations rank higher. Report maliciou
|
||||
|
||||
## Accounts and moderation
|
||||
|
||||
Workshop accounts use GitHub OAuth and are separate from users in a self-hosted Homarr instance. Workshop moderators can remove submissions, prevent an account from publishing, disable an account, and resolve reports. These actions require a reason and are recorded.
|
||||
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.
|
||||
|
||||
The Workshop is a central service operated by Homarr at `store.homarr.dev`. Running the backend locally is supported for contributors developing or testing this feature, not as a private production marketplace.
|
||||
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.
|
||||
|
||||
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.
|
||||
@@ -3,6 +3,10 @@ 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.",
|
||||
@@ -17,7 +21,7 @@ const config: Config = {
|
||||
locales: ["en"],
|
||||
},
|
||||
customFields: {
|
||||
workshopApiUrl: process.env.NEXT_PUBLIC_WORKSHOP_API_URL ?? "https://store.homarr.dev",
|
||||
workshopApiUrl,
|
||||
},
|
||||
onBrokenLinks: "throw",
|
||||
onBrokenAnchors: "throw",
|
||||
@@ -305,6 +309,7 @@ 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",
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
"license": "Apache-2.0",
|
||||
"scripts": {
|
||||
"docusaurus": "docusaurus",
|
||||
"dev": "docusaurus start --no-open --port 3003",
|
||||
"dev": "docusaurus start --no-open --host 0.0.0.0 --port 3003",
|
||||
"start": "docusaurus start --port 3003",
|
||||
"build": "docusaurus build",
|
||||
"serve": "docusaurus serve",
|
||||
@@ -34,6 +34,9 @@
|
||||
"@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:",
|
||||
"clsx": "catalog:",
|
||||
"dayjs": "catalog:",
|
||||
"docusaurus-plugin-image-zoom": "^3.0.1",
|
||||
|
||||
@@ -0,0 +1,26 @@
|
||||
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,16 +1,11 @@
|
||||
import React, { useCallback, useEffect, useMemo, useState } from "react";
|
||||
import React, { useEffect, useMemo, useState } from "react";
|
||||
import useDocusaurusContext from "@docusaurus/useDocusaurusContext";
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
|
||||
import {
|
||||
WORKSHOP_API_URL,
|
||||
WorkshopClient,
|
||||
type WorkshopAccountState,
|
||||
type WorkshopModerationAction,
|
||||
type WorkshopReport,
|
||||
type WorkshopRole,
|
||||
type WorkshopUser,
|
||||
} from "@homarr/workshop";
|
||||
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 =
|
||||
@@ -19,53 +14,96 @@ type PendingAction =
|
||||
| { 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) ?? WORKSHOP_API_URL),
|
||||
() => new WorkshopClient((siteConfig.customFields?.workshopApiUrl as string | undefined) || window.location.origin),
|
||||
[siteConfig],
|
||||
);
|
||||
const queryClient = useQueryClient();
|
||||
const [user, setUser] = useState<WorkshopUser | null>(null);
|
||||
const [reports, setReports] = useState<WorkshopReport[]>([]);
|
||||
const [users, setUsers] = useState<WorkshopUser[]>([]);
|
||||
const [actions, setActions] = useState<WorkshopModerationAction[]>([]);
|
||||
const [pending, setPending] = useState<PendingAction | null>(null);
|
||||
const [error, setError] = useState("");
|
||||
const [authLoading, setAuthLoading] = useState(true);
|
||||
|
||||
const load = useCallback(async () => {
|
||||
const current = await client.refreshAuth();
|
||||
setUser(current);
|
||||
if (!current || (current.role !== "moderator" && current.role !== "admin")) return;
|
||||
try {
|
||||
const [nextReports, nextUsers, nextActions] = await Promise.all([
|
||||
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(),
|
||||
]);
|
||||
setReports(nextReports);
|
||||
setUsers(nextUsers);
|
||||
setActions(nextActions);
|
||||
} catch (caught) {
|
||||
setError(caught instanceof Error ? caught.message : "Could not load moderation data");
|
||||
}
|
||||
}, [client]);
|
||||
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 load();
|
||||
}, [load]);
|
||||
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"
|
||||
onClick={async () => {
|
||||
await client.signInWithGitHub();
|
||||
await load();
|
||||
disabled={signInMutation.isPending}
|
||||
onClick={() => {
|
||||
setError("");
|
||||
signInMutation.mutate();
|
||||
}}
|
||||
>
|
||||
Sign in with GitHub
|
||||
{signInMutation.isPending ? "Connecting…" : "Sign in with GitHub"}
|
||||
</button>
|
||||
</main>
|
||||
);
|
||||
@@ -87,10 +125,12 @@ export function WorkshopAdmin() {
|
||||
<p>Review reports and apply the smallest action needed. Every action requires a reason and is recorded.</p>
|
||||
</div>
|
||||
</header>
|
||||
{error && (
|
||||
<div className={styles.error} role="alert">
|
||||
{error}
|
||||
</div>
|
||||
{(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>
|
||||
@@ -155,17 +195,6 @@ export function WorkshopAdmin() {
|
||||
"Current account"
|
||||
) : (
|
||||
<div className={styles.rowActions}>
|
||||
<button
|
||||
onClick={() =>
|
||||
setPending({
|
||||
kind: "state",
|
||||
id: account.id,
|
||||
state: account.state === "posting_banned" ? "active" : "posting_banned",
|
||||
})
|
||||
}
|
||||
>
|
||||
{account.state === "posting_banned" ? "Lift posting ban" : "Posting ban"}
|
||||
</button>
|
||||
<button
|
||||
onClick={() =>
|
||||
setPending({
|
||||
@@ -230,19 +259,22 @@ export function WorkshopAdmin() {
|
||||
<ReasonDialog
|
||||
action={pending}
|
||||
onClose={() => setPending(null)}
|
||||
onConfirm={async (reason) => {
|
||||
if (pending.kind === "report") await client.resolveReport(pending.id, pending.status, reason);
|
||||
else if (pending.kind === "state") await client.updateAccountState(pending.id, pending.state, reason);
|
||||
else await client.updateRole(pending.id, pending.role, reason);
|
||||
setPending(null);
|
||||
await load();
|
||||
}}
|
||||
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,
|
||||
@@ -256,45 +288,43 @@ function ReasonDialog({
|
||||
const [error, setError] = useState("");
|
||||
const [saving, setSaving] = useState(false);
|
||||
return (
|
||||
<div className={styles.overlay}>
|
||||
<dialog className={styles.dialog} open aria-labelledby="moderation-reason-title">
|
||||
<h2 id="moderation-reason-title">Confirm moderation action</h2>
|
||||
<p>
|
||||
This will apply <strong>{action.kind}</strong> to the selected record.
|
||||
</p>
|
||||
<form
|
||||
onSubmit={async (event) => {
|
||||
event.preventDefault();
|
||||
setSaving(true);
|
||||
try {
|
||||
await onConfirm(reason);
|
||||
} catch (caught) {
|
||||
setError(caught instanceof Error ? caught.message : "Action failed");
|
||||
setSaving(false);
|
||||
}
|
||||
}}
|
||||
>
|
||||
<label>
|
||||
Reason
|
||||
<textarea
|
||||
required
|
||||
minLength={3}
|
||||
maxLength={1000}
|
||||
value={reason}
|
||||
onChange={(event) => setReason(event.target.value)}
|
||||
/>
|
||||
</label>
|
||||
{error && <p className={styles.error}>{error}</p>}
|
||||
<div className={styles.formActions}>
|
||||
<button type="button" className="button button--secondary" onClick={onClose}>
|
||||
Cancel
|
||||
</button>
|
||||
<button disabled={saving} className="button button--danger">
|
||||
{saving ? "Applying…" : "Apply action"}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</dialog>
|
||||
</div>
|
||||
<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>
|
||||
);
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
import React, { useCallback, useEffect, useMemo, useState } from "react";
|
||||
import React, { useEffect, useMemo, useState } from "react";
|
||||
import Link from "@docusaurus/Link";
|
||||
import { useHistory, useLocation } from "@docusaurus/router";
|
||||
import useDocusaurusContext from "@docusaurus/useDocusaurusContext";
|
||||
@@ -8,12 +8,16 @@ import {
|
||||
IconCopy,
|
||||
IconDownload,
|
||||
IconFlag,
|
||||
IconHeart,
|
||||
IconPackage,
|
||||
IconSearch,
|
||||
IconSparkles,
|
||||
IconUpload,
|
||||
} from "@tabler/icons-react";
|
||||
|
||||
import {
|
||||
WORKSHOP_API_URL,
|
||||
MAX_CONTENT_LENGTH,
|
||||
MAX_SCREENSHOTS,
|
||||
WorkshopClient,
|
||||
type WorkshopSubmissionDetail,
|
||||
type WorkshopSubmissionInput,
|
||||
@@ -22,12 +26,15 @@ import {
|
||||
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) ?? WORKSHOP_API_URL;
|
||||
const apiUrl = (siteConfig.customFields?.workshopApiUrl as string | undefined) || window.location.origin;
|
||||
return useMemo(() => new WorkshopClient(apiUrl), [apiUrl]);
|
||||
};
|
||||
|
||||
@@ -43,56 +50,75 @@ const saveContent = (submission: WorkshopSubmissionDetail) => {
|
||||
};
|
||||
|
||||
export function WorkshopApp() {
|
||||
return (
|
||||
<WorkshopQueryProvider>
|
||||
<WorkshopAppContent />
|
||||
</WorkshopQueryProvider>
|
||||
);
|
||||
}
|
||||
|
||||
function WorkshopAppContent() {
|
||||
const client = useWorkshop();
|
||||
const queryClient = useQueryClient();
|
||||
const [user, setUser] = useState<WorkshopUser | null>(null);
|
||||
const [items, setItems] = useState<WorkshopSubmissionSummary[]>([]);
|
||||
const [page, setPage] = useState(1);
|
||||
const [totalPages, setTotalPages] = useState(1);
|
||||
const [type, setType] = useState<WorkshopSubmissionType | "all">("all");
|
||||
const [sort, setSort] = useState<"top" | "newest">("top");
|
||||
const [search, setSearch] = useState("");
|
||||
const [debouncedSearch, setDebouncedSearch] = useState("");
|
||||
const [mine, setMine] = useState(false);
|
||||
const [loading, setLoading] = useState(true);
|
||||
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]);
|
||||
|
||||
const load = useCallback(async () => {
|
||||
setLoading(true);
|
||||
setError("");
|
||||
try {
|
||||
await client.refreshAuth();
|
||||
const result = await client.list({ page, type, sort, search, author: mine ? client.currentUser?.id : undefined });
|
||||
setItems(result.items);
|
||||
setTotalPages(result.totalPages);
|
||||
} catch (caught) {
|
||||
setError(caught instanceof Error ? caught.message : "Workshop is unavailable");
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [client, mine, page, search, sort, type]);
|
||||
|
||||
useEffect(() => {
|
||||
const timeout = window.setTimeout(() => void load(), 250);
|
||||
void client.refreshAuth().catch(() => undefined);
|
||||
}, [client]);
|
||||
useEffect(() => {
|
||||
const timeout = window.setTimeout(() => setDebouncedSearch(search), 250);
|
||||
return () => window.clearTimeout(timeout);
|
||||
}, [load]);
|
||||
}, [search]);
|
||||
|
||||
const signIn = async () => {
|
||||
try {
|
||||
await client.signInWithGitHub();
|
||||
await load();
|
||||
} catch (caught) {
|
||||
setError(caught instanceof Error ? caught.message : "Sign-in failed");
|
||||
}
|
||||
};
|
||||
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>Community widgets and CSS themes, ready to inspect and use in Homarr.</p>
|
||||
<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 ? (
|
||||
@@ -110,8 +136,12 @@ export function WorkshopApp() {
|
||||
</button>
|
||||
</>
|
||||
) : (
|
||||
<button className="button button--primary" onClick={() => void signIn()}>
|
||||
<IconBrandGithub size={18} /> Sign in with GitHub
|
||||
<button
|
||||
className="button button--primary"
|
||||
disabled={signInMutation.isPending}
|
||||
onClick={() => signInMutation.mutate()}
|
||||
>
|
||||
<IconBrandGithub size={18} /> {signInMutation.isPending ? "Connecting…" : "Sign in with GitHub"}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
@@ -179,12 +209,14 @@ export function WorkshopApp() {
|
||||
)}
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<div className={styles.error} role="alert">
|
||||
{error} <button onClick={() => void load()}>Try again</button>
|
||||
</div>
|
||||
{(error || listQuery.isError || listQuery.fetchStatus === "paused") && (
|
||||
<WorkshopServiceState
|
||||
cached={items.length > 0}
|
||||
message={error || undefined}
|
||||
onRetry={() => void listQuery.refetch()}
|
||||
/>
|
||||
)}
|
||||
{loading ? (
|
||||
{listQuery.isPending ? (
|
||||
<WorkshopSkeleton />
|
||||
) : items.length ? (
|
||||
<div className={styles.grid}>
|
||||
@@ -192,10 +224,22 @@ export function WorkshopApp() {
|
||||
<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>Adjust the filters, or share the first creation in this category.</p>
|
||||
<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>
|
||||
)}
|
||||
|
||||
@@ -218,7 +262,7 @@ export function WorkshopApp() {
|
||||
onClose={() => setShowForm(false)}
|
||||
onSaved={async () => {
|
||||
setShowForm(false);
|
||||
await load();
|
||||
await queryClient.invalidateQueries({ queryKey: ["workshop"] });
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
@@ -246,7 +290,9 @@ function WorkshopCard({ client, item }: { client: WorkshopClient; item: Workshop
|
||||
</span>
|
||||
</div>
|
||||
<h2>
|
||||
<Link to={`/workshop/${item.id}`}>{item.title}</Link>
|
||||
<Link className={styles.cardLink} to={`/workshop/${item.id}`}>
|
||||
{item.title}
|
||||
</Link>
|
||||
</h2>
|
||||
<p>{item.description || "No description provided."}</p>
|
||||
<small>
|
||||
@@ -258,42 +304,64 @@ function WorkshopCard({ client, item }: { client: WorkshopClient; item: Workshop
|
||||
}
|
||||
|
||||
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 [submission, setSubmission] = useState<WorkshopSubmissionDetail | null>(null);
|
||||
const [error, setError] = useState("");
|
||||
const [reporting, setReporting] = useState(false);
|
||||
const [editing, setEditing] = useState(false);
|
||||
const [moderating, setModerating] = useState(false);
|
||||
const [actionError, setActionError] = useState("");
|
||||
|
||||
const load = useCallback(async () => {
|
||||
try {
|
||||
setSubmission(await client.get(id));
|
||||
} catch (caught) {
|
||||
setError(caught instanceof Error ? caught.message : "Submission not found");
|
||||
}
|
||||
}, [client, id]);
|
||||
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();
|
||||
void load();
|
||||
}, [client, load]);
|
||||
}, [client]);
|
||||
|
||||
if (error)
|
||||
if (detailQuery.isError)
|
||||
return (
|
||||
<main className={styles.page}>
|
||||
<div className={styles.error} role="alert">
|
||||
{error}
|
||||
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 (!submission) return <WorkshopSkeleton />;
|
||||
if (!detailQuery.data) return <WorkshopSkeleton />;
|
||||
|
||||
const requireAuth = async () => client.currentUser ?? client.signInWithGitHub();
|
||||
const submission = detailQuery.data;
|
||||
return (
|
||||
<main className={styles.page}>
|
||||
<Link to="/workshop">← Back to Workshop</Link>
|
||||
@@ -329,10 +397,10 @@ export function WorkshopDetailRoute() {
|
||||
</button>
|
||||
<button
|
||||
className="button button--secondary"
|
||||
onClick={async () => {
|
||||
await requireAuth();
|
||||
await client.vote(submission.id, 1);
|
||||
await load();
|
||||
disabled={voteMutation.isPending}
|
||||
onClick={() => {
|
||||
setActionError("");
|
||||
voteMutation.mutate();
|
||||
}}
|
||||
>
|
||||
Upvote ({submission.upvotes})
|
||||
@@ -347,10 +415,11 @@ export function WorkshopDetailRoute() {
|
||||
</button>
|
||||
<button
|
||||
className="button button--danger"
|
||||
onClick={async () => {
|
||||
disabled={deleteMutation.isPending}
|
||||
onClick={() => {
|
||||
if (window.confirm("Delete this submission permanently?")) {
|
||||
await client.delete(submission.id);
|
||||
history.push("/workshop");
|
||||
setActionError("");
|
||||
deleteMutation.mutate();
|
||||
}
|
||||
}}
|
||||
>
|
||||
@@ -364,6 +433,11 @@ export function WorkshopDetailRoute() {
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
{actionError && (
|
||||
<div className={styles.error} role="alert">
|
||||
{actionError}
|
||||
</div>
|
||||
)}
|
||||
<section>
|
||||
<h2>Source</h2>
|
||||
<p className={styles.sourceHint}>
|
||||
@@ -388,7 +462,7 @@ export function WorkshopDetailRoute() {
|
||||
onClose={() => setEditing(false)}
|
||||
onSaved={async () => {
|
||||
setEditing(false);
|
||||
await load();
|
||||
await queryClient.invalidateQueries({ queryKey: ["workshop", "detail", id] });
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
@@ -416,51 +490,49 @@ function ModerationRemovalForm({
|
||||
onRemoved: () => void;
|
||||
}) {
|
||||
const [reason, setReason] = useState("");
|
||||
const [error, setError] = useState("");
|
||||
const [pending, setPending] = useState(false);
|
||||
const removeMutation = useMutation({
|
||||
mutationKey: ["workshop", "moderation", "remove", submission.id],
|
||||
mutationFn: () => client.removeSubmission(submission.id, reason),
|
||||
onSuccess: onRemoved,
|
||||
});
|
||||
return (
|
||||
<div className={styles.overlay}>
|
||||
<dialog className={styles.dialog} open aria-labelledby="remove-submission-title">
|
||||
<h2 id="remove-submission-title">Remove {submission.title}</h2>
|
||||
<p>
|
||||
This permanently deletes the submission, files, votes, and reports. A metadata snapshot is retained in
|
||||
moderation history.
|
||||
</p>
|
||||
<form
|
||||
onSubmit={async (event) => {
|
||||
event.preventDefault();
|
||||
setPending(true);
|
||||
try {
|
||||
await client.removeSubmission(submission.id, reason);
|
||||
onRemoved();
|
||||
} catch (caught) {
|
||||
setError(caught instanceof Error ? caught.message : "Removal failed");
|
||||
setPending(false);
|
||||
}
|
||||
}}
|
||||
>
|
||||
<label>
|
||||
Reason
|
||||
<textarea
|
||||
required
|
||||
minLength={3}
|
||||
maxLength={1000}
|
||||
value={reason}
|
||||
onChange={(event) => setReason(event.target.value)}
|
||||
/>
|
||||
</label>
|
||||
{error && <p className={styles.error}>{error}</p>}
|
||||
<div className={styles.formActions}>
|
||||
<button type="button" className="button button--secondary" onClick={onClose}>
|
||||
Cancel
|
||||
</button>
|
||||
<button disabled={pending} className="button button--danger">
|
||||
{pending ? "Removing…" : "Remove permanently"}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</dialog>
|
||||
</div>
|
||||
<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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -482,117 +554,112 @@ function SubmissionForm({
|
||||
const [changelog, setChangelog] = useState(existing?.changelog ?? "");
|
||||
const [screenshots, setScreenshots] = useState<File[]>([]);
|
||||
const [removedScreenshots, setRemovedScreenshots] = useState<string[]>([]);
|
||||
const [error, setError] = useState("");
|
||||
const [pending, setPending] = useState(false);
|
||||
const submit = async (event: React.FormEvent) => {
|
||||
event.preventDefault();
|
||||
setPending(true);
|
||||
setError("");
|
||||
const input: WorkshopSubmissionInput = { type, title, description, content, changelog };
|
||||
try {
|
||||
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) {
|
||||
await client.update(existing.id, input, screenshots, removedScreenshots);
|
||||
} else {
|
||||
await client.create(input, screenshots);
|
||||
return client.update(existing.id, input, screenshots, removedScreenshots);
|
||||
}
|
||||
await onSaved();
|
||||
} catch (caught) {
|
||||
setError(caught instanceof Error ? caught.message : "Could not save submission");
|
||||
} finally {
|
||||
setPending(false);
|
||||
}
|
||||
};
|
||||
return client.create(input, screenshots);
|
||||
},
|
||||
onSuccess: onSaved,
|
||||
});
|
||||
return (
|
||||
<div
|
||||
className={styles.overlay}
|
||||
role="presentation"
|
||||
onMouseDown={(event) => {
|
||||
if (event.target === event.currentTarget) onClose();
|
||||
}}
|
||||
>
|
||||
<dialog className={styles.dialog} open aria-labelledby="submission-form-title">
|
||||
<h2 id="submission-form-title">{existing ? "Update creation" : "Share with the community"}</h2>
|
||||
<form onSubmit={(event) => void submit(event)}>
|
||||
<label>
|
||||
Type
|
||||
<select
|
||||
disabled={Boolean(existing)}
|
||||
value={type}
|
||||
onChange={(event) => setType(event.target.value as WorkshopSubmissionType)}
|
||||
>
|
||||
<option value="widget">Custom widget</option>
|
||||
<option value="css">CSS theme</option>
|
||||
</select>
|
||||
</label>
|
||||
<label>
|
||||
Title
|
||||
<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
|
||||
required
|
||||
minLength={3}
|
||||
maxLength={100}
|
||||
value={title}
|
||||
onChange={(event) => setTitle(event.target.value)}
|
||||
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>
|
||||
<label>
|
||||
Description
|
||||
<textarea maxLength={2000} value={description} onChange={(event) => setDescription(event.target.value)} />
|
||||
</label>
|
||||
<label>
|
||||
{type === "widget" ? "Widget JSON" : "CSS source"}
|
||||
<textarea
|
||||
className={styles.editor}
|
||||
required
|
||||
value={content}
|
||||
onChange={(event) => setContent(event.target.value)}
|
||||
/>
|
||||
</label>
|
||||
<label>
|
||||
Changelog
|
||||
<textarea maxLength={2000} value={changelog} onChange={(event) => setChangelog(event.target.value)} />
|
||||
</label>
|
||||
<label>
|
||||
Screenshots (PNG, JPG, or WebP, up to 5)
|
||||
<input
|
||||
type="file"
|
||||
accept="image/png,image/jpeg,image/webp"
|
||||
multiple
|
||||
onChange={(event) => setScreenshots(Array.from(event.target.files ?? []).slice(0, 5))}
|
||||
/>
|
||||
</label>
|
||||
{existing?.screenshots.map((screenshot) => (
|
||||
<label className={styles.checkbox} key={screenshot}>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={removedScreenshots.includes(screenshot)}
|
||||
onChange={(event) =>
|
||||
setRemovedScreenshots((current) =>
|
||||
event.target.checked
|
||||
? [...current, screenshot]
|
||||
: current.filter((filename) => filename !== screenshot),
|
||||
)
|
||||
}
|
||||
/>
|
||||
Remove existing screenshot {screenshot}
|
||||
</label>
|
||||
))}
|
||||
{error && (
|
||||
<p className={styles.error} role="alert">
|
||||
{error}
|
||||
</p>
|
||||
)}
|
||||
<div className={styles.formActions}>
|
||||
<button type="button" className="button button--secondary" onClick={onClose}>
|
||||
Cancel
|
||||
</button>
|
||||
<button disabled={pending} className="button button--primary">
|
||||
{pending ? "Saving…" : existing ? "Save update" : "Publish creation"}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</dialog>
|
||||
</div>
|
||||
))}
|
||||
{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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -607,53 +674,58 @@ function ReportForm({
|
||||
}) {
|
||||
const [category, setCategory] = useState<"malicious" | "spam" | "copyright" | "inappropriate" | "other">("malicious");
|
||||
const [explanation, setExplanation] = useState("");
|
||||
const [error, setError] = 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 (
|
||||
<div className={styles.overlay}>
|
||||
<dialog className={styles.dialog} open aria-labelledby="report-title">
|
||||
<h2 id="report-title">Report submission</h2>
|
||||
<form
|
||||
onSubmit={async (event) => {
|
||||
event.preventDefault();
|
||||
try {
|
||||
if (!client.currentUser) await client.signInWithGitHub();
|
||||
await client.report(submissionId, category, explanation);
|
||||
onClose();
|
||||
} catch (caught) {
|
||||
setError(caught instanceof Error ? caught.message : "Could not send report");
|
||||
}
|
||||
}}
|
||||
>
|
||||
<label>
|
||||
Reason
|
||||
<select value={category} onChange={(event) => setCategory(event.target.value as typeof category)}>
|
||||
<option value="malicious">Malicious</option>
|
||||
<option value="spam">Spam</option>
|
||||
<option value="copyright">Copyright</option>
|
||||
<option value="inappropriate">Inappropriate</option>
|
||||
<option value="other">Other</option>
|
||||
</select>
|
||||
</label>
|
||||
<label>
|
||||
Details
|
||||
<textarea
|
||||
required
|
||||
minLength={3}
|
||||
maxLength={1000}
|
||||
value={explanation}
|
||||
onChange={(event) => setExplanation(event.target.value)}
|
||||
/>
|
||||
</label>
|
||||
{error && <p className={styles.error}>{error}</p>}
|
||||
<div className={styles.formActions}>
|
||||
<button type="button" className="button button--secondary" onClick={onClose}>
|
||||
Cancel
|
||||
</button>
|
||||
<button className="button button--primary">Submit report</button>
|
||||
</div>
|
||||
</form>
|
||||
</dialog>
|
||||
</div>
|
||||
<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>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,43 @@
|
||||
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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,69 @@
|
||||
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>
|
||||
);
|
||||
}
|
||||
@@ -1,164 +1,343 @@
|
||||
.page {
|
||||
max-width: 1240px;
|
||||
--workshop-ease: cubic-bezier(0.25, 1, 0.5, 1);
|
||||
max-width: 1180px;
|
||||
min-height: 70vh;
|
||||
margin: 0 auto;
|
||||
padding: 2.5rem 1.25rem 5rem;
|
||||
padding: clamp(2rem, 5vw, 4.5rem) 1.25rem 5rem;
|
||||
}
|
||||
|
||||
.hero {
|
||||
display: flex;
|
||||
align-items: flex-end;
|
||||
justify-content: space-between;
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1fr) auto;
|
||||
align-items: center;
|
||||
gap: 2rem;
|
||||
margin-bottom: 1.5rem;
|
||||
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: 2.25rem;
|
||||
letter-spacing: -0.025em;
|
||||
font-size: clamp(2.35rem, 6vw, 4.25rem);
|
||||
line-height: 0.98;
|
||||
letter-spacing: -0.045em;
|
||||
}
|
||||
|
||||
.hero p {
|
||||
max-width: 65ch;
|
||||
margin: 0.5rem 0 0;
|
||||
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 {
|
||||
.formActions,
|
||||
.serviceStateActions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.75rem;
|
||||
}
|
||||
|
||||
.heroPromises li {
|
||||
gap: 0.35rem;
|
||||
}
|
||||
|
||||
.accountActions,
|
||||
.detailActions,
|
||||
.formActions,
|
||||
.serviceStateActions {
|
||||
flex-wrap: wrap;
|
||||
gap: 0.7rem;
|
||||
}
|
||||
|
||||
.accountActions :global(.button),
|
||||
.detailActions :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.85rem 1rem;
|
||||
margin-bottom: 1.5rem;
|
||||
border: 1px solid var(--ifm-color-warning-dark);
|
||||
border-radius: 10px;
|
||||
background: color-mix(in srgb, var(--ifm-color-warning) 10%, transparent);
|
||||
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: flex;
|
||||
display: grid;
|
||||
grid-template-columns: minmax(260px, 1fr) 150px 150px auto;
|
||||
align-items: end;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.75rem;
|
||||
padding: 0.75rem;
|
||||
margin-bottom: 1.5rem;
|
||||
padding: 0.85rem;
|
||||
margin-bottom: 1.25rem;
|
||||
border: 1px solid var(--ifm-color-emphasis-300);
|
||||
border-radius: 10px;
|
||||
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.3rem;
|
||||
font-size: 0.8rem;
|
||||
font-weight: 600;
|
||||
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.55rem 0.7rem;
|
||||
padding: 0.6rem 0.72rem;
|
||||
border: 1px solid var(--ifm-color-emphasis-400);
|
||||
border-radius: 7px;
|
||||
border-radius: 9px;
|
||||
color: var(--ifm-font-color-base);
|
||||
background: var(--ifm-background-surface-color);
|
||||
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;
|
||||
flex: 1 1 280px;
|
||||
}
|
||||
|
||||
.search svg {
|
||||
position: absolute;
|
||||
left: 0.75rem;
|
||||
bottom: 0.8rem;
|
||||
z-index: 1;
|
||||
left: 0.8rem;
|
||||
bottom: 0.82rem;
|
||||
color: var(--ifm-color-emphasis-600);
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.search input {
|
||||
width: 100%;
|
||||
padding-left: 2.25rem;
|
||||
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-fit, minmax(270px, 1fr));
|
||||
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: 12px;
|
||||
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;
|
||||
background: var(--ifm-color-emphasis-100);
|
||||
color: var(--ifm-color-emphasis-600);
|
||||
font: 700 1.5rem var(--ifm-font-family-monospace);
|
||||
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.4rem 0;
|
||||
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.65rem;
|
||||
margin: 0 0 0.7rem;
|
||||
color: var(--ifm-color-emphasis-700);
|
||||
font-size: 0.9rem;
|
||||
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.75rem;
|
||||
font-weight: 700;
|
||||
font-size: 0.72rem;
|
||||
font-weight: 750;
|
||||
letter-spacing: 0.035em;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.error {
|
||||
padding: 0.85rem 1rem;
|
||||
margin: 1rem 0;
|
||||
border-radius: 8px;
|
||||
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 {
|
||||
padding: 4rem 1rem;
|
||||
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: 12px;
|
||||
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;
|
||||
@@ -166,134 +345,248 @@
|
||||
gap: 1rem;
|
||||
margin-top: 2rem;
|
||||
}
|
||||
|
||||
.pagination button {
|
||||
min-height: 44px;
|
||||
padding: 0.5rem 0.8rem;
|
||||
padding: 0.5rem 0.85rem;
|
||||
}
|
||||
|
||||
.skeleton {
|
||||
min-height: 310px;
|
||||
border-radius: 12px;
|
||||
border-radius: 14px;
|
||||
background: var(--ifm-color-emphasis-200);
|
||||
animation: pulse 1.5s ease-in-out infinite;
|
||||
}
|
||||
|
||||
.detail {
|
||||
margin-top: 1.5rem;
|
||||
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;
|
||||
margin-bottom: 0.5rem;
|
||||
color: var(--ifm-color-primary);
|
||||
font-weight: 700;
|
||||
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-radius: 10px;
|
||||
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-radius: 8px;
|
||||
border: 1px solid var(--ifm-color-emphasis-300);
|
||||
border-radius: 10px;
|
||||
font-size: 0.78rem;
|
||||
}
|
||||
.overlay {
|
||||
position: fixed;
|
||||
z-index: 500;
|
||||
inset: 0;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
padding: 1rem;
|
||||
background: rgb(0 0 0 / 0.7);
|
||||
}
|
||||
|
||||
.dialog {
|
||||
width: min(680px, 100%);
|
||||
max-height: 90vh;
|
||||
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: 1.25rem;
|
||||
border-radius: 12px;
|
||||
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: 88px;
|
||||
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;
|
||||
}
|
||||
@keyframes pulse {
|
||||
50% {
|
||||
opacity: 0.55;
|
||||
}
|
||||
}
|
||||
@media (max-width: 700px) {
|
||||
.hero {
|
||||
align-items: stretch;
|
||||
flex-direction: column;
|
||||
}
|
||||
.accountActions > * {
|
||||
flex: 1;
|
||||
justify-content: center;
|
||||
}
|
||||
.toolbar > label {
|
||||
flex: 1 1 100%;
|
||||
}
|
||||
}
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.skeleton {
|
||||
animation: none;
|
||||
}
|
||||
}
|
||||
|
||||
.adminSection {
|
||||
margin: 2rem 0;
|
||||
}
|
||||
|
||||
.tableWrap {
|
||||
overflow-x: auto;
|
||||
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.7rem;
|
||||
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;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
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
@@ -0,0 +1,46 @@
|
||||
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();
|
||||
}),
|
||||
);
|
||||
});
|
||||
@@ -11,7 +11,6 @@ import { IconInfoCircle } from "@tabler/icons-react";
|
||||
import { useForm } from "@homarr/form";
|
||||
import { useI18n, useScopedI18n } from "@homarr/translation/client";
|
||||
|
||||
import { PublishToWorkshopButton } from "~/components/workshop/workshop-browser";
|
||||
import { UseCssFromWorkshopButton } from "~/components/workshop/workshop-actions";
|
||||
|
||||
import type { Board } from "../../_types";
|
||||
@@ -49,10 +48,7 @@ export const CustomCssSettingsContent = ({ board }: Props) => {
|
||||
</Alert>
|
||||
|
||||
<Group justify="space-between">
|
||||
<Group>
|
||||
<UseCssFromWorkshopButton onUse={(css) => form.setFieldValue("customCss", css)} />
|
||||
<PublishToWorkshopButton type="css" defaultTitle={board.name} getContent={() => form.values.customCss} />
|
||||
</Group>
|
||||
<UseCssFromWorkshopButton onUse={(css) => form.setFieldValue("customCss", css)} />
|
||||
<Button type="submit" loading={isPending}>
|
||||
{t("common.action.saveChanges")}
|
||||
</Button>
|
||||
|
||||
@@ -6,7 +6,6 @@ import {
|
||||
IconCopy,
|
||||
IconDots,
|
||||
IconDownload,
|
||||
IconShare3,
|
||||
IconToggleLeft,
|
||||
IconToggleRight,
|
||||
IconTrash,
|
||||
@@ -20,8 +19,6 @@ import { showErrorNotification, showSuccessNotification } from "@homarr/notifica
|
||||
import { useScopedI18n } from "@homarr/translation/client";
|
||||
|
||||
import { MobileAffixButton } from "~/components/manage/mobile-affix-button";
|
||||
import { PublishToWorkshopButton } from "~/components/workshop/workshop-browser";
|
||||
|
||||
const iconProps = { size: 16, stroke: 1.5 };
|
||||
|
||||
interface WidgetRef {
|
||||
@@ -32,7 +29,6 @@ interface WidgetRef {
|
||||
|
||||
export const CustomWidgetRowActions = ({ widget }: { widget: WidgetRef }) => {
|
||||
const t = useScopedI18n("customWidget");
|
||||
const workshopT = useScopedI18n("workshop");
|
||||
const { openConfirmModal } = useConfirmModal();
|
||||
const deleteMutation = clientApi.customWidget.delete.useMutation();
|
||||
const duplicateMutation = clientApi.customWidget.duplicate.useMutation();
|
||||
@@ -144,16 +140,6 @@ export const CustomWidgetRowActions = ({ widget }: { widget: WidgetRef }) => {
|
||||
<Menu.Item onClick={() => void handleExport()} leftSection={<IconDownload {...iconProps} />}>
|
||||
{t("action.export")}
|
||||
</Menu.Item>
|
||||
<PublishToWorkshopButton
|
||||
type="widget"
|
||||
defaultTitle={widget.name}
|
||||
getContent={async () => JSON.stringify(await utils.customWidget.export.fetch({ id: widget.id }), null, 2)}
|
||||
renderTrigger={(open) => (
|
||||
<Menu.Item onClick={open} leftSection={<IconShare3 {...iconProps} />}>
|
||||
{workshopT("action.share")}
|
||||
</Menu.Item>
|
||||
)}
|
||||
/>
|
||||
<Menu.Divider />
|
||||
<Menu.Item
|
||||
color="red"
|
||||
|
||||
@@ -6,6 +6,7 @@ import {
|
||||
AccordionPanel,
|
||||
ActionIcon,
|
||||
ActionIconGroup,
|
||||
Alert,
|
||||
Anchor,
|
||||
Divider,
|
||||
Group,
|
||||
@@ -18,14 +19,14 @@ import {
|
||||
TableTr,
|
||||
Text,
|
||||
} from "@mantine/core";
|
||||
import { IconPencil, IconPlugX } from "@tabler/icons-react";
|
||||
import { IconAlertTriangle, 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 } from "@homarr/definitions";
|
||||
import { getIntegrationName, isIntegrationKind } from "@homarr/definitions";
|
||||
import { getScopedI18n } from "@homarr/translation/server";
|
||||
import { CountBadge, IntegrationAvatar, Link } from "@homarr/ui";
|
||||
|
||||
@@ -87,11 +88,14 @@ const IntegrationList = async ({ integrations, activeTab }: IntegrationListProps
|
||||
const session = await auth();
|
||||
const hasFullAccess = session?.user.permissions.includes("integration-full-all") ?? false;
|
||||
|
||||
if (integrations.length === 0) {
|
||||
const supportedIntegrations = integrations.filter((integration) => isIntegrationKind(integration.kind));
|
||||
const unsupportedCount = integrations.length - supportedIntegrations.length;
|
||||
|
||||
if (supportedIntegrations.length === 0 && unsupportedCount === 0) {
|
||||
return <NoResults icon={IconPlugX} title={t("page.list.noResults.title")} />;
|
||||
}
|
||||
|
||||
const groupedIntegrations = integrations.reduce(
|
||||
const groupedIntegrations = supportedIntegrations.reduce(
|
||||
(acc, integration) => {
|
||||
// eslint-disable-next-line @typescript-eslint/no-unnecessary-condition
|
||||
if (!acc[integration.kind]) {
|
||||
@@ -108,88 +112,105 @@ const IntegrationList = async ({ integrations, activeTab }: IntegrationListProps
|
||||
const entries = objectEntries(groupedIntegrations);
|
||||
|
||||
return (
|
||||
<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>
|
||||
{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>
|
||||
|
||||
<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 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>
|
||||
);
|
||||
};
|
||||
@@ -179,8 +179,8 @@ export async function POST(req: Request) {
|
||||
}
|
||||
|
||||
setTimeout(() => {
|
||||
console.log("Database restored, restarting server...");
|
||||
process.exit(0);
|
||||
console.log("Database restored, restarting server and applying migrations...");
|
||||
process.exit(101);
|
||||
}, 500);
|
||||
|
||||
return NextResponse.json({ success: true, message: "Database restored. Server is restarting..." });
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
"use client";
|
||||
|
||||
import type { ReactNode } from "react";
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import {
|
||||
Alert,
|
||||
@@ -12,6 +11,7 @@ import {
|
||||
Group,
|
||||
Image,
|
||||
Modal,
|
||||
Paper,
|
||||
Pagination,
|
||||
Select,
|
||||
SimpleGrid,
|
||||
@@ -19,17 +19,25 @@ import {
|
||||
Stack,
|
||||
Text,
|
||||
TextInput,
|
||||
Textarea,
|
||||
} from "@mantine/core";
|
||||
import { useDebouncedValue, useDisclosure } from "@mantine/hooks";
|
||||
import { IconAlertTriangle, IconBrandGithub, IconDownload, IconExternalLink, IconSearch } from "@tabler/icons-react";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { 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 {
|
||||
@@ -39,7 +47,15 @@ export interface WorkshopBrowserProps {
|
||||
useLabel?: string;
|
||||
}
|
||||
|
||||
export function WorkshopBrowser({ initialType = "all", lockedType, onUse, useLabel }: WorkshopBrowserProps) {
|
||||
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);
|
||||
@@ -47,24 +63,41 @@ export function WorkshopBrowser({ initialType = "all", lockedType, onUse, useLab
|
||||
const [sort, setSort] = useState<"top" | "newest">("top");
|
||||
const [search, setSearch] = useState("");
|
||||
const [debouncedSearch] = useDebouncedValue(search, 250);
|
||||
const [selected, setSelected] = useState<WorkshopSubmissionDetail | null>(null);
|
||||
const [detailLoading, setDetailLoading] = useState(false);
|
||||
const [usePending, setUsePending] = useState(false);
|
||||
const [useError, setUseError] = useState("");
|
||||
const [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: () => client.list({ page, type, sort, search: debouncedSearch, perPage: 12 }),
|
||||
queryFn: ({ signal }) => client.list({ page, type, sort, search: debouncedSearch, perPage: 12, signal }),
|
||||
placeholderData: keepPreviousData,
|
||||
});
|
||||
|
||||
const openDetail = async (id: string) => {
|
||||
setDetailLoading(true);
|
||||
try {
|
||||
setSelected(await client.get(id));
|
||||
} finally {
|
||||
setDetailLoading(false);
|
||||
}
|
||||
};
|
||||
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(
|
||||
@@ -79,10 +112,34 @@ export function WorkshopBrowser({ initialType = "all", lockedType, onUse, useLab
|
||||
|
||||
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>
|
||||
<Group align="end">
|
||||
<SimpleGrid cols={{ base: 1, sm: lockedType ? 2 : 3 }} spacing="sm">
|
||||
<TextInput
|
||||
label={t("search.label")}
|
||||
placeholder={t("search.placeholder")}
|
||||
@@ -92,7 +149,6 @@ export function WorkshopBrowser({ initialType = "all", lockedType, onUse, useLab
|
||||
setSearch(event.currentTarget.value);
|
||||
setPage(1);
|
||||
}}
|
||||
style={{ flex: 1 }}
|
||||
/>
|
||||
<Select
|
||||
label={t("filter.type")}
|
||||
@@ -120,13 +176,36 @@ export function WorkshopBrowser({ initialType = "all", lockedType, onUse, useLab
|
||||
{ value: "newest", label: t("filter.newest") },
|
||||
]}
|
||||
/>
|
||||
</Group>
|
||||
{query.error && (
|
||||
<Alert color="red" title={t("error.loadTitle")}>
|
||||
{query.error.message}
|
||||
<Button variant="subtle" onClick={() => void query.refetch()}>
|
||||
{t("action.retry")}
|
||||
</Button>
|
||||
</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 ? (
|
||||
@@ -173,14 +252,14 @@ export function WorkshopBrowser({ initialType = "all", lockedType, onUse, useLab
|
||||
<Text size="xs" c="dimmed">
|
||||
{t("by", { name: item.authorName, revision: item.revision })}
|
||||
</Text>
|
||||
<Button variant="light" onClick={() => void openDetail(item.id)}>
|
||||
<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>
|
||||
@@ -190,13 +269,25 @@ export function WorkshopBrowser({ initialType = "all", lockedType, onUse, useLab
|
||||
<Pagination total={query.data?.totalPages ?? 1} value={page} onChange={setPage} mx="auto" />
|
||||
)}
|
||||
<Modal
|
||||
opened={Boolean(selected) || detailLoading}
|
||||
onClose={() => setSelected(null)}
|
||||
opened={selectedId !== null}
|
||||
onClose={() => {
|
||||
setSelectedId(null);
|
||||
useMutationState.reset();
|
||||
}}
|
||||
title={selected?.title ?? t("loading")}
|
||||
size="xl"
|
||||
>
|
||||
{detailLoading || !selected ? (
|
||||
{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>
|
||||
@@ -206,6 +297,18 @@ export function WorkshopBrowser({ initialType = "all", lockedType, onUse, useLab
|
||||
</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>
|
||||
@@ -229,111 +332,23 @@ export function WorkshopBrowser({ initialType = "all", lockedType, onUse, useLab
|
||||
</Group>
|
||||
{onUse && (
|
||||
<Button
|
||||
loading={usePending}
|
||||
onClick={async () => {
|
||||
setUsePending(true);
|
||||
setUseError("");
|
||||
try {
|
||||
if (lockedType && selected.type !== lockedType) throw new Error(t("error.wrongType"));
|
||||
await onUse(selected);
|
||||
setSelected(null);
|
||||
} catch (caught) {
|
||||
setUseError(caught instanceof Error ? caught.message : t("error.install"));
|
||||
} finally {
|
||||
setUsePending(false);
|
||||
}
|
||||
}}
|
||||
loading={useMutationState.isPending}
|
||||
disabled={!online}
|
||||
onClick={() => useMutationState.mutate(selected)}
|
||||
>
|
||||
{useLabel ?? t("action.use")}
|
||||
</Button>
|
||||
)}
|
||||
</Group>
|
||||
{useError && <Alert color="red">{useError}</Alert>}
|
||||
{!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>
|
||||
);
|
||||
}
|
||||
|
||||
export function PublishToWorkshopButton({
|
||||
type,
|
||||
getContent,
|
||||
defaultTitle,
|
||||
compact = false,
|
||||
renderTrigger,
|
||||
}: {
|
||||
type: WorkshopSubmissionType;
|
||||
getContent: () => Promise<string> | string;
|
||||
defaultTitle: string;
|
||||
compact?: boolean;
|
||||
renderTrigger?: (open: () => void) => ReactNode;
|
||||
}) {
|
||||
const t = useScopedI18n("workshop");
|
||||
const client = useMemo(() => new WorkshopClient(apiUrl), []);
|
||||
const [opened, { open, close }] = useDisclosure(false);
|
||||
const [title, setTitle] = useState(defaultTitle);
|
||||
const [description, setDescription] = useState("");
|
||||
const [changelog, setChangelog] = useState("");
|
||||
const [error, setError] = useState("");
|
||||
const [pending, setPending] = useState(false);
|
||||
useEffect(() => setTitle(defaultTitle), [defaultTitle]);
|
||||
const publish = async () => {
|
||||
setPending(true);
|
||||
setError("");
|
||||
try {
|
||||
if (!client.currentUser) await client.signInWithGitHub();
|
||||
await client.create({ type, title, description, changelog, content: await getContent() });
|
||||
close();
|
||||
} catch (caught) {
|
||||
setError(caught instanceof Error ? caught.message : t("error.publish"));
|
||||
} finally {
|
||||
setPending(false);
|
||||
}
|
||||
};
|
||||
return (
|
||||
<>
|
||||
{renderTrigger ? (
|
||||
renderTrigger(open)
|
||||
) : (
|
||||
<Button
|
||||
size={compact ? "xs" : undefined}
|
||||
variant="default"
|
||||
leftSection={<IconBrandGithub size={16} />}
|
||||
onClick={open}
|
||||
>
|
||||
{t("action.share")}
|
||||
</Button>
|
||||
)}
|
||||
<Modal opened={opened} onClose={close} title={t("publish.title")}>
|
||||
<Stack>
|
||||
<TextInput
|
||||
required
|
||||
label={t("publish.name")}
|
||||
value={title}
|
||||
onChange={(event) => setTitle(event.currentTarget.value)}
|
||||
/>
|
||||
<Textarea
|
||||
label={t("publish.description")}
|
||||
value={description}
|
||||
onChange={(event) => setDescription(event.currentTarget.value)}
|
||||
/>
|
||||
<Textarea
|
||||
label={t("publish.changelog")}
|
||||
value={changelog}
|
||||
onChange={(event) => setChangelog(event.currentTarget.value)}
|
||||
/>
|
||||
{error && <Alert color="red">{error}</Alert>}
|
||||
<Group justify="end">
|
||||
<Button variant="default" onClick={close}>
|
||||
{t("action.cancel")}
|
||||
</Button>
|
||||
<Button loading={pending} disabled={title.trim().length < 3} onClick={() => void publish()}>
|
||||
{t("action.publish")}
|
||||
</Button>
|
||||
</Group>
|
||||
</Stack>
|
||||
</Modal>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
"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,5 +0,0 @@
|
||||
GITHUB_CLIENT_ID=
|
||||
GITHUB_CLIENT_SECRET=
|
||||
PB_SUPERUSER_EMAIL=
|
||||
PB_SUPERUSER_PASSWORD=
|
||||
PB_EXPOSE_PORT=8090
|
||||
@@ -1,4 +0,0 @@
|
||||
.env
|
||||
.env.*
|
||||
!.env.example
|
||||
pb_data/
|
||||
@@ -1,31 +0,0 @@
|
||||
FROM alpine:3.22 AS download
|
||||
|
||||
ARG TARGETARCH
|
||||
ARG POCKETBASE_VERSION=0.39.6
|
||||
|
||||
RUN apk add --no-cache ca-certificates curl unzip
|
||||
RUN case "${TARGETARCH}" in \
|
||||
amd64) PB_ARCH=amd64 ;; \
|
||||
arm64) PB_ARCH=arm64 ;; \
|
||||
arm) PB_ARCH=armv7 ;; \
|
||||
*) echo "Unsupported architecture: ${TARGETARCH}" >&2; exit 1 ;; \
|
||||
esac && \
|
||||
BASE_URL="https://github.com/pocketbase/pocketbase/releases/download/v${POCKETBASE_VERSION}" && \
|
||||
ARCHIVE="pocketbase_${POCKETBASE_VERSION}_linux_${PB_ARCH}.zip" && \
|
||||
curl --fail --location --silent --show-error "${BASE_URL}/${ARCHIVE}" --output "/tmp/${ARCHIVE}" && \
|
||||
curl --fail --location --silent --show-error "${BASE_URL}/checksums.txt" --output /tmp/checksums.txt && \
|
||||
cd /tmp && grep " ${ARCHIVE}$" checksums.txt | sha256sum -c - && \
|
||||
unzip "/tmp/${ARCHIVE}" pocketbase -d /out
|
||||
|
||||
FROM alpine:3.22
|
||||
|
||||
RUN apk add --no-cache ca-certificates su-exec tzdata && addgroup -S pocketbase && adduser -S -G pocketbase pocketbase
|
||||
COPY --from=download /out/pocketbase /usr/local/bin/pocketbase
|
||||
COPY entrypoint.sh /usr/local/bin/workshop-entrypoint
|
||||
|
||||
RUN chmod +x /usr/local/bin/workshop-entrypoint && mkdir -p /pb_data /pb_hooks /pb_migrations && chown -R pocketbase:pocketbase /pb_data /pb_hooks /pb_migrations
|
||||
|
||||
USER root
|
||||
EXPOSE 8090
|
||||
HEALTHCHECK --interval=30s --timeout=5s --start-period=10s --retries=3 CMD wget -q --spider http://127.0.0.1:8090/api/health || exit 1
|
||||
ENTRYPOINT ["workshop-entrypoint"]
|
||||
@@ -1,45 +0,0 @@
|
||||
# Homarr Workshop backend
|
||||
|
||||
PocketBase provides the central Workshop API at `store.homarr.dev`. It is not a per-instance Homarr service. Local deployment is for development and integration testing.
|
||||
|
||||
## Local startup
|
||||
|
||||
1. Copy `.env.example` to `.env` and create a GitHub OAuth app.
|
||||
2. Set its callback URL to `http://localhost:8090/api/oauth2-redirect`.
|
||||
3. Set `GITHUB_CLIENT_ID`, `GITHUB_CLIENT_SECRET`, `PB_SUPERUSER_EMAIL`, and `PB_SUPERUSER_PASSWORD`.
|
||||
4. Run `docker compose up --build` from this directory.
|
||||
|
||||
The API is at `http://localhost:8090`; the emergency PocketBase dashboard is at `http://localhost:8090/_/`. Day-to-day moderation belongs in `/workshop/admin`, not the PocketBase dashboard.
|
||||
|
||||
The image downloads the official PocketBase `0.39.6` release and verifies it against that release's `checksums.txt`. Migrations and hooks are mounted read-only.
|
||||
|
||||
## Roles and account states
|
||||
|
||||
- `member`: publish, update/delete owned submissions, vote, and report.
|
||||
- `moderator`: report queue, submission removal, posting bans, and account disable/restore.
|
||||
- `admin`: moderator actions plus role changes.
|
||||
- `posting_banned`: cannot create or update submissions; can browse, vote, and report.
|
||||
- `disabled`: cannot authenticate or write.
|
||||
|
||||
Bootstrap the first admin through the PocketBase dashboard by changing a GitHub-authenticated user's role. All later staff actions use the restricted Workshop back office and are recorded in `moderation_actions`.
|
||||
|
||||
## Backup and restore
|
||||
|
||||
PocketBase stores records and uploads in `/pb_data`. Stop writes or stop the container, then archive the named volume:
|
||||
|
||||
```sh
|
||||
docker compose stop workshop
|
||||
docker run --rm -v store_workshop_data:/data -v "$PWD":/backup alpine tar czf /backup/workshop-backup.tgz -C /data .
|
||||
docker compose start workshop
|
||||
```
|
||||
|
||||
Restore only into the same PocketBase version after preserving the current volume. Extract the archive into an empty `workshop_data` volume, start the service, and verify `/api/health`, collection migrations, a listing request, and an authenticated write.
|
||||
|
||||
## Production checklist
|
||||
|
||||
- Terminate TLS at the reverse proxy and forward the real client IP.
|
||||
- Keep superuser credentials outside Compose and rotate them after bootstrap.
|
||||
- Restrict the dashboard path to operators; GitHub users never need it.
|
||||
- Review the versioned default rate limits for OAuth, submissions, votes, reports, moderation, and general API traffic against production load before launch.
|
||||
- Back up `/pb_data` before every PocketBase or migration upgrade and test restore regularly.
|
||||
- Monitor health, authentication failures, 429/5xx responses, moderation actions, disk use, and backup age.
|
||||
@@ -1,24 +0,0 @@
|
||||
services:
|
||||
workshop:
|
||||
build:
|
||||
context: .
|
||||
args:
|
||||
POCKETBASE_VERSION: 0.39.6
|
||||
image: homarr/workshop-pocketbase:0.39.6
|
||||
restart: unless-stopped
|
||||
environment:
|
||||
GITHUB_CLIENT_ID: ${GITHUB_CLIENT_ID:-}
|
||||
GITHUB_CLIENT_SECRET: ${GITHUB_CLIENT_SECRET:-}
|
||||
PB_SUPERUSER_EMAIL: ${PB_SUPERUSER_EMAIL:-}
|
||||
PB_SUPERUSER_PASSWORD: ${PB_SUPERUSER_PASSWORD:-}
|
||||
ports:
|
||||
- "${PB_EXPOSE_PORT:-8090}:8090"
|
||||
volumes:
|
||||
- workshop_data:/pb_data
|
||||
- ./pb_hooks:/pb_hooks:ro
|
||||
- ./pb_migrations:/pb_migrations:ro
|
||||
security_opt:
|
||||
- no-new-privileges:true
|
||||
|
||||
volumes:
|
||||
workshop_data:
|
||||
@@ -1,14 +0,0 @@
|
||||
#!/bin/sh
|
||||
set -eu
|
||||
|
||||
chown -R pocketbase:pocketbase /pb_data
|
||||
|
||||
if [ -n "${PB_SUPERUSER_EMAIL:-}" ] && [ -n "${PB_SUPERUSER_PASSWORD:-}" ]; then
|
||||
su-exec pocketbase pocketbase superuser upsert "${PB_SUPERUSER_EMAIL}" "${PB_SUPERUSER_PASSWORD}" --dir=/pb_data
|
||||
fi
|
||||
|
||||
exec su-exec pocketbase pocketbase serve \
|
||||
--http=0.0.0.0:8090 \
|
||||
--dir=/pb_data \
|
||||
--hooksDir=/pb_hooks \
|
||||
--migrationsDir=/pb_migrations
|
||||
@@ -1,26 +0,0 @@
|
||||
#!/bin/sh
|
||||
set -eu
|
||||
|
||||
project="homarr-workshop-integration"
|
||||
port="${WORKSHOP_TEST_PORT:-18091}"
|
||||
email="integration@homarr.test"
|
||||
password="WorkshopIntegrationPassword123!"
|
||||
|
||||
cleanup() {
|
||||
PB_EXPOSE_PORT="$port" docker compose --file apps/store/docker-compose.yml --project-name "$project" down --volumes >/dev/null 2>&1 || true
|
||||
}
|
||||
trap cleanup EXIT INT TERM
|
||||
cleanup
|
||||
|
||||
PB_EXPOSE_PORT="$port" \
|
||||
PB_SUPERUSER_EMAIL="$email" \
|
||||
PB_SUPERUSER_PASSWORD="$password" \
|
||||
docker compose --file apps/store/docker-compose.yml --project-name "$project" up --detach --build --wait
|
||||
|
||||
if ! WORKSHOP_TEST_URL="http://127.0.0.1:$port" \
|
||||
WORKSHOP_TEST_SUPERUSER_EMAIL="$email" \
|
||||
WORKSHOP_TEST_SUPERUSER_PASSWORD="$password" \
|
||||
node apps/store/tests/workshop.integration.mjs; then
|
||||
docker compose --file apps/store/docker-compose.yml --project-name "$project" logs --no-color workshop
|
||||
exit 1
|
||||
fi
|
||||
@@ -0,0 +1 @@
|
||||
pb_data/
|
||||
@@ -0,0 +1,77 @@
|
||||
# syntax=docker/dockerfile:1.10
|
||||
|
||||
FROM node:24.18.0-alpine AS dependencies
|
||||
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 \
|
||||
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
|
||||
|
||||
FROM alpine:3.22 AS pocketbase
|
||||
|
||||
ARG TARGETARCH
|
||||
ARG POCKETBASE_VERSION=0.39.6
|
||||
|
||||
RUN apk add --no-cache ca-certificates curl unzip
|
||||
RUN case "${TARGETARCH}" in \
|
||||
amd64) PB_ARCH=amd64 ;; \
|
||||
arm64) PB_ARCH=arm64 ;; \
|
||||
arm) PB_ARCH=armv7 ;; \
|
||||
*) echo "Unsupported architecture: ${TARGETARCH}" >&2; exit 1 ;; \
|
||||
esac && \
|
||||
BASE_URL="https://github.com/pocketbase/pocketbase/releases/download/v${POCKETBASE_VERSION}" && \
|
||||
ARCHIVE="pocketbase_${POCKETBASE_VERSION}_linux_${PB_ARCH}.zip" && \
|
||||
curl --fail --location --silent --show-error "${BASE_URL}/${ARCHIVE}" --output "/tmp/${ARCHIVE}" && \
|
||||
curl --fail --location --silent --show-error "${BASE_URL}/checksums.txt" --output /tmp/checksums.txt && \
|
||||
cd /tmp && grep " ${ARCHIVE}$" checksums.txt | sha256sum -c - && \
|
||||
unzip "/tmp/${ARCHIVE}" pocketbase -d /out
|
||||
|
||||
FROM alpine:3.22 AS development
|
||||
|
||||
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 apps/workshop/pb_hooks /pb_hooks
|
||||
COPY apps/workshop/pb_migrations /pb_migrations
|
||||
|
||||
RUN chmod +x /usr/local/bin/workshop-entrypoint && \
|
||||
mkdir -p /pb_data && \
|
||||
chown -R pocketbase:pocketbase /pb_data /pb_hooks /pb_migrations /pb_public
|
||||
|
||||
USER pocketbase
|
||||
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"]
|
||||
@@ -0,0 +1,21 @@
|
||||
**
|
||||
!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
|
||||
**/.docusaurus
|
||||
**/build
|
||||
**/dist
|
||||
**/.turbo
|
||||
@@ -0,0 +1,74 @@
|
||||
# Homarr Workshop service
|
||||
|
||||
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.
|
||||
|
||||
## Development
|
||||
|
||||
From the repository root, run:
|
||||
|
||||
```sh
|
||||
pnpm dev:workshop
|
||||
```
|
||||
|
||||
This starts:
|
||||
|
||||
- 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`.
|
||||
|
||||
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.
|
||||
|
||||
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.
|
||||
|
||||
Optional ports and CORS behavior are configured only in the repository-root `.env`:
|
||||
|
||||
```dotenv
|
||||
PB_EXPOSE_PORT=8090
|
||||
WORKSHOP_WEB_PORT=3003
|
||||
PB_ALLOWED_ORIGINS=*
|
||||
```
|
||||
|
||||
## Production-like preview
|
||||
|
||||
Run the exact combined image shape used for deployment:
|
||||
|
||||
```sh
|
||||
pnpm preview:workshop
|
||||
```
|
||||
|
||||
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`:
|
||||
|
||||
- website: `http://localhost:8090/workshop`;
|
||||
- API: `http://localhost:8090/api`;
|
||||
- dashboard: `http://localhost:8090/_/`.
|
||||
|
||||
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.
|
||||
|
||||
## Reset local data
|
||||
|
||||
Resetting is always explicit:
|
||||
|
||||
```sh
|
||||
pnpm reset:workshop
|
||||
```
|
||||
|
||||
The command asks for confirmation, stops both service variants, and clears `apps/workshop/pb_data/`. For automation, use `pnpm reset:workshop -- --force`.
|
||||
|
||||
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.
|
||||
|
||||
## 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:
|
||||
|
||||
```sh
|
||||
pnpm test:workshop-preview
|
||||
```
|
||||
|
||||
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.
|
||||
|
||||
## Operations
|
||||
|
||||
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`.
|
||||
|
||||
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.
|
||||
@@ -0,0 +1,10 @@
|
||||
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(),
|
||||
]),
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,19 @@
|
||||
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
|
||||
@@ -0,0 +1,21 @@
|
||||
name: homarr-workshop
|
||||
|
||||
services:
|
||||
workshop:
|
||||
build:
|
||||
context: ../..
|
||||
dockerfile: apps/workshop/Dockerfile
|
||||
target: development
|
||||
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
|
||||
- ./pb_hooks:/pb_hooks:ro
|
||||
- ./pb_migrations:/pb_migrations:ro
|
||||
security_opt:
|
||||
- no-new-privileges:true
|
||||
@@ -0,0 +1,16 @@
|
||||
#!/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
|
||||
@@ -0,0 +1,14 @@
|
||||
#!/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
|
||||
@@ -20,12 +20,10 @@ function fail(ErrorType, _code, message) {
|
||||
throw new ErrorType(message);
|
||||
}
|
||||
|
||||
function requireWritableAccount(app, auth, posting) {
|
||||
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");
|
||||
if (posting && account.getString("state") === "posting_banned")
|
||||
fail(ForbiddenError, "posting_banned", "Account cannot publish submissions");
|
||||
return account;
|
||||
}
|
||||
|
||||
@@ -69,14 +67,40 @@ function validateSubmission(record) {
|
||||
}
|
||||
|
||||
function snapshot(record) {
|
||||
return JSON.stringify(record.publicExport());
|
||||
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) {
|
||||
const account = requireWritableAccount(app, auth, false);
|
||||
const role = account.getString("role");
|
||||
if (role !== "moderator" && role !== "admin") fail(ForbiddenError, "forbidden", "Staff access is required");
|
||||
return role;
|
||||
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) {
|
||||
@@ -99,4 +123,13 @@ function audit(app, actor, action, targetType, targetId, reason, targetSnapshot)
|
||||
app.save(record);
|
||||
}
|
||||
|
||||
module.exports = { audit, body, fail, requireStaff, requireWritableAccount, snapshot, validateSubmission };
|
||||
module.exports = {
|
||||
audit,
|
||||
body,
|
||||
fail,
|
||||
requireStaff,
|
||||
requireWritableAccount,
|
||||
setStaffRole,
|
||||
snapshot,
|
||||
validateSubmission,
|
||||
};
|
||||
@@ -1,32 +1,5 @@
|
||||
/// <reference path="../pb_data/types.d.ts" />
|
||||
|
||||
onRecordAuthWithOAuth2Request((e) => {
|
||||
const utils = require(`${__hooks}/workshop-utils.js`);
|
||||
if (e.record && e.record.getString("state") === "disabled")
|
||||
utils.fail(ForbiddenError, "account_disabled", "Account is disabled");
|
||||
if (e.isNewRecord) {
|
||||
e.createData.displayName = e.oAuth2User.name || e.oAuth2User.username || "Community member";
|
||||
e.createData.avatarUrl = e.oAuth2User.avatarUrl || "";
|
||||
e.createData.role = "member";
|
||||
e.createData.state = "active";
|
||||
}
|
||||
e.next();
|
||||
}, "users");
|
||||
|
||||
onRecordAuthRequest((e) => {
|
||||
const utils = require(`${__hooks}/workshop-utils.js`);
|
||||
if (e.record.getString("state") === "disabled") utils.fail(ForbiddenError, "account_disabled", "Account is disabled");
|
||||
e.next();
|
||||
}, "users");
|
||||
|
||||
onRecordUpdateRequest((e) => {
|
||||
const original = e.app.findRecordById("users", e.record.id);
|
||||
e.record.set("role", original.get("role"));
|
||||
e.record.set("state", original.get("state"));
|
||||
e.record.set("moderationReason", original.get("moderationReason"));
|
||||
e.next();
|
||||
}, "users");
|
||||
|
||||
onRecordCreateRequest((e) => {
|
||||
const utils = require(`${__hooks}/workshop-utils.js`);
|
||||
const account = utils.requireWritableAccount(e.app, e.auth, true);
|
||||
@@ -106,8 +79,7 @@ routerAdd(
|
||||
const utils = require(`${__hooks}/workshop-utils.js`);
|
||||
const actorRole = utils.requireStaff(e.app, e.auth);
|
||||
const data = utils.body(e);
|
||||
if (!["active", "posting_banned", "disabled"].includes(data.state))
|
||||
throw new BadRequestError("Invalid account state");
|
||||
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) => {
|
||||
@@ -139,6 +111,7 @@ routerAdd(
|
||||
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 });
|
||||
+59
-21
@@ -9,13 +9,14 @@ migrate(
|
||||
users = new Collection({
|
||||
type: "auth",
|
||||
name: "users",
|
||||
listRule: "@request.auth.role = 'moderator' || @request.auth.role = 'admin'",
|
||||
viewRule: "id = @request.auth.id || @request.auth.role = 'moderator' || @request.auth.role = 'admin'",
|
||||
updateRule: "id = @request.auth.id && @request.auth.state != 'disabled'",
|
||||
deleteRule: null,
|
||||
passwordAuth: { enabled: false },
|
||||
});
|
||||
}
|
||||
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(
|
||||
@@ -26,20 +27,35 @@ migrate(
|
||||
name: "state",
|
||||
required: true,
|
||||
maxSelect: 1,
|
||||
values: ["active", "posting_banned", "disabled"],
|
||||
values: ["active", "disabled"],
|
||||
}),
|
||||
new TextField({ name: "moderationReason", max: 1000 }),
|
||||
);
|
||||
const githubClientId = $os.getenv("GITHUB_CLIENT_ID");
|
||||
const githubClientSecret = $os.getenv("GITHUB_CLIENT_SECRET");
|
||||
users.oauth2.enabled = Boolean(githubClientId && githubClientSecret);
|
||||
users.oauth2.providers =
|
||||
githubClientId && githubClientSecret
|
||||
? [{ name: "github", clientId: githubClientId, clientSecret: githubClientSecret }]
|
||||
: [];
|
||||
app.save(users);
|
||||
|
||||
const staffRule = "@request.auth.role = 'moderator' || @request.auth.role = 'admin'";
|
||||
const 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'";
|
||||
|
||||
@@ -216,14 +232,22 @@ migrate(
|
||||
app.save(listings);
|
||||
|
||||
const settings = app.settings();
|
||||
settings.rateLimits.enabled = true;
|
||||
settings.rateLimits.rules = [
|
||||
{ label: "*:auth", audience: "", duration: 60, maxRequests: 10 },
|
||||
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 },
|
||||
{ label: "/api/", audience: "", duration: 10, maxRequests: 300 },
|
||||
];
|
||||
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;
|
||||
@@ -233,7 +257,14 @@ migrate(
|
||||
(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"]) {
|
||||
for (const name of [
|
||||
"workshop_listings",
|
||||
"moderation_actions",
|
||||
"reports",
|
||||
"votes",
|
||||
"submissions",
|
||||
"workshop_staff",
|
||||
]) {
|
||||
try {
|
||||
app.delete(app.findCollectionByNameOrId(name));
|
||||
} catch {
|
||||
@@ -241,8 +272,15 @@ migrate(
|
||||
}
|
||||
}
|
||||
const settings = app.settings();
|
||||
settings.rateLimits.enabled = false;
|
||||
settings.rateLimits.rules = [];
|
||||
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);
|
||||
},
|
||||
);
|
||||
@@ -0,0 +1,73 @@
|
||||
/// <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.
|
||||
}
|
||||
},
|
||||
);
|
||||
Executable
+20
@@ -0,0 +1,20 @@
|
||||
#!/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
+53
@@ -0,0 +1,53 @@
|
||||
#!/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
|
||||
Executable
+51
@@ -0,0 +1,51 @@
|
||||
#!/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"
|
||||
+55
-20
@@ -5,10 +5,31 @@ 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!`;
|
||||
@@ -30,26 +51,29 @@ const expectStatus = async (operation, status) => {
|
||||
|
||||
const memberRecord = await createUser("Member");
|
||||
const secondMemberRecord = await createUser("SecondMember");
|
||||
const bannedRecord = await createUser("Banned", "member", "posting_banned");
|
||||
const disabledRecord = await createUser("Disabled");
|
||||
const moderatorRecord = await createUser("Moderator", "moderator");
|
||||
const adminRecord = await createUser("Admin", "admin");
|
||||
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 banned = await root.collection("users").impersonate(bannedRecord.id, 3600);
|
||||
const disabled = await root.collection("users").impersonate(disabledRecord.id, 3600);
|
||||
const moderator = await root.collection("users").impersonate(moderatorRecord.id, 3600);
|
||||
const admin = await root.collection("users").impersonate(adminRecord.id, 3600);
|
||||
|
||||
const forgedMember = await member.collection("users").update(memberRecord.id, {
|
||||
role: "admin",
|
||||
state: "posting_banned",
|
||||
moderationReason: "self escalation",
|
||||
});
|
||||
assert.equal(forgedMember.role, "member", "members must not change their own role");
|
||||
assert.equal(forgedMember.state, "active", "members must not change their own account state");
|
||||
assert.equal(forgedMember.moderationReason, "", "members must not change protected moderation fields");
|
||||
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",
|
||||
@@ -57,11 +81,20 @@ await admin.send(`/api/workshop/moderation/users/${disabledRecord.id}/state`, {
|
||||
});
|
||||
|
||||
const submission = await member.collection("submissions").create({
|
||||
type: "css",
|
||||
title: "Integration theme",
|
||||
type: "widget",
|
||||
title: "Integration widget",
|
||||
description: "A backend integration fixture",
|
||||
schemaVersion: "forged-version",
|
||||
content: ":root { --homarr-test: #fff; }",
|
||||
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,
|
||||
@@ -69,12 +102,8 @@ const submission = await member.collection("submissions").create({
|
||||
assert.equal(submission.author, memberRecord.id, "server must enforce ownership");
|
||||
assert.equal(submission.authorName, "Member", "server must snapshot the authenticated author name");
|
||||
assert.equal(submission.revision, 1, "server must own the initial revision");
|
||||
assert.equal(submission.schemaVersion, "homarr-custom-css-v1", "server must own schemaVersion");
|
||||
assert.equal(submission.schemaVersion, "homarr-custom-widget-v2", "server must own schemaVersion");
|
||||
|
||||
await expectStatus(
|
||||
() => banned.collection("submissions").create({ type: "css", title: "Blocked theme", content: "body {}" }),
|
||||
403,
|
||||
);
|
||||
await expectStatus(() => disabled.collection("votes").create({ submission: submission.id, value: 1 }), [400, 403]);
|
||||
await expectStatus(
|
||||
() =>
|
||||
@@ -109,7 +138,7 @@ await expectStatus(
|
||||
() =>
|
||||
member.send(`/api/workshop/moderation/users/${secondMemberRecord.id}/state`, {
|
||||
method: "POST",
|
||||
body: { state: "posting_banned", reason: "No permission" },
|
||||
body: { state: "disabled", reason: "No permission" },
|
||||
}),
|
||||
403,
|
||||
);
|
||||
@@ -135,6 +164,10 @@ await admin.send(`/api/workshop/moderation/users/${secondMemberRecord.id}/role`,
|
||||
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",
|
||||
@@ -155,7 +188,9 @@ assert.deepEqual(
|
||||
actions.map((action) => action.action),
|
||||
["set_account_state", "set_role", "resolve_report", "remove_submission"],
|
||||
);
|
||||
assert(actions.at(-1)?.snapshot.includes("Integration theme"), "removal audit must retain a target snapshot");
|
||||
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();
|
||||
@@ -14,7 +14,7 @@ Workshop behavior is defined once: one typed contract, one client, versioned Poc
|
||||
|
||||
## Baseline audit
|
||||
|
||||
The abandoned prototype is preserved at `origin/feat/widget-store-prototype-backup` (`89260b875`). This rewrite starts at `dev` commit `ba7718bde`.
|
||||
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 |
|
||||
| ------------- | -------- | ----------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------ |
|
||||
@@ -36,7 +36,11 @@ The abandoned prototype is preserved at `origin/feat/widget-store-prototype-back
|
||||
- Launch content: custom widgets (`homarr-custom-widget-v2`) and CSS (`homarr-custom-css-v1`).
|
||||
- Identity: GitHub OAuth only.
|
||||
- Community features: votes and reports; comments are removed.
|
||||
- Hosting: one Homarr-operated service at `store.homarr.dev`; overrides are development-only.
|
||||
- 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.
|
||||
|
||||
@@ -52,6 +56,7 @@ The abandoned prototype is preserved at `origin/feat/widget-store-prototype-back
|
||||
- [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
|
||||
@@ -64,15 +69,15 @@ The baseline has three P0, seven P1, and one P2 findings. The rewrite closes eve
|
||||
|
||||
| Gate | Result | Evidence |
|
||||
| --------------------------- | ------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| Branch ancestry | Pass | Rebuilt from `dev` at `ba7718bde`; prototype preserved at `origin/feat/widget-store-prototype-backup`. |
|
||||
| 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 image builds with checksum verification; clean migration, health, and listing/detail access boundaries pass. |
|
||||
| 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 and Next.js production builds pass under Node 24.18.0. |
|
||||
| Browser smoke | Pass | Desktop and 390px mobile discovery render without console errors or horizontal overflow; semantic labels, skip link, dark mode, and reduced motion are active. |
|
||||
| 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.
|
||||
+8
-2
@@ -21,7 +21,9 @@
|
||||
"db:studio": "pnpm -F db studio",
|
||||
"dev": "pnpm -F nextjs dev",
|
||||
"dev:docs": "pnpm -F @homarr/docs dev",
|
||||
"dev:workshop": "docker compose -f apps/store/docker-compose.yml up --build",
|
||||
"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}'",
|
||||
"docker:dev": "docker compose -f ./development/development.docker-compose.yml up",
|
||||
"format": "turbo format --continue -- --check",
|
||||
"format:fix": "turbo format --continue",
|
||||
@@ -29,12 +31,15 @@
|
||||
"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/store/tests/run-integration.sh",
|
||||
"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 --"
|
||||
@@ -55,6 +60,7 @@
|
||||
"@vitejs/plugin-react": "catalog:",
|
||||
"@vitest/coverage-v8": "catalog:",
|
||||
"@vitest/ui": "catalog:",
|
||||
"concurrently": "catalog:",
|
||||
"conventional-changelog-conventionalcommits": "catalog:",
|
||||
"cross-env": "catalog:",
|
||||
"jsdom": "catalog:",
|
||||
|
||||
@@ -497,6 +497,9 @@ 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;
|
||||
|
||||
@@ -896,6 +896,10 @@
|
||||
"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": {
|
||||
@@ -4457,6 +4461,15 @@
|
||||
"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",
|
||||
@@ -4484,27 +4497,27 @@
|
||||
"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",
|
||||
"publish": "The creation could not be published",
|
||||
"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"
|
||||
},
|
||||
"publish": {
|
||||
"title": "Share in Workshop",
|
||||
"name": "Title",
|
||||
"description": "Description",
|
||||
"changelog": "Changelog"
|
||||
},
|
||||
"action": {
|
||||
"retry": "Try again",
|
||||
"inspect": "Inspect source",
|
||||
"download": "Download",
|
||||
"website": "Open website",
|
||||
"use": "Use creation",
|
||||
"share": "Share in Workshop",
|
||||
"cancel": "Cancel",
|
||||
"publish": "Publish creation",
|
||||
"install": "Install widget",
|
||||
"useCss": "Use CSS"
|
||||
}
|
||||
@@ -5716,6 +5729,9 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"workshop": {
|
||||
"label": "Workshop"
|
||||
},
|
||||
"users": {
|
||||
"label": "Users",
|
||||
"create": {
|
||||
|
||||
@@ -27,6 +27,7 @@
|
||||
},
|
||||
"scripts": {
|
||||
"build": "esbuild src/index.ts src/client.ts src/schema.ts --bundle --format=esm --platform=browser --external:pocketbase --external:zod --external:zod/* --outdir=dist",
|
||||
"dev": "pnpm build --watch=forever",
|
||||
"clean": "rm -rf .turbo node_modules",
|
||||
"format": "oxfmt .",
|
||||
"lint": "oxlint",
|
||||
|
||||
@@ -15,6 +15,7 @@ import type {
|
||||
|
||||
import {
|
||||
WORKSHOP_API_URL,
|
||||
WORKSHOP_REQUEST_TIMEOUT_MS,
|
||||
WorkshopError,
|
||||
schemaVersionForType,
|
||||
workshopAccountStateSchema,
|
||||
@@ -37,6 +38,7 @@ export interface WorkshopListOptions {
|
||||
sort?: "top" | "newest";
|
||||
search?: string;
|
||||
author?: string;
|
||||
signal?: AbortSignal;
|
||||
}
|
||||
|
||||
export interface WorkshopPage<T> {
|
||||
@@ -57,20 +59,28 @@ const errorCodeForStatus = (status: number) => {
|
||||
return "unknown" as const;
|
||||
};
|
||||
|
||||
const workshopRequestSignal = (signal?: AbortSignal) =>
|
||||
AbortSignal.any(
|
||||
signal
|
||||
? [signal, AbortSignal.timeout(WORKSHOP_REQUEST_TIMEOUT_MS)]
|
||||
: [AbortSignal.timeout(WORKSHOP_REQUEST_TIMEOUT_MS)],
|
||||
);
|
||||
|
||||
const asWorkshopError = (error: unknown, fallback: string) => {
|
||||
if (error instanceof WorkshopError) return error;
|
||||
if (error instanceof ClientResponseError) {
|
||||
const message = error.data?.message || error.message || fallback;
|
||||
const messageCode =
|
||||
message === "Account is disabled"
|
||||
? "account_disabled"
|
||||
: message === "Account cannot publish submissions"
|
||||
? "posting_banned"
|
||||
: undefined;
|
||||
const messageCode = message === "Account is disabled" ? "account_disabled" : undefined;
|
||||
const parsedCode = workshopErrorCodeSchema.safeParse(messageCode ?? error.data?.data?.code ?? error.data?.code);
|
||||
const code = parsedCode.success ? parsedCode.data : errorCodeForStatus(error.status);
|
||||
return new WorkshopError(code, message, error.status);
|
||||
}
|
||||
if (error instanceof TypeError) {
|
||||
return new WorkshopError("unavailable", fallback);
|
||||
}
|
||||
if (error instanceof DOMException && (error.name === "AbortError" || error.name === "TimeoutError")) {
|
||||
return new WorkshopError("unavailable", `${fallback}. The request timed out.`);
|
||||
}
|
||||
return new WorkshopError("unknown", error instanceof Error ? error.message : fallback);
|
||||
};
|
||||
|
||||
@@ -93,6 +103,20 @@ export class WorkshopClient {
|
||||
});
|
||||
}
|
||||
|
||||
private async currentUserWithStaffRole() {
|
||||
const user = this.currentUser;
|
||||
if (!user) return null;
|
||||
try {
|
||||
const staff = await this.pocketBase
|
||||
.collection("workshop_staff")
|
||||
.getFirstListItem(this.pocketBase.filter("user = {:user}", { user: user.id }));
|
||||
return { ...user, role: workshopRoleSchema.parse(staff.role) };
|
||||
} catch (error) {
|
||||
if (error instanceof ClientResponseError && error.status === 404) return { ...user, role: "member" as const };
|
||||
return user;
|
||||
}
|
||||
}
|
||||
|
||||
subscribeToAuth(listener: (user: WorkshopUser | null) => void) {
|
||||
return this.pocketBase.authStore.onChange(() => listener(this.currentUser), true);
|
||||
}
|
||||
@@ -101,7 +125,7 @@ export class WorkshopClient {
|
||||
if (!this.pocketBase.authStore.isValid) return null;
|
||||
try {
|
||||
await this.pocketBase.collection("users").authRefresh();
|
||||
return this.currentUser;
|
||||
return this.currentUserWithStaffRole();
|
||||
} catch {
|
||||
this.pocketBase.authStore.clear();
|
||||
return null;
|
||||
@@ -109,9 +133,28 @@ export class WorkshopClient {
|
||||
}
|
||||
|
||||
async signInWithGitHub() {
|
||||
let methods: Awaited<ReturnType<ReturnType<PocketBase["collection"]>["listAuthMethods"]>>;
|
||||
try {
|
||||
await this.pocketBase.collection("users").authWithOAuth2({ provider: "github" });
|
||||
return this.currentUser;
|
||||
methods = await this.pocketBase.collection("users").listAuthMethods();
|
||||
} catch (error) {
|
||||
throw asWorkshopError(error, "Failed to load Workshop authentication methods");
|
||||
}
|
||||
if (!methods.oauth2.providers.some((provider) => provider.name === "github")) {
|
||||
throw new WorkshopError(
|
||||
"unavailable",
|
||||
"GitHub login is not configured. Configure the GitHub provider once in the PocketBase users collection.",
|
||||
);
|
||||
}
|
||||
try {
|
||||
await this.pocketBase.collection("users").authWithOAuth2({
|
||||
provider: "github",
|
||||
createData: {
|
||||
displayName: "Community member",
|
||||
role: "member",
|
||||
state: "active",
|
||||
},
|
||||
});
|
||||
return this.currentUserWithStaffRole();
|
||||
} catch (error) {
|
||||
throw asWorkshopError(error, "GitHub sign-in failed");
|
||||
}
|
||||
@@ -134,6 +177,7 @@ export class WorkshopClient {
|
||||
.getList(options.page ?? 1, options.perPage ?? 24, {
|
||||
filter: filters.join(" && "),
|
||||
sort: options.sort === "newest" ? "-created" : "-score,-created",
|
||||
signal: workshopRequestSignal(options.signal),
|
||||
});
|
||||
return { ...result, items: result.items.map((item) => workshopSubmissionSummarySchema.parse(item)) };
|
||||
} catch (error) {
|
||||
@@ -141,9 +185,11 @@ export class WorkshopClient {
|
||||
}
|
||||
}
|
||||
|
||||
async get(id: string): Promise<WorkshopSubmissionDetail> {
|
||||
async get(id: string, signal?: AbortSignal): Promise<WorkshopSubmissionDetail> {
|
||||
try {
|
||||
return workshopSubmissionDetailSchema.parse(await this.pocketBase.collection("submissions").getOne(id));
|
||||
return workshopSubmissionDetailSchema.parse(
|
||||
await this.pocketBase.collection("submissions").getOne(id, { signal: workshopRequestSignal(signal) }),
|
||||
);
|
||||
} catch (error) {
|
||||
throw asWorkshopError(error, "Submission not found");
|
||||
}
|
||||
|
||||
@@ -57,6 +57,8 @@ describe("Workshop contracts", () => {
|
||||
expect(schemaVersionForType("widget")).toBe(WIDGET_SCHEMA_VERSION);
|
||||
expect(schemaVersionForType("css")).toBe(CSS_SCHEMA_VERSION);
|
||||
expect(workshopExportFilename("My / Theme!", "css")).toBe("my-theme.css");
|
||||
expect(workshopExportFilename("---My---Widget---", "widget")).toBe("my-widget.json");
|
||||
expect(workshopExportFilename("-".repeat(100_000), "css")).toBe("homarr-workshop.css");
|
||||
});
|
||||
|
||||
it("validates screenshot count, MIME type, and size", () => {
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { customWidgetImportSchema } from "@homarr/validation/custom-widget";
|
||||
import { z } from "zod";
|
||||
|
||||
export const WORKSHOP_API_URL = "https://store.homarr.dev";
|
||||
export const WORKSHOP_API_URL = "https://homarr.dev";
|
||||
export const WORKSHOP_WEB_URL = "https://homarr.dev/workshop";
|
||||
export const WIDGET_SCHEMA_VERSION = "homarr-custom-widget-v2";
|
||||
export const CSS_SCHEMA_VERSION = "homarr-custom-css-v1";
|
||||
@@ -9,6 +9,7 @@ export const MAX_CSS_LENGTH = 16_384;
|
||||
export const MAX_CONTENT_LENGTH = 1_000_000;
|
||||
export const MAX_SCREENSHOTS = 5;
|
||||
export const MAX_SCREENSHOT_BYTES = 5 * 1024 * 1024;
|
||||
export const WORKSHOP_REQUEST_TIMEOUT_MS = 8_000;
|
||||
export const WORKSHOP_SCREENSHOT_MIME_TYPES = ["image/png", "image/jpeg", "image/webp"] as const;
|
||||
|
||||
export const workshopSubmissionTypeSchema = z.enum(["widget", "css"]);
|
||||
@@ -17,7 +18,7 @@ export type WorkshopSubmissionType = z.infer<typeof workshopSubmissionTypeSchema
|
||||
export const workshopRoleSchema = z.enum(["member", "moderator", "admin"]);
|
||||
export type WorkshopRole = z.infer<typeof workshopRoleSchema>;
|
||||
|
||||
export const workshopAccountStateSchema = z.enum(["active", "posting_banned", "disabled"]);
|
||||
export const workshopAccountStateSchema = z.enum(["active", "disabled"]);
|
||||
export type WorkshopAccountState = z.infer<typeof workshopAccountStateSchema>;
|
||||
|
||||
export const workshopReportCategorySchema = z.enum(["malicious", "spam", "copyright", "inappropriate", "other"]);
|
||||
@@ -111,7 +112,6 @@ export type WorkshopModerationAction = z.infer<typeof workshopModerationActionSc
|
||||
export const workshopErrorCodeSchema = z.enum([
|
||||
"authentication_required",
|
||||
"account_disabled",
|
||||
"posting_banned",
|
||||
"forbidden",
|
||||
"not_found",
|
||||
"invalid_submission",
|
||||
@@ -165,11 +165,21 @@ export const schemaVersionForType = (type: WorkshopSubmissionType) =>
|
||||
type === "widget" ? WIDGET_SCHEMA_VERSION : CSS_SCHEMA_VERSION;
|
||||
|
||||
export function workshopExportFilename(title: string, type: WorkshopSubmissionType) {
|
||||
const safeTitle =
|
||||
title
|
||||
.trim()
|
||||
.toLowerCase()
|
||||
.replace(/[^a-z0-9_-]+/g, "-")
|
||||
.replace(/^-+|-+$/g, "") || "homarr-workshop";
|
||||
let safeTitle = "";
|
||||
let separatorPending = false;
|
||||
|
||||
for (const character of title.trim().toLowerCase()) {
|
||||
const isLetter = character >= "a" && character <= "z";
|
||||
const isNumber = character >= "0" && character <= "9";
|
||||
if (isLetter || isNumber || character === "_") {
|
||||
if (separatorPending && safeTitle.length > 0) safeTitle += "-";
|
||||
safeTitle += character;
|
||||
separatorPending = false;
|
||||
} else {
|
||||
separatorPending = safeTitle.length > 0;
|
||||
}
|
||||
}
|
||||
|
||||
safeTitle ||= "homarr-workshop";
|
||||
return `${safeTitle}.${type === "widget" ? "json" : "css"}`;
|
||||
}
|
||||
Generated
+126
-140
@@ -271,11 +271,11 @@ catalogs:
|
||||
specifier: ^6.0.3
|
||||
version: 6.0.3
|
||||
'@vitest/coverage-v8':
|
||||
specifier: ^4.1.9
|
||||
version: 4.1.9
|
||||
specifier: ^4.1.10
|
||||
version: 4.1.10
|
||||
'@vitest/ui':
|
||||
specifier: ^4.1.9
|
||||
version: 4.1.9
|
||||
specifier: ^4.1.10
|
||||
version: 4.1.10
|
||||
'@xterm/addon-canvas':
|
||||
specifier: ^0.7.0
|
||||
version: 0.7.0
|
||||
@@ -412,8 +412,8 @@ catalogs:
|
||||
specifier: ^1.1.0
|
||||
version: 1.1.0
|
||||
mysql2:
|
||||
specifier: 3.22.5
|
||||
version: 3.22.5
|
||||
specifier: 3.22.6
|
||||
version: 3.22.6
|
||||
next:
|
||||
specifier: 16.2.10
|
||||
version: 16.2.10
|
||||
@@ -445,7 +445,7 @@ catalogs:
|
||||
specifier: ^1.18.0
|
||||
version: 1.18.0
|
||||
posthog-js:
|
||||
specifier: ^1.398.2
|
||||
specifier: ^1.398.7
|
||||
version: 1.399.0
|
||||
posthog-node:
|
||||
specifier: ^5.39.2
|
||||
@@ -538,8 +538,8 @@ catalogs:
|
||||
specifier: ^6.1.1
|
||||
version: 6.1.1
|
||||
vitest:
|
||||
specifier: ^4.1.9
|
||||
version: 4.1.9
|
||||
specifier: ^4.1.10
|
||||
version: 4.1.10
|
||||
winston:
|
||||
specifier: 3.19.0
|
||||
version: 3.19.0
|
||||
@@ -627,10 +627,13 @@ importers:
|
||||
version: 6.0.3(babel-plugin-react-compiler@1.0.0)(vite@8.1.3(@types/node@24.13.2)(esbuild@0.28.1)(jiti@2.7.0)(sass@1.101.0)(sugarss@5.0.0(postcss@8.5.16))(terser@5.46.1)(tsx@4.21.0)(yaml@2.9.0))
|
||||
'@vitest/coverage-v8':
|
||||
specifier: 'catalog:'
|
||||
version: 4.1.9(vitest@4.1.9)
|
||||
version: 4.1.10(vitest@4.1.10)
|
||||
'@vitest/ui':
|
||||
specifier: 'catalog:'
|
||||
version: 4.1.9(vitest@4.1.9)
|
||||
version: 4.1.10(vitest@4.1.10)
|
||||
concurrently:
|
||||
specifier: 'catalog:'
|
||||
version: 10.0.3
|
||||
conventional-changelog-conventionalcommits:
|
||||
specifier: 'catalog:'
|
||||
version: 10.2.0
|
||||
@@ -669,7 +672,7 @@ importers:
|
||||
version: 6.1.1(typescript@6.0.3)(vite@8.1.3(@types/node@24.13.2)(esbuild@0.28.1)(jiti@2.7.0)(sass@1.101.0)(sugarss@5.0.0(postcss@8.5.16))(terser@5.46.1)(tsx@4.21.0)(yaml@2.9.0))
|
||||
vitest:
|
||||
specifier: 'catalog:'
|
||||
version: 4.1.9(@opentelemetry/api@1.9.0)(@types/node@24.13.2)(@vitest/coverage-v8@4.1.9)(@vitest/ui@4.1.9)(jsdom@29.1.1(@noble/hashes@2.0.1))(msw@2.14.6(@types/node@24.13.3)(typescript@6.0.3))(vite@8.1.3(@types/node@24.13.2)(esbuild@0.28.1)(jiti@2.7.0)(sass@1.101.0)(sugarss@5.0.0(postcss@8.5.16))(terser@5.46.1)(tsx@4.21.0)(yaml@2.9.0))
|
||||
version: 4.1.10(@opentelemetry/api@1.9.0)(@types/node@24.13.3)(@vitest/coverage-v8@4.1.10)(@vitest/ui@4.1.10)(jsdom@29.1.1(@noble/hashes@2.0.1))(msw@2.14.6(@types/node@24.13.3)(typescript@6.0.3))(vite@8.1.3(@types/node@24.13.2)(esbuild@0.28.1)(jiti@2.7.0)(sass@1.101.0)(sugarss@5.0.0(postcss@8.5.16))(terser@5.46.1)(tsx@4.21.0)(yaml@2.9.0))
|
||||
|
||||
apps/docs:
|
||||
dependencies:
|
||||
@@ -718,6 +721,15 @@ importers:
|
||||
'@tailwindcss/postcss':
|
||||
specifier: ^4.3.2
|
||||
version: 4.3.2
|
||||
'@tanstack/query-async-storage-persister':
|
||||
specifier: 'catalog:'
|
||||
version: 5.101.2
|
||||
'@tanstack/react-query':
|
||||
specifier: 'catalog:'
|
||||
version: 5.101.2(react@19.2.7)
|
||||
'@tanstack/react-query-persist-client':
|
||||
specifier: 'catalog:'
|
||||
version: 5.101.2(@tanstack/react-query@5.101.2(react@19.2.7))(react@19.2.7)
|
||||
clsx:
|
||||
specifier: 'catalog:'
|
||||
version: 2.1.1
|
||||
@@ -985,7 +997,7 @@ importers:
|
||||
version: 17.4.2
|
||||
drizzle-orm:
|
||||
specifier: 'catalog:'
|
||||
version: 0.45.2(@libsql/client-wasm@0.14.0)(@opentelemetry/api@1.9.0)(@types/better-sqlite3@7.6.13)(@types/pg@8.20.0)(better-sqlite3@12.11.1)(gel@2.0.0)(kysely@0.29.2)(mysql2@3.22.5(@types/node@24.13.3))(pg@8.22.0)(sql.js@1.14.1)
|
||||
version: 0.45.2(@libsql/client-wasm@0.14.0)(@opentelemetry/api@1.9.0)(@types/better-sqlite3@7.6.13)(@types/pg@8.20.0)(better-sqlite3@12.11.1)(gel@2.0.0)(kysely@0.29.2)(mysql2@3.22.6(@types/node@24.13.3))(pg@8.22.0)(sql.js@1.14.1)
|
||||
flag-icons:
|
||||
specifier: 'catalog:'
|
||||
version: 7.5.0
|
||||
@@ -1048,7 +1060,7 @@ importers:
|
||||
version: 2.2.6
|
||||
trpc-to-mcp:
|
||||
specifier: 'catalog:'
|
||||
version: 1.3.2(@trpc/server@11.18.0(typescript@6.0.3))(better-auth@1.6.15(@opentelemetry/api@1.9.0)(better-sqlite3@12.11.1)(drizzle-kit@0.31.10)(drizzle-orm@0.45.2(@libsql/client-wasm@0.14.0)(@opentelemetry/api@1.9.0)(@types/better-sqlite3@7.6.13)(@types/pg@8.20.0)(better-sqlite3@12.11.1)(gel@2.0.0)(kysely@0.29.2)(mysql2@3.22.5(@types/node@24.13.3))(pg@8.22.0)(sql.js@1.14.1))(mysql2@3.22.5(@types/node@24.13.3))(next@16.2.10(@babel/core@7.29.0)(@opentelemetry/api@1.9.0)(@playwright/test@1.61.1)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)(sass@1.101.0))(pg@8.22.0)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)(vitest@4.1.9)(vue@3.5.35(typescript@6.0.3)))(mcp-handler@1.1.0(@modelcontextprotocol/sdk@1.29.0(zod@4.4.3))(next@16.2.10(@babel/core@7.29.0)(@opentelemetry/api@1.9.0)(@playwright/test@1.61.1)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)(sass@1.101.0)))(zod@4.4.3)
|
||||
version: 1.3.2(@trpc/server@11.18.0(typescript@6.0.3))(better-auth@1.6.15(@opentelemetry/api@1.9.0)(better-sqlite3@12.11.1)(drizzle-kit@0.31.10)(drizzle-orm@0.45.2(@libsql/client-wasm@0.14.0)(@opentelemetry/api@1.9.0)(@types/better-sqlite3@7.6.13)(@types/pg@8.20.0)(better-sqlite3@12.11.1)(gel@2.0.0)(kysely@0.29.2)(mysql2@3.22.6(@types/node@24.13.3))(pg@8.22.0)(sql.js@1.14.1))(mysql2@3.22.6(@types/node@24.13.3))(next@16.2.10(@babel/core@7.29.0)(@opentelemetry/api@1.9.0)(@playwright/test@1.61.1)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)(sass@1.101.0))(pg@8.22.0)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)(vitest@4.1.10)(vue@3.5.35(typescript@6.0.3)))(mcp-handler@1.1.0(@modelcontextprotocol/sdk@1.29.0(zod@4.4.3))(next@16.2.10(@babel/core@7.29.0)(@opentelemetry/api@1.9.0)(@playwright/test@1.61.1)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)(sass@1.101.0)))(zod@4.4.3)
|
||||
use-deep-compare-effect:
|
||||
specifier: 'catalog:'
|
||||
version: 1.8.1(react@19.2.7)
|
||||
@@ -1355,7 +1367,7 @@ importers:
|
||||
version: 2.2.6
|
||||
trpc-to-mcp:
|
||||
specifier: 'catalog:'
|
||||
version: 1.3.2(@trpc/server@11.18.0(typescript@6.0.3))(better-auth@1.6.15(@opentelemetry/api@1.9.0)(better-sqlite3@12.11.1)(drizzle-kit@0.31.10)(drizzle-orm@0.45.2(@libsql/client-wasm@0.14.0)(@opentelemetry/api@1.9.0)(@types/better-sqlite3@7.6.13)(@types/pg@8.20.0)(better-sqlite3@12.11.1)(gel@2.0.0)(kysely@0.29.2)(mysql2@3.22.5(@types/node@24.13.3))(pg@8.22.0)(sql.js@1.14.1))(mysql2@3.22.5(@types/node@24.13.3))(next@16.2.10(@opentelemetry/api@1.9.0)(@playwright/test@1.61.1)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)(sass@1.101.0))(pg@8.22.0)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)(vitest@4.1.9)(vue@3.5.35(typescript@6.0.3)))(mcp-handler@1.1.0(@modelcontextprotocol/sdk@1.26.0(zod@4.4.3))(next@16.2.10(@opentelemetry/api@1.9.0)(@playwright/test@1.61.1)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)(sass@1.101.0)))(zod@4.4.3)
|
||||
version: 1.3.2(@trpc/server@11.18.0(typescript@6.0.3))(better-auth@1.6.15(@opentelemetry/api@1.9.0)(better-sqlite3@12.11.1)(drizzle-kit@0.31.10)(drizzle-orm@0.45.2(@libsql/client-wasm@0.14.0)(@opentelemetry/api@1.9.0)(@types/better-sqlite3@7.6.13)(@types/pg@8.20.0)(better-sqlite3@12.11.1)(gel@2.0.0)(kysely@0.29.2)(mysql2@3.22.6(@types/node@24.13.3))(pg@8.22.0)(sql.js@1.14.1))(mysql2@3.22.6(@types/node@24.13.3))(next@16.2.10(@opentelemetry/api@1.9.0)(@playwright/test@1.61.1)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)(sass@1.101.0))(pg@8.22.0)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)(vitest@4.1.10)(vue@3.5.35(typescript@6.0.3)))(mcp-handler@1.1.0(@modelcontextprotocol/sdk@1.26.0(zod@4.4.3))(next@16.2.10(@opentelemetry/api@1.9.0)(@playwright/test@1.61.1)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)(sass@1.101.0)))(zod@4.4.3)
|
||||
trpc-to-openapi:
|
||||
specifier: 'catalog:'
|
||||
version: 3.3.0(patch_hash=2ca3c16af0fcca0c736697ad4fe553a14f794524fa9ce0d5c3e8ee4aea76090c)(@trpc/server@11.18.0(typescript@6.0.3))(zod-openapi@5.3.0(zod@4.4.3))(zod@4.4.3)
|
||||
@@ -1537,13 +1549,13 @@ importers:
|
||||
version: 0.2.9
|
||||
drizzle-orm:
|
||||
specifier: 'catalog:'
|
||||
version: 0.45.2(@libsql/client-wasm@0.14.0)(@opentelemetry/api@1.9.0)(@types/better-sqlite3@7.6.13)(@types/pg@8.20.0)(better-sqlite3@12.11.1)(gel@2.0.0)(kysely@0.29.2)(mysql2@3.22.5(@types/node@24.13.2))(pg@8.22.0)(sql.js@1.14.1)
|
||||
version: 0.45.2(@libsql/client-wasm@0.14.0)(@opentelemetry/api@1.9.0)(@types/better-sqlite3@7.6.13)(@types/pg@8.20.0)(better-sqlite3@12.11.1)(gel@2.0.0)(kysely@0.29.2)(mysql2@3.22.6(@types/node@24.13.3))(pg@8.22.0)(sql.js@1.14.1)
|
||||
ioredis:
|
||||
specifier: 'catalog:'
|
||||
version: 5.11.1
|
||||
mysql2:
|
||||
specifier: 'catalog:'
|
||||
version: 3.22.5(@types/node@24.13.2)
|
||||
version: 3.22.6(@types/node@24.13.3)
|
||||
pg:
|
||||
specifier: 'catalog:'
|
||||
version: 8.22.0
|
||||
@@ -1712,13 +1724,13 @@ importers:
|
||||
version: 0.31.10
|
||||
drizzle-orm:
|
||||
specifier: 'catalog:'
|
||||
version: 0.45.2(@libsql/client-wasm@0.14.0)(@opentelemetry/api@1.9.0)(@types/better-sqlite3@7.6.13)(@types/pg@8.20.0)(better-sqlite3@12.11.1)(gel@2.0.0)(kysely@0.29.2)(mysql2@3.22.5(@types/node@24.13.2))(pg@8.22.0)(sql.js@1.14.1)
|
||||
version: 0.45.2(@libsql/client-wasm@0.14.0)(@opentelemetry/api@1.9.0)(@types/better-sqlite3@7.6.13)(@types/pg@8.20.0)(better-sqlite3@12.11.1)(gel@2.0.0)(kysely@0.29.2)(mysql2@3.22.6(@types/node@24.13.3))(pg@8.22.0)(sql.js@1.14.1)
|
||||
drizzle-zod:
|
||||
specifier: 'catalog:'
|
||||
version: 0.8.3(drizzle-orm@0.45.2(@libsql/client-wasm@0.14.0)(@opentelemetry/api@1.9.0)(@types/better-sqlite3@7.6.13)(@types/pg@8.20.0)(better-sqlite3@12.11.1)(gel@2.0.0)(kysely@0.29.2)(mysql2@3.22.5(@types/node@24.13.2))(pg@8.22.0)(sql.js@1.14.1))(zod@4.4.3)
|
||||
version: 0.8.3(drizzle-orm@0.45.2(@libsql/client-wasm@0.14.0)(@opentelemetry/api@1.9.0)(@types/better-sqlite3@7.6.13)(@types/pg@8.20.0)(better-sqlite3@12.11.1)(gel@2.0.0)(kysely@0.29.2)(mysql2@3.22.6(@types/node@24.13.3))(pg@8.22.0)(sql.js@1.14.1))(zod@4.4.3)
|
||||
mysql2:
|
||||
specifier: 'catalog:'
|
||||
version: 3.22.5(@types/node@24.13.2)
|
||||
version: 3.22.6(@types/node@24.13.3)
|
||||
pg:
|
||||
specifier: 'catalog:'
|
||||
version: 8.22.0
|
||||
@@ -8093,20 +8105,20 @@ packages:
|
||||
babel-plugin-react-compiler:
|
||||
optional: true
|
||||
|
||||
'@vitest/coverage-v8@4.1.9':
|
||||
resolution: {integrity: sha512-G9/lgqibheLVBDRuya45EbsEXTYcWoSG+TLg7i2axuzx0Eq62eXn+aWXyaVdV5vKvFSWd6ywcX8hA7la9Pvu8g==}
|
||||
'@vitest/coverage-v8@4.1.10':
|
||||
resolution: {integrity: sha512-IM49HmthevbgAO4anp1hwtoT9wYe59w0LR00gr+eagHE+ZJ5lK4sLPeO0ubgoJcwLk6dehU3R24N+FbEEKDc8g==}
|
||||
peerDependencies:
|
||||
'@vitest/browser': 4.1.9
|
||||
vitest: 4.1.9
|
||||
'@vitest/browser': 4.1.10
|
||||
vitest: 4.1.10
|
||||
peerDependenciesMeta:
|
||||
'@vitest/browser':
|
||||
optional: true
|
||||
|
||||
'@vitest/expect@4.1.9':
|
||||
resolution: {integrity: sha512-vl/rYsUKcBr3SnQn166+XR5ZQcgMx3DQhFWdfli/cWpLnLUmbxZvyrJZotLFUryib+LtArYMSTJ5RbQ57ZqrlA==}
|
||||
'@vitest/expect@4.1.10':
|
||||
resolution: {integrity: sha512-YsCn+qAk1GWjQOWFEsEcL2gNQ0zmVmQu3T03qP6UyjhtmdtwtbuI+DASn/7iQB3HGTXkdBwGddzxPlmiql5vlA==}
|
||||
|
||||
'@vitest/mocker@4.1.9':
|
||||
resolution: {integrity: sha512-EVkXzBjrPGM+cK8/ANWgBrkUCfJfb38/EfTSO8h7pWvKkyPkpWxvR7BkD2MyItMF62C97zAEoqdpUixwR/e+Rw==}
|
||||
'@vitest/mocker@4.1.10':
|
||||
resolution: {integrity: sha512-v0xaezt+DKEmKfaxg133ldzADrwLGd7Ze1MfQQTYfvs8OqZIwbxyxaYURivwV7sWy5fqn3rH5uOrSp07bp44Ow==}
|
||||
peerDependencies:
|
||||
msw: ^2.4.9
|
||||
vite: ^6.0.0 || ^7.0.0 || ^8.0.0
|
||||
@@ -8116,25 +8128,25 @@ packages:
|
||||
vite:
|
||||
optional: true
|
||||
|
||||
'@vitest/pretty-format@4.1.9':
|
||||
resolution: {integrity: sha512-s0iufns3iIFitdgm+YR7g1whCAaGtXz459VS9/PqyKDEEFgYIhsHOQmXgIgDuYCt7DeQmiZT0Qe2OA2p4ZPu5A==}
|
||||
'@vitest/pretty-format@4.1.10':
|
||||
resolution: {integrity: sha512-W1HsjSH4MXQ9YfmmhLAoIYf1HRfekQCGngeIgcei6MP5QQGWUe0gkopdZQaVCFO+JDJMrAJGwa5pRpNpvy4P8Q==}
|
||||
|
||||
'@vitest/runner@4.1.9':
|
||||
resolution: {integrity: sha512-KXLMDtc7oe70+3mJfGrPUWPesswH+3sTxAMAMl8DG7I8IUQT4XW718dY5ID3vPUcmlu27CcKfY4P3h3I29SLJg==}
|
||||
'@vitest/runner@4.1.10':
|
||||
resolution: {integrity: sha512-IKI6kpIH+LmpROplyLwBBaCfMgOZOMsygVa6BARD6ahA04VRuJSa6OaVG7kRvSEMD870Vd91rSSw0eegtWyLGg==}
|
||||
|
||||
'@vitest/snapshot@4.1.9':
|
||||
resolution: {integrity: sha512-Jc7RKGNBo8Z28WYIm0Niej4xdSPByRf6mU58VpHQkd6Zh05rlnA+twjbK5HyeIGHxrzsc3mJgS43uM0CZKzaIA==}
|
||||
'@vitest/snapshot@4.1.10':
|
||||
resolution: {integrity: sha512-xRkfOT1qpTAi/Ti4Y1LtfRc3kEuqxGw59eN2jN9pRWMtS/XDevekhcFSqvQqjUNGksfjMJu3Y+oJ+4Ypn2OaJw==}
|
||||
|
||||
'@vitest/spy@4.1.9':
|
||||
resolution: {integrity: sha512-fHpsS6mIi+PiEW+vcRVOMkX1oSaPKne3VOclSFICPcGOmfKgXPU5iAah+wcNcj2xPrCCmfq99IDGf+EojhhvhA==}
|
||||
'@vitest/spy@4.1.10':
|
||||
resolution: {integrity: sha512-PLf/Ugvoq5wO/b4rwYCR1h2PSIdXz7wnkQFMiUpLdtM7l6pqVFcQIBEHyT1+l+cj7mNwAfZHzqXqDyjvOuwbDw==}
|
||||
|
||||
'@vitest/ui@4.1.9':
|
||||
resolution: {integrity: sha512-U/cRvtqfEPj27FI1n9cyUvi4vXXdcLhjJiI+InYKdk8hP4VrS6RXOjGL7rfFaeBc37iRKANsR6eEzIoC7lmgBQ==}
|
||||
'@vitest/ui@4.1.10':
|
||||
resolution: {integrity: sha512-EOUqfXHTXtpSHsyLHH40ts3Ue+hRhSGwzwzMlK0dTEOLSDYyOXLyr5JDGmHQWhN2DYI30gw6dVx3cdgM9FZl+Q==}
|
||||
peerDependencies:
|
||||
vitest: 4.1.9
|
||||
vitest: 4.1.10
|
||||
|
||||
'@vitest/utils@4.1.9':
|
||||
resolution: {integrity: sha512-A51o8ymO5PpqlWNnBP9ZHPXDIpuMtTLlGSjN7la4US+LJzoUMyhwjA5QXlm39JexgwHKW4Xjs8Z2d3dLCXOeuA==}
|
||||
'@vitest/utils@4.1.10':
|
||||
resolution: {integrity: sha512-fy9am/HWxbaGt/Sawrp90vt6Y6jQwf1RX77cz3uwoJwJVMli/e1IEwRPnMNJ7vKfPTwo0diXifkpPvwH9v7nGA==}
|
||||
|
||||
'@vue/compiler-core@3.5.35':
|
||||
resolution: {integrity: sha512-BUmHaR1J+O+CKZ9uJucdVTEr1LHsdyvv7vG3eNRhK3CczEHeMd/LtsHAuD7PbrxvI2envCY2v7HI1vC1aBRzKw==}
|
||||
@@ -12042,8 +12054,8 @@ packages:
|
||||
engines: {node: '>=8', npm: '>=5'}
|
||||
hasBin: true
|
||||
|
||||
mysql2@3.22.5:
|
||||
resolution: {integrity: sha512-95uZ2TrPWAZdwpB3vvvDbmEMcNG8yIeNCyu6GUcr/QnWEE/wXm7+mhOCsdQfWQDTV7qYT/PDUZ4U4UPP4AsXqQ==}
|
||||
mysql2@3.22.6:
|
||||
resolution: {integrity: sha512-fPKmeDGUzvFP7bMD5SASlJ5zIgvCC4hbanTmhbUlEmhyrY1hR4Hi3xLOWgTd3luYjLVifx6uGvMNJ2m/LlqEpg==}
|
||||
engines: {node: '>= 8.0'}
|
||||
peerDependencies:
|
||||
'@types/node': '>= 8'
|
||||
@@ -14117,8 +14129,8 @@ packages:
|
||||
sprintf-js@1.1.3:
|
||||
resolution: {integrity: sha512-Oo+0REFV59/rz3gfJNKQiBlwfHaSESl1pcGyABQsnnIfWOFt6JNj5gCog2U6MLZ//IGYD+nA8nI+mTShREReaA==}
|
||||
|
||||
sql-escaper@1.3.3:
|
||||
resolution: {integrity: sha512-BsTCV265VpTp8tm1wyIm1xqQCS+Q9NHx2Sr+WcnUrgLrQ6yiDIvHYJV5gHxsj1lMBy2zm5twLaZao8Jd+S8JJw==}
|
||||
sql-escaper@1.4.0:
|
||||
resolution: {integrity: sha512-Ti/Zx9J3aITMYUMFhCKbkplQjyi7Kk2SyYXp+rzrkyKZetIy19XbQtVZ+0ZR5aVm/178tIJ6+aVvBqXkH+Xs7w==}
|
||||
engines: {bun: '>=1.0.0', deno: '>=2.0.0', node: '>=12.0.0'}
|
||||
|
||||
sql.js@1.14.1:
|
||||
@@ -14998,20 +15010,20 @@ packages:
|
||||
yaml:
|
||||
optional: true
|
||||
|
||||
vitest@4.1.9:
|
||||
resolution: {integrity: sha512-nE3/LEyc0z87uHYLZebqCUOaJr2hdtuPp7BQ4BosVFnfltxgAvMG08NyrSGlPpOUWvR27c5flSmYFTNr78L9GQ==}
|
||||
vitest@4.1.10:
|
||||
resolution: {integrity: sha512-R9jUTe5S4Qb0HCd4TNqpC7oGcrMssMRGXLW80ubjWsW9VH5GF8y1Y0SFLY9AbqSk6nt0PnOx4H4WNJYZ13GUPw==}
|
||||
engines: {node: ^20.0.0 || ^22.0.0 || >=24.0.0}
|
||||
hasBin: true
|
||||
peerDependencies:
|
||||
'@edge-runtime/vm': '*'
|
||||
'@opentelemetry/api': ^1.9.0
|
||||
'@types/node': ^20.0.0 || ^22.0.0 || >=24.0.0
|
||||
'@vitest/browser-playwright': 4.1.9
|
||||
'@vitest/browser-preview': 4.1.9
|
||||
'@vitest/browser-webdriverio': 4.1.9
|
||||
'@vitest/coverage-istanbul': 4.1.9
|
||||
'@vitest/coverage-v8': 4.1.9
|
||||
'@vitest/ui': 4.1.9
|
||||
'@vitest/browser-playwright': 4.1.10
|
||||
'@vitest/browser-preview': 4.1.10
|
||||
'@vitest/browser-webdriverio': 4.1.10
|
||||
'@vitest/coverage-istanbul': 4.1.10
|
||||
'@vitest/coverage-v8': 4.1.10
|
||||
'@vitest/ui': 4.1.10
|
||||
happy-dom: '*'
|
||||
jsdom: '*'
|
||||
vite: ^6.0.0 || ^7.0.0 || ^8.0.0
|
||||
@@ -16420,12 +16432,12 @@ snapshots:
|
||||
optionalDependencies:
|
||||
'@opentelemetry/api': 1.9.0
|
||||
|
||||
'@better-auth/drizzle-adapter@1.6.15(@better-auth/core@1.6.15(@better-auth/utils@0.4.1)(@better-fetch/fetch@1.1.21)(@opentelemetry/api@1.9.0)(better-call@1.3.5(zod@4.4.3))(jose@6.2.3)(kysely@0.29.2)(nanostores@1.3.0))(@better-auth/utils@0.4.1)(drizzle-orm@0.45.2(@libsql/client-wasm@0.14.0)(@opentelemetry/api@1.9.0)(@types/better-sqlite3@7.6.13)(@types/pg@8.20.0)(better-sqlite3@12.11.1)(gel@2.0.0)(kysely@0.29.2)(mysql2@3.22.5(@types/node@24.13.3))(pg@8.22.0)(sql.js@1.14.1))':
|
||||
'@better-auth/drizzle-adapter@1.6.15(@better-auth/core@1.6.15(@better-auth/utils@0.4.1)(@better-fetch/fetch@1.1.21)(@opentelemetry/api@1.9.0)(better-call@1.3.5(zod@4.4.3))(jose@6.2.3)(kysely@0.29.2)(nanostores@1.3.0))(@better-auth/utils@0.4.1)(drizzle-orm@0.45.2(@libsql/client-wasm@0.14.0)(@opentelemetry/api@1.9.0)(@types/better-sqlite3@7.6.13)(@types/pg@8.20.0)(better-sqlite3@12.11.1)(gel@2.0.0)(kysely@0.29.2)(mysql2@3.22.6(@types/node@24.13.3))(pg@8.22.0)(sql.js@1.14.1))':
|
||||
dependencies:
|
||||
'@better-auth/core': 1.6.15(@better-auth/utils@0.4.1)(@better-fetch/fetch@1.1.21)(@opentelemetry/api@1.9.0)(better-call@1.3.5(zod@4.4.3))(jose@6.2.3)(kysely@0.29.2)(nanostores@1.3.0)
|
||||
'@better-auth/utils': 0.4.1
|
||||
optionalDependencies:
|
||||
drizzle-orm: 0.45.2(@libsql/client-wasm@0.14.0)(@opentelemetry/api@1.9.0)(@types/better-sqlite3@7.6.13)(@types/pg@8.20.0)(better-sqlite3@12.11.1)(gel@2.0.0)(kysely@0.29.2)(mysql2@3.22.5(@types/node@24.13.3))(pg@8.22.0)(sql.js@1.14.1)
|
||||
drizzle-orm: 0.45.2(@libsql/client-wasm@0.14.0)(@opentelemetry/api@1.9.0)(@types/better-sqlite3@7.6.13)(@types/pg@8.20.0)(better-sqlite3@12.11.1)(gel@2.0.0)(kysely@0.29.2)(mysql2@3.22.6(@types/node@24.13.3))(pg@8.22.0)(sql.js@1.14.1)
|
||||
|
||||
'@better-auth/kysely-adapter@1.6.15(@better-auth/core@1.6.15(@better-auth/utils@0.4.1)(@better-fetch/fetch@1.1.21)(@opentelemetry/api@1.9.0)(better-call@1.3.5(zod@4.4.3))(jose@6.2.3)(kysely@0.29.2)(nanostores@1.3.0))(@better-auth/utils@0.4.1)(kysely@0.29.2)':
|
||||
dependencies:
|
||||
@@ -21807,10 +21819,10 @@ snapshots:
|
||||
optionalDependencies:
|
||||
babel-plugin-react-compiler: 1.0.0
|
||||
|
||||
'@vitest/coverage-v8@4.1.9(vitest@4.1.9)':
|
||||
'@vitest/coverage-v8@4.1.10(vitest@4.1.10)':
|
||||
dependencies:
|
||||
'@bcoe/v8-coverage': 1.0.2
|
||||
'@vitest/utils': 4.1.9
|
||||
'@vitest/utils': 4.1.10
|
||||
ast-v8-to-istanbul: 1.0.0
|
||||
istanbul-lib-coverage: 3.2.2
|
||||
istanbul-lib-report: 3.0.1
|
||||
@@ -21819,29 +21831,29 @@ snapshots:
|
||||
obug: 2.1.1
|
||||
std-env: 4.0.0
|
||||
tinyrainbow: 3.1.0
|
||||
vitest: 4.1.9(@opentelemetry/api@1.9.0)(@types/node@24.13.2)(@vitest/coverage-v8@4.1.9)(@vitest/ui@4.1.9)(jsdom@29.1.1(@noble/hashes@2.0.1))(msw@2.14.6(@types/node@24.13.3)(typescript@6.0.3))(vite@8.1.3(@types/node@24.13.2)(esbuild@0.28.1)(jiti@2.7.0)(sass@1.101.0)(sugarss@5.0.0(postcss@8.5.16))(terser@5.46.1)(tsx@4.21.0)(yaml@2.9.0))
|
||||
vitest: 4.1.10(@opentelemetry/api@1.9.0)(@types/node@24.13.3)(@vitest/coverage-v8@4.1.10)(@vitest/ui@4.1.10)(jsdom@29.1.1(@noble/hashes@2.0.1))(msw@2.14.6(@types/node@24.13.3)(typescript@6.0.3))(vite@8.1.3(@types/node@24.13.2)(esbuild@0.28.1)(jiti@2.7.0)(sass@1.101.0)(sugarss@5.0.0(postcss@8.5.16))(terser@5.46.1)(tsx@4.21.0)(yaml@2.9.0))
|
||||
|
||||
'@vitest/expect@4.1.9':
|
||||
'@vitest/expect@4.1.10':
|
||||
dependencies:
|
||||
'@standard-schema/spec': 1.1.0
|
||||
'@types/chai': 5.2.2
|
||||
'@vitest/spy': 4.1.9
|
||||
'@vitest/utils': 4.1.9
|
||||
'@vitest/spy': 4.1.10
|
||||
'@vitest/utils': 4.1.10
|
||||
chai: 6.2.2
|
||||
tinyrainbow: 3.1.0
|
||||
|
||||
'@vitest/mocker@4.1.9(msw@2.14.6(@types/node@24.13.3)(typescript@6.0.3))(vite@8.1.3(@types/node@24.13.2)(esbuild@0.28.1)(jiti@2.7.0)(sass@1.101.0)(sugarss@5.0.0(postcss@8.5.16))(terser@5.46.1)(tsx@4.21.0)(yaml@2.9.0))':
|
||||
'@vitest/mocker@4.1.10(msw@2.14.6(@types/node@24.13.3)(typescript@6.0.3))(vite@8.1.3(@types/node@24.13.2)(esbuild@0.28.1)(jiti@2.7.0)(sass@1.101.0)(sugarss@5.0.0(postcss@8.5.16))(terser@5.46.1)(tsx@4.21.0)(yaml@2.9.0))':
|
||||
dependencies:
|
||||
'@vitest/spy': 4.1.9
|
||||
'@vitest/spy': 4.1.10
|
||||
estree-walker: 3.0.3
|
||||
magic-string: 0.30.21
|
||||
optionalDependencies:
|
||||
msw: 2.14.6(@types/node@24.13.3)(typescript@6.0.3)
|
||||
vite: 8.1.3(@types/node@24.13.2)(esbuild@0.28.1)(jiti@2.7.0)(sass@1.101.0)(sugarss@5.0.0(postcss@8.5.16))(terser@5.46.1)(tsx@4.21.0)(yaml@2.9.0)
|
||||
|
||||
'@vitest/mocker@4.1.9(msw@2.14.6(@types/node@24.13.3)(typescript@6.0.3))(vite@8.1.3(@types/node@24.13.3)(esbuild@0.28.1)(jiti@2.7.0)(sass@1.101.0)(sugarss@5.0.0(postcss@8.5.16))(terser@5.46.1)(tsx@4.21.0)(yaml@2.9.0))':
|
||||
'@vitest/mocker@4.1.10(msw@2.14.6(@types/node@24.13.3)(typescript@6.0.3))(vite@8.1.3(@types/node@24.13.3)(esbuild@0.28.1)(jiti@2.7.0)(sass@1.101.0)(sugarss@5.0.0(postcss@8.5.16))(terser@5.46.1)(tsx@4.21.0)(yaml@2.9.0))':
|
||||
dependencies:
|
||||
'@vitest/spy': 4.1.9
|
||||
'@vitest/spy': 4.1.10
|
||||
estree-walker: 3.0.3
|
||||
magic-string: 0.30.21
|
||||
optionalDependencies:
|
||||
@@ -21849,38 +21861,38 @@ snapshots:
|
||||
vite: 8.1.3(@types/node@24.13.3)(esbuild@0.28.1)(jiti@2.7.0)(sass@1.101.0)(sugarss@5.0.0(postcss@8.5.16))(terser@5.46.1)(tsx@4.21.0)(yaml@2.9.0)
|
||||
optional: true
|
||||
|
||||
'@vitest/pretty-format@4.1.9':
|
||||
'@vitest/pretty-format@4.1.10':
|
||||
dependencies:
|
||||
tinyrainbow: 3.1.0
|
||||
|
||||
'@vitest/runner@4.1.9':
|
||||
'@vitest/runner@4.1.10':
|
||||
dependencies:
|
||||
'@vitest/utils': 4.1.9
|
||||
'@vitest/utils': 4.1.10
|
||||
pathe: 2.0.3
|
||||
|
||||
'@vitest/snapshot@4.1.9':
|
||||
'@vitest/snapshot@4.1.10':
|
||||
dependencies:
|
||||
'@vitest/pretty-format': 4.1.9
|
||||
'@vitest/utils': 4.1.9
|
||||
'@vitest/pretty-format': 4.1.10
|
||||
'@vitest/utils': 4.1.10
|
||||
magic-string: 0.30.21
|
||||
pathe: 2.0.3
|
||||
|
||||
'@vitest/spy@4.1.9': {}
|
||||
'@vitest/spy@4.1.10': {}
|
||||
|
||||
'@vitest/ui@4.1.9(vitest@4.1.9)':
|
||||
'@vitest/ui@4.1.10(vitest@4.1.10)':
|
||||
dependencies:
|
||||
'@vitest/utils': 4.1.9
|
||||
'@vitest/utils': 4.1.10
|
||||
fflate: 0.8.2
|
||||
flatted: 3.4.2
|
||||
pathe: 2.0.3
|
||||
sirv: 3.0.2
|
||||
tinyglobby: 0.2.17
|
||||
tinyrainbow: 3.1.0
|
||||
vitest: 4.1.9(@opentelemetry/api@1.9.0)(@types/node@24.13.2)(@vitest/coverage-v8@4.1.9)(@vitest/ui@4.1.9)(jsdom@29.1.1(@noble/hashes@2.0.1))(msw@2.14.6(@types/node@24.13.3)(typescript@6.0.3))(vite@8.1.3(@types/node@24.13.2)(esbuild@0.28.1)(jiti@2.7.0)(sass@1.101.0)(sugarss@5.0.0(postcss@8.5.16))(terser@5.46.1)(tsx@4.21.0)(yaml@2.9.0))
|
||||
vitest: 4.1.10(@opentelemetry/api@1.9.0)(@types/node@24.13.3)(@vitest/coverage-v8@4.1.10)(@vitest/ui@4.1.10)(jsdom@29.1.1(@noble/hashes@2.0.1))(msw@2.14.6(@types/node@24.13.3)(typescript@6.0.3))(vite@8.1.3(@types/node@24.13.2)(esbuild@0.28.1)(jiti@2.7.0)(sass@1.101.0)(sugarss@5.0.0(postcss@8.5.16))(terser@5.46.1)(tsx@4.21.0)(yaml@2.9.0))
|
||||
|
||||
'@vitest/utils@4.1.9':
|
||||
'@vitest/utils@4.1.10':
|
||||
dependencies:
|
||||
'@vitest/pretty-format': 4.1.9
|
||||
'@vitest/pretty-format': 4.1.10
|
||||
convert-source-map: 2.0.0
|
||||
tinyrainbow: 3.1.0
|
||||
|
||||
@@ -22428,10 +22440,10 @@ snapshots:
|
||||
|
||||
before-after-hook@4.0.0: {}
|
||||
|
||||
better-auth@1.6.15(@opentelemetry/api@1.9.0)(better-sqlite3@12.11.1)(drizzle-kit@0.31.10)(drizzle-orm@0.45.2(@libsql/client-wasm@0.14.0)(@opentelemetry/api@1.9.0)(@types/better-sqlite3@7.6.13)(@types/pg@8.20.0)(better-sqlite3@12.11.1)(gel@2.0.0)(kysely@0.29.2)(mysql2@3.22.5(@types/node@24.13.3))(pg@8.22.0)(sql.js@1.14.1))(mysql2@3.22.5(@types/node@24.13.3))(next@16.2.10(@opentelemetry/api@1.9.0)(@playwright/test@1.61.1)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)(sass@1.101.0))(pg@8.22.0)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)(vitest@4.1.9)(vue@3.5.35(typescript@6.0.3)):
|
||||
better-auth@1.6.15(@opentelemetry/api@1.9.0)(better-sqlite3@12.11.1)(drizzle-kit@0.31.10)(drizzle-orm@0.45.2(@libsql/client-wasm@0.14.0)(@opentelemetry/api@1.9.0)(@types/better-sqlite3@7.6.13)(@types/pg@8.20.0)(better-sqlite3@12.11.1)(gel@2.0.0)(kysely@0.29.2)(mysql2@3.22.6(@types/node@24.13.3))(pg@8.22.0)(sql.js@1.14.1))(mysql2@3.22.6(@types/node@24.13.3))(next@16.2.10(@opentelemetry/api@1.9.0)(@playwright/test@1.61.1)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)(sass@1.101.0))(pg@8.22.0)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)(vitest@4.1.10)(vue@3.5.35(typescript@6.0.3)):
|
||||
dependencies:
|
||||
'@better-auth/core': 1.6.15(@better-auth/utils@0.4.1)(@better-fetch/fetch@1.1.21)(@opentelemetry/api@1.9.0)(better-call@1.3.5(zod@4.4.3))(jose@6.2.3)(kysely@0.29.2)(nanostores@1.3.0)
|
||||
'@better-auth/drizzle-adapter': 1.6.15(@better-auth/core@1.6.15(@better-auth/utils@0.4.1)(@better-fetch/fetch@1.1.21)(@opentelemetry/api@1.9.0)(better-call@1.3.5(zod@4.4.3))(jose@6.2.3)(kysely@0.29.2)(nanostores@1.3.0))(@better-auth/utils@0.4.1)(drizzle-orm@0.45.2(@libsql/client-wasm@0.14.0)(@opentelemetry/api@1.9.0)(@types/better-sqlite3@7.6.13)(@types/pg@8.20.0)(better-sqlite3@12.11.1)(gel@2.0.0)(kysely@0.29.2)(mysql2@3.22.5(@types/node@24.13.3))(pg@8.22.0)(sql.js@1.14.1))
|
||||
'@better-auth/drizzle-adapter': 1.6.15(@better-auth/core@1.6.15(@better-auth/utils@0.4.1)(@better-fetch/fetch@1.1.21)(@opentelemetry/api@1.9.0)(better-call@1.3.5(zod@4.4.3))(jose@6.2.3)(kysely@0.29.2)(nanostores@1.3.0))(@better-auth/utils@0.4.1)(drizzle-orm@0.45.2(@libsql/client-wasm@0.14.0)(@opentelemetry/api@1.9.0)(@types/better-sqlite3@7.6.13)(@types/pg@8.20.0)(better-sqlite3@12.11.1)(gel@2.0.0)(kysely@0.29.2)(mysql2@3.22.6(@types/node@24.13.3))(pg@8.22.0)(sql.js@1.14.1))
|
||||
'@better-auth/kysely-adapter': 1.6.15(@better-auth/core@1.6.15(@better-auth/utils@0.4.1)(@better-fetch/fetch@1.1.21)(@opentelemetry/api@1.9.0)(better-call@1.3.5(zod@4.4.3))(jose@6.2.3)(kysely@0.29.2)(nanostores@1.3.0))(@better-auth/utils@0.4.1)(kysely@0.29.2)
|
||||
'@better-auth/memory-adapter': 1.6.15(@better-auth/core@1.6.15(@better-auth/utils@0.4.1)(@better-fetch/fetch@1.1.21)(@opentelemetry/api@1.9.0)(better-call@1.3.5(zod@4.4.3))(jose@6.2.3)(kysely@0.29.2)(nanostores@1.3.0))(@better-auth/utils@0.4.1)
|
||||
'@better-auth/mongo-adapter': 1.6.15(@better-auth/core@1.6.15(@better-auth/utils@0.4.1)(@better-fetch/fetch@1.1.21)(@opentelemetry/api@1.9.0)(better-call@1.3.5(zod@4.4.3))(jose@6.2.3)(kysely@0.29.2)(nanostores@1.3.0))(@better-auth/utils@0.4.1)
|
||||
@@ -22450,13 +22462,13 @@ snapshots:
|
||||
optionalDependencies:
|
||||
better-sqlite3: 12.11.1
|
||||
drizzle-kit: 0.31.10
|
||||
drizzle-orm: 0.45.2(@libsql/client-wasm@0.14.0)(@opentelemetry/api@1.9.0)(@types/better-sqlite3@7.6.13)(@types/pg@8.20.0)(better-sqlite3@12.11.1)(gel@2.0.0)(kysely@0.29.2)(mysql2@3.22.5(@types/node@24.13.3))(pg@8.22.0)(sql.js@1.14.1)
|
||||
mysql2: 3.22.5(@types/node@24.13.3)
|
||||
drizzle-orm: 0.45.2(@libsql/client-wasm@0.14.0)(@opentelemetry/api@1.9.0)(@types/better-sqlite3@7.6.13)(@types/pg@8.20.0)(better-sqlite3@12.11.1)(gel@2.0.0)(kysely@0.29.2)(mysql2@3.22.6(@types/node@24.13.3))(pg@8.22.0)(sql.js@1.14.1)
|
||||
mysql2: 3.22.6(@types/node@24.13.3)
|
||||
next: 16.2.10(@babel/core@7.29.0)(@opentelemetry/api@1.9.0)(@playwright/test@1.61.1)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)(sass@1.101.0)
|
||||
pg: 8.22.0
|
||||
react: 19.2.7
|
||||
react-dom: 19.2.7(react@19.2.7)
|
||||
vitest: 4.1.9(@opentelemetry/api@1.9.0)(@types/node@24.13.3)(@vitest/coverage-v8@4.1.9)(@vitest/ui@4.1.9)(jsdom@29.1.1(@noble/hashes@2.0.1))(msw@2.14.6(@types/node@24.13.3)(typescript@6.0.3))(vite@8.1.3(@types/node@24.13.3)(esbuild@0.28.1)(jiti@2.7.0)(sass@1.101.0)(sugarss@5.0.0(postcss@8.5.16))(terser@5.46.1)(tsx@4.21.0)(yaml@2.9.0))
|
||||
vitest: 4.1.10(@opentelemetry/api@1.9.0)(@types/node@24.13.3)(@vitest/coverage-v8@4.1.10)(@vitest/ui@4.1.10)(jsdom@29.1.1(@noble/hashes@2.0.1))(msw@2.14.6(@types/node@24.13.3)(typescript@6.0.3))(vite@8.1.3(@types/node@24.13.3)(esbuild@0.28.1)(jiti@2.7.0)(sass@1.101.0)(sugarss@5.0.0(postcss@8.5.16))(terser@5.46.1)(tsx@4.21.0)(yaml@2.9.0))
|
||||
vue: 3.5.35(typescript@6.0.3)
|
||||
transitivePeerDependencies:
|
||||
- '@cloudflare/workers-types'
|
||||
@@ -23667,7 +23679,7 @@ snapshots:
|
||||
esbuild: 0.25.12
|
||||
tsx: 4.21.0
|
||||
|
||||
drizzle-orm@0.45.2(@libsql/client-wasm@0.14.0)(@opentelemetry/api@1.9.0)(@types/better-sqlite3@7.6.13)(@types/pg@8.20.0)(better-sqlite3@12.11.1)(gel@2.0.0)(kysely@0.29.2)(mysql2@3.22.5(@types/node@24.13.2))(pg@8.22.0)(sql.js@1.14.1):
|
||||
drizzle-orm@0.45.2(@libsql/client-wasm@0.14.0)(@opentelemetry/api@1.9.0)(@types/better-sqlite3@7.6.13)(@types/pg@8.20.0)(better-sqlite3@12.11.1)(gel@2.0.0)(kysely@0.29.2)(mysql2@3.22.6(@types/node@24.13.3))(pg@8.22.0)(sql.js@1.14.1):
|
||||
optionalDependencies:
|
||||
'@libsql/client-wasm': 0.14.0
|
||||
'@opentelemetry/api': 1.9.0
|
||||
@@ -23676,26 +23688,13 @@ snapshots:
|
||||
better-sqlite3: 12.11.1
|
||||
gel: 2.0.0
|
||||
kysely: 0.29.2
|
||||
mysql2: 3.22.5(@types/node@24.13.2)
|
||||
mysql2: 3.22.6(@types/node@24.13.3)
|
||||
pg: 8.22.0
|
||||
sql.js: 1.14.1
|
||||
|
||||
drizzle-orm@0.45.2(@libsql/client-wasm@0.14.0)(@opentelemetry/api@1.9.0)(@types/better-sqlite3@7.6.13)(@types/pg@8.20.0)(better-sqlite3@12.11.1)(gel@2.0.0)(kysely@0.29.2)(mysql2@3.22.5(@types/node@24.13.3))(pg@8.22.0)(sql.js@1.14.1):
|
||||
optionalDependencies:
|
||||
'@libsql/client-wasm': 0.14.0
|
||||
'@opentelemetry/api': 1.9.0
|
||||
'@types/better-sqlite3': 7.6.13
|
||||
'@types/pg': 8.20.0
|
||||
better-sqlite3: 12.11.1
|
||||
gel: 2.0.0
|
||||
kysely: 0.29.2
|
||||
mysql2: 3.22.5(@types/node@24.13.3)
|
||||
pg: 8.22.0
|
||||
sql.js: 1.14.1
|
||||
|
||||
drizzle-zod@0.8.3(drizzle-orm@0.45.2(@libsql/client-wasm@0.14.0)(@opentelemetry/api@1.9.0)(@types/better-sqlite3@7.6.13)(@types/pg@8.20.0)(better-sqlite3@12.11.1)(gel@2.0.0)(kysely@0.29.2)(mysql2@3.22.5(@types/node@24.13.2))(pg@8.22.0)(sql.js@1.14.1))(zod@4.4.3):
|
||||
drizzle-zod@0.8.3(drizzle-orm@0.45.2(@libsql/client-wasm@0.14.0)(@opentelemetry/api@1.9.0)(@types/better-sqlite3@7.6.13)(@types/pg@8.20.0)(better-sqlite3@12.11.1)(gel@2.0.0)(kysely@0.29.2)(mysql2@3.22.6(@types/node@24.13.3))(pg@8.22.0)(sql.js@1.14.1))(zod@4.4.3):
|
||||
dependencies:
|
||||
drizzle-orm: 0.45.2(@libsql/client-wasm@0.14.0)(@opentelemetry/api@1.9.0)(@types/better-sqlite3@7.6.13)(@types/pg@8.20.0)(better-sqlite3@12.11.1)(gel@2.0.0)(kysely@0.29.2)(mysql2@3.22.5(@types/node@24.13.2))(pg@8.22.0)(sql.js@1.14.1)
|
||||
drizzle-orm: 0.45.2(@libsql/client-wasm@0.14.0)(@opentelemetry/api@1.9.0)(@types/better-sqlite3@7.6.13)(@types/pg@8.20.0)(better-sqlite3@12.11.1)(gel@2.0.0)(kysely@0.29.2)(mysql2@3.22.6(@types/node@24.13.3))(pg@8.22.0)(sql.js@1.14.1)
|
||||
zod: 4.4.3
|
||||
|
||||
dunder-proto@1.0.1:
|
||||
@@ -26415,19 +26414,7 @@ snapshots:
|
||||
'@babel/runtime': 7.29.7
|
||||
global: 4.4.0
|
||||
|
||||
mysql2@3.22.5(@types/node@24.13.2):
|
||||
dependencies:
|
||||
'@types/node': 24.13.2
|
||||
aws-ssl-profiles: 1.1.2
|
||||
denque: 2.1.0
|
||||
generate-function: 2.3.1
|
||||
iconv-lite: 0.7.2
|
||||
long: 5.3.2
|
||||
lru.min: 1.1.4
|
||||
named-placeholders: 1.1.6
|
||||
sql-escaper: 1.3.3
|
||||
|
||||
mysql2@3.22.5(@types/node@24.13.3):
|
||||
mysql2@3.22.6(@types/node@24.13.3):
|
||||
dependencies:
|
||||
'@types/node': 24.13.3
|
||||
aws-ssl-profiles: 1.1.2
|
||||
@@ -26437,8 +26424,7 @@ snapshots:
|
||||
long: 5.3.2
|
||||
lru.min: 1.1.4
|
||||
named-placeholders: 1.1.6
|
||||
sql-escaper: 1.3.3
|
||||
optional: true
|
||||
sql-escaper: 1.4.0
|
||||
|
||||
mz@2.7.0:
|
||||
dependencies:
|
||||
@@ -28833,7 +28819,7 @@ snapshots:
|
||||
|
||||
sprintf-js@1.1.3: {}
|
||||
|
||||
sql-escaper@1.3.3: {}
|
||||
sql-escaper@1.4.0: {}
|
||||
|
||||
sql.js@1.14.1: {}
|
||||
|
||||
@@ -29302,22 +29288,22 @@ snapshots:
|
||||
|
||||
trough@2.2.0: {}
|
||||
|
||||
trpc-to-mcp@1.3.2(@trpc/server@11.18.0(typescript@6.0.3))(better-auth@1.6.15(@opentelemetry/api@1.9.0)(better-sqlite3@12.11.1)(drizzle-kit@0.31.10)(drizzle-orm@0.45.2(@libsql/client-wasm@0.14.0)(@opentelemetry/api@1.9.0)(@types/better-sqlite3@7.6.13)(@types/pg@8.20.0)(better-sqlite3@12.11.1)(gel@2.0.0)(kysely@0.29.2)(mysql2@3.22.5(@types/node@24.13.3))(pg@8.22.0)(sql.js@1.14.1))(mysql2@3.22.5(@types/node@24.13.3))(next@16.2.10(@babel/core@7.29.0)(@opentelemetry/api@1.9.0)(@playwright/test@1.61.1)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)(sass@1.101.0))(pg@8.22.0)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)(vitest@4.1.9)(vue@3.5.35(typescript@6.0.3)))(mcp-handler@1.1.0(@modelcontextprotocol/sdk@1.29.0(zod@4.4.3))(next@16.2.10(@babel/core@7.29.0)(@opentelemetry/api@1.9.0)(@playwright/test@1.61.1)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)(sass@1.101.0)))(zod@4.4.3):
|
||||
trpc-to-mcp@1.3.2(@trpc/server@11.18.0(typescript@6.0.3))(better-auth@1.6.15(@opentelemetry/api@1.9.0)(better-sqlite3@12.11.1)(drizzle-kit@0.31.10)(drizzle-orm@0.45.2(@libsql/client-wasm@0.14.0)(@opentelemetry/api@1.9.0)(@types/better-sqlite3@7.6.13)(@types/pg@8.20.0)(better-sqlite3@12.11.1)(gel@2.0.0)(kysely@0.29.2)(mysql2@3.22.6(@types/node@24.13.3))(pg@8.22.0)(sql.js@1.14.1))(mysql2@3.22.6(@types/node@24.13.3))(next@16.2.10(@babel/core@7.29.0)(@opentelemetry/api@1.9.0)(@playwright/test@1.61.1)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)(sass@1.101.0))(pg@8.22.0)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)(vitest@4.1.10)(vue@3.5.35(typescript@6.0.3)))(mcp-handler@1.1.0(@modelcontextprotocol/sdk@1.29.0(zod@4.4.3))(next@16.2.10(@babel/core@7.29.0)(@opentelemetry/api@1.9.0)(@playwright/test@1.61.1)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)(sass@1.101.0)))(zod@4.4.3):
|
||||
dependencies:
|
||||
'@modelcontextprotocol/sdk': 1.29.0(zod@4.4.3)
|
||||
'@trpc/server': 11.18.0(typescript@6.0.3)
|
||||
better-auth: 1.6.15(@opentelemetry/api@1.9.0)(better-sqlite3@12.11.1)(drizzle-kit@0.31.10)(drizzle-orm@0.45.2(@libsql/client-wasm@0.14.0)(@opentelemetry/api@1.9.0)(@types/better-sqlite3@7.6.13)(@types/pg@8.20.0)(better-sqlite3@12.11.1)(gel@2.0.0)(kysely@0.29.2)(mysql2@3.22.5(@types/node@24.13.3))(pg@8.22.0)(sql.js@1.14.1))(mysql2@3.22.5(@types/node@24.13.3))(next@16.2.10(@opentelemetry/api@1.9.0)(@playwright/test@1.61.1)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)(sass@1.101.0))(pg@8.22.0)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)(vitest@4.1.9)(vue@3.5.35(typescript@6.0.3))
|
||||
better-auth: 1.6.15(@opentelemetry/api@1.9.0)(better-sqlite3@12.11.1)(drizzle-kit@0.31.10)(drizzle-orm@0.45.2(@libsql/client-wasm@0.14.0)(@opentelemetry/api@1.9.0)(@types/better-sqlite3@7.6.13)(@types/pg@8.20.0)(better-sqlite3@12.11.1)(gel@2.0.0)(kysely@0.29.2)(mysql2@3.22.6(@types/node@24.13.3))(pg@8.22.0)(sql.js@1.14.1))(mysql2@3.22.6(@types/node@24.13.3))(next@16.2.10(@opentelemetry/api@1.9.0)(@playwright/test@1.61.1)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)(sass@1.101.0))(pg@8.22.0)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)(vitest@4.1.10)(vue@3.5.35(typescript@6.0.3))
|
||||
mcp-handler: 1.1.0(@modelcontextprotocol/sdk@1.29.0(zod@4.4.3))(next@16.2.10(@babel/core@7.29.0)(@opentelemetry/api@1.9.0)(@playwright/test@1.61.1)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)(sass@1.101.0))
|
||||
zod: 4.4.3
|
||||
transitivePeerDependencies:
|
||||
- '@cfworker/json-schema'
|
||||
- supports-color
|
||||
|
||||
trpc-to-mcp@1.3.2(@trpc/server@11.18.0(typescript@6.0.3))(better-auth@1.6.15(@opentelemetry/api@1.9.0)(better-sqlite3@12.11.1)(drizzle-kit@0.31.10)(drizzle-orm@0.45.2(@libsql/client-wasm@0.14.0)(@opentelemetry/api@1.9.0)(@types/better-sqlite3@7.6.13)(@types/pg@8.20.0)(better-sqlite3@12.11.1)(gel@2.0.0)(kysely@0.29.2)(mysql2@3.22.5(@types/node@24.13.3))(pg@8.22.0)(sql.js@1.14.1))(mysql2@3.22.5(@types/node@24.13.3))(next@16.2.10(@opentelemetry/api@1.9.0)(@playwright/test@1.61.1)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)(sass@1.101.0))(pg@8.22.0)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)(vitest@4.1.9)(vue@3.5.35(typescript@6.0.3)))(mcp-handler@1.1.0(@modelcontextprotocol/sdk@1.26.0(zod@4.4.3))(next@16.2.10(@opentelemetry/api@1.9.0)(@playwright/test@1.61.1)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)(sass@1.101.0)))(zod@4.4.3):
|
||||
trpc-to-mcp@1.3.2(@trpc/server@11.18.0(typescript@6.0.3))(better-auth@1.6.15(@opentelemetry/api@1.9.0)(better-sqlite3@12.11.1)(drizzle-kit@0.31.10)(drizzle-orm@0.45.2(@libsql/client-wasm@0.14.0)(@opentelemetry/api@1.9.0)(@types/better-sqlite3@7.6.13)(@types/pg@8.20.0)(better-sqlite3@12.11.1)(gel@2.0.0)(kysely@0.29.2)(mysql2@3.22.6(@types/node@24.13.3))(pg@8.22.0)(sql.js@1.14.1))(mysql2@3.22.6(@types/node@24.13.3))(next@16.2.10(@opentelemetry/api@1.9.0)(@playwright/test@1.61.1)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)(sass@1.101.0))(pg@8.22.0)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)(vitest@4.1.10)(vue@3.5.35(typescript@6.0.3)))(mcp-handler@1.1.0(@modelcontextprotocol/sdk@1.26.0(zod@4.4.3))(next@16.2.10(@opentelemetry/api@1.9.0)(@playwright/test@1.61.1)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)(sass@1.101.0)))(zod@4.4.3):
|
||||
dependencies:
|
||||
'@modelcontextprotocol/sdk': 1.29.0(zod@4.4.3)
|
||||
'@trpc/server': 11.18.0(typescript@6.0.3)
|
||||
better-auth: 1.6.15(@opentelemetry/api@1.9.0)(better-sqlite3@12.11.1)(drizzle-kit@0.31.10)(drizzle-orm@0.45.2(@libsql/client-wasm@0.14.0)(@opentelemetry/api@1.9.0)(@types/better-sqlite3@7.6.13)(@types/pg@8.20.0)(better-sqlite3@12.11.1)(gel@2.0.0)(kysely@0.29.2)(mysql2@3.22.5(@types/node@24.13.3))(pg@8.22.0)(sql.js@1.14.1))(mysql2@3.22.5(@types/node@24.13.3))(next@16.2.10(@opentelemetry/api@1.9.0)(@playwright/test@1.61.1)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)(sass@1.101.0))(pg@8.22.0)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)(vitest@4.1.9)(vue@3.5.35(typescript@6.0.3))
|
||||
better-auth: 1.6.15(@opentelemetry/api@1.9.0)(better-sqlite3@12.11.1)(drizzle-kit@0.31.10)(drizzle-orm@0.45.2(@libsql/client-wasm@0.14.0)(@opentelemetry/api@1.9.0)(@types/better-sqlite3@7.6.13)(@types/pg@8.20.0)(better-sqlite3@12.11.1)(gel@2.0.0)(kysely@0.29.2)(mysql2@3.22.6(@types/node@24.13.3))(pg@8.22.0)(sql.js@1.14.1))(mysql2@3.22.6(@types/node@24.13.3))(next@16.2.10(@opentelemetry/api@1.9.0)(@playwright/test@1.61.1)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)(sass@1.101.0))(pg@8.22.0)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)(vitest@4.1.10)(vue@3.5.35(typescript@6.0.3))
|
||||
mcp-handler: 1.1.0(@modelcontextprotocol/sdk@1.26.0(zod@4.4.3))(next@16.2.10(@opentelemetry/api@1.9.0)(@playwright/test@1.61.1)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)(sass@1.101.0))
|
||||
zod: 4.4.3
|
||||
transitivePeerDependencies:
|
||||
@@ -29750,15 +29736,15 @@ snapshots:
|
||||
yaml: 2.9.0
|
||||
optional: true
|
||||
|
||||
vitest@4.1.9(@opentelemetry/api@1.9.0)(@types/node@24.13.2)(@vitest/coverage-v8@4.1.9)(@vitest/ui@4.1.9)(jsdom@29.1.1(@noble/hashes@2.0.1))(msw@2.14.6(@types/node@24.13.3)(typescript@6.0.3))(vite@8.1.3(@types/node@24.13.2)(esbuild@0.28.1)(jiti@2.7.0)(sass@1.101.0)(sugarss@5.0.0(postcss@8.5.16))(terser@5.46.1)(tsx@4.21.0)(yaml@2.9.0)):
|
||||
vitest@4.1.10(@opentelemetry/api@1.9.0)(@types/node@24.13.3)(@vitest/coverage-v8@4.1.10)(@vitest/ui@4.1.10)(jsdom@29.1.1(@noble/hashes@2.0.1))(msw@2.14.6(@types/node@24.13.3)(typescript@6.0.3))(vite@8.1.3(@types/node@24.13.2)(esbuild@0.28.1)(jiti@2.7.0)(sass@1.101.0)(sugarss@5.0.0(postcss@8.5.16))(terser@5.46.1)(tsx@4.21.0)(yaml@2.9.0)):
|
||||
dependencies:
|
||||
'@vitest/expect': 4.1.9
|
||||
'@vitest/mocker': 4.1.9(msw@2.14.6(@types/node@24.13.3)(typescript@6.0.3))(vite@8.1.3(@types/node@24.13.2)(esbuild@0.28.1)(jiti@2.7.0)(sass@1.101.0)(sugarss@5.0.0(postcss@8.5.16))(terser@5.46.1)(tsx@4.21.0)(yaml@2.9.0))
|
||||
'@vitest/pretty-format': 4.1.9
|
||||
'@vitest/runner': 4.1.9
|
||||
'@vitest/snapshot': 4.1.9
|
||||
'@vitest/spy': 4.1.9
|
||||
'@vitest/utils': 4.1.9
|
||||
'@vitest/expect': 4.1.10
|
||||
'@vitest/mocker': 4.1.10(msw@2.14.6(@types/node@24.13.3)(typescript@6.0.3))(vite@8.1.3(@types/node@24.13.2)(esbuild@0.28.1)(jiti@2.7.0)(sass@1.101.0)(sugarss@5.0.0(postcss@8.5.16))(terser@5.46.1)(tsx@4.21.0)(yaml@2.9.0))
|
||||
'@vitest/pretty-format': 4.1.10
|
||||
'@vitest/runner': 4.1.10
|
||||
'@vitest/snapshot': 4.1.10
|
||||
'@vitest/spy': 4.1.10
|
||||
'@vitest/utils': 4.1.10
|
||||
es-module-lexer: 2.1.0
|
||||
expect-type: 1.3.0
|
||||
magic-string: 0.30.21
|
||||
@@ -29774,22 +29760,22 @@ snapshots:
|
||||
why-is-node-running: 2.3.0
|
||||
optionalDependencies:
|
||||
'@opentelemetry/api': 1.9.0
|
||||
'@types/node': 24.13.2
|
||||
'@vitest/coverage-v8': 4.1.9(vitest@4.1.9)
|
||||
'@vitest/ui': 4.1.9(vitest@4.1.9)
|
||||
'@types/node': 24.13.3
|
||||
'@vitest/coverage-v8': 4.1.10(vitest@4.1.10)
|
||||
'@vitest/ui': 4.1.10(vitest@4.1.10)
|
||||
jsdom: 29.1.1(@noble/hashes@2.0.1)
|
||||
transitivePeerDependencies:
|
||||
- msw
|
||||
|
||||
vitest@4.1.9(@opentelemetry/api@1.9.0)(@types/node@24.13.3)(@vitest/coverage-v8@4.1.9)(@vitest/ui@4.1.9)(jsdom@29.1.1(@noble/hashes@2.0.1))(msw@2.14.6(@types/node@24.13.3)(typescript@6.0.3))(vite@8.1.3(@types/node@24.13.3)(esbuild@0.28.1)(jiti@2.7.0)(sass@1.101.0)(sugarss@5.0.0(postcss@8.5.16))(terser@5.46.1)(tsx@4.21.0)(yaml@2.9.0)):
|
||||
vitest@4.1.10(@opentelemetry/api@1.9.0)(@types/node@24.13.3)(@vitest/coverage-v8@4.1.10)(@vitest/ui@4.1.10)(jsdom@29.1.1(@noble/hashes@2.0.1))(msw@2.14.6(@types/node@24.13.3)(typescript@6.0.3))(vite@8.1.3(@types/node@24.13.3)(esbuild@0.28.1)(jiti@2.7.0)(sass@1.101.0)(sugarss@5.0.0(postcss@8.5.16))(terser@5.46.1)(tsx@4.21.0)(yaml@2.9.0)):
|
||||
dependencies:
|
||||
'@vitest/expect': 4.1.9
|
||||
'@vitest/mocker': 4.1.9(msw@2.14.6(@types/node@24.13.3)(typescript@6.0.3))(vite@8.1.3(@types/node@24.13.3)(esbuild@0.28.1)(jiti@2.7.0)(sass@1.101.0)(sugarss@5.0.0(postcss@8.5.16))(terser@5.46.1)(tsx@4.21.0)(yaml@2.9.0))
|
||||
'@vitest/pretty-format': 4.1.9
|
||||
'@vitest/runner': 4.1.9
|
||||
'@vitest/snapshot': 4.1.9
|
||||
'@vitest/spy': 4.1.9
|
||||
'@vitest/utils': 4.1.9
|
||||
'@vitest/expect': 4.1.10
|
||||
'@vitest/mocker': 4.1.10(msw@2.14.6(@types/node@24.13.3)(typescript@6.0.3))(vite@8.1.3(@types/node@24.13.3)(esbuild@0.28.1)(jiti@2.7.0)(sass@1.101.0)(sugarss@5.0.0(postcss@8.5.16))(terser@5.46.1)(tsx@4.21.0)(yaml@2.9.0))
|
||||
'@vitest/pretty-format': 4.1.10
|
||||
'@vitest/runner': 4.1.10
|
||||
'@vitest/snapshot': 4.1.10
|
||||
'@vitest/spy': 4.1.10
|
||||
'@vitest/utils': 4.1.10
|
||||
es-module-lexer: 2.1.0
|
||||
expect-type: 1.3.0
|
||||
magic-string: 0.30.21
|
||||
@@ -29806,8 +29792,8 @@ snapshots:
|
||||
optionalDependencies:
|
||||
'@opentelemetry/api': 1.9.0
|
||||
'@types/node': 24.13.3
|
||||
'@vitest/coverage-v8': 4.1.9(vitest@4.1.9)
|
||||
'@vitest/ui': 4.1.9(vitest@4.1.9)
|
||||
'@vitest/coverage-v8': 4.1.10(vitest@4.1.10)
|
||||
'@vitest/ui': 4.1.10(vitest@4.1.10)
|
||||
jsdom: 29.1.1(@noble/hashes@2.0.1)
|
||||
transitivePeerDependencies:
|
||||
- msw
|
||||
|
||||
+5
-5
@@ -93,12 +93,12 @@ catalog:
|
||||
"@types/video.js": ^7.3.58
|
||||
"@types/ws": ^8.18.1
|
||||
"@types/xml2js": ^0.4.14
|
||||
posthog-js: ^1.398.2
|
||||
posthog-js: ^1.398.7
|
||||
posthog-node: ^5.39.2
|
||||
pocketbase: ^0.26.2
|
||||
"@vitejs/plugin-react": ^6.0.3
|
||||
"@vitest/coverage-v8": ^4.1.9
|
||||
"@vitest/ui": ^4.1.9
|
||||
"@vitest/coverage-v8": ^4.1.10
|
||||
"@vitest/ui": ^4.1.10
|
||||
"@xterm/addon-canvas": ^0.7.0
|
||||
"@xterm/addon-fit": 0.11.0
|
||||
"@xterm/xterm": ^6.0.0
|
||||
@@ -146,7 +146,7 @@ catalog:
|
||||
mantine-react-table: npm:mantine-react-table-open@9.0.3
|
||||
mcp-handler: ^1.1.0
|
||||
maria2: ^0.4.1
|
||||
mysql2: 3.22.5
|
||||
mysql2: 3.22.6
|
||||
next: 16.2.10
|
||||
next-auth: 5.0.0-beta.31
|
||||
next-intl: 4.13.1
|
||||
@@ -184,7 +184,7 @@ catalog:
|
||||
use-deep-compare-effect: ^1.8.1
|
||||
video.js: ^8.23.9
|
||||
vite-tsconfig-paths: ^6.1.1
|
||||
vitest: ^4.1.9
|
||||
vitest: ^4.1.10
|
||||
winston: 3.19.0
|
||||
ws: ^8.21.0
|
||||
xml2js: ^0.6.2
|
||||
|
||||
+19
-11
@@ -5,17 +5,20 @@ mkdir -p /appdata/db
|
||||
mkdir -p /appdata/redis
|
||||
mkdir -p /appdata/trusted-certificates
|
||||
|
||||
# Run migrations
|
||||
if [ "$DB_MIGRATIONS_DISABLED" = "true" ]; then
|
||||
echo "DB migrations are disabled, skipping"
|
||||
else
|
||||
echo "Running DB migrations"
|
||||
DISABLE_REDIS_LOGS=true node ./db/migrations/$DB_DIALECT/migrate.cjs ./db/migrations/$DB_DIALECT
|
||||
if [ $? -ne 0 ]; then
|
||||
echo "ERROR: DB migrations failed, aborting startup"
|
||||
exit 1
|
||||
fi
|
||||
fi
|
||||
run_migrations() {
|
||||
if [ "${DB_MIGRATIONS_DISABLED:-false}" = "true" ]; then
|
||||
echo "DB migrations are disabled, skipping"
|
||||
return
|
||||
fi
|
||||
|
||||
echo "Running DB migrations"
|
||||
if ! DISABLE_REDIS_LOGS=true node "./db/migrations/${DB_DIALECT}/migrate.cjs" "./db/migrations/${DB_DIALECT}"; then
|
||||
echo "ERROR: DB migrations failed, aborting startup"
|
||||
exit 1
|
||||
fi
|
||||
}
|
||||
|
||||
run_migrations
|
||||
|
||||
# Auth secret is generated every time the container starts as it is required, but not used because we don't need JWTs or Mail hashing
|
||||
export AUTH_SECRET=$(openssl rand -base64 32)
|
||||
@@ -69,6 +72,11 @@ while true; do
|
||||
break
|
||||
fi
|
||||
|
||||
if [ "$EXIT_CODE" -eq 101 ]; then
|
||||
echo "Database was restored, applying migrations before restart"
|
||||
run_migrations
|
||||
fi
|
||||
|
||||
echo "Next.js exited with code $EXIT_CODE, restarting in 1s..."
|
||||
sleep 1
|
||||
node apps/nextjs/server.js &
|
||||
|
||||
@@ -35,6 +35,9 @@
|
||||
"@homarr/db#build": {
|
||||
"outputs": ["migrations/sqlite/migrate.cjs", "migrations/mysql/migrate.cjs", "migrations/postgresql/migrate.cjs"]
|
||||
},
|
||||
"@homarr/workshop#build": {
|
||||
"outputs": ["dist/**"]
|
||||
},
|
||||
"@homarr/docs#build": {
|
||||
"inputs": ["$TURBO_DEFAULT$", "../../packages/definitions/src/docs/**"],
|
||||
"outputs": ["build/**", ".docusaurus/**"]
|
||||
|
||||
Reference in new issue
Block a user