fix(custom-widgets): harden v2 authoring

This commit is contained in:
Thomas Camlong committed 2026-07-20 22:16:18 +02:00
1 parent ee832ad3c6
commit 7c66c912e7
171 files changed
+20311 -50527

No files matched your search

@@ -0,0 +1,459 @@
.explorer {
--catalog-border: color-mix(in srgb, var(--ifm-color-emphasis-500) 42%, transparent);
--catalog-surface: color-mix(in srgb, var(--ifm-background-surface-color) 90%, var(--ifm-color-primary) 10%);
border: 1px solid var(--catalog-border);
border-radius: 18px;
margin: 1.5rem 0 2rem;
overflow: hidden;
}
.header {
align-items: flex-start;
background: linear-gradient(135deg, color-mix(in srgb, var(--ifm-color-primary) 13%, transparent), transparent 65%);
display: flex;
gap: 1.5rem;
justify-content: space-between;
padding: 1.4rem;
}
.eyebrow {
color: var(--ifm-color-primary);
font-size: 0.72rem;
font-weight: 750;
letter-spacing: 0.09em;
margin: 0 0 0.35rem;
text-transform: uppercase;
}
.title {
font-size: clamp(1.25rem, 3vw, 1.65rem);
margin: 0;
}
.summary {
color: var(--ifm-color-emphasis-700);
margin: 0.45rem 0 0;
max-width: 68ch;
}
.rawLink,
.expandButton,
.showMoreButton,
.resetButton {
align-items: center;
border-radius: 9px;
cursor: pointer;
display: inline-flex;
font: inherit;
font-weight: 650;
justify-content: center;
}
.rawLink,
.expandButton,
.showMoreButton {
background: var(--ifm-color-primary);
border: 1px solid var(--ifm-color-primary);
color: var(--ifm-color-white);
padding: 0.58rem 0.85rem;
text-decoration: none;
}
.rawLink {
flex: 0 0 auto;
white-space: nowrap;
}
.rawLink:hover,
.expandButton:hover,
.showMoreButton:hover {
background: var(--ifm-color-primary-dark);
color: var(--ifm-color-white);
text-decoration: none;
}
.versions {
border-bottom: 1px solid var(--catalog-border);
border-top: 1px solid var(--catalog-border);
display: grid;
grid-template-columns: repeat(4, minmax(0, 1fr));
margin: 0;
}
.globalProps {
align-items: center;
border-bottom: 1px solid var(--catalog-border);
display: grid;
gap: 1rem;
grid-template-columns: minmax(0, 1fr) auto;
padding: 1rem 1.4rem;
scroll-margin-top: calc(var(--ifm-navbar-height) + 1rem);
}
.globalProps h4,
.globalProps p {
margin: 0;
}
.globalProps p {
color: var(--ifm-color-emphasis-700);
font-size: 0.86rem;
margin-top: 0.2rem;
}
.globalPropsTable {
grid-column: 1 / -1;
}
.versions div {
padding: 0.75rem 1.4rem;
}
.versions div + div {
border-left: 1px solid var(--catalog-border);
}
.versions dt {
color: var(--ifm-color-emphasis-700);
font-size: 0.72rem;
font-weight: 650;
text-transform: uppercase;
}
.versions dd {
font-family: var(--ifm-font-family-monospace);
font-size: 0.86rem;
margin: 0.12rem 0 0;
}
.filters {
align-items: end;
background: var(--catalog-surface);
display: grid;
gap: 0.8rem;
grid-template-columns: minmax(220px, 2fr) repeat(3, minmax(130px, 1fr));
padding: 1.15rem 1.4rem;
}
.filters label:not(.checkboxField) {
display: grid;
gap: 0.3rem;
}
.filters label > span {
color: var(--ifm-color-emphasis-700);
font-size: 0.76rem;
font-weight: 700;
}
.filters input[type="search"],
.filters select {
background: var(--ifm-background-color);
border: 1px solid var(--catalog-border);
border-radius: 9px;
color: var(--ifm-font-color-base);
font: inherit;
min-height: 42px;
padding: 0.55rem 0.68rem;
width: 100%;
}
.filters input:focus-visible,
.filters select:focus-visible,
.expandButton:focus-visible,
.showMoreButton:focus-visible,
.resetButton:focus-visible,
.anchorLink:focus-visible {
outline: 3px solid color-mix(in srgb, var(--ifm-color-primary) 45%, transparent);
outline-offset: 2px;
}
.checkboxField {
align-items: center;
display: flex;
gap: 0.5rem;
grid-column: 1 / -1;
}
.checkboxField input {
height: 1rem;
margin: 0;
width: 1rem;
}
.resultSummary {
align-items: center;
border-bottom: 1px solid var(--catalog-border);
display: flex;
gap: 0.6rem;
min-height: 48px;
padding: 0.7rem 1.4rem;
}
.resetButton {
background: transparent;
border: 0;
color: var(--ifm-color-primary);
margin-left: auto;
padding: 0.35rem;
}
.componentList {
display: grid;
}
.componentCard {
scroll-margin-top: calc(var(--ifm-navbar-height) + 1rem);
}
.componentCard + .componentCard {
border-top: 1px solid var(--catalog-border);
}
.componentHeading {
align-items: center;
display: flex;
gap: 1rem;
justify-content: space-between;
padding: 1rem 1.4rem;
}
.componentNameRow {
align-items: baseline;
display: flex;
gap: 0.45rem;
}
.componentNameRow h4 {
font-family: var(--ifm-font-family-monospace);
font-size: 1.05rem;
margin: 0;
}
.anchorLink {
font-weight: 700;
opacity: 0;
text-decoration: none;
}
.componentCard:hover .anchorLink,
.anchorLink:focus-visible {
opacity: 1;
}
.badges {
display: flex;
flex-wrap: wrap;
gap: 0.38rem;
margin-top: 0.45rem;
}
.badges span {
background: var(--ifm-color-emphasis-200);
border-radius: 999px;
color: var(--ifm-color-emphasis-800);
font-size: 0.7rem;
padding: 0.2rem 0.48rem;
}
.badges span[data-safety="allowed"] {
background: color-mix(in srgb, #2f9e44 18%, transparent);
color: color-mix(in srgb, #2f9e44 78%, var(--ifm-font-color-base));
}
.badges span[data-safety="wrapped"] {
background: color-mix(in srgb, var(--ifm-color-primary) 18%, transparent);
color: var(--ifm-color-primary-darkest);
}
.badges span[data-safety="denied"] {
background: color-mix(in srgb, #e03131 18%, transparent);
color: color-mix(in srgb, #e03131 78%, var(--ifm-font-color-base));
}
.expandButton {
background: transparent;
color: var(--ifm-color-primary);
flex: 0 0 auto;
padding: 0.42rem 0.7rem;
}
.expandButton:hover {
background: color-mix(in srgb, var(--ifm-color-primary) 12%, transparent);
color: var(--ifm-color-primary-dark);
}
.description,
.deniedReason {
color: var(--ifm-color-emphasis-700);
margin: -0.45rem 1.4rem 1rem;
}
.deniedReason {
color: #c92a2a;
}
.details {
background: var(--catalog-surface);
border-top: 1px solid var(--catalog-border);
padding: 1.25rem 1.4rem;
}
.details h5 {
font-size: 0.9rem;
margin: 0 0 0.6rem;
}
.detailsGrid {
display: grid;
gap: 1rem;
grid-template-columns: minmax(0, 1.15fr) minmax(0, 0.85fr);
margin-bottom: 1.2rem;
}
.detailsGrid > section {
background: var(--ifm-background-color);
border: 1px solid var(--catalog-border);
border-radius: 12px;
min-width: 0;
padding: 0.9rem;
}
.detailsGrid p:last-of-type {
margin-bottom: 0.6rem;
}
.codeBlock {
font-size: 0.75rem;
margin: 0;
max-height: 320px;
overflow: auto;
white-space: pre;
}
.a11yList {
font-size: 0.86rem;
margin: 0 0 0.7rem;
padding-left: 1.1rem;
}
.tableScroll {
border: 1px solid var(--catalog-border);
border-radius: 10px;
max-height: 480px;
overflow: auto;
}
.propsTable {
display: table;
font-size: 0.78rem;
margin: 0;
width: 100%;
}
.propsTable th {
background: var(--ifm-background-color);
position: sticky;
top: 0;
z-index: 1;
}
.propsTable th,
.propsTable td {
min-width: 96px;
padding: 0.58rem;
vertical-align: top;
}
.propsTable td:nth-child(2) {
max-width: 340px;
min-width: 190px;
}
.type {
overflow-wrap: anywhere;
white-space: normal;
}
.muted {
color: var(--ifm-color-emphasis-600);
}
.rawComponent {
margin-top: 1rem;
}
.rawComponent summary {
color: var(--ifm-color-primary);
cursor: pointer;
font-weight: 650;
}
.rawComponent .codeBlock {
margin-top: 0.6rem;
}
.showMoreButton {
display: flex;
margin: 1.1rem auto;
}
.loading,
.error,
.empty {
background: var(--ifm-background-surface-color);
border: 1px solid var(--ifm-color-emphasis-300);
border-radius: 12px;
margin: 1rem 0;
padding: 1rem;
}
.empty {
border: 0;
margin: 0;
text-align: center;
}
.error {
border-color: color-mix(in srgb, #e03131 58%, transparent);
}
@media (max-width: 996px) {
.filters {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
.searchField {
grid-column: 1 / -1;
}
}
@media (max-width: 650px) {
.header,
.componentHeading {
align-items: stretch;
flex-direction: column;
}
.rawLink,
.expandButton {
align-self: flex-start;
}
.versions,
.filters,
.detailsGrid,
.globalProps {
grid-template-columns: 1fr;
}
.versions div + div {
border-left: 0;
border-top: 1px solid var(--catalog-border);
}
.searchField,
.checkboxField {
grid-column: auto;
}
}
@@ -1,66 +1,564 @@
import React from "react";
import BrowserOnly from "@docusaurus/BrowserOnly";
import useBaseUrl from "@docusaurus/useBaseUrl";
import { useEffect, useMemo, useState } from "react";
import customJsxComponentRegistry from "../generated/custom-jsx-components.json";
import styles from "./custom-jsx-component-reference.module.css";
const enabledComponents = customJsxComponentRegistry.filter((component) => component.safety !== "denied");
const deniedComponents = customJsxComponentRegistry.filter((component) => component.safety === "denied");
const categories = [...new Set(enabledComponents.map((component) => component.category))];
type ComponentSafety = "allowed" | "wrapped" | "denied";
type ComponentPackage = "@mantine/core" | "@mantine/dates" | "@mantine/charts" | "@homarr/widgets";
type ComponentBindingType = "string" | "number" | "boolean" | "string[]" | "number[]";
interface CatalogProp {
name: string;
typeRef: number;
required: boolean;
source: "global" | "component";
literalValues?: Array<string | number | boolean | null>;
description?: string;
}
interface CatalogComponent {
name: string;
package: ComponentPackage;
category: string;
safety: ComponentSafety;
description?: string;
documentationUrl: string;
props: CatalogProp[];
blockedProps: Array<{ name: string; reason: string }>;
subcomponents: string[];
bind?: {
type: ComponentBindingType;
initialProp: "defaultValue" | "defaultChecked";
};
accessibilityRequirements: string[];
deniedReason?: string;
}
interface ComponentCatalog {
schemaVersion: number;
mantineVersion: string;
customWidgetVersion: string;
types: string[];
globalProps: CatalogProp[];
blockedCapabilities: Array<{ kind: string; name: string; reason: string }>;
components: CatalogComponent[];
}
const PAGE_SIZE = 40;
export function CustomJsxComponentReference() {
return (
<>
<h3>Enabled and wrapped components</h3>
<table>
<thead>
<tr>
<th>Category</th>
<th>Components</th>
</tr>
</thead>
<tbody>
{categories.map((category) => (
<tr key={category}>
<td>
<code>{category}</code>
</td>
<td>
{enabledComponents
.filter((component) => component.category === category)
.map((component) => (
<span key={component.name} style={{ display: "inline-block", margin: "0 0.5rem 0.35rem 0" }}>
<a href={component.documentationUrl}>
<code>{component.name}</code>
</a>
{component.safety === "wrapped" && <small> (wrapped)</small>}
</span>
))}
</td>
</tr>
))}
</tbody>
</table>
<h3>Denied components</h3>
<table>
<thead>
<tr>
<th>Component</th>
<th>Reason</th>
</tr>
</thead>
<tbody>
{deniedComponents.map((component) => (
<tr key={component.name}>
<td>
<a href={component.documentationUrl} aria-label={component.name}>
<code>{component.name}</code>
</a>
</td>
<td>{component.reason}</td>
</tr>
))}
</tbody>
</table>
</>
<BrowserOnly fallback={<ComponentReferenceFallback />}>{() => <CustomJsxComponentReferenceClient />}</BrowserOnly>
);
}
function ComponentReferenceFallback() {
return (
<div className={styles.loading} aria-busy="true">
Loading the Custom JSX component catalog…
</div>
);
}
function CustomJsxComponentReferenceClient() {
const catalogUrl = useBaseUrl("/custom-widgets/component-catalog-v1.json");
const [catalog, setCatalog] = useState<ComponentCatalog | null>(null);
const [loadError, setLoadError] = useState<string | null>(null);
const [query, setQuery] = useState("");
const [packageFilter, setPackageFilter] = useState("all");
const [categoryFilter, setCategoryFilter] = useState("all");
const [safetyFilter, setSafetyFilter] = useState("available");
const [bindableOnly, setBindableOnly] = useState(false);
const [visibleCount, setVisibleCount] = useState(PAGE_SIZE);
const [expanded, setExpanded] = useState<ReadonlySet<string>>(() => new Set());
const [showGlobalProps, setShowGlobalProps] = useState(false);
useEffect(() => {
const controller = new AbortController();
setLoadError(null);
void fetch(catalogUrl, { signal: controller.signal })
.then(async (response) => {
if (!response.ok) throw new Error(`The catalog request returned HTTP ${response.status}.`);
return (await response.json()) as ComponentCatalog;
})
.then((value) => {
if (!Array.isArray(value.components) || !Array.isArray(value.types) || !Array.isArray(value.globalProps)) {
throw new Error("The catalog response has an unsupported shape.");
}
setCatalog(value);
})
.catch((error: unknown) => {
if (controller.signal.aborted) return;
setLoadError(error instanceof Error ? error.message : "The component catalog could not be loaded.");
});
return () => controller.abort();
}, [catalogUrl]);
const categories = useMemo(
() => [...new Set(catalog?.components.map((component) => component.category) ?? [])].sort(),
[catalog],
);
const packages = useMemo(
() => [...new Set(catalog?.components.map((component) => component.package) ?? [])].sort(),
[catalog],
);
const filteredComponents = useMemo(() => {
if (!catalog) return [];
const normalizedQuery = query.trim().toLocaleLowerCase();
return catalog.components.filter((component) => {
if (packageFilter !== "all" && component.package !== packageFilter) return false;
if (categoryFilter !== "all" && component.category !== categoryFilter) return false;
if (safetyFilter === "available" && component.safety === "denied") return false;
if (safetyFilter !== "all" && safetyFilter !== "available" && component.safety !== safetyFilter) return false;
if (bindableOnly && !component.bind) return false;
if (!normalizedQuery) return true;
return [
component.name,
component.package,
component.category,
component.description,
component.deniedReason,
...component.subcomponents,
...component.props.map((prop) => prop.name),
].some((value) => value?.toLocaleLowerCase().includes(normalizedQuery));
});
}, [bindableOnly, catalog, categoryFilter, packageFilter, query, safetyFilter]);
const visibleComponents = filteredComponents.slice(0, visibleCount);
useEffect(() => {
setVisibleCount(PAGE_SIZE);
}, [bindableOnly, categoryFilter, packageFilter, query, safetyFilter]);
useEffect(() => {
if (!catalog) return;
const revealHashTarget = () => {
const hash = decodeLocationHash(window.location.hash);
const component = catalog.components.find((entry) => componentAnchor(entry.name) === hash);
if (!component) return;
if (!document.getElementById(hash)) {
setQuery(component.name);
setPackageFilter("all");
setCategoryFilter("all");
setSafetyFilter(component.safety === "denied" ? "all" : "available");
setBindableOnly(false);
setVisibleCount(PAGE_SIZE);
}
setExpanded((current) => new Set([...current, component.name]));
window.requestAnimationFrame(() => {
window.requestAnimationFrame(() => document.getElementById(hash)?.scrollIntoView({ block: "start" }));
});
};
revealHashTarget();
window.addEventListener("hashchange", revealHashTarget);
return () => window.removeEventListener("hashchange", revealHashTarget);
}, [catalog]);
if (loadError) {
return (
<div className={styles.error} role="alert">
<strong>Component catalog unavailable.</strong> {loadError} <a href={catalogUrl}>Open the raw catalog</a> to
inspect the generated data directly.
</div>
);
}
if (!catalog) return <ComponentReferenceFallback />;
return (
<section className={styles.explorer} aria-label="Custom JSX component catalog">
<header className={styles.header}>
<div>
<p className={styles.eyebrow}>Release-matched static catalog</p>
<h3 className={styles.title}>Explore {catalog.components.length} Custom JSX components</h3>
<p className={styles.summary}>
This browser reads the same versioned JSON artifact used by offline authoring. It never imports or executes
the listed component objects.
</p>
</div>
<a className={styles.rawLink} href={catalogUrl} target="_blank" rel="noreferrer">
Open raw JSON
</a>
</header>
<dl className={styles.versions}>
<div>
<dt>Catalog schema</dt>
<dd>v{catalog.schemaVersion}</dd>
</div>
<div>
<dt>Custom Widget</dt>
<dd>{catalog.customWidgetVersion}</dd>
</div>
<div>
<dt>Mantine</dt>
<dd>{catalog.mantineVersion}</dd>
</div>
<div>
<dt>Blocked capabilities</dt>
<dd>{catalog.blockedCapabilities.length}</dd>
</div>
</dl>
<section className={styles.globalProps} id="custom-jsx-global-props">
<div>
<h4>Shared serializable props</h4>
<p>
Every available component also receives these {catalog.globalProps.length} generated global, ARIA, data, and
safe style props. Component cards below list only their component-specific additions.
</p>
</div>
<button
type="button"
className={styles.expandButton}
aria-expanded={showGlobalProps}
aria-controls="custom-jsx-global-props-table"
onClick={() => setShowGlobalProps((current) => !current)}
>
{showGlobalProps ? "Hide global props" : "View global props"}
</button>
{showGlobalProps && (
<div className={styles.globalPropsTable} id="custom-jsx-global-props-table">
<PropTable props={catalog.globalProps} types={catalog.types} />
</div>
)}
</section>
<div className={styles.filters}>
<label className={styles.searchField}>
<span>Search names, props, and subcomponents</span>
<input
type="search"
value={query}
onChange={(event) => setQuery(event.currentTarget.value)}
placeholder="For example: TextInput, bind, chart…"
/>
</label>
<label>
<span>Package</span>
<select value={packageFilter} onChange={(event) => setPackageFilter(event.currentTarget.value)}>
<option value="all">All packages</option>
{packages.map((packageName) => (
<option key={packageName} value={packageName}>
{packageName}
</option>
))}
</select>
</label>
<label>
<span>Category</span>
<select value={categoryFilter} onChange={(event) => setCategoryFilter(event.currentTarget.value)}>
<option value="all">All categories</option>
{categories.map((category) => (
<option key={category} value={category}>
{humanize(category)}
</option>
))}
</select>
</label>
<label>
<span>Availability</span>
<select value={safetyFilter} onChange={(event) => setSafetyFilter(event.currentTarget.value)}>
<option value="available">Available only</option>
<option value="all">Available and denied</option>
<option value="allowed">Allowed only</option>
<option value="wrapped">Wrapped only</option>
<option value="denied">Denied only</option>
</select>
</label>
<label className={styles.checkboxField}>
<input
type="checkbox"
checked={bindableOnly}
onChange={(event) => setBindableOnly(event.currentTarget.checked)}
/>
Supports temporary <code>bind</code>
</label>
</div>
<div className={styles.resultSummary} aria-live="polite">
<strong>{filteredComponents.length}</strong> matching component{filteredComponents.length === 1 ? "" : "s"}
{(query ||
packageFilter !== "all" ||
categoryFilter !== "all" ||
safetyFilter !== "available" ||
bindableOnly) && (
<button
type="button"
className={styles.resetButton}
onClick={() => {
setQuery("");
setPackageFilter("all");
setCategoryFilter("all");
setSafetyFilter("available");
setBindableOnly(false);
}}
>
Reset filters
</button>
)}
</div>
{filteredComponents.length === 0 ? (
<div className={styles.empty}>No component matches these filters.</div>
) : (
<div className={styles.componentList}>
{visibleComponents.map((component) => {
const isExpanded = expanded.has(component.name);
return (
<article className={styles.componentCard} id={componentAnchor(component.name)} key={component.name}>
<div className={styles.componentHeading}>
<div>
<div className={styles.componentNameRow}>
<h4>{component.name}</h4>
<a
className={styles.anchorLink}
href={`#${componentAnchor(component.name)}`}
aria-label={`Link to ${component.name}`}
>
#
</a>
</div>
<div className={styles.badges}>
<span>{component.package}</span>
<span>{humanize(component.category)}</span>
<span data-safety={component.safety}>{component.safety}</span>
{component.bind && <span>bind: {component.bind.type}</span>}
</div>
</div>
<button
type="button"
className={styles.expandButton}
aria-expanded={isExpanded}
aria-controls={`${componentAnchor(component.name)}-details`}
onClick={() => {
setExpanded((current) => {
const next = new Set(current);
if (next.has(component.name)) next.delete(component.name);
else next.add(component.name);
return next;
});
}}
>
{isExpanded ? "Hide API" : "View API"}
</button>
</div>
{(component.description || component.deniedReason) && (
<p className={component.safety === "denied" ? styles.deniedReason : styles.description}>
{component.deniedReason ?? component.description}
</p>
)}
{isExpanded && (
<ComponentDetails
id={`${componentAnchor(component.name)}-details`}
component={component}
types={catalog.types}
/>
)}
</article>
);
})}
</div>
)}
{visibleCount < filteredComponents.length && (
<button
type="button"
className={styles.showMoreButton}
onClick={() => setVisibleCount((current) => current + PAGE_SIZE)}
>
Show {Math.min(PAGE_SIZE, filteredComponents.length - visibleCount)} more
</button>
)}
</section>
);
}
function decodeLocationHash(hash: string) {
const value = hash.slice(1);
try {
return decodeURIComponent(value);
} catch {
return value;
}
}
function ComponentDetails({ id, component, types }: { id: string; component: CatalogComponent; types: string[] }) {
return (
<div className={styles.details} id={id}>
<div className={styles.detailsGrid}>
<section>
<h5>Usage example</h5>
<pre className={styles.codeBlock}>
<code>{buildUsageExample(component, types)}</code>
</pre>
</section>
<section>
<h5>Component contract</h5>
{component.bind ? (
<p>
<code>bind</code> stores a temporary <code>{component.bind.type}</code> input. Initialize it with{" "}
<code>{component.bind.initialProp}</code>.
</p>
) : (
<p>This component does not expose a temporary-input adapter.</p>
)}
{component.subcomponents.length > 0 && (
<p>
<strong>Subcomponents:</strong>{" "}
{component.subcomponents.map((name, index) => (
<span key={name}>
{index > 0 && ", "}
<code>{name}</code>
</span>
))}
</p>
)}
{component.blockedProps.length > 0 && (
<ul className={styles.a11yList}>
{component.blockedProps.map((prop) => (
<li key={prop.name}>
<code>{prop.name}</code> is blocked: {prop.reason}
</li>
))}
</ul>
)}
{component.accessibilityRequirements.length > 0 && (
<ul className={styles.a11yList}>
{component.accessibilityRequirements.map((requirement) => (
<li key={requirement}>{requirement}</li>
))}
</ul>
)}
<a href={component.documentationUrl} target="_blank" rel="noreferrer">
Open upstream documentation
</a>
</section>
</div>
<section>
<h5>
Component props <span className={styles.muted}>({component.props.length})</span>
</h5>
{component.props.length === 0 ? (
<p>No component-specific props are exposed.</p>
) : (
<PropTable props={component.props} types={types} />
)}
</section>
<details className={styles.rawComponent}>
<summary>Raw component JSON</summary>
<pre className={styles.codeBlock}>
<code>{JSON.stringify(component, null, 2)}</code>
</pre>
</details>
</div>
);
}
function PropTable({ props, types }: { props: CatalogProp[]; types: string[] }) {
return (
<div className={styles.tableScroll}>
<table className={styles.propsTable}>
<thead>
<tr>
<th>Prop</th>
<th>Type</th>
<th>Required</th>
<th>Known values</th>
<th>Description</th>
</tr>
</thead>
<tbody>
{props.map((prop) => (
<tr key={prop.name}>
<td>
<code>{prop.name}</code>
</td>
<td>
<code className={styles.type}>{types[prop.typeRef] ?? "unknown"}</code>
</td>
<td>{prop.required ? <strong>Yes</strong> : "No"}</td>
<td>
{prop.literalValues?.length ? (
<code>{prop.literalValues.map(formatLiteral).join(" | ")}</code>
) : (
<span className={styles.muted}>—</span>
)}
</td>
<td>{prop.description ?? <span className={styles.muted}>—</span>}</td>
</tr>
))}
</tbody>
</table>
</div>
);
}
function componentAnchor(name: string) {
return `custom-jsx-component-${name
.replaceAll(".", "-")
.replace(/([a-z0-9])([A-Z])/g, "$1-$2")
.toLowerCase()}`;
}
function humanize(value: string) {
return value.replaceAll("-", " ").replace(/\b\w/g, (character) => character.toUpperCase());
}
function formatLiteral(value: string | number | boolean | null) {
return typeof value === "string" ? JSON.stringify(value) : String(value);
}
function buildUsageExample(component: CatalogComponent, types: string[]) {
if (component.name === "RecursiveList") {
return `<RecursiveList data={data.tree} childrenPath="children" keyPath="id" showLines>
{(node, meta) => (
<Group gap="xs">
<Text>{node.label}</Text>
{meta.hasChildren && <Badge>{meta.childCount}</Badge>}
</Group>
)}
</RecursiveList>`;
}
const props = component.props
.filter((prop) => prop.required)
.slice(0, 4)
.map((prop) => `${prop.name}=${examplePropValue(prop, types[prop.typeRef] ?? "unknown")}`);
if (component.bind) {
props.unshift(
`bind="value"`,
`${component.bind.initialProp}=${bindingInitialValue(component.bind.type, component.bind.initialProp)}`,
);
}
const propText = props.length > 0 ? ` ${props.join(" ")}` : "";
return `<${component.name}${propText} />`;
}
function bindingInitialValue(type: ComponentBindingType, initialProp: "defaultValue" | "defaultChecked") {
if (initialProp === "defaultChecked") return "{false}";
if (type === "string") return '""';
if (type === "number") return "{0}";
if (type === "boolean") return "{false}";
return "{[]}";
}
function examplePropValue(prop: CatalogProp, type: string) {
const literal = prop.literalValues?.[0];
if (literal !== undefined) return `{${formatLiteral(literal)}}`;
if (type.includes("string")) return `"${prop.name}"`;
if (type.includes("boolean")) return "{true}";
if (type.includes("number")) return "{0}";
if (type.includes("[]") || type.includes("Array<")) return "{[]}";
return `{options.${prop.name}}`;
}
@@ -0,0 +1,125 @@
import { useState } from "react";
import { MantineProvider, Select, Stack, Text } from "@mantine/core";
/* oxlint-disable import/no-unassigned-import -- Mantine component styles intentionally load for side effects. */
import "@mantine/core/styles/Badge.css";
import "@mantine/core/styles/Button.css";
import "@mantine/core/styles/Combobox.css";
import "@mantine/core/styles/Group.css";
import "@mantine/core/styles/Input.css";
import "@mantine/core/styles/Loader.css";
import "@mantine/core/styles/Pill.css";
import "@mantine/core/styles/PillsInput.css";
import "@mantine/core/styles/Popover.css";
import "@mantine/core/styles/ScrollArea.css";
import "@mantine/core/styles/Stack.css";
import "@mantine/core/styles/Text.css";
import "@mantine/core/styles/UnstyledButton.css";
/* oxlint-enable import/no-unassigned-import */
import {
BUNDLED_CUSTOM_WIDGETS,
CUSTOM_WIDGET_OPTIONS_EXAMPLES,
CUSTOM_WIDGET_REQUEST_EXAMPLES,
} from "@homarr/custom-widgets/core";
import type { CustomWidgetEditorMessages } from "@homarr/custom-widgets/workbench";
import { ReadOnlyCustomWidgetCode } from "@homarr/custom-widgets/workbench";
import type { CanonicalCustomWidgetExampleProps, CustomWidgetCodeExampleProps } from "./custom-widget-code";
const messages: CustomWidgetEditorMessages = {
languageJsx: "JSX",
languageJson: "JSON",
undo: "Undo",
redo: "Redo",
components: "Components",
componentSearch: "Search components",
componentEmpty: "No matching components",
componentCount: (count) => `${count} components`,
insertStarter: "Insert starter",
format: "Format",
copy: "Copy",
copied: "Copied",
schema: "Schema",
schemaTab: "JSON Schema",
minimalTab: "Minimal example",
fullTab: "Full example",
errors: (count) => `${count} errors`,
warnings: (count) => `${count} warnings`,
ready: "Read-only example",
position: ({ line, column }) => `Ln ${line}, Col ${column}`,
characters: (count, limit) => (limit ? `${count} / ${limit}` : `${count} characters`),
diagnosticsTitle: "Diagnostics",
diagnostic: (diagnostic) => diagnostic.value ?? diagnostic.code,
};
export function CanonicalCustomWidgetExampleClient({ id, label, example, height }: CanonicalCustomWidgetExampleProps) {
const value =
example === "requests"
? CUSTOM_WIDGET_REQUEST_EXAMPLES.full
: example === "optionsSchema"
? CUSTOM_WIDGET_OPTIONS_EXAMPLES.full.schema
: CUSTOM_WIDGET_OPTIONS_EXAMPLES.full.defaults;
return <CustomWidgetCodeExampleClient id={id} label={label} code={JSON.stringify(value, null, 2)} height={height} />;
}
export function CustomWidgetCodeExampleClient(props: CustomWidgetCodeExampleProps) {
return (
<MantineProvider forceColorScheme={getDocumentationColorScheme()}>
<ReadOnlyCustomWidgetCode
id={props.id}
label={props.label}
language={props.language ?? "json"}
value={props.code}
messages={messages}
height={props.height}
/>
</MantineProvider>
);
}
export function BundledCustomWidgetGalleryClient() {
const [selectedId, setSelectedId] = useState(BUNDLED_CUSTOM_WIDGETS[0]?.id ?? null);
const selected = BUNDLED_CUSTOM_WIDGETS.find(({ id }) => id === selectedId) ?? BUNDLED_CUSTOM_WIDGETS[0];
if (!selected) return null;
const manifest = { ...selected.widget, template: "__HOMARR_TEMPLATE__" };
return (
<MantineProvider forceColorScheme={getDocumentationColorScheme()}>
<Stack gap="md">
<Select
label="Bundled widget"
data={BUNDLED_CUSTOM_WIDGETS.map(({ id, widget }) => ({ value: id, label: widget.name }))}
value={selected.id}
onChange={(value) => {
if (value) setSelectedId(value);
}}
allowDeselect={false}
/>
<Text size="sm" c="dimmed">
{selected.widget.description}
</Text>
<ReadOnlyCustomWidgetCode
id={`${selected.id}-manifest`}
label="widget.json"
language="json"
value={JSON.stringify(manifest, null, 2)}
messages={messages}
height="340px"
/>
<ReadOnlyCustomWidgetCode
id={`${selected.id}-template`}
label="widget.jsx"
language="jsx"
value={selected.widget.template}
messages={messages}
height="340px"
/>
</Stack>
</MantineProvider>
);
}
function getDocumentationColorScheme() {
return document.documentElement.dataset.theme === "dark" ? "dark" : "light";
}
+42 -122
View File
@@ -1,57 +1,7 @@
import BrowserOnly from "@docusaurus/BrowserOnly";
import { useState } from "react";
import { MantineProvider, Select, Stack, Text } from "@mantine/core";
/* oxlint-disable import/no-unassigned-import -- Mantine component styles intentionally load for side effects. */
import "@mantine/core/styles/Badge.css";
import "@mantine/core/styles/Button.css";
import "@mantine/core/styles/Combobox.css";
import "@mantine/core/styles/Group.css";
import "@mantine/core/styles/Input.css";
import "@mantine/core/styles/Loader.css";
import "@mantine/core/styles/Pill.css";
import "@mantine/core/styles/PillsInput.css";
import "@mantine/core/styles/Popover.css";
import "@mantine/core/styles/ScrollArea.css";
import "@mantine/core/styles/Stack.css";
import "@mantine/core/styles/Text.css";
import "@mantine/core/styles/UnstyledButton.css";
/* oxlint-enable import/no-unassigned-import */
import { lazy, Suspense } from "react";
import {
BUNDLED_CUSTOM_WIDGETS,
CUSTOM_WIDGET_OPTIONS_EXAMPLES,
CUSTOM_WIDGET_REQUEST_EXAMPLES,
} from "@homarr/custom-widgets/core";
import type { CustomWidgetEditorMessages } from "@homarr/custom-widgets/workbench";
import { ReadOnlyCustomWidgetCode } from "@homarr/custom-widgets/workbench";
const messages: CustomWidgetEditorMessages = {
languageJsx: "JSX",
languageJson: "JSON",
undo: "Undo",
redo: "Redo",
components: "Components",
componentSearch: "Search components",
componentEmpty: "No matching components",
componentCount: (count) => `${count} components`,
insertStarter: "Insert starter",
format: "Format",
copy: "Copy",
copied: "Copied",
schema: "Schema",
schemaTab: "JSON Schema",
minimalTab: "Minimal example",
fullTab: "Full example",
errors: (count) => `${count} errors`,
warnings: (count) => `${count} warnings`,
ready: "Read-only example",
position: ({ line, column }) => `Ln ${line}, Col ${column}`,
characters: (count, limit) => (limit ? `${count} / ${limit}` : `${count} characters`),
diagnosticsTitle: "Diagnostics",
diagnostic: (diagnostic) => diagnostic.value ?? diagnostic.code,
};
interface CustomWidgetCodeExampleProps {
export interface CustomWidgetCodeExampleProps {
id: string;
label: string;
code: string;
@@ -59,40 +9,47 @@ interface CustomWidgetCodeExampleProps {
height?: string;
}
export function CanonicalCustomWidgetExample({
id,
label,
example,
height,
}: {
export interface CanonicalCustomWidgetExampleProps {
id: string;
label: string;
example: "requests" | "optionsSchema" | "defaultOptions";
height?: string;
}) {
const value =
example === "requests"
? CUSTOM_WIDGET_REQUEST_EXAMPLES.full
: example === "optionsSchema"
? CUSTOM_WIDGET_OPTIONS_EXAMPLES.full.schema
: CUSTOM_WIDGET_OPTIONS_EXAMPLES.full.defaults;
return <CustomWidgetCodeExample id={id} label={label} code={JSON.stringify(value, null, 2)} height={height} />;
}
const LazyCanonicalCustomWidgetExample = lazy(async () => {
const module = await import("./custom-widget-code-client");
return { default: module.CanonicalCustomWidgetExampleClient };
});
const LazyCustomWidgetCodeExample = lazy(async () => {
const module = await import("./custom-widget-code-client");
return { default: module.CustomWidgetCodeExampleClient };
});
const LazyBundledCustomWidgetGallery = lazy(async () => {
const module = await import("./custom-widget-code-client");
return { default: module.BundledCustomWidgetGalleryClient };
});
export function CanonicalCustomWidgetExample(props: CanonicalCustomWidgetExampleProps) {
return (
<BrowserOnly fallback={<CodeExampleFallback height={props.height} />}>
{() => (
<Suspense fallback={<CodeExampleFallback height={props.height} />}>
<LazyCanonicalCustomWidgetExample {...props} />
</Suspense>
)}
</BrowserOnly>
);
}
export function CustomWidgetCodeExample(props: CustomWidgetCodeExampleProps) {
return (
<BrowserOnly fallback={<div style={{ minHeight: props.height ?? "220px" }} />}>
<BrowserOnly fallback={<CodeExampleFallback height={props.height} />}>
{() => (
<MantineProvider forceColorScheme={getDocumentationColorScheme()}>
<ReadOnlyCustomWidgetCode
id={props.id}
label={props.label}
language={props.language ?? "json"}
value={props.code}
messages={messages}
height={props.height}
/>
</MantineProvider>
<Suspense fallback={<CodeExampleFallback height={props.height} />}>
<LazyCustomWidgetCodeExample {...props} />
</Suspense>
)}
</BrowserOnly>
);
@@ -100,53 +57,16 @@ export function CustomWidgetCodeExample(props: CustomWidgetCodeExampleProps) {
export function BundledCustomWidgetGallery() {
return (
<BrowserOnly fallback={<div style={{ minHeight: "520px" }} />}>
{() => <BundledCustomWidgetGalleryClient />}
<BrowserOnly fallback={<CodeExampleFallback height="520px" />}>
{() => (
<Suspense fallback={<CodeExampleFallback height="520px" />}>
<LazyBundledCustomWidgetGallery />
</Suspense>
)}
</BrowserOnly>
);
}
function BundledCustomWidgetGalleryClient() {
const [selectedId, setSelectedId] = useState(BUNDLED_CUSTOM_WIDGETS[0]?.id ?? null);
const selected = BUNDLED_CUSTOM_WIDGETS.find(({ id }) => id === selectedId) ?? BUNDLED_CUSTOM_WIDGETS[0];
if (!selected) return null;
const manifest = { ...selected.widget, template: "__HOMARR_TEMPLATE__" };
return (
<MantineProvider forceColorScheme={getDocumentationColorScheme()}>
<Stack gap="md">
<Select
label="Bundled widget"
data={BUNDLED_CUSTOM_WIDGETS.map(({ id, widget }) => ({ value: id, label: widget.name }))}
value={selected.id}
onChange={(value) => {
if (value) setSelectedId(value);
}}
allowDeselect={false}
/>
<Text size="sm" c="dimmed">
{selected.widget.description}
</Text>
<ReadOnlyCustomWidgetCode
id={`${selected.id}-manifest`}
label="widget.json"
language="json"
value={JSON.stringify(manifest, null, 2)}
messages={messages}
height="340px"
/>
<ReadOnlyCustomWidgetCode
id={`${selected.id}-template`}
label="widget.jsx"
language="jsx"
value={selected.widget.template}
messages={messages}
height="340px"
/>
</Stack>
</MantineProvider>
);
}
function getDocumentationColorScheme() {
return document.documentElement.dataset.theme === "dark" ? "dark" : "light";
function CodeExampleFallback({ height }: { height?: string }) {
return <div style={{ minHeight: height ?? "220px" }} aria-busy="true" />;
}
@@ -1,5 +1,5 @@
import { useCallback, useEffect, useMemo, useState } from "react";
import type { WorkshopAdminAction, WorkshopReport, WorkshopSubmissionSummary, WorkshopUser } from "@homarr/workshop";
import type { WorkshopReport, WorkshopSubmissionSummary, WorkshopUser } from "@homarr/workshop";
import { WorkshopClient } from "@homarr/workshop";
import styles from "./workshop.module.css";
@@ -12,34 +12,61 @@ export function WorkshopAdmin() {
const [user, setUser] = useState<WorkshopUser | null>(null);
const [reports, setReports] = useState<WorkshopReport[]>([]);
const [submissions, setSubmissions] = useState<WorkshopSubmissionSummary[]>([]);
const [actions, setActions] = useState<WorkshopAdminAction[]>([]);
const [busyId, setBusyId] = useState<string | null>(null);
const [loading, setLoading] = useState(false);
const [error, setError] = useState("");
const load = useCallback(async () => {
setLoading(true);
try {
const [nextReports, nextSubmissions, nextActions] = await Promise.all([
const [nextReports, nextSubmissions] = await Promise.all([
client.listReports(),
client.listAll({ sort: "newest" }),
client.listAdminActions(),
]);
setReports(nextReports);
setSubmissions(nextSubmissions);
setActions(nextActions);
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((next) => {
setUser(next);
if (next?.isAdmin) void load();
void client.refreshAuth().then((nextUser) => {
setUser(nextUser);
if (nextUser?.isAdmin) void load();
});
return unsubscribe;
}, [client, load]);
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 (submission: WorkshopSubmissionSummary) => {
if (!window.confirm(`Delete ${submission.title}? Installed local copies will remain.`)) return;
setBusyId(submission.id);
try {
await client.delete(submission.id);
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),
@@ -49,51 +76,74 @@ export function WorkshopAdmin() {
<main className={styles.shell}>
<section className={styles.hero}>
<h1>Workshop administration</h1>
<p>
Review reports, see each reporter and vote totals, dismiss reports, or delete any submission. This page does
not manage user accounts.
</p>
{!user && (
<button
className={styles.button}
onClick={() =>
void client.signInWithGitHub().then((next) => {
setUser(next);
if (next?.isAdmin) void load();
})
}
>
Sign in with GitHub
</button>
)}
<p>Review community reports and remove submissions. PocketBase rules enforce every moderation action.</p>
<div className={styles.actions}>
<a className={styles.secondary} href="/workshop">
Back to Workshop
</a>
{!user && (
<button
className={styles.button}
onClick={() =>
void client
.signInWithGitHub()
.then((nextUser) => {
setUser(nextUser);
if (nextUser?.isAdmin) void load();
})
.catch((cause: Error) => setError(cause.message))
}
>
Sign in with GitHub
</button>
)}
{user?.isAdmin && (
<button className={styles.secondary} disabled={loading} onClick={() => void load()}>
{loading ? "Refreshing…" : "Refresh"}
</button>
)}
</div>
</section>
{user && !user.isAdmin && <div className={styles.error}>This Workshop account is not an administrator.</div>}
{error && <div className={styles.error}>{error}</div>}
{user?.isAdmin && (
<div className={styles.stack} style={{ marginTop: "2rem" }}>
<h2>Submissions and open reports</h2>
<section className={styles.adminList} aria-busy={loading}>
<div className={styles.adminSummary}>
<strong>{reports.length} open reports</strong>
<span>{submissions.length} published widgets</span>
</div>
{grouped.map(({ submission, reports: submissionReports }) => (
<section key={submission.id} className={styles.adminGroup}>
<div className={styles.meta}>
<strong>{submission.title}</strong>
<span>
▲ {submission.upvotes} · ▼ {submission.downvotes} · score {submission.score} ·{" "}
{submissionReports.length} reports
<article key={submission.id} className={styles.adminGroup}>
<div className={styles.adminHeading}>
<div>
<h2>{submission.title}</h2>
<span>by {submission.authorName}</span>
</div>
<span className={submissionReports.length > 0 ? styles.reportCount : undefined}>
{submissionReports.length} {submissionReports.length === 1 ? "report" : "reports"}
</span>
</div>
<p>{submission.description}</p>
<p>{submission.description || "No description"}</p>
<div className={styles.meta}>
<span>
▲ {submission.upvotes} · ▼ {submission.downvotes} · score {submission.score}
</span>
<span>Updated {new Date(submission.updated).toLocaleDateString()}</span>
</div>
{submissionReports.map((report) => (
<div key={report.id} className={styles.report}>
<strong>{report.category}</strong> by {report.reporterName}
<div key={report.id} className={styles.moderationReport}>
<div>
<strong>{report.category}</strong>
<span>Reported by {report.reporterName}</span>
</div>
<p>{report.explanation}</p>
<button
className={styles.secondary}
onClick={() => {
const reason = window.prompt("Why is this report being dismissed?")?.trim();
if (reason) void client.dismissReport(report.id, reason).then(load);
}}
disabled={busyId === report.id}
onClick={() => void dismiss(report.id)}
>
Dismiss report
{busyId === report.id ? "Dismissing…" : "Dismiss report"}
</button>
</div>
))}
@@ -103,26 +153,16 @@ export function WorkshopAdmin() {
</a>
<button
className={styles.danger}
onClick={() => {
if (window.confirm(`Delete ${submission.title}? Installed local copies will remain.`))
void client.delete(submission.id, "Deleted after administrator review").then(load);
}}
disabled={busyId === submission.id}
onClick={() => void remove(submission)}
>
Delete submission
{busyId === submission.id ? "Deleting…" : "Delete submission"}
</button>
</div>
</section>
</article>
))}
<h2>Administrator audit trail</h2>
{actions.map((action) => (
<div key={action.id} className={`${styles.adminGroup} ${styles.audit}`}>
{new Date(action.created).toLocaleString()} · {action.actorName} · {action.action} · submission{" "}
{action.submissionId || "—"} · report {action.reportId || "—"}
<br />
{action.reason}
</div>
))}
</div>
{!loading && grouped.length === 0 && <p className={styles.empty}>No Workshop submissions.</p>}
</section>
)}
</main>
);
+230 -113
View File
@@ -6,16 +6,12 @@ import type {
WorkshopSubmissionSummary,
WorkshopUser,
} from "@homarr/workshop";
import { validateWorkshopWidget, WorkshopClient } from "@homarr/workshop";
import { validateWorkshopWidget, WorkshopClient, workshopExportFilename } from "@homarr/workshop";
import { CustomWidgetCodeExample } from "../custom-widget-code";
import styles from "./workshop.module.css";
const emptyDraft: WorkshopSubmissionInput = {
title: "",
description: "",
content: "",
changelog: "Initial publication",
};
const emptyDraft: WorkshopSubmissionInput = { title: "", description: "", content: "" };
export function WorkshopPage() {
const client = useMemo(
@@ -24,9 +20,10 @@ export function WorkshopPage() {
);
const [user, setUser] = useState<WorkshopUser | null>(null);
const [items, setItems] = useState<WorkshopSubmissionSummary[]>([]);
const [reports, setReports] = useState<WorkshopReport[]>([]);
const [selected, setSelected] = useState<WorkshopSubmissionDetail | null>(null);
const [search, setSearch] = useState("");
const [sort, setSort] = useState<"top" | "newest">("top");
const [sort, setSort] = useState<"top" | "newest" | "reported">("top");
const [draft, setDraft] = useState<WorkshopSubmissionInput>(emptyDraft);
const [screenshots, setScreenshots] = useState<File[]>([]);
const [editorOpen, setEditorOpen] = useState(false);
@@ -35,15 +32,34 @@ export function WorkshopPage() {
const [reportText, setReportText] = useState("");
const [busy, setBusy] = useState(false);
const [error, setError] = useState("");
const draftValidation = useMemo(() => validateWorkshopWidget(draft.content), [draft.content]);
const reportCounts = useMemo(() => {
const counts = new Map<string, number>();
reports.forEach((report) => counts.set(report.submission, (counts.get(report.submission) ?? 0) + 1));
return counts;
}, [reports]);
const selectedReports = selected ? reports.filter((report) => report.submission === selected.id) : [];
const load = useCallback(async () => {
try {
setItems((await client.list({ search, sort, perPage: 60 })).items);
const [nextReports, nextItems] = await Promise.all([
user?.isAdmin ? client.listReports() : Promise.resolve([]),
sort === "reported" && user?.isAdmin
? client.listAll({ search, sort: "newest" })
: client.list({ search, sort: sort === "newest" ? "newest" : "top", perPage: 60 }).then((page) => page.items),
]);
if (sort === "reported") {
const counts = new Map<string, number>();
nextReports.forEach((report) => counts.set(report.submission, (counts.get(report.submission) ?? 0) + 1));
nextItems.sort((a, b) => (counts.get(b.id) ?? 0) - (counts.get(a.id) ?? 0));
}
setReports(nextReports);
setItems(nextItems);
setError("");
} catch (cause) {
setError(cause instanceof Error ? cause.message : "Workshop is unavailable");
}
}, [client, search, sort]);
}, [client, search, sort, user?.isAdmin]);
const openDetail = useCallback(
async (id: string) => {
@@ -73,7 +89,15 @@ export function WorkshopPage() {
return () => window.clearTimeout(timer);
}, [load]);
useEffect(() => {
if (!user?.isAdmin && sort === "reported") setSort("top");
}, [sort, user?.isAdmin]);
const save = async () => {
if (!draftValidation.success) {
setError(draftValidation.error);
return;
}
setBusy(true);
setError("");
try {
@@ -82,6 +106,7 @@ export function WorkshopPage() {
setEditorOpen(false);
setDraft(emptyDraft);
setScreenshots([]);
setSelected(null);
await load();
} catch (cause) {
setError(cause instanceof Error ? cause.message : "Unable to publish");
@@ -90,30 +115,72 @@ export function WorkshopPage() {
}
};
const edit = () => {
const vote = async (value: 1 | -1) => {
if (!selected) return;
setDraft({
title: selected.title,
description: selected.description,
content: selected.content,
changelog: selected.changelog,
});
setEditorOpen(true);
setError("");
try {
await client.vote(selected.id, value);
await Promise.all([openDetail(selected.id), load()]);
} catch (cause) {
setError(cause instanceof Error ? cause.message : "Unable to save vote");
}
};
const dismissReport = async (id: string) => {
setBusy(true);
setError("");
try {
await client.dismissReport(id);
await load();
} catch (cause) {
setError(cause instanceof Error ? cause.message : "Unable to dismiss report");
} finally {
setBusy(false);
}
};
const deleteSubmission = async () => {
if (!selected || !window.confirm(`Delete ${selected.title}? Installed copies will remain.`)) return;
setBusy(true);
setError("");
try {
await client.delete(selected.id);
setSelected(null);
await load();
} catch (cause) {
setError(cause instanceof Error ? cause.message : "Unable to delete submission");
} finally {
setBusy(false);
}
};
const submitReport = async () => {
if (!selected || reportText.trim().length < 3) return;
setBusy(true);
setError("");
try {
await client.report(selected.id, reportCategory, reportText);
setReportOpen(false);
setReportText("");
await load();
} catch (cause) {
setError(cause instanceof Error ? cause.message : "Unable to report submission");
} finally {
setBusy(false);
}
};
return (
<main className={styles.shell}>
<section className={styles.hero}>
<h1>Workshop</h1>
<p>
Discover and publish Custom JSX v2 widgets. Every submission is public immediately; the community can vote and
report unsafe content.
</p>
<p>Discover, inspect, and publish Custom JSX v2 widgets. Submissions are public immediately.</p>
<div className={styles.actions}>
{user ? (
<>
<span>
Signed in as <strong>{user.displayName}</strong>
{user.isAdmin && <span className={styles.adminBadge}>Admin</span>}
</span>
<button
className={styles.secondary}
@@ -129,11 +196,18 @@ export function WorkshopPage() {
onClick={() => {
setSelected(null);
setDraft(emptyDraft);
setScreenshots([]);
setError("");
setEditorOpen(true);
}}
>
Publish widget
</button>
{user.isAdmin && (
<a className={styles.secondary} href="/workshop/admin">
Review reports
</a>
)}
</>
) : (
<button
@@ -150,8 +224,10 @@ export function WorkshopPage() {
)}
</div>
</section>
<div className={styles.notice}>
Inspect API origins, methods, permissions, and JSX before importing. Workshop never stores widget credentials.
Inspect API origins, methods, permissions, and JSX before importing. Widget exports must not contain
credentials.
</div>
<div className={styles.toolbar}>
<input
@@ -165,42 +241,54 @@ export function WorkshopPage() {
className={styles.select}
style={{ width: 180 }}
value={sort}
onChange={(event) => setSort(event.currentTarget.value as "top" | "newest")}
onChange={(event) => {
const value = event.currentTarget.value;
setSort(value === "reported" && user?.isAdmin ? "reported" : value === "newest" ? "newest" : "top");
}}
>
<option value="top">Top rated</option>
<option value="newest">Newest</option>
{user?.isAdmin && <option value="reported">Most reported</option>}
</select>
</div>
{error && <div className={styles.error}>{error}</div>}
<div className={styles.grid}>
{items.map((item) => (
<article key={item.id} className={styles.card}>
{item.screenshots[0] ? (
<img
className={styles.previewImage}
src={client.fileUrl(item.id, item.screenshots[0], "480x320")}
alt=""
/>
) : (
<div className={styles.preview}>JSX</div>
)}
<div className={styles.cardBody}>
<div className={styles.meta}>
<span>Revision {item.revision}</span>
<span>
▲ {item.upvotes} · ▼ {item.downvotes}
</span>
{items.map((item) => {
const reportCount = reportCounts.get(item.id) ?? 0;
return (
<article key={item.id} className={`${styles.card} ${reportCount ? styles.reportedCard : ""}`}>
{item.screenshots[0] ? (
<img
className={styles.previewImage}
src={client.fileUrl(item.id, item.screenshots[0], "480x320")}
alt=""
/>
) : (
<div className={styles.preview}>JSX</div>
)}
<div className={styles.cardBody}>
<div className={styles.meta}>
<span>by {item.authorName}</span>
<span>
▲ {item.upvotes} · ▼ {item.downvotes}
</span>
</div>
{user?.isAdmin && reportCount > 0 && (
<span className={styles.reportCount}>
{reportCount} {reportCount === 1 ? "report" : "reports"}
</span>
)}
<h2>{item.title}</h2>
<p>{item.description || "No description"}</p>
<button className={styles.secondary} disabled={busy} onClick={() => void openDetail(item.id)}>
Inspect widget
</button>
</div>
<h2>{item.title}</h2>
<p>{item.description || "No description"}</p>
<small>by {item.authorName}</small>
<button className={styles.secondary} disabled={busy} onClick={() => void openDetail(item.id)}>
Inspect widget
</button>
</div>
</article>
))}
</article>
);
})}
</div>
{items.length === 0 && <p className={styles.empty}>No widgets match this search.</p>}
{selected && (
<div
@@ -209,14 +297,41 @@ export function WorkshopPage() {
onMouseDown={(event) => event.target === event.currentTarget && setSelected(null)}
>
<dialog open className={`${styles.dialog} ${styles.stack}`} aria-modal="true" aria-label={selected.title}>
<button type="button" className={styles.dialogClose} aria-label="Close" onClick={() => setSelected(null)}>
×
</button>
<h2>{selected.title}</h2>
<p>{selected.description}</p>
<div className={styles.meta}>
<span>by {selected.authorName}</span>
<span>
by {selected.authorName} · revision {selected.revision}
▲ {selected.upvotes} · ▼ {selected.downvotes}
</span>
<span>SHA-256 {selected.contentHash.slice(0, 12)}…</span>
</div>
{user?.isAdmin && selectedReports.length > 0 && (
<section className={styles.moderation} aria-labelledby="submission-reports-title">
<h3 id="submission-reports-title">
{selectedReports.length} {selectedReports.length === 1 ? "report" : "reports"}
</h3>
{selectedReports.map((report) => (
<div key={report.id} className={styles.moderationReport}>
<div>
<strong>{report.category}</strong>
<span>Reported by {report.reporterName}</span>
</div>
<p>{report.explanation}</p>
<button
type="button"
className={styles.secondary}
disabled={busy}
onClick={() => void dismissReport(report.id)}
>
Dismiss report
</button>
</div>
))}
</section>
)}
{selected.screenshots.length > 0 && (
<div className={styles.screenshots}>
{selected.screenshots.map((file, index) => (
@@ -229,52 +344,47 @@ export function WorkshopPage() {
</div>
)}
<WidgetCapabilitySummary content={selected.content} />
<pre className={styles.code}>{selected.content}</pre>
<CustomWidgetCodeExample
id={`workshop-submission-${selected.id}`}
label="widget.json"
code={selected.content}
height="340px"
/>
{error && <div className={styles.error}>{error}</div>}
<div className={styles.actions}>
<button
className={styles.secondary}
disabled={!user}
onClick={() =>
void client
.vote(selected.id, 1)
.then(() => openDetail(selected.id))
.then(load)
}
>
<button className={styles.secondary} disabled={!user} onClick={() => void vote(1)}>
▲ {selected.upvotes}
</button>
<button
className={styles.secondary}
disabled={!user}
onClick={() =>
void client
.vote(selected.id, -1)
.then(() => openDetail(selected.id))
.then(load)
}
>
<button className={styles.secondary} disabled={!user} onClick={() => void vote(-1)}>
▼ {selected.downvotes}
</button>
<button className={styles.danger} disabled={!user} onClick={() => setReportOpen(true)}>
<button
className={styles.danger}
disabled={!user}
onClick={() => {
setError("");
setReportOpen(true);
}}
>
Report
</button>
{selected.author === user?.id && (
<>
<button className={styles.secondary} onClick={edit}>
Edit
</button>
<button
className={styles.danger}
onClick={() =>
void client.delete(selected.id).then(() => {
setSelected(null);
return load();
})
}
>
Delete
</button>
</>
<button
className={styles.secondary}
onClick={() => {
setDraft({ title: selected.title, description: selected.description, content: selected.content });
setScreenshots([]);
setError("");
setEditorOpen(true);
}}
>
Edit
</button>
)}
{(selected.author === user?.id || user?.isAdmin) && (
<button className={styles.danger} disabled={busy} onClick={() => void deleteSubmission()}>
Delete
</button>
)}
<button
className={styles.button}
@@ -282,16 +392,13 @@ export function WorkshopPage() {
const url = URL.createObjectURL(new Blob([selected.content], { type: "application/json" }));
const link = document.createElement("a");
link.href = url;
link.download = `${selected.title}.json`;
link.download = workshopExportFilename(selected.title);
link.click();
URL.revokeObjectURL(url);
}}
>
Download
</button>
<button className={styles.secondary} onClick={() => setSelected(null)}>
Close
</button>
</div>
</dialog>
</div>
@@ -325,14 +432,13 @@ export function WorkshopPage() {
value={draft.content}
onChange={(event) => setDraft({ ...draft, content: event.currentTarget.value })}
/>
</label>
<label className={styles.field}>
Changelog
<textarea
className={styles.textarea}
value={draft.changelog}
onChange={(event) => setDraft({ ...draft, changelog: event.currentTarget.value })}
/>
{!draft.content.trim() ? (
<span className={styles.hint}>Paste a complete Custom Widget manifest.</span>
) : draftValidation.success ? (
<span className={styles.success}>Valid {draftValidation.data.$schema} manifest.</span>
) : (
<span className={styles.error}>{draftValidation.error}</span>
)}
</label>
<label className={styles.field}>
Screenshots
@@ -343,14 +449,29 @@ export function WorkshopPage() {
multiple
onChange={(event) => setScreenshots(Array.from(event.currentTarget.files ?? []))}
/>
<span className={styles.hint}>Up to five images, 5 MiB each.</span>
<span className={styles.hint}>
{selected?.author === user?.id
? "Choose files to replace the current screenshots, or leave this empty to keep them."
: "Up to five images, 5 MiB each."}
</span>
</label>
{error && <div className={styles.error}>{error}</div>}
<div className={styles.actions}>
<button className={styles.button} disabled={busy || !user} onClick={() => void save()}>
{busy ? "Publishing…" : "Publish immediately"}
<button
className={styles.button}
disabled={busy || !user || !draftValidation.success}
onClick={() => void save()}
>
{busy ? "Saving…" : selected?.author === user?.id ? "Save changes" : "Publish immediately"}
</button>
<button className={styles.secondary} onClick={() => setEditorOpen(false)}>
<button
className={styles.secondary}
onClick={() => {
setEditorOpen(false);
setScreenshots([]);
setError("");
}}
>
Cancel
</button>
</div>
@@ -382,16 +503,12 @@ export function WorkshopPage() {
onChange={(event) => setReportText(event.currentTarget.value)}
/>
</label>
{error && <div className={styles.error}>{error}</div>}
<div className={styles.actions}>
<button
className={styles.danger}
disabled={reportText.trim().length < 3}
onClick={() =>
void client.report(selected.id, reportCategory, reportText).then(() => {
setReportOpen(false);
setReportText("");
})
}
disabled={busy || reportText.trim().length < 3}
onClick={() => void submitReport()}
>
Send report
</button>
@@ -60,6 +60,20 @@
color: white;
background: var(--ifm-color-danger-dark);
}
.button:hover:not(:disabled),
.secondary:hover:not(:disabled),
.danger:hover:not(:disabled) {
filter: brightness(0.94);
}
.button:focus-visible,
.secondary:focus-visible,
.danger:focus-visible,
.input:focus-visible,
.textarea:focus-visible,
.select:focus-visible {
outline: 3px solid color-mix(in srgb, var(--ifm-color-primary) 45%, transparent);
outline-offset: 2px;
}
.button:disabled,
.secondary:disabled,
.danger:disabled {
@@ -98,6 +112,11 @@
grid-template-columns: repeat(auto-fill, minmax(270px, 1fr));
gap: 1rem;
}
.empty {
margin: 3rem 0;
color: var(--ifm-color-emphasis-700);
text-align: center;
}
.card {
overflow: hidden;
border: 1px solid var(--ifm-color-emphasis-200);
@@ -135,6 +154,29 @@
color: var(--ifm-color-emphasis-600);
font-size: 0.82rem;
}
.adminBadge,
.reportCount {
display: inline-flex;
align-items: center;
width: fit-content;
padding: 0.2rem 0.5rem;
border-radius: 999px;
font-size: 0.75rem;
font-weight: 700;
}
.adminBadge {
margin-left: 0.45rem;
color: var(--ifm-color-primary-darkest);
background: color-mix(in srgb, var(--ifm-color-primary) 16%, transparent);
}
.reportCount {
color: var(--ifm-color-warning-darkest);
background: color-mix(in srgb, var(--ifm-color-warning) 24%, transparent);
}
.reportedCard {
border-color: var(--ifm-color-warning-dark);
background: color-mix(in srgb, var(--ifm-color-warning) 5%, var(--ifm-card-background-color));
}
.overlay {
position: fixed;
@@ -161,20 +203,37 @@
}
.dialog h2 {
margin-top: 0;
padding-right: 2.5rem;
}
.dialogClose {
position: absolute;
top: 1rem;
right: 1rem;
display: grid;
place-items: center;
width: 2.25rem;
height: 2.25rem;
padding: 0;
border: 0;
border-radius: 0.5rem;
color: var(--ifm-font-color-base);
background: var(--ifm-color-emphasis-200);
font: inherit;
font-size: 1.35rem;
line-height: 1;
cursor: pointer;
}
.dialogClose:hover {
background: var(--ifm-color-emphasis-300);
}
.dialogClose:focus-visible {
outline: 3px solid color-mix(in srgb, var(--ifm-color-primary) 45%, transparent);
outline-offset: 2px;
}
.stack {
display: grid;
gap: 1rem;
}
.code {
max-height: 340px;
overflow: auto;
padding: 1rem;
border-radius: 0.65rem;
background: var(--ifm-color-emphasis-100);
white-space: pre;
font-size: 0.78rem;
}
.notice,
.error,
.success {
@@ -186,6 +245,7 @@
}
.error {
background: color-mix(in srgb, var(--ifm-color-danger) 20%, transparent);
white-space: pre-wrap;
}
.success {
background: color-mix(in srgb, var(--ifm-color-success) 20%, transparent);
@@ -214,22 +274,77 @@
color: var(--ifm-color-emphasis-700);
font-size: 0.82rem;
}
.adminGroup {
.moderation {
display: grid;
gap: 0.75rem;
padding: 1rem;
border: 1px solid var(--ifm-color-emphasis-300);
border-radius: 0.8rem;
border: 1px solid var(--ifm-color-warning-dark);
border-radius: 0.75rem;
background: color-mix(in srgb, var(--ifm-color-warning) 10%, var(--ifm-background-color));
}
.report {
padding-left: 0.75rem;
border-left: 3px solid var(--ifm-color-warning);
.moderation h3,
.moderationReport p {
margin: 0;
}
.audit {
font-family: var(--ifm-font-family-monospace);
font-size: 0.78rem;
.moderationReport {
padding-top: 0.75rem;
border-top: 1px solid color-mix(in srgb, var(--ifm-color-warning) 35%, transparent);
}
.moderationReport > div {
display: flex;
align-items: baseline;
justify-content: space-between;
gap: 1rem;
}
.moderationReport span {
color: var(--ifm-color-emphasis-700);
font-size: 0.8rem;
}
.moderationReport p {
margin-top: 0.35rem;
line-height: 1.55;
white-space: pre-wrap;
}
.moderationReport > button {
margin-top: 0.75rem;
}
.adminList {
display: grid;
gap: 1rem;
margin-top: 2rem;
}
.adminSummary,
.adminHeading {
display: flex;
align-items: center;
justify-content: space-between;
gap: 1rem;
}
.adminSummary {
padding: 1rem;
border-radius: 0.75rem;
background: var(--ifm-color-emphasis-100);
}
.adminGroup {
display: grid;
gap: 1rem;
padding: 1.25rem;
border: 1px solid var(--ifm-color-emphasis-200);
border-radius: 0.9rem;
background: var(--ifm-card-background-color);
}
.adminGroup h2,
.adminGroup p {
margin: 0;
}
.adminHeading > div {
display: grid;
gap: 0.2rem;
}
.adminHeading span {
color: var(--ifm-color-emphasis-600);
font-size: 0.82rem;
}
@media (max-width: 600px) {
.shell {
width: min(100% - 1rem, 1180px);
@@ -241,4 +356,14 @@
.actions > * {
flex: 1 1 auto;
}
.moderationReport > div {
align-items: flex-start;
flex-direction: column;
gap: 0.2rem;
}
.adminSummary,
.adminHeading {
align-items: flex-start;
flex-direction: column;
}
}
File diff suppressed because it is too large. Load diff