feat(custom-widgets): complete Custom JSX v2 (#6390)
This commit is contained in:
1 parent
60cbd7bb7a
commit
6aceb2fd1f
450 files changed
+39817
-20588
No files matched your search
@@ -0,0 +1,357 @@
|
||||
import React, { useCallback, useEffect, useState } from "react";
|
||||
import { IconCheck, IconEdit, IconMessageCircle, IconRefresh, IconSend, IconTrash, IconX } from "@tabler/icons-react";
|
||||
|
||||
import type { WorkshopComment } from "@site/src/lib/pocketbase";
|
||||
import { getWorkshopBackend } from "@site/src/lib/pocketbase";
|
||||
import type { WorkshopUser } from "@homarr/workshop/schema";
|
||||
import { errorMessage } from "@site/src/lib/utils";
|
||||
|
||||
import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert";
|
||||
import {
|
||||
AlertDialog,
|
||||
AlertDialogAction,
|
||||
AlertDialogCancel,
|
||||
AlertDialogContent,
|
||||
AlertDialogDescription,
|
||||
AlertDialogFooter,
|
||||
AlertDialogHeader,
|
||||
AlertDialogTitle,
|
||||
} from "@/components/ui/alert-dialog";
|
||||
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
|
||||
import { formatRelativeTime } from "./format";
|
||||
|
||||
interface CommentsSectionProps {
|
||||
submissionId: string;
|
||||
backend: ReturnType<typeof getWorkshopBackend>;
|
||||
currentUser: WorkshopUser | null;
|
||||
onRequireAuth: (action: string) => Promise<string | null>;
|
||||
}
|
||||
|
||||
const avatarFallback = (name: string) => name.trim().slice(0, 1).toUpperCase() || "?";
|
||||
|
||||
export const CommentsSection = ({ submissionId, backend, currentUser, onRequireAuth }: CommentsSectionProps) => {
|
||||
const [rows, setRows] = useState<WorkshopComment[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [fetchError, setFetchError] = useState<string | null>(null);
|
||||
const [reloadKey, setReloadKey] = useState(0);
|
||||
const [newComment, setNewComment] = useState("");
|
||||
const [editingId, setEditingId] = useState<string | null>(null);
|
||||
const [editContent, setEditContent] = useState("");
|
||||
const [mutationError, setMutationError] = useState<string | null>(null);
|
||||
const [confirmDeleteId, setConfirmDeleteId] = useState<string | null>(null);
|
||||
|
||||
const loadComments = useCallback(async () => {
|
||||
setLoading(true);
|
||||
setFetchError(null);
|
||||
try {
|
||||
setRows(await backend.listComments(submissionId));
|
||||
} catch (caught) {
|
||||
const message = errorMessage(caught, "Failed to load comments");
|
||||
setFetchError(
|
||||
/collection context/iu.test(message)
|
||||
? "Comments are unavailable because this Workshop database has not applied the current comments migration."
|
||||
: message,
|
||||
);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [backend, submissionId]);
|
||||
|
||||
useEffect(() => {
|
||||
void loadComments();
|
||||
}, [loadComments, reloadKey]);
|
||||
|
||||
const handleAdd = async () => {
|
||||
const trimmed = newComment.trim();
|
||||
if (!trimmed || !(await onRequireAuth("comment"))) return;
|
||||
try {
|
||||
setMutationError(null);
|
||||
const created = await backend.createComment(submissionId, trimmed);
|
||||
setRows((previous) => [...previous, created]);
|
||||
setNewComment("");
|
||||
} catch (caught) {
|
||||
setMutationError(errorMessage(caught, "Failed to post comment"));
|
||||
}
|
||||
};
|
||||
|
||||
const handleUpdate = async (id: string) => {
|
||||
const trimmed = editContent.trim();
|
||||
if (!trimmed) return;
|
||||
try {
|
||||
setMutationError(null);
|
||||
const updated = await backend.updateComment(id, trimmed);
|
||||
setRows((previous) => previous.map((comment) => (comment.id === id ? updated : comment)));
|
||||
setEditingId(null);
|
||||
} catch (caught) {
|
||||
setMutationError(errorMessage(caught, "Failed to update comment"));
|
||||
}
|
||||
};
|
||||
|
||||
const handleDelete = async (id: string) => {
|
||||
try {
|
||||
setMutationError(null);
|
||||
await backend.deleteComment(id);
|
||||
setRows((previous) => previous.filter((comment) => comment.id !== id));
|
||||
setConfirmDeleteId(null);
|
||||
} catch (caught) {
|
||||
setMutationError(errorMessage(caught, "Failed to delete comment"));
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<section aria-labelledby="workshop-comments-heading" className="space-y-5">
|
||||
<div className="flex items-center justify-between gap-4">
|
||||
<div>
|
||||
<div className="flex items-center gap-2">
|
||||
<IconMessageCircle size={18} className="text-muted-foreground" />
|
||||
<h2 id="workshop-comments-heading" className="text-lg font-semibold">
|
||||
Discussion
|
||||
</h2>
|
||||
{!loading && <Badge variant="secondary">{rows.length}</Badge>}
|
||||
</div>
|
||||
<p className="m-0 mt-1 text-sm text-muted-foreground">Questions, fixes, and feedback from the community.</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{loading && (
|
||||
<div className="space-y-4" aria-label="Loading comments">
|
||||
<div className="h-20 w-3/4 animate-pulse rounded-xl bg-muted" />
|
||||
<div className="ml-auto h-20 w-2/3 animate-pulse rounded-xl bg-primary/10" />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!loading && fetchError && (
|
||||
<Alert variant="destructive">
|
||||
<IconMessageCircle />
|
||||
<AlertTitle>Comments could not be loaded</AlertTitle>
|
||||
<AlertDescription>{fetchError}</AlertDescription>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="col-start-2 mt-2 w-fit text-destructive hover:bg-destructive/10"
|
||||
onClick={() => setReloadKey((value) => value + 1)}
|
||||
>
|
||||
<IconRefresh size={14} /> Retry comments
|
||||
</Button>
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
{!loading && !fetchError && rows.length === 0 && (
|
||||
<div className="rounded-xl bg-muted/40 px-4 py-8 text-center">
|
||||
<IconMessageCircle size={24} className="mx-auto text-muted-foreground" />
|
||||
<p className="m-0 mt-2 text-sm font-medium">Start the discussion</p>
|
||||
<p className="m-0 mt-1 text-sm text-muted-foreground">
|
||||
Share a question or something you learned using this submission.
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!loading && !fetchError && rows.length > 0 && (
|
||||
<div className="space-y-4">
|
||||
{rows.map((comment) => {
|
||||
const ownComment = currentUser?.id === comment.author;
|
||||
const canDelete = ownComment || currentUser?.isAdmin === true;
|
||||
const isEditing = editingId === comment.id;
|
||||
const edited = comment.updated !== comment.created;
|
||||
const authorName = ownComment ? (currentUser?.displayName ?? comment.authorName) : comment.authorName;
|
||||
const avatarUrl = ownComment
|
||||
? (currentUser?.avatarUrl ?? comment.authorAvatarUrl)
|
||||
: comment.authorAvatarUrl;
|
||||
const profileUrl = ownComment
|
||||
? (currentUser?.githubProfileUrl ?? comment.authorGithubProfileUrl)
|
||||
: comment.authorGithubProfileUrl;
|
||||
|
||||
return (
|
||||
<article
|
||||
key={comment.id}
|
||||
className={ownComment ? "ml-auto w-full max-w-[44rem]" : "w-full max-w-[44rem]"}
|
||||
>
|
||||
<div className={ownComment ? "flex flex-row-reverse items-start gap-3" : "flex items-start gap-3"}>
|
||||
<a
|
||||
href={profileUrl || undefined}
|
||||
target={profileUrl ? "_blank" : undefined}
|
||||
rel="noreferrer"
|
||||
aria-label={profileUrl ? `Open ${authorName}'s GitHub profile` : undefined}
|
||||
className="mt-0.5 shrink-0 rounded-full outline-none focus-visible:ring-3 focus-visible:ring-ring/50"
|
||||
>
|
||||
<Avatar className="size-9 ring-1 ring-border">
|
||||
{avatarUrl && <AvatarImage src={avatarUrl} alt="" />}
|
||||
<AvatarFallback>{avatarFallback(authorName)}</AvatarFallback>
|
||||
</Avatar>
|
||||
</a>
|
||||
|
||||
<div className={ownComment ? "flex min-w-0 flex-1 flex-col items-end" : "min-w-0 flex-1"}>
|
||||
<div
|
||||
className={
|
||||
ownComment
|
||||
? "mb-1.5 flex flex-wrap items-center justify-end gap-x-2 px-1"
|
||||
: "mb-1.5 flex flex-wrap items-center gap-x-2 px-1"
|
||||
}
|
||||
>
|
||||
<a
|
||||
href={profileUrl || undefined}
|
||||
target={profileUrl ? "_blank" : undefined}
|
||||
rel="noreferrer"
|
||||
className="truncate text-sm font-semibold text-primary hover:underline"
|
||||
>
|
||||
{authorName}
|
||||
</a>
|
||||
{ownComment && (
|
||||
<Badge variant="secondary" className="h-5 px-1.5 text-[0.6875rem]">
|
||||
You
|
||||
</Badge>
|
||||
)}
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{formatRelativeTime(comment.created)}
|
||||
{edited && " · edited"}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div
|
||||
className={
|
||||
ownComment
|
||||
? "w-fit max-w-full rounded-xl rounded-tr-sm bg-primary px-3.5 py-2.5 text-primary-foreground"
|
||||
: "w-fit max-w-full rounded-xl rounded-tl-sm border border-border/70 bg-muted/60 px-3.5 py-2.5 text-foreground"
|
||||
}
|
||||
>
|
||||
{isEditing ? (
|
||||
<div className="flex items-center gap-1.5">
|
||||
<Input
|
||||
className="min-w-48 bg-background text-foreground"
|
||||
aria-label="Edit comment"
|
||||
value={editContent}
|
||||
onChange={(event) => setEditContent(event.target.value)}
|
||||
onKeyDown={(event) => {
|
||||
if (event.key === "Enter") void handleUpdate(comment.id);
|
||||
}}
|
||||
/>
|
||||
<Button
|
||||
size="icon-sm"
|
||||
variant="secondary"
|
||||
aria-label="Save comment"
|
||||
onClick={() => void handleUpdate(comment.id)}
|
||||
>
|
||||
<IconCheck size={12} />
|
||||
</Button>
|
||||
<Button
|
||||
size="icon-sm"
|
||||
variant="ghost"
|
||||
aria-label="Cancel editing"
|
||||
onClick={() => setEditingId(null)}
|
||||
>
|
||||
<IconX size={12} />
|
||||
</Button>
|
||||
</div>
|
||||
) : (
|
||||
<p className="m-0 break-words whitespace-pre-wrap text-[0.9375rem] leading-6">
|
||||
{comment.content}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{(ownComment || canDelete) && !isEditing && (
|
||||
<div className={ownComment ? "mt-1 flex justify-end gap-1" : "mt-1 flex gap-1"}>
|
||||
{ownComment && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="xs"
|
||||
className="text-muted-foreground"
|
||||
onClick={() => {
|
||||
setEditingId(comment.id);
|
||||
setEditContent(comment.content);
|
||||
}}
|
||||
>
|
||||
<IconEdit /> Edit
|
||||
</Button>
|
||||
)}
|
||||
{canDelete && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="xs"
|
||||
className="text-muted-foreground hover:bg-destructive/10 hover:text-destructive"
|
||||
onClick={() => setConfirmDeleteId(comment.id)}
|
||||
>
|
||||
<IconTrash /> Delete
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</article>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="rounded-xl border border-border bg-muted/30 p-3">
|
||||
<div className="flex items-start gap-2.5">
|
||||
<Avatar className="mt-0.5 size-9 bg-background ring-1 ring-border">
|
||||
{currentUser?.avatarUrl && <AvatarImage src={currentUser.avatarUrl} alt="" />}
|
||||
<AvatarFallback className="bg-background">
|
||||
{avatarFallback(currentUser?.displayName ?? "Guest")}
|
||||
</AvatarFallback>
|
||||
</Avatar>
|
||||
<Textarea
|
||||
className="min-h-16 flex-1 resize-none bg-background"
|
||||
placeholder={currentUser ? "Add to the discussion…" : "Write a comment, you will be asked to sign in…"}
|
||||
aria-label="Write a comment"
|
||||
value={newComment}
|
||||
maxLength={2000}
|
||||
onChange={(event) => setNewComment(event.target.value)}
|
||||
onKeyDown={(event) => {
|
||||
if ((event.metaKey || event.ctrlKey) && event.key === "Enter") void handleAdd();
|
||||
}}
|
||||
/>
|
||||
<Button
|
||||
size="icon-lg"
|
||||
className="self-end"
|
||||
disabled={!newComment.trim()}
|
||||
onClick={() => void handleAdd()}
|
||||
aria-label="Post comment"
|
||||
>
|
||||
<IconSend size={15} />
|
||||
</Button>
|
||||
</div>
|
||||
<p className="m-0 mt-2 pl-12 text-xs text-muted-foreground">Press Ctrl or ⌘ + Enter to post.</p>
|
||||
{mutationError && (
|
||||
<Alert variant="destructive" className="mt-3">
|
||||
<IconMessageCircle />
|
||||
<AlertTitle>Comment was not saved</AlertTitle>
|
||||
<AlertDescription>{mutationError}</AlertDescription>
|
||||
</Alert>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<AlertDialog
|
||||
open={Boolean(confirmDeleteId)}
|
||||
onOpenChange={(open) => {
|
||||
if (!open) setConfirmDeleteId(null);
|
||||
}}
|
||||
>
|
||||
<AlertDialogContent size="sm">
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>Delete this comment?</AlertDialogTitle>
|
||||
<AlertDialogDescription>This cannot be undone.</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel>Cancel</AlertDialogCancel>
|
||||
<AlertDialogAction
|
||||
variant="destructive"
|
||||
onClick={() => {
|
||||
if (confirmDeleteId) void handleDelete(confirmDeleteId);
|
||||
}}
|
||||
>
|
||||
Delete comment
|
||||
</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
</section>
|
||||
);
|
||||
};
|
||||
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,157 @@
|
||||
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 {
|
||||
AlertDialog,
|
||||
AlertDialogAction,
|
||||
AlertDialogCancel,
|
||||
AlertDialogContent,
|
||||
AlertDialogDescription,
|
||||
AlertDialogFooter,
|
||||
AlertDialogHeader,
|
||||
AlertDialogMedia,
|
||||
AlertDialogTitle,
|
||||
AlertDialogTrigger,
|
||||
} from "@/components/ui/alert-dialog";
|
||||
import { Skeleton } from "@/components/ui/skeleton";
|
||||
import { cn } from "@site/src/lib/utils";
|
||||
|
||||
const prismThemes = { light: themes.github, dark: themes.dracula } as const;
|
||||
const colorModeKeys = { dark: "dark", light: "light" } as const;
|
||||
|
||||
export const DetailSkeleton = () => (
|
||||
<div className="mx-auto max-w-[90rem] space-y-6 px-4 py-8 sm:px-6 lg:px-8">
|
||||
<Skeleton className="h-4 w-24" />
|
||||
<div className="grid gap-10 xl:grid-cols-[minmax(0,1fr)_22rem]">
|
||||
<div className="space-y-6">
|
||||
<div className="space-y-2">
|
||||
<Skeleton className="h-9 w-2/3" />
|
||||
<Skeleton className="h-4 w-2/5" />
|
||||
</div>
|
||||
<Skeleton className="aspect-video w-full rounded-xl" />
|
||||
<Skeleton className="h-96 rounded-xl" />
|
||||
</div>
|
||||
<div className="space-y-5">
|
||||
<Skeleton className="h-40 rounded-xl" />
|
||||
<Skeleton className="h-72 rounded-xl" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
export const ScreenshotGallery = ({ urls, title }: { urls: string[]; title: string }) => {
|
||||
const [idx, setIdx] = useState(0);
|
||||
|
||||
return (
|
||||
<div className="group/gallery w-full">
|
||||
<div className="relative aspect-video overflow-hidden rounded-lg border border-border bg-muted">
|
||||
<img className="h-full w-full object-contain" src={urls[idx]} alt={`${title} screenshot ${idx + 1}`} />
|
||||
{urls.length > 1 && (
|
||||
<>
|
||||
<button
|
||||
type="button"
|
||||
className="absolute left-3 top-1/2 flex size-11 -translate-y-1/2 items-center justify-center rounded-lg bg-background/85 opacity-80 shadow-sm transition-opacity hover:opacity-100 focus-visible:ring-2 focus-visible:ring-ring sm:size-9"
|
||||
onClick={() => setIdx((i) => (i - 1 + urls.length) % urls.length)}
|
||||
aria-label="Previous screenshot"
|
||||
>
|
||||
<IconChevronLeft size={16} />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="absolute right-3 top-1/2 flex size-11 -translate-y-1/2 items-center justify-center rounded-lg bg-background/85 opacity-80 shadow-sm transition-opacity hover:opacity-100 focus-visible:ring-2 focus-visible:ring-ring sm:size-9"
|
||||
onClick={() => setIdx((i) => (i + 1) % urls.length)}
|
||||
aria-label="Next screenshot"
|
||||
>
|
||||
<IconChevronRight size={16} />
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
{urls.length > 1 && (
|
||||
<fieldset className="mt-3 min-w-0">
|
||||
<legend className="sr-only">Screenshot previews</legend>
|
||||
<div className="flex gap-2 overflow-x-auto pb-1">
|
||||
{urls.map((url, i) => {
|
||||
const selected = i === idx;
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
key={url}
|
||||
onClick={() => setIdx(i)}
|
||||
aria-label={`Show screenshot ${i + 1}`}
|
||||
aria-pressed={selected}
|
||||
className={cn(
|
||||
"aspect-video w-24 shrink-0 overflow-hidden rounded-md border-2 bg-muted transition-[border-color,opacity,box-shadow] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 sm:w-28",
|
||||
selected
|
||||
? "border-primary opacity-100 ring-2 ring-primary/20"
|
||||
: "border-border opacity-65 hover:border-muted-foreground/60 hover:opacity-100",
|
||||
)}
|
||||
>
|
||||
<img className="h-full w-full object-cover" src={url} alt="" loading={selected ? "eager" : "lazy"} />
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</fieldset>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export const CodeBlock = ({ content, language }: { content: string; language: string }) => {
|
||||
const { colorMode } = useColorMode();
|
||||
const theme = prismThemes[colorModeKeys[colorMode as keyof typeof colorModeKeys] ?? "light"];
|
||||
|
||||
return (
|
||||
<Highlight theme={theme} code={content} language={language}>
|
||||
{({ className, style, tokens, getLineProps, getTokenProps }) => (
|
||||
<pre
|
||||
className={cn(className, "overflow-auto rounded-lg border border-border p-4 text-sm leading-relaxed")}
|
||||
style={style}
|
||||
>
|
||||
{tokens.map((line, i) => (
|
||||
<div key={i} {...getLineProps({ line })}>
|
||||
{line.map((token, key) => (
|
||||
<span key={key} {...getTokenProps({ token })} />
|
||||
))}
|
||||
</div>
|
||||
))}
|
||||
</pre>
|
||||
)}
|
||||
</Highlight>
|
||||
);
|
||||
};
|
||||
|
||||
export const DeleteConfirmButton = ({ onConfirm, className }: { onConfirm: () => void; className?: string }) => {
|
||||
return (
|
||||
<AlertDialog>
|
||||
<AlertDialogTrigger
|
||||
render={
|
||||
<Button variant="outline" size="sm" className={cn("text-destructive hover:bg-destructive/10", className)} />
|
||||
}
|
||||
>
|
||||
<IconTrash size={14} /> Delete
|
||||
</AlertDialogTrigger>
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogMedia className="bg-destructive/10 text-destructive">
|
||||
<IconTrash />
|
||||
</AlertDialogMedia>
|
||||
<AlertDialogTitle>Delete submission?</AlertDialogTitle>
|
||||
<AlertDialogDescription>
|
||||
This permanently removes the Workshop listing, screenshots, votes, reports, and comments.
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel>Cancel</AlertDialogCancel>
|
||||
<AlertDialogAction variant="destructive" onClick={onConfirm}>
|
||||
Delete submission
|
||||
</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,125 @@
|
||||
import { useState } from "react";
|
||||
import { IconPhoto, IconUpload, IconX } from "@tabler/icons-react";
|
||||
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
export interface ScreenshotEditorItem {
|
||||
id: string;
|
||||
src: string;
|
||||
badge?: string;
|
||||
}
|
||||
|
||||
interface ScreenshotEditorProps {
|
||||
items: ScreenshotEditorItem[];
|
||||
onAdd: (files: FileList | File[]) => void;
|
||||
onRemove: (id: string) => void;
|
||||
disabled?: boolean;
|
||||
description?: string;
|
||||
max?: number;
|
||||
}
|
||||
|
||||
const dropState = {
|
||||
idle: { Icon: IconUpload, title: "Choose or drop images", iconClass: "bg-muted text-muted-foreground" },
|
||||
active: { Icon: IconPhoto, title: "Drop images here", iconClass: "bg-primary text-primary-foreground" },
|
||||
} as const;
|
||||
|
||||
export const ScreenshotEditor = ({
|
||||
items,
|
||||
onAdd,
|
||||
onRemove,
|
||||
disabled = false,
|
||||
description = "Add screenshots to help others preview your submission.",
|
||||
max = 5,
|
||||
}: ScreenshotEditorProps) => {
|
||||
const [dragOver, setDragOver] = useState(false);
|
||||
const full = items.length >= max;
|
||||
const { Icon, title, iconClass } = dropState[dragOver ? "active" : "idle"];
|
||||
|
||||
return (
|
||||
<section className="flex flex-col gap-3" aria-labelledby="workshop-screenshots-heading">
|
||||
<div className="flex items-start justify-between gap-4">
|
||||
<div>
|
||||
<h3 id="workshop-screenshots-heading" className="text-sm font-medium">
|
||||
Screenshots
|
||||
</h3>
|
||||
<p className="mt-0.5 text-xs text-muted-foreground">{description}</p>
|
||||
</div>
|
||||
<span className="shrink-0 text-xs tabular-nums text-muted-foreground">
|
||||
{items.length}/{max}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{items.length > 0 && (
|
||||
<div className="grid grid-cols-2 gap-2 sm:grid-cols-3 lg:grid-cols-5">
|
||||
{items.map((item, index) => (
|
||||
<div
|
||||
key={item.id}
|
||||
className="group relative aspect-[16/10] overflow-hidden rounded-lg border border-border bg-muted"
|
||||
>
|
||||
<img src={item.src} alt={`Screenshot ${index + 1}`} className="size-full object-cover" />
|
||||
{item.badge && (
|
||||
<span className="absolute bottom-1.5 left-1.5 rounded-md bg-background/90 px-1.5 py-0.5 text-[10px] font-medium text-foreground shadow-sm backdrop-blur-sm">
|
||||
{item.badge}
|
||||
</span>
|
||||
)}
|
||||
<Button
|
||||
type="button"
|
||||
variant="destructive"
|
||||
size="icon-sm"
|
||||
disabled={disabled}
|
||||
onClick={() => onRemove(item.id)}
|
||||
className="absolute top-1.5 right-1.5 opacity-90 shadow-sm transition-opacity group-hover:opacity-100"
|
||||
aria-label={`Remove screenshot ${index + 1}`}
|
||||
>
|
||||
<IconX size={14} />
|
||||
</Button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!full && (
|
||||
// oxlint-disable-next-line jsx-a11y/no-noninteractive-element-interactions
|
||||
<label
|
||||
onDragOver={(event) => {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
if (!disabled) setDragOver(true);
|
||||
}}
|
||||
onDragLeave={() => setDragOver(false)}
|
||||
onDrop={(event) => {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
setDragOver(false);
|
||||
if (!disabled) onAdd(event.dataTransfer.files);
|
||||
}}
|
||||
className={cn(
|
||||
"flex cursor-pointer items-center gap-3 rounded-lg border border-dashed border-border px-4 py-3 transition-colors hover:border-primary/50 hover:bg-primary/5",
|
||||
dragOver && "border-primary bg-primary/10",
|
||||
disabled && "pointer-events-none opacity-50",
|
||||
)}
|
||||
>
|
||||
<span className={cn("flex size-9 shrink-0 items-center justify-center rounded-lg", iconClass)}>
|
||||
<Icon size={18} />
|
||||
</span>
|
||||
<span className="min-w-0">
|
||||
<span className="block text-sm font-medium">{title}</span>
|
||||
<span className="block text-xs text-muted-foreground">PNG, JPG, or WebP · up to 5 MB each</span>
|
||||
</span>
|
||||
<input
|
||||
type="file"
|
||||
accept="image/png,image/jpeg,image/webp"
|
||||
multiple
|
||||
disabled={disabled}
|
||||
className="hidden"
|
||||
onChange={(event) => {
|
||||
if (event.target.files) onAdd(event.target.files);
|
||||
event.currentTarget.value = "";
|
||||
}}
|
||||
/>
|
||||
</label>
|
||||
)}
|
||||
</section>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,497 @@
|
||||
import React, { useCallback, useEffect, useRef, useState } from "react";
|
||||
import {
|
||||
IconAlertCircle,
|
||||
IconBraces,
|
||||
IconCheck,
|
||||
IconChevronLeft,
|
||||
IconChevronRight,
|
||||
IconFileUpload,
|
||||
IconLoader2,
|
||||
IconPalette,
|
||||
} from "@tabler/icons-react";
|
||||
|
||||
import type { SubmissionType } from "@site/src/lib/workshop-schema";
|
||||
import { validateSubmissionContent } from "@site/src/lib/workshop-schema";
|
||||
|
||||
import { CustomWidgetCodeInput } from "@/components/custom-widget-code";
|
||||
|
||||
import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@/components/ui/dialog";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
import { cn, errorMessage } from "@/lib/utils";
|
||||
|
||||
import type { SubmitInput } from "./useWorkshop";
|
||||
import { ScreenshotEditor } from "./ScreenshotEditor";
|
||||
|
||||
interface Props {
|
||||
onClose: () => void;
|
||||
onSubmit: (input: SubmitInput) => Promise<void>;
|
||||
}
|
||||
|
||||
const steps = ["Type", "Details", "Media"] as const;
|
||||
const navDirection = [-1, 1];
|
||||
const stepAnimClass = ["submit-step-from-left", "submit-step-from-right"];
|
||||
const backLabels = ["Cancel", "Back"];
|
||||
const submitLabels = ["Publish submission", "Publishing…"];
|
||||
const connectorClass = ["bg-border", "bg-primary"];
|
||||
const dropOverlayClass = ["pointer-events-none opacity-0", "opacity-100"];
|
||||
|
||||
const placeholders: Record<SubmissionType, string> = {
|
||||
customWidget: '{\n "$schema": "homarr-custom-widget-v2",\n "name": "My widget",\n ...\n}',
|
||||
customCss: ".grid-stack-item-content {\n border-radius: 16px;\n}",
|
||||
};
|
||||
const contentLabels: Record<SubmissionType, string> = {
|
||||
customWidget: "Widget JSON (homarr-custom-widget-v2)",
|
||||
customCss: "Custom CSS",
|
||||
};
|
||||
|
||||
const parseJsonObject = (json: string): Record<string, unknown> | null => {
|
||||
try {
|
||||
const parsed = JSON.parse(json);
|
||||
if (typeof parsed === "object" && parsed !== null) return parsed;
|
||||
} catch {
|
||||
/* invalid json */
|
||||
}
|
||||
return null;
|
||||
};
|
||||
|
||||
export const SubmitForm = ({ onClose, onSubmit }: Props) => {
|
||||
const [step, setStep] = useState(0);
|
||||
const [direction, setDirection] = useState(1);
|
||||
const [type, setType] = useState<SubmissionType | null>(null);
|
||||
const [title, setTitle] = useState("");
|
||||
const [description, setDescription] = useState("");
|
||||
const [changelog, setChangelog] = useState("Initial publication");
|
||||
const [content, setContent] = useState("");
|
||||
const [screenshots, setScreenshots] = useState<File[]>([]);
|
||||
const [previews, setPreviews] = useState<string[]>([]);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [pending, setPending] = useState(false);
|
||||
const [jsonDropHint, setJsonDropHint] = useState(false);
|
||||
const previewUrlsRef = useRef<Set<string>>(new Set());
|
||||
|
||||
const canAdvance = [type !== null, title.trim().length >= 3 && content.trim().length > 0, true];
|
||||
|
||||
const goTo = useCallback(
|
||||
(next: number) => {
|
||||
setDirection(navDirection[Number(next > step)]);
|
||||
setStep(next);
|
||||
},
|
||||
[step],
|
||||
);
|
||||
|
||||
const setContentAndAutofill = useCallback((json: string, currentTitle: string, currentDesc: string) => {
|
||||
setContent(json);
|
||||
const meta = parseJsonObject(json);
|
||||
if (!meta) return;
|
||||
if (typeof meta.name === "string" && !currentTitle) setTitle(meta.name);
|
||||
if (typeof meta.description === "string" && !currentDesc) setDescription(String(meta.description));
|
||||
}, []);
|
||||
|
||||
const handleJsonFileDrop = useCallback(
|
||||
(files: FileList | File[]) => {
|
||||
const jsonFile = Array.from(files).find((f) => f.name.endsWith(".json") || f.type === "application/json");
|
||||
if (!jsonFile) return false;
|
||||
const reader = new FileReader();
|
||||
reader.addEventListener("load", (e) => {
|
||||
const text = e.target?.result as string;
|
||||
if (!type) setType("customWidget");
|
||||
setContentAndAutofill(text, title, description);
|
||||
if (step === 0) goTo(1);
|
||||
});
|
||||
reader.addEventListener("error", () => setError("Could not read the file"));
|
||||
reader.readAsText(jsonFile);
|
||||
return true;
|
||||
},
|
||||
[type, step, title, description, setContentAndAutofill, goTo],
|
||||
);
|
||||
|
||||
const addImageFiles = useCallback(
|
||||
(files: FileList | File[]) => {
|
||||
const incoming = Array.from(files)
|
||||
.filter((f) => f.type.startsWith("image/"))
|
||||
.slice(0, 5 - screenshots.length);
|
||||
if (incoming.length === 0) return;
|
||||
setScreenshots((prev) => [...prev, ...incoming].slice(0, 5));
|
||||
const urls = incoming.map((f) => {
|
||||
const url = URL.createObjectURL(f);
|
||||
previewUrlsRef.current.add(url);
|
||||
return url;
|
||||
});
|
||||
setPreviews((prev) => [...prev, ...urls].slice(0, 5));
|
||||
},
|
||||
[screenshots.length],
|
||||
);
|
||||
|
||||
const removeScreenshot = (idx: number) => {
|
||||
const url = previews[idx];
|
||||
if (url) {
|
||||
previewUrlsRef.current.delete(url);
|
||||
URL.revokeObjectURL(url);
|
||||
}
|
||||
setScreenshots((prev) => prev.filter((_, i) => i !== idx));
|
||||
setPreviews((prev) => prev.filter((_, i) => i !== idx));
|
||||
};
|
||||
|
||||
useEffect(
|
||||
() => () => {
|
||||
previewUrlsRef.current.forEach(URL.revokeObjectURL);
|
||||
previewUrlsRef.current.clear();
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
const handleGlobalDrop = useCallback(
|
||||
(e: React.DragEvent) => {
|
||||
e.preventDefault();
|
||||
setJsonDropHint(false);
|
||||
if (!handleJsonFileDrop(e.dataTransfer.files) && step === 2) addImageFiles(e.dataTransfer.files);
|
||||
},
|
||||
[handleJsonFileDrop, step, addImageFiles],
|
||||
);
|
||||
|
||||
const handleSubmit = async () => {
|
||||
if (!type) return;
|
||||
const validation = validateSubmissionContent(type, content);
|
||||
if (!validation.success) {
|
||||
setError(validation.error);
|
||||
goTo(1);
|
||||
return;
|
||||
}
|
||||
setError(null);
|
||||
setPending(true);
|
||||
try {
|
||||
await onSubmit({ type, title, description, changelog, content, screenshots });
|
||||
} catch (caught) {
|
||||
setError(errorMessage(caught, "Submission failed"));
|
||||
} finally {
|
||||
setPending(false);
|
||||
}
|
||||
};
|
||||
|
||||
const onContentChange = (value: string) => {
|
||||
if (type === "customWidget") setContentAndAutofill(value, title, description);
|
||||
else setContent(value);
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog
|
||||
open
|
||||
onOpenChange={(open) => {
|
||||
if (!open && !pending) onClose();
|
||||
}}
|
||||
>
|
||||
{/* oxlint-disable-next-line jsx-a11y/no-noninteractive-element-interactions */}
|
||||
<DialogContent
|
||||
className="max-h-[92vh] overflow-y-auto sm:max-w-5xl"
|
||||
showCloseButton={!pending}
|
||||
onDragOver={(e: React.DragEvent) => {
|
||||
e.preventDefault();
|
||||
setJsonDropHint(true);
|
||||
}}
|
||||
onDragLeave={() => setJsonDropHint(false)}
|
||||
onDrop={handleGlobalDrop}
|
||||
>
|
||||
<div
|
||||
aria-hidden={!jsonDropHint}
|
||||
className={cn(
|
||||
"absolute inset-0 z-50 flex flex-col items-center justify-center gap-3 rounded-xl bg-primary/10 backdrop-blur-sm transition-opacity duration-200",
|
||||
dropOverlayClass[Number(jsonDropHint)],
|
||||
)}
|
||||
>
|
||||
<div className="flex size-14 items-center justify-center rounded-2xl bg-primary text-primary-foreground">
|
||||
<IconFileUpload size={28} />
|
||||
</div>
|
||||
<p className="text-sm font-semibold">Drop JSON file to import widget</p>
|
||||
<p className="text-xs text-muted-foreground">Title and description will be auto-filled</p>
|
||||
</div>
|
||||
|
||||
<DialogHeader>
|
||||
<DialogTitle>Share with the community</DialogTitle>
|
||||
<DialogDescription>Submit a custom CSS theme or widget to the Workshop.</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
{steps.map((label, i) => (
|
||||
<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 disabled:cursor-default",
|
||||
)}
|
||||
>
|
||||
{i < step ? <IconCheck size={12} /> : <span className="tabular-nums">{i + 1}</span>}
|
||||
{label}
|
||||
</button>
|
||||
</React.Fragment>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<Alert variant="destructive">
|
||||
<IconAlertCircle />
|
||||
<AlertTitle>Submission needs attention</AlertTitle>
|
||||
<AlertDescription className="whitespace-pre-wrap">{error}</AlertDescription>
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
<div className="relative min-h-[280px] overflow-hidden">
|
||||
<div key={step} className={cn("submit-step-enter", stepAnimClass[Number(direction > 0)])}>
|
||||
{step === 0 && <StepType value={type} onChange={setType} onJsonDrop={handleJsonFileDrop} />}
|
||||
{step === 1 && type && (
|
||||
<StepDetails
|
||||
type={type}
|
||||
title={title}
|
||||
setTitle={setTitle}
|
||||
description={description}
|
||||
setDescription={setDescription}
|
||||
changelog={changelog}
|
||||
setChangelog={setChangelog}
|
||||
content={content}
|
||||
onContentChange={onContentChange}
|
||||
/>
|
||||
)}
|
||||
{step === 2 && (
|
||||
<ScreenshotEditor
|
||||
items={previews.map((src) => ({ id: src, src }))}
|
||||
onAdd={addImageFiles}
|
||||
onRemove={(id) => removeScreenshot(previews.indexOf(id))}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<DialogFooter className="sm:justify-between">
|
||||
<Button
|
||||
variant="ghost"
|
||||
onClick={() => {
|
||||
if (step === 0) onClose();
|
||||
else goTo(step - 1);
|
||||
}}
|
||||
disabled={pending}
|
||||
>
|
||||
<IconChevronLeft size={14} /> {backLabels[Math.min(step, 1)]}
|
||||
</Button>
|
||||
<div className="flex items-center gap-2">
|
||||
{step < 2 && (
|
||||
<Button onClick={() => goTo(step + 1)} disabled={!canAdvance[step]}>
|
||||
Next <IconChevronRight size={14} />
|
||||
</Button>
|
||||
)}
|
||||
{step >= 2 && (
|
||||
<Button onClick={() => void handleSubmit()} disabled={pending || !canAdvance[1]}>
|
||||
{pending && <IconLoader2 size={14} className="animate-spin" />}
|
||||
{submitLabels[Number(pending)]}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
};
|
||||
|
||||
const typeCards: { type: SubmissionType; icon: typeof IconBraces; label: string; desc: string }[] = [
|
||||
{
|
||||
type: "customWidget",
|
||||
icon: IconBraces,
|
||||
label: "Custom Widget",
|
||||
desc: "A JSON-based widget using the homarr-custom-widget-v2 schema",
|
||||
},
|
||||
{
|
||||
type: "customCss",
|
||||
icon: IconPalette,
|
||||
label: "Custom CSS",
|
||||
desc: "A CSS theme or style override for Homarr dashboards",
|
||||
},
|
||||
];
|
||||
|
||||
const StepType = ({
|
||||
value,
|
||||
onChange,
|
||||
onJsonDrop,
|
||||
}: {
|
||||
value: SubmissionType | null;
|
||||
onChange: (t: SubmissionType) => void;
|
||||
onJsonDrop: (files: FileList | File[]) => boolean;
|
||||
}) => {
|
||||
const [dropActive, setDropActive] = useState(false);
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-3">
|
||||
<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}
|
||||
className={cn(
|
||||
"group relative flex min-h-48 flex-col items-center justify-center gap-3 rounded-xl border-2 p-6 text-center transition-colors hover:border-primary/50 hover:bg-primary/5",
|
||||
value === type ? "border-primary bg-primary/10 ring-1 ring-primary/20" : "border-border bg-card",
|
||||
)}
|
||||
>
|
||||
<div
|
||||
className={cn(
|
||||
"flex size-12 items-center justify-center rounded-xl transition-colors",
|
||||
value === type
|
||||
? "bg-primary text-primary-foreground"
|
||||
: "bg-muted text-muted-foreground group-hover:bg-primary/10 group-hover:text-primary",
|
||||
)}
|
||||
>
|
||||
<Icon size={24} />
|
||||
</div>
|
||||
<div>
|
||||
<p className="font-heading text-sm font-semibold">{label}</p>
|
||||
<p className="mt-1 text-xs leading-relaxed text-muted-foreground">{desc}</p>
|
||||
</div>
|
||||
{value === type && (
|
||||
<div className="absolute top-3 right-3 flex size-5 items-center justify-center rounded-full bg-primary text-primary-foreground">
|
||||
<IconCheck size={12} />
|
||||
</div>
|
||||
)}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* oxlint-disable-next-line jsx-a11y/no-noninteractive-element-interactions */}
|
||||
<label
|
||||
onDragOver={(e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
setDropActive(true);
|
||||
}}
|
||||
onDragLeave={() => setDropActive(false)}
|
||||
onDrop={(e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
setDropActive(false);
|
||||
onJsonDrop(e.dataTransfer.files);
|
||||
}}
|
||||
className={cn(
|
||||
"flex cursor-pointer items-center gap-3 rounded-xl border border-dashed p-3 transition-all hover:border-primary/40 hover:bg-primary/5",
|
||||
dropActive && "border-primary bg-primary/10",
|
||||
)}
|
||||
>
|
||||
<div className="flex size-8 shrink-0 items-center justify-center rounded-lg bg-muted text-muted-foreground">
|
||||
<IconFileUpload size={16} />
|
||||
</div>
|
||||
<div className="min-w-0">
|
||||
<p className="text-xs font-medium">Or drop a .json file here</p>
|
||||
<p className="text-xs text-muted-foreground">Auto-selects Widget type and fills title from JSON</p>
|
||||
</div>
|
||||
<input
|
||||
type="file"
|
||||
accept=".json,application/json"
|
||||
className="hidden"
|
||||
onChange={(e) => {
|
||||
if (e.target.files) onJsonDrop(e.target.files);
|
||||
}}
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const StepDetails = ({
|
||||
type,
|
||||
title,
|
||||
setTitle,
|
||||
description,
|
||||
setDescription,
|
||||
changelog,
|
||||
setChangelog,
|
||||
content,
|
||||
onContentChange,
|
||||
}: {
|
||||
type: SubmissionType;
|
||||
title: string;
|
||||
setTitle: (v: string) => void;
|
||||
description: string;
|
||||
setDescription: (v: string) => void;
|
||||
changelog: string;
|
||||
setChangelog: (v: string) => void;
|
||||
content: string;
|
||||
onContentChange: (v: string) => void;
|
||||
}) => (
|
||||
<div className="grid gap-5 lg:grid-cols-[minmax(0,1.45fr)_minmax(18rem,0.55fr)]">
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<CustomWidgetCodeInput
|
||||
id="submit-content"
|
||||
label={contentLabels[type]}
|
||||
language={type === "customWidget" ? "json" : "css"}
|
||||
value={content}
|
||||
onChange={onContentChange}
|
||||
placeholder={placeholders[type]}
|
||||
required
|
||||
height="min(44vh, 440px)"
|
||||
maxLength={type === "customCss" ? 16_384 : undefined}
|
||||
description={
|
||||
type === "customWidget"
|
||||
? "Paste exported widget JSON. The title and description are filled from its metadata."
|
||||
: "Paste the stylesheet that users will import into their board settings."
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex flex-col gap-4">
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<label htmlFor="submit-title" className="text-xs font-medium text-muted-foreground">
|
||||
Title *
|
||||
</label>
|
||||
<Input
|
||||
id="submit-title"
|
||||
value={title}
|
||||
onChange={(e) => setTitle(e.target.value)}
|
||||
required
|
||||
minLength={3}
|
||||
maxLength={100}
|
||||
placeholder="My awesome theme"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<label htmlFor="submit-description" className="text-xs font-medium text-muted-foreground">
|
||||
Description
|
||||
</label>
|
||||
<Textarea
|
||||
id="submit-description"
|
||||
value={description}
|
||||
onChange={(e) => setDescription(e.target.value)}
|
||||
maxLength={2000}
|
||||
rows={5}
|
||||
placeholder="A brief description of what this does"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<label htmlFor="submit-changelog" className="text-xs font-medium text-muted-foreground">
|
||||
Changelog
|
||||
</label>
|
||||
<Textarea
|
||||
id="submit-changelog"
|
||||
value={changelog}
|
||||
onChange={(e) => setChangelog(e.target.value)}
|
||||
maxLength={2000}
|
||||
rows={4}
|
||||
placeholder="What is included in this revision?"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
@@ -0,0 +1,118 @@
|
||||
import React, { useEffect, useRef, useState } from "react";
|
||||
import { IconBrandGithub, IconChevronDown, IconExternalLink, IconLogout } from "@tabler/icons-react";
|
||||
|
||||
import type { WorkshopUser } from "@homarr/workshop/schema";
|
||||
|
||||
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
const avatarFallback = (name: string) => name.trim().slice(0, 1).toUpperCase() || "?";
|
||||
|
||||
const githubProfileUrl = (user: WorkshopUser) =>
|
||||
user.githubProfileUrl ||
|
||||
(user.githubUsername ? `https://github.com/${encodeURIComponent(user.githubUsername)}` : undefined);
|
||||
|
||||
interface WorkshopAccountMenuProps {
|
||||
user: WorkshopUser;
|
||||
onSignOut: () => void;
|
||||
}
|
||||
|
||||
export const WorkshopAccountMenu = ({ user, onSignOut }: WorkshopAccountMenuProps) => {
|
||||
const [open, setOpen] = useState(false);
|
||||
const rootRef = useRef<HTMLDivElement>(null);
|
||||
const profileUrl = githubProfileUrl(user);
|
||||
const accountLabel = user.githubUsername ? `@${user.githubUsername}` : user.displayName || "Account";
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
|
||||
const closeOnOutsidePointer = (event: PointerEvent) => {
|
||||
if (!rootRef.current?.contains(event.target as Node)) setOpen(false);
|
||||
};
|
||||
const closeOnEscape = (event: KeyboardEvent) => {
|
||||
if (event.key === "Escape") setOpen(false);
|
||||
};
|
||||
|
||||
document.addEventListener("pointerdown", closeOnOutsidePointer);
|
||||
document.addEventListener("keydown", closeOnEscape);
|
||||
return () => {
|
||||
document.removeEventListener("pointerdown", closeOnOutsidePointer);
|
||||
document.removeEventListener("keydown", closeOnEscape);
|
||||
};
|
||||
}, [open]);
|
||||
|
||||
return (
|
||||
<div ref={rootRef} className="relative">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-10 gap-2 px-2 sm:h-8"
|
||||
aria-label="Workshop account menu"
|
||||
aria-haspopup="menu"
|
||||
aria-expanded={open}
|
||||
onClick={() => setOpen((value) => !value)}
|
||||
>
|
||||
<Avatar className="size-6">
|
||||
{user.avatarUrl && <AvatarImage src={user.avatarUrl} alt="" />}
|
||||
<AvatarFallback className="text-[10px]">{avatarFallback(user.displayName)}</AvatarFallback>
|
||||
</Avatar>
|
||||
<span className="max-w-36 truncate">{accountLabel}</span>
|
||||
<IconChevronDown
|
||||
size={13}
|
||||
aria-hidden="true"
|
||||
className={cn("text-muted-foreground transition-transform duration-150", open && "rotate-180")}
|
||||
/>
|
||||
</Button>
|
||||
|
||||
{open && (
|
||||
<div
|
||||
role="menu"
|
||||
aria-label="Workshop account"
|
||||
className="absolute right-0 z-50 mt-2 w-64 rounded-lg border border-border bg-popover p-1.5 text-popover-foreground shadow-md"
|
||||
>
|
||||
<div className="flex items-center gap-3 px-2 py-2">
|
||||
<Avatar className="size-9">
|
||||
{user.avatarUrl && <AvatarImage src={user.avatarUrl} alt="" />}
|
||||
<AvatarFallback>{avatarFallback(user.displayName)}</AvatarFallback>
|
||||
</Avatar>
|
||||
<div className="min-w-0">
|
||||
<p className="truncate text-sm font-medium">{user.displayName}</p>
|
||||
{user.githubUsername && <p className="truncate text-xs text-muted-foreground">@{user.githubUsername}</p>}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="my-1 h-px bg-border" />
|
||||
|
||||
{profileUrl && (
|
||||
<a
|
||||
role="menuitem"
|
||||
href={profileUrl}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
className="flex h-9 items-center gap-2 rounded-md px-2 text-sm text-foreground no-underline hover:bg-accent hover:text-accent-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring/50"
|
||||
onClick={() => setOpen(false)}
|
||||
>
|
||||
<IconBrandGithub size={16} aria-hidden="true" />
|
||||
View GitHub profile
|
||||
<IconExternalLink size={14} aria-hidden="true" className="ml-auto text-muted-foreground" />
|
||||
</a>
|
||||
)}
|
||||
|
||||
<button
|
||||
type="button"
|
||||
role="menuitem"
|
||||
className="flex h-9 w-full items-center gap-2 rounded-md px-2 text-sm text-foreground hover:bg-accent hover:text-accent-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring/50"
|
||||
onClick={() => {
|
||||
setOpen(false);
|
||||
onSignOut();
|
||||
}}
|
||||
>
|
||||
<IconLogout size={16} aria-hidden="true" />
|
||||
Sign out
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,319 @@
|
||||
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||
import {
|
||||
IconBrandGithub,
|
||||
IconExternalLink,
|
||||
IconFlag,
|
||||
IconLoader2,
|
||||
IconRefresh,
|
||||
IconShield,
|
||||
IconTrash,
|
||||
} from "@tabler/icons-react";
|
||||
|
||||
import { WorkshopBackend } from "@homarr/workshop/backend";
|
||||
import type { WorkshopReport, WorkshopSubmissionSummary, WorkshopUser } from "@homarr/workshop/schema";
|
||||
|
||||
import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert";
|
||||
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@/components/ui/dialog";
|
||||
|
||||
const avatarFallback = (name: string) => name.trim().slice(0, 1).toUpperCase() || "?";
|
||||
|
||||
export function WorkshopAdmin({ workshopUrl }: { workshopUrl?: string }) {
|
||||
const client = useMemo(() => {
|
||||
const url = workshopUrl || (typeof window === "undefined" ? "" : window.location.origin);
|
||||
return url ? new WorkshopBackend(url) : new WorkshopBackend();
|
||||
}, [workshopUrl]);
|
||||
const [user, setUser] = useState<WorkshopUser | null>(null);
|
||||
const [reports, setReports] = useState<WorkshopReport[]>([]);
|
||||
const [submissions, setSubmissions] = useState<WorkshopSubmissionSummary[]>([]);
|
||||
const [busyId, setBusyId] = useState<string | null>(null);
|
||||
const [pendingDelete, setPendingDelete] = useState<WorkshopSubmissionSummary | null>(null);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState("");
|
||||
|
||||
const load = useCallback(async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const [nextReports, nextSubmissions] = await Promise.all([
|
||||
client.listReports(),
|
||||
client.listAll({ sort: "newest" }),
|
||||
]);
|
||||
setReports(nextReports);
|
||||
setSubmissions(nextSubmissions);
|
||||
setError("");
|
||||
} catch (cause) {
|
||||
setError(cause instanceof Error ? cause.message : "Unable to load Workshop administration");
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [client]);
|
||||
|
||||
useEffect(() => {
|
||||
const unsubscribe = client.subscribeToAuth(setUser);
|
||||
void client.refreshAuth().then((nextUser) => {
|
||||
setUser(nextUser);
|
||||
if (nextUser?.isAdmin) void load();
|
||||
});
|
||||
return unsubscribe;
|
||||
}, [client, load]);
|
||||
|
||||
const signIn = async () => {
|
||||
try {
|
||||
const nextUser = await client.signInWithGitHub();
|
||||
setUser(nextUser);
|
||||
if (nextUser?.isAdmin) await load();
|
||||
} catch (cause) {
|
||||
setError(cause instanceof Error ? cause.message : "GitHub sign-in failed");
|
||||
}
|
||||
};
|
||||
|
||||
const dismiss = async (reportId: string) => {
|
||||
setBusyId(reportId);
|
||||
try {
|
||||
await client.dismissReport(reportId);
|
||||
await load();
|
||||
} catch (cause) {
|
||||
setError(cause instanceof Error ? cause.message : "Unable to dismiss report");
|
||||
} finally {
|
||||
setBusyId(null);
|
||||
}
|
||||
};
|
||||
|
||||
const remove = async () => {
|
||||
if (!pendingDelete) return;
|
||||
setBusyId(pendingDelete.id);
|
||||
try {
|
||||
await client.delete(pendingDelete.id);
|
||||
setPendingDelete(null);
|
||||
await load();
|
||||
} catch (cause) {
|
||||
setError(cause instanceof Error ? cause.message : "Unable to delete submission");
|
||||
} finally {
|
||||
setBusyId(null);
|
||||
}
|
||||
};
|
||||
|
||||
const grouped = submissions
|
||||
.map((submission) => ({
|
||||
submission,
|
||||
reports: reports.filter((report) => report.submission === submission.id),
|
||||
}))
|
||||
.sort((left, right) => Number(right.reports.length > 0) - Number(left.reports.length > 0));
|
||||
|
||||
return (
|
||||
<main className="marketplace mx-auto max-w-6xl px-4 pb-20 text-foreground sm:px-6">
|
||||
<header className="flex flex-col gap-5 border-b border-border py-8 sm:flex-row sm:items-end sm:justify-between">
|
||||
<div>
|
||||
<div className="mb-2 flex items-center gap-2 text-primary">
|
||||
<IconShield size={18} />
|
||||
<span className="text-sm font-medium">Restricted area</span>
|
||||
</div>
|
||||
<h1 className="text-3xl font-semibold tracking-tight">Workshop administration</h1>
|
||||
<p className="mt-2 max-w-2xl text-sm text-muted-foreground">
|
||||
Review reports and remove unsafe or outdated submissions. PocketBase rules enforce every action.
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<Button variant="outline" nativeButton={false} render={<a href="/workshop" aria-label="Back to Workshop" />}>
|
||||
Back to Workshop
|
||||
</Button>
|
||||
{!user && (
|
||||
<Button onClick={() => void signIn()}>
|
||||
<IconBrandGithub size={15} /> Sign in with GitHub
|
||||
</Button>
|
||||
)}
|
||||
{user?.isAdmin && (
|
||||
<Button variant="outline" disabled={loading} onClick={() => void load()}>
|
||||
<IconRefresh size={15} className={loading ? "animate-spin" : undefined} />
|
||||
{loading ? "Refreshing" : "Refresh data"}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<div className="py-6">
|
||||
{user && !user.isAdmin && (
|
||||
<Alert variant="destructive" className="mb-5">
|
||||
<IconShield />
|
||||
<AlertTitle>Administrator access required</AlertTitle>
|
||||
<AlertDescription>This GitHub account cannot access Workshop moderation.</AlertDescription>
|
||||
</Alert>
|
||||
)}
|
||||
{error && (
|
||||
<Alert variant="destructive" className="mb-5">
|
||||
<IconFlag />
|
||||
<AlertTitle>Workshop administration failed</AlertTitle>
|
||||
<AlertDescription>{error}</AlertDescription>
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
{user?.isAdmin && (
|
||||
<section aria-busy={loading}>
|
||||
{reports.length > 0 && (
|
||||
<Alert variant="destructive" className="mb-5">
|
||||
<IconFlag />
|
||||
<AlertTitle>
|
||||
{reports.length} open {reports.length === 1 ? "report needs" : "reports need"} review
|
||||
</AlertTitle>
|
||||
<AlertDescription>
|
||||
Reported submissions are highlighted and shown first. Dismiss reports that need no action, or delete
|
||||
the submission when it should no longer be available.
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
<div className="mb-5 flex flex-wrap items-center gap-2 text-sm">
|
||||
<Badge variant={reports.length > 0 ? "destructive" : "secondary"}>
|
||||
{reports.length} open {reports.length === 1 ? "report" : "reports"}
|
||||
</Badge>
|
||||
<span className="text-muted-foreground">
|
||||
{submissions.length} published {submissions.length === 1 ? "submission" : "submissions"}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="space-y-4">
|
||||
{grouped.map(({ submission, reports: submissionReports }) => (
|
||||
<Card
|
||||
key={submission.id}
|
||||
className={
|
||||
submissionReports.length > 0
|
||||
? "overflow-visible bg-destructive/[0.025] ring-destructive/50"
|
||||
: "overflow-visible"
|
||||
}
|
||||
>
|
||||
<CardHeader className="border-b border-border pb-4 sm:grid-cols-[1fr_auto]">
|
||||
<div className="flex min-w-0 items-center gap-3">
|
||||
<Avatar className="size-10">
|
||||
{submission.authorAvatarUrl && <AvatarImage src={submission.authorAvatarUrl} alt="" />}
|
||||
<AvatarFallback>{avatarFallback(submission.authorName)}</AvatarFallback>
|
||||
</Avatar>
|
||||
<div className="min-w-0">
|
||||
<CardTitle className="truncate text-base">{submission.title}</CardTitle>
|
||||
<a
|
||||
href={submission.authorGithubProfileUrl || undefined}
|
||||
target={submission.authorGithubProfileUrl ? "_blank" : undefined}
|
||||
rel="noreferrer"
|
||||
className="text-xs text-muted-foreground hover:text-foreground hover:underline"
|
||||
>
|
||||
{submission.authorName}
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
<Badge variant={submissionReports.length > 0 ? "destructive" : "secondary"}>
|
||||
{submissionReports.length} {submissionReports.length === 1 ? "report" : "reports"}
|
||||
</Badge>
|
||||
</CardHeader>
|
||||
|
||||
<CardContent className="space-y-5 pt-1">
|
||||
<div>
|
||||
<p className="text-sm leading-relaxed text-muted-foreground">
|
||||
{submission.description || "No description was provided."}
|
||||
</p>
|
||||
<div className="mt-3 flex flex-wrap gap-x-4 gap-y-1 text-xs text-muted-foreground">
|
||||
<span>{submission.upvotes} upvotes</span>
|
||||
<span>{submission.downvotes} downvotes</span>
|
||||
<span>Score {submission.score}</span>
|
||||
<span>Updated {new Date(submission.updated).toLocaleDateString()}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{submissionReports.length > 0 && (
|
||||
<div className="space-y-3">
|
||||
{submissionReports.map((report) => (
|
||||
<Alert key={report.id} variant="destructive">
|
||||
<IconFlag />
|
||||
<div className="col-start-2 min-w-0">
|
||||
<div className="flex flex-wrap items-start justify-between gap-3">
|
||||
<div className="min-w-0">
|
||||
<AlertTitle className="col-auto capitalize">{report.category} report</AlertTitle>
|
||||
<p className="m-0 mt-1 text-xs text-destructive/75">
|
||||
Reported by {report.reporterName} · {new Date(report.created).toLocaleString()}
|
||||
</p>
|
||||
</div>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="border-destructive/30 bg-background/70 text-destructive hover:bg-destructive/10 hover:text-destructive"
|
||||
disabled={busyId === report.id}
|
||||
onClick={() => void dismiss(report.id)}
|
||||
>
|
||||
{busyId === report.id && <IconLoader2 size={14} className="animate-spin" />}
|
||||
{busyId === report.id ? "Dismissing" : "Dismiss report"}
|
||||
</Button>
|
||||
</div>
|
||||
<AlertDescription className="col-auto mt-3 whitespace-pre-wrap">
|
||||
{report.explanation}
|
||||
</AlertDescription>
|
||||
</div>
|
||||
</Alert>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex flex-wrap items-center gap-2 border-t border-border pt-4">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
nativeButton={false}
|
||||
render={<a href={`/workshop/${submission.id}`} aria-label={`Inspect ${submission.title}`} />}
|
||||
>
|
||||
<IconExternalLink size={14} /> Inspect submission
|
||||
</Button>
|
||||
<Button
|
||||
variant={submissionReports.length > 0 ? "destructive" : "ghost"}
|
||||
size="sm"
|
||||
className={submissionReports.length > 0 ? undefined : "text-destructive hover:text-destructive"}
|
||||
onClick={() => setPendingDelete(submission)}
|
||||
>
|
||||
<IconTrash size={14} /> Delete submission
|
||||
</Button>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{!loading && grouped.length === 0 && (
|
||||
<div className="rounded-xl border border-dashed border-border py-16 text-center">
|
||||
<IconShield size={28} className="mx-auto text-muted-foreground" />
|
||||
<p className="mt-3 text-sm font-medium">No Workshop submissions</p>
|
||||
<p className="mt-1 text-sm text-muted-foreground">Published submissions will appear here.</p>
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<Dialog open={pendingDelete !== null} onOpenChange={(open) => !open && !busyId && setPendingDelete(null)}>
|
||||
<DialogContent className="sm:max-w-md">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Delete {pendingDelete?.title}?</DialogTitle>
|
||||
<DialogDescription>
|
||||
This removes the Workshop listing and its reports, votes, screenshots, and comments. Installed local
|
||||
copies remain. The submission author will be notified by email.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<DialogFooter>
|
||||
<Button variant="ghost" disabled={busyId !== null} onClick={() => setPendingDelete(null)}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button variant="destructive" disabled={busyId !== null} onClick={() => void remove()}>
|
||||
{busyId && <IconLoader2 size={14} className="animate-spin" />}
|
||||
{busyId ? "Deleting" : "Delete submission"}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,542 @@
|
||||
import React, { useMemo, useState } from "react";
|
||||
import {
|
||||
IconAlertCircle,
|
||||
IconArrowBigDown,
|
||||
IconArrowBigUp,
|
||||
IconBrandCss3,
|
||||
IconBrandGithub,
|
||||
IconChevronLeft,
|
||||
IconChevronRight,
|
||||
IconEye,
|
||||
IconMessage,
|
||||
IconPackage,
|
||||
IconPlus,
|
||||
IconPuzzle,
|
||||
IconRefresh,
|
||||
IconSearch,
|
||||
} from "@tabler/icons-react";
|
||||
|
||||
import type { WorkshopSubmission } from "@site/src/lib/pocketbase";
|
||||
import type { SubmissionType } from "@site/src/lib/workshop-schema";
|
||||
|
||||
import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert";
|
||||
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
Card,
|
||||
CardAction,
|
||||
CardContent,
|
||||
CardDescription,
|
||||
CardFooter,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from "@/components/ui/card";
|
||||
import { InputGroup, InputGroupAddon, InputGroupInput } from "@/components/ui/input-group";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
||||
import { Skeleton } from "@/components/ui/skeleton";
|
||||
import { Switch } from "@/components/ui/switch";
|
||||
import { ToggleGroup, ToggleGroupItem } from "@/components/ui/toggle-group";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
import { SubmitForm } from "./SubmitForm";
|
||||
import { WorkshopAccountMenu } from "./WorkshopAccountMenu";
|
||||
import { formatRelativeTime } from "./format";
|
||||
import type { SortKey, TypeFilter } from "./useWorkshop";
|
||||
import { useWorkshop } from "./useWorkshop";
|
||||
|
||||
const typeDotColors: Record<SubmissionType, string> = { customCss: "bg-blue-500", customWidget: "bg-yellow-500" };
|
||||
const typeLabels: Record<SubmissionType, string> = { customCss: "CSS", customWidget: "Widget" };
|
||||
const typeIcons: Record<SubmissionType, React.ComponentType<{ size: number; className?: string }>> = {
|
||||
customCss: IconBrandCss3,
|
||||
customWidget: IconPuzzle,
|
||||
};
|
||||
const typeBgColors: Record<SubmissionType, string> = { customCss: "bg-blue-500/5", customWidget: "bg-yellow-500/5" };
|
||||
const cardMediaClassName = "aspect-video w-full overflow-hidden bg-muted";
|
||||
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." },
|
||||
} as const;
|
||||
|
||||
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 },
|
||||
];
|
||||
const sortOptions: { value: SortKey; label: string }[] = [
|
||||
{ value: "top", label: "Top rated" },
|
||||
{ value: "new", label: "Newest" },
|
||||
{ value: "recent", label: "Recently updated" },
|
||||
{ value: "discussed", label: "Most discussed" },
|
||||
];
|
||||
|
||||
const stopCardNavigation = (event: React.MouseEvent<HTMLButtonElement>) => {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
};
|
||||
|
||||
const avatarFallback = (name: string) => name.trim().slice(0, 1).toUpperCase() || "?";
|
||||
|
||||
export const WorkshopApp = ({ workshopUrl }: { workshopUrl: string }) => {
|
||||
const workshop = useWorkshop(workshopUrl);
|
||||
const [typeFilter, setTypeFilter] = useState<TypeFilter>("all");
|
||||
const [sort, setSort] = useState<SortKey>("top");
|
||||
const [search, setSearch] = useState("");
|
||||
const [includeOutdated, setIncludeOutdated] = useState(true);
|
||||
const [showSubmit, setShowSubmit] = useState(false);
|
||||
|
||||
// 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.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, 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
|
||||
}, [submissionIds, workshop.sorters, typeFilter, sort, search, includeOutdated, workshop.user?.id]);
|
||||
|
||||
const visible = useMemo(() => {
|
||||
const byId = new Map(workshop.submissions.map((s) => [s.id, s]));
|
||||
return sortedIds.map((id) => byId.get(id)).filter(Boolean) as WorkshopSubmission[];
|
||||
}, [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-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 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 self-start sm:self-auto">
|
||||
{workshop.user ? (
|
||||
<>
|
||||
<Button className="h-10 sm:h-8" onClick={() => setShowSubmit(true)}>
|
||||
<IconPlus size={14} /> Share yours
|
||||
</Button>
|
||||
<WorkshopAccountMenu
|
||||
user={workshop.user}
|
||||
onSignOut={() => {
|
||||
setTypeFilter("all");
|
||||
workshop.logout();
|
||||
}}
|
||||
/>
|
||||
</>
|
||||
) : (
|
||||
<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, comment, report, and publish</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{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">
|
||||
<InputGroup className="order-first h-11 w-full sm:order-last sm:h-9 sm:w-64">
|
||||
<InputGroupAddon>
|
||||
<IconSearch size={16} />
|
||||
</InputGroupAddon>
|
||||
<InputGroupInput
|
||||
placeholder="Search"
|
||||
aria-label="Search submissions"
|
||||
value={search}
|
||||
onChange={(event) => setSearch(event.target.value)}
|
||||
/>
|
||||
</InputGroup>
|
||||
<div className="flex min-w-0 flex-col gap-2 sm:flex-row sm:items-center">
|
||||
<ToggleGroup
|
||||
value={[typeFilter]}
|
||||
onValueChange={(values) => values[0] && setTypeFilter(values[0] as TypeFilter)}
|
||||
variant="default"
|
||||
size="sm"
|
||||
spacing={0}
|
||||
className={cn("grid min-w-0 bg-muted/40 sm:flex", workshop.user ? "grid-cols-4" : "grid-cols-3")}
|
||||
aria-label="Submission type"
|
||||
>
|
||||
{availableTypeFilters.map((opt) => (
|
||||
<ToggleGroupItem
|
||||
key={opt.value}
|
||||
value={opt.value}
|
||||
className="min-h-10 min-w-0 gap-1.5 px-3 text-sm data-pressed:bg-background data-pressed:text-foreground data-pressed:shadow-sm sm:min-h-8 sm:text-xs"
|
||||
>
|
||||
{opt.dot && <span className={cn("size-2 rounded-full", opt.dot)} />}
|
||||
{opt.label}
|
||||
</ToggleGroupItem>
|
||||
))}
|
||||
</ToggleGroup>
|
||||
<div className="flex items-center gap-2">
|
||||
<Select value={sort} onValueChange={(value) => setSort(value as SortKey)}>
|
||||
<SelectTrigger aria-label="Sort submissions" className="h-10 min-w-40 flex-1 sm:h-8 sm:flex-none">
|
||||
<SelectValue>{(value) => sortOptions.find((option) => option.value === value)?.label}</SelectValue>
|
||||
</SelectTrigger>
|
||||
<SelectContent align="start">
|
||||
{sortOptions.map((option) => (
|
||||
<SelectItem key={option.value} value={option.value}>
|
||||
{option.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<Label className="h-10 shrink-0 cursor-pointer rounded-lg border border-input bg-background px-3 text-xs text-muted-foreground sm:h-8">
|
||||
<Switch
|
||||
size="sm"
|
||||
checked={!includeOutdated}
|
||||
onCheckedChange={(checked) => setIncludeOutdated(!checked)}
|
||||
/>
|
||||
Current only
|
||||
</Label>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<p className="sr-only" aria-live="polite">
|
||||
{workshop.loading ? "Loading Workshop listings" : `${visible.length} submissions shown`}
|
||||
</p>
|
||||
|
||||
{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 && (
|
||||
<Alert variant="destructive" className="mb-4 grid-cols-[auto_1fr_auto] items-center">
|
||||
<IconAlertCircle />
|
||||
<div>
|
||||
<AlertTitle>Workshop data is out of date</AlertTitle>
|
||||
<AlertDescription>The last refresh failed. Existing listings are still available.</AlertDescription>
|
||||
</div>
|
||||
<Button variant="ghost" size="sm" onClick={() => void workshop.refresh()}>
|
||||
<IconRefresh size={14} /> Retry
|
||||
</Button>
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
{workshop.loading && workshop.submissions.length === 0 && (
|
||||
<div className="grid auto-rows-fr grid-cols-1 items-stretch gap-4 lg:grid-cols-2 xl:grid-cols-3">
|
||||
{Array.from({ length: 6 }, (_, i) => (
|
||||
<SkeletonCard key={i} />
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!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
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="grid auto-rows-fr grid-cols-1 items-stretch gap-4 lg:grid-cols-2 xl:grid-cols-3">
|
||||
{visible.map((submission) => (
|
||||
<SubmissionCard
|
||||
key={submission.id}
|
||||
submission={submission}
|
||||
backend={workshop.backend}
|
||||
userVote={workshop.votes[submission.id]?.value}
|
||||
onVote={workshop.vote}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{showSubmit && (
|
||||
<SubmitForm
|
||||
onClose={() => setShowSubmit(false)}
|
||||
onSubmit={async (input) => {
|
||||
if (await workshop.submit(input)) setShowSubmit(false);
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
interface SubmissionCardProps {
|
||||
submission: WorkshopSubmission;
|
||||
backend: ReturnType<typeof useWorkshop>["backend"];
|
||||
userVote?: 1 | -1;
|
||||
onVote: (submissionId: string, value: 1 | -1) => void;
|
||||
}
|
||||
|
||||
const SubmissionCard = ({ submission, backend, userVote, onVote }: SubmissionCardProps) => {
|
||||
const score = submission.upvotes - submission.downvotes;
|
||||
const screenshotUrls = useMemo(
|
||||
() => submission.screenshots?.map((file) => backend.fileUrl(submission.id, file)) ?? [],
|
||||
[submission, backend],
|
||||
);
|
||||
|
||||
const hasScreenshots = screenshotUrls.length > 0;
|
||||
const TypeIcon = typeIcons[submission.type];
|
||||
|
||||
return (
|
||||
<Card className="relative flex h-full min-w-0 w-full flex-col">
|
||||
<a href={`/workshop/${submission.id}/`} className="block shrink-0">
|
||||
{hasScreenshots ? (
|
||||
<div className="relative">
|
||||
<Badge
|
||||
variant="secondary"
|
||||
className="absolute left-2 top-2 z-10 gap-1.5 bg-background/80 px-2 backdrop-blur-sm"
|
||||
>
|
||||
<span className={cn("size-2 rounded-full", typeDotColors[submission.type])} />
|
||||
{typeLabels[submission.type]}
|
||||
</Badge>
|
||||
<ScreenshotGallery urls={screenshotUrls} title={submission.title} />
|
||||
</div>
|
||||
) : (
|
||||
<div className={cn(cardMediaClassName, "flex items-center justify-center", typeBgColors[submission.type])}>
|
||||
<TypeIcon size={32} className="text-muted-foreground/20" />
|
||||
</div>
|
||||
)}
|
||||
</a>
|
||||
|
||||
<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>
|
||||
</a>
|
||||
{!hasScreenshots && (
|
||||
<Badge variant="secondary" className="shrink-0 gap-1.5 px-2">
|
||||
<span className={cn("size-2 rounded-full", typeDotColors[submission.type])} />
|
||||
{typeLabels[submission.type]}
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
<CardDescription className="flex min-w-0 items-center gap-1 text-xs">
|
||||
<a
|
||||
href={submission.authorGithubProfileUrl || undefined}
|
||||
target={submission.authorGithubProfileUrl ? "_blank" : undefined}
|
||||
rel="noreferrer"
|
||||
className="inline-flex min-w-0 items-center gap-1.5 hover:text-foreground"
|
||||
>
|
||||
<Avatar className="size-4">
|
||||
{submission.authorAvatarUrl && <AvatarImage src={submission.authorAvatarUrl} alt="" />}
|
||||
<AvatarFallback className="text-[9px]">{avatarFallback(submission.authorName)}</AvatarFallback>
|
||||
</Avatar>
|
||||
<span className="truncate">{submission.authorName}</span>
|
||||
</a>
|
||||
<span className="shrink-0 whitespace-nowrap">
|
||||
· v{submission.revision} · {formatRelativeTime(submission.created)}
|
||||
</span>
|
||||
</CardDescription>
|
||||
<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 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",
|
||||
)}
|
||||
>
|
||||
<IconArrowBigUp size={14} />
|
||||
</button>
|
||||
<span aria-live="polite" className="min-w-5 text-center text-xs font-semibold tabular-nums text-foreground">
|
||||
{score}
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => void onVote(submission.id, -1)}
|
||||
aria-label="Downvote"
|
||||
aria-pressed={userVote === -1}
|
||||
className={cn(
|
||||
"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",
|
||||
)}
|
||||
>
|
||||
<IconArrowBigDown size={14} />
|
||||
</button>
|
||||
</div>
|
||||
</CardAction>
|
||||
</CardHeader>
|
||||
|
||||
<CardContent className="min-h-[4.5rem] flex-1 overflow-hidden">
|
||||
{submission.outdated && (
|
||||
<div className="mb-2 flex flex-wrap gap-1.5">
|
||||
<Badge variant="secondary">Outdated</Badge>
|
||||
</div>
|
||||
)}
|
||||
{submission.description && (
|
||||
<p className="line-clamp-2 text-xs leading-relaxed text-muted-foreground">{submission.description}</p>
|
||||
)}
|
||||
</CardContent>
|
||||
|
||||
<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>
|
||||
</Card>
|
||||
);
|
||||
};
|
||||
|
||||
const SkeletonCard = () => (
|
||||
<Card className="h-full min-w-0 w-full">
|
||||
<Skeleton className="aspect-video rounded-none" />
|
||||
<CardHeader>
|
||||
<div className="flex items-center gap-2">
|
||||
<Skeleton className="h-4 w-32" />
|
||||
<Skeleton className="h-4 w-12 rounded-full" />
|
||||
</div>
|
||||
<Skeleton className="h-3 w-40" />
|
||||
</CardHeader>
|
||||
<CardContent className="min-h-[4.5rem]">
|
||||
<div className="space-y-1.5">
|
||||
<Skeleton className="h-3 w-full" />
|
||||
<Skeleton className="h-3 w-2/3" />
|
||||
</div>
|
||||
</CardContent>
|
||||
<CardFooter className="justify-between gap-3 px-3 py-2.5">
|
||||
<Skeleton className="h-3 w-20" />
|
||||
<Skeleton className="h-9 w-28" />
|
||||
</CardFooter>
|
||||
</Card>
|
||||
);
|
||||
|
||||
export const WorkshopListingFallback = () => (
|
||||
<div className="mx-auto max-w-7xl px-4 pb-16" aria-busy="true" aria-label="Loading Workshop listings">
|
||||
<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 max-w-2xl text-sm text-muted-foreground">
|
||||
Discover community-made widgets and CSS. Review the source, then import it into Homarr.
|
||||
</p>
|
||||
</div>
|
||||
<Skeleton className="h-10 w-40 sm:h-8" />
|
||||
</div>
|
||||
|
||||
<div className="mb-6 flex flex-col gap-3 rounded-xl border border-border bg-card p-3 sm:flex-row sm:justify-between">
|
||||
<Skeleton className="h-10 w-full sm:w-80" />
|
||||
<Skeleton className="h-10 w-full sm:w-64" />
|
||||
</div>
|
||||
|
||||
<div className="grid auto-rows-fr grid-cols-1 items-stretch gap-4 lg:grid-cols-2 xl:grid-cols-3">
|
||||
{Array.from({ length: 6 }, (_, index) => (
|
||||
<SkeletonCard key={index} />
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
const ScreenshotGallery = ({ urls, title }: { urls: string[]; title: string }) => {
|
||||
const [idx, setIdx] = useState(0);
|
||||
const dotClass = ["bg-white/40", "bg-white"];
|
||||
|
||||
return (
|
||||
<div className="group/gallery relative">
|
||||
<div className={cardMediaClassName}>
|
||||
<img
|
||||
className="h-full w-full object-cover"
|
||||
src={urls[idx]}
|
||||
alt={`${title} screenshot ${idx + 1}`}
|
||||
loading="lazy"
|
||||
/>
|
||||
</div>
|
||||
{urls.length > 1 && (
|
||||
<>
|
||||
<button
|
||||
type="button"
|
||||
className="absolute left-2 top-1/2 flex size-10 -translate-y-1/2 items-center justify-center rounded-lg bg-background/85 opacity-80 shadow-sm transition-opacity hover:opacity-100 sm:size-8"
|
||||
onClick={(event) => {
|
||||
stopCardNavigation(event);
|
||||
setIdx((i) => (i - 1 + urls.length) % urls.length);
|
||||
}}
|
||||
aria-label="Previous screenshot"
|
||||
>
|
||||
<IconChevronLeft size={14} />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="absolute right-2 top-1/2 flex size-10 -translate-y-1/2 items-center justify-center rounded-lg bg-background/85 opacity-80 shadow-sm transition-opacity hover:opacity-100 sm:size-8"
|
||||
onClick={(event) => {
|
||||
stopCardNavigation(event);
|
||||
setIdx((i) => (i + 1) % urls.length);
|
||||
}}
|
||||
aria-label="Next screenshot"
|
||||
>
|
||||
<IconChevronRight size={14} />
|
||||
</button>
|
||||
<div className="absolute bottom-2 left-1/2 flex -translate-x-1/2 rounded-full bg-black/60 px-1 py-0.5">
|
||||
{urls.map((_, i) => (
|
||||
<button
|
||||
type="button"
|
||||
key={i}
|
||||
onClick={(event) => {
|
||||
stopCardNavigation(event);
|
||||
setIdx(i);
|
||||
}}
|
||||
aria-label={`Screenshot ${i + 1}`}
|
||||
aria-current={i === idx ? "true" : undefined}
|
||||
className="flex size-8 items-center justify-center rounded-full focus-visible:ring-2 focus-visible:ring-white"
|
||||
>
|
||||
<span className={cn("size-1.5 rounded-full transition-colors", dotClass[Number(i === idx)])} />
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1 @@
|
||||
export { default } from "./DetailPage";
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
import dayjs from "dayjs";
|
||||
import relativeTime from "dayjs/plugin/relativeTime";
|
||||
|
||||
dayjs.extend(relativeTime);
|
||||
|
||||
export const formatRelativeTime = (date: string) => dayjs(date).fromNow();
|
||||
@@ -0,0 +1,310 @@
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import dayjs from "dayjs";
|
||||
|
||||
import type { WorkshopComment, WorkshopSubmission, WorkshopVote } from "@site/src/lib/pocketbase";
|
||||
import { getWorkshopBackend } from "@site/src/lib/pocketbase";
|
||||
import type { SubmissionType } from "@site/src/lib/workshop-schema";
|
||||
import { validateSubmissionContent } from "@site/src/lib/workshop-schema";
|
||||
import { errorMessage, oauthErrorMessage } from "@site/src/lib/utils";
|
||||
|
||||
import { voteDelta } from "./workshop-utils";
|
||||
|
||||
export type SortKey = "top" | "new" | "recent" | "discussed";
|
||||
export type TypeFilter = "all" | "yours" | SubmissionType;
|
||||
|
||||
export interface SubmitInput {
|
||||
type: SubmissionType;
|
||||
title: string;
|
||||
description: string;
|
||||
changelog: string;
|
||||
content: string;
|
||||
screenshots: File[];
|
||||
}
|
||||
|
||||
export interface CommentActions {
|
||||
fetch: (submissionId: string) => Promise<WorkshopComment[]>;
|
||||
add: (submissionId: string, content: string) => Promise<WorkshopComment | null>;
|
||||
update: (commentId: string, content: string) => Promise<WorkshopComment | null>;
|
||||
delete: (commentId: string) => Promise<boolean>;
|
||||
}
|
||||
|
||||
const sorters: Record<SortKey, (a: WorkshopSubmission, b: WorkshopSubmission) => number> = {
|
||||
top: (a, b) => b.upvotes - b.downvotes - (a.upvotes - a.downvotes),
|
||||
new: (a, b) => dayjs(b.created).valueOf() - dayjs(a.created).valueOf(),
|
||||
recent: (a, b) => dayjs(b.updated).valueOf() - dayjs(a.updated).valueOf(),
|
||||
discussed: (a, b) => b.commentCount - a.commentCount || dayjs(b.created).valueOf() - dayjs(a.created).valueOf(),
|
||||
};
|
||||
|
||||
export const useWorkshop = (workshopUrl: string) => {
|
||||
const backend = useMemo(() => getWorkshopBackend(workshopUrl), [workshopUrl]);
|
||||
const [submissions, setSubmissions] = useState<WorkshopSubmission[]>([]);
|
||||
const [votes, setVotes] = useState<Record<string, WorkshopVote>>({});
|
||||
const [user, setUser] = useState(backend.currentUser);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const votingIds = useRef(new Set<string>());
|
||||
|
||||
const refreshVotes = useCallback(async () => {
|
||||
if (!backend.currentUser) {
|
||||
setVotes({});
|
||||
return;
|
||||
}
|
||||
const rows = await backend.listVotesForCurrentUser();
|
||||
setVotes(Object.fromEntries(rows.map((row) => [row.submission, row])));
|
||||
}, [backend]);
|
||||
|
||||
const refresh = useCallback(async () => {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
try {
|
||||
const rows = await backend.listAll({ sort: "newest" });
|
||||
setSubmissions(rows.map((submission) => ({ ...submission, content: "" })));
|
||||
await refreshVotes();
|
||||
} catch (caught) {
|
||||
setError(errorMessage(caught, "Failed to load the workshop"));
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [backend, refreshVotes]);
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
const unsubscribe = backend.subscribeToAuth((nextUser) => {
|
||||
setUser(nextUser);
|
||||
if (!nextUser) setVotes({});
|
||||
});
|
||||
|
||||
const load = async () => {
|
||||
await backend.refreshAuth();
|
||||
if (!cancelled) await refresh();
|
||||
};
|
||||
|
||||
void load();
|
||||
return () => {
|
||||
cancelled = true;
|
||||
unsubscribe();
|
||||
};
|
||||
}, [backend, refresh]);
|
||||
|
||||
const ensureAuth = useCallback(async () => {
|
||||
if (backend.currentUser) return true;
|
||||
await backend.signInWithGitHub();
|
||||
await refreshVotes();
|
||||
return backend.currentUser !== null;
|
||||
}, [backend, refreshVotes]);
|
||||
|
||||
const requireUserId = useCallback(
|
||||
async (action: string) => {
|
||||
let authenticated = false;
|
||||
try {
|
||||
authenticated = await ensureAuth();
|
||||
} catch (caught) {
|
||||
setError(oauthErrorMessage(caught));
|
||||
return null;
|
||||
}
|
||||
|
||||
if (!authenticated) {
|
||||
setError(`Sign in to ${action}`);
|
||||
return null;
|
||||
}
|
||||
const userId = backend.currentUser?.id;
|
||||
if (!userId) {
|
||||
setError(`Sign in to ${action}`);
|
||||
return null;
|
||||
}
|
||||
return userId;
|
||||
},
|
||||
[backend, ensureAuth],
|
||||
);
|
||||
|
||||
const login = useCallback(async () => {
|
||||
try {
|
||||
await ensureAuth();
|
||||
setError(null);
|
||||
} catch (caught) {
|
||||
setError(oauthErrorMessage(caught));
|
||||
}
|
||||
}, [ensureAuth]);
|
||||
|
||||
const vote = useCallback(
|
||||
async (submissionId: string, value: 1 | -1) => {
|
||||
if (votingIds.current.has(submissionId)) return;
|
||||
votingIds.current.add(submissionId);
|
||||
|
||||
const userId = await requireUserId("vote");
|
||||
if (!userId) {
|
||||
votingIds.current.delete(submissionId);
|
||||
return;
|
||||
}
|
||||
|
||||
const prev = votes[submissionId];
|
||||
const isToggleOff = prev?.value === value;
|
||||
const [upD, downD] = voteDelta(prev?.value, value);
|
||||
|
||||
setVotes((v) => {
|
||||
const next = { ...v };
|
||||
if (isToggleOff) delete next[submissionId];
|
||||
else
|
||||
next[submissionId] = {
|
||||
...(prev ?? { id: "", submission: submissionId, user: userId, created: "", updated: "" }),
|
||||
value,
|
||||
} as WorkshopVote;
|
||||
return next;
|
||||
});
|
||||
setSubmissions((s) =>
|
||||
s.map((sub) =>
|
||||
sub.id === submissionId ? { ...sub, upvotes: sub.upvotes + upD, downvotes: sub.downvotes + downD } : sub,
|
||||
),
|
||||
);
|
||||
|
||||
try {
|
||||
const saved = await backend.vote(submissionId, value);
|
||||
setVotes((current) => {
|
||||
const next = { ...current };
|
||||
if (saved) next[submissionId] = saved;
|
||||
else delete next[submissionId];
|
||||
return next;
|
||||
});
|
||||
} catch (caught) {
|
||||
setVotes((v) => {
|
||||
const next = { ...v };
|
||||
if (prev) next[submissionId] = prev;
|
||||
else delete next[submissionId];
|
||||
return next;
|
||||
});
|
||||
setSubmissions((s) =>
|
||||
s.map((sub) =>
|
||||
sub.id === submissionId ? { ...sub, upvotes: sub.upvotes - upD, downvotes: sub.downvotes - downD } : sub,
|
||||
),
|
||||
);
|
||||
setError(errorMessage(caught, "Failed to register your vote"));
|
||||
} finally {
|
||||
votingIds.current.delete(submissionId);
|
||||
}
|
||||
},
|
||||
[backend, votes, requireUserId],
|
||||
);
|
||||
|
||||
const report = useCallback(
|
||||
async (
|
||||
submissionId: string,
|
||||
category: "malicious" | "spam" | "copyright" | "inappropriate" | "other",
|
||||
explanation: string,
|
||||
) => {
|
||||
try {
|
||||
if (!(await requireUserId("report"))) return;
|
||||
await backend.report(submissionId, category, explanation);
|
||||
} catch (caught) {
|
||||
setError(errorMessage(caught, "Failed to submit your report"));
|
||||
}
|
||||
},
|
||||
[backend, requireUserId],
|
||||
);
|
||||
|
||||
const deleteSubmission = useCallback(
|
||||
async (submissionId: string) => {
|
||||
try {
|
||||
await backend.delete(submissionId);
|
||||
setSubmissions((prev) => prev.filter((s) => s.id !== submissionId));
|
||||
return true;
|
||||
} catch (caught) {
|
||||
setError(errorMessage(caught, "Failed to delete submission"));
|
||||
return false;
|
||||
}
|
||||
},
|
||||
[backend],
|
||||
);
|
||||
|
||||
const submit = useCallback(
|
||||
async (input: SubmitInput): Promise<boolean> => {
|
||||
const validation = validateSubmissionContent(input.type, input.content);
|
||||
if (!validation.success) throw new Error(validation.error);
|
||||
if (!(await ensureAuth())) throw new Error("Sign in required to submit");
|
||||
await backend.create(
|
||||
{
|
||||
type: input.type,
|
||||
title: input.title,
|
||||
description: input.description,
|
||||
content: input.content,
|
||||
changelog: input.changelog || "Initial publication",
|
||||
outdated: false,
|
||||
},
|
||||
input.screenshots,
|
||||
);
|
||||
await refresh();
|
||||
return true;
|
||||
},
|
||||
[backend, ensureAuth, refresh],
|
||||
);
|
||||
|
||||
const fetchComments = useCallback((submissionId: string) => backend.listComments(submissionId), [backend]);
|
||||
|
||||
const addComment = useCallback(
|
||||
async (submissionId: string, content: string) => {
|
||||
const userId = await requireUserId("comment");
|
||||
if (!userId) return null;
|
||||
try {
|
||||
return await backend.createComment(submissionId, content);
|
||||
} catch (caught) {
|
||||
setError(errorMessage(caught, "Failed to post comment"));
|
||||
return null;
|
||||
}
|
||||
},
|
||||
[backend, requireUserId],
|
||||
);
|
||||
|
||||
const updateComment = useCallback(
|
||||
async (commentId: string, content: string) => {
|
||||
try {
|
||||
return await backend.updateComment(commentId, content);
|
||||
} catch (caught) {
|
||||
setError(errorMessage(caught, "Failed to update comment"));
|
||||
return null;
|
||||
}
|
||||
},
|
||||
[backend],
|
||||
);
|
||||
|
||||
const deleteComment = useCallback(
|
||||
async (commentId: string) => {
|
||||
try {
|
||||
await backend.deleteComment(commentId);
|
||||
return true;
|
||||
} catch (caught) {
|
||||
setError(errorMessage(caught, "Failed to delete comment"));
|
||||
return false;
|
||||
}
|
||||
},
|
||||
[backend],
|
||||
);
|
||||
|
||||
const logout = useCallback(() => backend.signOut(), [backend]);
|
||||
|
||||
const comments = useMemo<CommentActions>(
|
||||
() => ({
|
||||
fetch: fetchComments,
|
||||
add: addComment,
|
||||
update: updateComment,
|
||||
delete: deleteComment,
|
||||
}),
|
||||
[fetchComments, addComment, updateComment, deleteComment],
|
||||
);
|
||||
|
||||
return {
|
||||
backend,
|
||||
submissions,
|
||||
votes,
|
||||
user,
|
||||
loading,
|
||||
error,
|
||||
sorters,
|
||||
comments,
|
||||
refresh,
|
||||
login,
|
||||
logout,
|
||||
vote,
|
||||
report,
|
||||
submit,
|
||||
deleteSubmission,
|
||||
};
|
||||
};
|
||||
@@ -0,0 +1,16 @@
|
||||
import type { WorkshopSubmission } from "@site/src/lib/pocketbase";
|
||||
|
||||
export const downloadSubmissionJson = (submission: WorkshopSubmission) => {
|
||||
const url = URL.createObjectURL(new Blob([submission.content], { type: "application/json" }));
|
||||
Object.assign(document.createElement("a"), {
|
||||
href: url,
|
||||
download: `${submission.title.replace(/[^a-z0-9-_]+/gi, "-").toLowerCase()}.json`,
|
||||
}).click();
|
||||
URL.revokeObjectURL(url);
|
||||
};
|
||||
|
||||
export const voteDelta = (prev: 1 | -1 | undefined, next: 1 | -1): [up: number, down: number] => {
|
||||
if (!prev) return next === 1 ? [1, 0] : [0, 1];
|
||||
if (prev === next) return next === 1 ? [-1, 0] : [0, -1];
|
||||
return next === 1 ? [1, -1] : [-1, 1];
|
||||
};
|
||||
Reference in new issue
Block a user