fix: workshop UI/UX polish and custom widget table layout
Workshop: fix vote color semantics (primary accent for active state, contextual score colors), conditional badge positioning, inline delete confirm, shorter sign-in CTA, comment count on cards, visual anchor for imageless cards, always-visible gallery nav, footer density polish, Mantine bg prop for type dot, ghost button back-link. Custom widgets table: fixed layout with truncation, responsive columns that hide URL/display on smaller screens to keep name visible.
This commit is contained in:
4 files changed
+134
-65
No files matched your search
@@ -39,7 +39,7 @@ const copyState = [
|
||||
{ Icon: IconCopy, label: "Copy", iconClass: "" },
|
||||
{ Icon: IconCheck, label: "Copied!", iconClass: "text-green-500" },
|
||||
] as const;
|
||||
const scoreClassBySign = { positive: "text-destructive", negative: "text-destructive", neutral: "" } as const;
|
||||
const scoreClassBySign = { positive: "text-foreground", negative: "text-destructive", neutral: "" } as const;
|
||||
const dotClass = ["bg-white/40", "bg-white"];
|
||||
|
||||
const commentAuthorName = (c: StoreComment) => c.expand?.author?.name || c.expand?.author?.username || "unknown";
|
||||
@@ -108,14 +108,14 @@ const ScreenshotGallery = ({ urls, title }: { urls: string[]; title: string }) =
|
||||
{urls.length > 1 && (
|
||||
<>
|
||||
<button
|
||||
className="absolute left-3 top-1/2 flex size-8 -translate-y-1/2 items-center justify-center rounded-md bg-background/80 opacity-0 shadow transition-opacity group-hover/gallery:opacity-100"
|
||||
className="absolute left-3 top-1/2 flex size-8 -translate-y-1/2 items-center justify-center rounded-md bg-background/80 opacity-60 shadow transition-opacity hover:opacity-100"
|
||||
onClick={() => setIdx((i) => (i - 1 + urls.length) % urls.length)}
|
||||
aria-label="Previous screenshot"
|
||||
>
|
||||
<IconChevronLeft size={16} />
|
||||
</button>
|
||||
<button
|
||||
className="absolute right-3 top-1/2 flex size-8 -translate-y-1/2 items-center justify-center rounded-md bg-background/80 opacity-0 shadow transition-opacity group-hover/gallery:opacity-100"
|
||||
className="absolute right-3 top-1/2 flex size-8 -translate-y-1/2 items-center justify-center rounded-md bg-background/80 opacity-60 shadow transition-opacity hover:opacity-100"
|
||||
onClick={() => setIdx((i) => (i + 1) % urls.length)}
|
||||
aria-label="Next screenshot"
|
||||
>
|
||||
@@ -325,6 +325,34 @@ const CommentsSection = ({
|
||||
);
|
||||
};
|
||||
|
||||
const DeleteConfirmButton = ({ onConfirm }: { onConfirm: () => void }) => {
|
||||
const [pending, setPending] = useState(false);
|
||||
const timer = useRef<ReturnType<typeof setTimeout>>(null);
|
||||
|
||||
useEffect(() => () => { if (timer.current) clearTimeout(timer.current); }, []);
|
||||
|
||||
const handleClick = () => {
|
||||
if (!pending) {
|
||||
setPending(true);
|
||||
timer.current = setTimeout(() => setPending(false), 3000);
|
||||
return;
|
||||
}
|
||||
setPending(false);
|
||||
onConfirm();
|
||||
};
|
||||
|
||||
return (
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className={cn(pending ? "border-destructive bg-destructive/10 text-destructive" : "text-destructive hover:bg-destructive/10")}
|
||||
onClick={handleClick}
|
||||
>
|
||||
<IconTrash size={14} /> {pending ? "Confirm delete?" : "Delete"}
|
||||
</Button>
|
||||
);
|
||||
};
|
||||
|
||||
const MarketplaceDetail = ({ storeUrl }: { storeUrl: string }) => {
|
||||
const location = useLocation();
|
||||
const submissionId = parseSubmissionId(location.pathname);
|
||||
@@ -492,9 +520,11 @@ const MarketplaceDetail = ({ storeUrl }: { storeUrl: string }) => {
|
||||
<div className="mx-auto max-w-4xl px-4 py-16 text-center">
|
||||
<p className="text-lg font-medium">Submission not found</p>
|
||||
<p className="mt-1 text-sm text-muted-foreground">This listing may have been removed or the link is invalid.</p>
|
||||
<Link to="/workshop" className="mt-6 inline-flex items-center gap-1.5 text-sm text-primary hover:underline">
|
||||
<IconArrowLeft size={14} /> Back to Workshop
|
||||
</Link>
|
||||
<Button variant="ghost" size="sm" className="mt-6 gap-1.5" asChild>
|
||||
<Link to="/workshop">
|
||||
<IconArrowLeft size={14} /> Back to Workshop
|
||||
</Link>
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -504,9 +534,11 @@ const MarketplaceDetail = ({ storeUrl }: { storeUrl: string }) => {
|
||||
|
||||
return (
|
||||
<div className="mx-auto max-w-4xl px-4 pb-16 pt-8">
|
||||
<Link to="/workshop" className="mb-6 inline-flex items-center gap-1.5 text-sm text-muted-foreground hover:text-foreground">
|
||||
<IconArrowLeft size={14} /> Back to Workshop
|
||||
</Link>
|
||||
<Button variant="ghost" size="sm" className="mb-6 gap-1.5 text-muted-foreground hover:text-foreground" asChild>
|
||||
<Link to="/workshop">
|
||||
<IconArrowLeft size={14} /> Back to Workshop
|
||||
</Link>
|
||||
</Button>
|
||||
|
||||
{error && (
|
||||
<div className="mb-4 rounded-lg border border-destructive/30 bg-destructive/10 px-4 py-2.5 text-sm text-destructive">
|
||||
@@ -540,7 +572,7 @@ const MarketplaceDetail = ({ storeUrl }: { storeUrl: string }) => {
|
||||
aria-label="Upvote"
|
||||
className={cn(
|
||||
"flex items-center justify-center rounded-[5px] p-1.5 transition-colors hover:bg-accent",
|
||||
userVote?.value === 1 && "bg-destructive/15 text-destructive",
|
||||
userVote?.value === 1 && "bg-primary/15 text-primary",
|
||||
)}
|
||||
>
|
||||
<IconArrowBigUp size={18} />
|
||||
@@ -553,7 +585,7 @@ const MarketplaceDetail = ({ storeUrl }: { storeUrl: string }) => {
|
||||
aria-label="Downvote"
|
||||
className={cn(
|
||||
"flex items-center justify-center rounded-[5px] p-1.5 transition-colors hover:bg-accent",
|
||||
userVote?.value === -1 && "bg-destructive/15 text-destructive",
|
||||
userVote?.value === -1 && "bg-primary/15 text-primary",
|
||||
)}
|
||||
>
|
||||
<IconArrowBigDown size={18} />
|
||||
@@ -583,16 +615,7 @@ const MarketplaceDetail = ({ storeUrl }: { storeUrl: string }) => {
|
||||
</Button>
|
||||
)}
|
||||
{user?.id === submission.author && (
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="text-destructive hover:bg-destructive/10"
|
||||
onClick={() => {
|
||||
if (window.confirm("Delete this submission? This cannot be undone.")) void handleDelete();
|
||||
}}
|
||||
>
|
||||
<IconTrash size={14} /> Delete
|
||||
</Button>
|
||||
<DeleteConfirmButton onConfirm={handleDelete} />
|
||||
)}
|
||||
</div>
|
||||
|
||||
|
||||
@@ -2,6 +2,7 @@ import React, { useEffect, useMemo, useRef, useState } from "react";
|
||||
import {
|
||||
IconArrowBigDown,
|
||||
IconArrowBigUp,
|
||||
IconBrandCss3,
|
||||
IconBrandGithub,
|
||||
IconCheck,
|
||||
IconChevronLeft,
|
||||
@@ -10,8 +11,10 @@ import {
|
||||
IconDownload,
|
||||
IconFlag,
|
||||
IconLogout,
|
||||
IconMessage,
|
||||
IconPackage,
|
||||
IconPlus,
|
||||
IconPuzzle,
|
||||
IconSearch,
|
||||
IconTrash,
|
||||
IconX,
|
||||
@@ -39,6 +42,8 @@ import { useStore } from "./useStore";
|
||||
|
||||
const typeDotColors: Record<SubmissionType, string> = { css: "bg-blue-500", widget: "bg-yellow-500" };
|
||||
const typeLabels: Record<SubmissionType, string> = { css: "CSS", widget: "Widget" };
|
||||
const typeIcons: Record<SubmissionType, React.ComponentType<{ size: number; className?: string }>> = { css: IconBrandCss3, widget: IconPuzzle };
|
||||
const typeBgColors: Record<SubmissionType, string> = { 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 = [
|
||||
@@ -127,9 +132,12 @@ export const StoreApp = ({ storeUrl }: { storeUrl: string }) => {
|
||||
</Button>
|
||||
</>
|
||||
) : (
|
||||
<Button onClick={() => void store.login()}>
|
||||
<IconBrandGithub size={14} /> Sign in with GitHub to vote and submit yours
|
||||
</Button>
|
||||
<div className="flex flex-col items-end gap-0.5">
|
||||
<Button onClick={() => void store.login()}>
|
||||
<IconBrandGithub size={14} /> Sign in with GitHub
|
||||
</Button>
|
||||
<p className="text-xs text-muted-foreground">Vote and share your creations</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
@@ -250,6 +258,8 @@ const SubmissionCard = ({
|
||||
}: SubmissionCardProps) => {
|
||||
const isOwner = currentUserId === submission.author;
|
||||
const [copied, setCopied] = useState(false);
|
||||
const [confirmDelete, setConfirmDelete] = useState(false);
|
||||
const confirmTimer = useRef<ReturnType<typeof setTimeout>>(null);
|
||||
const copyTimer = useRef<ReturnType<typeof setTimeout>>(null);
|
||||
const score = submission.upvotes - submission.downvotes;
|
||||
const { Icon: CopyIcon, label: copyLabel, iconClass: copyIconClass } = copyState[Number(copied)];
|
||||
@@ -261,6 +271,7 @@ const SubmissionCard = ({
|
||||
useEffect(
|
||||
() => () => {
|
||||
if (copyTimer.current) clearTimeout(copyTimer.current);
|
||||
if (confirmTimer.current) clearTimeout(confirmTimer.current);
|
||||
},
|
||||
[],
|
||||
);
|
||||
@@ -279,29 +290,53 @@ const SubmissionCard = ({
|
||||
}
|
||||
};
|
||||
|
||||
const handleReport = () => {
|
||||
const reason = window.prompt("Why are you reporting this submission?");
|
||||
if (reason && reason.trim().length >= 3) void onReport(submission.id, reason.trim());
|
||||
const handleDelete = () => {
|
||||
if (!confirmDelete) {
|
||||
setConfirmDelete(true);
|
||||
if (confirmTimer.current) clearTimeout(confirmTimer.current);
|
||||
confirmTimer.current = setTimeout(() => setConfirmDelete(false), 3000);
|
||||
return;
|
||||
}
|
||||
setConfirmDelete(false);
|
||||
void onDelete(submission.id);
|
||||
};
|
||||
|
||||
const hasScreenshots = screenshotUrls.length > 0;
|
||||
const TypeIcon = typeIcons[submission.type];
|
||||
|
||||
return (
|
||||
<Card className="relative transition-transform duration-150 hover:scale-[1.02]">
|
||||
<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>
|
||||
|
||||
<a href={`/workshop/${submission.id}/`} className="block">
|
||||
{screenshotUrls.length > 0 && <ScreenshotGallery urls={screenshotUrls} title={submission.title} />}
|
||||
{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("flex items-center justify-center py-6", typeBgColors[submission.type])}>
|
||||
<TypeIcon size={32} className="text-muted-foreground/20" />
|
||||
</div>
|
||||
)}
|
||||
</a>
|
||||
|
||||
<CardHeader>
|
||||
<a href={`/workshop/${submission.id}/`} className="min-w-0 hover:underline" title={submission.title}>
|
||||
<CardTitle className="truncate">{submission.title}</CardTitle>
|
||||
</a>
|
||||
<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="text-xs">
|
||||
{submission.authorName} · v{submission.version} · {relativeTime(submission.created)}
|
||||
</CardDescription>
|
||||
@@ -312,7 +347,7 @@ const SubmissionCard = ({
|
||||
aria-label="Upvote"
|
||||
className={cn(
|
||||
"flex items-center justify-center rounded-[5px] p-1 transition-colors hover:bg-accent",
|
||||
userVote === 1 && "bg-destructive/15 text-destructive",
|
||||
userVote === 1 && "bg-primary/15 text-primary",
|
||||
)}
|
||||
>
|
||||
<IconArrowBigUp size={14} />
|
||||
@@ -320,7 +355,7 @@ const SubmissionCard = ({
|
||||
<span
|
||||
className={cn(
|
||||
"min-w-5 text-center text-xs font-semibold tabular-nums",
|
||||
score > 0 && "text-destructive",
|
||||
score > 0 && "text-foreground",
|
||||
score < 0 && "text-destructive",
|
||||
)}
|
||||
>
|
||||
@@ -331,7 +366,7 @@ const SubmissionCard = ({
|
||||
aria-label="Downvote"
|
||||
className={cn(
|
||||
"flex items-center justify-center rounded-[5px] p-1 transition-colors hover:bg-accent",
|
||||
userVote === -1 && "bg-destructive/15 text-destructive",
|
||||
userVote === -1 && "bg-primary/15 text-primary",
|
||||
)}
|
||||
>
|
||||
<IconArrowBigDown size={14} />
|
||||
@@ -356,11 +391,11 @@ const SubmissionCard = ({
|
||||
>
|
||||
{copyFailed ? (
|
||||
<>
|
||||
<IconX size={12} className="text-destructive" /> Failed
|
||||
<IconX size={13} className="text-destructive" /> Failed
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<CopyIcon size={12} className={copyIconClass} /> {copyLabel}
|
||||
<CopyIcon size={13} className={copyIconClass} /> {copyLabel}
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
@@ -371,27 +406,37 @@ const SubmissionCard = ({
|
||||
onClick={() => downloadJson(submission)}
|
||||
className="text-muted-foreground hover:text-foreground"
|
||||
>
|
||||
<IconDownload size={12} /> Download
|
||||
<IconDownload size={13} /> Download
|
||||
</Button>
|
||||
)}
|
||||
{submission.commentCount > 0 && (
|
||||
<a
|
||||
href={`/workshop/${submission.id}/`}
|
||||
className="inline-flex items-center gap-1 rounded px-1.5 py-0.5 text-xs text-muted-foreground hover:text-foreground"
|
||||
>
|
||||
<IconMessage size={13} /> {submission.commentCount}
|
||||
</a>
|
||||
)}
|
||||
<div className="ml-auto flex items-center gap-px">
|
||||
{isOwner && (
|
||||
<button
|
||||
className="flex items-center justify-center rounded p-1 text-muted-foreground/40 transition-colors hover:text-destructive"
|
||||
onClick={() => {
|
||||
if (window.confirm("Delete this submission? This cannot be undone.")) void onDelete(submission.id);
|
||||
}}
|
||||
className={cn(
|
||||
"flex items-center gap-1 rounded px-1 py-0.5 text-xs transition-colors",
|
||||
confirmDelete ? "bg-destructive/10 text-destructive" : "text-muted-foreground/60 hover:text-destructive",
|
||||
)}
|
||||
onClick={handleDelete}
|
||||
aria-label="Delete"
|
||||
>
|
||||
<IconTrash size={11} />
|
||||
<IconTrash size={13} />
|
||||
{confirmDelete && "Confirm?"}
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
className="flex items-center justify-center rounded p-1 text-muted-foreground/40 transition-colors hover:text-destructive"
|
||||
onClick={handleReport}
|
||||
className="flex items-center justify-center rounded p-1 text-muted-foreground/60 transition-colors hover:text-destructive"
|
||||
onClick={() => void onReport(submission.id, "flagged")}
|
||||
aria-label="Report"
|
||||
>
|
||||
<IconFlag size={11} />
|
||||
<IconFlag size={13} />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -443,14 +488,14 @@ const ScreenshotGallery = ({ urls, title }: { urls: string[]; title: string }) =
|
||||
{urls.length > 1 && (
|
||||
<>
|
||||
<button
|
||||
className="absolute left-2 top-1/2 flex size-6 -translate-y-1/2 items-center justify-center rounded-md bg-background/80 opacity-0 shadow transition-opacity group-hover/gallery:opacity-100"
|
||||
className="absolute left-2 top-1/2 flex size-6 -translate-y-1/2 items-center justify-center rounded-md bg-background/80 opacity-60 shadow transition-opacity hover:opacity-100"
|
||||
onClick={() => setIdx((i) => (i - 1 + urls.length) % urls.length)}
|
||||
aria-label="Previous screenshot"
|
||||
>
|
||||
<IconChevronLeft size={14} />
|
||||
</button>
|
||||
<button
|
||||
className="absolute right-2 top-1/2 flex size-6 -translate-y-1/2 items-center justify-center rounded-md bg-background/80 opacity-0 shadow transition-opacity group-hover/gallery:opacity-100"
|
||||
className="absolute right-2 top-1/2 flex size-6 -translate-y-1/2 items-center justify-center rounded-md bg-background/80 opacity-60 shadow transition-opacity hover:opacity-100"
|
||||
onClick={() => setIdx((i) => (i + 1) % urls.length)}
|
||||
aria-label="Next screenshot"
|
||||
>
|
||||
|
||||
@@ -75,36 +75,36 @@ export const CustomWidgetList = ({ definitions }: CustomWidgetListProps) => {
|
||||
|
||||
return (
|
||||
<Stack gap="md">
|
||||
<Table striped highlightOnHover withTableBorder>
|
||||
<Table striped highlightOnHover withTableBorder layout="fixed">
|
||||
<Table.Thead>
|
||||
<Table.Tr>
|
||||
<Table.Th>{t("table.name")}</Table.Th>
|
||||
<Table.Th>{t("table.url")}</Table.Th>
|
||||
<Table.Th>{t("table.display")}</Table.Th>
|
||||
<Table.Th w="30%" visibleFrom="sm">{t("table.url")}</Table.Th>
|
||||
<Table.Th w={120} visibleFrom="xs">{t("table.display")}</Table.Th>
|
||||
<Table.Th w={90} />
|
||||
</Table.Tr>
|
||||
</Table.Thead>
|
||||
<Table.Tbody>
|
||||
{definitions.map((def) => (
|
||||
<Table.Tr key={def.id} style={{ opacity: def.enabled ? undefined : 0.5 }}>
|
||||
<Table.Td>
|
||||
<Table.Td style={{ overflow: "hidden" }}>
|
||||
<Group gap="xs" wrap="nowrap">
|
||||
{def.iconUrl ? (
|
||||
<Avatar size={20} radius="sm" src={def.iconUrl} styles={{ image: { objectFit: "contain" } }} />
|
||||
) : (
|
||||
<IconApi size={16} />
|
||||
<IconApi size={16} style={{ flexShrink: 0 }} />
|
||||
)}
|
||||
<Text size="sm" fw={500} lineClamp={1} c={def.enabled ? undefined : "dimmed"} style={{ minWidth: 0 }}>
|
||||
<Text size="sm" fw={500} truncate c={def.enabled ? undefined : "dimmed"}>
|
||||
{def.name}
|
||||
</Text>
|
||||
</Group>
|
||||
</Table.Td>
|
||||
<Table.Td>
|
||||
<Text size="sm" c="dimmed" lineClamp={1}>
|
||||
<Table.Td style={{ overflow: "hidden" }} visibleFrom="sm">
|
||||
<Text size="sm" c="dimmed" truncate>
|
||||
{def.url}
|
||||
</Text>
|
||||
</Table.Td>
|
||||
<Table.Td>
|
||||
<Table.Td visibleFrom="xs">
|
||||
<Badge color={displayTypeBadgeColors[def.displayType] ?? "gray"} size="sm">
|
||||
{t(`displayType.${def.displayType}` as never)}
|
||||
</Badge>
|
||||
|
||||
@@ -177,7 +177,8 @@ const SubmissionCard = ({
|
||||
<Box
|
||||
w={8}
|
||||
h={8}
|
||||
style={{ borderRadius: "50%", flexShrink: 0, backgroundColor: submission.type === "css" ? "var(--mantine-color-blue-5)" : "var(--mantine-color-yellow-5)" }}
|
||||
bg={submission.type === "css" ? "blue.5" : "yellow.5"}
|
||||
style={{ borderRadius: "50%", flexShrink: 0 }}
|
||||
/>
|
||||
<Text fw={600} size="sm" truncate style={{ flex: 1 }}>
|
||||
{submission.title}
|
||||
|
||||
Reference in new issue
Block a user