feat(workshop): rebuild install and publish as pages

Replace the stacked install modals (browse -> details -> import review)
with page-based flows so the primary action is always reachable:

- /manage/custom-widgets/workshop browses submissions as a card grid
  with large previews, sitting in a tab next to the installed widgets
- /manage/custom-widgets/workshop/[id] merges the listing, security
  review and source setup onto one page with a sticky install footer
- /manage/custom-widgets/publish/[id] replaces the publish modal
- /manage/workshop redirects; its navbar entry is gone
- the Custom CSS picker collapses from three modals to one

Extract useCustomWidgetImport and ImportReviewContent so the Workshop
page and the file/clipboard dialog share one import pipeline.

Votes now show the viewer's own choice filled Reddit-style, backed by a
new user-votes query, and screenshots get a zoomable lightbox.

Rework the installed list onto ManageCollectionItem: show the widget
description, replace the cryptic "JSX - 2" badge with plain request and
origin facts, and swap the toggle and bare pencil for a status badge,
a labelled Edit button and a menu entry for enable/disable.

Fixes found while reviewing:

- importing could run twice and duplicate a widget; guard on pending and
  succeeded, and return to the installed list instead of the heavy edit
  form
- a background refetch of the submission (window focus, or the
  invalidation a vote triggers) reset the source setup and silently wiped
  part-typed credentials; key the reset on the sources, not the object
- breadcrumbs raised MISSING_MESSAGE for unmapped dynamic segments; fall
  back to the raw segment via t.has
- publishing had no in-flight guard and could post duplicate listings
- tooltips on disabled vote and report controls never fired, making the
  sign-in hint unreachable
This commit is contained in:
Thomas Camlong committed 2026-08-17 01:14:38 +02:00
1 parent 50537d077c
commit 206905ea2d
38 files changed
+2303 -1700

No files matched your search

@@ -1,24 +1,11 @@
import type { ReactNode } from "react";
import {
Alert,
Badge,
Box,
Button,
Code,
Group,
Modal,
Paper,
SimpleGrid,
Stack,
Text,
} from "@mantine/core";
import { Alert, Badge, Box, Button, Code, Group, Modal, Paper, SimpleGrid, Stack, Text } from "@mantine/core";
import type { ModalProps } from "@mantine/core";
import { IconAlertTriangle } from "@tabler/icons-react";
import type { ImportReview } from "../core/import";
export interface ImportReviewMessages {
title: string;
export interface ImportReviewContentMessages {
description: string;
name: string;
origin: string;
@@ -28,9 +15,13 @@ export interface ImportReviewMessages {
permissions: string;
actionWarningTitle: string;
actionWarningDescription: string;
permission(permission: string): string;
}
export interface ImportReviewMessages extends ImportReviewContentMessages {
title: string;
cancel: string;
confirm: string;
permission(permission: string): string;
}
export interface ImportReviewDialogProps {
@@ -75,6 +66,71 @@ function ImportFact({ label, value }: { label: string; value: string }) {
);
}
export interface ImportReviewContentProps {
review: ImportReview;
messages: ImportReviewContentMessages;
children?: ReactNode;
}
/**
* The security facts a user has to review before a custom widget is installed.
* Shared by the import modal and the Workshop install page so both read identically.
*/
export function ImportReviewContent({ review, messages, children }: ImportReviewContentProps) {
return (
<Stack gap="md">
<Text size="sm" c="dimmed">
{messages.description}
</Text>
<SimpleGrid cols={{ base: 1, sm: 2 }} spacing="xs">
<ImportFact label={messages.name} value={review.name} />
<ImportFact label={messages.origin} value={review.origins.join(", ")} />
<ImportFact label={messages.authentication} value={review.authTypes.join(", ")} />
<ImportFact label={messages.networkScope} value={review.networkScopes.join(", ")} />
</SimpleGrid>
<SimpleGrid cols={{ base: 1, sm: 2 }} spacing="md">
<div>
<Text size="sm" fw={600} mb={6}>
{messages.methods}
</Text>
<Group gap={6}>
{review.methods.map((method) => (
<Badge
key={method}
color={method === "DELETE" ? "red" : method === "GET" ? "blue" : "orange"}
variant="light"
>
{method}
</Badge>
))}
</Group>
</div>
<div>
<Text size="sm" fw={600} mb={6}>
{messages.permissions}
</Text>
<Group gap={6}>
{review.permissions.map((permission) => (
<Badge key={permission} color="gray" variant="light">
{messages.permission(permission)}
</Badge>
))}
</Group>
</div>
</SimpleGrid>
{review.hasActions && (
<Alert color="yellow" icon={<IconAlertTriangle size={16} />}>
<Text size="sm" fw={600}>
{messages.actionWarningTitle}
</Text>
<Text size="sm">{messages.actionWarningDescription}</Text>
</Alert>
)}
{children}
</Stack>
);
}
export function ImportReviewDialog({
opened,
review,
@@ -103,56 +159,9 @@ export function ImportReviewDialog({
{review && (
<>
<Box p="lg" style={{ flex: 1, minHeight: 0, overflowY: "auto" }}>
<Stack gap="md">
<Text size="sm" c="dimmed">
{messages.description}
</Text>
<SimpleGrid cols={{ base: 1, sm: 2 }} spacing="xs">
<ImportFact label={messages.name} value={review.name} />
<ImportFact label={messages.origin} value={review.origins.join(", ")} />
<ImportFact label={messages.authentication} value={review.authTypes.join(", ")} />
<ImportFact label={messages.networkScope} value={review.networkScopes.join(", ")} />
</SimpleGrid>
<SimpleGrid cols={{ base: 1, sm: 2 }} spacing="md">
<div>
<Text size="sm" fw={600} mb={6}>
{messages.methods}
</Text>
<Group gap={6}>
{review.methods.map((method) => (
<Badge
key={method}
color={method === "DELETE" ? "red" : method === "GET" ? "blue" : "orange"}
variant="light"
>
{method}
</Badge>
))}
</Group>
</div>
<div>
<Text size="sm" fw={600} mb={6}>
{messages.permissions}
</Text>
<Group gap={6}>
{review.permissions.map((permission) => (
<Badge key={permission} color="gray" variant="light">
{messages.permission(permission)}
</Badge>
))}
</Group>
</div>
</SimpleGrid>
{review.hasActions && (
<Alert color="yellow" icon={<IconAlertTriangle size={16} />}>
<Text size="sm" fw={600}>
{messages.actionWarningTitle}
</Text>
<Text size="sm">{messages.actionWarningDescription}</Text>
</Alert>
)}
<ImportReviewContent review={review} messages={messages}>
{children}
</Stack>
</ImportReviewContent>
</Box>
<Box
px="lg"