Polish Workshop and custom widget docs
This commit is contained in:
20 files changed
+733
-492
No files matched your search
@@ -27,9 +27,10 @@ The workshop contains community-made content. Widgets may contain unwanted or ma
|
||||
|
||||
- Filter by **Widgets** or **CSS**, show or hide outdated entries, and sort by popularity, creation date, recent updates, or discussion activity.
|
||||
- Sign in with **GitHub** to upvote/downvote, submit your own creations, or report a submission.
|
||||
- Each submission shows its title, description, linked GitHub author, revision, optional screenshots, reports, comments, and full content.
|
||||
- Each submission shows its title, description, linked GitHub author, revision, optional screenshots, comments, and full content.
|
||||
- A new submission receives one real upvote owned by its author. Votes remain unique per user and submission.
|
||||
- Reports and the manual **Outdated** badge are warnings, not automatic removal. Reported and outdated entries remain installable after confirmation.
|
||||
- Report details and reporter identities are visible only to Workshop administrators. Reports do not automatically remove a submission.
|
||||
- The manual **Outdated** badge warns visitors that a submission may need an update.
|
||||
|
||||
## Comments
|
||||
|
||||
@@ -38,7 +39,7 @@ Comments are publicly readable. Signed-in users can post; authors can edit or de
|
||||
## Submitting
|
||||
|
||||
1. Sign in with GitHub.
|
||||
2. Click **Submit** and choose the type:
|
||||
2. Click **Share yours** and choose the type:
|
||||
- **Custom widget** — paste the JSON exported from **Manage → Custom Widgets → Export**.
|
||||
It must match the `homarr-custom-widget-v2` schema (validated before submission).
|
||||
- **Custom CSS** — paste your stylesheet (max 16384 characters).
|
||||
@@ -62,8 +63,7 @@ You can install workshop entries without leaving Homarr:
|
||||
- **Custom CSS** — open a board's **Settings → Custom CSS**, click **Install from Workshop**, and
|
||||
pick a theme to load it into the editor before saving.
|
||||
|
||||
Alternatively, use **Copy** (or **Download** for widgets) on any submission and import it
|
||||
manually.
|
||||
Alternatively, open a submission in the Workshop and use **Download widget JSON** or **Copy CSS**. The submission page shows the matching manual installation steps before the source code.
|
||||
|
||||
Custom Widget imports detect required bearer, basic, header, or query credentials. You can enter them during import or explicitly defer them; secret values are never stored in Workshop content. Missing credentials remain visible on the local Custom Widget afterward.
|
||||
|
||||
|
||||
@@ -46,17 +46,6 @@ const config: Config = {
|
||||
|
||||
themes: ["@docusaurus/theme-mermaid"],
|
||||
|
||||
scripts: [
|
||||
{
|
||||
src: "https://widget.kapa.ai/kapa-widget.bundle.js",
|
||||
"data-website-id": "1e4656f4-abeb-4343-bbae-1d8626f52378",
|
||||
"data-project-name": "Homarr",
|
||||
"data-project-color": "#2B2B2B",
|
||||
"data-project-logo": "https://homarr.dev/img/favicon.png",
|
||||
async: true,
|
||||
},
|
||||
],
|
||||
|
||||
presets: [
|
||||
[
|
||||
"classic",
|
||||
|
||||
@@ -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)}
|
||||
|
||||
@@ -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}`}
|
||||
|
||||
@@ -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"));
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -0,0 +1,97 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>New Comment on Homarr Workshop</title>
|
||||
</head>
|
||||
<body style="margin: 0; padding: 0; background-color: #f4f4f4; font-family: Helvetica, Arial, sans-serif">
|
||||
<table role="presentation" width="100%" cellpadding="0" cellspacing="0" style="background-color: #f4f4f4">
|
||||
<tr>
|
||||
<td align="center" style="padding: 50px 20px">
|
||||
<table
|
||||
role="presentation"
|
||||
width="600"
|
||||
cellpadding="0"
|
||||
cellspacing="0"
|
||||
style="width: 600px; max-width: 100%; background-color: #ffffff; border: 1px solid #000000"
|
||||
>
|
||||
<!-- Header -->
|
||||
<tr>
|
||||
<td align="center" style="background-color: #000000; padding: 36px 20px">
|
||||
<a href="https://homarr.dev" target="_blank" style="text-decoration: none">
|
||||
<img
|
||||
src="https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons/svg/homarr-wordmark-light.png"
|
||||
alt="Homarr"
|
||||
width="170"
|
||||
style="display: block; width: 170px; height: auto; border: 0; margin: 0 auto"
|
||||
/>
|
||||
</a>
|
||||
</td>
|
||||
</tr>
|
||||
|
||||
<!-- Body -->
|
||||
<tr>
|
||||
<td style="padding: 45px 40px; color: #000000; font-size: 15px; line-height: 1.7">
|
||||
<p style="margin: 0 0 18px 0">Hi there,</p>
|
||||
<p style="margin: 0 0 18px 0">
|
||||
<strong>{{commenterName}}</strong> left a comment on your submission
|
||||
<strong>{{submissionTitle}}</strong>:
|
||||
</p>
|
||||
<blockquote
|
||||
style="
|
||||
margin: 0 0 24px 0;
|
||||
padding: 16px 20px;
|
||||
border-left: 3px solid #000000;
|
||||
background-color: #f9f9f9;
|
||||
color: #555555;
|
||||
"
|
||||
>
|
||||
{{commentExcerpt}}
|
||||
</blockquote>
|
||||
|
||||
<!-- CTA button -->
|
||||
<table role="presentation" cellpadding="0" cellspacing="0">
|
||||
<tr>
|
||||
<td align="center" style="background-color: #000000">
|
||||
<a
|
||||
href="{{submissionUrl}}"
|
||||
target="_blank"
|
||||
style="
|
||||
display: inline-block;
|
||||
padding: 12px 28px;
|
||||
color: #ffffff;
|
||||
font-size: 14px;
|
||||
font-weight: bold;
|
||||
text-decoration: none;
|
||||
letter-spacing: 0.5px;
|
||||
"
|
||||
>
|
||||
VIEW THE COMMENT
|
||||
</a>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
</td>
|
||||
</tr>
|
||||
|
||||
<!-- Divider -->
|
||||
<tr>
|
||||
<td style="border-top: 1px solid #000000"></td>
|
||||
</tr>
|
||||
|
||||
<!-- Footer -->
|
||||
<tr>
|
||||
<td align="center" style="padding: 24px 20px; color: #000000; font-size: 12px">
|
||||
<a href="https://homarr.dev" target="_blank" style="color: #000000; text-decoration: underline"
|
||||
>homarr.dev</a
|
||||
>
|
||||
· Homarr — 2026
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
</body>
|
||||
</html>
|
||||
@@ -40,12 +40,20 @@ onRecordAfterCreateSuccess((event) => {
|
||||
const rawExcerpt = event.record.getString("content").slice(0, 280);
|
||||
const submissionUrl = `${publicOrigin}/workshop/${submission.id}/`;
|
||||
const sender = event.app.settings().meta;
|
||||
const templatePath = $os.getenv("WORKSHOP_PB_HOOKS_DIR") || $filepath.dirname(__filepath);
|
||||
const template = $os
|
||||
.readFile($filepath.join(templatePath, "comment-email.html"))
|
||||
.replaceAll("{{commenterName}}", escapeHtml(commenterName))
|
||||
.replaceAll("{{submissionTitle}}", escapeHtml(submissionTitle))
|
||||
.replaceAll("{{commentExcerpt}}", escapeHtml(rawExcerpt))
|
||||
.replaceAll("{{submissionUrl}}", escapeHtml(submissionUrl));
|
||||
|
||||
const message = new MailerMessage({
|
||||
from: { address: sender.senderAddress, name: sender.senderName },
|
||||
to: [{ address: recipientEmail }],
|
||||
subject: `${commenterName} commented on ${submissionTitle}`,
|
||||
text: `${commenterName} commented on “${submissionTitle}”:\n\n${rawExcerpt}\n\n${submissionUrl}`,
|
||||
html: `<p><strong>${escapeHtml(commenterName)}</strong> commented on <strong>${escapeHtml(submissionTitle)}</strong>:</p><blockquote>${escapeHtml(rawExcerpt)}</blockquote><p><a href="${escapeHtml(submissionUrl)}">View the comment</a></p>`,
|
||||
html: template,
|
||||
});
|
||||
event.app.newMailClient().send(message);
|
||||
} catch (error) {
|
||||
|
||||
@@ -135,8 +135,8 @@ migrate(
|
||||
const reports = new Collection({
|
||||
type: "base",
|
||||
name: "reports",
|
||||
listRule: "",
|
||||
viewRule: "",
|
||||
listRule: adminRule,
|
||||
viewRule: adminRule,
|
||||
createRule: "@request.auth.id != '' && @request.body.reporter = @request.auth.id",
|
||||
updateRule: `${adminRule} && @request.body.reporter:changed = false && @request.body.submission:changed = false`,
|
||||
deleteRule: adminRule,
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
.root {
|
||||
margin-top: 4px;
|
||||
border: 1px solid light-dark(var(--mantine-color-gray-4), var(--mantine-color-dark-4));
|
||||
border: 1px solid var(--mantine-color-default-border);
|
||||
border-radius: var(--mantine-radius-sm);
|
||||
background: light-dark(var(--mantine-color-white), var(--mantine-color-dark-7));
|
||||
background: var(--mantine-color-body);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
@@ -15,7 +15,7 @@
|
||||
.toolbar,
|
||||
.footer {
|
||||
padding: 7px 10px;
|
||||
background: light-dark(var(--mantine-color-gray-0), var(--mantine-color-dark-8));
|
||||
background: var(--mantine-color-default-hover);
|
||||
}
|
||||
|
||||
.toolbar {
|
||||
@@ -29,8 +29,8 @@
|
||||
overflow: auto;
|
||||
}
|
||||
.viewport :global(.cm-editor) {
|
||||
background: light-dark(var(--mantine-color-white), var(--mantine-color-dark-7));
|
||||
color: light-dark(var(--mantine-color-black), var(--mantine-color-gray-0));
|
||||
background: var(--mantine-color-body);
|
||||
color: var(--mantine-color-text);
|
||||
font-family: "Fira Code", "SFMono-Regular", Consolas, monospace;
|
||||
font-size: 12px;
|
||||
}
|
||||
@@ -42,7 +42,7 @@
|
||||
overflow: auto;
|
||||
}
|
||||
.viewport :global(.cm-gutters) {
|
||||
background: light-dark(var(--mantine-color-gray-0), var(--mantine-color-dark-8));
|
||||
background: var(--mantine-color-default-hover);
|
||||
border-color: light-dark(var(--mantine-color-gray-3), var(--mantine-color-dark-5));
|
||||
}
|
||||
.diagnosticButton {
|
||||
|
||||
@@ -2,7 +2,7 @@ import { describe, expect, test } from "vitest";
|
||||
|
||||
import { CUSTOM_WIDGET_STARTER } from "@homarr/custom-widgets/core";
|
||||
|
||||
import { validateWorkshopWidget, workshopSubmissionInputSchema } from "./schema";
|
||||
import { validateWorkshopWidget, workshopSubmissionInputSchema, workshopSubmissionSummarySchema } from "./schema";
|
||||
|
||||
describe("Workshop widget validation", () => {
|
||||
test("accepts a canonical credential-free widget", () => {
|
||||
@@ -55,6 +55,25 @@ describe("Workshop widget validation", () => {
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
test("normalizes PocketBase records before rendering", () => {
|
||||
const result = workshopSubmissionSummarySchema.parse({
|
||||
id: "jzyxskmimxn42io",
|
||||
title: "Tautulli Activity",
|
||||
description: "Shows active Plex streams from Tautulli",
|
||||
widgetSchema: "homarr-custom-widget-v2",
|
||||
screenshots: "[]",
|
||||
author: "wsqylmotymtqpox",
|
||||
revision: 1,
|
||||
created: "2026-07-21 14:19:25.790Z",
|
||||
updated: "2026-07-21 14:19:25.790Z",
|
||||
});
|
||||
|
||||
expect(result.type).toBe("customWidget");
|
||||
expect(result.screenshots).toEqual([]);
|
||||
expect(result.authorName).toBe("Community member");
|
||||
expect(result.upvotes).toBe(0);
|
||||
});
|
||||
|
||||
test("rejects literal credentials in templates and icon URLs", () => {
|
||||
expect(
|
||||
validateWorkshopWidget(
|
||||
|
||||
@@ -36,7 +36,17 @@ export const workshopUserSchema = z.object({
|
||||
});
|
||||
export type WorkshopUser = z.infer<typeof workshopUserSchema>;
|
||||
|
||||
const workshopSubmissionBaseSchema = z.object({
|
||||
const normalizeWorkshopSubmissionRecord = (value: unknown) => {
|
||||
if (typeof value !== "object" || value === null || Array.isArray(value)) return value;
|
||||
const record = value as Record<string, unknown>;
|
||||
if (record.type === "customWidget" || record.type === "customCss") return value;
|
||||
if (record.widgetSchema === WORKSHOP_CSS_SCHEMA) return { ...record, type: "customCss" };
|
||||
if (typeof record.widgetSchema === "string" && record.widgetSchema.startsWith("homarr-custom-widget-"))
|
||||
return { ...record, type: "customWidget" };
|
||||
return value;
|
||||
};
|
||||
|
||||
const workshopSubmissionBaseObjectSchema = z.object({
|
||||
id: z.string(),
|
||||
type: workshopSubmissionTypeSchema,
|
||||
title: z.string(),
|
||||
@@ -44,13 +54,13 @@ const workshopSubmissionBaseSchema = z.object({
|
||||
widgetSchema: z.string(),
|
||||
screenshots: workshopFileListSchema,
|
||||
author: z.string(),
|
||||
authorName: z.string(),
|
||||
authorName: z.string().default("Community member"),
|
||||
authorAvatarUrl: z.string().default(""),
|
||||
authorGithubUsername: z.string().default(""),
|
||||
authorGithubProfileUrl: z.string().default(""),
|
||||
score: z.number().int(),
|
||||
upvotes: z.number().int(),
|
||||
downvotes: z.number().int(),
|
||||
score: z.number().int().default(0),
|
||||
upvotes: z.number().int().default(0),
|
||||
downvotes: z.number().int().default(0),
|
||||
commentCount: z.number().int().default(0),
|
||||
reportCount: z.number().int().default(0),
|
||||
revision: z.number().int().positive().default(1),
|
||||
@@ -60,9 +70,15 @@ const workshopSubmissionBaseSchema = z.object({
|
||||
updated: z.string(),
|
||||
});
|
||||
|
||||
export const workshopSubmissionSummarySchema = workshopSubmissionBaseSchema;
|
||||
export const workshopSubmissionSummarySchema = z.preprocess(
|
||||
normalizeWorkshopSubmissionRecord,
|
||||
workshopSubmissionBaseObjectSchema,
|
||||
);
|
||||
export type WorkshopSubmissionSummary = z.infer<typeof workshopSubmissionSummarySchema>;
|
||||
export const workshopSubmissionDetailSchema = workshopSubmissionBaseSchema.extend({ content: z.string() });
|
||||
export const workshopSubmissionDetailSchema = z.preprocess(
|
||||
normalizeWorkshopSubmissionRecord,
|
||||
workshopSubmissionBaseObjectSchema.extend({ content: z.string() }),
|
||||
);
|
||||
export type WorkshopSubmissionDetail = z.infer<typeof workshopSubmissionDetailSchema>;
|
||||
|
||||
export const workshopSubmissionInputSchema = z
|
||||
|
||||
Reference in new issue
Block a user