Compare commits

..
Author SHA1 Message Date
Thomas Camlong 9037da96b5 feat: rebuild workshop 2026-07-13 19:24:31 +02:00
63 changed files with 1192 additions and 2491 deletions

No files matched your search

+1 -11
View File
@@ -67,15 +67,5 @@ UNSAFE_ENABLE_MOCK_INTEGRATION=true
# Whether demo mode is read-only. Defaults to true (the public demo blocks all mutations).
# Set to false for writable preview deployments so the demo user can modify the dashboard.
# DEMO_READ_ONLY=false
# Central Workshop API is https://homarr.dev. Override only when developing
# the standalone Docusaurus or Homarr clients against a local backend.
# Central Homarr Workshop API. Override only when developing the Workshop locally.
# 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=*
+2 -2
View File
@@ -167,7 +167,7 @@ jobs:
- name: Cache Playwright browsers
if: '!env.ACT'
id: playwright-cache
uses: actions/cache@v6
uses: actions/cache@v4
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@v7
uses: actions/upload-artifact@v4
with:
name: playwright-report
path: e2e/test-results/
+56
View File
@@ -0,0 +1,56 @@
name: "[Deployment] Documentation"
on:
push:
branches: ["dev"]
paths:
- "apps/docs/**"
- "packages/definitions/src/docs/**"
- ".github/workflows/deployment-docs.yml"
workflow_dispatch:
permissions:
contents: read
pages: write
id-token: write
concurrency:
group: "pages"
cancel-in-progress: false
env:
SKIP_ENV_VALIDATION: true
TURBO_API: ${{ secrets.TURBO_API }}
TURBO_TEAM: ${{ secrets.TURBO_TEAM }}
TURBO_TOKEN: ${{ secrets.TURBO_TOKEN }}
TURBO_REMOTE_CACHE_SIGNATURE_KEY: ${{ secrets.TURBO_REMOTE_CACHE_SIGNATURE_KEY }}
jobs:
build:
runs-on: ubuntu-latest
timeout-minutes: 10
steps:
- uses: actions/checkout@v7
- name: Setup
uses: ./tooling/github/setup
- name: Build docs
run: pnpm turbo build --filter=@homarr/docs
env:
CI: "true"
- name: Setup Pages
uses: actions/configure-pages@v6
- name: Upload pages artifact
uses: actions/upload-pages-artifact@v5
with:
path: "apps/docs/build"
deploy:
runs-on: ubuntu-latest
needs: build
environment:
name: github-pages
url: ${{ steps.deployment.outputs.page_url }}
steps:
- name: Deploy to GitHub Pages
id: deployment
uses: actions/deploy-pages@v5
-50
View File
@@ -1,50 +0,0 @@
name: Workshop
on:
pull_request:
types: [opened, synchronize, reopened, ready_for_review]
paths: &workshop-paths
- "apps/workshop/**"
- "apps/docs/docusaurus.config.ts"
- "apps/docs/package.json"
- "apps/docs/src/components/workshop/**"
- "apps/docs/src/pages/workshop/**"
- "apps/docs/src/theme/Root.tsx"
- "apps/docs/static/workshop-*"
- "apps/docs/docs/management/workshop/**"
- "packages/workshop/**"
- "packages/validation/**"
- "packages/definitions/**"
- "patches/**"
- "package.json"
- "pnpm-lock.yaml"
- "pnpm-workspace.yaml"
- "turbo.json"
- ".github/workflows/workshop.yml"
push:
branches: [dev]
paths: *workshop-paths
workflow_dispatch:
permissions:
contents: read
concurrency:
group: workshop-${{ github.ref }}-${{ github.event.pull_request.number || github.sha }}
cancel-in-progress: true
jobs:
production-preview:
name: Production preview
runs-on: ubuntu-latest
timeout-minutes: 20
steps:
- uses: actions/checkout@v7
- name: Setup
uses: ./tooling/github/setup
- name: Copy environment defaults
run: cp .env.example .env
- name: Test Workshop backend
run: pnpm test:workshop-backend
- name: Build and smoke-test Workshop
run: pnpm test:workshop-preview
@@ -1,87 +0,0 @@
---
score: 9
p0: 0
p1: 1
timestamp: 2026-07-13T21-26-11Z
slug: apps-docs-src-components-workshop
---
# Workshop V2 audit
## Purpose
The Workshop is Homarr's central community catalog for custom widgets and CSS themes. It must let people discover, inspect, export, publish, update, report, and install creations from homarr.dev and from Homarr itself without requiring raw copy-and-paste as the default workflow.
### Feature North Star
A Homarr user can move from a useful local creation to a safe community submission, or from a community listing to an installed local creation, in one understandable flow with source visibility and an explicit confirmation boundary.
### Developer-experience North Star
Workshop behavior is defined once: one typed contract, one client, versioned PocketBase migrations, deterministic local startup, shared validation fixtures, typed failures, and UI components that contain presentation rather than PocketBase queries or authorization rules.
## Baseline audit
The abandoned prototype is preserved at `origin/feat/widget-store-prototype-backup` (`89260b875`). This rewrite starts at `dev` commit `ba7718bde`.
| Area | Severity | Baseline finding | V2 resolution |
| ------------- | -------- | ----------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------ |
| Branch health | P0 | PR #5984 was based on an obsolete `dev` commit and conflicted with the current branch. | Rebuild from current `dev`; keep the old head on a backup ref. |
| Security | P0 | Submission validation ran in browsers and could be bypassed with direct PocketBase writes. | Enforce validation, ownership, account state, immutable fields, and upload limits in PocketBase hooks and rules. |
| Moderation | P0 | There were no moderator roles, posting bans, disabled accounts, least-privilege actions, or durable moderation history. | Add roles, account states, explicit moderation routes, a staff UI, and append-only actions. |
| Architecture | P1 | Docs and Next.js defined separate record types, queries, mutations, sorting, voting, and error behavior. | Add `@homarr/workshop` as the only public contract and API client. |
| Performance | P1 | The browser downloaded every submission and full content, then searched, sorted, and filtered locally. | Server-side pagination/filtering/sorting; summary views exclude content. |
| Product | P1 | Website and app flows were disconnected and sharing still depended on manual content transfer. | Add contextual install and direct share actions backed by canonical exports. |
| UX/UI | P1 | A large bespoke Docusaurus component set and a separate Mantine modal produced inconsistent interaction and state behavior. | Use each host's native components with shared flows, copy rules, contracts, and states. |
| Accessibility | P1 | Several custom interactive elements bypassed semantic controls and touch targets; focus and reduced-motion behavior was incomplete. | Require semantic controls, visible focus, keyboard operation, 44px touch targets, AA contrast, and reduced motion. |
| Operations | P1 | A third-party PocketBase image and incomplete bootstrap docs made builds and recovery non-deterministic. | Build a pinned official binary with checksum verification; document health, backup, restore, OAuth, and upgrades. |
| Testing | P1 | Tests covered a narrow client schema and did not exercise API rules, roles, auth states, moderation, or cross-surface flows. | Add contract, backend permission-matrix, UI, and end-to-end coverage. |
| Documentation | P2 | Documentation advertised a supported self-hosted Workshop even though V2 is a central Homarr service. | Document the central service and local-development overrides only. |
## Decisions
- Public name: **Workshop**.
- Launch content: custom widgets (`homarr-custom-widget-v2`) and CSS (`homarr-custom-css-v1`).
- Identity: GitHub OAuth only.
- Community features: votes and reports; comments are removed.
- Hosting: one Homarr-operated image and origin at `homarr.dev`; PocketBase serves both compiled docs and `/api`.
- Resilience: the public shell and successfully loaded query data are cached independently; the rest of Homarr never depends on Workshop availability.
- Publishing: immediate, with source inspection, reporting, and staff removal.
- Staff: GitHub-authenticated moderators/admins use `/workshop/admin`; PocketBase superusers are bootstrap/emergency access.
## Launch acceptance
- [x] Anonymous users can browse, search, filter, inspect, copy, and download without receiving full content in listing responses.
- [x] Members can publish, update, delete, vote, and report; ownership and schemas are enforced server-side.
- [x] Posting-banned users cannot create or update submissions but can browse, vote, and report.
- [x] Disabled accounts cannot authenticate or write, including with an existing token.
- [x] Moderators can resolve reports, remove submissions, posting-ban users, and disable/restore non-admin users.
- [x] Only admins can change roles; staff actions are append-only and attributable.
- [x] Homarr can export/share local widgets and CSS and validate/confirm Workshop installs.
- [x] Website and app implement loading, empty, offline, validation, permission, and destructive-action states.
- [x] Keyboard semantics, mobile layout, dark mode, reduced motion, and horizontal overflow received a browser smoke test.
- [x] Clean PocketBase startup, migration, health, backup, and restore procedures are reproducible and documented.
- [x] Documentation and Workshop API are built into one reproducible image and served from `homarr.dev` without production cross-origin requests.
- [ ] Add automated authenticated cross-surface E2E coverage before changing the PR from draft to ready.
## Deferred after V2
Comments, private Workshop deployments, automated approval queues, follows, collections, notifications, arbitrary package types, historical version installation, and personalized recommendations.
## Exit scorecard
The baseline has three P0, seven P1, and one P2 findings. The rewrite closes every product, security, architecture, moderation, performance, operations, documentation, UX, and DX finding. One P1 release-verification item remains: automated authenticated E2E across both public and in-app surfaces. Keep PR #5984 in draft until it lands.
| Gate | Result | Evidence |
| --------------------------- | ------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| Branch ancestry | Pass | Rebuilt from `dev` at `ba7718bde`; prototype preserved at `origin/feat/widget-store-prototype-backup`. |
| Contract | Pass | Five Vitest cases cover widget/CSS fixtures, filenames, limits, screenshot validation, and round trips. |
| Backend | Pass | Disposable PocketBase integration matrix covers roles, account states, ownership forgery, schema rejection, duplicate votes, private reports, escalation, deletion cascades, and audit snapshots. |
| Backend operations | Pass | PocketBase 0.39.6 runs unprivileged and builds with checksum verification; clean boot and a cloned legacy SQLite volume both migrate and become healthy. |
| Type safety | Pass | Scoped Turbo typecheck passes for Workshop, Docs, and Next.js. |
| Lint | Pass | Scoped lint exits clean; remaining warnings predate the rewrite. Workshop has zero warnings. |
| Format | Pass | Every changed supported file passes `oxfmt --check`; repository-wide Docs formatting still reports pre-existing drift. |
| Production builds | Pass | Docusaurus and Next.js production builds pass under Node 24.18.0. |
| Browser smoke | Pass | Desktop/mobile discovery and the disconnected API state render without uncaught errors; cached data remains readable and retry is explicit. |
| Automated cross-surface E2E | Open P1 | Authenticated website-to-Homarr publish/install and moderator flows still need browser automation. |
Final implementation scores (10 is best): security 9, architecture/DX 9, moderation 9, performance 9, responsive UI 9, theming 9, accessibility 8, automated testing 7. Accessibility remains below 9 until authenticated dialogs and destructive flows receive a full keyboard/screen-reader pass.
-23
View File
@@ -1,23 +0,0 @@
# Workshop design system
Workshop is a trustworthy community workbench inside Homarr, not a separate storefront brand.
## Visual direction
- Reuse the host surface: Docusaurus tokens on `homarr.dev`, Mantine components inside Homarr.
- Use Homarr red only for primary action and focus; warnings use restrained amber and errors use red with explanatory text.
- Keep surfaces flat, bordered, and readable. Cards may lift by 3px on pointer hover; no gradients, glass panels, or decorative dashboards.
- Typography follows each host. Headings are compact and confident; body copy stays warm, concrete, and short.
- Spacing follows an 8px rhythm, 12-16px corners, and 44px minimum controls.
## Interaction direction
- The catalog progression is discover → inspect → confirm → use.
- Every remote state has a named loading, empty, cached, unavailable, validation, permission, and success outcome.
- Community source is never executed on the website. Installation always follows source inspection and local validation.
- Motion is limited to 150-200ms hover/state feedback and skeleton loading, with reduced-motion fallbacks.
- Native dialogs or Mantine modals own focus, Escape, backdrop dismissal, and keyboard order.
## Voice
Clear, dependable, and community-minded. Explain what remains usable during failures and provide one direct recovery action. Avoid playful language during moderation, destructive actions, or service failures.
+5 -17
View File
@@ -1,36 +1,23 @@
# syntax=docker/dockerfile:1.25
# syntax=docker/dockerfile:1.10
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 && \
sed -i 's/nodeLinker: hoisted/nodeLinker: isolated/' pnpm-workspace.yaml && \
pnpm fetch && \
sed -i 's/nodeLinker: isolated/nodeLinker: hoisted/' pnpm-workspace.yaml
pnpm config set store-dir /pnpm/store && pnpm fetch
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
pnpm install --recursive --offline --frozen-lockfile
ARG SKIP_ENV_VALIDATION='true'
ARG CI='true'
ARG DISABLE_REDIS_LOGS='true'
ARG TARGETPLATFORM
@@ -82,6 +69,7 @@ COPY nginx.conf /etc/nginx/templates/nginx.conf
ENV DB_URL='/appdata/db/db.sqlite'
ENV DB_DIALECT='sqlite'
ENV DB_DRIVER='better-sqlite3'
ENV AUTH_PROVIDERS='credentials'
ENV REDIS_IS_EXTERNAL='false'
ENV NODE_ENV='production'
-33
View File
@@ -1,33 +0,0 @@
# 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.
+4 -5
View File
@@ -24,10 +24,10 @@ Navigate to `Management` → `Tools` → `Backup`. You must have **admin** permi
Click the **Export** button in the export card. You'll be prompted to type `I understand` to confirm. Once confirmed, a ZIP file downloads containing:
| File | Description |
| --------------- | ------------------------------------------------------------------- |
| `db.sqlite` | Full SQLite database with all entities |
| `metadata.json` | Backup metadata: Homarr version, creation timestamp, encryption key |
| File | Description |
| -------------------- | ------------------------------------------------------------------- |
| `db.sqlite` | Full SQLite database with all entities |
| `metadata.json` | Backup metadata: Homarr version, creation timestamp, encryption key |
The backup includes everything: apps, integrations, secrets, boards, users, groups, permissions, search engines, custom widgets, and server settings. (It just backs-up the whole database)
@@ -54,7 +54,6 @@ Type `I understand` to acknowledge that restoring will replace your current data
The backup ZIP is sent to the server, validated, and applied. After a successful restore:
- **The Next.js server restarts automatically** to reload the new database state
- **Server-side database migrations run before Next.js restarts**, including migrations added after the backup was created
- Nginx and Redis (if running in Docker) stay up - only the Next.js process restarts
- The page reloads once the server is ready
@@ -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. 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.
Use **Install widget** on **Management → Workshop** to inspect and import community widgets. Use **Share in Workshop** from a widget's action menu to publish its canonical export without manually copying JSON. Secrets are never included in exports and must be configured on each Homarr instance.
## Available Mantine Components for Custom JSX
+3 -9
View File
@@ -19,17 +19,13 @@ 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
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.
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.
Submissions are public immediately. Authors can update or delete their own submissions. Updating content increases its revision and shows the supplied changelog.
@@ -39,8 +35,6 @@ 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. PocketBase creates OAuth accounts directly; staff privileges come only from the server-managed `workshop_staff` collection. Workshop moderators can remove submissions, disable an account, and resolve reports. These actions require a reason and are recorded.
Workshop 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.
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.
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.
+1 -6
View File
@@ -3,10 +3,6 @@ import type { Config } from "@docusaurus/types";
import { themes as prismThemes } from "prism-react-renderer";
const a11yEmoji = require("@fec/remark-a11y-emoji");
// Production uses PocketBase's own origin. The explicit override exists only
// for the standalone Docusaurus development server.
const workshopApiUrl = process.env.WORKSHOP_API_URL ?? process.env.NEXT_PUBLIC_WORKSHOP_API_URL ?? "";
const config: Config = {
title: "Homarr documentation",
tagline: "A simple yet powerful dashboard for your server.",
@@ -21,7 +17,7 @@ const config: Config = {
locales: ["en"],
},
customFields: {
workshopApiUrl,
workshopApiUrl: process.env.NEXT_PUBLIC_WORKSHOP_API_URL ?? "https://store.homarr.dev",
},
onBrokenLinks: "throw",
onBrokenAnchors: "throw",
@@ -309,7 +305,6 @@ const config: Config = {
],
"docusaurus-plugin-image-zoom",
require.resolve("./plugins/validate-docs-coverage"),
require.resolve("./plugins/offline-manifest.mjs"),
function disableExpensiveBundlerOptimizationPlugin() {
return {
name: "disable-expensive-bundler-optimizations",
+1 -4
View File
@@ -5,7 +5,7 @@
"license": "Apache-2.0",
"scripts": {
"docusaurus": "docusaurus",
"dev": "docusaurus start --no-open --host 0.0.0.0 --port 3003",
"dev": "docusaurus start --no-open --port 3003",
"start": "docusaurus start --port 3003",
"build": "docusaurus build",
"serve": "docusaurus serve",
@@ -34,9 +34,6 @@
"@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",
-26
View File
@@ -1,26 +0,0 @@
import { readdir, writeFile } from "node:fs/promises";
import path from "node:path";
const collectFiles = async (directory, root = directory) => {
const entries = await readdir(directory, { withFileTypes: true });
const files = await Promise.all(
entries.map((entry) => {
const absolutePath = path.join(directory, entry.name);
return entry.isDirectory() ? collectFiles(absolutePath, root) : path.relative(root, absolutePath);
}),
);
return files.flat();
};
export default function offlineManifestPlugin() {
return {
name: "workshop-offline-manifest",
async postBuild({ outDir }) {
const assetsDirectory = path.join(outDir, "assets");
const assets = (await collectFiles(assetsDirectory))
.filter((file) => /\.(?:css|js|woff2?)$/.test(file))
.map((file) => `/assets/${file.split(path.sep).join("/")}`);
await writeFile(path.join(outDir, "workshop-assets.json"), JSON.stringify([...assets, "/img/logo.png"]));
},
};
}
@@ -1,11 +1,16 @@
import React, { useEffect, useMemo, useState } from "react";
import React, { useCallback, useEffect, useMemo, useState } from "react";
import useDocusaurusContext from "@docusaurus/useDocusaurusContext";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { WorkshopClient, type WorkshopAccountState, type WorkshopRole, type WorkshopUser } from "@homarr/workshop";
import {
WORKSHOP_API_URL,
WorkshopClient,
type WorkshopAccountState,
type WorkshopModerationAction,
type WorkshopReport,
type WorkshopRole,
type WorkshopUser,
} from "@homarr/workshop";
import { WorkshopQueryProvider } from "./WorkshopQueryProvider";
import { WorkshopDialog, WorkshopServiceState } from "./WorkshopUi";
import styles from "./workshop.module.css";
type PendingAction =
@@ -14,96 +19,53 @@ 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) || window.location.origin),
() => new WorkshopClient((siteConfig.customFields?.workshopApiUrl as string | undefined) ?? WORKSHOP_API_URL),
[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 canModerate = user?.role === "moderator" || user?.role === "admin";
const moderationQuery = useQuery({
queryKey: ["workshop", "moderation"],
queryFn: async () => {
const [reports, users, actions] = await Promise.all([
const load = useCallback(async () => {
const current = await client.refreshAuth();
setUser(current);
if (!current || (current.role !== "moderator" && current.role !== "admin")) return;
try {
const [nextReports, nextUsers, nextActions] = await Promise.all([
client.listReports(),
client.listUsers(),
client.listModerationActions(),
]);
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"),
});
setReports(nextReports);
setUsers(nextUsers);
setActions(nextActions);
} catch (caught) {
setError(caught instanceof Error ? caught.message : "Could not load moderation data");
}
}, [client]);
useEffect(() => client.subscribeToAuth(setUser), [client]);
useEffect(() => {
void client
.refreshAuth()
.then(setUser)
.catch((caught: unknown) =>
setError(caught instanceof Error ? caught.message : "Workshop authentication is unavailable"),
)
.finally(() => setAuthLoading(false));
}, [client]);
void load();
}, [load]);
if (authLoading)
return (
<main className={styles.page}>
<WorkshopAdminSkeleton />
</main>
);
if (!user)
return (
<main className={styles.page}>
<h1>Workshop moderation</h1>
<p>Sign in with a promoted GitHub account to continue.</p>
{error && (
<div className={styles.error} role="alert">
{error}
</div>
)}
<button
className="button button--primary"
disabled={signInMutation.isPending}
onClick={() => {
setError("");
signInMutation.mutate();
onClick={async () => {
await client.signInWithGitHub();
await load();
}}
>
{signInMutation.isPending ? "Connecting…" : "Sign in with GitHub"}
Sign in with GitHub
</button>
</main>
);
@@ -125,12 +87,10 @@ function WorkshopAdminContent() {
<p>Review reports and apply the smallest action needed. Every action requires a reason and is recorded.</p>
</div>
</header>
{(error || moderationQuery.isError) && (
<WorkshopServiceState
cached={Boolean(moderationQuery.data)}
message={error || "Moderation data is unavailable. No action can be applied until Workshop reconnects."}
onRetry={() => void moderationQuery.refetch()}
/>
{error && (
<div className={styles.error} role="alert">
{error}
</div>
)}
<section className={styles.adminSection}>
<h2>Open reports</h2>
@@ -195,6 +155,17 @@ function WorkshopAdminContent() {
"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({
@@ -259,22 +230,19 @@ function WorkshopAdminContent() {
<ReasonDialog
action={pending}
onClose={() => setPending(null)}
onConfirm={(reason) => actionMutation.mutateAsync({ action: pending, reason })}
onConfirm={async (reason) => {
if (pending.kind === "report") await client.resolveReport(pending.id, pending.status, reason);
else if (pending.kind === "state") await client.updateAccountState(pending.id, pending.state, reason);
else await client.updateRole(pending.id, pending.role, reason);
setPending(null);
await load();
}}
/>
)}
</main>
);
}
function WorkshopAdminSkeleton() {
return (
<div aria-label="Loading Workshop moderation">
<h1>Workshop moderation</h1>
<p>Loading account and moderation data…</p>
</div>
);
}
function ReasonDialog({
action,
onClose,
@@ -288,43 +256,45 @@ function ReasonDialog({
const [error, setError] = useState("");
const [saving, setSaving] = useState(false);
return (
<WorkshopDialog titleId="moderation-reason-title" onClose={onClose}>
<h2 id="moderation-reason-title">Confirm moderation action</h2>
<p>
This will apply <strong>{action.kind}</strong> to the selected record.
</p>
<form
onSubmit={async (event) => {
event.preventDefault();
setSaving(true);
try {
await onConfirm(reason);
} catch (caught) {
setError(caught instanceof Error ? caught.message : "Action failed");
setSaving(false);
}
}}
>
<label>
Reason
<textarea
required
minLength={3}
maxLength={1000}
value={reason}
onChange={(event) => setReason(event.target.value)}
/>
</label>
{error && <p className={styles.error}>{error}</p>}
<div className={styles.formActions}>
<button type="button" className="button button--secondary" onClick={onClose}>
Cancel
</button>
<button disabled={saving} className="button button--danger">
{saving ? "Applying…" : "Apply action"}
</button>
</div>
</form>
</WorkshopDialog>
<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>
);
}
+265 -337
View File
@@ -1,4 +1,4 @@
import React, { useEffect, useMemo, useState } from "react";
import React, { useCallback, useEffect, useMemo, useState } from "react";
import Link from "@docusaurus/Link";
import { useHistory, useLocation } from "@docusaurus/router";
import useDocusaurusContext from "@docusaurus/useDocusaurusContext";
@@ -8,16 +8,12 @@ import {
IconCopy,
IconDownload,
IconFlag,
IconHeart,
IconPackage,
IconSearch,
IconSparkles,
IconUpload,
} from "@tabler/icons-react";
import {
MAX_CONTENT_LENGTH,
MAX_SCREENSHOTS,
WORKSHOP_API_URL,
WorkshopClient,
type WorkshopSubmissionDetail,
type WorkshopSubmissionInput,
@@ -26,15 +22,12 @@ 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) || window.location.origin;
const apiUrl = (siteConfig.customFields?.workshopApiUrl as string | undefined) ?? WORKSHOP_API_URL;
return useMemo(() => new WorkshopClient(apiUrl), [apiUrl]);
};
@@ -50,75 +43,56 @@ 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]);
useEffect(() => {
void client.refreshAuth().catch(() => undefined);
}, [client]);
useEffect(() => {
const timeout = window.setTimeout(() => setDebouncedSearch(search), 250);
return () => window.clearTimeout(timeout);
}, [search]);
const listQuery = useQuery({
queryKey: ["workshop", "list", page, type, sort, debouncedSearch, mine ? user?.id : null],
queryFn: ({ signal }) =>
client.list({
page,
type,
sort,
search: debouncedSearch,
author: mine ? user?.id : undefined,
signal,
}),
placeholderData: keepPreviousData,
});
const items = listQuery.data?.items ?? [];
const totalPages = listQuery.data?.totalPages ?? 1;
const load = useCallback(async () => {
setLoading(true);
setError("");
try {
await client.refreshAuth();
const result = await client.list({ page, type, sort, search, author: mine ? client.currentUser?.id : undefined });
setItems(result.items);
setTotalPages(result.totalPages);
} catch (caught) {
setError(caught instanceof Error ? caught.message : "Workshop is unavailable");
} finally {
setLoading(false);
}
}, [client, mine, page, search, sort, type]);
useEffect(() => {
const timeout = window.setTimeout(() => void load(), 250);
return () => window.clearTimeout(timeout);
}, [load]);
const signIn = async () => {
try {
await client.signInWithGitHub();
await load();
} catch (caught) {
setError(caught instanceof Error ? caught.message : "Sign-in failed");
}
};
return (
<main className={styles.page}>
<header className={styles.hero}>
<div>
<span className={styles.eyebrow}>Made by the Homarr community</span>
<h1>Workshop</h1>
<p>Discover useful creations, inspect every line, then bring the ones you trust into Homarr.</p>
<ul className={styles.heroPromises} aria-label="Workshop principles">
<li>
<IconSparkles size={16} /> Community-made
</li>
<li>
<IconPackage size={16} /> Ready to export
</li>
<li>
<IconHeart size={16} /> Ranked by members
</li>
</ul>
<p>Community widgets and CSS themes, ready to inspect and use in Homarr.</p>
</div>
<div className={styles.accountActions}>
{user ? (
@@ -136,12 +110,8 @@ function WorkshopAppContent() {
</button>
</>
) : (
<button
className="button button--primary"
disabled={signInMutation.isPending}
onClick={() => signInMutation.mutate()}
>
<IconBrandGithub size={18} /> {signInMutation.isPending ? "Connecting…" : "Sign in with GitHub"}
<button className="button button--primary" onClick={() => void signIn()}>
<IconBrandGithub size={18} /> Sign in with GitHub
</button>
)}
</div>
@@ -209,14 +179,12 @@ function WorkshopAppContent() {
)}
</div>
{(error || listQuery.isError || listQuery.fetchStatus === "paused") && (
<WorkshopServiceState
cached={items.length > 0}
message={error || undefined}
onRetry={() => void listQuery.refetch()}
/>
{error && (
<div className={styles.error} role="alert">
{error} <button onClick={() => void load()}>Try again</button>
</div>
)}
{listQuery.isPending ? (
{loading ? (
<WorkshopSkeleton />
) : items.length ? (
<div className={styles.grid}>
@@ -224,22 +192,10 @@ function WorkshopAppContent() {
<WorkshopCard client={client} item={item} key={item.id} />
))}
</div>
) : listQuery.isError || listQuery.fetchStatus === "paused" ? null : (
) : (
<section className={styles.empty}>
<IconSearch size={32} aria-hidden />
<h2>No creations found</h2>
<p>Try a broader search or clear the filters to explore everything the community has shared.</p>
<button
className="button button--secondary button--sm"
onClick={() => {
setSearch("");
setType("all");
setSort("top");
setPage(1);
}}
>
Clear filters
</button>
<p>Adjust the filters, or share the first creation in this category.</p>
</section>
)}
@@ -262,7 +218,7 @@ function WorkshopAppContent() {
onClose={() => setShowForm(false)}
onSaved={async () => {
setShowForm(false);
await queryClient.invalidateQueries({ queryKey: ["workshop"] });
await load();
}}
/>
)}
@@ -290,9 +246,7 @@ function WorkshopCard({ client, item }: { client: WorkshopClient; item: Workshop
</span>
</div>
<h2>
<Link className={styles.cardLink} to={`/workshop/${item.id}`}>
{item.title}
</Link>
<Link to={`/workshop/${item.id}`}>{item.title}</Link>
</h2>
<p>{item.description || "No description provided."}</p>
<small>
@@ -304,64 +258,42 @@ 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 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."),
});
const load = useCallback(async () => {
try {
setSubmission(await client.get(id));
} catch (caught) {
setError(caught instanceof Error ? caught.message : "Submission not found");
}
}, [client, id]);
useEffect(() => client.subscribeToAuth(setUser), [client]);
useEffect(() => {
void client.refreshAuth();
}, [client]);
void load();
}, [client, load]);
if (detailQuery.isError)
if (error)
return (
<main className={styles.page}>
<div className={styles.error} role="alert">
This creation is not saved on this device and Workshop could not be reached.{" "}
<button onClick={() => void detailQuery.refetch()}>Try again</button>
{error}
</div>
<Link to="/workshop">Back to Workshop</Link>
</main>
);
if (!detailQuery.data) return <WorkshopSkeleton />;
if (!submission) return <WorkshopSkeleton />;
const submission = detailQuery.data;
const requireAuth = async () => client.currentUser ?? client.signInWithGitHub();
return (
<main className={styles.page}>
<Link to="/workshop">← Back to Workshop</Link>
@@ -397,10 +329,10 @@ function WorkshopDetailRouteContent() {
</button>
<button
className="button button--secondary"
disabled={voteMutation.isPending}
onClick={() => {
setActionError("");
voteMutation.mutate();
onClick={async () => {
await requireAuth();
await client.vote(submission.id, 1);
await load();
}}
>
Upvote ({submission.upvotes})
@@ -415,11 +347,10 @@ function WorkshopDetailRouteContent() {
</button>
<button
className="button button--danger"
disabled={deleteMutation.isPending}
onClick={() => {
onClick={async () => {
if (window.confirm("Delete this submission permanently?")) {
setActionError("");
deleteMutation.mutate();
await client.delete(submission.id);
history.push("/workshop");
}
}}
>
@@ -433,11 +364,6 @@ function WorkshopDetailRouteContent() {
</button>
)}
</div>
{actionError && (
<div className={styles.error} role="alert">
{actionError}
</div>
)}
<section>
<h2>Source</h2>
<p className={styles.sourceHint}>
@@ -462,7 +388,7 @@ function WorkshopDetailRouteContent() {
onClose={() => setEditing(false)}
onSaved={async () => {
setEditing(false);
await queryClient.invalidateQueries({ queryKey: ["workshop", "detail", id] });
await load();
}}
/>
)}
@@ -490,49 +416,51 @@ function ModerationRemovalForm({
onRemoved: () => void;
}) {
const [reason, setReason] = useState("");
const removeMutation = useMutation({
mutationKey: ["workshop", "moderation", "remove", submission.id],
mutationFn: () => client.removeSubmission(submission.id, reason),
onSuccess: onRemoved,
});
const [error, setError] = useState("");
const [pending, setPending] = useState(false);
return (
<WorkshopDialog titleId="remove-submission-title" onClose={onClose}>
<h2 id="remove-submission-title">Remove {submission.title}</h2>
<p>
This permanently deletes the submission, files, votes, and reports. A metadata snapshot is retained in
moderation history.
</p>
<form
onSubmit={(event) => {
event.preventDefault();
removeMutation.mutate();
}}
>
<label>
Reason
<textarea
required
minLength={3}
maxLength={1000}
value={reason}
onChange={(event) => setReason(event.target.value)}
/>
</label>
{removeMutation.error && (
<p className={styles.error} role="alert">
{removeMutation.error instanceof Error ? removeMutation.error.message : "Removal failed"}
</p>
)}
<div className={styles.formActions}>
<button type="button" className="button button--secondary" onClick={onClose}>
Cancel
</button>
<button disabled={removeMutation.isPending} className="button button--danger">
{removeMutation.isPending ? "Removing…" : "Remove permanently"}
</button>
</div>
</form>
</WorkshopDialog>
<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>
);
}
@@ -554,112 +482,117 @@ function SubmissionForm({
const [changelog, setChangelog] = useState(existing?.changelog ?? "");
const [screenshots, setScreenshots] = useState<File[]>([]);
const [removedScreenshots, setRemovedScreenshots] = useState<string[]>([]);
const retainedScreenshotCount = (existing?.screenshots.length ?? 0) - removedScreenshots.length;
const remainingScreenshotSlots = Math.max(0, MAX_SCREENSHOTS - retainedScreenshotCount);
useEffect(() => {
setScreenshots((current) => current.slice(0, remainingScreenshotSlots));
}, [remainingScreenshotSlots]);
const saveMutation = useMutation({
mutationKey: ["workshop", existing ? "update" : "create", existing?.id],
mutationFn: async (input: WorkshopSubmissionInput) => {
const [error, setError] = useState("");
const [pending, setPending] = useState(false);
const submit = async (event: React.FormEvent) => {
event.preventDefault();
setPending(true);
setError("");
const input: WorkshopSubmissionInput = { type, title, description, content, changelog };
try {
if (!client.currentUser) await client.signInWithGitHub();
if (existing) {
return client.update(existing.id, input, screenshots, removedScreenshots);
await client.update(existing.id, input, screenshots, removedScreenshots);
} else {
await client.create(input, screenshots);
}
return client.create(input, screenshots);
},
onSuccess: onSaved,
});
await onSaved();
} catch (caught) {
setError(caught instanceof Error ? caught.message : "Could not save submission");
} finally {
setPending(false);
}
};
return (
<WorkshopDialog titleId="submission-form-title" onClose={onClose}>
<h2 id="submission-form-title">{existing ? "Update creation" : "Share with the community"}</h2>
<form
onSubmit={(event) => {
event.preventDefault();
saveMutation.mutate({ type, title, description, content, changelog });
}}
>
<label>
Type
<select
disabled={Boolean(existing)}
value={type}
onChange={(event) => setType(event.target.value as WorkshopSubmissionType)}
>
<option value="widget">Custom widget</option>
<option value="css">CSS theme</option>
</select>
</label>
<label>
Title
<input
required
minLength={3}
maxLength={100}
value={title}
onChange={(event) => setTitle(event.target.value)}
/>
</label>
<label>
Description
<textarea maxLength={2000} value={description} onChange={(event) => setDescription(event.target.value)} />
</label>
<label>
{type === "widget" ? "Widget JSON" : "CSS source"}
<textarea
className={styles.editor}
required
maxLength={MAX_CONTENT_LENGTH}
value={content}
onChange={(event) => setContent(event.target.value)}
/>
</label>
<label>
Changelog
<textarea maxLength={2000} value={changelog} onChange={(event) => setChangelog(event.target.value)} />
</label>
<label>
Screenshots (PNG, JPG, or WebP, {remainingScreenshotSlots} available)
<input
type="file"
accept="image/png,image/jpeg,image/webp"
multiple
onChange={(event) =>
setScreenshots(Array.from(event.target.files ?? []).slice(0, remainingScreenshotSlots))
}
/>
</label>
{existing?.screenshots.map((screenshot) => (
<label className={styles.checkbox} key={screenshot}>
<input
type="checkbox"
checked={removedScreenshots.includes(screenshot)}
onChange={(event) =>
setRemovedScreenshots((current) =>
event.target.checked
? [...current, screenshot]
: current.filter((filename) => filename !== screenshot),
)
}
/>
Remove existing screenshot {screenshot}
<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>
))}
{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>
<label>
Title
<input
required
minLength={3}
maxLength={100}
value={title}
onChange={(event) => setTitle(event.target.value)}
/>
</label>
<label>
Description
<textarea maxLength={2000} value={description} onChange={(event) => setDescription(event.target.value)} />
</label>
<label>
{type === "widget" ? "Widget JSON" : "CSS source"}
<textarea
className={styles.editor}
required
value={content}
onChange={(event) => setContent(event.target.value)}
/>
</label>
<label>
Changelog
<textarea maxLength={2000} value={changelog} onChange={(event) => setChangelog(event.target.value)} />
</label>
<label>
Screenshots (PNG, JPG, or WebP, up to 5)
<input
type="file"
accept="image/png,image/jpeg,image/webp"
multiple
onChange={(event) => setScreenshots(Array.from(event.target.files ?? []).slice(0, 5))}
/>
</label>
{existing?.screenshots.map((screenshot) => (
<label className={styles.checkbox} key={screenshot}>
<input
type="checkbox"
checked={removedScreenshots.includes(screenshot)}
onChange={(event) =>
setRemovedScreenshots((current) =>
event.target.checked
? [...current, screenshot]
: current.filter((filename) => filename !== screenshot),
)
}
/>
Remove existing screenshot {screenshot}
</label>
))}
{error && (
<p className={styles.error} role="alert">
{error}
</p>
)}
<div className={styles.formActions}>
<button type="button" className="button button--secondary" onClick={onClose}>
Cancel
</button>
<button disabled={pending} className="button button--primary">
{pending ? "Saving…" : existing ? "Save update" : "Publish creation"}
</button>
</div>
</form>
</dialog>
</div>
);
}
@@ -674,58 +607,53 @@ function ReportForm({
}) {
const [category, setCategory] = useState<"malicious" | "spam" | "copyright" | "inappropriate" | "other">("malicious");
const [explanation, setExplanation] = useState("");
const reportMutation = useMutation({
mutationKey: ["workshop", "report", submissionId],
mutationFn: async () => {
if (!client.currentUser) await client.signInWithGitHub();
return client.report(submissionId, category, explanation);
},
onSuccess: onClose,
});
const [error, setError] = useState("");
return (
<WorkshopDialog titleId="report-title" onClose={onClose}>
<h2 id="report-title">Report submission</h2>
<form
onSubmit={(event) => {
event.preventDefault();
reportMutation.mutate();
}}
>
<label>
Reason
<select value={category} onChange={(event) => setCategory(event.target.value as typeof category)}>
<option value="malicious">Malicious</option>
<option value="spam">Spam</option>
<option value="copyright">Copyright</option>
<option value="inappropriate">Inappropriate</option>
<option value="other">Other</option>
</select>
</label>
<label>
Details
<textarea
required
minLength={3}
maxLength={1000}
value={explanation}
onChange={(event) => setExplanation(event.target.value)}
/>
</label>
{reportMutation.error && (
<p className={styles.error} role="alert">
{reportMutation.error instanceof Error ? reportMutation.error.message : "Could not send report"}
</p>
)}
<div className={styles.formActions}>
<button type="button" className="button button--secondary" onClick={onClose}>
Cancel
</button>
<button disabled={reportMutation.isPending} className="button button--primary">
{reportMutation.isPending ? "Sending…" : "Submit report"}
</button>
</div>
</form>
</WorkshopDialog>
<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>
);
}
@@ -1,43 +0,0 @@
import React, { type PropsWithChildren, useState } from "react";
import { createAsyncStoragePersister } from "@tanstack/query-async-storage-persister";
import { QueryClient } from "@tanstack/react-query";
import { PersistQueryClientProvider } from "@tanstack/react-query-persist-client";
const maxAge = 1000 * 60 * 60 * 24;
export function WorkshopQueryProvider({ children }: PropsWithChildren) {
const [client] = useState(
() =>
new QueryClient({
defaultOptions: {
queries: {
gcTime: maxAge,
networkMode: "offlineFirst",
retry: 1,
staleTime: 1000 * 60 * 5,
},
mutations: { networkMode: "online", retry: false },
},
}),
);
const [persister] = useState(() =>
createAsyncStoragePersister({
key: "homarr-workshop-website-cache-v1",
storage: window.localStorage,
}),
);
return (
<PersistQueryClientProvider
client={client}
persistOptions={{
persister,
maxAge,
buster: "workshop-v1",
dehydrateOptions: { shouldDehydrateQuery: (query) => query.state.status === "success" },
}}
>
{children}
</PersistQueryClientProvider>
);
}
@@ -1,69 +0,0 @@
import React, { type PropsWithChildren, useEffect, useRef } from "react";
import Link from "@docusaurus/Link";
import { IconBooks, IconCloudOff, IconRefresh } from "@tabler/icons-react";
import styles from "./workshop.module.css";
export function WorkshopServiceState({
cached,
message,
onRetry,
}: {
cached: boolean;
message?: string;
onRetry: () => void;
}) {
return (
<section className={styles.serviceState} aria-live="polite">
<span className={styles.serviceStateIcon} aria-hidden>
<IconCloudOff size={24} />
</span>
<div>
<h2>{cached ? "Browsing saved creations" : "Workshop is taking a break"}</h2>
<p>
{message ??
(cached
? "The catalog saved on this device is still available. Live details and community actions will return when the service reconnects."
: "Community creations cannot be reached right now. Homarr and its documentation remain fully available.")}
</p>
<div className={styles.serviceStateActions}>
<button className="button button--primary button--sm" onClick={onRetry}>
<IconRefresh size={16} /> Check again
</button>
<Link className="button button--secondary button--sm" to="/docs/management/workshop">
<IconBooks size={16} /> Read the guide
</Link>
</div>
</div>
</section>
);
}
export function WorkshopDialog({
titleId,
onClose,
children,
}: PropsWithChildren<{ titleId: string; onClose: () => void }>) {
const ref = useRef<HTMLDialogElement>(null);
useEffect(() => {
const dialog = ref.current;
if (!dialog) return;
dialog.showModal();
return () => dialog.close();
}, []);
return (
<dialog
ref={ref}
className={styles.dialog}
aria-labelledby={titleId}
onCancel={(event) => {
event.preventDefault();
onClose();
}}
>
<div className={styles.dialogSurface}>{children}</div>
</dialog>
);
}
@@ -1,343 +1,164 @@
.page {
--workshop-ease: cubic-bezier(0.25, 1, 0.5, 1);
max-width: 1180px;
max-width: 1240px;
min-height: 70vh;
margin: 0 auto;
padding: clamp(2rem, 5vw, 4.5rem) 1.25rem 5rem;
padding: 2.5rem 1.25rem 5rem;
}
.hero {
display: grid;
grid-template-columns: minmax(0, 1fr) auto;
align-items: center;
display: flex;
align-items: flex-end;
justify-content: space-between;
gap: 2rem;
margin-bottom: 2rem;
margin-bottom: 1.5rem;
}
.eyebrow {
display: block;
margin-bottom: 0.45rem;
color: var(--ifm-color-primary);
font-size: 0.76rem;
font-weight: 750;
letter-spacing: 0.09em;
text-transform: uppercase;
}
.hero h1,
.detail h1 {
margin: 0;
font-size: clamp(2.35rem, 6vw, 4.25rem);
line-height: 0.98;
letter-spacing: -0.045em;
font-size: 2.25rem;
letter-spacing: -0.025em;
}
.hero p {
max-width: 62ch;
margin: 0.85rem 0 0;
max-width: 65ch;
margin: 0.5rem 0 0;
color: var(--ifm-color-emphasis-700);
font-size: clamp(1rem, 2vw, 1.12rem);
line-height: 1.65;
}
.heroPromises {
display: flex;
flex-wrap: wrap;
gap: 0.55rem 1rem;
padding: 0;
margin: 1.1rem 0 0;
color: var(--ifm-color-emphasis-700);
font-size: 0.82rem;
list-style: none;
}
.heroPromises li,
.accountActions,
.detailActions,
.formActions,
.serviceStateActions {
.formActions {
display: flex;
align-items: center;
}
.heroPromises li {
gap: 0.35rem;
}
.accountActions,
.detailActions,
.formActions,
.serviceStateActions {
flex-wrap: wrap;
gap: 0.7rem;
gap: 0.75rem;
}
.accountActions :global(.button),
.detailActions :global(.button),
.serviceStateActions :global(.button) {
.detailActions :global(.button) {
display: inline-flex;
align-items: center;
justify-content: center;
gap: 0.4rem;
}
.safety {
display: flex;
align-items: flex-start;
gap: 0.75rem;
padding: 0.9rem 1rem;
margin-bottom: 1rem;
border: 1px solid color-mix(in srgb, var(--ifm-color-warning) 55%, var(--ifm-color-emphasis-300));
border-radius: 12px;
background: color-mix(in srgb, var(--ifm-color-warning) 8%, var(--ifm-background-surface-color));
padding: 0.85rem 1rem;
margin-bottom: 1.5rem;
border: 1px solid var(--ifm-color-warning-dark);
border-radius: 10px;
background: color-mix(in srgb, var(--ifm-color-warning) 10%, transparent);
}
.safety svg {
flex: none;
margin-top: 0.1rem;
}
.safety p {
margin: 0;
color: var(--ifm-color-emphasis-800);
font-size: 0.9rem;
}
.toolbar {
display: grid;
grid-template-columns: minmax(260px, 1fr) 150px 150px auto;
display: flex;
align-items: end;
flex-wrap: wrap;
gap: 0.75rem;
padding: 0.85rem;
margin-bottom: 1.25rem;
padding: 0.75rem;
margin-bottom: 1.5rem;
border: 1px solid var(--ifm-color-emphasis-300);
border-radius: 14px;
background: var(--ifm-background-surface-color);
box-shadow: 0 8px 28px rgb(0 0 0 / 0.04);
border-radius: 10px;
}
.toolbar label,
.dialog label {
display: grid;
gap: 0.35rem;
font-size: 0.78rem;
font-weight: 700;
gap: 0.3rem;
font-size: 0.8rem;
font-weight: 600;
}
.toolbar input,
.toolbar select,
.dialog input,
.dialog select,
.dialog textarea {
width: 100%;
min-height: 44px;
padding: 0.6rem 0.72rem;
padding: 0.55rem 0.7rem;
border: 1px solid var(--ifm-color-emphasis-400);
border-radius: 9px;
border-radius: 7px;
color: var(--ifm-font-color-base);
background: var(--ifm-background-color);
background: var(--ifm-background-surface-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;
z-index: 1;
left: 0.8rem;
bottom: 0.82rem;
left: 0.75rem;
bottom: 0.8rem;
color: var(--ifm-color-emphasis-600);
pointer-events: none;
}
.search input {
padding-left: 2.35rem;
width: 100%;
padding-left: 2.25rem;
}
.checkbox {
display: flex !important;
grid-auto-flow: column;
align-items: center;
min-height: 44px;
}
.checkbox input {
width: auto;
min-height: auto;
}
.serviceState {
display: grid;
grid-template-columns: auto minmax(0, 1fr);
gap: 1rem;
padding: clamp(1.1rem, 3vw, 1.5rem);
margin: 1.25rem 0;
border: 1px solid color-mix(in srgb, var(--ifm-color-warning) 45%, var(--ifm-color-emphasis-300));
border-radius: 14px;
background: color-mix(in srgb, var(--ifm-color-warning) 7%, var(--ifm-background-surface-color));
}
.serviceStateIcon {
display: grid;
width: 44px;
height: 44px;
place-items: center;
border-radius: 10px;
color: var(--ifm-color-warning-darkest);
background: color-mix(in srgb, var(--ifm-color-warning) 18%, transparent);
}
.serviceState h2 {
margin: 0 0 0.25rem;
font-size: 1.05rem;
}
.serviceState p {
max-width: 70ch;
margin: 0 0 0.85rem;
color: var(--ifm-color-emphasis-700);
font-size: 0.9rem;
}
.grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(min(100%, 270px), 1fr));
grid-template-columns: repeat(auto-fit, minmax(270px, 1fr));
gap: 1rem;
}
.card {
position: relative;
overflow: hidden;
border: 1px solid var(--ifm-color-emphasis-300);
border-radius: 14px;
border-radius: 12px;
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);
background: color-mix(in srgb, var(--ifm-color-primary) 6%, var(--ifm-color-emphasis-100));
font: 750 1.45rem var(--ifm-font-family-monospace);
font: 700 1.5rem var(--ifm-font-family-monospace);
}
.cardBody {
padding: 1rem;
}
.cardBody h2 {
margin: 0.45rem 0;
margin: 0.4rem 0;
font-size: 1.08rem;
}
.cardLink::after {
position: absolute;
content: "";
inset: 0;
}
.cardLink:focus-visible {
outline: none;
}
.card:has(.cardLink:focus-visible) {
outline: 3px solid var(--ifm-color-primary);
outline-offset: 3px;
}
.cardBody p {
min-height: 2.7em;
margin: 0 0 0.7rem;
margin: 0 0 0.65rem;
color: var(--ifm-color-emphasis-700);
font-size: 0.88rem;
font-size: 0.9rem;
}
.cardBody small {
color: var(--ifm-color-emphasis-600);
}
.cardMeta {
display: flex;
justify-content: space-between;
color: var(--ifm-color-emphasis-700);
font-size: 0.72rem;
font-weight: 750;
letter-spacing: 0.035em;
text-transform: uppercase;
font-size: 0.75rem;
font-weight: 700;
}
.error {
padding: 0.85rem 1rem;
margin: 1rem 0;
border: 1px solid color-mix(in srgb, var(--ifm-color-danger) 45%, transparent);
border-radius: 10px;
border-radius: 8px;
color: var(--ifm-color-danger-contrast-foreground);
background: var(--ifm-color-danger-contrast-background);
}
.empty {
display: grid;
max-width: 620px;
min-height: 310px;
place-items: center;
align-content: center;
gap: 0.45rem;
padding: 3rem 1.25rem;
margin: 0 auto;
padding: 4rem 1rem;
text-align: center;
border: 1px dashed var(--ifm-color-emphasis-400);
border-radius: 16px;
color: var(--ifm-color-emphasis-700);
border-radius: 12px;
}
.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;
@@ -345,248 +166,134 @@
gap: 1rem;
margin-top: 2rem;
}
.pagination button {
min-height: 44px;
padding: 0.5rem 0.85rem;
padding: 0.5rem 0.8rem;
}
.skeleton {
min-height: 310px;
border-radius: 14px;
border-radius: 12px;
background: var(--ifm-color-emphasis-200);
animation: pulse 1.5s ease-in-out infinite;
}
.detail {
margin-top: 1.75rem;
margin-top: 1.5rem;
}
.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-size: 0.78rem;
font-weight: 750;
letter-spacing: 0.07em;
text-transform: uppercase;
font-weight: 700;
}
.gallery {
display: flex;
gap: 1rem;
overflow-x: auto;
padding-bottom: 0.6rem;
margin: 1.5rem 0;
scroll-snap-type: x mandatory;
}
.gallery img {
width: min(720px, 85vw);
max-height: 480px;
object-fit: contain;
border: 1px solid var(--ifm-color-emphasis-300);
border-radius: 12px;
border-radius: 10px;
background: var(--ifm-color-emphasis-100);
scroll-snap-align: start;
}
.detailActions {
margin: 1.5rem 0 2rem;
}
.sourceHint {
color: var(--ifm-color-emphasis-700);
}
.source {
max-height: 560px;
overflow: auto;
padding: 1rem;
border: 1px solid var(--ifm-color-emphasis-300);
border-radius: 10px;
border-radius: 8px;
font-size: 0.78rem;
}
.overlay {
position: fixed;
z-index: 500;
inset: 0;
display: grid;
place-items: center;
padding: 1rem;
background: rgb(0 0 0 / 0.7);
}
.dialog {
width: min(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);
width: min(680px, 100%);
max-height: 90vh;
overflow-y: auto;
padding: clamp(1.15rem, 4vw, 1.6rem);
border: 1px solid var(--ifm-color-emphasis-300);
border-radius: 16px;
padding: 1.25rem;
border-radius: 12px;
background: var(--ifm-background-color);
}
.dialogSurface > h2 {
margin-top: 0;
}
.dialog form {
display: grid;
gap: 1rem;
}
.dialog textarea {
min-height: 96px;
min-height: 88px;
resize: vertical;
}
.dialog .editor {
min-height: 240px;
font-family: var(--ifm-font-family-monospace);
font-size: 0.78rem;
}
.formActions {
justify-content: flex-end;
}
.adminSection {
margin: 2rem 0;
}
.tableWrap {
overflow-x: auto;
border: 1px solid var(--ifm-color-emphasis-300);
border-radius: 12px;
}
.tableWrap table {
width: 100%;
margin: 0;
border-collapse: collapse;
}
.tableWrap th,
.tableWrap td {
padding: 0.75rem;
border: 0;
border-bottom: 1px solid var(--ifm-color-emphasis-300);
text-align: left;
vertical-align: top;
}
.tableWrap tr:last-child td {
border-bottom: 0;
}
.rowActions {
display: flex;
flex-wrap: wrap;
gap: 0.4rem;
}
.rowActions button {
min-height: 36px;
}
@keyframes pulse {
50% {
opacity: 0.55;
}
}
@media (max-width: 820px) {
@media (max-width: 700px) {
.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%;
.accountActions > * {
flex: 1;
justify-content: center;
}
.toolbar {
grid-template-columns: 1fr;
}
.search {
grid-column: auto;
}
.serviceState {
grid-template-columns: 1fr;
}
.pagination {
justify-content: space-between;
gap: 0.5rem;
.toolbar > label {
flex: 1 1 100%;
}
}
@media (prefers-reduced-motion: reduce) {
.skeleton {
animation: none;
}
.card {
transition: none;
}
.card:hover,
.card:focus-within {
transform: none;
}
}
.adminSection {
margin: 2rem 0;
}
.tableWrap {
overflow-x: auto;
}
.tableWrap table {
width: 100%;
border-collapse: collapse;
}
.tableWrap th,
.tableWrap td {
padding: 0.7rem;
border-bottom: 1px solid var(--ifm-color-emphasis-300);
text-align: left;
vertical-align: top;
}
.rowActions {
display: flex;
flex-wrap: wrap;
gap: 0.4rem;
}
.rowActions button {
min-height: 36px;
}
-11
View File
@@ -1,11 +0,0 @@
import React, { type PropsWithChildren, useEffect } from "react";
export default function Root({ children }: PropsWithChildren) {
useEffect(() => {
if (process.env.NODE_ENV === "production" && "serviceWorker" in navigator) {
void navigator.serviceWorker.register("/workshop-sw.js", { scope: "/" });
}
}, []);
return children;
}
-46
View File
@@ -1,46 +0,0 @@
const CACHE_NAME = "homarr-site-v2";
const SHELL_PATHS = ["/", "/workshop/", "/docs/management/workshop/", "/img/logo.png"];
self.addEventListener("install", (event) => {
event.waitUntil(
caches.open(CACHE_NAME).then(async (cache) => {
const manifest = await fetch("/workshop-assets.json")
.then((response) => response.json())
.catch(() => []);
await Promise.allSettled([...SHELL_PATHS, ...manifest].map((path) => cache.add(path)));
}),
);
self.skipWaiting();
});
self.addEventListener("activate", (event) => {
event.waitUntil(
caches
.keys()
.then((keys) => Promise.all(keys.filter((key) => key !== CACHE_NAME).map((key) => caches.delete(key))))
.then(() => self.clients.claim()),
);
});
self.addEventListener("fetch", (event) => {
const request = event.request;
const url = new URL(request.url);
if (request.method !== "GET" || url.origin !== self.location.origin || url.pathname.startsWith("/api/")) return;
event.respondWith(
fetch(request)
.then((response) => {
if (response.ok) {
const copy = response.clone();
event.waitUntil(caches.open(CACHE_NAME).then((cache) => cache.put(request, copy)));
}
return response;
})
.catch(async () => {
const cached = await caches.match(request);
if (cached) return cached;
if (request.mode === "navigate") return (await caches.match("/workshop/")) ?? Response.error();
return Response.error();
}),
);
});
@@ -11,6 +11,7 @@ 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";
@@ -48,7 +49,10 @@ export const CustomCssSettingsContent = ({ board }: Props) => {
</Alert>
<Group justify="space-between">
<UseCssFromWorkshopButton onUse={(css) => form.setFieldValue("customCss", css)} />
<Group>
<UseCssFromWorkshopButton onUse={(css) => form.setFieldValue("customCss", css)} />
<PublishToWorkshopButton type="css" defaultTitle={board.name} getContent={() => form.values.customCss} />
</Group>
<Button type="submit" loading={isPending}>
{t("common.action.saveChanges")}
</Button>
@@ -6,6 +6,7 @@ import {
IconCopy,
IconDots,
IconDownload,
IconShare3,
IconToggleLeft,
IconToggleRight,
IconTrash,
@@ -19,6 +20,8 @@ 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 {
@@ -29,6 +32,7 @@ interface WidgetRef {
export const CustomWidgetRowActions = ({ widget }: { widget: WidgetRef }) => {
const t = useScopedI18n("customWidget");
const workshopT = useScopedI18n("workshop");
const { openConfirmModal } = useConfirmModal();
const deleteMutation = clientApi.customWidget.delete.useMutation();
const duplicateMutation = clientApi.customWidget.duplicate.useMutation();
@@ -140,6 +144,16 @@ export const CustomWidgetRowActions = ({ widget }: { widget: WidgetRef }) => {
<Menu.Item onClick={() => void handleExport()} leftSection={<IconDownload {...iconProps} />}>
{t("action.export")}
</Menu.Item>
<PublishToWorkshopButton
type="widget"
defaultTitle={widget.name}
getContent={async () => JSON.stringify(await utils.customWidget.export.fetch({ id: widget.id }), null, 2)}
renderTrigger={(open) => (
<Menu.Item onClick={open} leftSection={<IconShare3 {...iconProps} />}>
{workshopT("action.share")}
</Menu.Item>
)}
/>
<Menu.Divider />
<Menu.Item
color="red"
@@ -6,7 +6,6 @@ import {
AccordionPanel,
ActionIcon,
ActionIconGroup,
Alert,
Anchor,
Divider,
Group,
@@ -19,14 +18,14 @@ import {
TableTr,
Text,
} from "@mantine/core";
import { IconAlertTriangle, IconPencil, IconPlugX } from "@tabler/icons-react";
import { IconPencil, IconPlugX } from "@tabler/icons-react";
import type { RouterOutputs } from "@homarr/api";
import { api } from "@homarr/api/server";
import { auth } from "@homarr/auth/next";
import { objectEntries } from "@homarr/common";
import type { IntegrationKind } from "@homarr/definitions";
import { getIntegrationName, isIntegrationKind } from "@homarr/definitions";
import { getIntegrationName } from "@homarr/definitions";
import { getScopedI18n } from "@homarr/translation/server";
import { CountBadge, IntegrationAvatar, Link } from "@homarr/ui";
@@ -88,14 +87,11 @@ const IntegrationList = async ({ integrations, activeTab }: IntegrationListProps
const session = await auth();
const hasFullAccess = session?.user.permissions.includes("integration-full-all") ?? false;
const supportedIntegrations = integrations.filter((integration) => isIntegrationKind(integration.kind));
const unsupportedCount = integrations.length - supportedIntegrations.length;
if (supportedIntegrations.length === 0 && unsupportedCount === 0) {
if (integrations.length === 0) {
return <NoResults icon={IconPlugX} title={t("page.list.noResults.title")} />;
}
const groupedIntegrations = supportedIntegrations.reduce(
const groupedIntegrations = integrations.reduce(
(acc, integration) => {
// eslint-disable-next-line @typescript-eslint/no-unnecessary-condition
if (!acc[integration.kind]) {
@@ -112,105 +108,88 @@ const IntegrationList = async ({ integrations, activeTab }: IntegrationListProps
const entries = objectEntries(groupedIntegrations);
return (
<Stack>
{unsupportedCount > 0 && (
<Alert color="yellow" icon={<IconAlertTriangle size={18} />} title={t("page.list.outdated.title")}>
{t("page.list.outdated.description", { count: unsupportedCount })}
</Alert>
)}
{supportedIntegrations.length === 0 ? (
<NoResults icon={IconPlugX} title={t("page.list.noResults.title")} />
) : (
<ActiveTabAccordion defaultValue={activeTab} radius="lg" classNames={classes}>
{entries.map(([kind, kindIntegrations], index) => (
<AccordionItem key={kind} value={kind} data-first={index === 0} data-last={index === entries.length - 1}>
<AccordionControl icon={<IntegrationAvatar size="sm" kind={kind} radius="sm" />}>
<Group>
<Text>{getIntegrationName(kind)}</Text>
<CountBadge count={kindIntegrations.length} />
</Group>
</AccordionControl>
<AccordionPanel>
<Table visibleFrom="md">
<TableThead>
<TableTr>
<TableTh>{t("field.name.label")}</TableTh>
<TableTh>{t("field.url.label")}</TableTh>
<TableTh />
</TableTr>
</TableThead>
<TableTbody>
{kindIntegrations.map((integration) => (
<TableTr key={integration.id}>
<TableTd>{integration.name}</TableTd>
<TableTd>
<Anchor href={integration.url} target="_blank" rel="noreferrer" size="sm">
{integration.url}
</Anchor>
</TableTd>
<TableTd>
<Group justify="end">
{(hasFullAccess || integration.permissions.hasFullAccess) && (
<ActionIconGroup>
<ActionIcon
component={Link}
href={`/manage/integrations/edit/${integration.id}`}
variant="subtle"
color="gray"
aria-label={t("page.edit.title", { name: getIntegrationName(integration.kind) })}
>
<IconPencil size={16} stroke={1.5} />
</ActionIcon>
<DeleteIntegrationActionButton
integration={integration}
count={kindIntegrations.length}
/>
</ActionIconGroup>
)}
</Group>
</TableTd>
</TableTr>
))}
</TableTbody>
</Table>
<ActiveTabAccordion defaultValue={activeTab} radius="lg" classNames={classes}>
{entries.map(([kind, kindIntegrations], index) => (
<AccordionItem key={kind} value={kind} data-first={index === 0} data-last={index === entries.length - 1}>
<AccordionControl icon={<IntegrationAvatar size="sm" kind={kind} radius="sm" />}>
<Group>
<Text>{getIntegrationName(kind)}</Text>
<CountBadge count={kindIntegrations.length} />
</Group>
</AccordionControl>
<AccordionPanel>
<Table visibleFrom="md">
<TableThead>
<TableTr>
<TableTh>{t("field.name.label")}</TableTh>
<TableTh>{t("field.url.label")}</TableTh>
<TableTh />
</TableTr>
</TableThead>
<TableTbody>
{kindIntegrations.map((integration) => (
<TableTr key={integration.id}>
<TableTd>{integration.name}</TableTd>
<TableTd>
<Anchor href={integration.url} target="_blank" rel="noreferrer" size="sm">
{integration.url}
</Anchor>
</TableTd>
<TableTd>
<Group justify="end">
{(hasFullAccess || integration.permissions.hasFullAccess) && (
<ActionIconGroup>
<ActionIcon
component={Link}
href={`/manage/integrations/edit/${integration.id}`}
variant="subtle"
color="gray"
aria-label={t("page.edit.title", { name: getIntegrationName(integration.kind) })}
>
<IconPencil size={16} stroke={1.5} />
</ActionIcon>
<DeleteIntegrationActionButton integration={integration} count={kindIntegrations.length} />
</ActionIconGroup>
)}
</Group>
</TableTd>
</TableTr>
))}
</TableTbody>
</Table>
<Stack gap="xs" hiddenFrom="md">
{kindIntegrations.map((integration, integrationIndex) => (
<Fragment key={integration.id}>
{integrationIndex !== 0 && <Divider />}
<Stack gap={0}>
<Group justify="space-between" align="center" wrap="nowrap">
<Text>{integration.name}</Text>
{(hasFullAccess || integration.permissions.hasFullAccess) && (
<ActionIconGroup>
<ActionIcon
component={Link}
href={`/manage/integrations/edit/${integration.id}`}
variant="subtle"
color="gray"
aria-label={t("page.edit.title", { name: getIntegrationName(integration.kind) })}
>
<IconPencil size={16} stroke={1.5} />
</ActionIcon>
<DeleteIntegrationActionButton
integration={integration}
count={kindIntegrations.length}
/>
</ActionIconGroup>
)}
</Group>
<Anchor href={integration.url} target="_blank" rel="noreferrer" size="sm">
{integration.url}
</Anchor>
</Stack>
</Fragment>
))}
</Stack>
</AccordionPanel>
</AccordionItem>
))}
</ActiveTabAccordion>
)}
</Stack>
<Stack gap="xs" hiddenFrom="md">
{kindIntegrations.map((integration, integrationIndex) => (
<Fragment key={integration.id}>
{integrationIndex !== 0 && <Divider />}
<Stack gap={0}>
<Group justify="space-between" align="center" wrap="nowrap">
<Text>{integration.name}</Text>
{(hasFullAccess || integration.permissions.hasFullAccess) && (
<ActionIconGroup>
<ActionIcon
component={Link}
href={`/manage/integrations/edit/${integration.id}`}
variant="subtle"
color="gray"
aria-label={t("page.edit.title", { name: getIntegrationName(integration.kind) })}
>
<IconPencil size={16} stroke={1.5} />
</ActionIcon>
<DeleteIntegrationActionButton integration={integration} count={kindIntegrations.length} />
</ActionIconGroup>
)}
</Group>
<Anchor href={integration.url} target="_blank" rel="noreferrer" size="sm">
{integration.url}
</Anchor>
</Stack>
</Fragment>
))}
</Stack>
</AccordionPanel>
</AccordionItem>
))}
</ActiveTabAccordion>
);
};
@@ -179,8 +179,8 @@ export async function POST(req: Request) {
}
setTimeout(() => {
console.log("Database restored, restarting server and applying migrations...");
process.exit(101);
console.log("Database restored, restarting server...");
process.exit(0);
}, 500);
return NextResponse.json({ success: true, message: "Database restored. Server is restarting..." });
@@ -1,5 +1,6 @@
"use client";
import type { ReactNode } from "react";
import { useEffect, useMemo, useState } from "react";
import {
Alert,
@@ -11,7 +12,6 @@ import {
Group,
Image,
Modal,
Paper,
Pagination,
Select,
SimpleGrid,
@@ -19,25 +19,17 @@ import {
Stack,
Text,
TextInput,
Textarea,
} from "@mantine/core";
import { useDebouncedValue } from "@mantine/hooks";
import {
IconAlertTriangle,
IconBuildingStore,
IconDownload,
IconExternalLink,
IconSearch,
IconWifiOff,
} from "@tabler/icons-react";
import { keepPreviousData, useMutation, useQuery } from "@tanstack/react-query";
import { useDebouncedValue, useDisclosure } from "@mantine/hooks";
import { IconAlertTriangle, IconBrandGithub, IconDownload, IconExternalLink, IconSearch } from "@tabler/icons-react";
import { useQuery } from "@tanstack/react-query";
import type { WorkshopSubmissionDetail, WorkshopSubmissionType } from "@homarr/workshop";
import { WORKSHOP_API_URL, WORKSHOP_WEB_URL, WorkshopClient, workshopExportFilename } from "@homarr/workshop";
import { useScopedI18n } from "@homarr/translation/client";
import { WorkshopQueryProvider } from "./workshop-query-provider";
const apiUrl = process.env.NEXT_PUBLIC_WORKSHOP_API_URL ?? WORKSHOP_API_URL;
export interface WorkshopBrowserProps {
@@ -47,15 +39,7 @@ export interface WorkshopBrowserProps {
useLabel?: string;
}
export function WorkshopBrowser(props: WorkshopBrowserProps) {
return (
<WorkshopQueryProvider>
<WorkshopBrowserContent {...props} />
</WorkshopQueryProvider>
);
}
function WorkshopBrowserContent({ initialType = "all", lockedType, onUse, useLabel }: WorkshopBrowserProps) {
export function WorkshopBrowser({ initialType = "all", lockedType, onUse, useLabel }: WorkshopBrowserProps) {
const t = useScopedI18n("workshop");
const client = useMemo(() => new WorkshopClient(apiUrl), []);
const [page, setPage] = useState(1);
@@ -63,41 +47,24 @@ function WorkshopBrowserContent({ initialType = "all", lockedType, onUse, useLab
const [sort, setSort] = useState<"top" | "newest">("top");
const [search, setSearch] = useState("");
const [debouncedSearch] = useDebouncedValue(search, 250);
const [selectedId, setSelectedId] = useState<string | null>(null);
const [online, setOnline] = useState(true);
useEffect(() => {
const updateOnline = () => setOnline(window.navigator.onLine);
updateOnline();
window.addEventListener("online", updateOnline);
window.addEventListener("offline", updateOnline);
return () => {
window.removeEventListener("online", updateOnline);
window.removeEventListener("offline", updateOnline);
};
}, []);
const [selected, setSelected] = useState<WorkshopSubmissionDetail | null>(null);
const [detailLoading, setDetailLoading] = useState(false);
const [usePending, setUsePending] = useState(false);
const [useError, setUseError] = useState("");
const query = useQuery({
queryKey: ["workshop", "list", page, type, sort, debouncedSearch],
queryFn: ({ signal }) => client.list({ page, type, sort, search: debouncedSearch, perPage: 12, signal }),
placeholderData: keepPreviousData,
queryFn: () => client.list({ page, type, sort, search: debouncedSearch, perPage: 12 }),
});
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 openDetail = async (id: string) => {
setDetailLoading(true);
try {
setSelected(await client.get(id));
} finally {
setDetailLoading(false);
}
};
const download = (submission: WorkshopSubmissionDetail) => {
const url = URL.createObjectURL(
@@ -112,34 +79,10 @@ function WorkshopBrowserContent({ 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>
<SimpleGrid cols={{ base: 1, sm: lockedType ? 2 : 3 }} spacing="sm">
<Group align="end">
<TextInput
label={t("search.label")}
placeholder={t("search.placeholder")}
@@ -149,6 +92,7 @@ function WorkshopBrowserContent({ initialType = "all", lockedType, onUse, useLab
setSearch(event.currentTarget.value);
setPage(1);
}}
style={{ flex: 1 }}
/>
<Select
label={t("filter.type")}
@@ -176,36 +120,13 @@ function WorkshopBrowserContent({ initialType = "all", lockedType, onUse, useLab
{ value: "newest", label: t("filter.newest") },
]}
/>
</SimpleGrid>
{(query.isError || query.fetchStatus === "paused") && (
<Alert
icon={<IconWifiOff size={18} />}
color={hasCachedResults ? "yellow" : "red"}
title={hasCachedResults ? t("offline.cachedTitle") : t("offline.title")}
>
<Stack gap="sm">
<Text size="sm">
{hasCachedResults
? t("offline.cachedDescription")
: online
? t("offline.unavailableDescription")
: t("offline.description")}
</Text>
<Group wrap="wrap">
<Button size="xs" variant="light" onClick={() => void query.refetch()}>
{t("action.retry")}
</Button>
<Button
size="xs"
variant="subtle"
component="a"
href="https://homarr.dev/docs/management/workshop"
target="_blank"
>
{t("action.website")}
</Button>
</Group>
</Stack>
</Group>
{query.error && (
<Alert color="red" title={t("error.loadTitle")}>
{query.error.message}
<Button variant="subtle" onClick={() => void query.refetch()}>
{t("action.retry")}
</Button>
</Alert>
)}
{query.isLoading ? (
@@ -252,14 +173,14 @@ function WorkshopBrowserContent({ initialType = "all", lockedType, onUse, useLab
<Text size="xs" c="dimmed">
{t("by", { name: item.authorName, revision: item.revision })}
</Text>
<Button variant="light" onClick={() => setSelectedId(item.id)}>
<Button variant="light" onClick={() => void openDetail(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>
@@ -269,25 +190,13 @@ function WorkshopBrowserContent({ initialType = "all", lockedType, onUse, useLab
<Pagination total={query.data?.totalPages ?? 1} value={page} onChange={setPage} mx="auto" />
)}
<Modal
opened={selectedId !== null}
onClose={() => {
setSelectedId(null);
useMutationState.reset();
}}
opened={Boolean(selected) || detailLoading}
onClose={() => setSelected(null)}
title={selected?.title ?? t("loading")}
size="xl"
>
{detailQuery.isPending ? (
{detailLoading || !selected ? (
<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>
@@ -297,18 +206,6 @@ function WorkshopBrowserContent({ 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>
@@ -332,23 +229,111 @@ function WorkshopBrowserContent({ initialType = "all", lockedType, onUse, useLab
</Group>
{onUse && (
<Button
loading={useMutationState.isPending}
disabled={!online}
onClick={() => useMutationState.mutate(selected)}
loading={usePending}
onClick={async () => {
setUsePending(true);
setUseError("");
try {
if (lockedType && selected.type !== lockedType) throw new Error(t("error.wrongType"));
await onUse(selected);
setSelected(null);
} catch (caught) {
setUseError(caught instanceof Error ? caught.message : t("error.install"));
} finally {
setUsePending(false);
}
}}
>
{useLabel ?? t("action.use")}
</Button>
)}
</Group>
{!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>
)}
{useError && <Alert color="red">{useError}</Alert>}
</Stack>
)}
</Modal>
</Stack>
);
}
export function PublishToWorkshopButton({
type,
getContent,
defaultTitle,
compact = false,
renderTrigger,
}: {
type: WorkshopSubmissionType;
getContent: () => Promise<string> | string;
defaultTitle: string;
compact?: boolean;
renderTrigger?: (open: () => void) => ReactNode;
}) {
const t = useScopedI18n("workshop");
const client = useMemo(() => new WorkshopClient(apiUrl), []);
const [opened, { open, close }] = useDisclosure(false);
const [title, setTitle] = useState(defaultTitle);
const [description, setDescription] = useState("");
const [changelog, setChangelog] = useState("");
const [error, setError] = useState("");
const [pending, setPending] = useState(false);
useEffect(() => setTitle(defaultTitle), [defaultTitle]);
const publish = async () => {
setPending(true);
setError("");
try {
if (!client.currentUser) await client.signInWithGitHub();
await client.create({ type, title, description, changelog, content: await getContent() });
close();
} catch (caught) {
setError(caught instanceof Error ? caught.message : t("error.publish"));
} finally {
setPending(false);
}
};
return (
<>
{renderTrigger ? (
renderTrigger(open)
) : (
<Button
size={compact ? "xs" : undefined}
variant="default"
leftSection={<IconBrandGithub size={16} />}
onClick={open}
>
{t("action.share")}
</Button>
)}
<Modal opened={opened} onClose={close} title={t("publish.title")}>
<Stack>
<TextInput
required
label={t("publish.name")}
value={title}
onChange={(event) => setTitle(event.currentTarget.value)}
/>
<Textarea
label={t("publish.description")}
value={description}
onChange={(event) => setDescription(event.currentTarget.value)}
/>
<Textarea
label={t("publish.changelog")}
value={changelog}
onChange={(event) => setChangelog(event.currentTarget.value)}
/>
{error && <Alert color="red">{error}</Alert>}
<Group justify="end">
<Button variant="default" onClick={close}>
{t("action.cancel")}
</Button>
<Button loading={pending} disabled={title.trim().length < 3} onClick={() => void publish()}>
{t("action.publish")}
</Button>
</Group>
</Stack>
</Modal>
</>
);
}
@@ -1,51 +0,0 @@
"use client";
import type { PropsWithChildren } from "react";
import { useState } from "react";
import { createAsyncStoragePersister } from "@tanstack/query-async-storage-persister";
import { QueryClient } from "@tanstack/react-query";
import { PersistQueryClientProvider } from "@tanstack/react-query-persist-client";
const workshopCacheMaxAge = 1000 * 60 * 60 * 24;
export function WorkshopQueryProvider({ children }: PropsWithChildren) {
const [queryClient] = useState(
() =>
new QueryClient({
defaultOptions: {
queries: {
gcTime: workshopCacheMaxAge,
networkMode: "offlineFirst",
refetchOnReconnect: true,
retry: 1,
staleTime: 1000 * 60 * 5,
},
mutations: {
networkMode: "online",
retry: false,
},
},
}),
);
const [persister] = useState(() =>
createAsyncStoragePersister({
key: "homarr-workshop-cache-v1",
storage: typeof window === "undefined" ? undefined : window.localStorage,
throttleTime: 1000,
}),
);
return (
<PersistQueryClientProvider
client={queryClient}
persistOptions={{
persister,
maxAge: workshopCacheMaxAge,
buster: "workshop-v1",
dehydrateOptions: { shouldDehydrateQuery: (query) => query.state.status === "success" },
}}
>
{children}
</PersistQueryClientProvider>
);
}
+5
View File
@@ -0,0 +1,5 @@
GITHUB_CLIENT_ID=
GITHUB_CLIENT_SECRET=
PB_SUPERUSER_EMAIL=
PB_SUPERUSER_PASSWORD=
PB_EXPOSE_PORT=8090
+4
View File
@@ -0,0 +1,4 @@
.env
.env.*
!.env.example
pb_data/
+31
View File
@@ -0,0 +1,31 @@
FROM alpine:3.22 AS download
ARG TARGETARCH
ARG POCKETBASE_VERSION=0.39.6
RUN apk add --no-cache ca-certificates curl unzip
RUN case "${TARGETARCH}" in \
amd64) PB_ARCH=amd64 ;; \
arm64) PB_ARCH=arm64 ;; \
arm) PB_ARCH=armv7 ;; \
*) echo "Unsupported architecture: ${TARGETARCH}" >&2; exit 1 ;; \
esac && \
BASE_URL="https://github.com/pocketbase/pocketbase/releases/download/v${POCKETBASE_VERSION}" && \
ARCHIVE="pocketbase_${POCKETBASE_VERSION}_linux_${PB_ARCH}.zip" && \
curl --fail --location --silent --show-error "${BASE_URL}/${ARCHIVE}" --output "/tmp/${ARCHIVE}" && \
curl --fail --location --silent --show-error "${BASE_URL}/checksums.txt" --output /tmp/checksums.txt && \
cd /tmp && grep " ${ARCHIVE}$" checksums.txt | sha256sum -c - && \
unzip "/tmp/${ARCHIVE}" pocketbase -d /out
FROM alpine:3.22
RUN apk add --no-cache ca-certificates su-exec tzdata && addgroup -S pocketbase && adduser -S -G pocketbase pocketbase
COPY --from=download /out/pocketbase /usr/local/bin/pocketbase
COPY entrypoint.sh /usr/local/bin/workshop-entrypoint
RUN chmod +x /usr/local/bin/workshop-entrypoint && mkdir -p /pb_data /pb_hooks /pb_migrations && chown -R pocketbase:pocketbase /pb_data /pb_hooks /pb_migrations
USER root
EXPOSE 8090
HEALTHCHECK --interval=30s --timeout=5s --start-period=10s --retries=3 CMD wget -q --spider http://127.0.0.1:8090/api/health || exit 1
ENTRYPOINT ["workshop-entrypoint"]
+45
View File
@@ -0,0 +1,45 @@
# Homarr Workshop backend
PocketBase provides the central Workshop API at `store.homarr.dev`. It is not a per-instance Homarr service. Local deployment is for development and integration testing.
## Local startup
1. Copy `.env.example` to `.env` and create a GitHub OAuth app.
2. Set its callback URL to `http://localhost:8090/api/oauth2-redirect`.
3. Set `GITHUB_CLIENT_ID`, `GITHUB_CLIENT_SECRET`, `PB_SUPERUSER_EMAIL`, and `PB_SUPERUSER_PASSWORD`.
4. Run `docker compose up --build` from this directory.
The API is at `http://localhost:8090`; the emergency PocketBase dashboard is at `http://localhost:8090/_/`. Day-to-day moderation belongs in `/workshop/admin`, not the PocketBase dashboard.
The image downloads the official PocketBase `0.39.6` release and verifies it against that release's `checksums.txt`. Migrations and hooks are mounted read-only.
## Roles and account states
- `member`: publish, update/delete owned submissions, vote, and report.
- `moderator`: report queue, submission removal, posting bans, and account disable/restore.
- `admin`: moderator actions plus role changes.
- `posting_banned`: cannot create or update submissions; can browse, vote, and report.
- `disabled`: cannot authenticate or write.
Bootstrap the first admin through the PocketBase dashboard by changing a GitHub-authenticated user's role. All later staff actions use the restricted Workshop back office and are recorded in `moderation_actions`.
## Backup and restore
PocketBase stores records and uploads in `/pb_data`. Stop writes or stop the container, then archive the named volume:
```sh
docker compose stop workshop
docker run --rm -v store_workshop_data:/data -v "$PWD":/backup alpine tar czf /backup/workshop-backup.tgz -C /data .
docker compose start workshop
```
Restore only into the same PocketBase version after preserving the current volume. Extract the archive into an empty `workshop_data` volume, start the service, and verify `/api/health`, collection migrations, a listing request, and an authenticated write.
## Production checklist
- Terminate TLS at the reverse proxy and forward the real client IP.
- Keep superuser credentials outside Compose and rotate them after bootstrap.
- Restrict the dashboard path to operators; GitHub users never need it.
- Review the versioned default rate limits for OAuth, submissions, votes, reports, moderation, and general API traffic against production load before launch.
- Back up `/pb_data` before every PocketBase or migration upgrade and test restore regularly.
- Monitor health, authentication failures, 429/5xx responses, moderation actions, disk use, and backup age.
+24
View File
@@ -0,0 +1,24 @@
services:
workshop:
build:
context: .
args:
POCKETBASE_VERSION: 0.39.6
image: homarr/workshop-pocketbase:0.39.6
restart: unless-stopped
environment:
GITHUB_CLIENT_ID: ${GITHUB_CLIENT_ID:-}
GITHUB_CLIENT_SECRET: ${GITHUB_CLIENT_SECRET:-}
PB_SUPERUSER_EMAIL: ${PB_SUPERUSER_EMAIL:-}
PB_SUPERUSER_PASSWORD: ${PB_SUPERUSER_PASSWORD:-}
ports:
- "${PB_EXPOSE_PORT:-8090}:8090"
volumes:
- workshop_data:/pb_data
- ./pb_hooks:/pb_hooks:ro
- ./pb_migrations:/pb_migrations:ro
security_opt:
- no-new-privileges:true
volumes:
workshop_data:
+14
View File
@@ -0,0 +1,14 @@
#!/bin/sh
set -eu
chown -R pocketbase:pocketbase /pb_data
if [ -n "${PB_SUPERUSER_EMAIL:-}" ] && [ -n "${PB_SUPERUSER_PASSWORD:-}" ]; then
su-exec pocketbase pocketbase superuser upsert "${PB_SUPERUSER_EMAIL}" "${PB_SUPERUSER_PASSWORD}" --dir=/pb_data
fi
exec su-exec pocketbase pocketbase serve \
--http=0.0.0.0:8090 \
--dir=/pb_data \
--hooksDir=/pb_hooks \
--migrationsDir=/pb_migrations
@@ -20,10 +20,12 @@ function fail(ErrorType, _code, message) {
throw new ErrorType(message);
}
function requireWritableAccount(app, auth) {
function requireWritableAccount(app, auth, posting) {
if (!auth) fail(UnauthorizedError, "authentication_required", "Authentication is required");
const account = app.findRecordById("users", auth.id);
if (account.getString("state") === "disabled") fail(ForbiddenError, "account_disabled", "Account is disabled");
if (posting && account.getString("state") === "posting_banned")
fail(ForbiddenError, "posting_banned", "Account cannot publish submissions");
return account;
}
@@ -67,40 +69,14 @@ function validateSubmission(record) {
}
function snapshot(record) {
const exported = record.publicExport();
if (typeof exported.content === "string") {
exported.contentLength = exported.content.length;
delete exported.content;
}
return JSON.stringify(exported);
return JSON.stringify(record.publicExport());
}
function requireStaff(app, auth) {
requireWritableAccount(app, auth);
let staff;
try {
staff = app.findFirstRecordByFilter("workshop_staff", "user = {:user}", { user: auth.id });
} catch {
fail(ForbiddenError, "forbidden", "Staff access is required");
}
return staff.getString("role");
}
function setStaffRole(app, user, role) {
let staff = null;
try {
staff = app.findFirstRecordByFilter("workshop_staff", "user = {:user}", { user: user.id });
} catch {
// Members do not have a staff record.
}
if (role === "member") {
if (staff) app.delete(staff);
return;
}
if (!staff) staff = new Record(app.findCollectionByNameOrId("workshop_staff"));
staff.set("user", user.id);
staff.set("role", role);
app.save(staff);
const account = requireWritableAccount(app, auth, false);
const role = account.getString("role");
if (role !== "moderator" && role !== "admin") fail(ForbiddenError, "forbidden", "Staff access is required");
return role;
}
function body(e) {
@@ -123,13 +99,4 @@ function audit(app, actor, action, targetType, targetId, reason, targetSnapshot)
app.save(record);
}
module.exports = {
audit,
body,
fail,
requireStaff,
requireWritableAccount,
setStaffRole,
snapshot,
validateSubmission,
};
module.exports = { audit, body, fail, requireStaff, requireWritableAccount, snapshot, validateSubmission };
@@ -1,5 +1,32 @@
/// <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);
@@ -79,7 +106,8 @@ routerAdd(
const utils = require(`${__hooks}/workshop-utils.js`);
const actorRole = utils.requireStaff(e.app, e.auth);
const data = utils.body(e);
if (!["active", "disabled"].includes(data.state)) throw new BadRequestError("Invalid account state");
if (!["active", "posting_banned", "disabled"].includes(data.state))
throw new BadRequestError("Invalid account state");
const id = e.request.pathValue("id");
if (id === e.auth.id) throw new BadRequestError("You cannot moderate your own account");
e.app.runInTransaction((tx) => {
@@ -111,7 +139,6 @@ 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 });
@@ -9,14 +9,13 @@ 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(
@@ -27,35 +26,20 @@ migrate(
name: "state",
required: true,
maxSelect: 1,
values: ["active", "disabled"],
values: ["active", "posting_banned", "disabled"],
}),
new TextField({ name: "moderationReason", max: 1000 }),
);
const githubClientId = $os.getenv("GITHUB_CLIENT_ID");
const githubClientSecret = $os.getenv("GITHUB_CLIENT_SECRET");
users.oauth2.enabled = Boolean(githubClientId && githubClientSecret);
users.oauth2.providers =
githubClientId && githubClientSecret
? [{ name: "github", clientId: githubClientId, clientSecret: githubClientSecret }]
: [];
app.save(users);
const 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 staffRule = "@request.auth.role = 'moderator' || @request.auth.role = 'admin'";
const writableRule = "@request.auth.id != '' && @request.auth.state != 'disabled'";
const authorRule = "author = @request.auth.id && @request.auth.state = 'active'";
@@ -232,22 +216,14 @@ migrate(
app.save(listings);
const settings = app.settings();
const workshopRateLimitLabels = [
"submissions:create",
"votes:create",
"reports:create",
"/api/workshop/moderation/",
];
const workshopRateLimits = [
settings.rateLimits.enabled = true;
settings.rateLimits.rules = [
{ label: "*:auth", audience: "", duration: 60, maxRequests: 10 },
{ label: "submissions:create", audience: "@auth", duration: 60, maxRequests: 10 },
{ label: "votes:create", audience: "@auth", duration: 10, maxRequests: 20 },
{ label: "reports:create", audience: "@auth", duration: 60, maxRequests: 5 },
{ label: "/api/workshop/moderation/", audience: "@auth", duration: 60, maxRequests: 30 },
];
settings.rateLimits.enabled = true;
settings.rateLimits.rules = [
...settings.rateLimits.rules.filter((rule) => !workshopRateLimitLabels.includes(rule.label)),
...workshopRateLimits,
{ label: "/api/", audience: "", duration: 10, maxRequests: 300 },
];
settings.logs.maxDays = 30;
settings.logs.logAuthId = true;
@@ -257,14 +233,7 @@ 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",
"workshop_staff",
]) {
for (const name of ["workshop_listings", "moderation_actions", "reports", "votes", "submissions"]) {
try {
app.delete(app.findCollectionByNameOrId(name));
} catch {
@@ -272,15 +241,8 @@ migrate(
}
}
const settings = app.settings();
const workshopRateLimitLabels = [
"submissions:create",
"votes:create",
"reports:create",
"/api/workshop/moderation/",
];
settings.rateLimits.rules = settings.rateLimits.rules.filter(
(rule) => !workshopRateLimitLabels.includes(rule.label),
);
settings.rateLimits.enabled = false;
settings.rateLimits.rules = [];
app.save(settings);
},
);
+26
View File
@@ -0,0 +1,26 @@
#!/bin/sh
set -eu
project="homarr-workshop-integration"
port="${WORKSHOP_TEST_PORT:-18091}"
email="integration@homarr.test"
password="WorkshopIntegrationPassword123!"
cleanup() {
PB_EXPOSE_PORT="$port" docker compose --file apps/store/docker-compose.yml --project-name "$project" down --volumes >/dev/null 2>&1 || true
}
trap cleanup EXIT INT TERM
cleanup
PB_EXPOSE_PORT="$port" \
PB_SUPERUSER_EMAIL="$email" \
PB_SUPERUSER_PASSWORD="$password" \
docker compose --file apps/store/docker-compose.yml --project-name "$project" up --detach --build --wait
if ! WORKSHOP_TEST_URL="http://127.0.0.1:$port" \
WORKSHOP_TEST_SUPERUSER_EMAIL="$email" \
WORKSHOP_TEST_SUPERUSER_PASSWORD="$password" \
node apps/store/tests/workshop.integration.mjs; then
docker compose --file apps/store/docker-compose.yml --project-name "$project" logs --no-color workshop
exit 1
fi
@@ -5,31 +5,10 @@ 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!`;
@@ -51,29 +30,26 @@ 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);
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],
);
const forgedMember = await member.collection("users").update(memberRecord.id, {
role: "admin",
state: "posting_banned",
moderationReason: "self escalation",
});
assert.equal(forgedMember.role, "member", "members must not change their own role");
assert.equal(forgedMember.state, "active", "members must not change their own account state");
assert.equal(forgedMember.moderationReason, "", "members must not change protected moderation fields");
await admin.send(`/api/workshop/moderation/users/${disabledRecord.id}/state`, {
method: "POST",
@@ -81,20 +57,11 @@ await admin.send(`/api/workshop/moderation/users/${disabledRecord.id}/state`, {
});
const submission = await member.collection("submissions").create({
type: "widget",
title: "Integration widget",
type: "css",
title: "Integration theme",
description: "A backend integration fixture",
schemaVersion: "forged-version",
content: JSON.stringify({
$schema: "homarr-custom-widget-v2",
name: "Integration widget",
url: "https://example.com/status",
authType: "none",
method: "GET",
displayType: "raw",
displayConfig: { type: "raw" },
integrationTestPadding: "x".repeat(150_000),
}),
content: ":root { --homarr-test: #fff; }",
author: secondMemberRecord.id,
authorName: "Forged",
revision: 99,
@@ -102,8 +69,12 @@ 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-widget-v2", "server must own schemaVersion");
assert.equal(submission.schemaVersion, "homarr-custom-css-v1", "server must own schemaVersion");
await expectStatus(
() => banned.collection("submissions").create({ type: "css", title: "Blocked theme", content: "body {}" }),
403,
);
await expectStatus(() => disabled.collection("votes").create({ submission: submission.id, value: 1 }), [400, 403]);
await expectStatus(
() =>
@@ -138,7 +109,7 @@ await expectStatus(
() =>
member.send(`/api/workshop/moderation/users/${secondMemberRecord.id}/state`, {
method: "POST",
body: { state: "disabled", reason: "No permission" },
body: { state: "posting_banned", reason: "No permission" },
}),
403,
);
@@ -164,10 +135,6 @@ 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",
@@ -188,9 +155,7 @@ assert.deepEqual(
actions.map((action) => action.action),
["set_account_state", "set_role", "resolve_report", "remove_submission"],
);
assert(actions.at(-1)?.snapshot.includes("Integration widget"), "removal audit must retain a target snapshot");
assert(!actions.at(-1)?.snapshot.includes("integrationTestPadding"), "removal audit must omit large source content");
assert(actions.at(-1)?.snapshot.includes('"contentLength"'), "removal audit must retain the source length");
assert(actions.at(-1)?.snapshot.includes("Integration theme"), "removal audit must retain a target snapshot");
const anonymous = new PocketBase(baseUrl);
const listings = await anonymous.collection("workshop_listings").getList();
-1
View File
@@ -1 +0,0 @@
pb_data/
-77
View File
@@ -1,77 +0,0 @@
# 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"]
-21
View File
@@ -1,21 +0,0 @@
**
!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
-74
View File
@@ -1,74 +0,0 @@
# 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.
-10
View File
@@ -1,10 +0,0 @@
self.addEventListener("install", () => self.skipWaiting());
self.addEventListener("activate", (event) => {
event.waitUntil(
Promise.all([
caches.keys().then((keys) => Promise.all(keys.map((key) => caches.delete(key)))),
self.registration.unregister(),
]),
);
});
-19
View File
@@ -1,19 +0,0 @@
name: homarr-workshop
services:
workshop:
build:
context: ../..
dockerfile: apps/workshop/Dockerfile
target: production
args:
POCKETBASE_VERSION: 0.39.6
restart: unless-stopped
environment:
PB_ALLOWED_ORIGINS: ${PB_ALLOWED_ORIGINS:-*}
ports:
- "127.0.0.1:${PB_EXPOSE_PORT:-8090}:8090"
volumes:
- ${WORKSHOP_DATA_DIR:-./pb_data}:/pb_data
security_opt:
- no-new-privileges:true
-21
View File
@@ -1,21 +0,0 @@
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
-16
View File
@@ -1,16 +0,0 @@
#!/bin/sh
set -eu
echo "Workshop development API: http://localhost:8090/api"
echo "PocketBase dashboard: http://localhost:8090/_/"
exec pocketbase serve \
--dev \
--automigrate=false \
--http=0.0.0.0:8090 \
--origins="${PB_ALLOWED_ORIGINS:-*}" \
--dir=/pb_data \
--hooksDir=/pb_hooks \
--migrationsDir=/pb_migrations \
--publicDir=/pb_public \
--indexFallback=true
-14
View File
@@ -1,14 +0,0 @@
#!/bin/sh
set -eu
echo "Workshop is listening on port 8090"
echo "Workshop API is available at /api"
exec pocketbase serve \
--http=0.0.0.0:8090 \
--origins="${PB_ALLOWED_ORIGINS:-*}" \
--dir=/pb_data \
--hooksDir=/pb_hooks \
--migrationsDir=/pb_migrations \
--publicDir=/pb_public \
--indexFallback=true
@@ -1,73 +0,0 @@
/// <reference path="../pb_data/types.d.ts" />
migrate(
(app) => {
const users = app.findCollectionByNameOrId("users");
const state = users.fields.getByName("state");
if (state) state.values = ["active", "disabled"];
let staff;
try {
staff = app.findCollectionByNameOrId("workshop_staff");
} catch {
staff = new Collection({
type: "base",
name: "workshop_staff",
listRule: "user = @request.auth.id",
viewRule: "user = @request.auth.id",
createRule: null,
updateRule: null,
deleteRule: null,
fields: [
{
type: "relation",
name: "user",
required: true,
maxSelect: 1,
collectionId: users.id,
cascadeDelete: true,
},
{ type: "select", name: "role", required: true, maxSelect: 1, values: ["moderator", "admin"] },
{ type: "autodate", name: "created", onCreate: true },
{ type: "autodate", name: "updated", onCreate: true, onUpdate: true },
],
});
staff.addIndex("idx_workshop_staff_user", true, "user", "");
app.save(staff);
}
for (const user of app.findRecordsByFilter("users", "role = 'moderator' || role = 'admin'", "", 0, 0)) {
if (!user) continue;
let record;
try {
record = app.findFirstRecordByFilter("workshop_staff", "user = {:user}", { user: user.id });
} catch {
record = new Record(staff);
record.set("user", user.id);
}
record.set("role", user.getString("role"));
app.save(record);
}
const staffRule = "@collection.workshop_staff.user ?= @request.auth.id";
users.listRule = staffRule;
users.viewRule = `id = @request.auth.id || ${staffRule}`;
users.updateRule =
"id = @request.auth.id && @request.auth.state != 'disabled' && @request.body.role:changed = false && @request.body.state:changed = false && @request.body.moderationReason:changed = false";
app.save(users);
},
(app) => {
const users = app.findCollectionByNameOrId("users");
users.listRule = "@request.auth.role = 'moderator' || @request.auth.role = 'admin'";
users.viewRule = "id = @request.auth.id || @request.auth.role = 'moderator' || @request.auth.role = 'admin'";
users.updateRule = "id = @request.auth.id && @request.auth.state != 'disabled'";
const state = users.fields.getByName("state");
if (state) state.values = ["active", "posting_banned", "disabled"];
app.save(users);
try {
app.delete(app.findCollectionByNameOrId("workshop_staff"));
} catch {
// Already removed.
}
},
);
-20
View File
@@ -1,20 +0,0 @@
#!/bin/sh
set -eu
data_dir="${WORKSHOP_DATA_DIR:-apps/workshop/pb_data}"
if [ "${1:-}" != "--force" ]; then
printf "Delete the local Workshop database at %s? [y/N] " "$data_dir"
read -r answer
case "$answer" in
y|Y|yes|YES) ;;
*) echo "Reset cancelled"; exit 0 ;;
esac
fi
docker compose --env-file .env --file apps/workshop/docker-compose.yml down --remove-orphans >/dev/null 2>&1 || true
docker compose --env-file .env --file apps/workshop/docker-compose.preview.yml down --remove-orphans >/dev/null 2>&1 || true
mkdir -p "$data_dir"
docker run --rm --volume "$(cd "$data_dir" && pwd):/data" alpine:3.22 sh -c 'rm -rf /data/.[!.]* /data/..?* /data/*'
echo "Workshop database reset"
-53
View File
@@ -1,53 +0,0 @@
#!/bin/sh
set -eu
project="homarr-workshop-integration"
port="${WORKSHOP_TEST_PORT:-18091}"
email="integration@homarr.test"
password="${WORKSHOP_TEST_PASSWORD:-WorkshopIntegrationPassword123!}"
data_dir="$(mktemp -d)"
cleanup() {
PB_EXPOSE_PORT="$port" WORKSHOP_DATA_DIR="$data_dir" docker compose --env-file .env.example --file apps/workshop/docker-compose.yml --project-name "$project" down --remove-orphans >/dev/null 2>&1 || true
docker run --rm --volume "$data_dir:/data" alpine:3.22 sh -c 'rm -rf /data/.[!.]* /data/..?* /data/*' >/dev/null 2>&1 || true
rmdir "$data_dir" >/dev/null 2>&1 || true
}
trap cleanup EXIT INT TERM
PB_EXPOSE_PORT="$port" WORKSHOP_DATA_DIR="$data_dir" docker compose --env-file .env.example --file apps/workshop/docker-compose.yml --project-name "$project" down --remove-orphans >/dev/null 2>&1 || true
PB_EXPOSE_PORT="$port" WORKSHOP_DATA_DIR="$data_dir" docker compose \
--env-file .env.example \
--file apps/workshop/docker-compose.yml \
--project-name "$project" \
up --detach --build --wait
WORKSHOP_DATA_DIR="$data_dir" docker compose \
--env-file .env.example \
--file apps/workshop/docker-compose.yml \
--project-name "$project" \
exec --no-TTY workshop pocketbase superuser upsert "$email" "$password" --dir=/pb_data
if ! WORKSHOP_TEST_URL="http://127.0.0.1:$port" \
WORKSHOP_TEST_SUPERUSER_EMAIL="$email" \
WORKSHOP_TEST_SUPERUSER_PASSWORD="$password" \
node apps/workshop/tests/workshop.integration.mjs; then
PB_EXPOSE_PORT="$port" WORKSHOP_DATA_DIR="$data_dir" docker compose --env-file .env.example --file apps/workshop/docker-compose.yml --project-name "$project" logs --no-color workshop
exit 1
fi
PB_EXPOSE_PORT="$port" WORKSHOP_DATA_DIR="$data_dir" docker compose \
--env-file .env.example \
--file apps/workshop/docker-compose.yml \
--project-name "$project" \
restart workshop >/dev/null
PB_EXPOSE_PORT="$port" WORKSHOP_DATA_DIR="$data_dir" docker compose \
--env-file .env.example \
--file apps/workshop/docker-compose.yml \
--project-name "$project" \
up --detach --wait >/dev/null
WORKSHOP_TEST_URL="http://127.0.0.1:$port" \
WORKSHOP_TEST_SUPERUSER_EMAIL="$email" \
WORKSHOP_TEST_SUPERUSER_PASSWORD="$password" \
WORKSHOP_VERIFY_CONFIGURATION_ONLY=true \
node apps/workshop/tests/workshop.integration.mjs
-51
View File
@@ -1,51 +0,0 @@
#!/bin/sh
set -eu
project="homarr-workshop-preview-test"
port="${WORKSHOP_PREVIEW_TEST_PORT:-18092}"
email="preview@homarr.test"
password="${WORKSHOP_PREVIEW_TEST_PASSWORD:-WorkshopPreviewPassword123!}"
data_dir="$(mktemp -d)"
base_url="http://127.0.0.1:$port"
cleanup() {
PB_EXPOSE_PORT="$port" WORKSHOP_DATA_DIR="$data_dir" docker compose --env-file .env.example --file apps/workshop/docker-compose.preview.yml --project-name "$project" down --remove-orphans >/dev/null 2>&1 || true
docker run --rm --volume "$data_dir:/data" alpine:3.22 sh -c 'rm -rf /data/.[!.]* /data/..?* /data/*' >/dev/null 2>&1 || true
rmdir "$data_dir" >/dev/null 2>&1 || true
}
trap cleanup EXIT INT TERM
PB_EXPOSE_PORT="$port" WORKSHOP_DATA_DIR="$data_dir" docker compose --env-file .env.example --file apps/workshop/docker-compose.preview.yml --project-name "$project" down --remove-orphans >/dev/null 2>&1 || true
PB_EXPOSE_PORT="$port" WORKSHOP_DATA_DIR="$data_dir" docker compose \
--env-file .env.example \
--file apps/workshop/docker-compose.preview.yml \
--project-name "$project" \
up --detach --build --wait
curl --fail --silent --show-error "$base_url/api/health" >/dev/null
curl --fail --silent --show-error --location "$base_url/workshop" >/dev/null
curl --fail --silent --show-error "$base_url/workshop-sw.js" >/dev/null
PB_EXPOSE_PORT="$port" WORKSHOP_DATA_DIR="$data_dir" docker compose \
--env-file .env.example \
--file apps/workshop/docker-compose.preview.yml \
--project-name "$project" \
exec --no-TTY workshop pocketbase superuser upsert "$email" "$password" --dir=/pb_data >/dev/null
token="$(curl --fail --silent --show-error \
--header 'Content-Type: application/json' \
--data "{\"identity\":\"$email\",\"password\":\"$password\"}" \
"$base_url/api/collections/_superusers/auth-with-password" | jq --raw-output '.token')"
collections="$(curl --fail --silent --show-error \
--header "Authorization: $token" \
"$base_url/api/collections?perPage=200")"
for collection in users submissions votes reports moderation_actions workshop_listings; do
echo "$collections" | jq --exit-status --arg name "$collection" '.items | any(.name == $name)' >/dev/null || {
echo "Missing Workshop collection: $collection" >&2
exit 1
}
done
echo "Workshop production preview passed"
+6 -11
View File
@@ -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`). The rewritten branch is rebased onto the current `origin/dev` before each final push.
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 |
| ------------- | -------- | ----------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------ |
@@ -36,11 +36,7 @@ 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 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.
- Hosting: one Homarr-operated service at `store.homarr.dev`; overrides are development-only.
- Publishing: immediate, with source inspection, reporting, and staff removal.
- Staff: GitHub-authenticated moderators/admins use `/workshop/admin`; PocketBase superusers are bootstrap/emergency access.
@@ -56,7 +52,6 @@ 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
@@ -69,15 +64,15 @@ The baseline has three P0, seven P1, and one P2 findings. The rewrite closes eve
| Gate | Result | Evidence |
| --------------------------- | ------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| Branch ancestry | Pass | Current `origin/dev` is an ancestor; the prototype remains preserved at `origin/feat/widget-store-prototype-backup`. |
| 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 builds with checksum verification; clean dev boot, persistence, disposable integration tests, and the combined production preview migrate and become healthy. |
| Backend operations | Pass | PocketBase 0.39.6 image builds with checksum verification; clean migration, health, and listing/detail access boundaries pass. |
| Type safety | Pass | Scoped Turbo typecheck passes for Workshop, Docs, and Next.js. |
| Lint | Pass | Scoped lint exits clean; remaining warnings predate the rewrite. Workshop has zero warnings. |
| Format | Pass | Every changed supported file passes `oxfmt --check`; repository-wide Docs formatting still reports pre-existing drift. |
| Production builds | Pass | Docusaurus, 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. |
| Production builds | Pass | Docusaurus and Next.js production builds pass under Node 24.18.0. |
| Browser smoke | Pass | Desktop and 390px mobile discovery render without console errors or horizontal overflow; semantic labels, skip link, dark mode, and reduced motion are active. |
| Automated cross-surface E2E | Open P1 | Authenticated website-to-Homarr publish/install and moderator flows still need browser automation. |
Final implementation scores (10 is best): security 9, architecture/DX 9, moderation 9, performance 9, responsive UI 9, theming 9, accessibility 8, automated testing 7. Accessibility remains below 9 until authenticated dialogs and destructive flows receive a full keyboard/screen-reader pass.
+2 -8
View File
@@ -21,9 +21,7 @@
"db:studio": "pnpm -F db studio",
"dev": "pnpm -F nextjs dev",
"dev:docs": "pnpm -F @homarr/docs dev",
"dev:workshop": "pnpm -F @homarr/workshop build && concurrently --kill-others-on-fail --names pocketbase,contracts,website \"pnpm dev:workshop:backend\" \"pnpm -F @homarr/workshop dev\" \"pnpm dev:workshop:website\"",
"dev:workshop:backend": "docker compose --env-file .env --file apps/workshop/docker-compose.yml up --build --remove-orphans",
"dev:workshop:website": "pnpm with-env sh -c 'WORKSHOP_API_URL=${WORKSHOP_API_URL:-http://localhost:${PB_EXPOSE_PORT:-8090}} pnpm -F @homarr/docs docusaurus start --no-open --host 0.0.0.0 --port ${WORKSHOP_WEB_PORT:-3003}'",
"dev:workshop": "docker compose -f apps/store/docker-compose.yml up --build",
"docker:dev": "docker compose -f ./development/development.docker-compose.yml up",
"format": "turbo format --continue -- --check",
"format:fix": "turbo format --continue",
@@ -31,15 +29,12 @@
"lint:fix": "turbo lint --continue -- --fix",
"lint:ws": "pnpm dlx sherif@latest",
"release": "semantic-release",
"preview:workshop": "docker compose --env-file .env --file apps/workshop/docker-compose.preview.yml up --build --remove-orphans",
"reset:workshop": "sh apps/workshop/reset.sh",
"scripts:update-bug-report-template": "tsx ./scripts/update-bug-report-template.mts",
"scripts:update-readme-integrations": "tsx ./scripts/update-integration-list.mts",
"start": "concurrently \"pnpm with-env node apps/tasks/tasks.cjs\" \"pnpm with-env node apps/websocket/wssServer.cjs\" \"pnpm -F nextjs start\"",
"test": "cross-env NODE_ENV=development CI=true vitest run --exclude e2e --coverage.enabled ",
"test:e2e": "cross-env NODE_ENV=development CI=true vitest e2e",
"test:workshop-backend": "bash apps/workshop/tests/run-integration.sh",
"test:workshop-preview": "bash apps/workshop/tests/run-preview.sh",
"test:workshop-backend": "bash apps/store/tests/run-integration.sh",
"test:ui": "cross-env NODE_ENV=development CI=true vitest --exclude e2e --ui --coverage.enabled",
"typecheck": "turbo typecheck",
"with-env": "dotenv -e .env --"
@@ -60,7 +55,6 @@
"@vitejs/plugin-react": "catalog:",
"@vitest/coverage-v8": "catalog:",
"@vitest/ui": "catalog:",
"concurrently": "catalog:",
"conventional-changelog-conventionalcommits": "catalog:",
"cross-env": "catalog:",
"jsdom": "catalog:",
-3
View File
@@ -497,9 +497,6 @@ export const integrationDefs = {
export const integrationKinds = objectKeys(integrationDefs) as AtLeastOneOf<IntegrationKind>;
export const isIntegrationKind = (integration: string): integration is IntegrationKind =>
Object.hasOwn(integrationDefs, integration);
export const getIconUrl = (integration: IntegrationKind) => integrationDefs[integration].iconUrl;
export const getIntegrationName = (integration: IntegrationKind) => integrationDefs[integration].name;
+10 -26
View File
@@ -896,10 +896,6 @@
"search": "Search integrations",
"noResults": {
"title": "There are no integrations yet"
},
"outdated": {
"title": "Some integrations need a database update",
"description": "{count} outdated integrations were hidden to keep this page available. Restart Homarr to apply pending data migrations."
}
},
"create": {
@@ -4461,15 +4457,6 @@
"workshop": {
"title": "Workshop",
"loading": "Loading submission",
"intro": {
"title": "Community Workshop",
"description": "Discover widgets and CSS themes made by other Homarr users. Inspect the source before bringing anything into this instance."
},
"status": {
"connected": "Connected",
"offline": "Saved catalog"
},
"screenshotAlt": "{title} screenshot {number}",
"by": "By {name} · revision {revision}",
"type": {
"widget": "Widget",
@@ -4497,27 +4484,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",
"detailTitle": "This creation is unavailable",
"detailDescription": "Its source is not saved on this device and Workshop could not be reached.",
"publish": "The creation could not be published",
"install": "The creation could not be used",
"wrongType": "This creation does not match the requested type"
},
"publish": {
"title": "Share in Workshop",
"name": "Title",
"description": "Description",
"changelog": "Changelog"
},
"action": {
"retry": "Try again",
"inspect": "Inspect source",
"download": "Download",
"website": "Open website",
"use": "Use creation",
"share": "Share in Workshop",
"cancel": "Cancel",
"publish": "Publish creation",
"install": "Install widget",
"useCss": "Use CSS"
}
@@ -5729,9 +5716,6 @@
}
}
},
"workshop": {
"label": "Workshop"
},
"users": {
"label": "Users",
"create": {
-1
View File
@@ -27,7 +27,6 @@
},
"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",
+11 -57
View File
@@ -15,7 +15,6 @@ import type {
import {
WORKSHOP_API_URL,
WORKSHOP_REQUEST_TIMEOUT_MS,
WorkshopError,
schemaVersionForType,
workshopAccountStateSchema,
@@ -38,7 +37,6 @@ export interface WorkshopListOptions {
sort?: "top" | "newest";
search?: string;
author?: string;
signal?: AbortSignal;
}
export interface WorkshopPage<T> {
@@ -59,28 +57,20 @@ 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" : undefined;
const messageCode =
message === "Account is disabled"
? "account_disabled"
: message === "Account cannot publish submissions"
? "posting_banned"
: undefined;
const parsedCode = workshopErrorCodeSchema.safeParse(messageCode ?? error.data?.data?.code ?? error.data?.code);
const code = parsedCode.success ? parsedCode.data : errorCodeForStatus(error.status);
return new WorkshopError(code, message, error.status);
}
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);
};
@@ -103,20 +93,6 @@ 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);
}
@@ -125,7 +101,7 @@ export class WorkshopClient {
if (!this.pocketBase.authStore.isValid) return null;
try {
await this.pocketBase.collection("users").authRefresh();
return this.currentUserWithStaffRole();
return this.currentUser;
} catch {
this.pocketBase.authStore.clear();
return null;
@@ -133,28 +109,9 @@ export class WorkshopClient {
}
async signInWithGitHub() {
let methods: Awaited<ReturnType<ReturnType<PocketBase["collection"]>["listAuthMethods"]>>;
try {
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();
await this.pocketBase.collection("users").authWithOAuth2({ provider: "github" });
return this.currentUser;
} catch (error) {
throw asWorkshopError(error, "GitHub sign-in failed");
}
@@ -177,7 +134,6 @@ 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) {
@@ -185,11 +141,9 @@ export class WorkshopClient {
}
}
async get(id: string, signal?: AbortSignal): Promise<WorkshopSubmissionDetail> {
async get(id: string): Promise<WorkshopSubmissionDetail> {
try {
return workshopSubmissionDetailSchema.parse(
await this.pocketBase.collection("submissions").getOne(id, { signal: workshopRequestSignal(signal) }),
);
return workshopSubmissionDetailSchema.parse(await this.pocketBase.collection("submissions").getOne(id));
} catch (error) {
throw asWorkshopError(error, "Submission not found");
}
-2
View File
@@ -57,8 +57,6 @@ 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", () => {
+9 -19
View File
@@ -1,7 +1,7 @@
import { customWidgetImportSchema } from "@homarr/validation/custom-widget";
import { z } from "zod";
export const WORKSHOP_API_URL = "https://homarr.dev";
export const WORKSHOP_API_URL = "https://store.homarr.dev";
export const WORKSHOP_WEB_URL = "https://homarr.dev/workshop";
export const WIDGET_SCHEMA_VERSION = "homarr-custom-widget-v2";
export const CSS_SCHEMA_VERSION = "homarr-custom-css-v1";
@@ -9,7 +9,6 @@ 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"]);
@@ -18,7 +17,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", "disabled"]);
export const workshopAccountStateSchema = z.enum(["active", "posting_banned", "disabled"]);
export type WorkshopAccountState = z.infer<typeof workshopAccountStateSchema>;
export const workshopReportCategorySchema = z.enum(["malicious", "spam", "copyright", "inappropriate", "other"]);
@@ -112,6 +111,7 @@ export type WorkshopModerationAction = z.infer<typeof workshopModerationActionSc
export const workshopErrorCodeSchema = z.enum([
"authentication_required",
"account_disabled",
"posting_banned",
"forbidden",
"not_found",
"invalid_submission",
@@ -165,21 +165,11 @@ export const schemaVersionForType = (type: WorkshopSubmissionType) =>
type === "widget" ? WIDGET_SCHEMA_VERSION : CSS_SCHEMA_VERSION;
export function workshopExportFilename(title: string, type: WorkshopSubmissionType) {
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";
const safeTitle =
title
.trim()
.toLowerCase()
.replace(/[^a-z0-9_-]+/g, "-")
.replace(/^-+|-+$/g, "") || "homarr-workshop";
return `${safeTitle}.${type === "widget" ? "json" : "css"}`;
}
+140 -126
View File
@@ -271,11 +271,11 @@ catalogs:
specifier: ^6.0.3
version: 6.0.3
'@vitest/coverage-v8':
specifier: ^4.1.10
version: 4.1.10
specifier: ^4.1.9
version: 4.1.9
'@vitest/ui':
specifier: ^4.1.10
version: 4.1.10
specifier: ^4.1.9
version: 4.1.9
'@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.6
version: 3.22.6
specifier: 3.22.5
version: 3.22.5
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.7
specifier: ^1.398.2
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.10
version: 4.1.10
specifier: ^4.1.9
version: 4.1.9
winston:
specifier: 3.19.0
version: 3.19.0
@@ -627,13 +627,10 @@ 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.10(vitest@4.1.10)
version: 4.1.9(vitest@4.1.9)
'@vitest/ui':
specifier: 'catalog:'
version: 4.1.10(vitest@4.1.10)
concurrently:
specifier: 'catalog:'
version: 10.0.3
version: 4.1.9(vitest@4.1.9)
conventional-changelog-conventionalcommits:
specifier: 'catalog:'
version: 10.2.0
@@ -672,7 +669,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.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))
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))
apps/docs:
dependencies:
@@ -721,15 +718,6 @@ 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
@@ -997,7 +985,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.6(@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.5(@types/node@24.13.3))(pg@8.22.0)(sql.js@1.14.1)
flag-icons:
specifier: 'catalog:'
version: 7.5.0
@@ -1060,7 +1048,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.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)
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)
use-deep-compare-effect:
specifier: 'catalog:'
version: 1.8.1(react@19.2.7)
@@ -1367,7 +1355,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.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)
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)
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)
@@ -1549,13 +1537,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.6(@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.5(@types/node@24.13.2))(pg@8.22.0)(sql.js@1.14.1)
ioredis:
specifier: 'catalog:'
version: 5.11.1
mysql2:
specifier: 'catalog:'
version: 3.22.6(@types/node@24.13.3)
version: 3.22.5(@types/node@24.13.2)
pg:
specifier: 'catalog:'
version: 8.22.0
@@ -1724,13 +1712,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.6(@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.5(@types/node@24.13.2))(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.6(@types/node@24.13.3))(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.5(@types/node@24.13.2))(pg@8.22.0)(sql.js@1.14.1))(zod@4.4.3)
mysql2:
specifier: 'catalog:'
version: 3.22.6(@types/node@24.13.3)
version: 3.22.5(@types/node@24.13.2)
pg:
specifier: 'catalog:'
version: 8.22.0
@@ -8105,20 +8093,20 @@ packages:
babel-plugin-react-compiler:
optional: true
'@vitest/coverage-v8@4.1.10':
resolution: {integrity: sha512-IM49HmthevbgAO4anp1hwtoT9wYe59w0LR00gr+eagHE+ZJ5lK4sLPeO0ubgoJcwLk6dehU3R24N+FbEEKDc8g==}
'@vitest/coverage-v8@4.1.9':
resolution: {integrity: sha512-G9/lgqibheLVBDRuya45EbsEXTYcWoSG+TLg7i2axuzx0Eq62eXn+aWXyaVdV5vKvFSWd6ywcX8hA7la9Pvu8g==}
peerDependencies:
'@vitest/browser': 4.1.10
vitest: 4.1.10
'@vitest/browser': 4.1.9
vitest: 4.1.9
peerDependenciesMeta:
'@vitest/browser':
optional: true
'@vitest/expect@4.1.10':
resolution: {integrity: sha512-YsCn+qAk1GWjQOWFEsEcL2gNQ0zmVmQu3T03qP6UyjhtmdtwtbuI+DASn/7iQB3HGTXkdBwGddzxPlmiql5vlA==}
'@vitest/expect@4.1.9':
resolution: {integrity: sha512-vl/rYsUKcBr3SnQn166+XR5ZQcgMx3DQhFWdfli/cWpLnLUmbxZvyrJZotLFUryib+LtArYMSTJ5RbQ57ZqrlA==}
'@vitest/mocker@4.1.10':
resolution: {integrity: sha512-v0xaezt+DKEmKfaxg133ldzADrwLGd7Ze1MfQQTYfvs8OqZIwbxyxaYURivwV7sWy5fqn3rH5uOrSp07bp44Ow==}
'@vitest/mocker@4.1.9':
resolution: {integrity: sha512-EVkXzBjrPGM+cK8/ANWgBrkUCfJfb38/EfTSO8h7pWvKkyPkpWxvR7BkD2MyItMF62C97zAEoqdpUixwR/e+Rw==}
peerDependencies:
msw: ^2.4.9
vite: ^6.0.0 || ^7.0.0 || ^8.0.0
@@ -8128,25 +8116,25 @@ packages:
vite:
optional: true
'@vitest/pretty-format@4.1.10':
resolution: {integrity: sha512-W1HsjSH4MXQ9YfmmhLAoIYf1HRfekQCGngeIgcei6MP5QQGWUe0gkopdZQaVCFO+JDJMrAJGwa5pRpNpvy4P8Q==}
'@vitest/pretty-format@4.1.9':
resolution: {integrity: sha512-s0iufns3iIFitdgm+YR7g1whCAaGtXz459VS9/PqyKDEEFgYIhsHOQmXgIgDuYCt7DeQmiZT0Qe2OA2p4ZPu5A==}
'@vitest/runner@4.1.10':
resolution: {integrity: sha512-IKI6kpIH+LmpROplyLwBBaCfMgOZOMsygVa6BARD6ahA04VRuJSa6OaVG7kRvSEMD870Vd91rSSw0eegtWyLGg==}
'@vitest/runner@4.1.9':
resolution: {integrity: sha512-KXLMDtc7oe70+3mJfGrPUWPesswH+3sTxAMAMl8DG7I8IUQT4XW718dY5ID3vPUcmlu27CcKfY4P3h3I29SLJg==}
'@vitest/snapshot@4.1.10':
resolution: {integrity: sha512-xRkfOT1qpTAi/Ti4Y1LtfRc3kEuqxGw59eN2jN9pRWMtS/XDevekhcFSqvQqjUNGksfjMJu3Y+oJ+4Ypn2OaJw==}
'@vitest/snapshot@4.1.9':
resolution: {integrity: sha512-Jc7RKGNBo8Z28WYIm0Niej4xdSPByRf6mU58VpHQkd6Zh05rlnA+twjbK5HyeIGHxrzsc3mJgS43uM0CZKzaIA==}
'@vitest/spy@4.1.10':
resolution: {integrity: sha512-PLf/Ugvoq5wO/b4rwYCR1h2PSIdXz7wnkQFMiUpLdtM7l6pqVFcQIBEHyT1+l+cj7mNwAfZHzqXqDyjvOuwbDw==}
'@vitest/spy@4.1.9':
resolution: {integrity: sha512-fHpsS6mIi+PiEW+vcRVOMkX1oSaPKne3VOclSFICPcGOmfKgXPU5iAah+wcNcj2xPrCCmfq99IDGf+EojhhvhA==}
'@vitest/ui@4.1.10':
resolution: {integrity: sha512-EOUqfXHTXtpSHsyLHH40ts3Ue+hRhSGwzwzMlK0dTEOLSDYyOXLyr5JDGmHQWhN2DYI30gw6dVx3cdgM9FZl+Q==}
'@vitest/ui@4.1.9':
resolution: {integrity: sha512-U/cRvtqfEPj27FI1n9cyUvi4vXXdcLhjJiI+InYKdk8hP4VrS6RXOjGL7rfFaeBc37iRKANsR6eEzIoC7lmgBQ==}
peerDependencies:
vitest: 4.1.10
vitest: 4.1.9
'@vitest/utils@4.1.10':
resolution: {integrity: sha512-fy9am/HWxbaGt/Sawrp90vt6Y6jQwf1RX77cz3uwoJwJVMli/e1IEwRPnMNJ7vKfPTwo0diXifkpPvwH9v7nGA==}
'@vitest/utils@4.1.9':
resolution: {integrity: sha512-A51o8ymO5PpqlWNnBP9ZHPXDIpuMtTLlGSjN7la4US+LJzoUMyhwjA5QXlm39JexgwHKW4Xjs8Z2d3dLCXOeuA==}
'@vue/compiler-core@3.5.35':
resolution: {integrity: sha512-BUmHaR1J+O+CKZ9uJucdVTEr1LHsdyvv7vG3eNRhK3CczEHeMd/LtsHAuD7PbrxvI2envCY2v7HI1vC1aBRzKw==}
@@ -12054,8 +12042,8 @@ packages:
engines: {node: '>=8', npm: '>=5'}
hasBin: true
mysql2@3.22.6:
resolution: {integrity: sha512-fPKmeDGUzvFP7bMD5SASlJ5zIgvCC4hbanTmhbUlEmhyrY1hR4Hi3xLOWgTd3luYjLVifx6uGvMNJ2m/LlqEpg==}
mysql2@3.22.5:
resolution: {integrity: sha512-95uZ2TrPWAZdwpB3vvvDbmEMcNG8yIeNCyu6GUcr/QnWEE/wXm7+mhOCsdQfWQDTV7qYT/PDUZ4U4UPP4AsXqQ==}
engines: {node: '>= 8.0'}
peerDependencies:
'@types/node': '>= 8'
@@ -14129,8 +14117,8 @@ packages:
sprintf-js@1.1.3:
resolution: {integrity: sha512-Oo+0REFV59/rz3gfJNKQiBlwfHaSESl1pcGyABQsnnIfWOFt6JNj5gCog2U6MLZ//IGYD+nA8nI+mTShREReaA==}
sql-escaper@1.4.0:
resolution: {integrity: sha512-Ti/Zx9J3aITMYUMFhCKbkplQjyi7Kk2SyYXp+rzrkyKZetIy19XbQtVZ+0ZR5aVm/178tIJ6+aVvBqXkH+Xs7w==}
sql-escaper@1.3.3:
resolution: {integrity: sha512-BsTCV265VpTp8tm1wyIm1xqQCS+Q9NHx2Sr+WcnUrgLrQ6yiDIvHYJV5gHxsj1lMBy2zm5twLaZao8Jd+S8JJw==}
engines: {bun: '>=1.0.0', deno: '>=2.0.0', node: '>=12.0.0'}
sql.js@1.14.1:
@@ -15010,20 +14998,20 @@ packages:
yaml:
optional: true
vitest@4.1.10:
resolution: {integrity: sha512-R9jUTe5S4Qb0HCd4TNqpC7oGcrMssMRGXLW80ubjWsW9VH5GF8y1Y0SFLY9AbqSk6nt0PnOx4H4WNJYZ13GUPw==}
vitest@4.1.9:
resolution: {integrity: sha512-nE3/LEyc0z87uHYLZebqCUOaJr2hdtuPp7BQ4BosVFnfltxgAvMG08NyrSGlPpOUWvR27c5flSmYFTNr78L9GQ==}
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.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
'@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
happy-dom: '*'
jsdom: '*'
vite: ^6.0.0 || ^7.0.0 || ^8.0.0
@@ -16432,12 +16420,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.6(@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.5(@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.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)
'@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:
@@ -21819,10 +21807,10 @@ snapshots:
optionalDependencies:
babel-plugin-react-compiler: 1.0.0
'@vitest/coverage-v8@4.1.10(vitest@4.1.10)':
'@vitest/coverage-v8@4.1.9(vitest@4.1.9)':
dependencies:
'@bcoe/v8-coverage': 1.0.2
'@vitest/utils': 4.1.10
'@vitest/utils': 4.1.9
ast-v8-to-istanbul: 1.0.0
istanbul-lib-coverage: 3.2.2
istanbul-lib-report: 3.0.1
@@ -21831,29 +21819,29 @@ snapshots:
obug: 2.1.1
std-env: 4.0.0
tinyrainbow: 3.1.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: 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/expect@4.1.10':
'@vitest/expect@4.1.9':
dependencies:
'@standard-schema/spec': 1.1.0
'@types/chai': 5.2.2
'@vitest/spy': 4.1.10
'@vitest/utils': 4.1.10
'@vitest/spy': 4.1.9
'@vitest/utils': 4.1.9
chai: 6.2.2
tinyrainbow: 3.1.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))':
'@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))':
dependencies:
'@vitest/spy': 4.1.10
'@vitest/spy': 4.1.9
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.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/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))':
dependencies:
'@vitest/spy': 4.1.10
'@vitest/spy': 4.1.9
estree-walker: 3.0.3
magic-string: 0.30.21
optionalDependencies:
@@ -21861,38 +21849,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.10':
'@vitest/pretty-format@4.1.9':
dependencies:
tinyrainbow: 3.1.0
'@vitest/runner@4.1.10':
'@vitest/runner@4.1.9':
dependencies:
'@vitest/utils': 4.1.10
'@vitest/utils': 4.1.9
pathe: 2.0.3
'@vitest/snapshot@4.1.10':
'@vitest/snapshot@4.1.9':
dependencies:
'@vitest/pretty-format': 4.1.10
'@vitest/utils': 4.1.10
'@vitest/pretty-format': 4.1.9
'@vitest/utils': 4.1.9
magic-string: 0.30.21
pathe: 2.0.3
'@vitest/spy@4.1.10': {}
'@vitest/spy@4.1.9': {}
'@vitest/ui@4.1.10(vitest@4.1.10)':
'@vitest/ui@4.1.9(vitest@4.1.9)':
dependencies:
'@vitest/utils': 4.1.10
'@vitest/utils': 4.1.9
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.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: 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/utils@4.1.10':
'@vitest/utils@4.1.9':
dependencies:
'@vitest/pretty-format': 4.1.10
'@vitest/pretty-format': 4.1.9
convert-source-map: 2.0.0
tinyrainbow: 3.1.0
@@ -22440,10 +22428,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.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)):
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)):
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.6(@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.5(@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)
@@ -22462,13 +22450,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.6(@types/node@24.13.3))(pg@8.22.0)(sql.js@1.14.1)
mysql2: 3.22.6(@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.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: 19.2.7
react-dom: 19.2.7(react@19.2.7)
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))
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))
vue: 3.5.35(typescript@6.0.3)
transitivePeerDependencies:
- '@cloudflare/workers-types'
@@ -23679,7 +23667,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.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.2))(pg@8.22.0)(sql.js@1.14.1):
optionalDependencies:
'@libsql/client-wasm': 0.14.0
'@opentelemetry/api': 1.9.0
@@ -23688,13 +23676,26 @@ snapshots:
better-sqlite3: 12.11.1
gel: 2.0.0
kysely: 0.29.2
mysql2: 3.22.6(@types/node@24.13.3)
mysql2: 3.22.5(@types/node@24.13.2)
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.6(@types/node@24.13.3))(pg@8.22.0)(sql.js@1.14.1))(zod@4.4.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.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):
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.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.2))(pg@8.22.0)(sql.js@1.14.1)
zod: 4.4.3
dunder-proto@1.0.1:
@@ -26414,7 +26415,19 @@ snapshots:
'@babel/runtime': 7.29.7
global: 4.4.0
mysql2@3.22.6(@types/node@24.13.3):
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):
dependencies:
'@types/node': 24.13.3
aws-ssl-profiles: 1.1.2
@@ -26424,7 +26437,8 @@ snapshots:
long: 5.3.2
lru.min: 1.1.4
named-placeholders: 1.1.6
sql-escaper: 1.4.0
sql-escaper: 1.3.3
optional: true
mz@2.7.0:
dependencies:
@@ -28819,7 +28833,7 @@ snapshots:
sprintf-js@1.1.3: {}
sql-escaper@1.4.0: {}
sql-escaper@1.3.3: {}
sql.js@1.14.1: {}
@@ -29288,22 +29302,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.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):
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):
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.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))
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.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.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-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):
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.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))
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
transitivePeerDependencies:
@@ -29736,15 +29750,15 @@ snapshots:
yaml: 2.9.0
optional: true
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@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)):
dependencies:
'@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
'@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
es-module-lexer: 2.1.0
expect-type: 1.3.0
magic-string: 0.30.21
@@ -29760,22 +29774,22 @@ snapshots:
why-is-node-running: 2.3.0
optionalDependencies:
'@opentelemetry/api': 1.9.0
'@types/node': 24.13.3
'@vitest/coverage-v8': 4.1.10(vitest@4.1.10)
'@vitest/ui': 4.1.10(vitest@4.1.10)
'@types/node': 24.13.2
'@vitest/coverage-v8': 4.1.9(vitest@4.1.9)
'@vitest/ui': 4.1.9(vitest@4.1.9)
jsdom: 29.1.1(@noble/hashes@2.0.1)
transitivePeerDependencies:
- msw
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)):
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)):
dependencies:
'@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
'@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
es-module-lexer: 2.1.0
expect-type: 1.3.0
magic-string: 0.30.21
@@ -29792,8 +29806,8 @@ snapshots:
optionalDependencies:
'@opentelemetry/api': 1.9.0
'@types/node': 24.13.3
'@vitest/coverage-v8': 4.1.10(vitest@4.1.10)
'@vitest/ui': 4.1.10(vitest@4.1.10)
'@vitest/coverage-v8': 4.1.9(vitest@4.1.9)
'@vitest/ui': 4.1.9(vitest@4.1.9)
jsdom: 29.1.1(@noble/hashes@2.0.1)
transitivePeerDependencies:
- msw
+5 -5
View File
@@ -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.7
posthog-js: ^1.398.2
posthog-node: ^5.39.2
pocketbase: ^0.26.2
"@vitejs/plugin-react": ^6.0.3
"@vitest/coverage-v8": ^4.1.10
"@vitest/ui": ^4.1.10
"@vitest/coverage-v8": ^4.1.9
"@vitest/ui": ^4.1.9
"@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.6
mysql2: 3.22.5
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.10
vitest: ^4.1.9
winston: 3.19.0
ws: ^8.21.0
xml2js: ^0.6.2
+11 -19
View File
@@ -5,20 +5,17 @@ mkdir -p /appdata/db
mkdir -p /appdata/redis
mkdir -p /appdata/trusted-certificates
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
# 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
# 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)
@@ -72,11 +69,6 @@ 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 &
-3
View File
@@ -35,9 +35,6 @@
"@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/**"]