Polish Workshop and custom widget docs

This commit is contained in:
Thomas Camlong committed 2026-07-22 14:56:15 +02:00
1 parent 2236ec7cec
commit 47cd7dda1f
20 files changed
+733 -492

No files matched your search

@@ -1,19 +1,8 @@
import { useState } from "react";
import { useColorMode } from "@docusaurus/theme-common";
import { MantineProvider, Select, Stack, Text } from "@mantine/core";
/* oxlint-disable import/no-unassigned-import -- Mantine component styles intentionally load for side effects. */
import "@mantine/core/styles/Badge.css";
import "@mantine/core/styles/Button.css";
import "@mantine/core/styles/Combobox.css";
import "@mantine/core/styles/Group.css";
import "@mantine/core/styles/Input.css";
import "@mantine/core/styles/Loader.css";
import "@mantine/core/styles/Pill.css";
import "@mantine/core/styles/PillsInput.css";
import "@mantine/core/styles/Popover.css";
import "@mantine/core/styles/ScrollArea.css";
import "@mantine/core/styles/Stack.css";
import "@mantine/core/styles/Text.css";
import "@mantine/core/styles/UnstyledButton.css";
import "@mantine/core/styles.layer.css";
/* oxlint-enable import/no-unassigned-import */
import {
@@ -64,8 +53,10 @@ export function CanonicalCustomWidgetExampleClient({ id, label, example, height
}
export function CustomWidgetCodeExampleClient(props: CustomWidgetCodeExampleProps) {
const { colorMode } = useColorMode();
return (
<MantineProvider forceColorScheme={getDocumentationColorScheme()}>
<MantineProvider forceColorScheme={colorMode}>
<ReadOnlyCustomWidgetCode
id={props.id}
label={props.label}
@@ -79,21 +70,24 @@ export function CustomWidgetCodeExampleClient(props: CustomWidgetCodeExampleProp
}
export function CustomWidgetCodeInputClient(props: CustomWidgetCodeInputProps) {
const { colorMode } = useColorMode();
return (
<MantineProvider forceColorScheme={getDocumentationColorScheme()}>
<MantineProvider forceColorScheme={colorMode}>
<CustomWidgetCodeEditor {...props} messages={editableMessages} />
</MantineProvider>
);
}
export function BundledCustomWidgetGalleryClient() {
const { colorMode } = useColorMode();
const [selectedId, setSelectedId] = useState(BUNDLED_CUSTOM_WIDGETS[0]?.id ?? null);
const selected = BUNDLED_CUSTOM_WIDGETS.find(({ id }) => id === selectedId) ?? BUNDLED_CUSTOM_WIDGETS[0];
if (!selected) return null;
const manifest = { ...selected.widget, template: "__HOMARR_TEMPLATE__" };
return (
<MantineProvider forceColorScheme={getDocumentationColorScheme()}>
<MantineProvider forceColorScheme={colorMode}>
<Stack gap="md">
<Select
label="Bundled widget"
@@ -127,7 +121,3 @@ export function BundledCustomWidgetGalleryClient() {
</MantineProvider>
);
}
function getDocumentationColorScheme() {
return document.documentElement.dataset.theme === "dark" ? "dark" : "light";
}
@@ -68,5 +68,15 @@ export function BundledCustomWidgetGallery() {
}
function CodeExampleFallback({ height }: { height?: string }) {
return <div style={{ minHeight: height ?? "220px" }} aria-busy="true" />;
return (
<div
style={{
minHeight: height ?? "220px",
border: "1px solid var(--ifm-color-emphasis-300)",
borderRadius: "var(--ifm-global-radius)",
background: "var(--ifm-background-surface-color)",
}}
aria-busy="true"
/>
);
}
@@ -114,7 +114,7 @@ export const CommentsSection = ({
<h2 className="text-sm font-medium">Comments</h2>
<div className="flex items-center gap-2">
<Input
className="flex-1"
className="h-10 flex-1"
placeholder="Write a comment…"
aria-label="Write a comment"
value={newComment}
@@ -123,7 +123,7 @@ export const CommentsSection = ({
if (event.key === "Enter") void handleAdd();
}}
/>
<Button size="icon-sm" onClick={() => void handleAdd()} aria-label="Post comment">
<Button className="size-10" size="icon-lg" onClick={() => void handleAdd()} aria-label="Post comment">
<IconSend size={14} />
</Button>
</div>
@@ -134,7 +134,16 @@ export const CommentsSection = ({
</p>
)}
{loading && <p className="py-4 text-center text-sm text-muted-foreground">Loading comments…</p>}
{loading && (
<div className="space-y-2" aria-label="Loading comments">
{Array.from({ length: 2 }, (_, index) => (
<div key={index} className="animate-pulse rounded-lg bg-muted/30 px-3 py-3">
<div className="h-3 w-32 rounded bg-muted" />
<div className="mt-3 h-3 w-4/5 rounded bg-muted" />
</div>
))}
</div>
)}
{!loading && fetchError && <p className="py-4 text-center text-sm text-destructive">Failed to load comments</p>}
{!loading && !fetchError && rows.length === 0 && (
<p className="py-4 text-center text-sm text-muted-foreground">No comments yet</p>
@@ -165,6 +174,7 @@ export const CommentsSection = ({
{canManage && !isEditing && (
<div className="ml-auto flex gap-0.5">
<button
type="button"
className="rounded p-1 hover:bg-accent"
aria-label="Edit comment"
onClick={() => {
@@ -175,6 +185,7 @@ export const CommentsSection = ({
<IconEdit size={12} />
</button>
<button
type="button"
className="rounded p-1 hover:bg-destructive/20 hover:text-destructive"
aria-label="Delete comment"
onClick={() => void handleDelete(comment.id)}
+242 -78
View File
@@ -11,12 +11,21 @@ import {
IconCheck,
IconCopy,
IconDownload,
IconFlag,
IconInfoCircle,
IconLoader2,
IconRefresh,
IconX,
} from "@tabler/icons-react";
import type { ClientResponseError } from "pocketbase";
import type { WorkshopSubmission, WorkshopVote } from "@site/src/lib/pocketbase";
import { getPocketBase, getSubmissionFileUrl, signInWithGitHub } from "@site/src/lib/pocketbase";
import {
getPocketBase,
getSubmissionFileUrl,
parseWorkshopSubmission,
signInWithGitHub,
} from "@site/src/lib/pocketbase";
import type { SubmissionType } from "@site/src/lib/workshop-schema";
import { Badge } from "@/components/ui/badge";
@@ -31,21 +40,22 @@ import {
} from "@/components/ui/dialog";
import { Input } from "@/components/ui/input";
import { Textarea } from "@/components/ui/textarea";
import { CustomWidgetCodeExample } from "../custom-widget-code";
import { CustomWidgetCodeExample, CustomWidgetCodeInput } from "../custom-widget-code";
import { validateSubmissionContent } from "@site/src/lib/workshop-schema";
import { cn, errorMessage, oauthErrorMessage } from "@site/src/lib/utils";
import { CommentsSection } from "./DetailComments";
import { CodeBlock, DeleteConfirmButton, DetailSkeleton, ScreenshotGallery } from "./DetailSections";
import { formatRelativeTime } from "./format";
import { WorkshopErrorBoundary } from "./WorkshopErrorBoundary";
import { downloadSubmissionJson, voteDelta } from "./workshop-utils";
const typeLabels: Record<SubmissionType, string> = { customCss: "CSS", customWidget: "Widget" };
const typeDotColors: Record<SubmissionType, string> = { customCss: "bg-blue-500", customWidget: "bg-yellow-500" };
const contentLanguages: Record<SubmissionType, string> = { customCss: "css", customWidget: "json" };
const copyState = [
{ Icon: IconCopy, label: "Copy", iconClass: "" },
{ Icon: IconCheck, label: "Copied!", iconClass: "text-green-500" },
{ Icon: IconCopy, iconClass: "" },
{ Icon: IconCheck, iconClass: "text-green-500" },
] as const;
const scoreClassBySign = { positive: "text-foreground", negative: "text-destructive", neutral: "" } as const;
@@ -71,7 +81,6 @@ const MarketplaceDetail = ({ workshopUrl }: { workshopUrl: string }) => {
const pb = useMemo(() => getPocketBase(workshopUrl), [workshopUrl]);
const [submission, setSubmission] = useState<WorkshopSubmission | null>(null);
const [reports, setReports] = useState<Array<{ id: string; category: string; explanation: string }>>([]);
const [userVote, setUserVote] = useState<WorkshopVote | undefined>();
const [user, setUser] = useState(pb.authStore.record);
const [loading, setLoading] = useState(true);
@@ -84,6 +93,14 @@ const MarketplaceDetail = ({ workshopUrl }: { workshopUrl: string }) => {
const [editDescription, setEditDescription] = useState("");
const [editChangelog, setEditChangelog] = useState("");
const [editContent, setEditContent] = useState("");
const [editError, setEditError] = useState<string | null>(null);
const [editPending, setEditPending] = useState(false);
const [reportOpen, setReportOpen] = useState(false);
const [reportCategory, setReportCategory] = useState("other");
const [reportExplanation, setReportExplanation] = useState("");
const [reportPending, setReportPending] = useState(false);
const [reportError, setReportError] = useState<string | null>(null);
const [reloadKey, setReloadKey] = useState(0);
const copyTimer = useRef<ReturnType<typeof setTimeout>>(null);
const copyFailedTimer = useRef<ReturnType<typeof setTimeout>>(null);
const voting = useRef(false);
@@ -138,17 +155,14 @@ const MarketplaceDetail = ({ workshopUrl }: { workshopUrl: string }) => {
const load = async () => {
try {
const [listing, record, reportRows] = await Promise.all([
const [listing, record] = await Promise.all([
pb.collection("workshop_listings").getOne<WorkshopSubmission>(submissionId),
pb.collection("submissions").getOne<WorkshopSubmission>(submissionId),
pb.collection("reports").getFullList<{ id: string; category: string; explanation: string }>({
filter: pb.filter("submission = {:id} && status = 'open'", { id: submissionId }),
sort: "-created",
}),
]);
if (cancelled) return;
setSubmission({ ...listing, ...record, content: record.content });
setReports(reportRows);
const parsed = parseWorkshopSubmission({ ...listing, ...record, content: record.content });
if (!parsed) throw new Error("This Workshop submission has an invalid record shape");
setSubmission(parsed);
if (pb.authStore.isValid && pb.authStore.record) {
const votes = await pb.collection("votes").getFullList<WorkshopVote>({
@@ -172,7 +186,7 @@ const MarketplaceDetail = ({ workshopUrl }: { workshopUrl: string }) => {
return () => {
cancelled = true;
};
}, [pb, submissionId]);
}, [pb, submissionId, reloadKey]);
const screenshotUrls = useMemo(
() => submission?.screenshots?.map((f) => getSubmissionFileUrl(pb.baseURL, submission.id, f)) ?? [],
@@ -248,6 +262,7 @@ const MarketplaceDetail = ({ workshopUrl }: { workshopUrl: string }) => {
setEditDescription(submission.description);
setEditChangelog("");
setEditContent(submission.content);
setEditError(null);
setEditOpen(true);
};
@@ -255,9 +270,11 @@ const MarketplaceDetail = ({ workshopUrl }: { workshopUrl: string }) => {
if (!submission) return;
const validation = validateSubmissionContent(submission.type, editContent);
if (!validation.success) {
setError(validation.error);
setEditError(validation.error);
return;
}
setEditPending(true);
setEditError(null);
try {
const updated = await pb.collection("submissions").update<WorkshopSubmission>(submission.id, {
title: editTitle.trim(),
@@ -270,7 +287,32 @@ const MarketplaceDetail = ({ workshopUrl }: { workshopUrl: string }) => {
setEditOpen(false);
setError(null);
} catch (caught) {
setError(errorMessage(caught, "Failed to update submission"));
setEditError(errorMessage(caught, "Failed to update submission"));
} finally {
setEditPending(false);
}
};
const handleReport = async () => {
if (!submission) return;
const userId = await requireUserId("report this submission");
if (!userId) return;
setReportPending(true);
setReportError(null);
try {
await pb.collection("reports").create({
submission: submission.id,
reporter: userId,
category: reportCategory,
explanation: reportExplanation.trim(),
status: "open",
});
setReportOpen(false);
setReportExplanation("");
} catch (caught) {
setReportError(errorMessage(caught, "Failed to submit report"));
} finally {
setReportPending(false);
}
};
@@ -292,6 +334,28 @@ const MarketplaceDetail = ({ workshopUrl }: { workshopUrl: string }) => {
if (loading) return <DetailSkeleton />;
if (error && !submission) {
return (
<div className="mx-auto flex min-h-[50vh] max-w-xl flex-col items-center justify-center gap-4 px-4 text-center">
<div className="flex size-11 items-center justify-center rounded-xl bg-destructive/10 text-destructive">
<IconX size={22} />
</div>
<div>
<h1 className="text-xl font-semibold">Submission could not be loaded</h1>
<p className="mt-1 text-sm text-muted-foreground">Check the Workshop service and try again.</p>
</div>
<div className="flex flex-wrap justify-center gap-2">
<Button variant="outline" onClick={() => setReloadKey((value) => value + 1)}>
<IconRefresh size={15} /> Try loading again
</Button>
<Button variant="ghost" nativeButton={false} render={<Link to="/workshop" />}>
Back to Workshop
</Button>
</div>
</div>
);
}
if (notFound || !submission) {
return (
<div className="mx-auto max-w-4xl px-4 py-16 text-center">
@@ -308,7 +372,7 @@ const MarketplaceDetail = ({ workshopUrl }: { workshopUrl: string }) => {
}
const score = submission.upvotes - submission.downvotes;
const { Icon: CopyIcon, label: copyLabel, iconClass: copyIconClass } = copyState[Number(copied)];
const { Icon: CopyIcon, iconClass: copyIconClass } = copyState[Number(copied)];
return (
<div className="mx-auto max-w-4xl px-4 pb-16 pt-8">
@@ -340,7 +404,6 @@ const MarketplaceDetail = ({ workshopUrl }: { workshopUrl: string }) => {
{typeLabels[submission.type]}
</Badge>
{submission.outdated && <Badge variant="secondary">Outdated</Badge>}
{submission.reportCount > 0 && <Badge variant="destructive">Reported</Badge>}
</div>
<p className="mt-1 text-sm text-muted-foreground">
<a
@@ -360,10 +423,12 @@ const MarketplaceDetail = ({ workshopUrl }: { workshopUrl: string }) => {
<div className="flex items-center gap-px rounded-md border border-border bg-muted/40 p-px">
<button
type="button"
onClick={() => void handleVote(1)}
aria-label="Upvote"
aria-pressed={userVote?.value === 1}
className={cn(
"flex items-center justify-center rounded-[5px] p-1.5 transition-colors hover:bg-accent",
"flex size-10 items-center justify-center rounded-[5px] transition-colors hover:bg-accent focus-visible:ring-2 focus-visible:ring-ring/50 sm:size-9",
userVote?.value === 1 && "bg-primary/15 text-primary",
)}
>
@@ -375,10 +440,12 @@ const MarketplaceDetail = ({ workshopUrl }: { workshopUrl: string }) => {
{score}
</span>
<button
type="button"
onClick={() => void handleVote(-1)}
aria-label="Downvote"
aria-pressed={userVote?.value === -1}
className={cn(
"flex items-center justify-center rounded-[5px] p-1.5 transition-colors hover:bg-accent",
"flex size-10 items-center justify-center rounded-[5px] transition-colors hover:bg-accent focus-visible:ring-2 focus-visible:ring-ring/50 sm:size-9",
userVote?.value === -1 && "bg-primary/15 text-primary",
)}
>
@@ -392,56 +459,65 @@ const MarketplaceDetail = ({ workshopUrl }: { workshopUrl: string }) => {
)}
{submission.changelog && (
<div className="mt-4 rounded-lg border border-border bg-muted/30 p-4">
<p className="text-xs font-medium uppercase tracking-wide text-muted-foreground">Changelog</p>
<p className="text-xs font-medium text-muted-foreground">Changelog</p>
<p className="mt-1 text-sm">{submission.changelog}</p>
</div>
)}
{reports.length > 0 && (
<div className="mt-6 rounded-lg border border-destructive/30 bg-destructive/10 p-4 text-sm">
<p className="font-medium text-destructive">
The community has reported this submission {reports.length} time{reports.length === 1 ? "" : "s"}.
</p>
<ul className="mt-2 space-y-1 text-muted-foreground">
{reports.map((report) => (
<li key={report.id}>
<strong>{report.category}:</strong> {report.explanation}
</li>
))}
</ul>
</div>
)}
<div className="mt-6 flex flex-wrap items-center gap-2">
<Button variant="outline" size="sm" onClick={() => void handleCopy()}>
{submission.type === "customWidget" && (
<Button className="h-10 sm:h-7" size="sm" onClick={() => downloadSubmissionJson(submission)}>
<IconDownload size={14} /> Download widget JSON
</Button>
)}
<Button
className="h-10 sm:h-7"
variant={submission.type === "customCss" ? "default" : "outline"}
size="sm"
onClick={() => void handleCopy()}
>
{copyFailed ? (
<>
<IconX size={14} className="text-destructive" /> Copy failed
</>
) : (
<>
<CopyIcon size={14} className={copyIconClass} /> {copyLabel}
<CopyIcon size={14} className={copyIconClass} />
{copied ? "Copied" : submission.type === "customCss" ? "Copy CSS" : "Copy widget JSON"}
</>
)}
</Button>
{submission.type === "customWidget" && (
<Button variant="outline" size="sm" onClick={() => downloadSubmissionJson(submission)}>
<IconDownload size={14} /> Download
</Button>
)}
<Button className="h-10 sm:h-7" variant="ghost" size="sm" onClick={() => setReportOpen(true)}>
<IconFlag size={14} /> Report submission
</Button>
{(user?.id === submission.author || user?.isAdmin === true) && (
<Button variant="outline" size="sm" onClick={() => void handleOutdated()}>
<Button className="h-10 sm:h-7" variant="outline" size="sm" onClick={() => void handleOutdated()}>
{submission.outdated ? "Mark current" : "Mark outdated"}
</Button>
)}
{user?.id === submission.author && (
<Button variant="outline" size="sm" onClick={openEdit}>
<Button className="h-10 sm:h-7" variant="outline" size="sm" onClick={openEdit}>
Edit submission
</Button>
)}
{(user?.id === submission.author || user?.isAdmin === true) && <DeleteConfirmButton onConfirm={handleDelete} />}
</div>
<div className="mt-4 flex gap-3 rounded-lg bg-muted/40 p-4 text-sm">
<IconInfoCircle size={18} className="mt-0.5 shrink-0 text-muted-foreground" />
<div>
<p className="font-medium">Install in Homarr</p>
<p className="mt-1 max-w-2xl text-muted-foreground">
{submission.type === "customWidget"
? "Download the JSON, then open Manage → Custom Widgets → Import in your Homarr instance. Homarr asks for any required credentials during import."
: "Copy the CSS, then open your board settings and paste it into Custom CSS."}{" "}
<Link to="/docs/management/workshop/" className="font-medium text-primary hover:underline">
Read the installation guide
</Link>
</p>
</div>
</div>
<div className="mt-6">
{submission.type === "customWidget" ? (
<CustomWidgetCodeExample
@@ -466,49 +542,133 @@ const MarketplaceDetail = ({ workshopUrl }: { workshopUrl: string }) => {
onError={setError}
/>
</div>
<Dialog open={editOpen} onOpenChange={setEditOpen}>
<DialogContent className="max-h-[90vh] overflow-y-auto sm:max-w-2xl">
<Dialog
open={reportOpen}
onOpenChange={(open) => {
if (reportPending) return;
setReportOpen(open);
if (!open) setReportError(null);
}}
>
<DialogContent className="sm:max-w-md">
<DialogHeader>
<DialogTitle>Report submission</DialogTitle>
<DialogDescription>
Only Workshop administrators can review report details and reporter identities.
</DialogDescription>
</DialogHeader>
{reportError && (
<div className="rounded-lg border border-destructive/30 bg-destructive/10 px-3 py-2 text-sm text-destructive">
{reportError}
</div>
)}
<label htmlFor="workshop-report-category" className="grid gap-1.5 text-sm">
Category
<select
id="workshop-report-category"
className="h-10 rounded-md border border-input bg-transparent px-3"
value={reportCategory}
onChange={(event) => setReportCategory(event.target.value)}
>
<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 htmlFor="workshop-report-explanation" className="grid gap-1.5 text-sm">
Explanation
<Textarea
id="workshop-report-explanation"
value={reportExplanation}
onChange={(event) => setReportExplanation(event.target.value)}
placeholder="Explain what should be reviewed"
maxLength={1000}
rows={5}
/>
</label>
<DialogFooter>
<Button variant="ghost" onClick={() => setReportOpen(false)} disabled={reportPending}>
Cancel
</Button>
<Button
variant="destructive"
disabled={reportPending || reportExplanation.trim().length < 3}
onClick={() => void handleReport()}
>
{reportPending && <IconLoader2 size={14} className="animate-spin" />}
{reportPending ? "Submitting…" : "Submit report"}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
<Dialog
open={editOpen}
onOpenChange={(open) => {
if (!editPending) setEditOpen(open);
}}
>
<DialogContent className="max-h-[92vh] overflow-y-auto sm:max-w-5xl">
<DialogHeader>
<DialogTitle>Edit submission</DialogTitle>
<DialogDescription>Saving creates a new revision and keeps the same Workshop URL.</DialogDescription>
</DialogHeader>
<label htmlFor="workshop-edit-title" className="grid gap-1.5 text-sm">
Title
<Input id="workshop-edit-title" value={editTitle} onChange={(event) => setEditTitle(event.target.value)} />
</label>
<label htmlFor="workshop-edit-description" className="grid gap-1.5 text-sm">
Description
<Textarea
id="workshop-edit-description"
value={editDescription}
onChange={(event) => setEditDescription(event.target.value)}
/>
</label>
<label htmlFor="workshop-edit-changelog" className="grid gap-1.5 text-sm">
Changelog
<Textarea
id="workshop-edit-changelog"
value={editChangelog}
onChange={(event) => setEditChangelog(event.target.value)}
placeholder="What changed?"
/>
</label>
<label htmlFor="workshop-edit-content" className="grid gap-1.5 text-sm">
Content
<Textarea
{editError && (
<div className="rounded-lg border border-destructive/30 bg-destructive/10 px-3 py-2 text-sm text-destructive">
{editError}
</div>
)}
<div className="grid gap-5 lg:grid-cols-[minmax(0,1.45fr)_minmax(18rem,0.55fr)]">
<CustomWidgetCodeInput
id="workshop-edit-content"
className="font-mono text-xs"
rows={14}
label={submission.type === "customWidget" ? "Widget JSON" : "Custom CSS"}
language={submission.type === "customWidget" ? "json" : "css"}
value={editContent}
onChange={(event) => setEditContent(event.target.value)}
onChange={setEditContent}
height="min(46vh, 460px)"
required
/>
</label>
<div className="flex flex-col gap-4">
<label htmlFor="workshop-edit-title" className="grid gap-1.5 text-sm">
Title
<Input
id="workshop-edit-title"
value={editTitle}
onChange={(event) => setEditTitle(event.target.value)}
/>
</label>
<label htmlFor="workshop-edit-description" className="grid gap-1.5 text-sm">
Description
<Textarea
id="workshop-edit-description"
value={editDescription}
onChange={(event) => setEditDescription(event.target.value)}
rows={5}
/>
</label>
<label htmlFor="workshop-edit-changelog" className="grid gap-1.5 text-sm">
Changelog
<Textarea
id="workshop-edit-changelog"
value={editChangelog}
onChange={(event) => setEditChangelog(event.target.value)}
placeholder="What changed?"
rows={4}
/>
</label>
</div>
</div>
<DialogFooter>
<Button variant="ghost" onClick={() => setEditOpen(false)}>
<Button variant="ghost" onClick={() => setEditOpen(false)} disabled={editPending}>
Cancel
</Button>
<Button disabled={editTitle.trim().length < 3 || !editContent.trim()} onClick={() => void handleEdit()}>
Save revision
<Button
disabled={editPending || editTitle.trim().length < 3 || !editContent.trim()}
onClick={() => void handleEdit()}
>
{editPending && <IconLoader2 size={14} className="animate-spin" />}
{editPending ? "Saving…" : "Save revision"}
</Button>
</DialogFooter>
</DialogContent>
@@ -525,7 +685,11 @@ export default function MarketplaceDetailPage() {
<Layout title="Workshop" description="Community custom CSS and custom widgets for Homarr">
<main className="marketplace bg-background text-foreground min-h-[80vh]">
<BrowserOnly fallback={<DetailSkeleton />}>
{() => <MarketplaceDetail workshopUrl={configuredWorkshopUrl || window.location.origin} />}
{() => (
<WorkshopErrorBoundary>
<MarketplaceDetail workshopUrl={configuredWorkshopUrl || window.location.origin} />
</WorkshopErrorBoundary>
)}
</BrowserOnly>
</main>
</Layout>
@@ -1,9 +1,17 @@
import React, { useEffect, useRef, useState } from "react";
import React, { useState } from "react";
import { useColorMode } from "@docusaurus/theme-common";
import { IconChevronLeft, IconChevronRight, IconTrash } from "@tabler/icons-react";
import { Highlight, themes } from "prism-react-renderer";
import { Button } from "@/components/ui/button";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import { cn } from "@site/src/lib/utils";
const prismThemes = { light: themes.github, dark: themes.dracula } as const;
@@ -34,6 +42,7 @@ export const ScreenshotGallery = ({ urls, title }: { urls: string[]; title: stri
{urls.length > 1 && (
<>
<button
type="button"
className="absolute left-3 top-1/2 flex size-8 -translate-y-1/2 items-center justify-center rounded-md bg-background/80 opacity-60 shadow transition-opacity hover:opacity-100"
onClick={() => setIdx((i) => (i - 1 + urls.length) % urls.length)}
aria-label="Previous screenshot"
@@ -41,6 +50,7 @@ export const ScreenshotGallery = ({ urls, title }: { urls: string[]; title: stri
<IconChevronLeft size={16} />
</button>
<button
type="button"
className="absolute right-3 top-1/2 flex size-8 -translate-y-1/2 items-center justify-center rounded-md bg-background/80 opacity-60 shadow transition-opacity hover:opacity-100"
onClick={() => setIdx((i) => (i + 1) % urls.length)}
aria-label="Next screenshot"
@@ -50,6 +60,7 @@ export const ScreenshotGallery = ({ urls, title }: { urls: string[]; title: stri
<div className="absolute bottom-3 left-1/2 flex -translate-x-1/2 gap-1.5 rounded-full bg-black/50 px-2.5 py-1">
{urls.map((_, i) => (
<button
type="button"
key={i}
onClick={() => setIdx(i)}
aria-label={`Screenshot ${i + 1}`}
@@ -88,36 +99,42 @@ export const CodeBlock = ({ content, language }: { content: string; language: st
};
export const DeleteConfirmButton = ({ onConfirm }: { onConfirm: () => void }) => {
const [pending, setPending] = useState(false);
const timer = useRef<ReturnType<typeof setTimeout>>(null);
useEffect(
() => () => {
if (timer.current) clearTimeout(timer.current);
},
[],
);
const handleClick = () => {
if (!pending) {
setPending(true);
timer.current = setTimeout(() => setPending(false), 3000);
return;
}
setPending(false);
onConfirm();
};
const [opened, setOpened] = useState(false);
return (
<Button
variant="outline"
size="sm"
className={cn(
pending ? "border-destructive bg-destructive/10 text-destructive" : "text-destructive hover:bg-destructive/10",
)}
onClick={handleClick}
>
<IconTrash size={14} /> {pending ? "Confirm delete?" : "Delete"}
</Button>
<>
<Button
variant="outline"
size="sm"
className="h-10 text-destructive hover:bg-destructive/10 sm:h-7"
onClick={() => setOpened(true)}
>
<IconTrash size={14} /> Delete
</Button>
<Dialog open={opened} onOpenChange={setOpened}>
<DialogContent className="sm:max-w-md">
<DialogHeader>
<DialogTitle>Delete submission?</DialogTitle>
<DialogDescription>
This permanently removes the Workshop listing, screenshots, votes, reports, and comments.
</DialogDescription>
</DialogHeader>
<DialogFooter>
<Button variant="ghost" onClick={() => setOpened(false)}>
Cancel
</Button>
<Button
variant="destructive"
onClick={() => {
setOpened(false);
onConfirm();
}}
>
Delete submission
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</>
);
};
@@ -225,14 +225,17 @@ export const SubmitForm = ({ onClose, onSubmit }: Props) => {
<React.Fragment key={label}>
{i > 0 && <div className={cn("h-px flex-1 transition-colors", connectorClass[Number(i <= step)])} />}
<button
type="button"
onClick={() => {
if (i < step) goTo(i);
}}
disabled={i > step}
aria-current={i === step ? "step" : undefined}
className={cn(
"flex items-center gap-1.5 rounded-full px-3 py-1 text-xs font-medium transition-all",
i === step && "bg-primary text-primary-foreground shadow-sm",
i < step && "bg-primary/10 text-primary cursor-pointer hover:bg-primary/20",
i > step && "bg-muted text-muted-foreground",
i > step && "bg-muted text-muted-foreground disabled:cursor-default",
)}
>
{i < step ? <IconCheck size={12} /> : <span className="tabular-nums">{i + 1}</span>}
@@ -331,6 +334,7 @@ const StepType = ({
<div className="grid grid-cols-2 gap-3">
{typeCards.map(({ type, icon: Icon, label, desc }) => (
<button
type="button"
key={type}
onClick={() => onChange(type)}
aria-pressed={value === type}
@@ -556,6 +560,7 @@ const StepMedia = ({
<div key={i} className="group relative h-24 overflow-hidden rounded-xl border border-border bg-muted">
<img src={src} alt={`Preview ${i + 1}`} className="h-full w-full object-cover" />
<button
type="button"
onClick={() => removeScreenshot(i)}
className="absolute inset-0 flex items-center justify-center bg-black/60 opacity-0 transition-opacity group-hover:opacity-100 focus-visible:opacity-100"
aria-label={`Remove screenshot ${i + 1}`}
+161 -292
View File
@@ -1,23 +1,20 @@
import React, { useEffect, useMemo, useRef, useState } from "react";
import React, { useMemo, useState } from "react";
import {
IconAlertCircle,
IconArrowBigDown,
IconArrowBigUp,
IconBrandCss3,
IconBrandGithub,
IconCheck,
IconChevronLeft,
IconChevronRight,
IconCopy,
IconDownload,
IconFlag,
IconEye,
IconLogout,
IconMessage,
IconPackage,
IconPlus,
IconPuzzle,
IconRefresh,
IconSearch,
IconTrash,
IconX,
} from "@tabler/icons-react";
import { getSubmissionFileUrl, type WorkshopSubmission } from "@site/src/lib/pocketbase";
@@ -34,20 +31,10 @@ import {
CardHeader,
CardTitle,
} from "@/components/ui/card";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import { Textarea } from "@/components/ui/textarea";
import { cn } from "@/lib/utils";
import { SubmitForm } from "./SubmitForm";
import { formatRelativeTime } from "./format";
import { downloadSubmissionJson } from "./workshop-utils";
import type { SortKey, TypeFilter } from "./useWorkshop";
import { useWorkshop } from "./useWorkshop";
@@ -60,10 +47,6 @@ const typeIcons: Record<SubmissionType, React.ComponentType<{ size: number; clas
const typeBgColors: Record<SubmissionType, string> = { customCss: "bg-blue-500/5", customWidget: "bg-yellow-500/5" };
const filterActiveClass = "bg-background text-foreground shadow-sm";
const filterInactiveClass = "text-muted-foreground hover:text-foreground";
const copyState = [
{ Icon: IconCopy, label: "Copy", iconClass: "" },
{ Icon: IconCheck, label: "Copied!", iconClass: "text-green-500" },
] as const;
const emptyState = {
none: { title: "No submissions yet", hint: "Be the first to share something." },
filtered: { title: "No matching results", hint: "Try adjusting your filters or search." },
@@ -73,7 +56,6 @@ const typeFilters: { value: TypeFilter; label: string; dot?: string }[] = [
{ value: "all", label: "All" },
{ value: "customWidget", label: "Widgets", dot: typeDotColors.customWidget },
{ value: "customCss", label: "CSS", dot: typeDotColors.customCss },
{ value: "yours", label: "Yours" },
];
const sortOptions: { value: SortKey; label: string }[] = [
{ value: "top", label: "Top rated" },
@@ -98,14 +80,17 @@ export const WorkshopApp = ({ workshopUrl }: { workshopUrl: string }) => {
// Stable sort order: only re-sort when filter/sort/search or submission set changes (not vote counts)
const submissionIds = useMemo(() => workshop.submissions.map((s) => s.id).join(","), [workshop.submissions]);
const sortedIds = useMemo(() => {
const q = search.toLowerCase();
const q = search.trim().toLowerCase();
return workshop.submissions
.filter((item) => {
if (typeFilter === "yours") return item.author === workshop.user?.id;
return typeFilter === "all" || item.type === typeFilter;
})
.filter((item) => includeOutdated || !item.outdated)
.filter((item) => !q || item.title.toLowerCase().includes(q))
.filter(
(item) =>
!q || [item.title, item.description, item.authorName].some((value) => value?.toLowerCase().includes(q)),
)
.toSorted(workshop.sorters[sort])
.map((s) => s.id);
// eslint-disable-next-line react-hooks/exhaustive-deps -- intentionally keyed on submissionIds string, not the array
@@ -117,108 +102,168 @@ export const WorkshopApp = ({ workshopUrl }: { workshopUrl: string }) => {
}, [workshop.submissions, sortedIds]);
const empty = emptyState[workshop.submissions.length === 0 ? "none" : "filtered"];
const initialLoadFailed = !workshop.loading && Boolean(workshop.error) && workshop.submissions.length === 0;
const availableTypeFilters = workshop.user
? [...typeFilters, { value: "yours" as const, label: "Yours" }]
: typeFilters;
return (
<div className="mx-auto max-w-7xl px-4 pb-16">
<div className="flex flex-col gap-6 py-8 sm:flex-row sm:items-end sm:justify-between">
<div className="flex flex-col gap-5 py-8 sm:flex-row sm:items-end sm:justify-between">
<div>
<h1 className="text-2xl font-semibold tracking-tight">Workshop</h1>
<p className="mt-1 text-sm text-muted-foreground">Community custom widgets and CSS themes for Homarr.</p>
<p className="mt-1 max-w-2xl text-sm text-muted-foreground">
Discover community-made widgets and CSS. Review the source, then import it into Homarr.
</p>
</div>
<div className="flex items-center gap-2">
<div className="flex items-center gap-2 self-start sm:self-auto">
{workshop.user ? (
<>
<Button onClick={() => setShowSubmit(true)}>
<Button className="h-10 sm:h-8" onClick={() => setShowSubmit(true)}>
<IconPlus size={14} /> Share yours
</Button>
<Button variant="ghost" size="sm" onClick={workshop.logout}>
<Button
variant="ghost"
size="sm"
className="h-10 sm:h-7"
onClick={() => {
setTypeFilter("all");
workshop.logout();
}}
>
<IconLogout size={14} /> {workshop.user?.displayName || workshop.user?.username || "Account"}
</Button>
</>
) : (
<div className="flex flex-col items-end gap-0.5">
<Button onClick={() => void workshop.login()}>
<div className="flex flex-col items-start gap-1 sm:items-end">
<Button className="h-10 sm:h-8" onClick={() => void workshop.login()}>
<IconBrandGithub size={14} /> Sign in with GitHub
</Button>
<p className="text-xs text-muted-foreground">Vote and share your creations</p>
<p className="text-xs text-muted-foreground">Vote, comment, report, and publish</p>
</div>
)}
</div>
</div>
<div className="mb-6 flex flex-wrap items-center justify-between gap-3 rounded-lg border border-border bg-card px-3 py-2">
<div className="flex items-center gap-2">
<div className="inline-flex rounded-md bg-muted p-0.5">
{typeFilters.map((opt) => (
{workshop.submissions.length > 0 && (
<div className="mb-6 flex flex-col gap-3 rounded-lg border border-border bg-card p-3 sm:flex-row sm:items-center sm:justify-between">
<div className="relative order-first w-full sm:order-last sm:w-60">
<IconSearch size={16} className="absolute left-3 top-1/2 -translate-y-1/2 text-muted-foreground" />
<input
className="h-11 w-full rounded-md border border-input bg-transparent pl-9 pr-3 text-sm outline-none placeholder:text-muted-foreground focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50 sm:h-9"
placeholder="Search by title, description, or author"
aria-label="Search submissions"
value={search}
onChange={(event) => setSearch(event.target.value)}
/>
</div>
<div className="flex min-w-0 flex-col gap-2 sm:flex-row sm:items-center">
<fieldset
className={cn(
"grid min-w-0 rounded-md border-0 bg-muted p-0.5 sm:flex",
workshop.user ? "grid-cols-4" : "grid-cols-3",
)}
aria-label="Submission type"
>
{availableTypeFilters.map((opt) => (
<button
type="button"
key={opt.value}
onClick={() => setTypeFilter(opt.value)}
aria-pressed={typeFilter === opt.value}
className={cn(
"inline-flex min-h-10 items-center justify-center gap-1.5 rounded px-3 text-sm font-medium transition-colors sm:min-h-8 sm:text-xs",
typeFilter === opt.value ? filterActiveClass : filterInactiveClass,
)}
>
{opt.dot && <span className={cn("size-2 rounded-full", opt.dot)} />}
{opt.label}
</button>
))}
</fieldset>
<div className="flex items-center gap-2">
<select
aria-label="Sort submissions"
className="h-10 min-w-0 flex-1 rounded-md border border-input bg-transparent px-3 text-sm outline-none focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50 sm:h-8 sm:flex-none sm:text-xs dark:bg-input/30"
value={sort}
onChange={(event) => setSort(event.target.value as SortKey)}
>
{sortOptions.map((option) => (
<option key={option.value} value={option.value}>
{option.label}
</option>
))}
</select>
<button
key={opt.value}
onClick={() => setTypeFilter(opt.value)}
type="button"
onClick={() => setIncludeOutdated((value) => !value)}
aria-pressed={!includeOutdated}
className={cn(
"inline-flex items-center gap-1.5 rounded px-2.5 py-1 text-xs font-medium transition-colors",
typeFilter === opt.value ? filterActiveClass : filterInactiveClass,
"h-10 shrink-0 rounded-md border border-input px-3 text-sm transition-colors focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50 sm:h-8 sm:text-xs",
includeOutdated ? "bg-transparent text-muted-foreground" : filterActiveClass,
)}
>
{opt.dot && <span className={cn("size-2 rounded-full", opt.dot)} />}
{opt.label}
{includeOutdated ? "Hide outdated" : "Show outdated"}
</button>
))}
</div>
</div>
<select
aria-label="Sort order"
className="h-7 rounded-md border border-input bg-transparent px-2 text-xs outline-none dark:bg-input/30"
value={sort}
onChange={(e) => setSort(e.target.value as SortKey)}
>
{sortOptions.map((o) => (
<option key={o.value} value={o.value}>
{o.label}
</option>
))}
</select>
<button
type="button"
onClick={() => setIncludeOutdated((value) => !value)}
className={cn(
"h-7 rounded-md border border-input px-2 text-xs transition-colors",
includeOutdated ? "bg-transparent text-muted-foreground" : filterActiveClass,
)}
>
{includeOutdated ? "Hide outdated" : "Show outdated"}
</button>
</div>
<div className="relative">
<IconSearch size={14} className="absolute left-2.5 top-1/2 -translate-y-1/2 text-muted-foreground" />
<input
className="h-7 w-48 rounded-md border border-input bg-transparent pl-8 text-xs outline-none placeholder:text-muted-foreground"
placeholder="Search…"
aria-label="Search submissions"
value={search}
onChange={(e) => setSearch(e.target.value)}
/>
</div>
</div>
{workshop.error && (
<div className="mb-4 rounded-lg border border-destructive/30 bg-destructive/10 px-4 py-2.5 text-sm text-destructive">
{workshop.error}
</div>
)}
{workshop.loading && (
<div className="columns-1 gap-4 lg:columns-2 xl:columns-3 2xl:columns-4 [&>*]:mb-4">
{initialLoadFailed && (
<div className="flex min-h-80 flex-col items-center justify-center gap-4 rounded-xl border border-border bg-card px-6 py-12 text-center">
<div className="flex size-11 items-center justify-center rounded-xl bg-destructive/10 text-destructive">
<IconAlertCircle size={22} />
</div>
<div>
<h2 className="text-base font-semibold">Workshop listings could not be loaded</h2>
<p className="mt-1 max-w-md text-sm text-muted-foreground">
Check the Workshop service and your connection, then try again.
</p>
</div>
<Button variant="outline" onClick={() => void workshop.refresh()}>
<IconRefresh size={15} /> Try loading again
</Button>
</div>
)}
{workshop.error && workshop.submissions.length > 0 && (
<div className="mb-4 flex flex-wrap items-center justify-between gap-3 rounded-lg border border-destructive/30 bg-destructive/10 px-4 py-3 text-sm">
<span className="text-destructive">Some Workshop data could not be refreshed.</span>
<Button variant="ghost" size="sm" onClick={() => void workshop.refresh()}>
<IconRefresh size={14} /> Retry
</Button>
</div>
)}
{workshop.loading && workshop.submissions.length === 0 && (
<div className="grid grid-cols-1 gap-4 lg:grid-cols-2 xl:grid-cols-3">
{Array.from({ length: 6 }, (_, i) => (
<SkeletonCard key={i} hasImage={i % 3 === 0} />
))}
</div>
)}
{!workshop.loading && visible.length === 0 && (
{!workshop.loading && !workshop.error && visible.length === 0 && (
<div className="flex flex-col items-center gap-3 rounded-lg border border-dashed border-border py-16">
<IconPackage size={28} stroke={1.5} className="text-muted-foreground" />
<div className="text-center">
<p className="text-sm font-medium">{empty.title}</p>
<p className="mt-0.5 text-xs text-muted-foreground">{empty.hint}</p>
</div>
{workshop.submissions.length > 0 && (
<Button
variant="outline"
size="sm"
onClick={() => {
setTypeFilter("all");
setSearch("");
setIncludeOutdated(true);
}}
>
Clear filters
</Button>
)}
{workshop.user && workshop.submissions.length === 0 && (
<Button size="sm" onClick={() => setShowSubmit(true)}>
<IconPlus size={14} /> Create submission
@@ -227,18 +272,14 @@ export const WorkshopApp = ({ workshopUrl }: { workshopUrl: string }) => {
</div>
)}
<div className="columns-1 gap-4 lg:columns-2 xl:columns-3 [&>*]:mb-4">
<div className="grid grid-cols-1 gap-4 lg:grid-cols-2 xl:grid-cols-3">
{visible.map((submission) => (
<SubmissionCard
key={submission.id}
submission={submission}
pb={workshop.pb}
userVote={workshop.votes[submission.id]?.value}
currentUserId={workshop.user?.id}
currentUserIsAdmin={workshop.user?.isAdmin === true}
onVote={workshop.vote}
onReport={workshop.report}
onDelete={workshop.deleteSubmission}
/>
))}
</div>
@@ -259,96 +300,21 @@ interface SubmissionCardProps {
submission: WorkshopSubmission;
pb: ReturnType<typeof useWorkshop>["pb"];
userVote?: 1 | -1;
currentUserId?: string;
currentUserIsAdmin?: boolean;
onVote: (submissionId: string, value: 1 | -1) => void;
onReport: (
submissionId: string,
category: "malicious" | "spam" | "copyright" | "inappropriate" | "other",
explanation: string,
) => void;
onDelete: (submissionId: string) => Promise<boolean>;
}
const SubmissionCard = ({
submission,
pb,
userVote,
currentUserId,
currentUserIsAdmin,
onVote,
onReport,
onDelete,
}: SubmissionCardProps) => {
const canDelete = currentUserId === submission.author || currentUserIsAdmin;
const [copied, setCopied] = useState(false);
const [confirmDelete, setConfirmDelete] = useState(false);
const confirmTimer = useRef<ReturnType<typeof setTimeout>>(null);
const copyTimer = useRef<ReturnType<typeof setTimeout>>(null);
const SubmissionCard = ({ submission, pb, userVote, onVote }: SubmissionCardProps) => {
const score = submission.upvotes - submission.downvotes;
const { Icon: CopyIcon, label: copyLabel, iconClass: copyIconClass } = copyState[Number(copied)];
const screenshotUrls = useMemo(
() => submission.screenshots?.map((f) => getSubmissionFileUrl(pb.baseURL, submission.id, f)) ?? [],
[submission, pb],
);
useEffect(
() => () => {
if (copyTimer.current) clearTimeout(copyTimer.current);
if (confirmTimer.current) clearTimeout(confirmTimer.current);
},
[],
);
const [copyFailed, setCopyFailed] = useState(false);
const [reportOpen, setReportOpen] = useState(false);
const [reportCategory, setReportCategory] = useState("other");
const [reportExplanation, setReportExplanation] = useState("");
const loadContent = async () => {
if (submission.content) return submission.content;
const record = await pb.collection("submissions").getOne<{ content: string }>(submission.id);
return record.content;
};
const handleCopy = async () => {
try {
await navigator.clipboard.writeText(await loadContent());
setCopied(true);
setCopyFailed(false);
if (copyTimer.current) clearTimeout(copyTimer.current);
copyTimer.current = setTimeout(() => setCopied(false), 1500);
} catch {
setCopyFailed(true);
setTimeout(() => setCopyFailed(false), 2000);
}
};
const handleDownload = async () => {
try {
downloadSubmissionJson({ ...submission, content: await loadContent() });
} catch {
setCopyFailed(true);
setTimeout(() => setCopyFailed(false), 2000);
}
};
const handleDelete = () => {
if (!confirmDelete) {
setConfirmDelete(true);
if (confirmTimer.current) clearTimeout(confirmTimer.current);
confirmTimer.current = setTimeout(() => setConfirmDelete(false), 3000);
return;
}
setConfirmDelete(false);
void onDelete(submission.id);
};
const hasScreenshots = screenshotUrls.length > 0;
const TypeIcon = typeIcons[submission.type];
return (
<Card className="relative transition-transform duration-150 hover:scale-[1.02]">
<Card className="relative flex h-full flex-col">
<a href={`/workshop/${submission.id}/`} className="block">
{hasScreenshots ? (
<div className="relative">
@@ -368,7 +334,7 @@ const SubmissionCard = ({
)}
</a>
<CardHeader>
<CardHeader className="flex flex-col gap-2 sm:grid">
<div className="flex items-center gap-2">
<a href={`/workshop/${submission.id}/`} className="min-w-0 hover:underline" title={submission.title}>
<CardTitle className="truncate">{submission.title}</CardTitle>
@@ -394,13 +360,15 @@ const SubmissionCard = ({
</a>{" "}
· v{submission.revision} · {formatRelativeTime(submission.created)}
</CardDescription>
<CardAction>
<CardAction className="col-start-auto row-span-1 row-start-auto self-auto justify-self-start sm:col-start-2 sm:row-span-2 sm:row-start-1 sm:self-start sm:justify-self-end">
<div className="flex items-center gap-px rounded-md border border-border bg-muted/40 p-px">
<button
type="button"
onClick={() => void onVote(submission.id, 1)}
aria-label="Upvote"
aria-pressed={userVote === 1}
className={cn(
"flex items-center justify-center rounded-[5px] p-1 transition-colors hover:bg-accent",
"flex size-10 items-center justify-center rounded-[5px] transition-colors hover:bg-accent focus-visible:ring-2 focus-visible:ring-ring/50 sm:size-8",
userVote === 1 && "bg-primary/15 text-primary",
)}
>
@@ -416,10 +384,12 @@ const SubmissionCard = ({
{score}
</span>
<button
type="button"
onClick={() => void onVote(submission.id, -1)}
aria-label="Downvote"
aria-pressed={userVote === -1}
className={cn(
"flex items-center justify-center rounded-[5px] p-1 transition-colors hover:bg-accent",
"flex size-10 items-center justify-center rounded-[5px] transition-colors hover:bg-accent focus-visible:ring-2 focus-visible:ring-ring/50 sm:size-8",
userVote === -1 && "bg-primary/15 text-primary",
)}
>
@@ -429,15 +399,10 @@ const SubmissionCard = ({
</CardAction>
</CardHeader>
<CardContent>
{(submission.outdated || submission.reportCount > 0) && (
<CardContent className="flex-1">
{submission.outdated && (
<div className="mb-2 flex flex-wrap gap-1.5">
{submission.outdated && <Badge variant="secondary">Outdated</Badge>}
{submission.reportCount > 0 && (
<Badge variant="destructive">
{submission.reportCount} community report{submission.reportCount === 1 ? "" : "s"}
</Badge>
)}
<Badge variant="secondary">Outdated</Badge>
</div>
)}
{submission.description && (
@@ -445,115 +410,19 @@ const SubmissionCard = ({
)}
</CardContent>
<CardFooter className="flex-col gap-0 p-0">
<div className="flex w-full flex-wrap items-center gap-0.5 px-2 py-1.5">
<Button
variant="ghost"
size="xs"
onClick={() => void handleCopy()}
className="text-muted-foreground hover:text-foreground"
>
{copyFailed ? (
<>
<IconX size={13} className="text-destructive" /> Failed
</>
) : (
<>
<CopyIcon size={13} className={copyIconClass} /> {copyLabel}
</>
)}
</Button>
{submission.type === "customWidget" && (
<Button
variant="ghost"
size="xs"
onClick={() => void handleDownload()}
className="text-muted-foreground hover:text-foreground"
>
<IconDownload size={13} /> Download
</Button>
)}
{submission.commentCount > 0 && (
<a
href={`/workshop/${submission.id}/`}
className="inline-flex items-center gap-1 rounded px-1.5 py-0.5 text-xs text-muted-foreground hover:text-foreground"
>
<IconMessage size={13} /> {submission.commentCount}
</a>
)}
<div className="ml-auto flex items-center gap-px">
{canDelete && (
<button
className={cn(
"flex items-center gap-1 rounded px-1 py-0.5 text-xs transition-colors",
confirmDelete
? "bg-destructive/10 text-destructive"
: "text-muted-foreground/60 hover:text-destructive",
)}
onClick={handleDelete}
aria-label="Delete"
>
<IconTrash size={13} />
{confirmDelete && "Confirm?"}
</button>
)}
<button
className="flex items-center justify-center rounded p-1 text-muted-foreground/60 transition-colors hover:text-destructive"
onClick={() => setReportOpen(true)}
aria-label="Report"
>
<IconFlag size={13} />
</button>
</div>
</div>
<CardFooter className="justify-between gap-3 px-3 py-2.5">
<span className="inline-flex items-center gap-1 text-xs text-muted-foreground">
<IconMessage size={14} /> {submission.commentCount} {submission.commentCount === 1 ? "comment" : "comments"}
</span>
<Button
className="h-10 sm:h-7"
size="sm"
nativeButton={false}
render={<a href={`/workshop/${submission.id}/`} aria-label={`View ${submission.title} details`} />}
>
<IconEye size={14} /> View details
</Button>
</CardFooter>
<Dialog open={reportOpen} onOpenChange={setReportOpen}>
<DialogContent className="sm:max-w-md">
<DialogHeader>
<DialogTitle>Report submission</DialogTitle>
<DialogDescription>Reports are public and help others make an informed choice.</DialogDescription>
</DialogHeader>
<label className="grid gap-1.5 text-sm">
Category
<select
className="h-9 rounded-md border border-input bg-transparent px-3"
value={reportCategory}
onChange={(event) => setReportCategory(event.target.value)}
>
<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>
<Textarea
value={reportExplanation}
onChange={(event) => setReportExplanation(event.target.value)}
placeholder="Explain what is wrong…"
maxLength={1000}
/>
<DialogFooter>
<Button variant="ghost" onClick={() => setReportOpen(false)}>
Cancel
</Button>
<Button
variant="destructive"
disabled={reportExplanation.trim().length < 3}
onClick={() => {
void onReport(
submission.id,
reportCategory as "malicious" | "spam" | "copyright" | "inappropriate" | "other",
reportExplanation.trim(),
);
setReportOpen(false);
}}
>
Submit report
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</Card>
);
};
@@ -574,12 +443,9 @@ const SkeletonCard = ({ hasImage }: { hasImage: boolean }) => (
<div className="h-3 w-2/3 animate-pulse rounded bg-muted" />
</div>
</CardContent>
<CardFooter className="p-0">
<div className="flex w-full gap-2 px-2 py-2">
<div className="h-5 w-12 animate-pulse rounded bg-muted" />
<div className="h-5 w-12 animate-pulse rounded bg-muted" />
<div className="h-5 w-16 animate-pulse rounded bg-muted" />
</div>
<CardFooter className="justify-between gap-3 px-3 py-2.5">
<div className="h-3 w-20 animate-pulse rounded bg-muted" />
<div className="h-9 w-28 animate-pulse rounded bg-muted" />
</CardFooter>
</Card>
);
@@ -601,6 +467,7 @@ const ScreenshotGallery = ({ urls, title }: { urls: string[]; title: string }) =
{urls.length > 1 && (
<>
<button
type="button"
className="absolute left-2 top-1/2 flex size-6 -translate-y-1/2 items-center justify-center rounded-md bg-background/80 opacity-60 shadow transition-opacity hover:opacity-100"
onClick={(event) => {
stopCardNavigation(event);
@@ -611,6 +478,7 @@ const ScreenshotGallery = ({ urls, title }: { urls: string[]; title: string }) =
<IconChevronLeft size={14} />
</button>
<button
type="button"
className="absolute right-2 top-1/2 flex size-6 -translate-y-1/2 items-center justify-center rounded-md bg-background/80 opacity-60 shadow transition-opacity hover:opacity-100"
onClick={(event) => {
stopCardNavigation(event);
@@ -623,6 +491,7 @@ const ScreenshotGallery = ({ urls, title }: { urls: string[]; title: string }) =
<div className="absolute bottom-2 left-1/2 flex -translate-x-1/2 gap-1 rounded-full bg-black/50 px-2 py-1">
{urls.map((_, i) => (
<button
type="button"
key={i}
onClick={(event) => {
stopCardNavigation(event);
@@ -0,0 +1,35 @@
import React from "react";
import { IconAlertTriangle } from "@tabler/icons-react";
import { Button } from "@/components/ui/button";
interface State {
failed: boolean;
}
export class WorkshopErrorBoundary extends React.Component<React.PropsWithChildren<object>, State> {
public state: State = { failed: false };
public static getDerivedStateFromError(): State {
return { failed: true };
}
public render() {
if (!this.state.failed) return this.props.children;
return (
<div className="mx-auto flex min-h-[50vh] max-w-xl flex-col items-center justify-center gap-4 px-4 text-center">
<div className="flex size-11 items-center justify-center rounded-xl bg-destructive/10 text-destructive">
<IconAlertTriangle size={22} />
</div>
<div>
<h1 className="text-xl font-semibold">Workshop could not be displayed</h1>
<p className="mt-1 text-sm text-muted-foreground">
Reload the page to try again. Your Homarr installation is not affected.
</p>
</div>
<Button onClick={() => window.location.reload()}>Reload Workshop</Button>
</div>
);
}
}
@@ -2,7 +2,7 @@ import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import dayjs from "dayjs";
import type { WorkshopComment, WorkshopSubmission, WorkshopVote } from "@site/src/lib/pocketbase";
import { getPocketBase, signInWithGitHub } from "@site/src/lib/pocketbase";
import { getPocketBase, parseWorkshopSubmission, signInWithGitHub } from "@site/src/lib/pocketbase";
import type { SubmissionType } from "@site/src/lib/workshop-schema";
import { schemaVersionByType, validateSubmissionContent } from "@site/src/lib/workshop-schema";
import { errorMessage, oauthErrorMessage } from "@site/src/lib/utils";
@@ -62,7 +62,13 @@ export const useWorkshop = (workshopUrl: string) => {
setLoading(true);
setError(null);
try {
setSubmissions(await pb.collection("workshop_listings").getFullList<WorkshopSubmission>({ sort: "-created" }));
const rows = await pb.collection("workshop_listings").getFullList<WorkshopSubmission>({ sort: "-created" });
const parsed = rows
.map(parseWorkshopSubmission)
.filter((submission): submission is WorkshopSubmission => submission !== null);
setSubmissions(parsed);
if (parsed.length !== rows.length)
setError(`${rows.length - parsed.length} Workshop submission could not be displayed`);
await refreshVotes();
} catch (caught) {
setError(errorMessage(caught, "Failed to load the workshop"));
+11 -4
View File
@@ -112,6 +112,12 @@
--sidebar-ring: oklch(0.723 0.014 214.4);
}
body {
background-color: var(--background);
color: var(--foreground);
font-family: var(--ifm-font-family-base);
}
[data-theme="dark"] {
--ifm-color-primary: #fa5252;
--ifm-color-primary-dark: #f93232;
@@ -121,18 +127,19 @@
--ifm-color-primary-lighter: #fb8282;
--ifm-color-primary-lightest: #fdc3c3;
--docusaurus-highlighted-code-line-bg: rgba(0, 0, 0, 0.3);
--homarr-secondary-bg: oklch(0.218 0.008 223.9);
--homarr-secondary-bg: oklch(0.245 0.006 223.9);
--ifm-color-content-inverse: var(--ifm-color-emphasis-100);
--aa-primary-color-rgb: #fa5252 !important;
--aa-muted-color-rgb: #fa5252 !important;
--aa-background-color-rgb: 24, 24, 24 !important;
--background: oklch(0.148 0.004 228.8);
/* Preserve Docusaurus's familiar charcoal canvas instead of a near-black app shell. */
--background: #1b1b1d;
--foreground: oklch(0.987 0.002 197.1);
--card: oklch(0.218 0.008 223.9);
--card: oklch(0.245 0.006 223.9);
--card-foreground: oklch(0.987 0.002 197.1);
--popover: oklch(0.218 0.008 223.9);
--popover: oklch(0.245 0.006 223.9);
--popover-foreground: oklch(0.987 0.002 197.1);
--primary: oklch(0.455 0.188 13.697);
--primary-foreground: oklch(0.969 0.015 12.422);
+18 -24
View File
@@ -1,5 +1,7 @@
import PocketBase from "pocketbase";
import { workshopSubmissionSummarySchema, type WorkshopSubmissionSummary } from "@homarr/workshop/schema";
let client: PocketBase | undefined;
let clientUrl: string | undefined;
@@ -34,31 +36,23 @@ export const signInWithGitHub = async (pb: PocketBase) => {
return updated;
};
export interface WorkshopSubmission {
id: string;
collectionId: string;
collectionName: string;
type: "customCss" | "customWidget";
title: string;
description: string;
widgetSchema: string;
export type WorkshopSubmission = WorkshopSubmissionSummary & {
collectionId?: string;
collectionName?: string;
content: string;
screenshots: string[];
upvotes: number;
downvotes: number;
commentCount: number;
reportCount: number;
revision: number;
changelog: string;
outdated: boolean;
author: string;
authorName: string;
authorAvatarUrl: string;
authorGithubUsername: string;
authorGithubProfileUrl: string;
created: string;
updated: string;
}
};
export const parseWorkshopSubmission = (value: unknown): WorkshopSubmission | null => {
const result = workshopSubmissionSummarySchema.safeParse(value);
if (!result.success) return null;
const record = value as Record<string, unknown>;
return {
...result.data,
collectionId: typeof record.collectionId === "string" ? record.collectionId : undefined,
collectionName: typeof record.collectionName === "string" ? record.collectionName : undefined,
content: typeof record.content === "string" ? record.content : "",
};
};
export interface WorkshopVote {
id: string;
+8 -4
View File
@@ -3,6 +3,9 @@ import BrowserOnly from "@docusaurus/BrowserOnly";
import useDocusaurusContext from "@docusaurus/useDocusaurusContext";
import Layout from "@theme/Layout";
import { WorkshopApp } from "@site/src/components/workshop/WorkshopApp";
import { WorkshopErrorBoundary } from "@site/src/components/workshop/WorkshopErrorBoundary";
export default function WorkshopPage() {
const { siteConfig } = useDocusaurusContext();
const configuredWorkshopUrl = (siteConfig.customFields?.workshopUrl as string | undefined) ?? "";
@@ -11,10 +14,11 @@ export default function WorkshopPage() {
<Layout title="Workshop" description="Community custom CSS and custom widgets for Homarr">
<main className="marketplace bg-background text-foreground min-h-[80vh]">
<BrowserOnly fallback={<div style={{ minHeight: "50vh" }} />}>
{() => {
const { WorkshopApp } = require("@site/src/components/workshop/WorkshopApp");
return <WorkshopApp workshopUrl={configuredWorkshopUrl || window.location.origin} />;
}}
{() => (
<WorkshopErrorBoundary>
<WorkshopApp workshopUrl={configuredWorkshopUrl || window.location.origin} />
</WorkshopErrorBoundary>
)}
</BrowserOnly>
</main>
</Layout>