Restore Workshop and preserve Custom Widget docs

This commit is contained in:
Thomas Camlong committed 2026-07-21 11:48:51 +02:00
1 parent 2c932f1181
commit 09e653c36f
208 files changed
+9503 -3337

No files matched your search

@@ -0,0 +1,451 @@
.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;
}
.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 > div {
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;
}
}
@@ -0,0 +1,497 @@
import BrowserOnly from "@docusaurus/BrowserOnly";
import useBaseUrl from "@docusaurus/useBaseUrl";
import { useEffect, useMemo, useState } from "react";
import { buildCustomJsxComponentUsageExample } from "@homarr/custom-widgets/catalog-example";
import type {
CustomJsxAuthoringCatalog,
CustomJsxComponentApi,
CustomJsxPropDescriptor,
} from "@homarr/custom-widgets/catalog";
import { CustomWidgetCodeExample } from "./custom-widget-code";
import styles from "./custom-jsx-component-reference.module.css";
type ComponentCatalog = CustomJsxAuthoringCatalog;
type CatalogComponent = CustomJsxComponentApi;
type CatalogProp = CustomJsxPropDescriptor;
const PAGE_SIZE = 40;
export function CustomJsxComponentReference() {
return (
<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>
<CustomWidgetCodeExample
id={`${componentAnchor(component.name)}-usage`}
label="Usage example"
language="jsx"
code={buildCustomJsxComponentUsageExample(component, { types })}
height="180px"
/>
</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>
<CustomWidgetCodeExample
id={`${componentAnchor(component.name)}-json`}
label="Component JSON"
code={JSON.stringify(component, null, 2)}
height="320px"
/>
</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);
}
@@ -0,0 +1,120 @@
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 : CUSTOM_WIDGET_OPTIONS_EXAMPLES.full;
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";
}
@@ -0,0 +1,50 @@
import BrowserOnly from "@docusaurus/BrowserOnly";
import {
BundledCustomWidgetGalleryClient,
CanonicalCustomWidgetExampleClient,
CustomWidgetCodeExampleClient,
} from "./custom-widget-code-client";
export interface CustomWidgetCodeExampleProps {
id: string;
label: string;
code: string;
language?: "json" | "jsx";
height?: string;
}
export interface CanonicalCustomWidgetExampleProps {
id: string;
label: string;
example: "requests" | "options";
height?: string;
}
export function CanonicalCustomWidgetExample(props: CanonicalCustomWidgetExampleProps) {
return (
<BrowserOnly fallback={<CodeExampleFallback height={props.height} />}>
{() => <CanonicalCustomWidgetExampleClient {...props} />}
</BrowserOnly>
);
}
export function CustomWidgetCodeExample(props: CustomWidgetCodeExampleProps) {
return (
<BrowserOnly fallback={<CodeExampleFallback height={props.height} />}>
{() => <CustomWidgetCodeExampleClient {...props} />}
</BrowserOnly>
);
}
export function BundledCustomWidgetGallery() {
return (
<BrowserOnly fallback={<CodeExampleFallback height="520px" />}>
{() => <BundledCustomWidgetGalleryClient />}
</BrowserOnly>
);
}
function CodeExampleFallback({ height }: { height?: string }) {
return <div style={{ minHeight: height ?? "220px" }} aria-busy="true" />;
}
@@ -1,20 +1,20 @@
@keyframes scrolling {
0% {
transform: translateY(0);
}
100% {
transform: translateY(-50.8%);
}
0% {
transform: translateY(0);
}
100% {
transform: translateY(-50.8%);
}
}
.scrollAnimationContainer {
animation: scrolling;
animation-timing-function: linear;
animation-iteration-count: infinite;
animation: scrolling;
animation-timing-function: linear;
animation-iteration-count: infinite;
}
@media (prefers-reduced-motion: reduce) {
.scrollAnimationContainer {
animation: none !important;
}
}
.scrollAnimationContainer {
animation: none !important;
}
}
@@ -4,8 +4,6 @@
margin-bottom: 50px;
}
.communityFeedbackPlaceholderImage {
display: block;
margin: 0 auto;
@@ -14,5 +12,3 @@
.communityFeedbackPlaceholderText {
text-align: center;
}
@@ -11,23 +11,32 @@ import { Input } from "@/components/ui/input";
import { formatRelativeTime } from "./format";
const commentAuthorName = (comment: WorkshopComment) =>
comment.expand?.author?.name || comment.expand?.author?.username || "unknown";
comment.expand?.author?.displayName || comment.expand?.author?.githubUsername || "unknown";
interface CommentsSectionProps {
submissionId: string;
pb: ReturnType<typeof getPocketBase>;
currentUserId?: string;
currentUserIsAdmin?: boolean;
onRequireAuth: (action: string) => Promise<string | null>;
onError: (message: string) => void;
}
export const CommentsSection = ({ submissionId, pb, currentUserId, onRequireAuth, onError }: CommentsSectionProps) => {
export const CommentsSection = ({
submissionId,
pb,
currentUserId,
currentUserIsAdmin,
onRequireAuth,
}: CommentsSectionProps) => {
const [rows, setRows] = useState<WorkshopComment[]>([]);
const [loading, setLoading] = useState(true);
const [fetchError, setFetchError] = useState(false);
const [newComment, setNewComment] = useState("");
const [editingId, setEditingId] = useState<string | null>(null);
const [editContent, setEditContent] = useState("");
const [mutationError, setMutationError] = useState<string | null>(null);
const [confirmDeleteId, setConfirmDeleteId] = useState<string | null>(null);
useEffect(() => {
let cancelled = false;
@@ -59,13 +68,14 @@ export const CommentsSection = ({ submissionId, pb, currentUserId, onRequireAuth
const userId = await onRequireAuth("comment");
if (!userId) return;
try {
setMutationError(null);
const created = await pb
.collection("comments")
.create<WorkshopComment>({ submission: submissionId, content: trimmed, author: userId }, { expand: "author" });
setRows((prev) => [created, ...prev]);
setNewComment("");
} catch (caught) {
onError(errorMessage(caught, "Failed to post comment"));
setMutationError(errorMessage(caught, "Failed to post comment"));
}
};
@@ -73,22 +83,29 @@ export const CommentsSection = ({ submissionId, pb, currentUserId, onRequireAuth
const trimmed = editContent.trim();
if (!trimmed) return;
try {
setMutationError(null);
const updated = await pb
.collection("comments")
.update<WorkshopComment>(id, { content: trimmed }, { expand: "author" });
setRows((prev) => prev.map((comment) => (comment.id === id ? updated : comment)));
setEditingId(null);
} catch (caught) {
onError(errorMessage(caught, "Failed to update comment"));
setMutationError(errorMessage(caught, "Failed to update comment"));
}
};
const handleDelete = async (id: string) => {
if (confirmDeleteId !== id) {
setConfirmDeleteId(id);
return;
}
try {
setMutationError(null);
await pb.collection("comments").delete(id);
setRows((prev) => prev.filter((comment) => comment.id !== id));
setConfirmDeleteId(null);
} catch (caught) {
onError(errorMessage(caught, "Failed to delete comment"));
setMutationError(errorMessage(caught, "Failed to delete comment"));
}
};
@@ -111,6 +128,12 @@ export const CommentsSection = ({ submissionId, pb, currentUserId, onRequireAuth
</Button>
</div>
{mutationError && (
<p className="rounded-lg border border-destructive/30 bg-destructive/10 px-3 py-2 text-sm text-destructive">
{mutationError}
</p>
)}
{loading && <p className="py-4 text-center text-sm text-muted-foreground">Loading comments…</p>}
{!loading && fetchError && <p className="py-4 text-center text-sm text-destructive">Failed to load comments</p>}
{!loading && !fetchError && rows.length === 0 && (
@@ -119,14 +142,27 @@ export const CommentsSection = ({ submissionId, pb, currentUserId, onRequireAuth
<div className="space-y-2">
{rows.map((comment) => {
const isOwner = currentUserId === comment.author;
const canManage = currentUserId === comment.author || currentUserIsAdmin;
const isEditing = editingId === comment.id;
const author = comment.expand?.author;
const edited = comment.updated !== comment.created;
return (
<div key={comment.id} className="rounded-lg bg-muted/30 px-3 py-2 dark:bg-input/20">
<div className="flex items-center gap-2 text-xs">
<span className="font-medium">{commentAuthorName(comment)}</span>
<a
href={author?.githubProfileUrl || undefined}
target={author?.githubProfileUrl ? "_blank" : undefined}
rel="noreferrer"
className="inline-flex items-center gap-1.5 font-medium hover:underline"
>
{author?.avatarUrl && (
<img src={author.avatarUrl} alt="" className="size-5 rounded-full object-cover" />
)}
{commentAuthorName(comment)}
</a>
<span className="text-muted-foreground/60">{formatRelativeTime(comment.created)}</span>
{isOwner && !isEditing && (
{edited && <span className="text-muted-foreground/60">edited</span>}
{canManage && !isEditing && (
<div className="ml-auto flex gap-0.5">
<button
className="rounded p-1 hover:bg-accent"
@@ -144,6 +180,7 @@ export const CommentsSection = ({ submissionId, pb, currentUserId, onRequireAuth
onClick={() => void handleDelete(comment.id)}
>
<IconTrash size={12} />
{confirmDeleteId === comment.id && <span className="ml-1">Confirm?</span>}
</button>
</div>
)}
+179 -13
View File
@@ -16,21 +16,33 @@ import {
import type { ClientResponseError } from "pocketbase";
import type { WorkshopSubmission, WorkshopVote } from "@site/src/lib/pocketbase";
import { getPocketBase, getSubmissionFileUrl } from "@site/src/lib/pocketbase";
import { getPocketBase, getSubmissionFileUrl, signInWithGitHub } from "@site/src/lib/pocketbase";
import type { SubmissionType } from "@site/src/lib/workshop-schema";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { cn, errorMessage } from "@site/src/lib/utils";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import { Input } from "@/components/ui/input";
import { Textarea } from "@/components/ui/textarea";
import { CustomWidgetCodeExample } from "../custom-widget-code";
import { validateSubmissionContent } from "@site/src/lib/workshop-schema";
import { cn, errorMessage, oauthErrorMessage } from "@site/src/lib/utils";
import { CommentsSection } from "./DetailComments";
import { CodeBlock, DeleteConfirmButton, DetailSkeleton, ScreenshotGallery } from "./DetailSections";
import { formatRelativeTime } from "./format";
import { downloadSubmissionJson, voteDelta } from "./workshop-utils";
const typeLabels: Record<SubmissionType, string> = { css: "CSS", widget: "Widget" };
const typeDotColors: Record<SubmissionType, string> = { css: "bg-blue-500", widget: "bg-yellow-500" };
const contentLanguages: Record<SubmissionType, string> = { css: "css", widget: "json" };
const typeLabels: Record<SubmissionType, string> = { customCss: "CSS", customWidget: "Widget" };
const typeDotColors: Record<SubmissionType, string> = { customCss: "bg-blue-500", customWidget: "bg-yellow-500" };
const contentLanguages: Record<SubmissionType, string> = { customCss: "css", customWidget: "json" };
const copyState = [
{ Icon: IconCopy, label: "Copy", iconClass: "" },
{ Icon: IconCheck, label: "Copied!", iconClass: "text-green-500" },
@@ -59,6 +71,7 @@ const MarketplaceDetail = ({ workshopUrl }: { workshopUrl: string }) => {
const pb = useMemo(() => getPocketBase(workshopUrl), [workshopUrl]);
const [submission, setSubmission] = useState<WorkshopSubmission | null>(null);
const [reports, setReports] = useState<Array<{ id: string; category: string; explanation: string }>>([]);
const [userVote, setUserVote] = useState<WorkshopVote | undefined>();
const [user, setUser] = useState(pb.authStore.record);
const [loading, setLoading] = useState(true);
@@ -66,6 +79,11 @@ const MarketplaceDetail = ({ workshopUrl }: { workshopUrl: string }) => {
const [error, setError] = useState<string | null>(null);
const [copied, setCopied] = useState(false);
const [copyFailed, setCopyFailed] = useState(false);
const [editOpen, setEditOpen] = useState(false);
const [editTitle, setEditTitle] = useState("");
const [editDescription, setEditDescription] = useState("");
const [editChangelog, setEditChangelog] = useState("");
const [editContent, setEditContent] = useState("");
const copyTimer = useRef<ReturnType<typeof setTimeout>>(null);
const copyFailedTimer = useRef<ReturnType<typeof setTimeout>>(null);
const voting = useRef(false);
@@ -74,9 +92,9 @@ const MarketplaceDetail = ({ workshopUrl }: { workshopUrl: string }) => {
async (action: string) => {
if (!pb.authStore.isValid) {
try {
await pb.collection("users").authWithOAuth2({ provider: "github" });
await signInWithGitHub(pb);
} catch (caught) {
setError(errorMessage(caught, "Sign in failed"));
setError(oauthErrorMessage(caught));
return null;
}
}
@@ -120,9 +138,17 @@ const MarketplaceDetail = ({ workshopUrl }: { workshopUrl: string }) => {
const load = async () => {
try {
const record = await pb.collection("marketplace").getOne<WorkshopSubmission>(submissionId);
const [listing, record, reportRows] = await Promise.all([
pb.collection("workshop_listings").getOne<WorkshopSubmission>(submissionId),
pb.collection("submissions").getOne<WorkshopSubmission>(submissionId),
pb.collection("reports").getFullList<{ id: string; category: string; explanation: string }>({
filter: pb.filter("submission = {:id} && status = 'open'", { id: submissionId }),
sort: "-created",
}),
]);
if (cancelled) return;
setSubmission(record);
setSubmission({ ...listing, ...record, content: record.content });
setReports(reportRows);
if (pb.authStore.isValid && pb.authStore.record) {
const votes = await pb.collection("votes").getFullList<WorkshopVote>({
@@ -204,6 +230,50 @@ const MarketplaceDetail = ({ workshopUrl }: { workshopUrl: string }) => {
}
};
const handleOutdated = async () => {
if (!submission) return;
try {
const updated = await pb.collection("submissions").update<WorkshopSubmission>(submission.id, {
outdated: !submission.outdated,
});
setSubmission({ ...submission, outdated: updated.outdated });
} catch (caught) {
setError(errorMessage(caught, "Failed to update submission status"));
}
};
const openEdit = () => {
if (!submission) return;
setEditTitle(submission.title);
setEditDescription(submission.description);
setEditChangelog("");
setEditContent(submission.content);
setEditOpen(true);
};
const handleEdit = async () => {
if (!submission) return;
const validation = validateSubmissionContent(submission.type, editContent);
if (!validation.success) {
setError(validation.error);
return;
}
try {
const updated = await pb.collection("submissions").update<WorkshopSubmission>(submission.id, {
title: editTitle.trim(),
description: editDescription.trim(),
content: editContent,
changelog: editChangelog.trim(),
revision: submission.revision + 1,
});
setSubmission({ ...submission, ...updated, revision: submission.revision + 1 });
setEditOpen(false);
setError(null);
} catch (caught) {
setError(errorMessage(caught, "Failed to update submission"));
}
};
const handleCopy = async () => {
if (!submission) return;
try {
@@ -269,9 +339,22 @@ const MarketplaceDetail = ({ workshopUrl }: { workshopUrl: string }) => {
<span className={cn("size-2 rounded-full", typeDotColors[submission.type])} />
{typeLabels[submission.type]}
</Badge>
{submission.outdated && <Badge variant="secondary">Outdated</Badge>}
{submission.reportCount > 0 && <Badge variant="destructive">Reported</Badge>}
</div>
<p className="mt-1 text-sm text-muted-foreground">
{submission.authorName} · v{submission.version} · {formatRelativeTime(submission.created)}
<a
href={submission.authorGithubProfileUrl || undefined}
target={submission.authorGithubProfileUrl ? "_blank" : undefined}
rel="noreferrer"
className="inline-flex items-center gap-1.5 hover:text-foreground"
>
{submission.authorAvatarUrl && (
<img src={submission.authorAvatarUrl} alt="" className="size-5 rounded-full object-cover" />
)}
{submission.authorName}
</a>{" "}
· v{submission.revision} · {formatRelativeTime(submission.created)}
</p>
</div>
@@ -307,6 +390,27 @@ const MarketplaceDetail = ({ workshopUrl }: { workshopUrl: string }) => {
{submission.description && (
<p className="mt-6 text-sm leading-relaxed text-muted-foreground">{submission.description}</p>
)}
{submission.changelog && (
<div className="mt-4 rounded-lg border border-border bg-muted/30 p-4">
<p className="text-xs font-medium uppercase tracking-wide text-muted-foreground">Changelog</p>
<p className="mt-1 text-sm">{submission.changelog}</p>
</div>
)}
{reports.length > 0 && (
<div className="mt-6 rounded-lg border border-destructive/30 bg-destructive/10 p-4 text-sm">
<p className="font-medium text-destructive">
The community has reported this submission {reports.length} time{reports.length === 1 ? "" : "s"}.
</p>
<ul className="mt-2 space-y-1 text-muted-foreground">
{reports.map((report) => (
<li key={report.id}>
<strong>{report.category}:</strong> {report.explanation}
</li>
))}
</ul>
</div>
)}
<div className="mt-6 flex flex-wrap items-center gap-2">
<Button variant="outline" size="sm" onClick={() => void handleCopy()}>
@@ -320,16 +424,36 @@ const MarketplaceDetail = ({ workshopUrl }: { workshopUrl: string }) => {
</>
)}
</Button>
{submission.type === "widget" && (
{submission.type === "customWidget" && (
<Button variant="outline" size="sm" onClick={() => downloadSubmissionJson(submission)}>
<IconDownload size={14} /> Download
</Button>
)}
{user?.id === submission.author && <DeleteConfirmButton onConfirm={handleDelete} />}
{(user?.id === submission.author || user?.isAdmin === true) && (
<Button variant="outline" size="sm" onClick={() => void handleOutdated()}>
{submission.outdated ? "Mark current" : "Mark outdated"}
</Button>
)}
{user?.id === submission.author && (
<Button variant="outline" size="sm" onClick={openEdit}>
Edit submission
</Button>
)}
{(user?.id === submission.author || user?.isAdmin === true) && <DeleteConfirmButton onConfirm={handleDelete} />}
</div>
<div className="mt-6">
<CodeBlock content={submission.content} language={contentLanguages[submission.type]} />
{submission.type === "customWidget" ? (
<CustomWidgetCodeExample
id={`workshop-${submission.id}`}
label="widget.json"
code={submission.content}
language="json"
height="520px"
/>
) : (
<CodeBlock content={submission.content} language={contentLanguages[submission.type]} />
)}
</div>
<div className="mt-10 border-t border-border pt-8">
@@ -337,10 +461,52 @@ const MarketplaceDetail = ({ workshopUrl }: { workshopUrl: string }) => {
submissionId={submission.id}
pb={pb}
currentUserId={user?.id}
currentUserIsAdmin={user?.isAdmin === true}
onRequireAuth={requireUserId}
onError={setError}
/>
</div>
<Dialog open={editOpen} onOpenChange={setEditOpen}>
<DialogContent className="max-h-[90vh] overflow-y-auto sm:max-w-2xl">
<DialogHeader>
<DialogTitle>Edit submission</DialogTitle>
<DialogDescription>Saving creates a new revision and keeps the same Workshop URL.</DialogDescription>
</DialogHeader>
<label className="grid gap-1.5 text-sm">
Title
<Input value={editTitle} onChange={(event) => setEditTitle(event.target.value)} />
</label>
<label className="grid gap-1.5 text-sm">
Description
<Textarea value={editDescription} onChange={(event) => setEditDescription(event.target.value)} />
</label>
<label className="grid gap-1.5 text-sm">
Changelog
<Textarea
value={editChangelog}
onChange={(event) => setEditChangelog(event.target.value)}
placeholder="What changed?"
/>
</label>
<label className="grid gap-1.5 text-sm">
Content
<Textarea
className="font-mono text-xs"
rows={14}
value={editContent}
onChange={(event) => setEditContent(event.target.value)}
/>
</label>
<DialogFooter>
<Button variant="ghost" onClick={() => setEditOpen(false)}>
Cancel
</Button>
<Button disabled={editTitle.trim().length < 3 || !editContent.trim()} onClick={() => void handleEdit()}>
Save revision
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</div>
);
};
@@ -44,12 +44,12 @@ const connectorClass = ["bg-border", "bg-primary"];
const dropOverlayClass = ["pointer-events-none opacity-0", "opacity-100"];
const placeholders: Record<SubmissionType, string> = {
widget: '{\n "$schema": "homarr-custom-widget-v2",\n "name": "My widget",\n ...\n}',
css: ".grid-stack-item-content {\n border-radius: 16px;\n}",
customWidget: '{\n "$schema": "homarr-custom-widget-v2",\n "name": "My widget",\n ...\n}',
customCss: ".grid-stack-item-content {\n border-radius: 16px;\n}",
};
const contentLabels: Record<SubmissionType, string> = {
widget: "Widget JSON (homarr-custom-widget-v2)",
css: "Custom CSS",
customWidget: "Widget JSON (homarr-custom-widget-v2)",
customCss: "Custom CSS",
};
const parseJsonObject = (json: string): Record<string, unknown> | null => {
@@ -68,6 +68,7 @@ export const SubmitForm = ({ onClose, onSubmit }: Props) => {
const [type, setType] = useState<SubmissionType | null>(null);
const [title, setTitle] = useState("");
const [description, setDescription] = useState("");
const [changelog, setChangelog] = useState("Initial publication");
const [content, setContent] = useState("");
const [screenshots, setScreenshots] = useState<File[]>([]);
const [previews, setPreviews] = useState<string[]>([]);
@@ -98,7 +99,7 @@ export const SubmitForm = ({ onClose, onSubmit }: Props) => {
const reader = new FileReader();
reader.onload = (e) => {
const text = e.target?.result as string;
if (!type) setType("widget");
if (!type) setType("customWidget");
setContentAndAutofill(text, title, description);
if (step === 0) goTo(1);
};
@@ -164,7 +165,7 @@ export const SubmitForm = ({ onClose, onSubmit }: Props) => {
setError(null);
setPending(true);
try {
await onSubmit({ type, title, description, content, screenshots });
await onSubmit({ type, title, description, changelog, content, screenshots });
} catch (caught) {
setError(errorMessage(caught, "Submission failed"));
} finally {
@@ -173,7 +174,7 @@ export const SubmitForm = ({ onClose, onSubmit }: Props) => {
};
const onContentChange = (value: string) => {
if (type === "widget") setContentAndAutofill(value, title, description);
if (type === "customWidget") setContentAndAutofill(value, title, description);
else setContent(value);
};
@@ -252,6 +253,8 @@ export const SubmitForm = ({ onClose, onSubmit }: Props) => {
setTitle={setTitle}
description={description}
setDescription={setDescription}
changelog={changelog}
setChangelog={setChangelog}
content={content}
onContentChange={onContentChange}
/>
@@ -294,12 +297,17 @@ export const SubmitForm = ({ onClose, onSubmit }: Props) => {
const typeCards: { type: SubmissionType; icon: typeof IconBraces; label: string; desc: string }[] = [
{
type: "widget",
type: "customWidget",
icon: IconBraces,
label: "Custom Widget",
desc: "A JSON-based widget using the homarr-custom-widget-v2 schema",
},
{ type: "css", icon: IconPalette, label: "Custom CSS", desc: "A CSS theme or style override for Homarr dashboards" },
{
type: "customCss",
icon: IconPalette,
label: "Custom CSS",
desc: "A CSS theme or style override for Homarr dashboards",
},
];
const StepType = ({
@@ -393,6 +401,8 @@ const StepDetails = ({
setTitle,
description,
setDescription,
changelog,
setChangelog,
content,
onContentChange,
}: {
@@ -401,6 +411,8 @@ const StepDetails = ({
setTitle: (v: string) => void;
description: string;
setDescription: (v: string) => void;
changelog: string;
setChangelog: (v: string) => void;
content: string;
onContentChange: (v: string) => void;
}) => (
@@ -418,7 +430,7 @@ const StepDetails = ({
rows={8}
required
/>
{type === "widget" && (
{type === "customWidget" && (
<p className="text-xs text-muted-foreground">
Paste your widget JSON — title and description will auto-fill from the{" "}
<code className="rounded bg-muted px-1 py-0.5 text-foreground">name</code> field.
@@ -452,6 +464,19 @@ const StepDetails = ({
placeholder="A brief description of what this does"
/>
</div>
<div className="flex flex-col gap-1.5">
<label htmlFor="submit-changelog" className="text-xs font-medium text-muted-foreground">
Changelog
</label>
<Textarea
id="submit-changelog"
value={changelog}
onChange={(e) => setChangelog(e.target.value)}
maxLength={2000}
rows={2}
placeholder="What is included in this revision?"
/>
</div>
</div>
);
@@ -0,0 +1,188 @@
import { useCallback, useEffect, useMemo, useState } from "react";
import type { WorkshopReport, WorkshopSubmissionSummary, WorkshopUser } from "@homarr/workshop";
import { WorkshopClient } from "@homarr/workshop";
const styles = {
shell: "marketplace mx-auto max-w-5xl px-4 pb-16 text-foreground",
hero: "py-8 [&_h1]:text-2xl [&_h1]:font-semibold [&_p]:mt-1 [&_p]:text-sm [&_p]:text-muted-foreground",
actions: "mt-4 flex flex-wrap items-center gap-2",
button: "rounded-md bg-primary px-3 py-2 text-sm font-medium text-primary-foreground",
secondary: "rounded-md border border-border px-3 py-2 text-sm hover:bg-muted disabled:opacity-50",
danger: "rounded-md bg-destructive px-3 py-2 text-sm font-medium text-destructive-foreground disabled:opacity-50",
error: "mb-4 rounded-lg border border-destructive/30 bg-destructive/10 px-4 py-3 text-sm text-destructive",
adminList: "space-y-4",
adminSummary: "flex items-center justify-between rounded-lg border border-border bg-card px-4 py-3 text-sm",
adminGroup: "rounded-xl border border-border bg-card p-5 shadow-sm",
adminHeading: "flex flex-wrap items-start justify-between gap-3 [&_h2]:font-semibold [&_span]:text-sm [&_span]:text-muted-foreground",
reportCount: "rounded-full bg-destructive/10 px-2 py-1 !text-destructive",
meta: "mt-3 flex flex-wrap gap-4 text-xs text-muted-foreground",
moderationReport: "mt-4 rounded-lg border border-destructive/20 bg-destructive/5 p-4 [&_strong]:block [&_span]:text-xs [&_span]:text-muted-foreground [&_p]:my-2 [&_p]:text-sm",
empty: "py-12 text-center text-sm text-muted-foreground",
} as const;
export function WorkshopAdmin({ workshopUrl }: { workshopUrl?: string }) {
const client = useMemo(
() => {
const url = workshopUrl || (typeof window === "undefined" ? "" : window.location.origin);
return url ? new WorkshopClient(url) : new WorkshopClient();
},
[workshopUrl],
);
const [user, setUser] = useState<WorkshopUser | null>(null);
const [reports, setReports] = useState<WorkshopReport[]>([]);
const [submissions, setSubmissions] = useState<WorkshopSubmissionSummary[]>([]);
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] = await Promise.all([
client.listReports(),
client.listAll({ sort: "newest" }),
]);
setReports(nextReports);
setSubmissions(nextSubmissions);
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((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),
}));
return (
<main className={styles.shell}>
<section className={styles.hero}>
<h1>Workshop administration</h1>
<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 && (
<section className={styles.adminList} aria-busy={loading}>
<div className={styles.adminSummary}>
<strong>{reports.length} open reports</strong>
<span>{submissions.length} published submissions</span>
</div>
{grouped.map(({ submission, reports: submissionReports }) => (
<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 || "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.moderationReport}>
<div>
<strong>{report.category}</strong>
<span>Reported by {report.reporterName}</span>
</div>
<p>{report.explanation}</p>
<button
className={styles.secondary}
disabled={busyId === report.id}
onClick={() => void dismiss(report.id)}
>
{busyId === report.id ? "Dismissing…" : "Dismiss report"}
</button>
</div>
))}
<div className={styles.actions}>
<a className={styles.secondary} href={`/workshop/${submission.id}`}>
Inspect
</a>
<button
className={styles.danger}
disabled={busyId === submission.id}
onClick={() => void remove(submission)}
>
{busyId === submission.id ? "Deleting…" : "Delete submission"}
</button>
</div>
</article>
))}
{!loading && grouped.length === 0 && <p className={styles.empty}>No Workshop submissions.</p>}
</section>
)}
</main>
);
}
+134 -17
View File
@@ -34,6 +34,15 @@ import {
CardHeader,
CardTitle,
} from "@/components/ui/card";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import { Textarea } from "@/components/ui/textarea";
import { cn } from "@/lib/utils";
import { SubmitForm } from "./SubmitForm";
@@ -42,13 +51,13 @@ import { downloadSubmissionJson } from "./workshop-utils";
import type { SortKey, TypeFilter } from "./useWorkshop";
import { useWorkshop } from "./useWorkshop";
const typeDotColors: Record<SubmissionType, string> = { css: "bg-blue-500", widget: "bg-yellow-500" };
const typeLabels: Record<SubmissionType, string> = { css: "CSS", widget: "Widget" };
const typeDotColors: Record<SubmissionType, string> = { customCss: "bg-blue-500", customWidget: "bg-yellow-500" };
const typeLabels: Record<SubmissionType, string> = { customCss: "CSS", customWidget: "Widget" };
const typeIcons: Record<SubmissionType, React.ComponentType<{ size: number; className?: string }>> = {
css: IconBrandCss3,
widget: IconPuzzle,
customCss: IconBrandCss3,
customWidget: IconPuzzle,
};
const typeBgColors: Record<SubmissionType, string> = { css: "bg-blue-500/5", widget: "bg-yellow-500/5" };
const typeBgColors: Record<SubmissionType, string> = { customCss: "bg-blue-500/5", customWidget: "bg-yellow-500/5" };
const filterActiveClass = "bg-background text-foreground shadow-sm";
const filterInactiveClass = "text-muted-foreground hover:text-foreground";
const copyState = [
@@ -62,13 +71,15 @@ const emptyState = {
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: "customWidget", label: "Widgets", dot: typeDotColors.customWidget },
{ value: "customCss", label: "CSS", dot: typeDotColors.customCss },
{ value: "yours", label: "Yours" },
];
const sortOptions: { value: SortKey; label: string }[] = [
{ value: "top", label: "Top rated" },
{ value: "new", label: "Newest" },
{ value: "recent", label: "Recently updated" },
{ value: "discussed", label: "Most discussed" },
];
const stopCardNavigation = (event: React.MouseEvent<HTMLButtonElement>) => {
@@ -81,6 +92,7 @@ export const WorkshopApp = ({ workshopUrl }: { workshopUrl: string }) => {
const [typeFilter, setTypeFilter] = useState<TypeFilter>("all");
const [sort, setSort] = useState<SortKey>("top");
const [search, setSearch] = useState("");
const [includeOutdated, setIncludeOutdated] = useState(true);
const [showSubmit, setShowSubmit] = useState(false);
// Stable sort order: only re-sort when filter/sort/search or submission set changes (not vote counts)
@@ -92,11 +104,12 @@ export const WorkshopApp = ({ workshopUrl }: { workshopUrl: string }) => {
if (typeFilter === "yours") return item.author === workshop.user?.id;
return typeFilter === "all" || item.type === typeFilter;
})
.filter((item) => includeOutdated || !item.outdated)
.filter((item) => !q || item.title.toLowerCase().includes(q))
.toSorted(workshop.sorters[sort])
.map((s) => s.id);
// eslint-disable-next-line react-hooks/exhaustive-deps -- intentionally keyed on submissionIds string, not the array
}, [submissionIds, workshop.sorters, typeFilter, sort, search, workshop.user?.id]);
}, [submissionIds, workshop.sorters, typeFilter, sort, search, includeOutdated, workshop.user?.id]);
const visible = useMemo(() => {
const byId = new Map(workshop.submissions.map((s) => [s.id, s]));
@@ -119,7 +132,7 @@ export const WorkshopApp = ({ workshopUrl }: { workshopUrl: string }) => {
<IconPlus size={14} /> Share yours
</Button>
<Button variant="ghost" size="sm" onClick={workshop.logout}>
<IconLogout size={14} /> {workshop.user?.name || workshop.user?.username || "Account"}
<IconLogout size={14} /> {workshop.user?.displayName || workshop.user?.username || "Account"}
</Button>
</>
) : (
@@ -162,6 +175,16 @@ export const WorkshopApp = ({ workshopUrl }: { workshopUrl: string }) => {
</option>
))}
</select>
<button
type="button"
onClick={() => setIncludeOutdated((value) => !value)}
className={cn(
"h-7 rounded-md border border-input px-2 text-xs transition-colors",
includeOutdated ? "bg-transparent text-muted-foreground" : filterActiveClass,
)}
>
{includeOutdated ? "Hide outdated" : "Show outdated"}
</button>
</div>
<div className="relative">
<IconSearch size={14} className="absolute left-2.5 top-1/2 -translate-y-1/2 text-muted-foreground" />
@@ -212,6 +235,7 @@ export const WorkshopApp = ({ workshopUrl }: { workshopUrl: string }) => {
pb={workshop.pb}
userVote={workshop.votes[submission.id]?.value}
currentUserId={workshop.user?.id}
currentUserIsAdmin={workshop.user?.isAdmin === true}
onVote={workshop.vote}
onReport={workshop.report}
onDelete={workshop.deleteSubmission}
@@ -236,8 +260,13 @@ interface SubmissionCardProps {
pb: ReturnType<typeof useWorkshop>["pb"];
userVote?: 1 | -1;
currentUserId?: string;
currentUserIsAdmin?: boolean;
onVote: (submissionId: string, value: 1 | -1) => void;
onReport: (submissionId: string, reason: string) => void;
onReport: (
submissionId: string,
category: "malicious" | "spam" | "copyright" | "inappropriate" | "other",
explanation: string,
) => void;
onDelete: (submissionId: string) => Promise<boolean>;
}
@@ -246,11 +275,12 @@ const SubmissionCard = ({
pb,
userVote,
currentUserId,
currentUserIsAdmin,
onVote,
onReport,
onDelete,
}: SubmissionCardProps) => {
const isOwner = currentUserId === submission.author;
const canDelete = currentUserId === submission.author || currentUserIsAdmin;
const [copied, setCopied] = useState(false);
const [confirmDelete, setConfirmDelete] = useState(false);
const confirmTimer = useRef<ReturnType<typeof setTimeout>>(null);
@@ -271,9 +301,19 @@ const SubmissionCard = ({
);
const [copyFailed, setCopyFailed] = useState(false);
const [reportOpen, setReportOpen] = useState(false);
const [reportCategory, setReportCategory] = useState("other");
const [reportExplanation, setReportExplanation] = useState("");
const loadContent = async () => {
if (submission.content) return submission.content;
const record = await pb.collection("submissions").getOne<{ content: string }>(submission.id);
return record.content;
};
const handleCopy = async () => {
try {
await navigator.clipboard.writeText(submission.content);
await navigator.clipboard.writeText(await loadContent());
setCopied(true);
setCopyFailed(false);
if (copyTimer.current) clearTimeout(copyTimer.current);
@@ -284,6 +324,15 @@ const SubmissionCard = ({
}
};
const handleDownload = async () => {
try {
downloadSubmissionJson({ ...submission, content: await loadContent() });
} catch {
setCopyFailed(true);
setTimeout(() => setCopyFailed(false), 2000);
}
};
const handleDelete = () => {
if (!confirmDelete) {
setConfirmDelete(true);
@@ -332,7 +381,18 @@ const SubmissionCard = ({
)}
</div>
<CardDescription className="text-xs">
{submission.authorName} · v{submission.version} · {formatRelativeTime(submission.created)}
<a
href={submission.authorGithubProfileUrl || undefined}
target={submission.authorGithubProfileUrl ? "_blank" : undefined}
rel="noreferrer"
className="inline-flex items-center gap-1.5 hover:text-foreground"
>
{submission.authorAvatarUrl && (
<img src={submission.authorAvatarUrl} alt="" className="size-4 rounded-full object-cover" />
)}
{submission.authorName}
</a>{" "}
· v{submission.revision} · {formatRelativeTime(submission.created)}
</CardDescription>
<CardAction>
<div className="flex items-center gap-px rounded-md border border-border bg-muted/40 p-px">
@@ -370,6 +430,16 @@ const SubmissionCard = ({
</CardHeader>
<CardContent>
{(submission.outdated || submission.reportCount > 0) && (
<div className="mb-2 flex flex-wrap gap-1.5">
{submission.outdated && <Badge variant="secondary">Outdated</Badge>}
{submission.reportCount > 0 && (
<Badge variant="destructive">
{submission.reportCount} community report{submission.reportCount === 1 ? "" : "s"}
</Badge>
)}
</div>
)}
{submission.description && (
<p className="line-clamp-2 text-xs leading-relaxed text-muted-foreground">{submission.description}</p>
)}
@@ -393,11 +463,11 @@ const SubmissionCard = ({
</>
)}
</Button>
{submission.type === "widget" && (
{submission.type === "customWidget" && (
<Button
variant="ghost"
size="xs"
onClick={() => downloadSubmissionJson(submission)}
onClick={() => void handleDownload()}
className="text-muted-foreground hover:text-foreground"
>
<IconDownload size={13} /> Download
@@ -412,7 +482,7 @@ const SubmissionCard = ({
</a>
)}
<div className="ml-auto flex items-center gap-px">
{isOwner && (
{canDelete && (
<button
className={cn(
"flex items-center gap-1 rounded px-1 py-0.5 text-xs transition-colors",
@@ -429,7 +499,7 @@ const SubmissionCard = ({
)}
<button
className="flex items-center justify-center rounded p-1 text-muted-foreground/60 transition-colors hover:text-destructive"
onClick={() => void onReport(submission.id, "flagged")}
onClick={() => setReportOpen(true)}
aria-label="Report"
>
<IconFlag size={13} />
@@ -437,6 +507,53 @@ const SubmissionCard = ({
</div>
</div>
</CardFooter>
<Dialog open={reportOpen} onOpenChange={setReportOpen}>
<DialogContent className="sm:max-w-md">
<DialogHeader>
<DialogTitle>Report submission</DialogTitle>
<DialogDescription>Reports are public and help others make an informed choice.</DialogDescription>
</DialogHeader>
<label className="grid gap-1.5 text-sm">
Category
<select
className="h-9 rounded-md border border-input bg-transparent px-3"
value={reportCategory}
onChange={(event) => setReportCategory(event.target.value)}
>
<option value="malicious">Malicious</option>
<option value="spam">Spam</option>
<option value="copyright">Copyright</option>
<option value="inappropriate">Inappropriate</option>
<option value="other">Other</option>
</select>
</label>
<Textarea
value={reportExplanation}
onChange={(event) => setReportExplanation(event.target.value)}
placeholder="Explain what is wrong…"
maxLength={1000}
/>
<DialogFooter>
<Button variant="ghost" onClick={() => setReportOpen(false)}>
Cancel
</Button>
<Button
variant="destructive"
disabled={reportExplanation.trim().length < 3}
onClick={() => {
void onReport(
submission.id,
reportCategory as "malicious" | "spam" | "copyright" | "inappropriate" | "other",
reportExplanation.trim(),
);
setReportOpen(false);
}}
>
Submit report
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</Card>
);
};
@@ -0,0 +1 @@
export { default } from "./DetailPage";
@@ -2,20 +2,21 @@ import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import dayjs from "dayjs";
import type { WorkshopComment, WorkshopSubmission, WorkshopVote } from "@site/src/lib/pocketbase";
import { getPocketBase } from "@site/src/lib/pocketbase";
import { getPocketBase, signInWithGitHub } from "@site/src/lib/pocketbase";
import type { SubmissionType } from "@site/src/lib/workshop-schema";
import { schemaVersionByType, validateSubmissionContent } from "@site/src/lib/workshop-schema";
import { errorMessage } from "@site/src/lib/utils";
import { errorMessage, oauthErrorMessage } from "@site/src/lib/utils";
import { voteDelta } from "./workshop-utils";
export type SortKey = "top" | "new";
export type SortKey = "top" | "new" | "recent" | "discussed";
export type TypeFilter = "all" | "yours" | SubmissionType;
export interface SubmitInput {
type: SubmissionType;
title: string;
description: string;
changelog: string;
content: string;
screenshots: File[];
}
@@ -30,6 +31,8 @@ export interface CommentActions {
const sorters: Record<SortKey, (a: WorkshopSubmission, b: WorkshopSubmission) => number> = {
top: (a, b) => b.upvotes - b.downvotes - (a.upvotes - a.downvotes),
new: (a, b) => dayjs(b.created).valueOf() - dayjs(a.created).valueOf(),
recent: (a, b) => dayjs(b.updated).valueOf() - dayjs(a.updated).valueOf(),
discussed: (a, b) => b.commentCount - a.commentCount || dayjs(b.created).valueOf() - dayjs(a.created).valueOf(),
};
const isNotFoundError = (error: unknown) =>
@@ -59,7 +62,7 @@ export const useWorkshop = (workshopUrl: string) => {
setLoading(true);
setError(null);
try {
setSubmissions(await pb.collection("marketplace").getFullList<WorkshopSubmission>({ sort: "-created" }));
setSubmissions(await pb.collection("workshop_listings").getFullList<WorkshopSubmission>({ sort: "-created" }));
await refreshVotes();
} catch (caught) {
setError(errorMessage(caught, "Failed to load the workshop"));
@@ -94,15 +97,8 @@ export const useWorkshop = (workshopUrl: string) => {
}, [pb, refresh]);
const ensureAuth = useCallback(async () => {
if (pb.authStore.isValid) {
try {
await pb.collection("users").authRefresh();
return true;
} catch {
pb.authStore.clear();
}
}
await pb.collection("users").authWithOAuth2({ provider: "github" });
if (pb.authStore.isValid) return true;
await signInWithGitHub(pb);
await refreshVotes();
return pb.authStore.isValid;
}, [pb, refreshVotes]);
@@ -113,7 +109,7 @@ export const useWorkshop = (workshopUrl: string) => {
try {
authenticated = await ensureAuth();
} catch (caught) {
setError(errorMessage(caught, `Sign in to ${action}`));
setError(oauthErrorMessage(caught));
return null;
}
@@ -136,7 +132,7 @@ export const useWorkshop = (workshopUrl: string) => {
await ensureAuth();
setError(null);
} catch (caught) {
setError(errorMessage(caught, "Sign in failed"));
setError(oauthErrorMessage(caught));
}
}, [ensureAuth]);
@@ -217,11 +213,21 @@ export const useWorkshop = (workshopUrl: string) => {
);
const report = useCallback(
async (submissionId: string, reason: string) => {
async (
submissionId: string,
category: "malicious" | "spam" | "copyright" | "inappropriate" | "other",
explanation: string,
) => {
try {
const userId = await requireUserId("report");
if (!userId) return;
await pb.collection("reports").create({ submission: submissionId, reason, user: userId });
await pb.collection("reports").create({
submission: submissionId,
reporter: userId,
category,
explanation,
status: "open",
});
} catch (caught) {
setError(errorMessage(caught, "Failed to submit your report"));
}
@@ -255,9 +261,12 @@ export const useWorkshop = (workshopUrl: string) => {
data.set("type", input.type);
data.set("title", input.title);
data.set("description", input.description);
data.set("schemaVersion", schemaVersionByType[input.type]);
data.set("widgetSchema", schemaVersionByType[input.type]);
data.set("content", input.content);
data.set("author", userId);
data.set("revision", "1");
data.set("changelog", input.changelog || "Initial publication");
data.set("outdated", "false");
for (const file of input.screenshots) data.append("screenshots", file);
await pb.collection("submissions").create(data);
await refresh();
+39 -4
View File
@@ -14,24 +14,50 @@ export const getPocketBase = (url: string): PocketBase => {
export const getSubmissionFileUrl = (baseUrl: string, submissionId: string, filename: string) =>
`${baseUrl}/api/files/submissions/${submissionId}/${encodeURIComponent(filename)}`;
export const signInWithGitHub = async (pb: PocketBase) => {
const auth = await pb.collection("users").authWithOAuth2({
provider: "github",
createData: { displayName: "GitHub user" },
});
const meta = auth.meta as Record<string, unknown>;
const rawUser = meta.rawUser && typeof meta.rawUser === "object" ? (meta.rawUser as Record<string, unknown>) : {};
const githubUsername = String(meta.username || rawUser.login || "");
const displayName = String(meta.name || rawUser.name || githubUsername || "GitHub user").slice(0, 100);
const avatarUrl = String(meta.avatarUrl || meta.avatarURL || rawUser.avatar_url || "");
const updated = await pb.collection("users").update(auth.record.id, {
displayName,
avatarUrl: avatarUrl.startsWith("https://") ? avatarUrl : "",
githubUsername,
githubProfileUrl: githubUsername ? `https://github.com/${encodeURIComponent(githubUsername)}` : "",
});
pb.authStore.save(auth.token, updated);
return updated;
};
export interface WorkshopSubmission {
id: string;
collectionId: string;
collectionName: string;
type: "css" | "widget";
type: "customCss" | "customWidget";
title: string;
description: string;
schemaVersion: string;
widgetSchema: string;
content: string;
screenshots: string[];
upvotes: number;
downvotes: number;
commentCount: number;
version: number;
reportCount: number;
revision: number;
changelog: string;
outdated: boolean;
author: string;
authorName: string;
authorAvatarUrl: string;
authorGithubUsername: string;
authorGithubProfileUrl: string;
created: string;
updated: string;
}
export interface WorkshopVote {
@@ -48,5 +74,14 @@ export interface WorkshopComment {
content: string;
created: string;
updated: string;
expand?: { author?: { id: string; name?: string; username?: string } };
expand?: {
author?: {
id: string;
displayName?: string;
avatarUrl?: string;
githubUsername?: string;
githubProfileUrl?: string;
isAdmin?: boolean;
};
};
}
+10
View File
@@ -9,3 +9,13 @@ export function cn(...inputs: ClassValue[]) {
export function errorMessage(caught: unknown, fallback: string) {
return caught instanceof Error ? caught.message : fallback;
}
export function oauthErrorMessage(caught: unknown) {
const message = errorMessage(caught, "GitHub sign-in failed");
if (/popup.*block|blocked.*popup/iu.test(message))
return "GitHub sign-in popup was blocked. Allow popups for this site and try again.";
if (/cancel|closed.*popup|popup.*closed/iu.test(message)) return "GitHub sign-in was cancelled before it completed.";
if (/provider|oauth|redirect|callback|client.?id/iu.test(message))
return "GitHub sign-in is not configured correctly on the Workshop server.";
return message;
}
+10 -23
View File
@@ -1,49 +1,36 @@
import { describe, expect, it } from "vitest";
import { CUSTOM_WIDGET_STARTER } from "@homarr/custom-widgets/core";
import { validateSubmissionContent } from "./workshop-schema";
const validWidget = {
$schema: "homarr-custom-widget-v2",
name: "Example",
url: "https://example.com/api",
authType: "none",
method: "GET",
displayType: "singleValue",
displayConfig: { type: "singleValue", jsonPath: "$.count", label: "Count", unit: "" },
};
const validWidget = CUSTOM_WIDGET_STARTER;
describe("validateSubmissionContent", () => {
it("accepts a valid homarr-custom-widget-v2 JSON", () => {
expect(validateSubmissionContent("widget", JSON.stringify(validWidget)).success).toBe(true);
expect(validateSubmissionContent("customWidget", JSON.stringify(validWidget)).success).toBe(true);
});
it("rejects an old/unknown widget schema version", () => {
const result = validateSubmissionContent(
"widget",
"customWidget",
JSON.stringify({ ...validWidget, $schema: "homarr-custom-widget-v1" }),
);
expect(result.success).toBe(false);
});
it("rejects widgets without a schema version", () => {
const widgetWithoutSchema = {
name: validWidget.name,
url: validWidget.url,
authType: validWidget.authType,
method: validWidget.method,
displayType: validWidget.displayType,
displayConfig: validWidget.displayConfig,
};
const { $schema: _schema, ...widgetWithoutSchema } = validWidget;
expect(validateSubmissionContent("widget", JSON.stringify(widgetWithoutSchema)).success).toBe(false);
expect(validateSubmissionContent("customWidget", JSON.stringify(widgetWithoutSchema)).success).toBe(false);
});
it("rejects malformed widget JSON", () => {
expect(validateSubmissionContent("widget", "{ not json").success).toBe(false);
expect(validateSubmissionContent("customWidget", "{ not json").success).toBe(false);
});
it("accepts non-empty CSS and rejects empty CSS", () => {
expect(validateSubmissionContent("css", ".card { color: red; }").success).toBe(true);
expect(validateSubmissionContent("css", " ").success).toBe(false);
expect(validateSubmissionContent("customCss", ".card { color: red; }").success).toBe(true);
expect(validateSubmissionContent("customCss", " ").success).toBe(false);
});
});
+17 -71
View File
@@ -1,78 +1,24 @@
import { z } from "zod";
import { CUSTOM_WIDGET_SCHEMA, type HomarrCustomWidgetV2 } from "@homarr/custom-widgets/core";
import {
MAX_WORKSHOP_CSS_LENGTH,
type WorkshopSubmissionType,
validateWorkshopContent,
WORKSHOP_CSS_SCHEMA,
} from "@homarr/workshop/schema";
export type SubmissionType = "css" | "widget";
export type SubmissionType = WorkshopSubmissionType;
export const WIDGET_SCHEMA_VERSION = CUSTOM_WIDGET_SCHEMA;
export const CSS_SCHEMA_VERSION = WORKSHOP_CSS_SCHEMA;
export const MAX_CSS_LENGTH = MAX_WORKSHOP_CSS_LENGTH;
export const WIDGET_SCHEMA_VERSION = "homarr-custom-widget-v2";
export const CSS_SCHEMA_VERSION = "homarr-custom-css-v1";
export const MAX_CSS_LENGTH = 16384;
export type WorkshopValidationResult = { success: true; data: unknown } | { success: false; error: string };
const authTypes = ["none", "bearer", "basic", "apiKeyHeader", "apiKeyQuery"] as const;
const methods = ["GET", "POST", "PUT", "DELETE", "PATCH"] as const;
const displayTypes = [
"singleValue",
"keyValue",
"table",
"statGrid",
"progressBars",
"statusIndicator",
"countGrid",
"raw",
"actionButton",
"customJsx",
] as const;
const widgetSchema = z
.object({
$schema: z.literal(WIDGET_SCHEMA_VERSION),
name: z.string().min(1).max(128),
url: z.string().min(1),
authType: z.enum(authTypes),
method: z.enum(methods),
displayType: z.enum(displayTypes),
displayConfig: z.object({ type: z.enum(displayTypes) }),
})
.refine((widget) => widget.displayConfig.type === widget.displayType, {
message: "displayConfig.type must match displayType",
});
const validateWidgetContent = (raw: string): WorkshopValidationResult => {
let parsed: unknown;
try {
parsed = JSON.parse(raw);
} catch {
return { success: false, error: "Content is not valid JSON" };
}
const result = widgetSchema.safeParse(parsed);
if (!result.success) {
return { success: false, error: result.error.issues[0]?.message ?? "Invalid custom widget JSON" };
}
return { success: true, data: result.data };
};
const validateCssContent = (raw: string): WorkshopValidationResult => {
if (raw.trim().length === 0) {
return { success: false, error: "CSS cannot be empty" };
}
if (raw.length > MAX_CSS_LENGTH) {
return { success: false, error: `CSS must be at most ${MAX_CSS_LENGTH} characters` };
}
return { success: true, data: raw };
};
export const submissionValidators = {
widget: validateWidgetContent,
css: validateCssContent,
} satisfies Record<SubmissionType, (raw: string) => WorkshopValidationResult>;
export type WorkshopValidationResult =
| { success: true; data: HomarrCustomWidgetV2 | string }
| { success: false; error: string };
export const schemaVersionByType = {
widget: WIDGET_SCHEMA_VERSION,
css: CSS_SCHEMA_VERSION,
customWidget: WIDGET_SCHEMA_VERSION,
customCss: CSS_SCHEMA_VERSION,
} satisfies Record<SubmissionType, string>;
export const validateSubmissionContent = (type: SubmissionType, raw: string): WorkshopValidationResult =>
submissionValidators[type](raw);
validateWorkshopContent(type, raw);
-1
View File
@@ -27,7 +27,6 @@ section {
max-width: min(100%, 500px);
}
.heroBanner {
padding: 4rem;
position: relative;
@@ -0,0 +1,15 @@
import React from "react";
import useDocusaurusContext from "@docusaurus/useDocusaurusContext";
import Layout from "@theme/Layout";
import { WorkshopAdmin } from "@site/src/components/workshop/WorkshopAdmin";
export default function WorkshopAdminRoute() {
const { siteConfig } = useDocusaurusContext();
const workshopUrl = (siteConfig.customFields?.workshopUrl as string | undefined) ?? "";
return (
<Layout title="Workshop administration" noFooter>
<WorkshopAdmin workshopUrl={workshopUrl} />
</Layout>
);
}