import React, { useEffect, useMemo, useRef, useState } from "react"; import { IconArrowBigDown, IconArrowBigUp, IconBrandCss3, IconBrandGithub, IconCheck, IconChevronLeft, IconChevronRight, IconCopy, IconDownload, IconFlag, IconLogout, IconMessage, IconPackage, IconPlus, IconPuzzle, IconSearch, IconTrash, IconX, } from "@tabler/icons-react"; import { getSubmissionFileUrl, type StoreSubmission } from "@site/src/lib/pocketbase"; import type { SubmissionType } from "@site/src/lib/store-schema"; 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 { cn } from "@/lib/utils"; import { SubmitForm } from "./SubmitForm"; import type { SortKey, TypeFilter } from "./useStore"; import { useStore } from "./useStore"; const typeDotColors: Record = { css: "bg-blue-500", widget: "bg-yellow-500" }; const typeLabels: Record = { css: "CSS", widget: "Widget" }; const typeIcons: Record> = { css: IconBrandCss3, widget: IconPuzzle, }; const typeBgColors: Record = { css: "bg-blue-500/5", widget: "bg-yellow-500/5" }; const filterActiveClass = "bg-background text-foreground shadow-sm"; const filterInactiveClass = "text-muted-foreground hover:text-foreground"; const copyState = [ { Icon: IconCopy, label: "Copy", iconClass: "" }, { Icon: IconCheck, label: "Copied!", iconClass: "text-green-500" }, ] as const; const emptyState = { none: { title: "No submissions yet", hint: "Be the first to share something." }, filtered: { title: "No matching results", hint: "Try adjusting your filters or search." }, } as const; const typeFilters: { value: TypeFilter; label: string; dot?: string }[] = [ { value: "all", label: "All" }, { value: "widget", label: "Widgets", dot: typeDotColors.widget }, { value: "css", label: "CSS", dot: typeDotColors.css }, { value: "yours", label: "Yours" }, ]; const sortOptions: { value: SortKey; label: string }[] = [ { value: "top", label: "Top rated" }, { value: "new", label: "Newest" }, ]; const relativeTime = (date: string) => { const minutes = Math.floor((Date.now() - new Date(date).getTime()) / 60000); if (minutes < 1) return "just now"; if (minutes < 60) return `${minutes}m ago`; const hours = Math.floor(minutes / 60); if (hours < 24) return `${hours}h ago`; return `${Math.floor(hours / 24)}d ago`; }; const downloadJson = (s: StoreSubmission) => { const url = URL.createObjectURL(new Blob([s.content], { type: "application/json" })); Object.assign(document.createElement("a"), { href: url, download: `${s.title.replace(/[^a-z0-9-_]+/gi, "-").toLowerCase()}.json`, }).click(); URL.revokeObjectURL(url); }; export const StoreApp = ({ storeUrl }: { storeUrl: string }) => { const store = useStore(storeUrl); const [typeFilter, setTypeFilter] = useState("all"); const [sort, setSort] = useState("top"); const [search, setSearch] = useState(""); 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(() => store.submissions.map((s) => s.id).join(","), [store.submissions]); const sortedIds = useMemo(() => { const q = search.toLowerCase(); return store.submissions .filter((item) => { if (typeFilter === "yours") return item.author === store.user?.id; return typeFilter === "all" || item.type === typeFilter; }) .filter((item) => !q || item.title.toLowerCase().includes(q)) .toSorted(store.sorters[sort]) .map((s) => s.id); // eslint-disable-next-line react-hooks/exhaustive-deps -- intentionally keyed on submissionIds string, not the array }, [submissionIds, store.sorters, typeFilter, sort, search, store.user?.id]); const visible = useMemo(() => { const byId = new Map(store.submissions.map((s) => [s.id, s])); return sortedIds.map((id) => byId.get(id)).filter(Boolean) as StoreSubmission[]; }, [store.submissions, sortedIds]); const empty = emptyState[store.submissions.length === 0 ? "none" : "filtered"]; return (

Workshop

Community custom widgets and CSS themes for Homarr.

{store.user ? ( <> ) : (

Vote and share your creations

)}
{typeFilters.map((opt) => ( ))}
setSearch(e.target.value)} />
{store.error && (
{store.error}
)} {store.loading && (
{Array.from({ length: 6 }, (_, i) => ( ))}
)} {!store.loading && visible.length === 0 && (

{empty.title}

{empty.hint}

{store.user && store.submissions.length === 0 && ( )}
)}
{visible.map((submission) => ( ))}
{showSubmit && ( setShowSubmit(false)} onSubmit={async (input) => { if (await store.submit(input)) setShowSubmit(false); }} /> )}
); }; interface SubmissionCardProps { submission: StoreSubmission; pb: ReturnType["pb"]; userVote?: 1 | -1; currentUserId?: string; onVote: (submissionId: string, value: 1 | -1) => void; onReport: (submissionId: string, reason: string) => void; onDelete: (submissionId: string) => Promise; } const SubmissionCard = ({ submission, pb, userVote, currentUserId, onVote, onReport, onDelete, }: SubmissionCardProps) => { const isOwner = currentUserId === submission.author; const [copied, setCopied] = useState(false); const [confirmDelete, setConfirmDelete] = useState(false); const confirmTimer = useRef>(null); const copyTimer = useRef>(null); const score = submission.upvotes - submission.downvotes; const { Icon: CopyIcon, label: copyLabel, iconClass: copyIconClass } = copyState[Number(copied)]; const screenshotUrls = useMemo( () => submission.screenshots?.map((f) => getSubmissionFileUrl(pb.baseURL, submission.id, f)) ?? [], [submission, pb], ); useEffect( () => () => { if (copyTimer.current) clearTimeout(copyTimer.current); if (confirmTimer.current) clearTimeout(confirmTimer.current); }, [], ); const [copyFailed, setCopyFailed] = useState(false); const handleCopy = async () => { try { await navigator.clipboard.writeText(submission.content); setCopied(true); setCopyFailed(false); if (copyTimer.current) clearTimeout(copyTimer.current); copyTimer.current = setTimeout(() => setCopied(false), 1500); } catch { setCopyFailed(true); setTimeout(() => setCopyFailed(false), 2000); } }; const handleDelete = () => { if (!confirmDelete) { setConfirmDelete(true); if (confirmTimer.current) clearTimeout(confirmTimer.current); confirmTimer.current = setTimeout(() => setConfirmDelete(false), 3000); return; } setConfirmDelete(false); void onDelete(submission.id); }; const hasScreenshots = screenshotUrls.length > 0; const TypeIcon = typeIcons[submission.type]; return ( {hasScreenshots ? (
{typeLabels[submission.type]}
) : (
)}
{submission.title} {!hasScreenshots && ( {typeLabels[submission.type]} )}
{submission.authorName} · v{submission.version} · {relativeTime(submission.created)}
0 && "text-foreground", score < 0 && "text-destructive", )} > {score}
{submission.description && (

{submission.description}

)}
{submission.type === "widget" && ( )} {submission.commentCount > 0 && ( {submission.commentCount} )}
{isOwner && ( )}
); }; const SkeletonCard = ({ hasImage }: { hasImage: boolean }) => ( {hasImage &&
}
); const ScreenshotGallery = ({ urls, title }: { urls: string[]; title: string }) => { const [idx, setIdx] = useState(0); const dotClass = ["bg-white/40", "bg-white"]; return (
{`${title}
{urls.length > 1 && ( <>
{urls.map((_, i) => (
)}
); };