fix: harden workshop availability
This commit is contained in:
28 files changed
+671
-279
No files matched your search
+1
-1
@@ -78,4 +78,4 @@ GITHUB_CLIENT_SECRET=
|
||||
PB_SUPERUSER_EMAIL=
|
||||
PB_SUPERUSER_PASSWORD=
|
||||
PB_EXPOSE_PORT=8090
|
||||
PB_ALLOWED_ORIGINS=http://localhost:3003,http://127.0.0.1:3003
|
||||
PB_ALLOWED_ORIGINS=http://localhost:3000,http://127.0.0.1:3000,http://localhost:3003,http://127.0.0.1:3003,http://localhost:7575,http://127.0.0.1:7575
|
||||
+33
@@ -0,0 +1,33 @@
|
||||
# Product
|
||||
|
||||
## Register
|
||||
|
||||
product
|
||||
|
||||
## Users
|
||||
|
||||
Homarr serves people who organize and operate self-hosted services, from individual home-lab users to administrators managing shared instances. Workshop also serves community creators who publish and install custom widgets and CSS themes. Users need the core dashboard to remain useful even when optional community services are offline.
|
||||
|
||||
## Product Purpose
|
||||
|
||||
Homarr provides a dependable dashboard for discovering, organizing, and operating applications and integrations. Workshop adds a central community catalog where users can inspect, export, publish, report, and install creations with clear source visibility and explicit confirmation.
|
||||
|
||||
## Brand Personality
|
||||
|
||||
Clear, dependable, and community-minded. Interface copy should be direct and practical, with enough context for users to recover from failures without exposing internal implementation details.
|
||||
|
||||
## Anti-references
|
||||
|
||||
Avoid fragmented feature surfaces, generic failure messages, decorative dashboard patterns, unexpected network dependencies, and optional services that can crash or block core Homarr workflows.
|
||||
|
||||
## Design Principles
|
||||
|
||||
- Keep core Homarr workflows available when optional services fail.
|
||||
- Explain failures in user terms and always offer a useful recovery action.
|
||||
- Use native Homarr patterns and shared contracts instead of feature-specific interaction systems.
|
||||
- Make community content inspectable and require explicit confirmation before installation.
|
||||
- Keep operational behavior deterministic for contributors and instance administrators.
|
||||
|
||||
## Accessibility & Inclusion
|
||||
|
||||
Meet WCAG 2.1 AA for contrast, focus, semantics, and keyboard operation. Respect reduced-motion preferences, preserve useful layouts at 200% zoom and on mobile, and ensure status changes and recovery actions are understandable without relying on color alone.
|
||||
@@ -24,10 +24,10 @@ Navigate to `Management` → `Tools` → `Backup`. You must have **admin** permi
|
||||
|
||||
Click the **Export** button in the export card. You'll be prompted to type `I understand` to confirm. Once confirmed, a ZIP file downloads containing:
|
||||
|
||||
| File | Description |
|
||||
| -------------------- | ------------------------------------------------------------------- |
|
||||
| `db.sqlite` | Full SQLite database with all entities |
|
||||
| `metadata.json` | Backup metadata: Homarr version, creation timestamp, encryption key |
|
||||
| File | Description |
|
||||
| --------------- | ------------------------------------------------------------------- |
|
||||
| `db.sqlite` | Full SQLite database with all entities |
|
||||
| `metadata.json` | Backup metadata: Homarr version, creation timestamp, encryption key |
|
||||
|
||||
The backup includes everything: apps, integrations, secrets, boards, users, groups, permissions, search engines, custom widgets, and server settings. (It just backs-up the whole database)
|
||||
|
||||
@@ -54,6 +54,7 @@ Type `I understand` to acknowledge that restoring will replace your current data
|
||||
The backup ZIP is sent to the server, validated, and applied. After a successful restore:
|
||||
|
||||
- **The Next.js server restarts automatically** to reload the new database state
|
||||
- **Server-side database migrations run before Next.js restarts**, including migrations added after the backup was created
|
||||
- Nginx and Redis (if running in Docker) stay up - only the Next.js process restarts
|
||||
- The page reloads once the server is ready
|
||||
|
||||
|
||||
@@ -19,6 +19,8 @@ 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.
|
||||
|
||||
- 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.
|
||||
|
||||
@@ -3,6 +3,11 @@ import type { Config } from "@docusaurus/types";
|
||||
import { themes as prismThemes } from "prism-react-renderer";
|
||||
const a11yEmoji = require("@fec/remark-a11y-emoji");
|
||||
|
||||
const workshopApiUrl =
|
||||
process.env.NODE_ENV !== "production"
|
||||
? (process.env.WORKSHOP_API_URL ?? process.env.NEXT_PUBLIC_WORKSHOP_API_URL ?? "https://workshop.homarr.dev")
|
||||
: "https://workshop.homarr.dev";
|
||||
|
||||
const config: Config = {
|
||||
title: "Homarr documentation",
|
||||
tagline: "A simple yet powerful dashboard for your server.",
|
||||
@@ -17,8 +22,7 @@ const config: Config = {
|
||||
locales: ["en"],
|
||||
},
|
||||
customFields: {
|
||||
workshopApiUrl:
|
||||
process.env.WORKSHOP_API_URL ?? process.env.NEXT_PUBLIC_WORKSHOP_API_URL ?? "https://workshop.homarr.dev",
|
||||
workshopApiUrl,
|
||||
},
|
||||
onBrokenLinks: "throw",
|
||||
onBrokenAnchors: "throw",
|
||||
|
||||
@@ -34,6 +34,9 @@
|
||||
"@signalwire/docusaurus-plugin-llms-txt": "^1.2.2",
|
||||
"@tabler/icons-react": "catalog:",
|
||||
"@tailwindcss/postcss": "^4.3.2",
|
||||
"@tanstack/query-async-storage-persister": "catalog:",
|
||||
"@tanstack/react-query": "catalog:",
|
||||
"@tanstack/react-query-persist-client": "catalog:",
|
||||
"clsx": "catalog:",
|
||||
"dayjs": "catalog:",
|
||||
"docusaurus-plugin-image-zoom": "^3.0.1",
|
||||
|
||||
@@ -1,16 +1,16 @@
|
||||
import React, { useCallback, useEffect, useMemo, useState } from "react";
|
||||
import React, { useEffect, useMemo, useState } from "react";
|
||||
import useDocusaurusContext from "@docusaurus/useDocusaurusContext";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
|
||||
import {
|
||||
WORKSHOP_API_URL,
|
||||
WorkshopClient,
|
||||
type WorkshopAccountState,
|
||||
type WorkshopModerationAction,
|
||||
type WorkshopReport,
|
||||
type WorkshopRole,
|
||||
type WorkshopUser,
|
||||
} from "@homarr/workshop";
|
||||
|
||||
import { WorkshopQueryProvider } from "./WorkshopQueryProvider";
|
||||
import styles from "./workshop.module.css";
|
||||
|
||||
type PendingAction =
|
||||
@@ -19,50 +19,77 @@ type PendingAction =
|
||||
| { kind: "role"; id: string; role: WorkshopRole };
|
||||
|
||||
export function WorkshopAdmin() {
|
||||
return (
|
||||
<WorkshopQueryProvider>
|
||||
<WorkshopAdminContent />
|
||||
</WorkshopQueryProvider>
|
||||
);
|
||||
}
|
||||
|
||||
function WorkshopAdminContent() {
|
||||
const { siteConfig } = useDocusaurusContext();
|
||||
const client = useMemo(
|
||||
() => new WorkshopClient((siteConfig.customFields?.workshopApiUrl as string | undefined) ?? WORKSHOP_API_URL),
|
||||
[siteConfig],
|
||||
);
|
||||
const [user, setUser] = useState<WorkshopUser | null>(null);
|
||||
const [reports, setReports] = useState<WorkshopReport[]>([]);
|
||||
const [users, setUsers] = useState<WorkshopUser[]>([]);
|
||||
const [actions, setActions] = useState<WorkshopModerationAction[]>([]);
|
||||
const [pending, setPending] = useState<PendingAction | null>(null);
|
||||
const [error, setError] = useState("");
|
||||
const [authLoading, setAuthLoading] = useState(true);
|
||||
|
||||
const load = useCallback(async () => {
|
||||
const current = await client.refreshAuth();
|
||||
setUser(current);
|
||||
if (!current || (current.role !== "moderator" && current.role !== "admin")) return;
|
||||
try {
|
||||
const [nextReports, nextUsers, nextActions] = await Promise.all([
|
||||
const canModerate = user?.role === "moderator" || user?.role === "admin";
|
||||
const moderationQuery = useQuery({
|
||||
queryKey: ["workshop", "moderation"],
|
||||
queryFn: async () => {
|
||||
const [reports, users, actions] = await Promise.all([
|
||||
client.listReports(),
|
||||
client.listUsers(),
|
||||
client.listModerationActions(),
|
||||
]);
|
||||
setReports(nextReports);
|
||||
setUsers(nextUsers);
|
||||
setActions(nextActions);
|
||||
} catch (caught) {
|
||||
setError(caught instanceof Error ? caught.message : "Could not load moderation data");
|
||||
}
|
||||
}, [client]);
|
||||
return { reports, users, actions };
|
||||
},
|
||||
enabled: canModerate,
|
||||
});
|
||||
const reports = moderationQuery.data?.reports ?? [];
|
||||
const users = moderationQuery.data?.users ?? [];
|
||||
const actions = moderationQuery.data?.actions ?? [];
|
||||
|
||||
useEffect(() => client.subscribeToAuth(setUser), [client]);
|
||||
useEffect(() => {
|
||||
void load();
|
||||
}, [load]);
|
||||
void client
|
||||
.refreshAuth()
|
||||
.then(setUser)
|
||||
.catch((caught: unknown) =>
|
||||
setError(caught instanceof Error ? caught.message : "Workshop authentication is unavailable"),
|
||||
)
|
||||
.finally(() => setAuthLoading(false));
|
||||
}, [client]);
|
||||
|
||||
if (authLoading)
|
||||
return (
|
||||
<main className={styles.page}>
|
||||
<WorkshopAdminSkeleton />
|
||||
</main>
|
||||
);
|
||||
if (!user)
|
||||
return (
|
||||
<main className={styles.page}>
|
||||
<h1>Workshop moderation</h1>
|
||||
<p>Sign in with a promoted GitHub account to continue.</p>
|
||||
{error && (
|
||||
<div className={styles.error} role="alert">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
<button
|
||||
className="button button--primary"
|
||||
onClick={async () => {
|
||||
await client.signInWithGitHub();
|
||||
await load();
|
||||
setError("");
|
||||
try {
|
||||
setUser(await client.signInWithGitHub());
|
||||
} catch (caught) {
|
||||
setError(caught instanceof Error ? caught.message : "GitHub sign-in is unavailable");
|
||||
}
|
||||
}}
|
||||
>
|
||||
Sign in with GitHub
|
||||
@@ -87,9 +114,10 @@ export function WorkshopAdmin() {
|
||||
<p>Review reports and apply the smallest action needed. Every action requires a reason and is recorded.</p>
|
||||
</div>
|
||||
</header>
|
||||
{error && (
|
||||
{(error || moderationQuery.isError) && (
|
||||
<div className={styles.error} role="alert">
|
||||
{error}
|
||||
{error || "Moderation data is unavailable. Try again when Workshop reconnects."}{" "}
|
||||
{moderationQuery.isError && <button onClick={() => void moderationQuery.refetch()}>Try again</button>}
|
||||
</div>
|
||||
)}
|
||||
<section className={styles.adminSection}>
|
||||
@@ -235,7 +263,7 @@ export function WorkshopAdmin() {
|
||||
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();
|
||||
await moderationQuery.refetch();
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
@@ -243,6 +271,15 @@ export function WorkshopAdmin() {
|
||||
);
|
||||
}
|
||||
|
||||
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,
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import React, { useCallback, useEffect, useMemo, useState } from "react";
|
||||
import React, { useEffect, useMemo, useState } from "react";
|
||||
import Link from "@docusaurus/Link";
|
||||
import { useHistory, useLocation } from "@docusaurus/router";
|
||||
import useDocusaurusContext from "@docusaurus/useDocusaurusContext";
|
||||
@@ -13,6 +13,8 @@ import {
|
||||
} from "@tabler/icons-react";
|
||||
|
||||
import {
|
||||
MAX_CONTENT_LENGTH,
|
||||
MAX_SCREENSHOTS,
|
||||
WORKSHOP_API_URL,
|
||||
WorkshopClient,
|
||||
type WorkshopSubmissionDetail,
|
||||
@@ -22,7 +24,9 @@ import {
|
||||
type WorkshopUser,
|
||||
workshopExportFilename,
|
||||
} from "@homarr/workshop";
|
||||
import { keepPreviousData, useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
|
||||
import { WorkshopQueryProvider } from "./WorkshopQueryProvider";
|
||||
import styles from "./workshop.module.css";
|
||||
|
||||
const useWorkshop = () => {
|
||||
@@ -43,45 +47,55 @@ 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);
|
||||
|
||||
useEffect(() => client.subscribeToAuth(setUser), [client]);
|
||||
|
||||
const load = useCallback(async () => {
|
||||
setLoading(true);
|
||||
setError("");
|
||||
try {
|
||||
await client.refreshAuth();
|
||||
const result = await client.list({ page, type, sort, search, author: mine ? client.currentUser?.id : undefined });
|
||||
setItems(result.items);
|
||||
setTotalPages(result.totalPages);
|
||||
} catch (caught) {
|
||||
setError(caught instanceof Error ? caught.message : "Workshop is unavailable");
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [client, mine, page, search, sort, type]);
|
||||
|
||||
useEffect(() => {
|
||||
const timeout = window.setTimeout(() => void load(), 250);
|
||||
void client.refreshAuth().catch(() => undefined);
|
||||
}, [client]);
|
||||
useEffect(() => {
|
||||
const timeout = window.setTimeout(() => setDebouncedSearch(search), 250);
|
||||
return () => window.clearTimeout(timeout);
|
||||
}, [load]);
|
||||
}, [search]);
|
||||
|
||||
const 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 signIn = async () => {
|
||||
try {
|
||||
await client.signInWithGitHub();
|
||||
await load();
|
||||
await queryClient.invalidateQueries({ queryKey: ["workshop"] });
|
||||
} catch (caught) {
|
||||
setError(caught instanceof Error ? caught.message : "Sign-in failed");
|
||||
}
|
||||
@@ -179,12 +193,17 @@ export function WorkshopApp() {
|
||||
)}
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
{(error || listQuery.isError || listQuery.fetchStatus === "paused") && (
|
||||
<div className={styles.error} role="alert">
|
||||
{error} <button onClick={() => void load()}>Try again</button>
|
||||
<strong>{items.length ? "Showing saved Workshop results." : "Workshop is unavailable."}</strong>{" "}
|
||||
{error ||
|
||||
(items.length
|
||||
? "Some details and actions may be unavailable until the service reconnects."
|
||||
: "Homarr and the documentation remain available while the community service is offline.")}{" "}
|
||||
<button onClick={() => void listQuery.refetch()}>Try again</button>
|
||||
</div>
|
||||
)}
|
||||
{loading ? (
|
||||
{listQuery.isPending ? (
|
||||
<WorkshopSkeleton />
|
||||
) : items.length ? (
|
||||
<div className={styles.grid}>
|
||||
@@ -192,7 +211,7 @@ export function WorkshopApp() {
|
||||
<WorkshopCard client={client} item={item} key={item.id} />
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
) : listQuery.isError || listQuery.fetchStatus === "paused" ? null : (
|
||||
<section className={styles.empty}>
|
||||
<h2>No creations found</h2>
|
||||
<p>Adjust the filters, or share the first creation in this category.</p>
|
||||
@@ -218,7 +237,7 @@ export function WorkshopApp() {
|
||||
onClose={() => setShowForm(false)}
|
||||
onSaved={async () => {
|
||||
setShowForm(false);
|
||||
await load();
|
||||
await queryClient.invalidateQueries({ queryKey: ["workshop"] });
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
@@ -258,42 +277,64 @@ function WorkshopCard({ client, item }: { client: WorkshopClient; item: Workshop
|
||||
}
|
||||
|
||||
export function WorkshopDetailRoute() {
|
||||
return (
|
||||
<WorkshopQueryProvider>
|
||||
<WorkshopDetailRouteContent />
|
||||
</WorkshopQueryProvider>
|
||||
);
|
||||
}
|
||||
|
||||
function WorkshopDetailRouteContent() {
|
||||
const client = useWorkshop();
|
||||
const queryClient = useQueryClient();
|
||||
const location = useLocation();
|
||||
const id = location.pathname.split("/").filter(Boolean).at(-1) ?? "";
|
||||
const history = useHistory();
|
||||
const [user, setUser] = useState<WorkshopUser | null>(null);
|
||||
const [submission, setSubmission] = useState<WorkshopSubmissionDetail | null>(null);
|
||||
const [error, setError] = useState("");
|
||||
const [reporting, setReporting] = useState(false);
|
||||
const [editing, setEditing] = useState(false);
|
||||
const [moderating, setModerating] = useState(false);
|
||||
const [actionError, setActionError] = useState("");
|
||||
|
||||
const load = useCallback(async () => {
|
||||
try {
|
||||
setSubmission(await client.get(id));
|
||||
} catch (caught) {
|
||||
setError(caught instanceof Error ? caught.message : "Submission not found");
|
||||
}
|
||||
}, [client, id]);
|
||||
const detailQuery = useQuery({
|
||||
queryKey: ["workshop", "detail", id],
|
||||
queryFn: ({ signal }) => client.get(id, signal),
|
||||
enabled: id.length > 0,
|
||||
});
|
||||
const voteMutation = useMutation({
|
||||
mutationKey: ["workshop", "vote", id],
|
||||
mutationFn: async () => {
|
||||
await (client.currentUser ?? client.signInWithGitHub());
|
||||
return client.vote(id, 1);
|
||||
},
|
||||
onSuccess: async () => queryClient.invalidateQueries({ queryKey: ["workshop", "detail", id] }),
|
||||
onError: (caught) => setActionError(caught instanceof Error ? caught.message : "The vote could not be saved."),
|
||||
});
|
||||
const deleteMutation = useMutation({
|
||||
mutationKey: ["workshop", "delete", id],
|
||||
mutationFn: () => client.delete(id),
|
||||
onSuccess: () => history.push("/workshop"),
|
||||
onError: (caught) =>
|
||||
setActionError(caught instanceof Error ? caught.message : "The submission could not be deleted."),
|
||||
});
|
||||
useEffect(() => client.subscribeToAuth(setUser), [client]);
|
||||
useEffect(() => {
|
||||
void client.refreshAuth();
|
||||
void load();
|
||||
}, [client, load]);
|
||||
}, [client]);
|
||||
|
||||
if (error)
|
||||
if (detailQuery.isError)
|
||||
return (
|
||||
<main className={styles.page}>
|
||||
<div className={styles.error} role="alert">
|
||||
{error}
|
||||
This creation is not saved on this device and Workshop could not be reached.{" "}
|
||||
<button onClick={() => void detailQuery.refetch()}>Try again</button>
|
||||
</div>
|
||||
<Link to="/workshop">Back to Workshop</Link>
|
||||
</main>
|
||||
);
|
||||
if (!submission) return <WorkshopSkeleton />;
|
||||
if (!detailQuery.data) return <WorkshopSkeleton />;
|
||||
|
||||
const requireAuth = async () => client.currentUser ?? client.signInWithGitHub();
|
||||
const submission = detailQuery.data;
|
||||
return (
|
||||
<main className={styles.page}>
|
||||
<Link to="/workshop">← Back to Workshop</Link>
|
||||
@@ -329,10 +370,10 @@ export function WorkshopDetailRoute() {
|
||||
</button>
|
||||
<button
|
||||
className="button button--secondary"
|
||||
onClick={async () => {
|
||||
await requireAuth();
|
||||
await client.vote(submission.id, 1);
|
||||
await load();
|
||||
disabled={voteMutation.isPending}
|
||||
onClick={() => {
|
||||
setActionError("");
|
||||
voteMutation.mutate();
|
||||
}}
|
||||
>
|
||||
Upvote ({submission.upvotes})
|
||||
@@ -347,10 +388,11 @@ export function WorkshopDetailRoute() {
|
||||
</button>
|
||||
<button
|
||||
className="button button--danger"
|
||||
onClick={async () => {
|
||||
disabled={deleteMutation.isPending}
|
||||
onClick={() => {
|
||||
if (window.confirm("Delete this submission permanently?")) {
|
||||
await client.delete(submission.id);
|
||||
history.push("/workshop");
|
||||
setActionError("");
|
||||
deleteMutation.mutate();
|
||||
}
|
||||
}}
|
||||
>
|
||||
@@ -364,6 +406,11 @@ export function WorkshopDetailRoute() {
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
{actionError && (
|
||||
<div className={styles.error} role="alert">
|
||||
{actionError}
|
||||
</div>
|
||||
)}
|
||||
<section>
|
||||
<h2>Source</h2>
|
||||
<p className={styles.sourceHint}>
|
||||
@@ -388,7 +435,7 @@ export function WorkshopDetailRoute() {
|
||||
onClose={() => setEditing(false)}
|
||||
onSaved={async () => {
|
||||
setEditing(false);
|
||||
await load();
|
||||
await queryClient.invalidateQueries({ queryKey: ["workshop", "detail", id] });
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
@@ -484,6 +531,11 @@ function SubmissionForm({
|
||||
const [removedScreenshots, setRemovedScreenshots] = useState<string[]>([]);
|
||||
const [error, setError] = useState("");
|
||||
const [pending, setPending] = useState(false);
|
||||
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 submit = async (event: React.FormEvent) => {
|
||||
event.preventDefault();
|
||||
setPending(true);
|
||||
@@ -544,6 +596,7 @@ function SubmissionForm({
|
||||
<textarea
|
||||
className={styles.editor}
|
||||
required
|
||||
maxLength={MAX_CONTENT_LENGTH}
|
||||
value={content}
|
||||
onChange={(event) => setContent(event.target.value)}
|
||||
/>
|
||||
@@ -553,12 +606,14 @@ function SubmissionForm({
|
||||
<textarea maxLength={2000} value={changelog} onChange={(event) => setChangelog(event.target.value)} />
|
||||
</label>
|
||||
<label>
|
||||
Screenshots (PNG, JPG, or WebP, up to 5)
|
||||
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, 5))}
|
||||
onChange={(event) =>
|
||||
setScreenshots(Array.from(event.target.files ?? []).slice(0, remainingScreenshotSlots))
|
||||
}
|
||||
/>
|
||||
</label>
|
||||
{existing?.screenshots.map((screenshot) => (
|
||||
|
||||
@@ -0,0 +1,43 @@
|
||||
import React, { type PropsWithChildren, useState } from "react";
|
||||
import { createAsyncStoragePersister } from "@tanstack/query-async-storage-persister";
|
||||
import { QueryClient } from "@tanstack/react-query";
|
||||
import { PersistQueryClientProvider } from "@tanstack/react-query-persist-client";
|
||||
|
||||
const maxAge = 1000 * 60 * 60 * 24;
|
||||
|
||||
export function WorkshopQueryProvider({ children }: PropsWithChildren) {
|
||||
const [client] = useState(
|
||||
() =>
|
||||
new QueryClient({
|
||||
defaultOptions: {
|
||||
queries: {
|
||||
gcTime: maxAge,
|
||||
networkMode: "offlineFirst",
|
||||
retry: 1,
|
||||
staleTime: 1000 * 60 * 5,
|
||||
},
|
||||
mutations: { networkMode: "online", retry: false },
|
||||
},
|
||||
}),
|
||||
);
|
||||
const [persister] = useState(() =>
|
||||
createAsyncStoragePersister({
|
||||
key: "homarr-workshop-website-cache-v1",
|
||||
storage: window.localStorage,
|
||||
}),
|
||||
);
|
||||
|
||||
return (
|
||||
<PersistQueryClientProvider
|
||||
client={client}
|
||||
persistOptions={{
|
||||
persister,
|
||||
maxAge,
|
||||
buster: "workshop-v1",
|
||||
dehydrateOptions: { shouldDehydrateQuery: (query) => query.state.status === "success" },
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
</PersistQueryClientProvider>
|
||||
);
|
||||
}
|
||||
@@ -149,7 +149,7 @@ export const CustomWidgetRowActions = ({ widget }: { widget: WidgetRef }) => {
|
||||
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} />}>
|
||||
<Menu.Item closeMenuOnClick={false} onClick={open} leftSection={<IconShare3 {...iconProps} />}>
|
||||
{workshopT("action.share")}
|
||||
</Menu.Item>
|
||||
)}
|
||||
|
||||
@@ -6,6 +6,7 @@ import {
|
||||
AccordionPanel,
|
||||
ActionIcon,
|
||||
ActionIconGroup,
|
||||
Alert,
|
||||
Anchor,
|
||||
Divider,
|
||||
Group,
|
||||
@@ -18,14 +19,14 @@ import {
|
||||
TableTr,
|
||||
Text,
|
||||
} from "@mantine/core";
|
||||
import { IconPencil, IconPlugX } from "@tabler/icons-react";
|
||||
import { IconAlertTriangle, IconPencil, IconPlugX } from "@tabler/icons-react";
|
||||
|
||||
import type { RouterOutputs } from "@homarr/api";
|
||||
import { api } from "@homarr/api/server";
|
||||
import { auth } from "@homarr/auth/next";
|
||||
import { objectEntries } from "@homarr/common";
|
||||
import type { IntegrationKind } from "@homarr/definitions";
|
||||
import { getIntegrationName } from "@homarr/definitions";
|
||||
import { getIntegrationName, isIntegrationKind } from "@homarr/definitions";
|
||||
import { getScopedI18n } from "@homarr/translation/server";
|
||||
import { CountBadge, IntegrationAvatar, Link } from "@homarr/ui";
|
||||
|
||||
@@ -87,11 +88,14 @@ const IntegrationList = async ({ integrations, activeTab }: IntegrationListProps
|
||||
const session = await auth();
|
||||
const hasFullAccess = session?.user.permissions.includes("integration-full-all") ?? false;
|
||||
|
||||
if (integrations.length === 0) {
|
||||
const supportedIntegrations = integrations.filter((integration) => isIntegrationKind(integration.kind));
|
||||
const unsupportedCount = integrations.length - supportedIntegrations.length;
|
||||
|
||||
if (supportedIntegrations.length === 0 && unsupportedCount === 0) {
|
||||
return <NoResults icon={IconPlugX} title={t("page.list.noResults.title")} />;
|
||||
}
|
||||
|
||||
const groupedIntegrations = integrations.reduce(
|
||||
const groupedIntegrations = supportedIntegrations.reduce(
|
||||
(acc, integration) => {
|
||||
// eslint-disable-next-line @typescript-eslint/no-unnecessary-condition
|
||||
if (!acc[integration.kind]) {
|
||||
@@ -108,88 +112,105 @@ const IntegrationList = async ({ integrations, activeTab }: IntegrationListProps
|
||||
const entries = objectEntries(groupedIntegrations);
|
||||
|
||||
return (
|
||||
<ActiveTabAccordion defaultValue={activeTab} radius="lg" classNames={classes}>
|
||||
{entries.map(([kind, kindIntegrations], index) => (
|
||||
<AccordionItem key={kind} value={kind} data-first={index === 0} data-last={index === entries.length - 1}>
|
||||
<AccordionControl icon={<IntegrationAvatar size="sm" kind={kind} radius="sm" />}>
|
||||
<Group>
|
||||
<Text>{getIntegrationName(kind)}</Text>
|
||||
<CountBadge count={kindIntegrations.length} />
|
||||
</Group>
|
||||
</AccordionControl>
|
||||
<AccordionPanel>
|
||||
<Table visibleFrom="md">
|
||||
<TableThead>
|
||||
<TableTr>
|
||||
<TableTh>{t("field.name.label")}</TableTh>
|
||||
<TableTh>{t("field.url.label")}</TableTh>
|
||||
<TableTh />
|
||||
</TableTr>
|
||||
</TableThead>
|
||||
<TableTbody>
|
||||
{kindIntegrations.map((integration) => (
|
||||
<TableTr key={integration.id}>
|
||||
<TableTd>{integration.name}</TableTd>
|
||||
<TableTd>
|
||||
<Anchor href={integration.url} target="_blank" rel="noreferrer" size="sm">
|
||||
{integration.url}
|
||||
</Anchor>
|
||||
</TableTd>
|
||||
<TableTd>
|
||||
<Group justify="end">
|
||||
{(hasFullAccess || integration.permissions.hasFullAccess) && (
|
||||
<ActionIconGroup>
|
||||
<ActionIcon
|
||||
component={Link}
|
||||
href={`/manage/integrations/edit/${integration.id}`}
|
||||
variant="subtle"
|
||||
color="gray"
|
||||
aria-label={t("page.edit.title", { name: getIntegrationName(integration.kind) })}
|
||||
>
|
||||
<IconPencil size={16} stroke={1.5} />
|
||||
</ActionIcon>
|
||||
<DeleteIntegrationActionButton integration={integration} count={kindIntegrations.length} />
|
||||
</ActionIconGroup>
|
||||
)}
|
||||
</Group>
|
||||
</TableTd>
|
||||
</TableTr>
|
||||
))}
|
||||
</TableTbody>
|
||||
</Table>
|
||||
<Stack>
|
||||
{unsupportedCount > 0 && (
|
||||
<Alert color="yellow" icon={<IconAlertTriangle size={18} />} title={t("page.list.outdated.title")}>
|
||||
{t("page.list.outdated.description", { count: unsupportedCount })}
|
||||
</Alert>
|
||||
)}
|
||||
{supportedIntegrations.length === 0 ? (
|
||||
<NoResults icon={IconPlugX} title={t("page.list.noResults.title")} />
|
||||
) : (
|
||||
<ActiveTabAccordion defaultValue={activeTab} radius="lg" classNames={classes}>
|
||||
{entries.map(([kind, kindIntegrations], index) => (
|
||||
<AccordionItem key={kind} value={kind} data-first={index === 0} data-last={index === entries.length - 1}>
|
||||
<AccordionControl icon={<IntegrationAvatar size="sm" kind={kind} radius="sm" />}>
|
||||
<Group>
|
||||
<Text>{getIntegrationName(kind)}</Text>
|
||||
<CountBadge count={kindIntegrations.length} />
|
||||
</Group>
|
||||
</AccordionControl>
|
||||
<AccordionPanel>
|
||||
<Table visibleFrom="md">
|
||||
<TableThead>
|
||||
<TableTr>
|
||||
<TableTh>{t("field.name.label")}</TableTh>
|
||||
<TableTh>{t("field.url.label")}</TableTh>
|
||||
<TableTh />
|
||||
</TableTr>
|
||||
</TableThead>
|
||||
<TableTbody>
|
||||
{kindIntegrations.map((integration) => (
|
||||
<TableTr key={integration.id}>
|
||||
<TableTd>{integration.name}</TableTd>
|
||||
<TableTd>
|
||||
<Anchor href={integration.url} target="_blank" rel="noreferrer" size="sm">
|
||||
{integration.url}
|
||||
</Anchor>
|
||||
</TableTd>
|
||||
<TableTd>
|
||||
<Group justify="end">
|
||||
{(hasFullAccess || integration.permissions.hasFullAccess) && (
|
||||
<ActionIconGroup>
|
||||
<ActionIcon
|
||||
component={Link}
|
||||
href={`/manage/integrations/edit/${integration.id}`}
|
||||
variant="subtle"
|
||||
color="gray"
|
||||
aria-label={t("page.edit.title", { name: getIntegrationName(integration.kind) })}
|
||||
>
|
||||
<IconPencil size={16} stroke={1.5} />
|
||||
</ActionIcon>
|
||||
<DeleteIntegrationActionButton
|
||||
integration={integration}
|
||||
count={kindIntegrations.length}
|
||||
/>
|
||||
</ActionIconGroup>
|
||||
)}
|
||||
</Group>
|
||||
</TableTd>
|
||||
</TableTr>
|
||||
))}
|
||||
</TableTbody>
|
||||
</Table>
|
||||
|
||||
<Stack gap="xs" hiddenFrom="md">
|
||||
{kindIntegrations.map((integration, integrationIndex) => (
|
||||
<Fragment key={integration.id}>
|
||||
{integrationIndex !== 0 && <Divider />}
|
||||
<Stack gap={0}>
|
||||
<Group justify="space-between" align="center" wrap="nowrap">
|
||||
<Text>{integration.name}</Text>
|
||||
{(hasFullAccess || integration.permissions.hasFullAccess) && (
|
||||
<ActionIconGroup>
|
||||
<ActionIcon
|
||||
component={Link}
|
||||
href={`/manage/integrations/edit/${integration.id}`}
|
||||
variant="subtle"
|
||||
color="gray"
|
||||
aria-label={t("page.edit.title", { name: getIntegrationName(integration.kind) })}
|
||||
>
|
||||
<IconPencil size={16} stroke={1.5} />
|
||||
</ActionIcon>
|
||||
<DeleteIntegrationActionButton integration={integration} count={kindIntegrations.length} />
|
||||
</ActionIconGroup>
|
||||
)}
|
||||
</Group>
|
||||
<Anchor href={integration.url} target="_blank" rel="noreferrer" size="sm">
|
||||
{integration.url}
|
||||
</Anchor>
|
||||
</Stack>
|
||||
</Fragment>
|
||||
))}
|
||||
</Stack>
|
||||
</AccordionPanel>
|
||||
</AccordionItem>
|
||||
))}
|
||||
</ActiveTabAccordion>
|
||||
<Stack gap="xs" hiddenFrom="md">
|
||||
{kindIntegrations.map((integration, integrationIndex) => (
|
||||
<Fragment key={integration.id}>
|
||||
{integrationIndex !== 0 && <Divider />}
|
||||
<Stack gap={0}>
|
||||
<Group justify="space-between" align="center" wrap="nowrap">
|
||||
<Text>{integration.name}</Text>
|
||||
{(hasFullAccess || integration.permissions.hasFullAccess) && (
|
||||
<ActionIconGroup>
|
||||
<ActionIcon
|
||||
component={Link}
|
||||
href={`/manage/integrations/edit/${integration.id}`}
|
||||
variant="subtle"
|
||||
color="gray"
|
||||
aria-label={t("page.edit.title", { name: getIntegrationName(integration.kind) })}
|
||||
>
|
||||
<IconPencil size={16} stroke={1.5} />
|
||||
</ActionIcon>
|
||||
<DeleteIntegrationActionButton
|
||||
integration={integration}
|
||||
count={kindIntegrations.length}
|
||||
/>
|
||||
</ActionIconGroup>
|
||||
)}
|
||||
</Group>
|
||||
<Anchor href={integration.url} target="_blank" rel="noreferrer" size="sm">
|
||||
{integration.url}
|
||||
</Anchor>
|
||||
</Stack>
|
||||
</Fragment>
|
||||
))}
|
||||
</Stack>
|
||||
</AccordionPanel>
|
||||
</AccordionItem>
|
||||
))}
|
||||
</ActiveTabAccordion>
|
||||
)}
|
||||
</Stack>
|
||||
);
|
||||
};
|
||||
@@ -179,8 +179,8 @@ export async function POST(req: Request) {
|
||||
}
|
||||
|
||||
setTimeout(() => {
|
||||
console.log("Database restored, restarting server...");
|
||||
process.exit(0);
|
||||
console.log("Database restored, restarting server and applying migrations...");
|
||||
process.exit(101);
|
||||
}, 500);
|
||||
|
||||
return NextResponse.json({ success: true, message: "Database restored. Server is restarting..." });
|
||||
|
||||
@@ -22,14 +22,23 @@ import {
|
||||
Textarea,
|
||||
} from "@mantine/core";
|
||||
import { useDebouncedValue, useDisclosure } from "@mantine/hooks";
|
||||
import { IconAlertTriangle, IconBrandGithub, IconDownload, IconExternalLink, IconSearch } from "@tabler/icons-react";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import {
|
||||
IconAlertTriangle,
|
||||
IconBrandGithub,
|
||||
IconDownload,
|
||||
IconExternalLink,
|
||||
IconSearch,
|
||||
IconWifiOff,
|
||||
} from "@tabler/icons-react";
|
||||
import { keepPreviousData, useMutation, useQuery } from "@tanstack/react-query";
|
||||
|
||||
import type { WorkshopSubmissionDetail, WorkshopSubmissionType } from "@homarr/workshop";
|
||||
|
||||
import { WORKSHOP_API_URL, WORKSHOP_WEB_URL, WorkshopClient, workshopExportFilename } from "@homarr/workshop";
|
||||
import { useScopedI18n } from "@homarr/translation/client";
|
||||
|
||||
import { WorkshopQueryProvider } from "./workshop-query-provider";
|
||||
|
||||
const apiUrl = process.env.NEXT_PUBLIC_WORKSHOP_API_URL ?? WORKSHOP_API_URL;
|
||||
|
||||
export interface WorkshopBrowserProps {
|
||||
@@ -39,7 +48,15 @@ export interface WorkshopBrowserProps {
|
||||
useLabel?: string;
|
||||
}
|
||||
|
||||
export function WorkshopBrowser({ initialType = "all", lockedType, onUse, useLabel }: WorkshopBrowserProps) {
|
||||
export function WorkshopBrowser(props: WorkshopBrowserProps) {
|
||||
return (
|
||||
<WorkshopQueryProvider>
|
||||
<WorkshopBrowserContent {...props} />
|
||||
</WorkshopQueryProvider>
|
||||
);
|
||||
}
|
||||
|
||||
function WorkshopBrowserContent({ initialType = "all", lockedType, onUse, useLabel }: WorkshopBrowserProps) {
|
||||
const t = useScopedI18n("workshop");
|
||||
const client = useMemo(() => new WorkshopClient(apiUrl), []);
|
||||
const [page, setPage] = useState(1);
|
||||
@@ -47,24 +64,41 @@ export function WorkshopBrowser({ initialType = "all", lockedType, onUse, useLab
|
||||
const [sort, setSort] = useState<"top" | "newest">("top");
|
||||
const [search, setSearch] = useState("");
|
||||
const [debouncedSearch] = useDebouncedValue(search, 250);
|
||||
const [selected, setSelected] = useState<WorkshopSubmissionDetail | null>(null);
|
||||
const [detailLoading, setDetailLoading] = useState(false);
|
||||
const [usePending, setUsePending] = useState(false);
|
||||
const [useError, setUseError] = useState("");
|
||||
const [selectedId, setSelectedId] = useState<string | null>(null);
|
||||
const [online, setOnline] = useState(true);
|
||||
|
||||
useEffect(() => {
|
||||
const updateOnline = () => setOnline(window.navigator.onLine);
|
||||
updateOnline();
|
||||
window.addEventListener("online", updateOnline);
|
||||
window.addEventListener("offline", updateOnline);
|
||||
return () => {
|
||||
window.removeEventListener("online", updateOnline);
|
||||
window.removeEventListener("offline", updateOnline);
|
||||
};
|
||||
}, []);
|
||||
|
||||
const query = useQuery({
|
||||
queryKey: ["workshop", "list", page, type, sort, debouncedSearch],
|
||||
queryFn: () => client.list({ page, type, sort, search: debouncedSearch, perPage: 12 }),
|
||||
queryFn: ({ signal }) => client.list({ page, type, sort, search: debouncedSearch, perPage: 12, signal }),
|
||||
placeholderData: keepPreviousData,
|
||||
});
|
||||
|
||||
const openDetail = async (id: string) => {
|
||||
setDetailLoading(true);
|
||||
try {
|
||||
setSelected(await client.get(id));
|
||||
} finally {
|
||||
setDetailLoading(false);
|
||||
}
|
||||
};
|
||||
const detailQuery = useQuery({
|
||||
queryKey: ["workshop", "detail", selectedId],
|
||||
queryFn: ({ signal }) => client.get(selectedId ?? "", signal),
|
||||
enabled: selectedId !== null,
|
||||
});
|
||||
const useMutationState = useMutation({
|
||||
mutationKey: ["workshop", "use"],
|
||||
mutationFn: async (submission: WorkshopSubmissionDetail) => {
|
||||
if (lockedType && submission.type !== lockedType) throw new Error(t("error.wrongType"));
|
||||
if (!onUse) return;
|
||||
await onUse(submission);
|
||||
},
|
||||
onSuccess: () => setSelectedId(null),
|
||||
});
|
||||
const selected = detailQuery.data ?? null;
|
||||
const hasCachedResults = Boolean(query.data?.items.length);
|
||||
|
||||
const download = (submission: WorkshopSubmissionDetail) => {
|
||||
const url = URL.createObjectURL(
|
||||
@@ -121,12 +155,29 @@ export function WorkshopBrowser({ initialType = "all", lockedType, onUse, useLab
|
||||
]}
|
||||
/>
|
||||
</Group>
|
||||
{query.error && (
|
||||
<Alert color="red" title={t("error.loadTitle")}>
|
||||
{query.error.message}
|
||||
<Button variant="subtle" onClick={() => void query.refetch()}>
|
||||
{t("action.retry")}
|
||||
</Button>
|
||||
{(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>
|
||||
<Button size="xs" variant="light" onClick={() => void query.refetch()}>
|
||||
{t("action.retry")}
|
||||
</Button>
|
||||
<Button size="xs" variant="subtle" component="a" href={WORKSHOP_WEB_URL} target="_blank">
|
||||
{t("action.website")}
|
||||
</Button>
|
||||
</Group>
|
||||
</Stack>
|
||||
</Alert>
|
||||
)}
|
||||
{query.isLoading ? (
|
||||
@@ -173,14 +224,14 @@ export function WorkshopBrowser({ initialType = "all", lockedType, onUse, useLab
|
||||
<Text size="xs" c="dimmed">
|
||||
{t("by", { name: item.authorName, revision: item.revision })}
|
||||
</Text>
|
||||
<Button variant="light" onClick={() => void openDetail(item.id)}>
|
||||
<Button variant="light" onClick={() => setSelectedId(item.id)}>
|
||||
{t("action.inspect")}
|
||||
</Button>
|
||||
</Stack>
|
||||
</Card>
|
||||
))}
|
||||
</SimpleGrid>
|
||||
) : (
|
||||
) : query.isError || query.fetchStatus === "paused" ? null : (
|
||||
<Stack align="center" py="xl">
|
||||
<Text fw={600}>{t("empty.title")}</Text>
|
||||
<Text c="dimmed">{t("empty.description")}</Text>
|
||||
@@ -190,13 +241,25 @@ export function WorkshopBrowser({ initialType = "all", lockedType, onUse, useLab
|
||||
<Pagination total={query.data?.totalPages ?? 1} value={page} onChange={setPage} mx="auto" />
|
||||
)}
|
||||
<Modal
|
||||
opened={Boolean(selected) || detailLoading}
|
||||
onClose={() => setSelected(null)}
|
||||
opened={selectedId !== null}
|
||||
onClose={() => {
|
||||
setSelectedId(null);
|
||||
useMutationState.reset();
|
||||
}}
|
||||
title={selected?.title ?? t("loading")}
|
||||
size="xl"
|
||||
>
|
||||
{detailLoading || !selected ? (
|
||||
{detailQuery.isPending ? (
|
||||
<Skeleton h={400} />
|
||||
) : detailQuery.isError || !selected ? (
|
||||
<Alert icon={<IconWifiOff size={18} />} color="red" title={t("error.detailTitle")}>
|
||||
<Stack gap="sm">
|
||||
<Text size="sm">{t("error.detailDescription")}</Text>
|
||||
<Button size="xs" variant="light" onClick={() => void detailQuery.refetch()}>
|
||||
{t("action.retry")}
|
||||
</Button>
|
||||
</Stack>
|
||||
</Alert>
|
||||
) : (
|
||||
<Stack>
|
||||
<Group>
|
||||
@@ -229,26 +292,20 @@ export function WorkshopBrowser({ initialType = "all", lockedType, onUse, useLab
|
||||
</Group>
|
||||
{onUse && (
|
||||
<Button
|
||||
loading={usePending}
|
||||
onClick={async () => {
|
||||
setUsePending(true);
|
||||
setUseError("");
|
||||
try {
|
||||
if (lockedType && selected.type !== lockedType) throw new Error(t("error.wrongType"));
|
||||
await onUse(selected);
|
||||
setSelected(null);
|
||||
} catch (caught) {
|
||||
setUseError(caught instanceof Error ? caught.message : t("error.install"));
|
||||
} finally {
|
||||
setUsePending(false);
|
||||
}
|
||||
}}
|
||||
loading={useMutationState.isPending}
|
||||
disabled={!online}
|
||||
onClick={() => useMutationState.mutate(selected)}
|
||||
>
|
||||
{useLabel ?? t("action.use")}
|
||||
</Button>
|
||||
)}
|
||||
</Group>
|
||||
{useError && <Alert color="red">{useError}</Alert>}
|
||||
{!online && onUse && <Alert color="yellow">{t("offline.actionUnavailable")}</Alert>}
|
||||
{useMutationState.error && (
|
||||
<Alert color="red">
|
||||
{useMutationState.error instanceof Error ? useMutationState.error.message : t("error.install")}
|
||||
</Alert>
|
||||
)}
|
||||
</Stack>
|
||||
)}
|
||||
</Modal>
|
||||
@@ -275,22 +332,15 @@ export function PublishToWorkshopButton({
|
||||
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 {
|
||||
const publishMutation = useMutation({
|
||||
mutationKey: ["workshop", "publish", type],
|
||||
mutationFn: async () => {
|
||||
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 client.create({ type, title, description, changelog, content: await getContent() });
|
||||
},
|
||||
onSuccess: close,
|
||||
});
|
||||
return (
|
||||
<>
|
||||
{renderTrigger ? (
|
||||
@@ -305,7 +355,14 @@ export function PublishToWorkshopButton({
|
||||
{t("action.share")}
|
||||
</Button>
|
||||
)}
|
||||
<Modal opened={opened} onClose={close} title={t("publish.title")}>
|
||||
<Modal
|
||||
opened={opened}
|
||||
onClose={() => {
|
||||
close();
|
||||
publishMutation.reset();
|
||||
}}
|
||||
title={t("publish.title")}
|
||||
>
|
||||
<Stack>
|
||||
<TextInput
|
||||
required
|
||||
@@ -323,12 +380,20 @@ export function PublishToWorkshopButton({
|
||||
value={changelog}
|
||||
onChange={(event) => setChangelog(event.currentTarget.value)}
|
||||
/>
|
||||
{error && <Alert color="red">{error}</Alert>}
|
||||
{publishMutation.error && (
|
||||
<Alert color="red">
|
||||
{publishMutation.error instanceof Error ? publishMutation.error.message : t("error.publish")}
|
||||
</Alert>
|
||||
)}
|
||||
<Group justify="end">
|
||||
<Button variant="default" onClick={close}>
|
||||
{t("action.cancel")}
|
||||
</Button>
|
||||
<Button loading={pending} disabled={title.trim().length < 3} onClick={() => void publish()}>
|
||||
<Button
|
||||
loading={publishMutation.isPending}
|
||||
disabled={title.trim().length < 3}
|
||||
onClick={() => publishMutation.mutate()}
|
||||
>
|
||||
{t("action.publish")}
|
||||
</Button>
|
||||
</Group>
|
||||
|
||||
@@ -0,0 +1,51 @@
|
||||
"use client";
|
||||
|
||||
import type { PropsWithChildren } from "react";
|
||||
import { useState } from "react";
|
||||
import { createAsyncStoragePersister } from "@tanstack/query-async-storage-persister";
|
||||
import { QueryClient } from "@tanstack/react-query";
|
||||
import { PersistQueryClientProvider } from "@tanstack/react-query-persist-client";
|
||||
|
||||
const workshopCacheMaxAge = 1000 * 60 * 60 * 24;
|
||||
|
||||
export function WorkshopQueryProvider({ children }: PropsWithChildren) {
|
||||
const [queryClient] = useState(
|
||||
() =>
|
||||
new QueryClient({
|
||||
defaultOptions: {
|
||||
queries: {
|
||||
gcTime: workshopCacheMaxAge,
|
||||
networkMode: "offlineFirst",
|
||||
refetchOnReconnect: true,
|
||||
retry: 1,
|
||||
staleTime: 1000 * 60 * 5,
|
||||
},
|
||||
mutations: {
|
||||
networkMode: "online",
|
||||
retry: false,
|
||||
},
|
||||
},
|
||||
}),
|
||||
);
|
||||
const [persister] = useState(() =>
|
||||
createAsyncStoragePersister({
|
||||
key: "homarr-workshop-cache-v1",
|
||||
storage: typeof window === "undefined" ? undefined : window.localStorage,
|
||||
throttleTime: 1000,
|
||||
}),
|
||||
);
|
||||
|
||||
return (
|
||||
<PersistQueryClientProvider
|
||||
client={queryClient}
|
||||
persistOptions={{
|
||||
persister,
|
||||
maxAge: workshopCacheMaxAge,
|
||||
buster: "workshop-v1",
|
||||
dehydrateOptions: { shouldDehydrateQuery: (query) => query.state.status === "success" },
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
</PersistQueryClientProvider>
|
||||
);
|
||||
}
|
||||
@@ -19,13 +19,13 @@ RUN case "${TARGETARCH}" in \
|
||||
|
||||
FROM alpine:3.22
|
||||
|
||||
RUN apk add --no-cache ca-certificates su-exec tzdata && addgroup -S pocketbase && adduser -S -G pocketbase pocketbase
|
||||
RUN apk add --no-cache ca-certificates 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
|
||||
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"]
|
||||
@@ -5,17 +5,17 @@ PocketBase provides the central Workshop API at `workshop.homarr.dev`. It is not
|
||||
## Local startup
|
||||
|
||||
1. Copy the repository-root `.env.example` to `.env` and create a GitHub OAuth app.
|
||||
2. Set its callback URL to `http://localhost:8090/api/oauth2-redirect`.
|
||||
2. Set its callback URL to `http://localhost:<PB_EXPOSE_PORT>/api/oauth2-redirect` (port `8090` by default).
|
||||
3. Set `GITHUB_CLIENT_ID`, `GITHUB_CLIENT_SECRET`, `PB_SUPERUSER_EMAIL`, and `PB_SUPERUSER_PASSWORD`.
|
||||
4. Run `pnpm dev:workshop` from the repository root. It starts PocketBase and the Docusaurus Workshop together and removes obsolete Compose containers.
|
||||
|
||||
Open the Workshop at `http://localhost:3003/workshop`. The API is at `http://localhost:8090`; the emergency PocketBase dashboard is at `http://localhost:8090/_/`. `0.0.0.0` in PocketBase's server log is its container bind address, not a browser URL. Day-to-day moderation belongs in `/workshop/admin`, not the PocketBase dashboard.
|
||||
Open the Workshop at `http://localhost:3003/workshop`. The API is at `http://localhost:<PB_EXPOSE_PORT>` and the emergency PocketBase dashboard is at `http://localhost:<PB_EXPOSE_PORT>/_/` (port `8090` by default). `0.0.0.0` in PocketBase's server log is its container bind address, not a browser URL. Day-to-day moderation belongs in `/workshop/admin`, not the PocketBase dashboard.
|
||||
|
||||
Use `pnpm dev:workshop:backend` when only the API is needed. All Workshop configuration is read from the repository-root `.env`; there is no app-specific environment file. If port 8090 is unavailable, set `PB_EXPOSE_PORT` there. The website derives its local API URL from that port unless `WORKSHOP_API_URL` is explicitly set.
|
||||
|
||||
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.
|
||||
|
||||
GitHub OAuth configuration is synchronized from the environment on every boot, including for an existing development volume. Local CORS is restricted to the Docusaurus origins in `PB_ALLOWED_ORIGINS`; production must set it to `https://homarr.dev`.
|
||||
GitHub OAuth configuration is synchronized from the environment on every boot, including for an existing development volume. Local CORS is restricted to the documented Homarr and Docusaurus origins in `PB_ALLOWED_ORIGINS`; production must set it to `https://homarr.dev`.
|
||||
|
||||
## Roles and account states
|
||||
|
||||
|
||||
@@ -14,7 +14,7 @@ services:
|
||||
PB_SUPERUSER_EMAIL: ${PB_SUPERUSER_EMAIL:-}
|
||||
PB_SUPERUSER_PASSWORD: ${PB_SUPERUSER_PASSWORD:-}
|
||||
PB_PUBLIC_URL: http://localhost:${PB_EXPOSE_PORT:-8090}
|
||||
PB_ALLOWED_ORIGINS: ${PB_ALLOWED_ORIGINS:-http://localhost:3003,http://127.0.0.1:3003}
|
||||
PB_ALLOWED_ORIGINS: ${PB_ALLOWED_ORIGINS:-http://localhost:3000,http://127.0.0.1:3000,http://localhost:3003,http://127.0.0.1:3003,http://localhost:7575,http://127.0.0.1:7575}
|
||||
ports:
|
||||
- "127.0.0.1:${PB_EXPOSE_PORT:-8090}:8090"
|
||||
volumes:
|
||||
|
||||
@@ -1,18 +1,16 @@
|
||||
#!/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
|
||||
pocketbase superuser upsert "${PB_SUPERUSER_EMAIL}" "${PB_SUPERUSER_PASSWORD}" --dir=/pb_data
|
||||
fi
|
||||
|
||||
echo "Workshop API: ${PB_PUBLIC_URL:-http://localhost:8090}"
|
||||
echo "Workshop website: http://localhost:3003/workshop"
|
||||
|
||||
exec su-exec pocketbase pocketbase serve \
|
||||
exec pocketbase serve \
|
||||
--http=0.0.0.0:8090 \
|
||||
--origins="${PB_ALLOWED_ORIGINS:-*}" \
|
||||
--origins="${PB_ALLOWED_ORIGINS:-http://localhost:3000,http://127.0.0.1:3000,http://localhost:3003,http://127.0.0.1:3003,http://localhost:7575,http://127.0.0.1:7575}" \
|
||||
--dir=/pb_data \
|
||||
--hooksDir=/pb_hooks \
|
||||
--migrationsDir=/pb_migrations
|
||||
@@ -69,7 +69,12 @@ function validateSubmission(record) {
|
||||
}
|
||||
|
||||
function snapshot(record) {
|
||||
return JSON.stringify(record.publicExport());
|
||||
const exported = record.publicExport();
|
||||
if (typeof exported.content === "string") {
|
||||
exported.contentLength = exported.content.length;
|
||||
delete exported.content;
|
||||
}
|
||||
return JSON.stringify(exported);
|
||||
}
|
||||
|
||||
function requireStaff(app, auth) {
|
||||
|
||||
@@ -9,13 +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'";
|
||||
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(
|
||||
@@ -216,14 +216,22 @@ migrate(
|
||||
app.save(listings);
|
||||
|
||||
const settings = app.settings();
|
||||
settings.rateLimits.enabled = true;
|
||||
settings.rateLimits.rules = [
|
||||
{ label: "*:auth", audience: "", duration: 60, maxRequests: 10 },
|
||||
const workshopRateLimitLabels = [
|
||||
"submissions:create",
|
||||
"votes:create",
|
||||
"reports:create",
|
||||
"/api/workshop/moderation/",
|
||||
];
|
||||
const workshopRateLimits = [
|
||||
{ label: "submissions:create", audience: "@auth", duration: 60, maxRequests: 10 },
|
||||
{ label: "votes:create", audience: "@auth", duration: 10, maxRequests: 20 },
|
||||
{ label: "reports:create", audience: "@auth", duration: 60, maxRequests: 5 },
|
||||
{ label: "/api/workshop/moderation/", audience: "@auth", duration: 60, maxRequests: 30 },
|
||||
{ label: "/api/", audience: "", duration: 10, maxRequests: 300 },
|
||||
];
|
||||
settings.rateLimits.enabled = true;
|
||||
settings.rateLimits.rules = [
|
||||
...settings.rateLimits.rules.filter((rule) => !workshopRateLimitLabels.includes(rule.label)),
|
||||
...workshopRateLimits,
|
||||
];
|
||||
settings.logs.maxDays = 30;
|
||||
settings.logs.logAuthId = true;
|
||||
@@ -241,8 +249,15 @@ migrate(
|
||||
}
|
||||
}
|
||||
const settings = app.settings();
|
||||
settings.rateLimits.enabled = false;
|
||||
settings.rateLimits.rules = [];
|
||||
const workshopRateLimitLabels = [
|
||||
"submissions:create",
|
||||
"votes:create",
|
||||
"reports:create",
|
||||
"/api/workshop/moderation/",
|
||||
];
|
||||
settings.rateLimits.rules = settings.rateLimits.rules.filter(
|
||||
(rule) => !workshopRateLimitLabels.includes(rule.label),
|
||||
);
|
||||
app.save(settings);
|
||||
},
|
||||
);
|
||||
@@ -4,7 +4,7 @@ set -eu
|
||||
project="homarr-workshop-integration"
|
||||
port="${WORKSHOP_TEST_PORT:-18091}"
|
||||
email="integration@homarr.test"
|
||||
password="WorkshopIntegrationPassword123!"
|
||||
password="${WORKSHOP_TEST_PASSWORD:-WorkshopIntegrationPassword123!}"
|
||||
|
||||
cleanup() {
|
||||
PB_EXPOSE_PORT="$port" docker compose --env-file .env.example --file apps/workshop/docker-compose.yml --project-name "$project" down --volumes >/dev/null 2>&1 || true
|
||||
|
||||
@@ -11,6 +11,7 @@ const root = new PocketBase(baseUrl);
|
||||
await root.collection("_superusers").authWithPassword(superuserEmail, superuserPassword);
|
||||
const authMethods = await root.collection("users").listAuthMethods();
|
||||
assert.equal(authMethods.oauth2.enabled, true, "GitHub OAuth must be configured from the runtime environment");
|
||||
assert.equal(authMethods.password.enabled, false, "password authentication must remain disabled");
|
||||
assert.deepEqual(
|
||||
authMethods.oauth2.providers.map((provider) => provider.name),
|
||||
["github"],
|
||||
@@ -48,6 +49,8 @@ const disabled = await root.collection("users").impersonate(disabledRecord.id, 3
|
||||
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");
|
||||
|
||||
const forgedMember = await member.collection("users").update(memberRecord.id, {
|
||||
role: "admin",
|
||||
state: "posting_banned",
|
||||
@@ -63,11 +66,20 @@ await admin.send(`/api/workshop/moderation/users/${disabledRecord.id}/state`, {
|
||||
});
|
||||
|
||||
const submission = await member.collection("submissions").create({
|
||||
type: "css",
|
||||
title: "Integration theme",
|
||||
type: "widget",
|
||||
title: "Integration widget",
|
||||
description: "A backend integration fixture",
|
||||
schemaVersion: "forged-version",
|
||||
content: ":root { --homarr-test: #fff; }",
|
||||
content: JSON.stringify({
|
||||
$schema: "homarr-custom-widget-v2",
|
||||
name: "Integration widget",
|
||||
url: "https://example.com/status",
|
||||
authType: "none",
|
||||
method: "GET",
|
||||
displayType: "raw",
|
||||
displayConfig: { type: "raw" },
|
||||
integrationTestPadding: "x".repeat(150_000),
|
||||
}),
|
||||
author: secondMemberRecord.id,
|
||||
authorName: "Forged",
|
||||
revision: 99,
|
||||
@@ -75,7 +87,7 @@ const submission = await member.collection("submissions").create({
|
||||
assert.equal(submission.author, memberRecord.id, "server must enforce ownership");
|
||||
assert.equal(submission.authorName, "Member", "server must snapshot the authenticated author name");
|
||||
assert.equal(submission.revision, 1, "server must own the initial revision");
|
||||
assert.equal(submission.schemaVersion, "homarr-custom-css-v1", "server must own schemaVersion");
|
||||
assert.equal(submission.schemaVersion, "homarr-custom-widget-v2", "server must own schemaVersion");
|
||||
|
||||
await expectStatus(
|
||||
() => banned.collection("submissions").create({ type: "css", title: "Blocked theme", content: "body {}" }),
|
||||
@@ -161,7 +173,9 @@ assert.deepEqual(
|
||||
actions.map((action) => action.action),
|
||||
["set_account_state", "set_role", "resolve_report", "remove_submission"],
|
||||
);
|
||||
assert(actions.at(-1)?.snapshot.includes("Integration theme"), "removal audit must retain a target snapshot");
|
||||
assert(actions.at(-1)?.snapshot.includes("Integration widget"), "removal audit must retain a target snapshot");
|
||||
assert(!actions.at(-1)?.snapshot.includes("integrationTestPadding"), "removal audit must omit large source content");
|
||||
assert(actions.at(-1)?.snapshot.includes('"contentLength"'), "removal audit must retain the source length");
|
||||
|
||||
const anonymous = new PocketBase(baseUrl);
|
||||
const listings = await anonymous.collection("workshop_listings").getList();
|
||||
|
||||
@@ -67,12 +67,12 @@ The baseline has three P0, seven P1, and one P2 findings. The rewrite closes eve
|
||||
| Branch ancestry | Pass | Rebuilt from `dev` at `ba7718bde`; prototype preserved at `origin/feat/widget-store-prototype-backup`. |
|
||||
| Contract | Pass | Five Vitest cases cover widget/CSS fixtures, filenames, limits, screenshot validation, and round trips. |
|
||||
| Backend | Pass | Disposable PocketBase integration matrix covers roles, account states, ownership forgery, schema rejection, duplicate votes, private reports, escalation, deletion cascades, and audit snapshots. |
|
||||
| Backend operations | Pass | PocketBase 0.39.6 image builds with checksum verification; clean migration, health, and listing/detail access boundaries pass. |
|
||||
| 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 and 390px mobile discovery render without console errors or horizontal overflow; semantic labels, skip link, dark mode, and reduced motion are active. |
|
||||
| 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.
|
||||
@@ -497,6 +497,9 @@ export const integrationDefs = {
|
||||
|
||||
export const integrationKinds = objectKeys(integrationDefs) as AtLeastOneOf<IntegrationKind>;
|
||||
|
||||
export const isIntegrationKind = (integration: string): integration is IntegrationKind =>
|
||||
Object.hasOwn(integrationDefs, integration);
|
||||
|
||||
export const getIconUrl = (integration: IntegrationKind) => integrationDefs[integration].iconUrl;
|
||||
|
||||
export const getIntegrationName = (integration: IntegrationKind) => integrationDefs[integration].name;
|
||||
|
||||
@@ -896,6 +896,10 @@
|
||||
"search": "Search integrations",
|
||||
"noResults": {
|
||||
"title": "There are no integrations yet"
|
||||
},
|
||||
"outdated": {
|
||||
"title": "Some integrations need a database update",
|
||||
"description": "{count} outdated integrations were hidden to keep this page available. Restart Homarr to apply pending data migrations."
|
||||
}
|
||||
},
|
||||
"create": {
|
||||
@@ -4484,8 +4488,18 @@
|
||||
"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"
|
||||
@@ -5716,6 +5730,9 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"workshop": {
|
||||
"label": "Workshop"
|
||||
},
|
||||
"users": {
|
||||
"label": "Users",
|
||||
"create": {
|
||||
|
||||
@@ -37,6 +37,7 @@ export interface WorkshopListOptions {
|
||||
sort?: "top" | "newest";
|
||||
search?: string;
|
||||
author?: string;
|
||||
signal?: AbortSignal;
|
||||
}
|
||||
|
||||
export interface WorkshopPage<T> {
|
||||
@@ -71,6 +72,9 @@ const asWorkshopError = (error: unknown, fallback: string) => {
|
||||
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);
|
||||
}
|
||||
return new WorkshopError("unknown", error instanceof Error ? error.message : fallback);
|
||||
};
|
||||
|
||||
@@ -146,6 +150,7 @@ export class WorkshopClient {
|
||||
.getList(options.page ?? 1, options.perPage ?? 24, {
|
||||
filter: filters.join(" && "),
|
||||
sort: options.sort === "newest" ? "-created" : "-score,-created",
|
||||
signal: options.signal,
|
||||
});
|
||||
return { ...result, items: result.items.map((item) => workshopSubmissionSummarySchema.parse(item)) };
|
||||
} catch (error) {
|
||||
@@ -153,9 +158,11 @@ export class WorkshopClient {
|
||||
}
|
||||
}
|
||||
|
||||
async get(id: string): Promise<WorkshopSubmissionDetail> {
|
||||
async get(id: string, signal?: AbortSignal): Promise<WorkshopSubmissionDetail> {
|
||||
try {
|
||||
return workshopSubmissionDetailSchema.parse(await this.pocketBase.collection("submissions").getOne(id));
|
||||
return workshopSubmissionDetailSchema.parse(
|
||||
await this.pocketBase.collection("submissions").getOne(id, { signal }),
|
||||
);
|
||||
} catch (error) {
|
||||
throw asWorkshopError(error, "Submission not found");
|
||||
}
|
||||
|
||||
Generated
+9
@@ -718,6 +718,15 @@ importers:
|
||||
'@tailwindcss/postcss':
|
||||
specifier: ^4.3.2
|
||||
version: 4.3.2
|
||||
'@tanstack/query-async-storage-persister':
|
||||
specifier: 'catalog:'
|
||||
version: 5.101.2
|
||||
'@tanstack/react-query':
|
||||
specifier: 'catalog:'
|
||||
version: 5.101.2(react@19.2.7)
|
||||
'@tanstack/react-query-persist-client':
|
||||
specifier: 'catalog:'
|
||||
version: 5.101.2(@tanstack/react-query@5.101.2(react@19.2.7))(react@19.2.7)
|
||||
clsx:
|
||||
specifier: 'catalog:'
|
||||
version: 2.1.1
|
||||
|
||||
+20
-11
@@ -5,17 +5,21 @@ mkdir -p /appdata/db
|
||||
mkdir -p /appdata/redis
|
||||
mkdir -p /appdata/trusted-certificates
|
||||
|
||||
# Run migrations
|
||||
if [ "$DB_MIGRATIONS_DISABLED" = "true" ]; then
|
||||
echo "DB migrations are disabled, skipping"
|
||||
else
|
||||
echo "Running DB migrations"
|
||||
DISABLE_REDIS_LOGS=true node ./db/migrations/$DB_DIALECT/migrate.cjs ./db/migrations/$DB_DIALECT
|
||||
if [ $? -ne 0 ]; then
|
||||
echo "ERROR: DB migrations failed, aborting startup"
|
||||
exit 1
|
||||
fi
|
||||
fi
|
||||
run_migrations() {
|
||||
if [ "${DB_MIGRATIONS_DISABLED:-false}" = "true" ]; then
|
||||
echo "DB migrations are disabled, skipping"
|
||||
return
|
||||
fi
|
||||
|
||||
echo "Running DB migrations"
|
||||
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
|
||||
}
|
||||
|
||||
run_migrations
|
||||
|
||||
# Auth secret is generated every time the container starts as it is required, but not used because we don't need JWTs or Mail hashing
|
||||
export AUTH_SECRET=$(openssl rand -base64 32)
|
||||
@@ -69,6 +73,11 @@ while true; do
|
||||
break
|
||||
fi
|
||||
|
||||
if [ "$EXIT_CODE" -eq 101 ]; then
|
||||
echo "Database was restored, applying migrations before restart"
|
||||
run_migrations
|
||||
fi
|
||||
|
||||
echo "Next.js exited with code $EXIT_CODE, restarting in 1s..."
|
||||
sleep 1
|
||||
node apps/nextjs/server.js &
|
||||
|
||||
Reference in new issue
Block a user