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

@@ -19,16 +19,17 @@ export const ManageCollectionPage = ({
...layoutProps
}: ManageCollectionPageProps) => (
<ManagePageLayout {...layoutProps}>
{itemCount === 0 ? (
emptyState
) : (
<Stack component="ul" aria-label={ariaLabel} gap="sm" m={0} p={0} style={{ listStyle: "none" }}>
{children}
</Stack>
)}
{itemCount === 0 ? emptyState : <ManageCollectionList ariaLabel={ariaLabel}>{children}</ManageCollectionList>}
</ManagePageLayout>
);
/** The `ul` wrapper used by every manage list, so collections rendered outside `ManageCollectionPage` still match. */
export const ManageCollectionList = ({ ariaLabel, children }: { ariaLabel: string; children: ReactNode }) => (
<Stack component="ul" aria-label={ariaLabel} gap="sm" m={0} p={0} style={{ listStyle: "none" }}>
{children}
</Stack>
);
export interface ManageCollectionItemProps {
leading?: ReactNode;
title: ReactNode;
@@ -8,6 +8,8 @@ import { MANAGE_FLOATING_ACTION_BOTTOM_OFFSET } from "./manage-page.constants";
export interface ManagePageLayoutProps {
title: ReactNode;
/** Overrides the default breadcrumb, e.g. to map a dynamic route segment onto a readable name. */
breadcrumb?: ReactNode;
primaryAction?: ReactNode;
toolbar?: ReactNode;
footer?: ReactNode;
@@ -18,6 +20,7 @@ export interface ManagePageLayoutProps {
export const ManagePageLayout = ({
title,
breadcrumb,
primaryAction,
toolbar,
footer,
@@ -29,7 +32,7 @@ export const ManagePageLayout = ({
return (
<ManageContainer size={size}>
<DynamicBreadcrumb />
{breadcrumb ?? <DynamicBreadcrumb />}
<Stack pb={floatingPrimaryAction ? { base: MANAGE_FLOATING_ACTION_BOTTOM_OFFSET, md: 0 } : undefined}>
<Group justify="space-between" align="center">
{titleNode}
@@ -0,0 +1,35 @@
import type { ReactNode } from "react";
import { Group, Paper } from "@mantine/core";
export interface ManageStickyFooterProps {
/** Secondary controls, pinned to the start of the bar. */
secondary?: ReactNode;
/** The primary action(s) of the page, pinned to the end of the bar. */
children: ReactNode;
}
/**
* A bar that stays visible at the bottom of the viewport while the page scrolls,
* so the primary action of a long page is always one click away.
*/
export const ManageStickyFooter = ({ secondary, children }: ManageStickyFooterProps) => (
<Paper
withBorder
shadow="md"
radius="md"
p="sm"
pos="sticky"
bottom="var(--mantine-spacing-md)"
bg="var(--mantine-color-body)"
style={{ zIndex: 2 }}
>
<Group justify="space-between" align="center" gap="sm" wrap="wrap">
<Group gap="xs" wrap="wrap">
{secondary}
</Group>
<Group gap="sm" wrap="nowrap" ml="auto">
{children}
</Group>
</Group>
</Paper>
);