Restore Workshop and preserve Custom Widget docs
This commit is contained in:
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" />;
|
||||
}
|
||||
+13
-13
@@ -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>
|
||||
)}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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();
|
||||
|
||||
@@ -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;
|
||||
};
|
||||
};
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
Reference in new issue
Block a user