feat(custom-widgets): complete JSX v2

This commit is contained in:
Thomas Camlong committed 2026-07-18 15:33:25 +02:00
1 parent c0f1d6624c
commit eae48f50fd
263 files changed
+65503 -17816

No files matched your search

@@ -0,0 +1,129 @@
import { useCallback, useEffect, useMemo, useState } from "react";
import type { WorkshopAdminAction, WorkshopReport, WorkshopSubmissionSummary, WorkshopUser } from "@homarr/workshop";
import { WorkshopClient } from "@homarr/workshop";
import styles from "./workshop.module.css";
export function WorkshopAdmin() {
const client = useMemo(
() => new WorkshopClient(typeof window === "undefined" ? undefined : window.location.origin),
[],
);
const [user, setUser] = useState<WorkshopUser | null>(null);
const [reports, setReports] = useState<WorkshopReport[]>([]);
const [submissions, setSubmissions] = useState<WorkshopSubmissionSummary[]>([]);
const [actions, setActions] = useState<WorkshopAdminAction[]>([]);
const [error, setError] = useState("");
const load = useCallback(async () => {
try {
const [nextReports, nextSubmissions, nextActions] = await Promise.all([
client.listReports(),
client.listAll({ sort: "newest" }),
client.listAdminActions(),
]);
setReports(nextReports);
setSubmissions(nextSubmissions);
setActions(nextActions);
setError("");
} catch (cause) {
setError(cause instanceof Error ? cause.message : "Unable to load Workshop administration");
}
}, [client]);
useEffect(() => {
const unsubscribe = client.subscribeToAuth(setUser);
void client.refreshAuth().then((next) => {
setUser(next);
if (next?.isAdmin) void load();
});
return unsubscribe;
}, [client, load]);
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 reports, see each reporter and vote totals, dismiss reports, or delete any submission. This page does
not manage user accounts.
</p>
{!user && (
<button
className={styles.button}
onClick={() =>
void client.signInWithGitHub().then((next) => {
setUser(next);
if (next?.isAdmin) void load();
})
}
>
Sign in with GitHub
</button>
)}
</section>
{user && !user.isAdmin && <div className={styles.error}>This Workshop account is not an administrator.</div>}
{error && <div className={styles.error}>{error}</div>}
{user?.isAdmin && (
<div className={styles.stack} style={{ marginTop: "2rem" }}>
<h2>Submissions and open reports</h2>
{grouped.map(({ submission, reports: submissionReports }) => (
<section key={submission.id} className={styles.adminGroup}>
<div className={styles.meta}>
<strong>{submission.title}</strong>
<span>
▲ {submission.upvotes} · ▼ {submission.downvotes} · score {submission.score} ·{" "}
{submissionReports.length} reports
</span>
</div>
<p>{submission.description}</p>
{submissionReports.map((report) => (
<div key={report.id} className={styles.report}>
<strong>{report.category}</strong> by {report.reporterName}
<p>{report.explanation}</p>
<button
className={styles.secondary}
onClick={() => {
const reason = window.prompt("Why is this report being dismissed?")?.trim();
if (reason) void client.dismissReport(report.id, reason).then(load);
}}
>
Dismiss report
</button>
</div>
))}
<div className={styles.actions}>
<a className={styles.secondary} href={`/workshop?id=${submission.id}`}>
Inspect
</a>
<button
className={styles.danger}
onClick={() => {
if (window.confirm(`Delete ${submission.title}? Installed local copies will remain.`))
void client.delete(submission.id, "Deleted after administrator review").then(load);
}}
>
Delete submission
</button>
</div>
</section>
))}
<h2>Administrator audit trail</h2>
{actions.map((action) => (
<div key={action.id} className={`${styles.adminGroup} ${styles.audit}`}>
{new Date(action.created).toLocaleString()} · {action.actorName} · {action.action} · submission{" "}
{action.submissionId || "—"} · report {action.reportId || "—"}
<br />
{action.reason}
</div>
))}
</div>
)}
</main>
);
}
@@ -0,0 +1,436 @@
import { useCallback, useEffect, useMemo, useState } from "react";
import type {
WorkshopReport,
WorkshopSubmissionDetail,
WorkshopSubmissionInput,
WorkshopSubmissionSummary,
WorkshopUser,
} from "@homarr/workshop";
import { validateWorkshopWidget, WorkshopClient } from "@homarr/workshop";
import styles from "./workshop.module.css";
const emptyDraft: WorkshopSubmissionInput = {
title: "",
description: "",
content: "",
changelog: "Initial publication",
};
export function WorkshopPage() {
const client = useMemo(
() => new WorkshopClient(typeof window === "undefined" ? undefined : window.location.origin),
[],
);
const [user, setUser] = useState<WorkshopUser | null>(null);
const [items, setItems] = useState<WorkshopSubmissionSummary[]>([]);
const [selected, setSelected] = useState<WorkshopSubmissionDetail | null>(null);
const [search, setSearch] = useState("");
const [sort, setSort] = useState<"top" | "newest">("top");
const [draft, setDraft] = useState<WorkshopSubmissionInput>(emptyDraft);
const [screenshots, setScreenshots] = useState<File[]>([]);
const [editorOpen, setEditorOpen] = useState(false);
const [reportOpen, setReportOpen] = useState(false);
const [reportCategory, setReportCategory] = useState<WorkshopReport["category"]>("other");
const [reportText, setReportText] = useState("");
const [busy, setBusy] = useState(false);
const [error, setError] = useState("");
const load = useCallback(async () => {
try {
setItems((await client.list({ search, sort, perPage: 60 })).items);
setError("");
} catch (cause) {
setError(cause instanceof Error ? cause.message : "Workshop is unavailable");
}
}, [client, search, sort]);
const openDetail = useCallback(
async (id: string) => {
setBusy(true);
setError("");
try {
setSelected(await client.get(id));
} catch (cause) {
setError(cause instanceof Error ? cause.message : "Unable to load submission");
} finally {
setBusy(false);
}
},
[client],
);
useEffect(() => {
const unsubscribe = client.subscribeToAuth(setUser);
void client.refreshAuth().then(setUser);
const requestedSubmission = new URLSearchParams(window.location.search).get("id");
if (requestedSubmission) void openDetail(requestedSubmission);
return unsubscribe;
}, [client, openDetail]);
useEffect(() => {
const timer = window.setTimeout(() => void load(), 200);
return () => window.clearTimeout(timer);
}, [load]);
const save = async () => {
setBusy(true);
setError("");
try {
if (selected && selected.author === user?.id) await client.update(selected.id, draft, screenshots);
else await client.create(draft, screenshots);
setEditorOpen(false);
setDraft(emptyDraft);
setScreenshots([]);
await load();
} catch (cause) {
setError(cause instanceof Error ? cause.message : "Unable to publish");
} finally {
setBusy(false);
}
};
const edit = () => {
if (!selected) return;
setDraft({
title: selected.title,
description: selected.description,
content: selected.content,
changelog: selected.changelog,
});
setEditorOpen(true);
};
return (
<main className={styles.shell}>
<section className={styles.hero}>
<h1>Workshop</h1>
<p>
Discover and publish Custom JSX v2 widgets. Every submission is public immediately; the community can vote and
report unsafe content.
</p>
<div className={styles.actions}>
{user ? (
<>
<span>
Signed in as <strong>{user.displayName}</strong>
</span>
<button
className={styles.secondary}
onClick={() => {
client.signOut();
setUser(null);
}}
>
Sign out
</button>
<button
className={styles.button}
onClick={() => {
setSelected(null);
setDraft(emptyDraft);
setEditorOpen(true);
}}
>
Publish widget
</button>
</>
) : (
<button
className={styles.button}
onClick={() =>
void client
.signInWithGitHub()
.then(setUser)
.catch((cause: Error) => setError(cause.message))
}
>
Sign in with GitHub
</button>
)}
</div>
</section>
<div className={styles.notice}>
Inspect API origins, methods, permissions, and JSX before importing. Workshop never stores widget credentials.
</div>
<div className={styles.toolbar}>
<input
className={styles.input}
aria-label="Search Workshop"
placeholder="Search widgets"
value={search}
onChange={(event) => setSearch(event.currentTarget.value)}
/>
<select
className={styles.select}
style={{ width: 180 }}
value={sort}
onChange={(event) => setSort(event.currentTarget.value as "top" | "newest")}
>
<option value="top">Top rated</option>
<option value="newest">Newest</option>
</select>
</div>
{error && <div className={styles.error}>{error}</div>}
<div className={styles.grid}>
{items.map((item) => (
<article key={item.id} className={styles.card}>
{item.screenshots[0] ? (
<img
className={styles.previewImage}
src={client.fileUrl(item.id, item.screenshots[0], "480x320")}
alt=""
/>
) : (
<div className={styles.preview}>JSX</div>
)}
<div className={styles.cardBody}>
<div className={styles.meta}>
<span>Revision {item.revision}</span>
<span>
▲ {item.upvotes} · ▼ {item.downvotes}
</span>
</div>
<h2>{item.title}</h2>
<p>{item.description || "No description"}</p>
<small>by {item.authorName}</small>
<button className={styles.secondary} disabled={busy} onClick={() => void openDetail(item.id)}>
Inspect widget
</button>
</div>
</article>
))}
</div>
{selected && (
<div
className={styles.overlay}
role="presentation"
onMouseDown={(event) => event.target === event.currentTarget && setSelected(null)}
>
<dialog open className={`${styles.dialog} ${styles.stack}`} aria-modal="true" aria-label={selected.title}>
<h2>{selected.title}</h2>
<p>{selected.description}</p>
<div className={styles.meta}>
<span>
by {selected.authorName} · revision {selected.revision}
</span>
<span>SHA-256 {selected.contentHash.slice(0, 12)}…</span>
</div>
{selected.screenshots.length > 0 && (
<div className={styles.screenshots}>
{selected.screenshots.map((file, index) => (
<img
key={file}
src={client.fileUrl(selected.id, file, "960x640")}
alt={`${selected.title} screenshot ${index + 1}`}
/>
))}
</div>
)}
<WidgetCapabilitySummary content={selected.content} />
<pre className={styles.code}>{selected.content}</pre>
<div className={styles.actions}>
<button
className={styles.secondary}
disabled={!user}
onClick={() =>
void client
.vote(selected.id, 1)
.then(() => openDetail(selected.id))
.then(load)
}
>
▲ {selected.upvotes}
</button>
<button
className={styles.secondary}
disabled={!user}
onClick={() =>
void client
.vote(selected.id, -1)
.then(() => openDetail(selected.id))
.then(load)
}
>
▼ {selected.downvotes}
</button>
<button className={styles.danger} disabled={!user} onClick={() => setReportOpen(true)}>
Report
</button>
{selected.author === user?.id && (
<>
<button className={styles.secondary} onClick={edit}>
Edit
</button>
<button
className={styles.danger}
onClick={() =>
void client.delete(selected.id).then(() => {
setSelected(null);
return load();
})
}
>
Delete
</button>
</>
)}
<button
className={styles.button}
onClick={() => {
const url = URL.createObjectURL(new Blob([selected.content], { type: "application/json" }));
const link = document.createElement("a");
link.href = url;
link.download = `${selected.title}.json`;
link.click();
URL.revokeObjectURL(url);
}}
>
Download
</button>
<button className={styles.secondary} onClick={() => setSelected(null)}>
Close
</button>
</div>
</dialog>
</div>
)}
{editorOpen && (
<div className={styles.overlay}>
<dialog open className={`${styles.dialog} ${styles.stack}`} aria-modal="true" aria-label="Publish widget">
<h2>{selected?.author === user?.id ? "Update submission" : "Publish widget"}</h2>
<label className={styles.field}>
Title
<input
className={styles.input}
value={draft.title}
onChange={(event) => setDraft({ ...draft, title: event.currentTarget.value })}
/>
</label>
<label className={styles.field}>
Description
<textarea
className={styles.textarea}
value={draft.description}
onChange={(event) => setDraft({ ...draft, description: event.currentTarget.value })}
/>
</label>
<label className={styles.field}>
Canonical widget.json
<textarea
className={styles.textarea}
style={{ minHeight: 260 }}
value={draft.content}
onChange={(event) => setDraft({ ...draft, content: event.currentTarget.value })}
/>
</label>
<label className={styles.field}>
Changelog
<textarea
className={styles.textarea}
value={draft.changelog}
onChange={(event) => setDraft({ ...draft, changelog: event.currentTarget.value })}
/>
</label>
<label className={styles.field}>
Screenshots
<input
className={styles.input}
type="file"
accept="image/png,image/jpeg,image/webp"
multiple
onChange={(event) => setScreenshots(Array.from(event.currentTarget.files ?? []))}
/>
<span className={styles.hint}>Up to five images, 5 MiB each.</span>
</label>
{error && <div className={styles.error}>{error}</div>}
<div className={styles.actions}>
<button className={styles.button} disabled={busy || !user} onClick={() => void save()}>
{busy ? "Publishing…" : "Publish immediately"}
</button>
<button className={styles.secondary} onClick={() => setEditorOpen(false)}>
Cancel
</button>
</div>
</dialog>
</div>
)}
{reportOpen && selected && (
<div className={styles.overlay}>
<dialog open className={`${styles.dialog} ${styles.stack}`} aria-modal="true" aria-label="Report submission">
<h2>Report {selected.title}</h2>
<label className={styles.field}>
Category
<select
className={styles.select}
value={reportCategory}
onChange={(event) => setReportCategory(event.currentTarget.value as WorkshopReport["category"])}
>
{["malicious", "spam", "copyright", "inappropriate", "other"].map((value) => (
<option key={value}>{value}</option>
))}
</select>
</label>
<label className={styles.field}>
Explanation
<textarea
className={styles.textarea}
value={reportText}
onChange={(event) => setReportText(event.currentTarget.value)}
/>
</label>
<div className={styles.actions}>
<button
className={styles.danger}
disabled={reportText.trim().length < 3}
onClick={() =>
void client.report(selected.id, reportCategory, reportText).then(() => {
setReportOpen(false);
setReportText("");
})
}
>
Send report
</button>
<button className={styles.secondary} onClick={() => setReportOpen(false)}>
Cancel
</button>
</div>
</dialog>
</div>
)}
</main>
);
}
function WidgetCapabilitySummary({ content }: { content: string }) {
const result = validateWorkshopWidget(content);
if (!result.success) return <div className={styles.error}>{result.error}</div>;
return (
<div className={styles.capabilities}>
<section>
<strong>API sources</strong>
{result.data.sources.map((source) => (
<p key={source.id}>
<code>{new URL(source.baseUrl).origin}</code> · {source.networkScope} · {source.auth.type}
</p>
))}
</section>
<section>
<strong>Requests and permissions</strong>
{result.data.requests.length === 0 ? (
<p>No network requests</p>
) : (
result.data.requests.map((request) => (
<p key={request.id}>
<code>{request.id}</code> · {request.kind} · {request.method} · {request.minimumBoardPermission}
</p>
))
)}
</section>
</div>
);
}
@@ -0,0 +1,244 @@
.shell {
width: min(1180px, calc(100% - 2rem));
margin: 0 auto;
padding: 3rem 0 5rem;
}
.hero {
display: grid;
gap: 1rem;
padding: clamp(1.5rem, 4vw, 3rem);
border: 1px solid var(--ifm-color-emphasis-200);
border-radius: 1.25rem;
background: linear-gradient(135deg, color-mix(in srgb, var(--ifm-color-primary) 12%, transparent), transparent 60%);
}
.hero h1 {
margin: 0;
font-size: clamp(2.25rem, 6vw, 4.5rem);
letter-spacing: -0.05em;
}
.hero p {
margin: 0;
max-width: 62ch;
color: var(--ifm-color-emphasis-700);
font-size: 1.05rem;
}
.toolbar,
.actions {
display: flex;
align-items: center;
gap: 0.75rem;
flex-wrap: wrap;
}
.toolbar {
margin: 2rem 0 1.25rem;
}
.toolbar input {
flex: 1 1 280px;
}
.button,
.secondary,
.danger {
border: 0;
border-radius: 0.65rem;
padding: 0.7rem 1rem;
font: inherit;
font-weight: 700;
cursor: pointer;
}
.button {
color: white;
background: var(--ifm-color-primary);
}
.secondary {
color: var(--ifm-font-color-base);
background: var(--ifm-color-emphasis-200);
}
.danger {
color: white;
background: var(--ifm-color-danger-dark);
}
.button:disabled,
.secondary:disabled,
.danger:disabled {
opacity: 0.5;
cursor: not-allowed;
}
.input,
.textarea,
.select {
width: 100%;
border: 1px solid var(--ifm-color-emphasis-300);
border-radius: 0.65rem;
padding: 0.75rem;
color: var(--ifm-font-color-base);
background: var(--ifm-background-color);
font: inherit;
}
.textarea {
min-height: 110px;
resize: vertical;
}
.field {
display: grid;
gap: 0.35rem;
font-weight: 700;
}
.hint {
color: var(--ifm-color-emphasis-600);
font-size: 0.85rem;
font-weight: 400;
}
.grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(270px, 1fr));
gap: 1rem;
}
.card {
overflow: hidden;
border: 1px solid var(--ifm-color-emphasis-200);
border-radius: 1rem;
background: var(--ifm-card-background-color);
}
.cardBody {
display: grid;
gap: 0.65rem;
padding: 1rem;
}
.cardBody h2,
.cardBody p {
margin: 0;
}
.cardBody p {
color: var(--ifm-color-emphasis-700);
}
.preview {
display: grid;
place-items: center;
height: 160px;
background: var(--ifm-color-emphasis-100);
font: 800 1.5rem var(--ifm-font-family-monospace);
}
.previewImage {
width: 100%;
height: 160px;
object-fit: cover;
}
.meta {
display: flex;
justify-content: space-between;
gap: 1rem;
color: var(--ifm-color-emphasis-600);
font-size: 0.82rem;
}
.overlay {
position: fixed;
inset: 0;
z-index: 1000;
display: grid;
place-items: center;
padding: 1rem;
background: rgb(0 0 0 / 0.62);
}
.dialog {
position: relative;
inset: auto;
width: min(820px, 100%);
max-height: 90dvh;
margin: 0;
overflow: auto;
padding: 1.5rem;
border: 0;
border-radius: 1rem;
background: var(--ifm-background-color);
color: inherit;
box-shadow: 0 24px 80px rgb(0 0 0 / 0.35);
}
.dialog h2 {
margin-top: 0;
}
.stack {
display: grid;
gap: 1rem;
}
.code {
max-height: 340px;
overflow: auto;
padding: 1rem;
border-radius: 0.65rem;
background: var(--ifm-color-emphasis-100);
white-space: pre;
font-size: 0.78rem;
}
.notice,
.error,
.success {
padding: 0.85rem 1rem;
border-radius: 0.65rem;
}
.notice {
background: color-mix(in srgb, var(--ifm-color-warning) 20%, transparent);
}
.error {
background: color-mix(in srgb, var(--ifm-color-danger) 20%, transparent);
}
.success {
background: color-mix(in srgb, var(--ifm-color-success) 20%, transparent);
}
.screenshots {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(220px, 1fr));
gap: 0.75rem;
}
.screenshots img {
width: 100%;
border-radius: 0.65rem;
}
.capabilities {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(240px, 1fr));
gap: 0.75rem;
}
.capabilities section {
padding: 0.85rem;
border: 1px solid var(--ifm-color-emphasis-300);
border-radius: 0.65rem;
}
.capabilities p {
margin: 0.45rem 0 0;
color: var(--ifm-color-emphasis-700);
font-size: 0.82rem;
}
.adminGroup {
display: grid;
gap: 0.75rem;
padding: 1rem;
border: 1px solid var(--ifm-color-emphasis-300);
border-radius: 0.8rem;
}
.report {
padding-left: 0.75rem;
border-left: 3px solid var(--ifm-color-warning);
}
.audit {
font-family: var(--ifm-font-family-monospace);
font-size: 0.78rem;
}
@media (max-width: 600px) {
.shell {
width: min(100% - 1rem, 1180px);
padding-top: 1rem;
}
.dialog {
padding: 1rem;
}
.actions > * {
flex: 1 1 auto;
}
}
File diff suppressed because it is too large. Load diff
@@ -0,0 +1,12 @@
import React from "react";
import Layout from "@theme/Layout";
import { WorkshopAdmin } from "@site/src/components/workshop/WorkshopAdmin";
export default function WorkshopAdminRoute() {
return (
<Layout title="Workshop administration" noFooter>
<WorkshopAdmin />
</Layout>
);
}
+12
View File
@@ -0,0 +1,12 @@
import React from "react";
import Layout from "@theme/Layout";
import { WorkshopPage } from "@site/src/components/workshop/WorkshopPage";
export default function WorkshopRoute() {
return (
<Layout title="Workshop" description="Discover and publish Homarr Custom JSX widgets">
<WorkshopPage />
</Layout>
);
}