feat: unify Workshop experience

This commit is contained in:
Thomas Camlong committed 2026-07-14 01:21:53 +02:00
1 parent 066090d99e
commit 9f979f683a
27 files changed
+1184 -483

No files matched your search

+3 -1
View File
@@ -21,6 +21,8 @@ In Homarr, open **Management → Workshop** to browse, search, filter, inspect,
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.
@@ -39,4 +41,4 @@ Signed-in members can vote to help useful creations rank higher. Report maliciou
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 `workshop.homarr.dev`. Running the backend locally is supported for contributors developing or testing this feature, not as a private production marketplace.
The Workshop is a central service operated by Homarr at `homarr.dev`. The documentation website and Workshop API ship in one image and use the same origin. Running it locally is supported for contributors developing or testing this feature, not as a private production marketplace.
+4 -4
View File
@@ -3,10 +3,9 @@ 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";
// 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",
@@ -310,6 +309,7 @@ const config: Config = {
],
"docusaurus-plugin-image-zoom",
require.resolve("./plugins/validate-docs-coverage"),
require.resolve("./plugins/offline-manifest.mjs"),
function disableExpensiveBundlerOptimizationPlugin() {
return {
name: "disable-expensive-bundler-optimizations",
+26
View File
@@ -0,0 +1,26 @@
import { readdir, writeFile } from "node:fs/promises";
import path from "node:path";
const collectFiles = async (directory, root = directory) => {
const entries = await readdir(directory, { withFileTypes: true });
const files = await Promise.all(
entries.map((entry) => {
const absolutePath = path.join(directory, entry.name);
return entry.isDirectory() ? collectFiles(absolutePath, root) : path.relative(root, absolutePath);
}),
);
return files.flat();
};
export default function offlineManifestPlugin() {
return {
name: "workshop-offline-manifest",
async postBuild({ outDir }) {
const assetsDirectory = path.join(outDir, "assets");
const assets = (await collectFiles(assetsDirectory))
.filter((file) => /\.(?:css|js|woff2?)$/.test(file))
.map((file) => `/assets/${file.split(path.sep).join("/")}`);
await writeFile(path.join(outDir, "workshop-assets.json"), JSON.stringify([...assets, "/img/logo.png"]));
},
};
}
@@ -1,16 +1,11 @@
import React, { useEffect, useMemo, useState } from "react";
import useDocusaurusContext from "@docusaurus/useDocusaurusContext";
import { useQuery } from "@tanstack/react-query";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import {
WORKSHOP_API_URL,
WorkshopClient,
type WorkshopAccountState,
type WorkshopRole,
type WorkshopUser,
} from "@homarr/workshop";
import { WorkshopClient, type WorkshopAccountState, type WorkshopRole, type WorkshopUser } from "@homarr/workshop";
import { WorkshopQueryProvider } from "./WorkshopQueryProvider";
import { WorkshopDialog, WorkshopServiceState } from "./WorkshopUi";
import styles from "./workshop.module.css";
type PendingAction =
@@ -29,9 +24,10 @@ export function WorkshopAdmin() {
function WorkshopAdminContent() {
const { siteConfig } = useDocusaurusContext();
const client = useMemo(
() => new WorkshopClient((siteConfig.customFields?.workshopApiUrl as string | undefined) ?? WORKSHOP_API_URL),
() => new WorkshopClient((siteConfig.customFields?.workshopApiUrl as string | undefined) || window.location.origin),
[siteConfig],
);
const queryClient = useQueryClient();
const [user, setUser] = useState<WorkshopUser | null>(null);
const [pending, setPending] = useState<PendingAction | null>(null);
const [error, setError] = useState("");
@@ -53,6 +49,24 @@ function WorkshopAdminContent() {
const reports = moderationQuery.data?.reports ?? [];
const users = moderationQuery.data?.users ?? [];
const actions = moderationQuery.data?.actions ?? [];
const actionMutation = useMutation({
mutationKey: ["workshop", "moderation", "action"],
mutationFn: async ({ action, reason }: { action: PendingAction; reason: string }) => {
if (action.kind === "report") await client.resolveReport(action.id, action.status, reason);
else if (action.kind === "state") await client.updateAccountState(action.id, action.state, reason);
else await client.updateRole(action.id, action.role, reason);
},
onSuccess: async () => {
setPending(null);
await queryClient.invalidateQueries({ queryKey: ["workshop", "moderation"] });
},
});
const signInMutation = useMutation({
mutationKey: ["workshop", "auth", "moderator"],
mutationFn: () => client.signInWithGitHub(),
onSuccess: setUser,
onError: (caught) => setError(caught instanceof Error ? caught.message : "GitHub sign-in is unavailable"),
});
useEffect(() => client.subscribeToAuth(setUser), [client]);
useEffect(() => {
@@ -83,16 +97,13 @@ function WorkshopAdminContent() {
)}
<button
className="button button--primary"
onClick={async () => {
disabled={signInMutation.isPending}
onClick={() => {
setError("");
try {
setUser(await client.signInWithGitHub());
} catch (caught) {
setError(caught instanceof Error ? caught.message : "GitHub sign-in is unavailable");
}
signInMutation.mutate();
}}
>
Sign in with GitHub
{signInMutation.isPending ? "Connecting…" : "Sign in with GitHub"}
</button>
</main>
);
@@ -115,10 +126,11 @@ function WorkshopAdminContent() {
</div>
</header>
{(error || moderationQuery.isError) && (
<div className={styles.error} role="alert">
{error || "Moderation data is unavailable. Try again when Workshop reconnects."}{" "}
{moderationQuery.isError && <button onClick={() => void moderationQuery.refetch()}>Try again</button>}
</div>
<WorkshopServiceState
cached={Boolean(moderationQuery.data)}
message={error || "Moderation data is unavailable. No action can be applied until Workshop reconnects."}
onRetry={() => void moderationQuery.refetch()}
/>
)}
<section className={styles.adminSection}>
<h2>Open reports</h2>
@@ -258,13 +270,7 @@ function WorkshopAdminContent() {
<ReasonDialog
action={pending}
onClose={() => setPending(null)}
onConfirm={async (reason) => {
if (pending.kind === "report") await client.resolveReport(pending.id, pending.status, reason);
else if (pending.kind === "state") await client.updateAccountState(pending.id, pending.state, reason);
else await client.updateRole(pending.id, pending.role, reason);
setPending(null);
await moderationQuery.refetch();
}}
onConfirm={(reason) => actionMutation.mutateAsync({ action: pending, reason })}
/>
)}
</main>
@@ -293,45 +299,43 @@ function ReasonDialog({
const [error, setError] = useState("");
const [saving, setSaving] = useState(false);
return (
<div className={styles.overlay}>
<dialog className={styles.dialog} open aria-labelledby="moderation-reason-title">
<h2 id="moderation-reason-title">Confirm moderation action</h2>
<p>
This will apply <strong>{action.kind}</strong> to the selected record.
</p>
<form
onSubmit={async (event) => {
event.preventDefault();
setSaving(true);
try {
await onConfirm(reason);
} catch (caught) {
setError(caught instanceof Error ? caught.message : "Action failed");
setSaving(false);
}
}}
>
<label>
Reason
<textarea
required
minLength={3}
maxLength={1000}
value={reason}
onChange={(event) => setReason(event.target.value)}
/>
</label>
{error && <p className={styles.error}>{error}</p>}
<div className={styles.formActions}>
<button type="button" className="button button--secondary" onClick={onClose}>
Cancel
</button>
<button disabled={saving} className="button button--danger">
{saving ? "Applying…" : "Apply action"}
</button>
</div>
</form>
</dialog>
</div>
<WorkshopDialog titleId="moderation-reason-title" onClose={onClose}>
<h2 id="moderation-reason-title">Confirm moderation action</h2>
<p>
This will apply <strong>{action.kind}</strong> to the selected record.
</p>
<form
onSubmit={async (event) => {
event.preventDefault();
setSaving(true);
try {
await onConfirm(reason);
} catch (caught) {
setError(caught instanceof Error ? caught.message : "Action failed");
setSaving(false);
}
}}
>
<label>
Reason
<textarea
required
minLength={3}
maxLength={1000}
value={reason}
onChange={(event) => setReason(event.target.value)}
/>
</label>
{error && <p className={styles.error}>{error}</p>}
<div className={styles.formActions}>
<button type="button" className="button button--secondary" onClick={onClose}>
Cancel
</button>
<button disabled={saving} className="button button--danger">
{saving ? "Applying…" : "Apply action"}
</button>
</div>
</form>
</WorkshopDialog>
);
}
+236 -219
View File
@@ -8,14 +8,16 @@ 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,
@@ -27,11 +29,12 @@ import {
import { keepPreviousData, useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { WorkshopQueryProvider } from "./WorkshopQueryProvider";
import { WorkshopDialog, WorkshopServiceState } from "./WorkshopUi";
import styles from "./workshop.module.css";
const useWorkshop = () => {
const { siteConfig } = useDocusaurusContext();
const apiUrl = (siteConfig.customFields?.workshopApiUrl as string | undefined) ?? WORKSHOP_API_URL;
const apiUrl = (siteConfig.customFields?.workshopApiUrl as string | undefined) || window.location.origin;
return useMemo(() => new WorkshopClient(apiUrl), [apiUrl]);
};
@@ -66,6 +69,12 @@ function WorkshopAppContent() {
const [mine, setMine] = useState(false);
const [error, setError] = useState("");
const [showForm, setShowForm] = useState(false);
const signInMutation = useMutation({
mutationKey: ["workshop", "auth", "sign-in"],
mutationFn: () => client.signInWithGitHub(),
onSuccess: async () => queryClient.invalidateQueries({ queryKey: ["workshop"] }),
onError: (caught) => setError(caught instanceof Error ? caught.message : "Sign-in failed"),
});
useEffect(() => client.subscribeToAuth(setUser), [client]);
useEffect(() => {
@@ -92,21 +101,24 @@ function WorkshopAppContent() {
const items = listQuery.data?.items ?? [];
const totalPages = listQuery.data?.totalPages ?? 1;
const signIn = async () => {
try {
await client.signInWithGitHub();
await queryClient.invalidateQueries({ queryKey: ["workshop"] });
} 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>Community widgets and CSS themes, ready to inspect and use in Homarr.</p>
<p>Discover useful creations, inspect every line, then bring the ones you trust into Homarr.</p>
<ul className={styles.heroPromises} aria-label="Workshop principles">
<li>
<IconSparkles size={16} /> Community-made
</li>
<li>
<IconPackage size={16} /> Ready to export
</li>
<li>
<IconHeart size={16} /> Ranked by members
</li>
</ul>
</div>
<div className={styles.accountActions}>
{user ? (
@@ -124,8 +136,12 @@ function WorkshopAppContent() {
</button>
</>
) : (
<button className="button button--primary" onClick={() => void signIn()}>
<IconBrandGithub size={18} /> Sign in with GitHub
<button
className="button button--primary"
disabled={signInMutation.isPending}
onClick={() => signInMutation.mutate()}
>
<IconBrandGithub size={18} /> {signInMutation.isPending ? "Connecting…" : "Sign in with GitHub"}
</button>
)}
</div>
@@ -194,14 +210,11 @@ function WorkshopAppContent() {
</div>
{(error || listQuery.isError || listQuery.fetchStatus === "paused") && (
<div className={styles.error} role="alert">
<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>
<WorkshopServiceState
cached={items.length > 0}
message={error || undefined}
onRetry={() => void listQuery.refetch()}
/>
)}
{listQuery.isPending ? (
<WorkshopSkeleton />
@@ -213,8 +226,20 @@ function WorkshopAppContent() {
</div>
) : listQuery.isError || listQuery.fetchStatus === "paused" ? null : (
<section className={styles.empty}>
<IconSearch size={32} aria-hidden />
<h2>No creations found</h2>
<p>Adjust the filters, or share the first creation in this category.</p>
<p>Try a broader search or clear the filters to explore everything the community has shared.</p>
<button
className="button button--secondary button--sm"
onClick={() => {
setSearch("");
setType("all");
setSort("top");
setPage(1);
}}
>
Clear filters
</button>
</section>
)}
@@ -265,7 +290,9 @@ function WorkshopCard({ client, item }: { client: WorkshopClient; item: Workshop
</span>
</div>
<h2>
<Link to={`/workshop/${item.id}`}>{item.title}</Link>
<Link className={styles.cardLink} to={`/workshop/${item.id}`}>
{item.title}
</Link>
</h2>
<p>{item.description || "No description provided."}</p>
<small>
@@ -463,51 +490,49 @@ function ModerationRemovalForm({
onRemoved: () => void;
}) {
const [reason, setReason] = useState("");
const [error, setError] = useState("");
const [pending, setPending] = useState(false);
const removeMutation = useMutation({
mutationKey: ["workshop", "moderation", "remove", submission.id],
mutationFn: () => client.removeSubmission(submission.id, reason),
onSuccess: onRemoved,
});
return (
<div className={styles.overlay}>
<dialog className={styles.dialog} open aria-labelledby="remove-submission-title">
<h2 id="remove-submission-title">Remove {submission.title}</h2>
<p>
This permanently deletes the submission, files, votes, and reports. A metadata snapshot is retained in
moderation history.
</p>
<form
onSubmit={async (event) => {
event.preventDefault();
setPending(true);
try {
await client.removeSubmission(submission.id, reason);
onRemoved();
} catch (caught) {
setError(caught instanceof Error ? caught.message : "Removal failed");
setPending(false);
}
}}
>
<label>
Reason
<textarea
required
minLength={3}
maxLength={1000}
value={reason}
onChange={(event) => setReason(event.target.value)}
/>
</label>
{error && <p className={styles.error}>{error}</p>}
<div className={styles.formActions}>
<button type="button" className="button button--secondary" onClick={onClose}>
Cancel
</button>
<button disabled={pending} className="button button--danger">
{pending ? "Removing…" : "Remove permanently"}
</button>
</div>
</form>
</dialog>
</div>
<WorkshopDialog titleId="remove-submission-title" onClose={onClose}>
<h2 id="remove-submission-title">Remove {submission.title}</h2>
<p>
This permanently deletes the submission, files, votes, and reports. A metadata snapshot is retained in
moderation history.
</p>
<form
onSubmit={(event) => {
event.preventDefault();
removeMutation.mutate();
}}
>
<label>
Reason
<textarea
required
minLength={3}
maxLength={1000}
value={reason}
onChange={(event) => setReason(event.target.value)}
/>
</label>
{removeMutation.error && (
<p className={styles.error} role="alert">
{removeMutation.error instanceof Error ? removeMutation.error.message : "Removal failed"}
</p>
)}
<div className={styles.formActions}>
<button type="button" className="button button--secondary" onClick={onClose}>
Cancel
</button>
<button disabled={removeMutation.isPending} className="button button--danger">
{removeMutation.isPending ? "Removing…" : "Remove permanently"}
</button>
</div>
</form>
</WorkshopDialog>
);
}
@@ -529,125 +554,112 @@ function SubmissionForm({
const [changelog, setChangelog] = useState(existing?.changelog ?? "");
const [screenshots, setScreenshots] = useState<File[]>([]);
const [removedScreenshots, setRemovedScreenshots] = useState<string[]>([]);
const [error, setError] = useState("");
const [pending, setPending] = useState(false);
const 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);
setError("");
const input: WorkshopSubmissionInput = { type, title, description, content, changelog };
try {
const saveMutation = useMutation({
mutationKey: ["workshop", existing ? "update" : "create", existing?.id],
mutationFn: async (input: WorkshopSubmissionInput) => {
if (!client.currentUser) await client.signInWithGitHub();
if (existing) {
await client.update(existing.id, input, screenshots, removedScreenshots);
} else {
await client.create(input, screenshots);
return client.update(existing.id, input, screenshots, removedScreenshots);
}
await onSaved();
} catch (caught) {
setError(caught instanceof Error ? caught.message : "Could not save submission");
} finally {
setPending(false);
}
};
return client.create(input, screenshots);
},
onSuccess: onSaved,
});
return (
<div
className={styles.overlay}
role="presentation"
onMouseDown={(event) => {
if (event.target === event.currentTarget) onClose();
}}
>
<dialog className={styles.dialog} open aria-labelledby="submission-form-title">
<h2 id="submission-form-title">{existing ? "Update creation" : "Share with the community"}</h2>
<form onSubmit={(event) => void submit(event)}>
<label>
Type
<select
disabled={Boolean(existing)}
value={type}
onChange={(event) => setType(event.target.value as WorkshopSubmissionType)}
>
<option value="widget">Custom widget</option>
<option value="css">CSS theme</option>
</select>
</label>
<label>
Title
<WorkshopDialog titleId="submission-form-title" onClose={onClose}>
<h2 id="submission-form-title">{existing ? "Update creation" : "Share with the community"}</h2>
<form
onSubmit={(event) => {
event.preventDefault();
saveMutation.mutate({ type, title, description, content, changelog });
}}
>
<label>
Type
<select
disabled={Boolean(existing)}
value={type}
onChange={(event) => setType(event.target.value as WorkshopSubmissionType)}
>
<option value="widget">Custom widget</option>
<option value="css">CSS theme</option>
</select>
</label>
<label>
Title
<input
required
minLength={3}
maxLength={100}
value={title}
onChange={(event) => setTitle(event.target.value)}
/>
</label>
<label>
Description
<textarea maxLength={2000} value={description} onChange={(event) => setDescription(event.target.value)} />
</label>
<label>
{type === "widget" ? "Widget JSON" : "CSS source"}
<textarea
className={styles.editor}
required
maxLength={MAX_CONTENT_LENGTH}
value={content}
onChange={(event) => setContent(event.target.value)}
/>
</label>
<label>
Changelog
<textarea maxLength={2000} value={changelog} onChange={(event) => setChangelog(event.target.value)} />
</label>
<label>
Screenshots (PNG, JPG, or WebP, {remainingScreenshotSlots} available)
<input
type="file"
accept="image/png,image/jpeg,image/webp"
multiple
onChange={(event) =>
setScreenshots(Array.from(event.target.files ?? []).slice(0, remainingScreenshotSlots))
}
/>
</label>
{existing?.screenshots.map((screenshot) => (
<label className={styles.checkbox} key={screenshot}>
<input
required
minLength={3}
maxLength={100}
value={title}
onChange={(event) => setTitle(event.target.value)}
/>
</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
type="checkbox"
checked={removedScreenshots.includes(screenshot)}
onChange={(event) =>
setScreenshots(Array.from(event.target.files ?? []).slice(0, remainingScreenshotSlots))
setRemovedScreenshots((current) =>
event.target.checked
? [...current, screenshot]
: current.filter((filename) => filename !== screenshot),
)
}
/>
Remove existing screenshot {screenshot}
</label>
{existing?.screenshots.map((screenshot) => (
<label className={styles.checkbox} key={screenshot}>
<input
type="checkbox"
checked={removedScreenshots.includes(screenshot)}
onChange={(event) =>
setRemovedScreenshots((current) =>
event.target.checked
? [...current, screenshot]
: current.filter((filename) => filename !== screenshot),
)
}
/>
Remove existing screenshot {screenshot}
</label>
))}
{error && (
<p className={styles.error} role="alert">
{error}
</p>
)}
<div className={styles.formActions}>
<button type="button" className="button button--secondary" onClick={onClose}>
Cancel
</button>
<button disabled={pending} className="button button--primary">
{pending ? "Saving…" : existing ? "Save update" : "Publish creation"}
</button>
</div>
</form>
</dialog>
</div>
))}
{saveMutation.error && (
<p className={styles.error} role="alert">
{saveMutation.error instanceof Error ? saveMutation.error.message : "Could not save submission"}
</p>
)}
<div className={styles.formActions}>
<button type="button" className="button button--secondary" onClick={onClose}>
Cancel
</button>
<button disabled={saveMutation.isPending} className="button button--primary">
{saveMutation.isPending ? "Saving…" : existing ? "Save update" : "Publish creation"}
</button>
</div>
</form>
</WorkshopDialog>
);
}
@@ -662,53 +674,58 @@ function ReportForm({
}) {
const [category, setCategory] = useState<"malicious" | "spam" | "copyright" | "inappropriate" | "other">("malicious");
const [explanation, setExplanation] = useState("");
const [error, setError] = useState("");
const reportMutation = useMutation({
mutationKey: ["workshop", "report", submissionId],
mutationFn: async () => {
if (!client.currentUser) await client.signInWithGitHub();
return client.report(submissionId, category, explanation);
},
onSuccess: onClose,
});
return (
<div className={styles.overlay}>
<dialog className={styles.dialog} open aria-labelledby="report-title">
<h2 id="report-title">Report submission</h2>
<form
onSubmit={async (event) => {
event.preventDefault();
try {
if (!client.currentUser) await client.signInWithGitHub();
await client.report(submissionId, category, explanation);
onClose();
} catch (caught) {
setError(caught instanceof Error ? caught.message : "Could not send report");
}
}}
>
<label>
Reason
<select value={category} onChange={(event) => setCategory(event.target.value as typeof category)}>
<option value="malicious">Malicious</option>
<option value="spam">Spam</option>
<option value="copyright">Copyright</option>
<option value="inappropriate">Inappropriate</option>
<option value="other">Other</option>
</select>
</label>
<label>
Details
<textarea
required
minLength={3}
maxLength={1000}
value={explanation}
onChange={(event) => setExplanation(event.target.value)}
/>
</label>
{error && <p className={styles.error}>{error}</p>}
<div className={styles.formActions}>
<button type="button" className="button button--secondary" onClick={onClose}>
Cancel
</button>
<button className="button button--primary">Submit report</button>
</div>
</form>
</dialog>
</div>
<WorkshopDialog titleId="report-title" onClose={onClose}>
<h2 id="report-title">Report submission</h2>
<form
onSubmit={(event) => {
event.preventDefault();
reportMutation.mutate();
}}
>
<label>
Reason
<select value={category} onChange={(event) => setCategory(event.target.value as typeof category)}>
<option value="malicious">Malicious</option>
<option value="spam">Spam</option>
<option value="copyright">Copyright</option>
<option value="inappropriate">Inappropriate</option>
<option value="other">Other</option>
</select>
</label>
<label>
Details
<textarea
required
minLength={3}
maxLength={1000}
value={explanation}
onChange={(event) => setExplanation(event.target.value)}
/>
</label>
{reportMutation.error && (
<p className={styles.error} role="alert">
{reportMutation.error instanceof Error ? reportMutation.error.message : "Could not send report"}
</p>
)}
<div className={styles.formActions}>
<button type="button" className="button button--secondary" onClick={onClose}>
Cancel
</button>
<button disabled={reportMutation.isPending} className="button button--primary">
{reportMutation.isPending ? "Sending…" : "Submit report"}
</button>
</div>
</form>
</WorkshopDialog>
);
}
@@ -0,0 +1,69 @@
import React, { type PropsWithChildren, useEffect, useRef } from "react";
import Link from "@docusaurus/Link";
import { IconBooks, IconCloudOff, IconRefresh } from "@tabler/icons-react";
import styles from "./workshop.module.css";
export function WorkshopServiceState({
cached,
message,
onRetry,
}: {
cached: boolean;
message?: string;
onRetry: () => void;
}) {
return (
<section className={styles.serviceState} aria-live="polite">
<span className={styles.serviceStateIcon} aria-hidden>
<IconCloudOff size={24} />
</span>
<div>
<h2>{cached ? "Browsing saved creations" : "Workshop is taking a break"}</h2>
<p>
{message ??
(cached
? "The catalog saved on this device is still available. Live details and community actions will return when the service reconnects."
: "Community creations cannot be reached right now. Homarr and its documentation remain fully available.")}
</p>
<div className={styles.serviceStateActions}>
<button className="button button--primary button--sm" onClick={onRetry}>
<IconRefresh size={16} /> Check again
</button>
<Link className="button button--secondary button--sm" to="/docs/management/workshop">
<IconBooks size={16} /> Read the guide
</Link>
</div>
</div>
</section>
);
}
export function WorkshopDialog({
titleId,
onClose,
children,
}: PropsWithChildren<{ titleId: string; onClose: () => void }>) {
const ref = useRef<HTMLDialogElement>(null);
useEffect(() => {
const dialog = ref.current;
if (!dialog) return;
dialog.showModal();
return () => dialog.close();
}, []);
return (
<dialog
ref={ref}
className={styles.dialog}
aria-labelledby={titleId}
onCancel={(event) => {
event.preventDefault();
onClose();
}}
>
<div className={styles.dialogSurface}>{children}</div>
</dialog>
);
}
@@ -1,164 +1,343 @@
.page {
max-width: 1240px;
--workshop-ease: cubic-bezier(0.25, 1, 0.5, 1);
max-width: 1180px;
min-height: 70vh;
margin: 0 auto;
padding: 2.5rem 1.25rem 5rem;
padding: clamp(2rem, 5vw, 4.5rem) 1.25rem 5rem;
}
.hero {
display: flex;
align-items: flex-end;
justify-content: space-between;
display: grid;
grid-template-columns: minmax(0, 1fr) auto;
align-items: center;
gap: 2rem;
margin-bottom: 1.5rem;
margin-bottom: 2rem;
}
.eyebrow {
display: block;
margin-bottom: 0.45rem;
color: var(--ifm-color-primary);
font-size: 0.76rem;
font-weight: 750;
letter-spacing: 0.09em;
text-transform: uppercase;
}
.hero h1,
.detail h1 {
margin: 0;
font-size: 2.25rem;
letter-spacing: -0.025em;
font-size: clamp(2.35rem, 6vw, 4.25rem);
line-height: 0.98;
letter-spacing: -0.045em;
}
.hero p {
max-width: 65ch;
margin: 0.5rem 0 0;
max-width: 62ch;
margin: 0.85rem 0 0;
color: var(--ifm-color-emphasis-700);
font-size: clamp(1rem, 2vw, 1.12rem);
line-height: 1.65;
}
.heroPromises {
display: flex;
flex-wrap: wrap;
gap: 0.55rem 1rem;
padding: 0;
margin: 1.1rem 0 0;
color: var(--ifm-color-emphasis-700);
font-size: 0.82rem;
list-style: none;
}
.heroPromises li,
.accountActions,
.detailActions,
.formActions {
.formActions,
.serviceStateActions {
display: flex;
align-items: center;
flex-wrap: wrap;
gap: 0.75rem;
}
.heroPromises li {
gap: 0.35rem;
}
.accountActions,
.detailActions,
.formActions,
.serviceStateActions {
flex-wrap: wrap;
gap: 0.7rem;
}
.accountActions :global(.button),
.detailActions :global(.button) {
.detailActions :global(.button),
.serviceStateActions :global(.button) {
display: inline-flex;
align-items: center;
justify-content: center;
gap: 0.4rem;
}
.safety {
display: flex;
align-items: flex-start;
gap: 0.75rem;
padding: 0.85rem 1rem;
margin-bottom: 1.5rem;
border: 1px solid var(--ifm-color-warning-dark);
border-radius: 10px;
background: color-mix(in srgb, var(--ifm-color-warning) 10%, transparent);
padding: 0.9rem 1rem;
margin-bottom: 1rem;
border: 1px solid color-mix(in srgb, var(--ifm-color-warning) 55%, var(--ifm-color-emphasis-300));
border-radius: 12px;
background: color-mix(in srgb, var(--ifm-color-warning) 8%, var(--ifm-background-surface-color));
}
.safety svg {
flex: none;
margin-top: 0.1rem;
}
.safety p {
margin: 0;
color: var(--ifm-color-emphasis-800);
font-size: 0.9rem;
}
.toolbar {
display: flex;
display: grid;
grid-template-columns: minmax(260px, 1fr) 150px 150px auto;
align-items: end;
flex-wrap: wrap;
gap: 0.75rem;
padding: 0.75rem;
margin-bottom: 1.5rem;
padding: 0.85rem;
margin-bottom: 1.25rem;
border: 1px solid var(--ifm-color-emphasis-300);
border-radius: 10px;
border-radius: 14px;
background: var(--ifm-background-surface-color);
box-shadow: 0 8px 28px rgb(0 0 0 / 0.04);
}
.toolbar label,
.dialog label {
display: grid;
gap: 0.3rem;
font-size: 0.8rem;
font-weight: 600;
gap: 0.35rem;
font-size: 0.78rem;
font-weight: 700;
}
.toolbar input,
.toolbar select,
.dialog input,
.dialog select,
.dialog textarea {
width: 100%;
min-height: 44px;
padding: 0.55rem 0.7rem;
padding: 0.6rem 0.72rem;
border: 1px solid var(--ifm-color-emphasis-400);
border-radius: 7px;
border-radius: 9px;
color: var(--ifm-font-color-base);
background: var(--ifm-background-surface-color);
background: var(--ifm-background-color);
font: inherit;
transition:
border-color 150ms ease,
box-shadow 150ms ease;
}
.toolbar input:focus-visible,
.toolbar select:focus-visible,
.dialog input:focus-visible,
.dialog select:focus-visible,
.dialog textarea:focus-visible,
.pagination button:focus-visible,
.rowActions button:focus-visible {
outline: 2px solid var(--ifm-color-primary);
outline-offset: 2px;
}
.search {
position: relative;
flex: 1 1 280px;
}
.search svg {
position: absolute;
left: 0.75rem;
bottom: 0.8rem;
z-index: 1;
left: 0.8rem;
bottom: 0.82rem;
color: var(--ifm-color-emphasis-600);
pointer-events: none;
}
.search input {
width: 100%;
padding-left: 2.25rem;
padding-left: 2.35rem;
}
.checkbox {
display: flex !important;
grid-auto-flow: column;
align-items: center;
min-height: 44px;
}
.checkbox input {
width: auto;
min-height: auto;
}
.serviceState {
display: grid;
grid-template-columns: auto minmax(0, 1fr);
gap: 1rem;
padding: clamp(1.1rem, 3vw, 1.5rem);
margin: 1.25rem 0;
border: 1px solid color-mix(in srgb, var(--ifm-color-warning) 45%, var(--ifm-color-emphasis-300));
border-radius: 14px;
background: color-mix(in srgb, var(--ifm-color-warning) 7%, var(--ifm-background-surface-color));
}
.serviceStateIcon {
display: grid;
width: 44px;
height: 44px;
place-items: center;
border-radius: 10px;
color: var(--ifm-color-warning-darkest);
background: color-mix(in srgb, var(--ifm-color-warning) 18%, transparent);
}
.serviceState h2 {
margin: 0 0 0.25rem;
font-size: 1.05rem;
}
.serviceState p {
max-width: 70ch;
margin: 0 0 0.85rem;
color: var(--ifm-color-emphasis-700);
font-size: 0.9rem;
}
.grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(270px, 1fr));
grid-template-columns: repeat(auto-fill, minmax(min(100%, 270px), 1fr));
gap: 1rem;
}
.card {
position: relative;
overflow: hidden;
border: 1px solid var(--ifm-color-emphasis-300);
border-radius: 12px;
border-radius: 14px;
background: var(--ifm-background-surface-color);
box-shadow: 0 4px 14px rgb(0 0 0 / 0.035);
transition:
transform 200ms var(--workshop-ease),
border-color 200ms ease,
box-shadow 200ms var(--workshop-ease);
}
.card:hover,
.card:focus-within {
border-color: color-mix(in srgb, var(--ifm-color-primary) 55%, var(--ifm-color-emphasis-300));
box-shadow: 0 14px 34px rgb(0 0 0 / 0.1);
transform: translateY(-3px);
}
.card img,
.cardPlaceholder {
width: 100%;
aspect-ratio: 3 / 2;
object-fit: cover;
}
.cardPlaceholder {
display: grid;
place-items: center;
background: var(--ifm-color-emphasis-100);
color: var(--ifm-color-emphasis-600);
font: 700 1.5rem var(--ifm-font-family-monospace);
background: color-mix(in srgb, var(--ifm-color-primary) 6%, var(--ifm-color-emphasis-100));
font: 750 1.45rem var(--ifm-font-family-monospace);
}
.cardBody {
padding: 1rem;
}
.cardBody h2 {
margin: 0.4rem 0;
margin: 0.45rem 0;
font-size: 1.08rem;
}
.cardLink::after {
position: absolute;
content: "";
inset: 0;
}
.cardLink:focus-visible {
outline: none;
}
.card:has(.cardLink:focus-visible) {
outline: 3px solid var(--ifm-color-primary);
outline-offset: 3px;
}
.cardBody p {
min-height: 2.7em;
margin: 0 0 0.65rem;
margin: 0 0 0.7rem;
color: var(--ifm-color-emphasis-700);
font-size: 0.9rem;
font-size: 0.88rem;
}
.cardBody small {
color: var(--ifm-color-emphasis-600);
}
.cardMeta {
display: flex;
justify-content: space-between;
color: var(--ifm-color-emphasis-700);
font-size: 0.75rem;
font-weight: 700;
font-size: 0.72rem;
font-weight: 750;
letter-spacing: 0.035em;
text-transform: uppercase;
}
.error {
padding: 0.85rem 1rem;
margin: 1rem 0;
border-radius: 8px;
border: 1px solid color-mix(in srgb, var(--ifm-color-danger) 45%, transparent);
border-radius: 10px;
color: var(--ifm-color-danger-contrast-foreground);
background: var(--ifm-color-danger-contrast-background);
}
.empty {
padding: 4rem 1rem;
display: grid;
max-width: 620px;
min-height: 310px;
place-items: center;
align-content: center;
gap: 0.45rem;
padding: 3rem 1.25rem;
margin: 0 auto;
text-align: center;
border: 1px dashed var(--ifm-color-emphasis-400);
border-radius: 12px;
border-radius: 16px;
color: var(--ifm-color-emphasis-700);
}
.empty h2,
.empty p {
margin: 0;
}
.empty svg {
color: var(--ifm-color-primary);
}
.empty :global(.button) {
margin-top: 0.75rem;
}
.pagination {
display: flex;
justify-content: center;
@@ -166,134 +345,248 @@
gap: 1rem;
margin-top: 2rem;
}
.pagination button {
min-height: 44px;
padding: 0.5rem 0.8rem;
padding: 0.5rem 0.85rem;
}
.skeleton {
min-height: 310px;
border-radius: 12px;
border-radius: 14px;
background: var(--ifm-color-emphasis-200);
animation: pulse 1.5s ease-in-out infinite;
}
.detail {
margin-top: 1.5rem;
margin-top: 1.75rem;
}
.detail header {
max-width: 75ch;
}
.detail h1 {
margin-top: 0.5rem;
font-size: clamp(2.1rem, 5vw, 3.6rem);
}
.type {
display: inline-block;
margin-bottom: 0.5rem;
color: var(--ifm-color-primary);
font-weight: 700;
font-size: 0.78rem;
font-weight: 750;
letter-spacing: 0.07em;
text-transform: uppercase;
}
.gallery {
display: flex;
gap: 1rem;
overflow-x: auto;
padding-bottom: 0.6rem;
margin: 1.5rem 0;
scroll-snap-type: x mandatory;
}
.gallery img {
width: min(720px, 85vw);
max-height: 480px;
object-fit: contain;
border-radius: 10px;
border: 1px solid var(--ifm-color-emphasis-300);
border-radius: 12px;
background: var(--ifm-color-emphasis-100);
scroll-snap-align: start;
}
.detailActions {
margin: 1.5rem 0 2rem;
}
.sourceHint {
color: var(--ifm-color-emphasis-700);
}
.source {
max-height: 560px;
overflow: auto;
padding: 1rem;
border-radius: 8px;
border: 1px solid var(--ifm-color-emphasis-300);
border-radius: 10px;
font-size: 0.78rem;
}
.overlay {
position: fixed;
z-index: 500;
inset: 0;
display: grid;
place-items: center;
padding: 1rem;
background: rgb(0 0 0 / 0.7);
}
.dialog {
width: min(680px, 100%);
max-height: 90vh;
width: min(700px, calc(100vw - 2rem));
max-height: min(90vh, 900px);
padding: 0;
overflow: visible;
border: 0;
border-radius: 16px;
color: var(--ifm-font-color-base);
background: transparent;
box-shadow: 0 24px 80px rgb(0 0 0 / 0.35);
}
.dialog::backdrop {
background: rgb(0 0 0 / 0.68);
backdrop-filter: blur(3px);
}
.dialogSurface {
max-height: min(90vh, 900px);
overflow-y: auto;
padding: 1.25rem;
border-radius: 12px;
padding: clamp(1.15rem, 4vw, 1.6rem);
border: 1px solid var(--ifm-color-emphasis-300);
border-radius: 16px;
background: var(--ifm-background-color);
}
.dialogSurface > h2 {
margin-top: 0;
}
.dialog form {
display: grid;
gap: 1rem;
}
.dialog textarea {
min-height: 88px;
min-height: 96px;
resize: vertical;
}
.dialog .editor {
min-height: 240px;
font-family: var(--ifm-font-family-monospace);
font-size: 0.78rem;
}
.formActions {
justify-content: flex-end;
}
@keyframes pulse {
50% {
opacity: 0.55;
}
}
@media (max-width: 700px) {
.hero {
align-items: stretch;
flex-direction: column;
}
.accountActions > * {
flex: 1;
justify-content: center;
}
.toolbar > label {
flex: 1 1 100%;
}
}
@media (prefers-reduced-motion: reduce) {
.skeleton {
animation: none;
}
}
.adminSection {
margin: 2rem 0;
}
.tableWrap {
overflow-x: auto;
border: 1px solid var(--ifm-color-emphasis-300);
border-radius: 12px;
}
.tableWrap table {
width: 100%;
margin: 0;
border-collapse: collapse;
}
.tableWrap th,
.tableWrap td {
padding: 0.7rem;
padding: 0.75rem;
border: 0;
border-bottom: 1px solid var(--ifm-color-emphasis-300);
text-align: left;
vertical-align: top;
}
.tableWrap tr:last-child td {
border-bottom: 0;
}
.rowActions {
display: flex;
flex-wrap: wrap;
gap: 0.4rem;
}
.rowActions button {
min-height: 36px;
}
@keyframes pulse {
50% {
opacity: 0.55;
}
}
@media (max-width: 820px) {
.hero {
grid-template-columns: 1fr;
gap: 1.4rem;
}
.accountActions > * {
flex: 1 1 auto;
}
.toolbar {
grid-template-columns: 1fr 1fr;
}
.search {
grid-column: 1 / -1;
}
}
@media (max-width: 520px) {
.page {
padding-inline: 1rem;
}
.hero h1 {
font-size: 2.75rem;
}
.heroPromises {
display: grid;
}
.accountActions,
.detailActions,
.formActions,
.serviceStateActions {
align-items: stretch;
flex-direction: column;
}
.accountActions > *,
.detailActions > *,
.formActions > *,
.serviceStateActions > * {
width: 100%;
}
.toolbar {
grid-template-columns: 1fr;
}
.search {
grid-column: auto;
}
.serviceState {
grid-template-columns: 1fr;
}
.pagination {
justify-content: space-between;
gap: 0.5rem;
}
}
@media (prefers-reduced-motion: reduce) {
.skeleton {
animation: none;
}
.card {
transition: none;
}
.card:hover,
.card:focus-within {
transform: none;
}
}
+11
View File
@@ -0,0 +1,11 @@
import React, { type PropsWithChildren, useEffect } from "react";
export default function Root({ children }: PropsWithChildren) {
useEffect(() => {
if (process.env.NODE_ENV === "production" && "serviceWorker" in navigator) {
void navigator.serviceWorker.register("/workshop-sw.js", { scope: "/" });
}
}, []);
return children;
}
+46
View File
@@ -0,0 +1,46 @@
const CACHE_NAME = "homarr-site-v2";
const SHELL_PATHS = ["/", "/workshop/", "/docs/management/workshop/", "/img/logo.png"];
self.addEventListener("install", (event) => {
event.waitUntil(
caches.open(CACHE_NAME).then(async (cache) => {
const manifest = await fetch("/workshop-assets.json")
.then((response) => response.json())
.catch(() => []);
await Promise.allSettled([...SHELL_PATHS, ...manifest].map((path) => cache.add(path)));
}),
);
self.skipWaiting();
});
self.addEventListener("activate", (event) => {
event.waitUntil(
caches
.keys()
.then((keys) => Promise.all(keys.filter((key) => key !== CACHE_NAME).map((key) => caches.delete(key))))
.then(() => self.clients.claim()),
);
});
self.addEventListener("fetch", (event) => {
const request = event.request;
const url = new URL(request.url);
if (request.method !== "GET" || url.origin !== self.location.origin || url.pathname.startsWith("/api/")) return;
event.respondWith(
fetch(request)
.then((response) => {
if (response.ok) {
const copy = response.clone();
event.waitUntil(caches.open(CACHE_NAME).then((cache) => cache.put(request, copy)));
}
return response;
})
.catch(async () => {
const cached = await caches.match(request);
if (cached) return cached;
if (request.mode === "navigate") return (await caches.match("/workshop/")) ?? Response.error();
return Response.error();
}),
);
});