feat: unify Workshop experience
This commit is contained in:
27 files changed
+1184
-483
No files matched your search
@@ -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.
|
||||
@@ -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",
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
import React, { type PropsWithChildren, useEffect } from "react";
|
||||
|
||||
export default function Root({ children }: PropsWithChildren) {
|
||||
useEffect(() => {
|
||||
if (process.env.NODE_ENV === "production" && "serviceWorker" in navigator) {
|
||||
void navigator.serviceWorker.register("/workshop-sw.js", { scope: "/" });
|
||||
}
|
||||
}, []);
|
||||
|
||||
return children;
|
||||
}
|
||||
Vendored
+46
@@ -0,0 +1,46 @@
|
||||
const CACHE_NAME = "homarr-site-v2";
|
||||
const SHELL_PATHS = ["/", "/workshop/", "/docs/management/workshop/", "/img/logo.png"];
|
||||
|
||||
self.addEventListener("install", (event) => {
|
||||
event.waitUntil(
|
||||
caches.open(CACHE_NAME).then(async (cache) => {
|
||||
const manifest = await fetch("/workshop-assets.json")
|
||||
.then((response) => response.json())
|
||||
.catch(() => []);
|
||||
await Promise.allSettled([...SHELL_PATHS, ...manifest].map((path) => cache.add(path)));
|
||||
}),
|
||||
);
|
||||
self.skipWaiting();
|
||||
});
|
||||
|
||||
self.addEventListener("activate", (event) => {
|
||||
event.waitUntil(
|
||||
caches
|
||||
.keys()
|
||||
.then((keys) => Promise.all(keys.filter((key) => key !== CACHE_NAME).map((key) => caches.delete(key))))
|
||||
.then(() => self.clients.claim()),
|
||||
);
|
||||
});
|
||||
|
||||
self.addEventListener("fetch", (event) => {
|
||||
const request = event.request;
|
||||
const url = new URL(request.url);
|
||||
if (request.method !== "GET" || url.origin !== self.location.origin || url.pathname.startsWith("/api/")) return;
|
||||
|
||||
event.respondWith(
|
||||
fetch(request)
|
||||
.then((response) => {
|
||||
if (response.ok) {
|
||||
const copy = response.clone();
|
||||
event.waitUntil(caches.open(CACHE_NAME).then((cache) => cache.put(request, copy)));
|
||||
}
|
||||
return response;
|
||||
})
|
||||
.catch(async () => {
|
||||
const cached = await caches.match(request);
|
||||
if (cached) return cached;
|
||||
if (request.mode === "navigate") return (await caches.match("/workshop/")) ?? Response.error();
|
||||
return Response.error();
|
||||
}),
|
||||
);
|
||||
});
|
||||
Reference in new issue
Block a user