fix(custom-widgets): harden v2 authoring
This commit is contained in:
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";
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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
Reference in new issue
Block a user