Compare commits
1
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
9037da96b5 |
No files matched your search
+3
-1
@@ -66,4 +66,6 @@ UNSAFE_ENABLE_MOCK_INTEGRATION=true
|
||||
|
||||
# Whether demo mode is read-only. Defaults to true (the public demo blocks all mutations).
|
||||
# Set to false for writable preview deployments so the demo user can modify the dashboard.
|
||||
# DEMO_READ_ONLY=false
|
||||
# DEMO_READ_ONLY=false
|
||||
# Central Homarr Workshop API. Override only when developing the Workshop locally.
|
||||
# NEXT_PUBLIC_WORKSHOP_API_URL=http://localhost:8090
|
||||
@@ -82,6 +82,10 @@ Custom widgets can be exported and imported as JSON files. To export, select the
|
||||
|
||||
The import format uses `"$schema": "homarr-custom-widget-v2"` to identify the widget version.
|
||||
|
||||
## Community Workshop
|
||||
|
||||
Use **Install widget** on **Management → Workshop** to inspect and import community widgets. Use **Share in Workshop** from a widget's action menu to publish its canonical export without manually copying JSON. Secrets are never included in exports and must be configured on each Homarr instance.
|
||||
|
||||
## Available Mantine Components for Custom JSX
|
||||
|
||||
When using the `customJsx` display type, your template has access to a whitelist of [Mantine v9](https://mantine.dev) components. Unrecognized components, event handlers, and dangerous tags are stripped for security.
|
||||
|
||||
@@ -0,0 +1,40 @@
|
||||
---
|
||||
tags:
|
||||
- Workshop
|
||||
- Custom Widgets
|
||||
- Custom CSS
|
||||
---
|
||||
|
||||
# Community Workshop
|
||||
|
||||
The [Workshop](pathname:///workshop) is Homarr's community catalog for custom widgets and CSS themes. You can inspect every creation before downloading it or using it in Homarr.
|
||||
|
||||
:::warning Community content
|
||||
|
||||
Workshop entries are created by community members. Review widget endpoints, templates, and CSS before using them. Keep a current Homarr backup and never add secrets to an exported widget.
|
||||
|
||||
:::
|
||||
|
||||
## Discover and install
|
||||
|
||||
In Homarr, open **Management → Workshop** to browse, search, filter, inspect, download, and install creations. The custom-widget management page and a board's custom CSS settings also provide contextual Workshop actions.
|
||||
|
||||
- Widgets are validated against `homarr-custom-widget-v2` before installation. API keys and other secrets are not included and must be configured locally.
|
||||
- CSS is loaded into the board editor for review. It is not saved until you choose **Save changes**.
|
||||
- The public website never executes submitted widget templates or CSS.
|
||||
|
||||
## Share a creation
|
||||
|
||||
Use **Share in Workshop** beside a custom widget or in a board's custom CSS settings. Sign in with GitHub, provide a public title and description, inspect the exported source, and publish it. You can also publish from the Workshop website by pasting an exported widget or CSS source.
|
||||
|
||||
Submissions are public immediately. Authors can update or delete their own submissions. Updating content increases its revision and shows the supplied changelog.
|
||||
|
||||
## Votes and reports
|
||||
|
||||
Signed-in members can vote to help useful creations rank higher. Report malicious content, spam, copyright problems, or inappropriate material from a submission's detail page. Reports are visible only to Workshop staff.
|
||||
|
||||
## Accounts and moderation
|
||||
|
||||
Workshop accounts use GitHub OAuth and are separate from users in a self-hosted Homarr instance. Workshop moderators can remove submissions, prevent an account from publishing, disable an account, and resolve reports. These actions require a reason and are recorded.
|
||||
|
||||
The Workshop is a central service operated by Homarr at `store.homarr.dev`. Running the backend locally is supported for contributors developing or testing this feature, not as a private production marketplace.
|
||||
@@ -16,6 +16,9 @@ const config: Config = {
|
||||
defaultLocale: "en",
|
||||
locales: ["en"],
|
||||
},
|
||||
customFields: {
|
||||
workshopApiUrl: process.env.NEXT_PUBLIC_WORKSHOP_API_URL ?? "https://store.homarr.dev",
|
||||
},
|
||||
onBrokenLinks: "throw",
|
||||
onBrokenAnchors: "throw",
|
||||
onDuplicateRoutes: "throw",
|
||||
@@ -124,6 +127,11 @@ const config: Config = {
|
||||
position: "left",
|
||||
to: "/blog",
|
||||
},
|
||||
{
|
||||
label: "Workshop",
|
||||
position: "left",
|
||||
to: "/workshop",
|
||||
},
|
||||
{
|
||||
label: "About us",
|
||||
position: "left",
|
||||
@@ -309,6 +317,18 @@ const config: Config = {
|
||||
},
|
||||
};
|
||||
},
|
||||
function workshopRoutesPlugin() {
|
||||
return {
|
||||
name: "workshop-routes",
|
||||
async contentLoaded({ actions }) {
|
||||
actions.addRoute({
|
||||
path: "/workshop/:id",
|
||||
component: "@site/src/components/workshop/WorkshopDetailRoutePage",
|
||||
exact: true,
|
||||
});
|
||||
},
|
||||
};
|
||||
},
|
||||
"@signalwire/docusaurus-plugin-llms-txt",
|
||||
async function tailwindCssPlugin() {
|
||||
return {
|
||||
|
||||
@@ -27,6 +27,7 @@
|
||||
"@docusaurus/plugin-sitemap": "^3.10.1",
|
||||
"@docusaurus/preset-classic": "^3.10.1",
|
||||
"@homarr/definitions": "workspace:^",
|
||||
"@homarr/workshop": "workspace:^0.1.0",
|
||||
"@mdx-js/react": "^3.1.1",
|
||||
"@nivo/line": "^0.99.0",
|
||||
"@scalar/api-reference-react": "catalog:",
|
||||
|
||||
@@ -0,0 +1,300 @@
|
||||
import React, { useCallback, useEffect, useMemo, useState } from "react";
|
||||
import useDocusaurusContext from "@docusaurus/useDocusaurusContext";
|
||||
|
||||
import {
|
||||
WORKSHOP_API_URL,
|
||||
WorkshopClient,
|
||||
type WorkshopAccountState,
|
||||
type WorkshopModerationAction,
|
||||
type WorkshopReport,
|
||||
type WorkshopRole,
|
||||
type WorkshopUser,
|
||||
} from "@homarr/workshop";
|
||||
|
||||
import styles from "./workshop.module.css";
|
||||
|
||||
type PendingAction =
|
||||
| { kind: "report"; id: string; status: "resolved" | "dismissed" }
|
||||
| { kind: "state"; id: string; state: WorkshopAccountState }
|
||||
| { kind: "role"; id: string; role: WorkshopRole };
|
||||
|
||||
export function WorkshopAdmin() {
|
||||
const { siteConfig } = useDocusaurusContext();
|
||||
const client = useMemo(
|
||||
() => new WorkshopClient((siteConfig.customFields?.workshopApiUrl as string | undefined) ?? WORKSHOP_API_URL),
|
||||
[siteConfig],
|
||||
);
|
||||
const [user, setUser] = useState<WorkshopUser | null>(null);
|
||||
const [reports, setReports] = useState<WorkshopReport[]>([]);
|
||||
const [users, setUsers] = useState<WorkshopUser[]>([]);
|
||||
const [actions, setActions] = useState<WorkshopModerationAction[]>([]);
|
||||
const [pending, setPending] = useState<PendingAction | null>(null);
|
||||
const [error, setError] = useState("");
|
||||
|
||||
const load = useCallback(async () => {
|
||||
const current = await client.refreshAuth();
|
||||
setUser(current);
|
||||
if (!current || (current.role !== "moderator" && current.role !== "admin")) return;
|
||||
try {
|
||||
const [nextReports, nextUsers, nextActions] = await Promise.all([
|
||||
client.listReports(),
|
||||
client.listUsers(),
|
||||
client.listModerationActions(),
|
||||
]);
|
||||
setReports(nextReports);
|
||||
setUsers(nextUsers);
|
||||
setActions(nextActions);
|
||||
} catch (caught) {
|
||||
setError(caught instanceof Error ? caught.message : "Could not load moderation data");
|
||||
}
|
||||
}, [client]);
|
||||
useEffect(() => client.subscribeToAuth(setUser), [client]);
|
||||
useEffect(() => {
|
||||
void load();
|
||||
}, [load]);
|
||||
|
||||
if (!user)
|
||||
return (
|
||||
<main className={styles.page}>
|
||||
<h1>Workshop moderation</h1>
|
||||
<p>Sign in with a promoted GitHub account to continue.</p>
|
||||
<button
|
||||
className="button button--primary"
|
||||
onClick={async () => {
|
||||
await client.signInWithGitHub();
|
||||
await load();
|
||||
}}
|
||||
>
|
||||
Sign in with GitHub
|
||||
</button>
|
||||
</main>
|
||||
);
|
||||
if (user.role === "member")
|
||||
return (
|
||||
<main className={styles.page}>
|
||||
<h1>Workshop moderation</h1>
|
||||
<div className={styles.error} role="alert">
|
||||
Your account does not have moderation access.
|
||||
</div>
|
||||
</main>
|
||||
);
|
||||
|
||||
return (
|
||||
<main className={styles.page}>
|
||||
<header className={styles.hero}>
|
||||
<div>
|
||||
<h1>Workshop moderation</h1>
|
||||
<p>Review reports and apply the smallest action needed. Every action requires a reason and is recorded.</p>
|
||||
</div>
|
||||
</header>
|
||||
{error && (
|
||||
<div className={styles.error} role="alert">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
<section className={styles.adminSection}>
|
||||
<h2>Open reports</h2>
|
||||
{reports.length === 0 ? (
|
||||
<p>No open reports.</p>
|
||||
) : (
|
||||
<div className={styles.tableWrap}>
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Category</th>
|
||||
<th>Submission</th>
|
||||
<th>Details</th>
|
||||
<th>Created</th>
|
||||
<th>Action</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{reports.map((report) => (
|
||||
<tr key={report.id}>
|
||||
<td>{report.category}</td>
|
||||
<td>{report.submission}</td>
|
||||
<td>{report.explanation}</td>
|
||||
<td>{new Date(report.created).toLocaleString()}</td>
|
||||
<td aria-label="Report actions">
|
||||
<div className={styles.rowActions}>
|
||||
<button onClick={() => setPending({ kind: "report", id: report.id, status: "resolved" })}>
|
||||
Resolve
|
||||
</button>
|
||||
<button onClick={() => setPending({ kind: "report", id: report.id, status: "dismissed" })}>
|
||||
Dismiss
|
||||
</button>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
<section className={styles.adminSection}>
|
||||
<h2>Community accounts</h2>
|
||||
<div className={styles.tableWrap}>
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>User</th>
|
||||
<th>Role</th>
|
||||
<th>State</th>
|
||||
<th>Moderate</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{users.map((account) => (
|
||||
<tr key={account.id}>
|
||||
<td>{account.displayName}</td>
|
||||
<td>{account.role}</td>
|
||||
<td>{account.state}</td>
|
||||
<td>
|
||||
{account.id === user.id ? (
|
||||
"Current account"
|
||||
) : (
|
||||
<div className={styles.rowActions}>
|
||||
<button
|
||||
onClick={() =>
|
||||
setPending({
|
||||
kind: "state",
|
||||
id: account.id,
|
||||
state: account.state === "posting_banned" ? "active" : "posting_banned",
|
||||
})
|
||||
}
|
||||
>
|
||||
{account.state === "posting_banned" ? "Lift posting ban" : "Posting ban"}
|
||||
</button>
|
||||
<button
|
||||
onClick={() =>
|
||||
setPending({
|
||||
kind: "state",
|
||||
id: account.id,
|
||||
state: account.state === "disabled" ? "active" : "disabled",
|
||||
})
|
||||
}
|
||||
>
|
||||
{account.state === "disabled" ? "Restore" : "Disable"}
|
||||
</button>
|
||||
{user.role === "admin" && (
|
||||
<button
|
||||
onClick={() =>
|
||||
setPending({
|
||||
kind: "role",
|
||||
id: account.id,
|
||||
role: account.role === "member" ? "moderator" : "member",
|
||||
})
|
||||
}
|
||||
>
|
||||
{account.role === "member" ? "Promote" : "Set member"}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</section>
|
||||
<section className={styles.adminSection}>
|
||||
<h2>Recent actions</h2>
|
||||
<div className={styles.tableWrap}>
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Action</th>
|
||||
<th>Target</th>
|
||||
<th>Reason</th>
|
||||
<th>Created</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{actions.map((action) => (
|
||||
<tr key={action.id}>
|
||||
<td>{action.action}</td>
|
||||
<td>
|
||||
{action.targetType}:{action.targetId}
|
||||
</td>
|
||||
<td>{action.reason}</td>
|
||||
<td>{new Date(action.created).toLocaleString()}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</section>
|
||||
{pending && (
|
||||
<ReasonDialog
|
||||
action={pending}
|
||||
onClose={() => setPending(null)}
|
||||
onConfirm={async (reason) => {
|
||||
if (pending.kind === "report") await client.resolveReport(pending.id, pending.status, reason);
|
||||
else if (pending.kind === "state") await client.updateAccountState(pending.id, pending.state, reason);
|
||||
else await client.updateRole(pending.id, pending.role, reason);
|
||||
setPending(null);
|
||||
await load();
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</main>
|
||||
);
|
||||
}
|
||||
|
||||
function ReasonDialog({
|
||||
action,
|
||||
onClose,
|
||||
onConfirm,
|
||||
}: {
|
||||
action: PendingAction;
|
||||
onClose: () => void;
|
||||
onConfirm: (reason: string) => Promise<void>;
|
||||
}) {
|
||||
const [reason, setReason] = useState("");
|
||||
const [error, setError] = useState("");
|
||||
const [saving, setSaving] = useState(false);
|
||||
return (
|
||||
<div className={styles.overlay}>
|
||||
<dialog className={styles.dialog} open aria-labelledby="moderation-reason-title">
|
||||
<h2 id="moderation-reason-title">Confirm moderation action</h2>
|
||||
<p>
|
||||
This will apply <strong>{action.kind}</strong> to the selected record.
|
||||
</p>
|
||||
<form
|
||||
onSubmit={async (event) => {
|
||||
event.preventDefault();
|
||||
setSaving(true);
|
||||
try {
|
||||
await onConfirm(reason);
|
||||
} catch (caught) {
|
||||
setError(caught instanceof Error ? caught.message : "Action failed");
|
||||
setSaving(false);
|
||||
}
|
||||
}}
|
||||
>
|
||||
<label>
|
||||
Reason
|
||||
<textarea
|
||||
required
|
||||
minLength={3}
|
||||
maxLength={1000}
|
||||
value={reason}
|
||||
onChange={(event) => setReason(event.target.value)}
|
||||
/>
|
||||
</label>
|
||||
{error && <p className={styles.error}>{error}</p>}
|
||||
<div className={styles.formActions}>
|
||||
<button type="button" className="button button--secondary" onClick={onClose}>
|
||||
Cancel
|
||||
</button>
|
||||
<button disabled={saving} className="button button--danger">
|
||||
{saving ? "Applying…" : "Apply action"}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</dialog>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
import React from "react";
|
||||
import BrowserOnly from "@docusaurus/BrowserOnly";
|
||||
import Layout from "@theme/Layout";
|
||||
|
||||
export default function WorkshopDetailRoutePage() {
|
||||
return (
|
||||
<Layout title="Workshop submission">
|
||||
<BrowserOnly fallback={<main style={{ minHeight: "70vh" }} />}>
|
||||
{() => {
|
||||
const { WorkshopDetailRoute } = require("./WorkshopPage");
|
||||
return <WorkshopDetailRoute />;
|
||||
}}
|
||||
</BrowserOnly>
|
||||
</Layout>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,668 @@
|
||||
import React, { useCallback, useEffect, useMemo, useState } from "react";
|
||||
import Link from "@docusaurus/Link";
|
||||
import { useHistory, useLocation } from "@docusaurus/router";
|
||||
import useDocusaurusContext from "@docusaurus/useDocusaurusContext";
|
||||
import {
|
||||
IconAlertTriangle,
|
||||
IconBrandGithub,
|
||||
IconCopy,
|
||||
IconDownload,
|
||||
IconFlag,
|
||||
IconSearch,
|
||||
IconUpload,
|
||||
} from "@tabler/icons-react";
|
||||
|
||||
import {
|
||||
WORKSHOP_API_URL,
|
||||
WorkshopClient,
|
||||
type WorkshopSubmissionDetail,
|
||||
type WorkshopSubmissionInput,
|
||||
type WorkshopSubmissionSummary,
|
||||
type WorkshopSubmissionType,
|
||||
type WorkshopUser,
|
||||
workshopExportFilename,
|
||||
} from "@homarr/workshop";
|
||||
|
||||
import styles from "./workshop.module.css";
|
||||
|
||||
const useWorkshop = () => {
|
||||
const { siteConfig } = useDocusaurusContext();
|
||||
const apiUrl = (siteConfig.customFields?.workshopApiUrl as string | undefined) ?? WORKSHOP_API_URL;
|
||||
return useMemo(() => new WorkshopClient(apiUrl), [apiUrl]);
|
||||
};
|
||||
|
||||
const saveContent = (submission: WorkshopSubmissionDetail) => {
|
||||
const url = URL.createObjectURL(
|
||||
new Blob([submission.content], { type: submission.type === "css" ? "text/css" : "application/json" }),
|
||||
);
|
||||
const link = document.createElement("a");
|
||||
link.href = url;
|
||||
link.download = workshopExportFilename(submission.title, submission.type);
|
||||
link.click();
|
||||
URL.revokeObjectURL(url);
|
||||
};
|
||||
|
||||
export function WorkshopApp() {
|
||||
const client = useWorkshop();
|
||||
const [user, setUser] = useState<WorkshopUser | null>(null);
|
||||
const [items, setItems] = useState<WorkshopSubmissionSummary[]>([]);
|
||||
const [page, setPage] = useState(1);
|
||||
const [totalPages, setTotalPages] = useState(1);
|
||||
const [type, setType] = useState<WorkshopSubmissionType | "all">("all");
|
||||
const [sort, setSort] = useState<"top" | "newest">("top");
|
||||
const [search, setSearch] = useState("");
|
||||
const [mine, setMine] = useState(false);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState("");
|
||||
const [showForm, setShowForm] = useState(false);
|
||||
|
||||
useEffect(() => client.subscribeToAuth(setUser), [client]);
|
||||
|
||||
const load = useCallback(async () => {
|
||||
setLoading(true);
|
||||
setError("");
|
||||
try {
|
||||
await client.refreshAuth();
|
||||
const result = await client.list({ page, type, sort, search, author: mine ? client.currentUser?.id : undefined });
|
||||
setItems(result.items);
|
||||
setTotalPages(result.totalPages);
|
||||
} catch (caught) {
|
||||
setError(caught instanceof Error ? caught.message : "Workshop is unavailable");
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [client, mine, page, search, sort, type]);
|
||||
|
||||
useEffect(() => {
|
||||
const timeout = window.setTimeout(() => void load(), 250);
|
||||
return () => window.clearTimeout(timeout);
|
||||
}, [load]);
|
||||
|
||||
const signIn = async () => {
|
||||
try {
|
||||
await client.signInWithGitHub();
|
||||
await load();
|
||||
} catch (caught) {
|
||||
setError(caught instanceof Error ? caught.message : "Sign-in failed");
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<main className={styles.page}>
|
||||
<header className={styles.hero}>
|
||||
<div>
|
||||
<h1>Workshop</h1>
|
||||
<p>Community widgets and CSS themes, ready to inspect and use in Homarr.</p>
|
||||
</div>
|
||||
<div className={styles.accountActions}>
|
||||
{user ? (
|
||||
<>
|
||||
{(user.role === "moderator" || user.role === "admin") && (
|
||||
<Link className="button button--secondary" to="/workshop/admin">
|
||||
Moderate
|
||||
</Link>
|
||||
)}
|
||||
<button className="button button--primary" onClick={() => setShowForm(true)}>
|
||||
<IconUpload size={17} /> Share creation
|
||||
</button>
|
||||
<button className="button button--secondary" onClick={() => client.signOut()}>
|
||||
Sign out
|
||||
</button>
|
||||
</>
|
||||
) : (
|
||||
<button className="button button--primary" onClick={() => void signIn()}>
|
||||
<IconBrandGithub size={18} /> Sign in with GitHub
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<section className={styles.safety} aria-label="Community content warning">
|
||||
<IconAlertTriangle aria-hidden size={20} />
|
||||
<p>
|
||||
Workshop content is community-provided. Inspect source before installing it and keep a current Homarr backup.
|
||||
</p>
|
||||
</section>
|
||||
|
||||
<div className={styles.toolbar}>
|
||||
<label className={styles.search}>
|
||||
<span className="sr-only">Search Workshop</span>
|
||||
<IconSearch aria-hidden size={18} />
|
||||
<input
|
||||
value={search}
|
||||
onChange={(event) => {
|
||||
setSearch(event.target.value);
|
||||
setPage(1);
|
||||
}}
|
||||
placeholder="Search creations"
|
||||
/>
|
||||
</label>
|
||||
<label>
|
||||
Type
|
||||
<select
|
||||
value={type}
|
||||
onChange={(event) => {
|
||||
setType(event.target.value as typeof type);
|
||||
setPage(1);
|
||||
}}
|
||||
>
|
||||
<option value="all">All</option>
|
||||
<option value="widget">Widgets</option>
|
||||
<option value="css">CSS themes</option>
|
||||
</select>
|
||||
</label>
|
||||
<label>
|
||||
Sort
|
||||
<select
|
||||
value={sort}
|
||||
onChange={(event) => {
|
||||
setSort(event.target.value as typeof sort);
|
||||
setPage(1);
|
||||
}}
|
||||
>
|
||||
<option value="top">Top rated</option>
|
||||
<option value="newest">Newest</option>
|
||||
</select>
|
||||
</label>
|
||||
{user && (
|
||||
<label className={styles.checkbox}>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={mine}
|
||||
onChange={(event) => {
|
||||
setMine(event.target.checked);
|
||||
setPage(1);
|
||||
}}
|
||||
/>{" "}
|
||||
My submissions
|
||||
</label>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<div className={styles.error} role="alert">
|
||||
{error} <button onClick={() => void load()}>Try again</button>
|
||||
</div>
|
||||
)}
|
||||
{loading ? (
|
||||
<WorkshopSkeleton />
|
||||
) : items.length ? (
|
||||
<div className={styles.grid}>
|
||||
{items.map((item) => (
|
||||
<WorkshopCard client={client} item={item} key={item.id} />
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<section className={styles.empty}>
|
||||
<h2>No creations found</h2>
|
||||
<p>Adjust the filters, or share the first creation in this category.</p>
|
||||
</section>
|
||||
)}
|
||||
|
||||
{totalPages > 1 && (
|
||||
<nav className={styles.pagination} aria-label="Workshop pages">
|
||||
<button disabled={page === 1} onClick={() => setPage((value) => value - 1)}>
|
||||
Previous
|
||||
</button>
|
||||
<span>
|
||||
Page {page} of {totalPages}
|
||||
</span>
|
||||
<button disabled={page === totalPages} onClick={() => setPage((value) => value + 1)}>
|
||||
Next
|
||||
</button>
|
||||
</nav>
|
||||
)}
|
||||
{showForm && (
|
||||
<SubmissionForm
|
||||
client={client}
|
||||
onClose={() => setShowForm(false)}
|
||||
onSaved={async () => {
|
||||
setShowForm(false);
|
||||
await load();
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</main>
|
||||
);
|
||||
}
|
||||
|
||||
function WorkshopCard({ client, item }: { client: WorkshopClient; item: WorkshopSubmissionSummary }) {
|
||||
const image = item.screenshots[0];
|
||||
return (
|
||||
<article className={styles.card}>
|
||||
{image ? (
|
||||
<img src={client.fileUrl(item.id, image, "480x320")} alt="" loading="lazy" />
|
||||
) : (
|
||||
<div className={styles.cardPlaceholder} aria-hidden>
|
||||
{item.type === "widget" ? "{}" : "CSS"}
|
||||
</div>
|
||||
)}
|
||||
<div className={styles.cardBody}>
|
||||
<div className={styles.cardMeta}>
|
||||
<span>{item.type === "widget" ? "Widget" : "CSS theme"}</span>
|
||||
<span>
|
||||
{item.score >= 0 ? "+" : ""}
|
||||
{item.score}
|
||||
</span>
|
||||
</div>
|
||||
<h2>
|
||||
<Link to={`/workshop/${item.id}`}>{item.title}</Link>
|
||||
</h2>
|
||||
<p>{item.description || "No description provided."}</p>
|
||||
<small>
|
||||
By {item.authorName} · revision {item.revision}
|
||||
</small>
|
||||
</div>
|
||||
</article>
|
||||
);
|
||||
}
|
||||
|
||||
export function WorkshopDetailRoute() {
|
||||
const client = useWorkshop();
|
||||
const location = useLocation();
|
||||
const id = location.pathname.split("/").filter(Boolean).at(-1) ?? "";
|
||||
const history = useHistory();
|
||||
const [user, setUser] = useState<WorkshopUser | null>(null);
|
||||
const [submission, setSubmission] = useState<WorkshopSubmissionDetail | null>(null);
|
||||
const [error, setError] = useState("");
|
||||
const [reporting, setReporting] = useState(false);
|
||||
const [editing, setEditing] = useState(false);
|
||||
const [moderating, setModerating] = useState(false);
|
||||
|
||||
const load = useCallback(async () => {
|
||||
try {
|
||||
setSubmission(await client.get(id));
|
||||
} catch (caught) {
|
||||
setError(caught instanceof Error ? caught.message : "Submission not found");
|
||||
}
|
||||
}, [client, id]);
|
||||
useEffect(() => client.subscribeToAuth(setUser), [client]);
|
||||
useEffect(() => {
|
||||
void client.refreshAuth();
|
||||
void load();
|
||||
}, [client, load]);
|
||||
|
||||
if (error)
|
||||
return (
|
||||
<main className={styles.page}>
|
||||
<div className={styles.error} role="alert">
|
||||
{error}
|
||||
</div>
|
||||
<Link to="/workshop">Back to Workshop</Link>
|
||||
</main>
|
||||
);
|
||||
if (!submission) return <WorkshopSkeleton />;
|
||||
|
||||
const requireAuth = async () => client.currentUser ?? client.signInWithGitHub();
|
||||
return (
|
||||
<main className={styles.page}>
|
||||
<Link to="/workshop">← Back to Workshop</Link>
|
||||
<article className={styles.detail}>
|
||||
<header>
|
||||
<span className={styles.type}>{submission.type === "widget" ? "Widget" : "CSS theme"}</span>
|
||||
<h1>{submission.title}</h1>
|
||||
<p>{submission.description}</p>
|
||||
<small>
|
||||
By {submission.authorName} · revision {submission.revision}
|
||||
</small>
|
||||
</header>
|
||||
{submission.screenshots.length > 0 && (
|
||||
<div className={styles.gallery}>
|
||||
{submission.screenshots.map((image, index) => (
|
||||
<img
|
||||
key={image}
|
||||
src={client.fileUrl(submission.id, image, "960x640")}
|
||||
alt={`${submission.title} screenshot ${index + 1}`}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
<div className={styles.detailActions}>
|
||||
<button className="button button--primary" onClick={() => saveContent(submission)}>
|
||||
<IconDownload size={17} /> Download {submission.type === "widget" ? "JSON" : "CSS"}
|
||||
</button>
|
||||
<button
|
||||
className="button button--secondary"
|
||||
onClick={() => void navigator.clipboard.writeText(submission.content)}
|
||||
>
|
||||
<IconCopy size={17} /> Copy source
|
||||
</button>
|
||||
<button
|
||||
className="button button--secondary"
|
||||
onClick={async () => {
|
||||
await requireAuth();
|
||||
await client.vote(submission.id, 1);
|
||||
await load();
|
||||
}}
|
||||
>
|
||||
Upvote ({submission.upvotes})
|
||||
</button>
|
||||
<button className="button button--secondary" onClick={() => setReporting(true)}>
|
||||
<IconFlag size={16} /> Report
|
||||
</button>
|
||||
{user?.id === submission.author && (
|
||||
<>
|
||||
<button className="button button--secondary" onClick={() => setEditing(true)}>
|
||||
Edit
|
||||
</button>
|
||||
<button
|
||||
className="button button--danger"
|
||||
onClick={async () => {
|
||||
if (window.confirm("Delete this submission permanently?")) {
|
||||
await client.delete(submission.id);
|
||||
history.push("/workshop");
|
||||
}
|
||||
}}
|
||||
>
|
||||
Delete
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
{(user?.role === "moderator" || user?.role === "admin") && user.id !== submission.author && (
|
||||
<button className="button button--danger" onClick={() => setModerating(true)}>
|
||||
Remove submission
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
<section>
|
||||
<h2>Source</h2>
|
||||
<p className={styles.sourceHint}>
|
||||
Review this content before using it. Workshop content is never executed on homarr.dev.
|
||||
</p>
|
||||
<pre className={styles.source}>
|
||||
<code>{submission.content}</code>
|
||||
</pre>
|
||||
</section>
|
||||
{submission.changelog && (
|
||||
<section>
|
||||
<h2>Latest changes</h2>
|
||||
<p>{submission.changelog}</p>
|
||||
</section>
|
||||
)}
|
||||
</article>
|
||||
{reporting && <ReportForm client={client} submissionId={submission.id} onClose={() => setReporting(false)} />}
|
||||
{editing && (
|
||||
<SubmissionForm
|
||||
client={client}
|
||||
existing={submission}
|
||||
onClose={() => setEditing(false)}
|
||||
onSaved={async () => {
|
||||
setEditing(false);
|
||||
await load();
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
{moderating && (
|
||||
<ModerationRemovalForm
|
||||
client={client}
|
||||
submission={submission}
|
||||
onClose={() => setModerating(false)}
|
||||
onRemoved={() => history.push("/workshop/admin")}
|
||||
/>
|
||||
)}
|
||||
</main>
|
||||
);
|
||||
}
|
||||
|
||||
function ModerationRemovalForm({
|
||||
client,
|
||||
submission,
|
||||
onClose,
|
||||
onRemoved,
|
||||
}: {
|
||||
client: WorkshopClient;
|
||||
submission: WorkshopSubmissionDetail;
|
||||
onClose: () => void;
|
||||
onRemoved: () => void;
|
||||
}) {
|
||||
const [reason, setReason] = useState("");
|
||||
const [error, setError] = useState("");
|
||||
const [pending, setPending] = useState(false);
|
||||
return (
|
||||
<div className={styles.overlay}>
|
||||
<dialog className={styles.dialog} open aria-labelledby="remove-submission-title">
|
||||
<h2 id="remove-submission-title">Remove {submission.title}</h2>
|
||||
<p>
|
||||
This permanently deletes the submission, files, votes, and reports. A metadata snapshot is retained in
|
||||
moderation history.
|
||||
</p>
|
||||
<form
|
||||
onSubmit={async (event) => {
|
||||
event.preventDefault();
|
||||
setPending(true);
|
||||
try {
|
||||
await client.removeSubmission(submission.id, reason);
|
||||
onRemoved();
|
||||
} catch (caught) {
|
||||
setError(caught instanceof Error ? caught.message : "Removal failed");
|
||||
setPending(false);
|
||||
}
|
||||
}}
|
||||
>
|
||||
<label>
|
||||
Reason
|
||||
<textarea
|
||||
required
|
||||
minLength={3}
|
||||
maxLength={1000}
|
||||
value={reason}
|
||||
onChange={(event) => setReason(event.target.value)}
|
||||
/>
|
||||
</label>
|
||||
{error && <p className={styles.error}>{error}</p>}
|
||||
<div className={styles.formActions}>
|
||||
<button type="button" className="button button--secondary" onClick={onClose}>
|
||||
Cancel
|
||||
</button>
|
||||
<button disabled={pending} className="button button--danger">
|
||||
{pending ? "Removing…" : "Remove permanently"}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</dialog>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function SubmissionForm({
|
||||
client,
|
||||
existing,
|
||||
onClose,
|
||||
onSaved,
|
||||
}: {
|
||||
client: WorkshopClient;
|
||||
existing?: WorkshopSubmissionDetail;
|
||||
onClose: () => void;
|
||||
onSaved: () => void;
|
||||
}) {
|
||||
const [type, setType] = useState<WorkshopSubmissionType>(existing?.type ?? "widget");
|
||||
const [title, setTitle] = useState(existing?.title ?? "");
|
||||
const [description, setDescription] = useState(existing?.description ?? "");
|
||||
const [content, setContent] = useState(existing?.content ?? "");
|
||||
const [changelog, setChangelog] = useState(existing?.changelog ?? "");
|
||||
const [screenshots, setScreenshots] = useState<File[]>([]);
|
||||
const [removedScreenshots, setRemovedScreenshots] = useState<string[]>([]);
|
||||
const [error, setError] = useState("");
|
||||
const [pending, setPending] = useState(false);
|
||||
const submit = async (event: React.FormEvent) => {
|
||||
event.preventDefault();
|
||||
setPending(true);
|
||||
setError("");
|
||||
const input: WorkshopSubmissionInput = { type, title, description, content, changelog };
|
||||
try {
|
||||
if (!client.currentUser) await client.signInWithGitHub();
|
||||
if (existing) {
|
||||
await client.update(existing.id, input, screenshots, removedScreenshots);
|
||||
} else {
|
||||
await client.create(input, screenshots);
|
||||
}
|
||||
await onSaved();
|
||||
} catch (caught) {
|
||||
setError(caught instanceof Error ? caught.message : "Could not save submission");
|
||||
} finally {
|
||||
setPending(false);
|
||||
}
|
||||
};
|
||||
return (
|
||||
<div
|
||||
className={styles.overlay}
|
||||
role="presentation"
|
||||
onMouseDown={(event) => {
|
||||
if (event.target === event.currentTarget) onClose();
|
||||
}}
|
||||
>
|
||||
<dialog className={styles.dialog} open aria-labelledby="submission-form-title">
|
||||
<h2 id="submission-form-title">{existing ? "Update creation" : "Share with the community"}</h2>
|
||||
<form onSubmit={(event) => void submit(event)}>
|
||||
<label>
|
||||
Type
|
||||
<select
|
||||
disabled={Boolean(existing)}
|
||||
value={type}
|
||||
onChange={(event) => setType(event.target.value as WorkshopSubmissionType)}
|
||||
>
|
||||
<option value="widget">Custom widget</option>
|
||||
<option value="css">CSS theme</option>
|
||||
</select>
|
||||
</label>
|
||||
<label>
|
||||
Title
|
||||
<input
|
||||
required
|
||||
minLength={3}
|
||||
maxLength={100}
|
||||
value={title}
|
||||
onChange={(event) => setTitle(event.target.value)}
|
||||
/>
|
||||
</label>
|
||||
<label>
|
||||
Description
|
||||
<textarea maxLength={2000} value={description} onChange={(event) => setDescription(event.target.value)} />
|
||||
</label>
|
||||
<label>
|
||||
{type === "widget" ? "Widget JSON" : "CSS source"}
|
||||
<textarea
|
||||
className={styles.editor}
|
||||
required
|
||||
value={content}
|
||||
onChange={(event) => setContent(event.target.value)}
|
||||
/>
|
||||
</label>
|
||||
<label>
|
||||
Changelog
|
||||
<textarea maxLength={2000} value={changelog} onChange={(event) => setChangelog(event.target.value)} />
|
||||
</label>
|
||||
<label>
|
||||
Screenshots (PNG, JPG, or WebP, up to 5)
|
||||
<input
|
||||
type="file"
|
||||
accept="image/png,image/jpeg,image/webp"
|
||||
multiple
|
||||
onChange={(event) => setScreenshots(Array.from(event.target.files ?? []).slice(0, 5))}
|
||||
/>
|
||||
</label>
|
||||
{existing?.screenshots.map((screenshot) => (
|
||||
<label className={styles.checkbox} key={screenshot}>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={removedScreenshots.includes(screenshot)}
|
||||
onChange={(event) =>
|
||||
setRemovedScreenshots((current) =>
|
||||
event.target.checked
|
||||
? [...current, screenshot]
|
||||
: current.filter((filename) => filename !== screenshot),
|
||||
)
|
||||
}
|
||||
/>
|
||||
Remove existing screenshot {screenshot}
|
||||
</label>
|
||||
))}
|
||||
{error && (
|
||||
<p className={styles.error} role="alert">
|
||||
{error}
|
||||
</p>
|
||||
)}
|
||||
<div className={styles.formActions}>
|
||||
<button type="button" className="button button--secondary" onClick={onClose}>
|
||||
Cancel
|
||||
</button>
|
||||
<button disabled={pending} className="button button--primary">
|
||||
{pending ? "Saving…" : existing ? "Save update" : "Publish creation"}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</dialog>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ReportForm({
|
||||
client,
|
||||
submissionId,
|
||||
onClose,
|
||||
}: {
|
||||
client: WorkshopClient;
|
||||
submissionId: string;
|
||||
onClose: () => void;
|
||||
}) {
|
||||
const [category, setCategory] = useState<"malicious" | "spam" | "copyright" | "inappropriate" | "other">("malicious");
|
||||
const [explanation, setExplanation] = useState("");
|
||||
const [error, setError] = useState("");
|
||||
return (
|
||||
<div className={styles.overlay}>
|
||||
<dialog className={styles.dialog} open aria-labelledby="report-title">
|
||||
<h2 id="report-title">Report submission</h2>
|
||||
<form
|
||||
onSubmit={async (event) => {
|
||||
event.preventDefault();
|
||||
try {
|
||||
if (!client.currentUser) await client.signInWithGitHub();
|
||||
await client.report(submissionId, category, explanation);
|
||||
onClose();
|
||||
} catch (caught) {
|
||||
setError(caught instanceof Error ? caught.message : "Could not send report");
|
||||
}
|
||||
}}
|
||||
>
|
||||
<label>
|
||||
Reason
|
||||
<select value={category} onChange={(event) => setCategory(event.target.value as typeof category)}>
|
||||
<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>
|
||||
<label>
|
||||
Details
|
||||
<textarea
|
||||
required
|
||||
minLength={3}
|
||||
maxLength={1000}
|
||||
value={explanation}
|
||||
onChange={(event) => setExplanation(event.target.value)}
|
||||
/>
|
||||
</label>
|
||||
{error && <p className={styles.error}>{error}</p>}
|
||||
<div className={styles.formActions}>
|
||||
<button type="button" className="button button--secondary" onClick={onClose}>
|
||||
Cancel
|
||||
</button>
|
||||
<button className="button button--primary">Submit report</button>
|
||||
</div>
|
||||
</form>
|
||||
</dialog>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function WorkshopSkeleton() {
|
||||
return (
|
||||
<div className={styles.grid} aria-label="Loading Workshop">
|
||||
{Array.from({ length: 6 }, (_, index) => (
|
||||
<div className={styles.skeleton} key={index} />
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,299 @@
|
||||
.page {
|
||||
max-width: 1240px;
|
||||
min-height: 70vh;
|
||||
margin: 0 auto;
|
||||
padding: 2.5rem 1.25rem 5rem;
|
||||
}
|
||||
.hero {
|
||||
display: flex;
|
||||
align-items: flex-end;
|
||||
justify-content: space-between;
|
||||
gap: 2rem;
|
||||
margin-bottom: 1.5rem;
|
||||
}
|
||||
.hero h1,
|
||||
.detail h1 {
|
||||
margin: 0;
|
||||
font-size: 2.25rem;
|
||||
letter-spacing: -0.025em;
|
||||
}
|
||||
.hero p {
|
||||
max-width: 65ch;
|
||||
margin: 0.5rem 0 0;
|
||||
color: var(--ifm-color-emphasis-700);
|
||||
}
|
||||
.accountActions,
|
||||
.detailActions,
|
||||
.formActions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.75rem;
|
||||
}
|
||||
.accountActions :global(.button),
|
||||
.detailActions :global(.button) {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.4rem;
|
||||
}
|
||||
.safety {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 0.75rem;
|
||||
padding: 0.85rem 1rem;
|
||||
margin-bottom: 1.5rem;
|
||||
border: 1px solid var(--ifm-color-warning-dark);
|
||||
border-radius: 10px;
|
||||
background: color-mix(in srgb, var(--ifm-color-warning) 10%, transparent);
|
||||
}
|
||||
.safety p {
|
||||
margin: 0;
|
||||
}
|
||||
.toolbar {
|
||||
display: flex;
|
||||
align-items: end;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.75rem;
|
||||
padding: 0.75rem;
|
||||
margin-bottom: 1.5rem;
|
||||
border: 1px solid var(--ifm-color-emphasis-300);
|
||||
border-radius: 10px;
|
||||
}
|
||||
.toolbar label,
|
||||
.dialog label {
|
||||
display: grid;
|
||||
gap: 0.3rem;
|
||||
font-size: 0.8rem;
|
||||
font-weight: 600;
|
||||
}
|
||||
.toolbar input,
|
||||
.toolbar select,
|
||||
.dialog input,
|
||||
.dialog select,
|
||||
.dialog textarea {
|
||||
min-height: 44px;
|
||||
padding: 0.55rem 0.7rem;
|
||||
border: 1px solid var(--ifm-color-emphasis-400);
|
||||
border-radius: 7px;
|
||||
color: var(--ifm-font-color-base);
|
||||
background: var(--ifm-background-surface-color);
|
||||
font: inherit;
|
||||
}
|
||||
.search {
|
||||
position: relative;
|
||||
flex: 1 1 280px;
|
||||
}
|
||||
.search svg {
|
||||
position: absolute;
|
||||
left: 0.75rem;
|
||||
bottom: 0.8rem;
|
||||
color: var(--ifm-color-emphasis-600);
|
||||
}
|
||||
.search input {
|
||||
width: 100%;
|
||||
padding-left: 2.25rem;
|
||||
}
|
||||
.checkbox {
|
||||
display: flex !important;
|
||||
grid-auto-flow: column;
|
||||
align-items: center;
|
||||
min-height: 44px;
|
||||
}
|
||||
.checkbox input {
|
||||
min-height: auto;
|
||||
}
|
||||
.grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(270px, 1fr));
|
||||
gap: 1rem;
|
||||
}
|
||||
.card {
|
||||
overflow: hidden;
|
||||
border: 1px solid var(--ifm-color-emphasis-300);
|
||||
border-radius: 12px;
|
||||
background: var(--ifm-background-surface-color);
|
||||
}
|
||||
.card img,
|
||||
.cardPlaceholder {
|
||||
width: 100%;
|
||||
aspect-ratio: 3 / 2;
|
||||
object-fit: cover;
|
||||
}
|
||||
.cardPlaceholder {
|
||||
display: grid;
|
||||
place-items: center;
|
||||
background: var(--ifm-color-emphasis-100);
|
||||
color: var(--ifm-color-emphasis-600);
|
||||
font: 700 1.5rem var(--ifm-font-family-monospace);
|
||||
}
|
||||
.cardBody {
|
||||
padding: 1rem;
|
||||
}
|
||||
.cardBody h2 {
|
||||
margin: 0.4rem 0;
|
||||
font-size: 1.08rem;
|
||||
}
|
||||
.cardBody p {
|
||||
min-height: 2.7em;
|
||||
margin: 0 0 0.65rem;
|
||||
color: var(--ifm-color-emphasis-700);
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
.cardMeta {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
color: var(--ifm-color-emphasis-700);
|
||||
font-size: 0.75rem;
|
||||
font-weight: 700;
|
||||
}
|
||||
.error {
|
||||
padding: 0.85rem 1rem;
|
||||
margin: 1rem 0;
|
||||
border-radius: 8px;
|
||||
color: var(--ifm-color-danger-contrast-foreground);
|
||||
background: var(--ifm-color-danger-contrast-background);
|
||||
}
|
||||
.empty {
|
||||
padding: 4rem 1rem;
|
||||
text-align: center;
|
||||
border: 1px dashed var(--ifm-color-emphasis-400);
|
||||
border-radius: 12px;
|
||||
}
|
||||
.pagination {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
gap: 1rem;
|
||||
margin-top: 2rem;
|
||||
}
|
||||
.pagination button {
|
||||
min-height: 44px;
|
||||
padding: 0.5rem 0.8rem;
|
||||
}
|
||||
.skeleton {
|
||||
min-height: 310px;
|
||||
border-radius: 12px;
|
||||
background: var(--ifm-color-emphasis-200);
|
||||
animation: pulse 1.5s ease-in-out infinite;
|
||||
}
|
||||
.detail {
|
||||
margin-top: 1.5rem;
|
||||
}
|
||||
.detail header {
|
||||
max-width: 75ch;
|
||||
}
|
||||
.type {
|
||||
display: inline-block;
|
||||
margin-bottom: 0.5rem;
|
||||
color: var(--ifm-color-primary);
|
||||
font-weight: 700;
|
||||
}
|
||||
.gallery {
|
||||
display: flex;
|
||||
gap: 1rem;
|
||||
overflow-x: auto;
|
||||
margin: 1.5rem 0;
|
||||
scroll-snap-type: x mandatory;
|
||||
}
|
||||
.gallery img {
|
||||
width: min(720px, 85vw);
|
||||
max-height: 480px;
|
||||
object-fit: contain;
|
||||
border-radius: 10px;
|
||||
background: var(--ifm-color-emphasis-100);
|
||||
scroll-snap-align: start;
|
||||
}
|
||||
.detailActions {
|
||||
margin: 1.5rem 0 2rem;
|
||||
}
|
||||
.sourceHint {
|
||||
color: var(--ifm-color-emphasis-700);
|
||||
}
|
||||
.source {
|
||||
max-height: 560px;
|
||||
overflow: auto;
|
||||
padding: 1rem;
|
||||
border-radius: 8px;
|
||||
font-size: 0.78rem;
|
||||
}
|
||||
.overlay {
|
||||
position: fixed;
|
||||
z-index: 500;
|
||||
inset: 0;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
padding: 1rem;
|
||||
background: rgb(0 0 0 / 0.7);
|
||||
}
|
||||
.dialog {
|
||||
width: min(680px, 100%);
|
||||
max-height: 90vh;
|
||||
overflow-y: auto;
|
||||
padding: 1.25rem;
|
||||
border-radius: 12px;
|
||||
background: var(--ifm-background-color);
|
||||
}
|
||||
.dialog form {
|
||||
display: grid;
|
||||
gap: 1rem;
|
||||
}
|
||||
.dialog textarea {
|
||||
min-height: 88px;
|
||||
resize: vertical;
|
||||
}
|
||||
.dialog .editor {
|
||||
min-height: 240px;
|
||||
font-family: var(--ifm-font-family-monospace);
|
||||
font-size: 0.78rem;
|
||||
}
|
||||
.formActions {
|
||||
justify-content: flex-end;
|
||||
}
|
||||
@keyframes pulse {
|
||||
50% {
|
||||
opacity: 0.55;
|
||||
}
|
||||
}
|
||||
@media (max-width: 700px) {
|
||||
.hero {
|
||||
align-items: stretch;
|
||||
flex-direction: column;
|
||||
}
|
||||
.accountActions > * {
|
||||
flex: 1;
|
||||
justify-content: center;
|
||||
}
|
||||
.toolbar > label {
|
||||
flex: 1 1 100%;
|
||||
}
|
||||
}
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.skeleton {
|
||||
animation: none;
|
||||
}
|
||||
}
|
||||
.adminSection {
|
||||
margin: 2rem 0;
|
||||
}
|
||||
.tableWrap {
|
||||
overflow-x: auto;
|
||||
}
|
||||
.tableWrap table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
}
|
||||
.tableWrap th,
|
||||
.tableWrap td {
|
||||
padding: 0.7rem;
|
||||
border-bottom: 1px solid var(--ifm-color-emphasis-300);
|
||||
text-align: left;
|
||||
vertical-align: top;
|
||||
}
|
||||
.rowActions {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.4rem;
|
||||
}
|
||||
.rowActions button {
|
||||
min-height: 36px;
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
import React from "react";
|
||||
import BrowserOnly from "@docusaurus/BrowserOnly";
|
||||
import Layout from "@theme/Layout";
|
||||
|
||||
export default function WorkshopAdminPage() {
|
||||
return (
|
||||
<Layout title="Workshop moderation" noFooter>
|
||||
<BrowserOnly fallback={<main style={{ minHeight: "70vh" }} />}>
|
||||
{() => {
|
||||
const { WorkshopAdmin } = require("@site/src/components/workshop/WorkshopAdmin");
|
||||
return <WorkshopAdmin />;
|
||||
}}
|
||||
</BrowserOnly>
|
||||
</Layout>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
import React from "react";
|
||||
import BrowserOnly from "@docusaurus/BrowserOnly";
|
||||
import Layout from "@theme/Layout";
|
||||
|
||||
export default function WorkshopPage() {
|
||||
return (
|
||||
<Layout title="Workshop" description="Community widgets and CSS themes for Homarr">
|
||||
<BrowserOnly fallback={<main style={{ minHeight: "70vh" }} />}>
|
||||
{() => {
|
||||
const { WorkshopApp } = require("@site/src/components/workshop/WorkshopPage");
|
||||
return <WorkshopApp />;
|
||||
}}
|
||||
</BrowserOnly>
|
||||
</Layout>
|
||||
);
|
||||
}
|
||||
@@ -50,6 +50,7 @@
|
||||
"@homarr/validation": "workspace:^0.1.0",
|
||||
"@homarr/websocket": "workspace:^0.1.0",
|
||||
"@homarr/widgets": "workspace:^0.1.0",
|
||||
"@homarr/workshop": "workspace:^0.1.0",
|
||||
"@mantine/colors-generator": "catalog:",
|
||||
"@mantine/core": "catalog:",
|
||||
"@mantine/dropzone": "catalog:",
|
||||
|
||||
@@ -11,6 +11,9 @@ import { IconInfoCircle } from "@tabler/icons-react";
|
||||
import { useForm } from "@homarr/form";
|
||||
import { useI18n, useScopedI18n } from "@homarr/translation/client";
|
||||
|
||||
import { PublishToWorkshopButton } from "~/components/workshop/workshop-browser";
|
||||
import { UseCssFromWorkshopButton } from "~/components/workshop/workshop-actions";
|
||||
|
||||
import type { Board } from "../../_types";
|
||||
import { useSavePartialSettingsMutation } from "./_shared";
|
||||
import classes from "./customcss.module.css";
|
||||
@@ -45,7 +48,11 @@ export const CustomCssSettingsContent = ({ board }: Props) => {
|
||||
{customCssT("customClassesAlert.description")}
|
||||
</Alert>
|
||||
|
||||
<Group justify="end">
|
||||
<Group justify="space-between">
|
||||
<Group>
|
||||
<UseCssFromWorkshopButton onUse={(css) => form.setFieldValue("customCss", css)} />
|
||||
<PublishToWorkshopButton type="css" defaultTitle={board.name} getContent={() => form.values.customCss} />
|
||||
</Group>
|
||||
<Button type="submit" loading={isPending}>
|
||||
{t("common.action.saveChanges")}
|
||||
</Button>
|
||||
|
||||
@@ -6,6 +6,7 @@ import {
|
||||
IconCopy,
|
||||
IconDots,
|
||||
IconDownload,
|
||||
IconShare3,
|
||||
IconToggleLeft,
|
||||
IconToggleRight,
|
||||
IconTrash,
|
||||
@@ -19,6 +20,7 @@ import { showErrorNotification, showSuccessNotification } from "@homarr/notifica
|
||||
import { useScopedI18n } from "@homarr/translation/client";
|
||||
|
||||
import { MobileAffixButton } from "~/components/manage/mobile-affix-button";
|
||||
import { PublishToWorkshopButton } from "~/components/workshop/workshop-browser";
|
||||
|
||||
const iconProps = { size: 16, stroke: 1.5 };
|
||||
|
||||
@@ -30,6 +32,7 @@ interface WidgetRef {
|
||||
|
||||
export const CustomWidgetRowActions = ({ widget }: { widget: WidgetRef }) => {
|
||||
const t = useScopedI18n("customWidget");
|
||||
const workshopT = useScopedI18n("workshop");
|
||||
const { openConfirmModal } = useConfirmModal();
|
||||
const deleteMutation = clientApi.customWidget.delete.useMutation();
|
||||
const duplicateMutation = clientApi.customWidget.duplicate.useMutation();
|
||||
@@ -141,6 +144,16 @@ export const CustomWidgetRowActions = ({ widget }: { widget: WidgetRef }) => {
|
||||
<Menu.Item onClick={() => void handleExport()} leftSection={<IconDownload {...iconProps} />}>
|
||||
{t("action.export")}
|
||||
</Menu.Item>
|
||||
<PublishToWorkshopButton
|
||||
type="widget"
|
||||
defaultTitle={widget.name}
|
||||
getContent={async () => JSON.stringify(await utils.customWidget.export.fetch({ id: widget.id }), null, 2)}
|
||||
renderTrigger={(open) => (
|
||||
<Menu.Item onClick={open} leftSection={<IconShare3 {...iconProps} />}>
|
||||
{workshopT("action.share")}
|
||||
</Menu.Item>
|
||||
)}
|
||||
/>
|
||||
<Menu.Divider />
|
||||
<Menu.Item
|
||||
color="red"
|
||||
|
||||
@@ -9,6 +9,7 @@ import { Link } from "@homarr/ui";
|
||||
|
||||
import { ManagePageLayout } from "~/components/manage/manage-page-layout";
|
||||
import { MobileAffixButton } from "~/components/manage/mobile-affix-button";
|
||||
import { InstallWidgetFromWorkshopButton } from "~/components/workshop/workshop-actions";
|
||||
import { CustomWidgetBetaBanner } from "./_beta-banner";
|
||||
import { ImportCustomWidgetButton } from "./_custom-widget-actions";
|
||||
import { CustomWidgetList } from "./_custom-widget-list";
|
||||
@@ -28,6 +29,7 @@ export default async function CustomWidgetsPage() {
|
||||
title={t("page.list.title")}
|
||||
primaryAction={
|
||||
<Group gap="xs">
|
||||
<InstallWidgetFromWorkshopButton />
|
||||
<ImportCustomWidgetButton />
|
||||
<MobileAffixButton component={Link} href="/manage/custom-widgets/new" leftSection={<IconPlus size={16} />}>
|
||||
{t("action.create")}
|
||||
|
||||
@@ -18,6 +18,7 @@ import {
|
||||
IconHomeFilled,
|
||||
IconLayoutDashboardFilled,
|
||||
IconMailForward,
|
||||
IconBuildingStore,
|
||||
IconPhotoFilled,
|
||||
IconPointerFilled,
|
||||
IconSearch,
|
||||
@@ -80,6 +81,12 @@ export default async function ManageLayout({ children }: PropsWithChildren) {
|
||||
label: t("items.customWidgets"),
|
||||
hidden: !session,
|
||||
},
|
||||
{
|
||||
icon: IconBuildingStore,
|
||||
href: "/manage/workshop",
|
||||
label: t("items.workshop"),
|
||||
hidden: !session?.user.permissions.includes("admin"),
|
||||
},
|
||||
{
|
||||
icon: IconSearch,
|
||||
href: "/manage/search-engines",
|
||||
|
||||
@@ -0,0 +1,18 @@
|
||||
import { redirect } from "next/navigation";
|
||||
|
||||
import { auth } from "@homarr/auth/next";
|
||||
import { getScopedI18n } from "@homarr/translation/server";
|
||||
|
||||
import { ManagePageLayout } from "~/components/manage/manage-page-layout";
|
||||
import { WorkshopBrowser } from "~/components/workshop/workshop-browser";
|
||||
|
||||
export default async function WorkshopPage() {
|
||||
const session = await auth();
|
||||
if (!session?.user.permissions.includes("admin")) redirect(session ? "/" : "/auth/login");
|
||||
const t = await getScopedI18n("workshop");
|
||||
return (
|
||||
<ManagePageLayout title={t("title")} size="xl">
|
||||
<WorkshopBrowser />
|
||||
</ManagePageLayout>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,72 @@
|
||||
"use client";
|
||||
|
||||
import { Button, Modal } from "@mantine/core";
|
||||
import { useDisclosure } from "@mantine/hooks";
|
||||
import { IconBuildingStore } from "@tabler/icons-react";
|
||||
|
||||
import { clientApi } from "@homarr/api/client";
|
||||
import { revalidatePathActionAsync } from "@homarr/common/client";
|
||||
import { showErrorNotification, showSuccessNotification } from "@homarr/notifications";
|
||||
import { useScopedI18n } from "@homarr/translation/client";
|
||||
import { customWidgetImportSchema } from "@homarr/validation/custom-widget";
|
||||
import { validateWorkshopContent } from "@homarr/workshop";
|
||||
|
||||
import { WorkshopBrowser } from "./workshop-browser";
|
||||
|
||||
export function InstallWidgetFromWorkshopButton() {
|
||||
const t = useScopedI18n("workshop");
|
||||
const [opened, { open, close }] = useDisclosure(false);
|
||||
const utils = clientApi.useUtils();
|
||||
const mutation = clientApi.customWidget.import.useMutation({
|
||||
onSuccess: () => {
|
||||
showSuccessNotification({ title: t("title"), message: t("action.install") });
|
||||
void utils.customWidget.all.invalidate();
|
||||
void revalidatePathActionAsync("/manage/custom-widgets");
|
||||
close();
|
||||
},
|
||||
onError: (error) => showErrorNotification({ title: t("title"), message: error.message }),
|
||||
});
|
||||
return (
|
||||
<>
|
||||
<Button variant="default" leftSection={<IconBuildingStore size={16} />} onClick={open}>
|
||||
{t("action.install")}
|
||||
</Button>
|
||||
<Modal opened={opened} onClose={close} title={t("title")} size="90%" fullScreen={false}>
|
||||
<WorkshopBrowser
|
||||
initialType="widget"
|
||||
lockedType="widget"
|
||||
useLabel={t("action.install")}
|
||||
onUse={async (submission) => {
|
||||
const parsed = customWidgetImportSchema.parse(JSON.parse(submission.content));
|
||||
await mutation.mutateAsync(parsed);
|
||||
}}
|
||||
/>
|
||||
</Modal>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
export function UseCssFromWorkshopButton({ onUse }: { onUse: (css: string) => void }) {
|
||||
const t = useScopedI18n("workshop");
|
||||
const [opened, { open, close }] = useDisclosure(false);
|
||||
return (
|
||||
<>
|
||||
<Button variant="default" leftSection={<IconBuildingStore size={16} />} onClick={open}>
|
||||
{t("action.useCss")}
|
||||
</Button>
|
||||
<Modal opened={opened} onClose={close} title={t("title")} size="90%">
|
||||
<WorkshopBrowser
|
||||
initialType="css"
|
||||
lockedType="css"
|
||||
useLabel={t("action.useCss")}
|
||||
onUse={(submission) => {
|
||||
const validation = validateWorkshopContent("css", submission.content);
|
||||
if (!validation.success) throw new Error(validation.error);
|
||||
onUse(submission.content);
|
||||
close();
|
||||
}}
|
||||
/>
|
||||
</Modal>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,339 @@
|
||||
"use client";
|
||||
|
||||
import type { ReactNode } from "react";
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import {
|
||||
Alert,
|
||||
Badge,
|
||||
Box,
|
||||
Button,
|
||||
Card,
|
||||
Code,
|
||||
Group,
|
||||
Image,
|
||||
Modal,
|
||||
Pagination,
|
||||
Select,
|
||||
SimpleGrid,
|
||||
Skeleton,
|
||||
Stack,
|
||||
Text,
|
||||
TextInput,
|
||||
Textarea,
|
||||
} from "@mantine/core";
|
||||
import { useDebouncedValue, useDisclosure } from "@mantine/hooks";
|
||||
import { IconAlertTriangle, IconBrandGithub, IconDownload, IconExternalLink, IconSearch } from "@tabler/icons-react";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
|
||||
import type { WorkshopSubmissionDetail, WorkshopSubmissionType } from "@homarr/workshop";
|
||||
|
||||
import { WORKSHOP_API_URL, WORKSHOP_WEB_URL, WorkshopClient, workshopExportFilename } from "@homarr/workshop";
|
||||
import { useScopedI18n } from "@homarr/translation/client";
|
||||
|
||||
const apiUrl = process.env.NEXT_PUBLIC_WORKSHOP_API_URL ?? WORKSHOP_API_URL;
|
||||
|
||||
export interface WorkshopBrowserProps {
|
||||
initialType?: WorkshopSubmissionType | "all";
|
||||
lockedType?: WorkshopSubmissionType;
|
||||
onUse?: (submission: WorkshopSubmissionDetail) => Promise<void> | void;
|
||||
useLabel?: string;
|
||||
}
|
||||
|
||||
export function WorkshopBrowser({ initialType = "all", lockedType, onUse, useLabel }: WorkshopBrowserProps) {
|
||||
const t = useScopedI18n("workshop");
|
||||
const client = useMemo(() => new WorkshopClient(apiUrl), []);
|
||||
const [page, setPage] = useState(1);
|
||||
const [type, setType] = useState<WorkshopSubmissionType | "all">(initialType);
|
||||
const [sort, setSort] = useState<"top" | "newest">("top");
|
||||
const [search, setSearch] = useState("");
|
||||
const [debouncedSearch] = useDebouncedValue(search, 250);
|
||||
const [selected, setSelected] = useState<WorkshopSubmissionDetail | null>(null);
|
||||
const [detailLoading, setDetailLoading] = useState(false);
|
||||
const [usePending, setUsePending] = useState(false);
|
||||
const [useError, setUseError] = useState("");
|
||||
|
||||
const query = useQuery({
|
||||
queryKey: ["workshop", "list", page, type, sort, debouncedSearch],
|
||||
queryFn: () => client.list({ page, type, sort, search: debouncedSearch, perPage: 12 }),
|
||||
});
|
||||
|
||||
const openDetail = async (id: string) => {
|
||||
setDetailLoading(true);
|
||||
try {
|
||||
setSelected(await client.get(id));
|
||||
} finally {
|
||||
setDetailLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const download = (submission: WorkshopSubmissionDetail) => {
|
||||
const url = URL.createObjectURL(
|
||||
new Blob([submission.content], { type: submission.type === "css" ? "text/css" : "application/json" }),
|
||||
);
|
||||
const anchor = document.createElement("a");
|
||||
anchor.href = url;
|
||||
anchor.download = workshopExportFilename(submission.title, submission.type);
|
||||
anchor.click();
|
||||
URL.revokeObjectURL(url);
|
||||
};
|
||||
|
||||
return (
|
||||
<Stack gap="lg">
|
||||
<Alert icon={<IconAlertTriangle size={18} />} color="yellow" title={t("safety.title")}>
|
||||
{t("safety.description")}
|
||||
</Alert>
|
||||
<Group align="end">
|
||||
<TextInput
|
||||
label={t("search.label")}
|
||||
placeholder={t("search.placeholder")}
|
||||
leftSection={<IconSearch size={16} />}
|
||||
value={search}
|
||||
onChange={(event) => {
|
||||
setSearch(event.currentTarget.value);
|
||||
setPage(1);
|
||||
}}
|
||||
style={{ flex: 1 }}
|
||||
/>
|
||||
<Select
|
||||
label={t("filter.type")}
|
||||
value={type}
|
||||
disabled={Boolean(lockedType)}
|
||||
onChange={(value) => {
|
||||
setType((value ?? "all") as typeof type);
|
||||
setPage(1);
|
||||
}}
|
||||
data={[
|
||||
{ value: "all", label: t("filter.all") },
|
||||
{ value: "widget", label: t("filter.widgets") },
|
||||
{ value: "css", label: t("filter.css") },
|
||||
]}
|
||||
/>
|
||||
<Select
|
||||
label={t("filter.sort")}
|
||||
value={sort}
|
||||
onChange={(value) => {
|
||||
setSort((value ?? "top") as typeof sort);
|
||||
setPage(1);
|
||||
}}
|
||||
data={[
|
||||
{ value: "top", label: t("filter.top") },
|
||||
{ value: "newest", label: t("filter.newest") },
|
||||
]}
|
||||
/>
|
||||
</Group>
|
||||
{query.error && (
|
||||
<Alert color="red" title={t("error.loadTitle")}>
|
||||
{query.error.message}
|
||||
<Button variant="subtle" onClick={() => void query.refetch()}>
|
||||
{t("action.retry")}
|
||||
</Button>
|
||||
</Alert>
|
||||
)}
|
||||
{query.isLoading ? (
|
||||
<SimpleGrid cols={{ base: 1, sm: 2, lg: 3 }}>
|
||||
<Skeleton h={280} />
|
||||
<Skeleton h={280} />
|
||||
<Skeleton h={280} />
|
||||
</SimpleGrid>
|
||||
) : query.data?.items.length ? (
|
||||
<SimpleGrid cols={{ base: 1, sm: 2, lg: 3 }}>
|
||||
{query.data.items.map((item) => (
|
||||
<Card key={item.id} withBorder padding="md">
|
||||
{item.screenshots[0] ? (
|
||||
<Card.Section>
|
||||
<Image src={client.fileUrl(item.id, item.screenshots[0], "480x320")} h={170} alt="" />
|
||||
</Card.Section>
|
||||
) : (
|
||||
<Card.Section>
|
||||
<Box
|
||||
h={170}
|
||||
bg="var(--mantine-color-default-hover)"
|
||||
style={{ display: "grid", placeItems: "center" }}
|
||||
>
|
||||
<Text ff="monospace" fw={700}>
|
||||
{item.type === "widget" ? "{}" : "CSS"}
|
||||
</Text>
|
||||
</Box>
|
||||
</Card.Section>
|
||||
)}
|
||||
<Stack gap="xs" mt="md">
|
||||
<Group justify="space-between">
|
||||
<Badge variant="light">{item.type === "widget" ? t("type.widget") : t("type.css")}</Badge>
|
||||
<Text size="sm" c="dimmed">
|
||||
{item.score >= 0 ? "+" : ""}
|
||||
{item.score}
|
||||
</Text>
|
||||
</Group>
|
||||
<Text fw={600} lineClamp={1}>
|
||||
{item.title}
|
||||
</Text>
|
||||
<Text size="sm" c="dimmed" lineClamp={2}>
|
||||
{item.description || t("empty.description")}
|
||||
</Text>
|
||||
<Text size="xs" c="dimmed">
|
||||
{t("by", { name: item.authorName, revision: item.revision })}
|
||||
</Text>
|
||||
<Button variant="light" onClick={() => void openDetail(item.id)}>
|
||||
{t("action.inspect")}
|
||||
</Button>
|
||||
</Stack>
|
||||
</Card>
|
||||
))}
|
||||
</SimpleGrid>
|
||||
) : (
|
||||
<Stack align="center" py="xl">
|
||||
<Text fw={600}>{t("empty.title")}</Text>
|
||||
<Text c="dimmed">{t("empty.description")}</Text>
|
||||
</Stack>
|
||||
)}
|
||||
{(query.data?.totalPages ?? 0) > 1 && (
|
||||
<Pagination total={query.data?.totalPages ?? 1} value={page} onChange={setPage} mx="auto" />
|
||||
)}
|
||||
<Modal
|
||||
opened={Boolean(selected) || detailLoading}
|
||||
onClose={() => setSelected(null)}
|
||||
title={selected?.title ?? t("loading")}
|
||||
size="xl"
|
||||
>
|
||||
{detailLoading || !selected ? (
|
||||
<Skeleton h={400} />
|
||||
) : (
|
||||
<Stack>
|
||||
<Group>
|
||||
<Badge>{selected.type === "widget" ? t("type.widget") : t("type.css")}</Badge>
|
||||
<Text size="sm" c="dimmed">
|
||||
{t("by", { name: selected.authorName, revision: selected.revision })}
|
||||
</Text>
|
||||
</Group>
|
||||
<Text>{selected.description}</Text>
|
||||
<Alert icon={<IconAlertTriangle size={18} />} color="yellow">
|
||||
{t("safety.inspect")}
|
||||
</Alert>
|
||||
<Code block mah={360} style={{ overflow: "auto", whiteSpace: "pre" }}>
|
||||
{selected.content}
|
||||
</Code>
|
||||
<Group justify="space-between">
|
||||
<Group>
|
||||
<Button variant="default" leftSection={<IconDownload size={16} />} onClick={() => download(selected)}>
|
||||
{t("action.download")}
|
||||
</Button>
|
||||
<Button
|
||||
component="a"
|
||||
href={`${WORKSHOP_WEB_URL}/${selected.id}`}
|
||||
target="_blank"
|
||||
variant="subtle"
|
||||
leftSection={<IconExternalLink size={16} />}
|
||||
>
|
||||
{t("action.website")}
|
||||
</Button>
|
||||
</Group>
|
||||
{onUse && (
|
||||
<Button
|
||||
loading={usePending}
|
||||
onClick={async () => {
|
||||
setUsePending(true);
|
||||
setUseError("");
|
||||
try {
|
||||
if (lockedType && selected.type !== lockedType) throw new Error(t("error.wrongType"));
|
||||
await onUse(selected);
|
||||
setSelected(null);
|
||||
} catch (caught) {
|
||||
setUseError(caught instanceof Error ? caught.message : t("error.install"));
|
||||
} finally {
|
||||
setUsePending(false);
|
||||
}
|
||||
}}
|
||||
>
|
||||
{useLabel ?? t("action.use")}
|
||||
</Button>
|
||||
)}
|
||||
</Group>
|
||||
{useError && <Alert color="red">{useError}</Alert>}
|
||||
</Stack>
|
||||
)}
|
||||
</Modal>
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
|
||||
export function PublishToWorkshopButton({
|
||||
type,
|
||||
getContent,
|
||||
defaultTitle,
|
||||
compact = false,
|
||||
renderTrigger,
|
||||
}: {
|
||||
type: WorkshopSubmissionType;
|
||||
getContent: () => Promise<string> | string;
|
||||
defaultTitle: string;
|
||||
compact?: boolean;
|
||||
renderTrigger?: (open: () => void) => ReactNode;
|
||||
}) {
|
||||
const t = useScopedI18n("workshop");
|
||||
const client = useMemo(() => new WorkshopClient(apiUrl), []);
|
||||
const [opened, { open, close }] = useDisclosure(false);
|
||||
const [title, setTitle] = useState(defaultTitle);
|
||||
const [description, setDescription] = useState("");
|
||||
const [changelog, setChangelog] = useState("");
|
||||
const [error, setError] = useState("");
|
||||
const [pending, setPending] = useState(false);
|
||||
useEffect(() => setTitle(defaultTitle), [defaultTitle]);
|
||||
const publish = async () => {
|
||||
setPending(true);
|
||||
setError("");
|
||||
try {
|
||||
if (!client.currentUser) await client.signInWithGitHub();
|
||||
await client.create({ type, title, description, changelog, content: await getContent() });
|
||||
close();
|
||||
} catch (caught) {
|
||||
setError(caught instanceof Error ? caught.message : t("error.publish"));
|
||||
} finally {
|
||||
setPending(false);
|
||||
}
|
||||
};
|
||||
return (
|
||||
<>
|
||||
{renderTrigger ? (
|
||||
renderTrigger(open)
|
||||
) : (
|
||||
<Button
|
||||
size={compact ? "xs" : undefined}
|
||||
variant="default"
|
||||
leftSection={<IconBrandGithub size={16} />}
|
||||
onClick={open}
|
||||
>
|
||||
{t("action.share")}
|
||||
</Button>
|
||||
)}
|
||||
<Modal opened={opened} onClose={close} title={t("publish.title")}>
|
||||
<Stack>
|
||||
<TextInput
|
||||
required
|
||||
label={t("publish.name")}
|
||||
value={title}
|
||||
onChange={(event) => setTitle(event.currentTarget.value)}
|
||||
/>
|
||||
<Textarea
|
||||
label={t("publish.description")}
|
||||
value={description}
|
||||
onChange={(event) => setDescription(event.currentTarget.value)}
|
||||
/>
|
||||
<Textarea
|
||||
label={t("publish.changelog")}
|
||||
value={changelog}
|
||||
onChange={(event) => setChangelog(event.currentTarget.value)}
|
||||
/>
|
||||
{error && <Alert color="red">{error}</Alert>}
|
||||
<Group justify="end">
|
||||
<Button variant="default" onClick={close}>
|
||||
{t("action.cancel")}
|
||||
</Button>
|
||||
<Button loading={pending} disabled={title.trim().length < 3} onClick={() => void publish()}>
|
||||
{t("action.publish")}
|
||||
</Button>
|
||||
</Group>
|
||||
</Stack>
|
||||
</Modal>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
GITHUB_CLIENT_ID=
|
||||
GITHUB_CLIENT_SECRET=
|
||||
PB_SUPERUSER_EMAIL=
|
||||
PB_SUPERUSER_PASSWORD=
|
||||
PB_EXPOSE_PORT=8090
|
||||
@@ -0,0 +1,4 @@
|
||||
.env
|
||||
.env.*
|
||||
!.env.example
|
||||
pb_data/
|
||||
@@ -0,0 +1,31 @@
|
||||
FROM alpine:3.22 AS download
|
||||
|
||||
ARG TARGETARCH
|
||||
ARG POCKETBASE_VERSION=0.39.6
|
||||
|
||||
RUN apk add --no-cache ca-certificates curl unzip
|
||||
RUN case "${TARGETARCH}" in \
|
||||
amd64) PB_ARCH=amd64 ;; \
|
||||
arm64) PB_ARCH=arm64 ;; \
|
||||
arm) PB_ARCH=armv7 ;; \
|
||||
*) echo "Unsupported architecture: ${TARGETARCH}" >&2; exit 1 ;; \
|
||||
esac && \
|
||||
BASE_URL="https://github.com/pocketbase/pocketbase/releases/download/v${POCKETBASE_VERSION}" && \
|
||||
ARCHIVE="pocketbase_${POCKETBASE_VERSION}_linux_${PB_ARCH}.zip" && \
|
||||
curl --fail --location --silent --show-error "${BASE_URL}/${ARCHIVE}" --output "/tmp/${ARCHIVE}" && \
|
||||
curl --fail --location --silent --show-error "${BASE_URL}/checksums.txt" --output /tmp/checksums.txt && \
|
||||
cd /tmp && grep " ${ARCHIVE}$" checksums.txt | sha256sum -c - && \
|
||||
unzip "/tmp/${ARCHIVE}" pocketbase -d /out
|
||||
|
||||
FROM alpine:3.22
|
||||
|
||||
RUN apk add --no-cache ca-certificates su-exec tzdata && addgroup -S pocketbase && adduser -S -G pocketbase pocketbase
|
||||
COPY --from=download /out/pocketbase /usr/local/bin/pocketbase
|
||||
COPY entrypoint.sh /usr/local/bin/workshop-entrypoint
|
||||
|
||||
RUN chmod +x /usr/local/bin/workshop-entrypoint && mkdir -p /pb_data /pb_hooks /pb_migrations && chown -R pocketbase:pocketbase /pb_data /pb_hooks /pb_migrations
|
||||
|
||||
USER root
|
||||
EXPOSE 8090
|
||||
HEALTHCHECK --interval=30s --timeout=5s --start-period=10s --retries=3 CMD wget -q --spider http://127.0.0.1:8090/api/health || exit 1
|
||||
ENTRYPOINT ["workshop-entrypoint"]
|
||||
@@ -0,0 +1,45 @@
|
||||
# Homarr Workshop backend
|
||||
|
||||
PocketBase provides the central Workshop API at `store.homarr.dev`. It is not a per-instance Homarr service. Local deployment is for development and integration testing.
|
||||
|
||||
## Local startup
|
||||
|
||||
1. Copy `.env.example` to `.env` and create a GitHub OAuth app.
|
||||
2. Set its callback URL to `http://localhost:8090/api/oauth2-redirect`.
|
||||
3. Set `GITHUB_CLIENT_ID`, `GITHUB_CLIENT_SECRET`, `PB_SUPERUSER_EMAIL`, and `PB_SUPERUSER_PASSWORD`.
|
||||
4. Run `docker compose up --build` from this directory.
|
||||
|
||||
The API is at `http://localhost:8090`; the emergency PocketBase dashboard is at `http://localhost:8090/_/`. Day-to-day moderation belongs in `/workshop/admin`, not the PocketBase dashboard.
|
||||
|
||||
The image downloads the official PocketBase `0.39.6` release and verifies it against that release's `checksums.txt`. Migrations and hooks are mounted read-only.
|
||||
|
||||
## Roles and account states
|
||||
|
||||
- `member`: publish, update/delete owned submissions, vote, and report.
|
||||
- `moderator`: report queue, submission removal, posting bans, and account disable/restore.
|
||||
- `admin`: moderator actions plus role changes.
|
||||
- `posting_banned`: cannot create or update submissions; can browse, vote, and report.
|
||||
- `disabled`: cannot authenticate or write.
|
||||
|
||||
Bootstrap the first admin through the PocketBase dashboard by changing a GitHub-authenticated user's role. All later staff actions use the restricted Workshop back office and are recorded in `moderation_actions`.
|
||||
|
||||
## Backup and restore
|
||||
|
||||
PocketBase stores records and uploads in `/pb_data`. Stop writes or stop the container, then archive the named volume:
|
||||
|
||||
```sh
|
||||
docker compose stop workshop
|
||||
docker run --rm -v store_workshop_data:/data -v "$PWD":/backup alpine tar czf /backup/workshop-backup.tgz -C /data .
|
||||
docker compose start workshop
|
||||
```
|
||||
|
||||
Restore only into the same PocketBase version after preserving the current volume. Extract the archive into an empty `workshop_data` volume, start the service, and verify `/api/health`, collection migrations, a listing request, and an authenticated write.
|
||||
|
||||
## Production checklist
|
||||
|
||||
- Terminate TLS at the reverse proxy and forward the real client IP.
|
||||
- Keep superuser credentials outside Compose and rotate them after bootstrap.
|
||||
- Restrict the dashboard path to operators; GitHub users never need it.
|
||||
- Review the versioned default rate limits for OAuth, submissions, votes, reports, moderation, and general API traffic against production load before launch.
|
||||
- Back up `/pb_data` before every PocketBase or migration upgrade and test restore regularly.
|
||||
- Monitor health, authentication failures, 429/5xx responses, moderation actions, disk use, and backup age.
|
||||
@@ -0,0 +1,24 @@
|
||||
services:
|
||||
workshop:
|
||||
build:
|
||||
context: .
|
||||
args:
|
||||
POCKETBASE_VERSION: 0.39.6
|
||||
image: homarr/workshop-pocketbase:0.39.6
|
||||
restart: unless-stopped
|
||||
environment:
|
||||
GITHUB_CLIENT_ID: ${GITHUB_CLIENT_ID:-}
|
||||
GITHUB_CLIENT_SECRET: ${GITHUB_CLIENT_SECRET:-}
|
||||
PB_SUPERUSER_EMAIL: ${PB_SUPERUSER_EMAIL:-}
|
||||
PB_SUPERUSER_PASSWORD: ${PB_SUPERUSER_PASSWORD:-}
|
||||
ports:
|
||||
- "${PB_EXPOSE_PORT:-8090}:8090"
|
||||
volumes:
|
||||
- workshop_data:/pb_data
|
||||
- ./pb_hooks:/pb_hooks:ro
|
||||
- ./pb_migrations:/pb_migrations:ro
|
||||
security_opt:
|
||||
- no-new-privileges:true
|
||||
|
||||
volumes:
|
||||
workshop_data:
|
||||
@@ -0,0 +1,14 @@
|
||||
#!/bin/sh
|
||||
set -eu
|
||||
|
||||
chown -R pocketbase:pocketbase /pb_data
|
||||
|
||||
if [ -n "${PB_SUPERUSER_EMAIL:-}" ] && [ -n "${PB_SUPERUSER_PASSWORD:-}" ]; then
|
||||
su-exec pocketbase pocketbase superuser upsert "${PB_SUPERUSER_EMAIL}" "${PB_SUPERUSER_PASSWORD}" --dir=/pb_data
|
||||
fi
|
||||
|
||||
exec su-exec pocketbase pocketbase serve \
|
||||
--http=0.0.0.0:8090 \
|
||||
--dir=/pb_data \
|
||||
--hooksDir=/pb_hooks \
|
||||
--migrationsDir=/pb_migrations
|
||||
@@ -0,0 +1,102 @@
|
||||
const WIDGET_SCHEMA = "homarr-custom-widget-v2";
|
||||
const CSS_SCHEMA = "homarr-custom-css-v1";
|
||||
const CSS_LIMIT = 16384;
|
||||
const WIDGET_AUTH_TYPES = ["none", "bearer", "basic", "apiKeyHeader", "apiKeyQuery"];
|
||||
const WIDGET_METHODS = ["GET", "POST", "PUT", "DELETE", "PATCH"];
|
||||
const WIDGET_DISPLAY_TYPES = [
|
||||
"singleValue",
|
||||
"keyValue",
|
||||
"table",
|
||||
"statGrid",
|
||||
"progressBars",
|
||||
"statusIndicator",
|
||||
"countGrid",
|
||||
"raw",
|
||||
"actionButton",
|
||||
"customJsx",
|
||||
];
|
||||
|
||||
function fail(ErrorType, _code, message) {
|
||||
throw new ErrorType(message);
|
||||
}
|
||||
|
||||
function requireWritableAccount(app, auth, posting) {
|
||||
if (!auth) fail(UnauthorizedError, "authentication_required", "Authentication is required");
|
||||
const account = app.findRecordById("users", auth.id);
|
||||
if (account.getString("state") === "disabled") fail(ForbiddenError, "account_disabled", "Account is disabled");
|
||||
if (posting && account.getString("state") === "posting_banned")
|
||||
fail(ForbiddenError, "posting_banned", "Account cannot publish submissions");
|
||||
return account;
|
||||
}
|
||||
|
||||
function validateSubmission(record) {
|
||||
const type = record.getString("type");
|
||||
const content = record.getString("content");
|
||||
if (type === "css") {
|
||||
if (!content.trim()) fail(BadRequestError, "invalid_submission", "CSS cannot be empty");
|
||||
if (content.length > CSS_LIMIT) fail(BadRequestError, "invalid_submission", "CSS is too large");
|
||||
record.set("schemaVersion", CSS_SCHEMA);
|
||||
return;
|
||||
}
|
||||
if (type !== "widget") fail(BadRequestError, "invalid_submission", "Unsupported submission type");
|
||||
let widget;
|
||||
try {
|
||||
widget = JSON.parse(content);
|
||||
} catch {
|
||||
fail(BadRequestError, "invalid_submission", "Widget content is not valid JSON");
|
||||
}
|
||||
if (
|
||||
!widget ||
|
||||
widget.$schema !== WIDGET_SCHEMA ||
|
||||
typeof widget.name !== "string" ||
|
||||
widget.name.length < 1 ||
|
||||
widget.name.length > 128 ||
|
||||
typeof widget.url !== "string" ||
|
||||
widget.url.length < 1 ||
|
||||
!WIDGET_AUTH_TYPES.includes(widget.authType) ||
|
||||
!WIDGET_METHODS.includes(widget.method) ||
|
||||
!WIDGET_DISPLAY_TYPES.includes(widget.displayType) ||
|
||||
typeof widget.displayConfig !== "object" ||
|
||||
Array.isArray(widget.displayConfig) ||
|
||||
Object.prototype.hasOwnProperty.call(widget, "secrets")
|
||||
) {
|
||||
fail(BadRequestError, "invalid_submission", "Widget does not match homarr-custom-widget-v2");
|
||||
}
|
||||
if (!widget.displayConfig || widget.displayConfig.type !== widget.displayType) {
|
||||
fail(BadRequestError, "invalid_submission", "Widget displayConfig.type must match displayType");
|
||||
}
|
||||
record.set("schemaVersion", WIDGET_SCHEMA);
|
||||
}
|
||||
|
||||
function snapshot(record) {
|
||||
return JSON.stringify(record.publicExport());
|
||||
}
|
||||
|
||||
function requireStaff(app, auth) {
|
||||
const account = requireWritableAccount(app, auth, false);
|
||||
const role = account.getString("role");
|
||||
if (role !== "moderator" && role !== "admin") fail(ForbiddenError, "forbidden", "Staff access is required");
|
||||
return role;
|
||||
}
|
||||
|
||||
function body(e) {
|
||||
const data = new DynamicModel({ reason: "", state: "", role: "", status: "" });
|
||||
e.bindBody(data);
|
||||
if (!data.reason || data.reason.trim().length < 3)
|
||||
fail(BadRequestError, "invalid_submission", "A reason is required");
|
||||
return data;
|
||||
}
|
||||
|
||||
function audit(app, actor, action, targetType, targetId, reason, targetSnapshot) {
|
||||
const collection = app.findCollectionByNameOrId("moderation_actions");
|
||||
const record = new Record(collection);
|
||||
record.set("actor", actor.id);
|
||||
record.set("action", action);
|
||||
record.set("targetType", targetType);
|
||||
record.set("targetId", targetId);
|
||||
record.set("reason", reason.trim());
|
||||
record.set("snapshot", targetSnapshot || "");
|
||||
app.save(record);
|
||||
}
|
||||
|
||||
module.exports = { audit, body, fail, requireStaff, requireWritableAccount, snapshot, validateSubmission };
|
||||
@@ -0,0 +1,168 @@
|
||||
/// <reference path="../pb_data/types.d.ts" />
|
||||
|
||||
onRecordAuthWithOAuth2Request((e) => {
|
||||
const utils = require(`${__hooks}/workshop-utils.js`);
|
||||
if (e.record && e.record.getString("state") === "disabled")
|
||||
utils.fail(ForbiddenError, "account_disabled", "Account is disabled");
|
||||
if (e.isNewRecord) {
|
||||
e.createData.displayName = e.oAuth2User.name || e.oAuth2User.username || "Community member";
|
||||
e.createData.avatarUrl = e.oAuth2User.avatarUrl || "";
|
||||
e.createData.role = "member";
|
||||
e.createData.state = "active";
|
||||
}
|
||||
e.next();
|
||||
}, "users");
|
||||
|
||||
onRecordAuthRequest((e) => {
|
||||
const utils = require(`${__hooks}/workshop-utils.js`);
|
||||
if (e.record.getString("state") === "disabled") utils.fail(ForbiddenError, "account_disabled", "Account is disabled");
|
||||
e.next();
|
||||
}, "users");
|
||||
|
||||
onRecordUpdateRequest((e) => {
|
||||
const original = e.app.findRecordById("users", e.record.id);
|
||||
e.record.set("role", original.get("role"));
|
||||
e.record.set("state", original.get("state"));
|
||||
e.record.set("moderationReason", original.get("moderationReason"));
|
||||
e.next();
|
||||
}, "users");
|
||||
|
||||
onRecordCreateRequest((e) => {
|
||||
const utils = require(`${__hooks}/workshop-utils.js`);
|
||||
const account = utils.requireWritableAccount(e.app, e.auth, true);
|
||||
utils.validateSubmission(e.record);
|
||||
e.record.set("author", e.auth.id);
|
||||
e.record.set("authorName", account.getString("displayName"));
|
||||
e.record.set("revision", 1);
|
||||
e.next();
|
||||
}, "submissions");
|
||||
|
||||
onRecordUpdateRequest((e) => {
|
||||
const utils = require(`${__hooks}/workshop-utils.js`);
|
||||
utils.requireWritableAccount(e.app, e.auth, true);
|
||||
const original = e.app.findRecordById("submissions", e.record.id);
|
||||
e.record.set("author", original.get("author"));
|
||||
e.record.set("authorName", original.get("authorName"));
|
||||
e.record.set("type", original.get("type"));
|
||||
e.record.set("schemaVersion", original.get("schemaVersion"));
|
||||
e.record.set(
|
||||
"revision",
|
||||
original.getInt("revision") + (e.record.getString("content") !== original.getString("content") ? 1 : 0),
|
||||
);
|
||||
utils.validateSubmission(e.record);
|
||||
e.next();
|
||||
}, "submissions");
|
||||
|
||||
onRecordCreateRequest((e) => {
|
||||
const utils = require(`${__hooks}/workshop-utils.js`);
|
||||
utils.requireWritableAccount(e.app, e.auth, false);
|
||||
const value = e.record.getInt("value");
|
||||
if (value !== 1 && value !== -1) throw new BadRequestError("Vote must be 1 or -1");
|
||||
e.record.set("user", e.auth.id);
|
||||
e.next();
|
||||
}, "votes");
|
||||
|
||||
onRecordUpdateRequest((e) => {
|
||||
const utils = require(`${__hooks}/workshop-utils.js`);
|
||||
utils.requireWritableAccount(e.app, e.auth, false);
|
||||
const original = e.app.findRecordById("votes", e.record.id);
|
||||
const value = e.record.getInt("value");
|
||||
if (value !== 1 && value !== -1) throw new BadRequestError("Vote must be 1 or -1");
|
||||
e.record.set("user", original.get("user"));
|
||||
e.record.set("submission", original.get("submission"));
|
||||
e.next();
|
||||
}, "votes");
|
||||
|
||||
onRecordCreateRequest((e) => {
|
||||
const utils = require(`${__hooks}/workshop-utils.js`);
|
||||
utils.requireWritableAccount(e.app, e.auth, false);
|
||||
e.record.set("reporter", e.auth.id);
|
||||
e.record.set("status", "open");
|
||||
e.next();
|
||||
}, "reports");
|
||||
|
||||
routerAdd(
|
||||
"POST",
|
||||
"/api/workshop/moderation/submissions/{id}/remove",
|
||||
(e) => {
|
||||
const utils = require(`${__hooks}/workshop-utils.js`);
|
||||
utils.requireStaff(e.app, e.auth);
|
||||
const data = utils.body(e);
|
||||
const id = e.request.pathValue("id");
|
||||
e.app.runInTransaction((tx) => {
|
||||
const target = tx.findRecordById("submissions", id);
|
||||
utils.audit(tx, e.auth, "remove_submission", "submission", id, data.reason, utils.snapshot(target));
|
||||
tx.delete(target);
|
||||
});
|
||||
return e.json(200, { success: true });
|
||||
},
|
||||
$apis.requireAuth("users"),
|
||||
);
|
||||
|
||||
routerAdd(
|
||||
"POST",
|
||||
"/api/workshop/moderation/users/{id}/state",
|
||||
(e) => {
|
||||
const utils = require(`${__hooks}/workshop-utils.js`);
|
||||
const actorRole = utils.requireStaff(e.app, e.auth);
|
||||
const data = utils.body(e);
|
||||
if (!["active", "posting_banned", "disabled"].includes(data.state))
|
||||
throw new BadRequestError("Invalid account state");
|
||||
const id = e.request.pathValue("id");
|
||||
if (id === e.auth.id) throw new BadRequestError("You cannot moderate your own account");
|
||||
e.app.runInTransaction((tx) => {
|
||||
const target = tx.findRecordById("users", id);
|
||||
if (target.getString("role") === "admin" && actorRole !== "admin") throw new ForbiddenError("forbidden");
|
||||
const previous = utils.snapshot(target);
|
||||
target.set("state", data.state);
|
||||
target.set("moderationReason", data.reason);
|
||||
tx.save(target);
|
||||
utils.audit(tx, e.auth, "set_account_state", "user", id, data.reason, previous);
|
||||
});
|
||||
return e.json(200, { success: true });
|
||||
},
|
||||
$apis.requireAuth("users"),
|
||||
);
|
||||
|
||||
routerAdd(
|
||||
"POST",
|
||||
"/api/workshop/moderation/users/{id}/role",
|
||||
(e) => {
|
||||
const utils = require(`${__hooks}/workshop-utils.js`);
|
||||
if (utils.requireStaff(e.app, e.auth) !== "admin") throw new ForbiddenError("forbidden");
|
||||
const data = utils.body(e);
|
||||
if (!["member", "moderator", "admin"].includes(data.role)) throw new BadRequestError("Invalid role");
|
||||
const id = e.request.pathValue("id");
|
||||
if (id === e.auth.id) throw new BadRequestError("You cannot change your own role");
|
||||
e.app.runInTransaction((tx) => {
|
||||
const target = tx.findRecordById("users", id);
|
||||
const previous = utils.snapshot(target);
|
||||
target.set("role", data.role);
|
||||
tx.save(target);
|
||||
utils.audit(tx, e.auth, "set_role", "user", id, data.reason, previous);
|
||||
});
|
||||
return e.json(200, { success: true });
|
||||
},
|
||||
$apis.requireAuth("users"),
|
||||
);
|
||||
|
||||
routerAdd(
|
||||
"POST",
|
||||
"/api/workshop/moderation/reports/{id}/resolve",
|
||||
(e) => {
|
||||
const utils = require(`${__hooks}/workshop-utils.js`);
|
||||
utils.requireStaff(e.app, e.auth);
|
||||
const data = utils.body(e);
|
||||
if (!["resolved", "dismissed"].includes(data.status)) throw new BadRequestError("Invalid report status");
|
||||
const id = e.request.pathValue("id");
|
||||
e.app.runInTransaction((tx) => {
|
||||
const target = tx.findRecordById("reports", id);
|
||||
const previous = utils.snapshot(target);
|
||||
target.set("status", data.status);
|
||||
tx.save(target);
|
||||
utils.audit(tx, e.auth, "resolve_report", "report", id, data.reason, previous);
|
||||
});
|
||||
return e.json(200, { success: true });
|
||||
},
|
||||
$apis.requireAuth("users"),
|
||||
);
|
||||
@@ -0,0 +1,248 @@
|
||||
/// <reference path="../pb_data/types.d.ts" />
|
||||
|
||||
migrate(
|
||||
(app) => {
|
||||
let users;
|
||||
try {
|
||||
users = app.findCollectionByNameOrId("users");
|
||||
} catch {
|
||||
users = new Collection({
|
||||
type: "auth",
|
||||
name: "users",
|
||||
listRule: "@request.auth.role = 'moderator' || @request.auth.role = 'admin'",
|
||||
viewRule: "id = @request.auth.id || @request.auth.role = 'moderator' || @request.auth.role = 'admin'",
|
||||
updateRule: "id = @request.auth.id && @request.auth.state != 'disabled'",
|
||||
deleteRule: null,
|
||||
passwordAuth: { enabled: false },
|
||||
});
|
||||
}
|
||||
// PocketBase creates a default users auth collection on first boot, so
|
||||
// Workshop fields must be added to the existing collection explicitly.
|
||||
users.fields.add(
|
||||
new TextField({ name: "displayName", required: true, min: 1, max: 100 }),
|
||||
new URLField({ name: "avatarUrl" }),
|
||||
new SelectField({ name: "role", required: true, maxSelect: 1, values: ["member", "moderator", "admin"] }),
|
||||
new SelectField({
|
||||
name: "state",
|
||||
required: true,
|
||||
maxSelect: 1,
|
||||
values: ["active", "posting_banned", "disabled"],
|
||||
}),
|
||||
new TextField({ name: "moderationReason", max: 1000 }),
|
||||
);
|
||||
const githubClientId = $os.getenv("GITHUB_CLIENT_ID");
|
||||
const githubClientSecret = $os.getenv("GITHUB_CLIENT_SECRET");
|
||||
users.oauth2.enabled = Boolean(githubClientId && githubClientSecret);
|
||||
users.oauth2.providers =
|
||||
githubClientId && githubClientSecret
|
||||
? [{ name: "github", clientId: githubClientId, clientSecret: githubClientSecret }]
|
||||
: [];
|
||||
app.save(users);
|
||||
|
||||
const staffRule = "@request.auth.role = 'moderator' || @request.auth.role = 'admin'";
|
||||
const writableRule = "@request.auth.id != '' && @request.auth.state != 'disabled'";
|
||||
const authorRule = "author = @request.auth.id && @request.auth.state = 'active'";
|
||||
|
||||
const submissions = new Collection({
|
||||
type: "base",
|
||||
name: "submissions",
|
||||
// Full source is intentionally available only through getOne; discovery
|
||||
// must use workshop_listings so list payloads never contain content.
|
||||
listRule: null,
|
||||
viewRule: "",
|
||||
createRule: null,
|
||||
updateRule: null,
|
||||
deleteRule: null,
|
||||
fields: [
|
||||
{ type: "select", name: "type", required: true, maxSelect: 1, values: ["widget", "css"] },
|
||||
{ type: "text", name: "title", required: true, min: 3, max: 100 },
|
||||
{ type: "text", name: "description", max: 2000 },
|
||||
{ type: "text", name: "schemaVersion", required: true, max: 100 },
|
||||
{ type: "text", name: "content", required: true, max: 1000000 },
|
||||
{
|
||||
type: "file",
|
||||
name: "screenshots",
|
||||
maxSelect: 5,
|
||||
maxSize: 5242880,
|
||||
mimeTypes: ["image/png", "image/jpeg", "image/webp"],
|
||||
thumbs: ["480x320", "960x640"],
|
||||
},
|
||||
{
|
||||
type: "relation",
|
||||
name: "author",
|
||||
required: true,
|
||||
maxSelect: 1,
|
||||
collectionId: users.id,
|
||||
cascadeDelete: true,
|
||||
},
|
||||
{ type: "text", name: "authorName", required: true, max: 100 },
|
||||
{ type: "number", name: "revision", required: true, onlyInt: true, min: 1 },
|
||||
{ type: "text", name: "changelog", max: 2000 },
|
||||
{ type: "autodate", name: "created", onCreate: true },
|
||||
{ type: "autodate", name: "updated", onCreate: true, onUpdate: true },
|
||||
],
|
||||
});
|
||||
app.save(submissions);
|
||||
submissions.createRule = "@request.auth.id != ''";
|
||||
submissions.updateRule = authorRule;
|
||||
submissions.deleteRule = authorRule;
|
||||
submissions.addIndex("idx_submissions_author", false, "author", "");
|
||||
submissions.addIndex("idx_submissions_type_created", false, "type, created", "");
|
||||
app.save(submissions);
|
||||
|
||||
const votes = new Collection({
|
||||
type: "base",
|
||||
name: "votes",
|
||||
listRule: null,
|
||||
viewRule: null,
|
||||
createRule: null,
|
||||
updateRule: null,
|
||||
deleteRule: null,
|
||||
fields: [
|
||||
{
|
||||
type: "relation",
|
||||
name: "submission",
|
||||
required: true,
|
||||
maxSelect: 1,
|
||||
collectionId: submissions.id,
|
||||
cascadeDelete: true,
|
||||
},
|
||||
{ type: "relation", name: "user", required: true, maxSelect: 1, collectionId: users.id, cascadeDelete: true },
|
||||
{ type: "number", name: "value", required: true, onlyInt: true, min: -1, max: 1 },
|
||||
{ type: "autodate", name: "created", onCreate: true },
|
||||
{ type: "autodate", name: "updated", onCreate: true, onUpdate: true },
|
||||
],
|
||||
});
|
||||
app.save(votes);
|
||||
votes.listRule = "user = @request.auth.id";
|
||||
votes.viewRule = "user = @request.auth.id";
|
||||
votes.createRule = writableRule;
|
||||
votes.updateRule = "user = @request.auth.id && @request.auth.state != 'disabled'";
|
||||
votes.deleteRule = "user = @request.auth.id && @request.auth.state != 'disabled'";
|
||||
votes.addIndex("idx_votes_user_submission", true, "user, submission", "");
|
||||
votes.addIndex("idx_votes_submission", false, "submission", "");
|
||||
app.save(votes);
|
||||
|
||||
const reports = new Collection({
|
||||
type: "base",
|
||||
name: "reports",
|
||||
listRule: null,
|
||||
viewRule: null,
|
||||
createRule: null,
|
||||
updateRule: null,
|
||||
deleteRule: null,
|
||||
fields: [
|
||||
{
|
||||
type: "relation",
|
||||
name: "submission",
|
||||
required: true,
|
||||
maxSelect: 1,
|
||||
collectionId: submissions.id,
|
||||
cascadeDelete: true,
|
||||
},
|
||||
{
|
||||
type: "relation",
|
||||
name: "reporter",
|
||||
required: true,
|
||||
maxSelect: 1,
|
||||
collectionId: users.id,
|
||||
cascadeDelete: true,
|
||||
},
|
||||
{
|
||||
type: "select",
|
||||
name: "category",
|
||||
required: true,
|
||||
maxSelect: 1,
|
||||
values: ["malicious", "spam", "copyright", "inappropriate", "other"],
|
||||
},
|
||||
{ type: "text", name: "explanation", required: true, min: 3, max: 1000 },
|
||||
{ type: "select", name: "status", required: true, maxSelect: 1, values: ["open", "resolved", "dismissed"] },
|
||||
{ type: "autodate", name: "created", onCreate: true },
|
||||
{ type: "autodate", name: "updated", onCreate: true, onUpdate: true },
|
||||
],
|
||||
});
|
||||
app.save(reports);
|
||||
reports.listRule = staffRule;
|
||||
reports.viewRule = staffRule;
|
||||
reports.createRule = writableRule;
|
||||
reports.addIndex("idx_reports_reporter_submission", true, "reporter, submission", "");
|
||||
reports.addIndex("idx_reports_status_created", false, "status, created", "");
|
||||
app.save(reports);
|
||||
|
||||
const moderationActions = new Collection({
|
||||
type: "base",
|
||||
name: "moderation_actions",
|
||||
listRule: staffRule,
|
||||
viewRule: staffRule,
|
||||
createRule: null,
|
||||
updateRule: null,
|
||||
deleteRule: null,
|
||||
fields: [
|
||||
{ type: "relation", name: "actor", required: true, maxSelect: 1, collectionId: users.id },
|
||||
{
|
||||
type: "select",
|
||||
name: "action",
|
||||
required: true,
|
||||
maxSelect: 1,
|
||||
values: ["remove_submission", "set_account_state", "set_role", "resolve_report"],
|
||||
},
|
||||
{ type: "text", name: "targetType", required: true, max: 30 },
|
||||
{ type: "text", name: "targetId", required: true, max: 30 },
|
||||
{ type: "text", name: "reason", required: true, min: 3, max: 1000 },
|
||||
{ type: "text", name: "snapshot", max: 100000 },
|
||||
{ type: "autodate", name: "created", onCreate: true },
|
||||
],
|
||||
});
|
||||
app.save(moderationActions);
|
||||
moderationActions.addIndex("idx_moderation_actions_created", false, "created", "");
|
||||
app.save(moderationActions);
|
||||
|
||||
const listings = new Collection({
|
||||
type: "view",
|
||||
name: "workshop_listings",
|
||||
listRule: "",
|
||||
viewRule: "",
|
||||
viewQuery: `
|
||||
SELECT s.id, s.type, s.title, s.description, s.schemaVersion, s.screenshots,
|
||||
s.revision, s.changelog, s.author, s.authorName, s.created, s.updated,
|
||||
COALESCE(SUM(CASE WHEN v.value = 1 THEN 1 ELSE 0 END), 0) AS upvotes,
|
||||
COALESCE(SUM(CASE WHEN v.value = -1 THEN 1 ELSE 0 END), 0) AS downvotes,
|
||||
COALESCE(SUM(v.value), 0) AS score
|
||||
FROM submissions s
|
||||
LEFT JOIN votes v ON v.submission = s.id
|
||||
GROUP BY s.id
|
||||
`,
|
||||
});
|
||||
app.save(listings);
|
||||
|
||||
const settings = app.settings();
|
||||
settings.rateLimits.enabled = true;
|
||||
settings.rateLimits.rules = [
|
||||
{ label: "*:auth", audience: "", duration: 60, maxRequests: 10 },
|
||||
{ label: "submissions:create", audience: "@auth", duration: 60, maxRequests: 10 },
|
||||
{ label: "votes:create", audience: "@auth", duration: 10, maxRequests: 20 },
|
||||
{ label: "reports:create", audience: "@auth", duration: 60, maxRequests: 5 },
|
||||
{ label: "/api/workshop/moderation/", audience: "@auth", duration: 60, maxRequests: 30 },
|
||||
{ label: "/api/", audience: "", duration: 10, maxRequests: 300 },
|
||||
];
|
||||
settings.logs.maxDays = 30;
|
||||
settings.logs.logAuthId = true;
|
||||
settings.logs.logIP = true;
|
||||
app.save(settings);
|
||||
},
|
||||
(app) => {
|
||||
// PocketBase creates the users auth collection before this migration. Keep
|
||||
// it intact on rollback so reverting Workshop can never delete accounts.
|
||||
for (const name of ["workshop_listings", "moderation_actions", "reports", "votes", "submissions"]) {
|
||||
try {
|
||||
app.delete(app.findCollectionByNameOrId(name));
|
||||
} catch {
|
||||
// Collection was not created or was already removed.
|
||||
}
|
||||
}
|
||||
const settings = app.settings();
|
||||
settings.rateLimits.enabled = false;
|
||||
settings.rateLimits.rules = [];
|
||||
app.save(settings);
|
||||
},
|
||||
);
|
||||
Executable
+26
@@ -0,0 +1,26 @@
|
||||
#!/bin/sh
|
||||
set -eu
|
||||
|
||||
project="homarr-workshop-integration"
|
||||
port="${WORKSHOP_TEST_PORT:-18091}"
|
||||
email="integration@homarr.test"
|
||||
password="WorkshopIntegrationPassword123!"
|
||||
|
||||
cleanup() {
|
||||
PB_EXPOSE_PORT="$port" docker compose --file apps/store/docker-compose.yml --project-name "$project" down --volumes >/dev/null 2>&1 || true
|
||||
}
|
||||
trap cleanup EXIT INT TERM
|
||||
cleanup
|
||||
|
||||
PB_EXPOSE_PORT="$port" \
|
||||
PB_SUPERUSER_EMAIL="$email" \
|
||||
PB_SUPERUSER_PASSWORD="$password" \
|
||||
docker compose --file apps/store/docker-compose.yml --project-name "$project" up --detach --build --wait
|
||||
|
||||
if ! WORKSHOP_TEST_URL="http://127.0.0.1:$port" \
|
||||
WORKSHOP_TEST_SUPERUSER_EMAIL="$email" \
|
||||
WORKSHOP_TEST_SUPERUSER_PASSWORD="$password" \
|
||||
node apps/store/tests/workshop.integration.mjs; then
|
||||
docker compose --file apps/store/docker-compose.yml --project-name "$project" logs --no-color workshop
|
||||
exit 1
|
||||
fi
|
||||
@@ -0,0 +1,165 @@
|
||||
import assert from "node:assert/strict";
|
||||
|
||||
import PocketBase, { ClientResponseError } from "pocketbase";
|
||||
|
||||
const baseUrl = process.env.WORKSHOP_TEST_URL;
|
||||
const superuserEmail = process.env.WORKSHOP_TEST_SUPERUSER_EMAIL;
|
||||
const superuserPassword = process.env.WORKSHOP_TEST_SUPERUSER_PASSWORD;
|
||||
assert(baseUrl && superuserEmail && superuserPassword, "Workshop integration test environment is incomplete");
|
||||
|
||||
const root = new PocketBase(baseUrl);
|
||||
await root.collection("_superusers").authWithPassword(superuserEmail, superuserPassword);
|
||||
|
||||
const createUser = async (name, role = "member", state = "active") => {
|
||||
const password = `Workshop${name}Password123!`;
|
||||
return root.collection("users").create({
|
||||
email: `${name.toLowerCase()}@homarr.test`,
|
||||
password,
|
||||
passwordConfirm: password,
|
||||
verified: true,
|
||||
displayName: name,
|
||||
role,
|
||||
state,
|
||||
});
|
||||
};
|
||||
|
||||
const expectStatus = async (operation, status) => {
|
||||
const statuses = Array.isArray(status) ? status : [status];
|
||||
await assert.rejects(operation, (error) => error instanceof ClientResponseError && statuses.includes(error.status));
|
||||
};
|
||||
|
||||
const memberRecord = await createUser("Member");
|
||||
const secondMemberRecord = await createUser("SecondMember");
|
||||
const bannedRecord = await createUser("Banned", "member", "posting_banned");
|
||||
const disabledRecord = await createUser("Disabled");
|
||||
const moderatorRecord = await createUser("Moderator", "moderator");
|
||||
const adminRecord = await createUser("Admin", "admin");
|
||||
|
||||
const member = await root.collection("users").impersonate(memberRecord.id, 3600);
|
||||
const secondMember = await root.collection("users").impersonate(secondMemberRecord.id, 3600);
|
||||
const banned = await root.collection("users").impersonate(bannedRecord.id, 3600);
|
||||
const disabled = await root.collection("users").impersonate(disabledRecord.id, 3600);
|
||||
const moderator = await root.collection("users").impersonate(moderatorRecord.id, 3600);
|
||||
const admin = await root.collection("users").impersonate(adminRecord.id, 3600);
|
||||
|
||||
const forgedMember = await member.collection("users").update(memberRecord.id, {
|
||||
role: "admin",
|
||||
state: "posting_banned",
|
||||
moderationReason: "self escalation",
|
||||
});
|
||||
assert.equal(forgedMember.role, "member", "members must not change their own role");
|
||||
assert.equal(forgedMember.state, "active", "members must not change their own account state");
|
||||
assert.equal(forgedMember.moderationReason, "", "members must not change protected moderation fields");
|
||||
|
||||
await admin.send(`/api/workshop/moderation/users/${disabledRecord.id}/state`, {
|
||||
method: "POST",
|
||||
body: { state: "disabled", reason: "Integration disable" },
|
||||
});
|
||||
|
||||
const submission = await member.collection("submissions").create({
|
||||
type: "css",
|
||||
title: "Integration theme",
|
||||
description: "A backend integration fixture",
|
||||
schemaVersion: "forged-version",
|
||||
content: ":root { --homarr-test: #fff; }",
|
||||
author: secondMemberRecord.id,
|
||||
authorName: "Forged",
|
||||
revision: 99,
|
||||
});
|
||||
assert.equal(submission.author, memberRecord.id, "server must enforce ownership");
|
||||
assert.equal(submission.authorName, "Member", "server must snapshot the authenticated author name");
|
||||
assert.equal(submission.revision, 1, "server must own the initial revision");
|
||||
assert.equal(submission.schemaVersion, "homarr-custom-css-v1", "server must own schemaVersion");
|
||||
|
||||
await expectStatus(
|
||||
() => banned.collection("submissions").create({ type: "css", title: "Blocked theme", content: "body {}" }),
|
||||
403,
|
||||
);
|
||||
await expectStatus(() => disabled.collection("votes").create({ submission: submission.id, value: 1 }), [400, 403]);
|
||||
await expectStatus(
|
||||
() =>
|
||||
member.collection("submissions").create({
|
||||
type: "widget",
|
||||
title: "Invalid widget",
|
||||
content: JSON.stringify({ $schema: "homarr-custom-widget-v2", name: "Missing contract" }),
|
||||
}),
|
||||
400,
|
||||
);
|
||||
|
||||
const vote = await member.collection("votes").create({ submission: submission.id, value: 1 });
|
||||
await expectStatus(() => member.collection("votes").create({ submission: submission.id, value: -1 }), 400);
|
||||
assert.equal(
|
||||
(await member.collection("votes").update(vote.id, { submission: "forged", user: "forged", value: -1 })).submission,
|
||||
submission.id,
|
||||
);
|
||||
|
||||
const report = await secondMember.collection("reports").create({
|
||||
submission: submission.id,
|
||||
reporter: memberRecord.id,
|
||||
category: "spam",
|
||||
explanation: "Integration report",
|
||||
status: "dismissed",
|
||||
});
|
||||
assert.equal(report.reporter, secondMemberRecord.id);
|
||||
assert.equal(report.status, "open");
|
||||
assert.equal((await member.collection("reports").getList()).totalItems, 0, "reports must remain private from members");
|
||||
assert.equal((await moderator.collection("reports").getList()).items.length, 1);
|
||||
|
||||
await expectStatus(
|
||||
() =>
|
||||
member.send(`/api/workshop/moderation/users/${secondMemberRecord.id}/state`, {
|
||||
method: "POST",
|
||||
body: { state: "posting_banned", reason: "No permission" },
|
||||
}),
|
||||
403,
|
||||
);
|
||||
await expectStatus(
|
||||
() =>
|
||||
moderator.send(`/api/workshop/moderation/users/${adminRecord.id}/state`, {
|
||||
method: "POST",
|
||||
body: { state: "disabled", reason: "Cannot alter admin" },
|
||||
}),
|
||||
403,
|
||||
);
|
||||
await expectStatus(
|
||||
() =>
|
||||
moderator.send(`/api/workshop/moderation/users/${secondMemberRecord.id}/role`, {
|
||||
method: "POST",
|
||||
body: { role: "moderator", reason: "Cannot promote" },
|
||||
}),
|
||||
403,
|
||||
);
|
||||
|
||||
await admin.send(`/api/workshop/moderation/users/${secondMemberRecord.id}/role`, {
|
||||
method: "POST",
|
||||
body: { role: "moderator", reason: "Integration promotion" },
|
||||
});
|
||||
assert.equal((await root.collection("users").getOne(secondMemberRecord.id)).role, "moderator");
|
||||
|
||||
await moderator.send(`/api/workshop/moderation/reports/${report.id}/resolve`, {
|
||||
method: "POST",
|
||||
body: { status: "resolved", reason: "Integration resolution" },
|
||||
});
|
||||
assert.equal((await root.collection("reports").getOne(report.id)).status, "resolved");
|
||||
|
||||
await moderator.send(`/api/workshop/moderation/submissions/${submission.id}/remove`, {
|
||||
method: "POST",
|
||||
body: { reason: "Integration removal" },
|
||||
});
|
||||
await expectStatus(() => root.collection("submissions").getOne(submission.id), 404);
|
||||
await expectStatus(() => root.collection("votes").getOne(vote.id), 404);
|
||||
await expectStatus(() => root.collection("reports").getOne(report.id), 404);
|
||||
|
||||
const actions = await root.collection("moderation_actions").getFullList({ sort: "created" });
|
||||
assert.deepEqual(
|
||||
actions.map((action) => action.action),
|
||||
["set_account_state", "set_role", "resolve_report", "remove_submission"],
|
||||
);
|
||||
assert(actions.at(-1)?.snapshot.includes("Integration theme"), "removal audit must retain a target snapshot");
|
||||
|
||||
const anonymous = new PocketBase(baseUrl);
|
||||
const listings = await anonymous.collection("workshop_listings").getList();
|
||||
assert.equal(listings.totalItems, 0);
|
||||
await expectStatus(() => anonymous.collection("submissions").getList(), 403);
|
||||
|
||||
console.log("Workshop backend integration matrix passed");
|
||||
@@ -0,0 +1,78 @@
|
||||
# Workshop V2 audit
|
||||
|
||||
## Purpose
|
||||
|
||||
The Workshop is Homarr's central community catalog for custom widgets and CSS themes. It must let people discover, inspect, export, publish, update, report, and install creations from homarr.dev and from Homarr itself without requiring raw copy-and-paste as the default workflow.
|
||||
|
||||
### Feature North Star
|
||||
|
||||
A Homarr user can move from a useful local creation to a safe community submission, or from a community listing to an installed local creation, in one understandable flow with source visibility and an explicit confirmation boundary.
|
||||
|
||||
### Developer-experience North Star
|
||||
|
||||
Workshop behavior is defined once: one typed contract, one client, versioned PocketBase migrations, deterministic local startup, shared validation fixtures, typed failures, and UI components that contain presentation rather than PocketBase queries or authorization rules.
|
||||
|
||||
## Baseline audit
|
||||
|
||||
The abandoned prototype is preserved at `origin/feat/widget-store-prototype-backup` (`89260b875`). This rewrite starts at `dev` commit `ba7718bde`.
|
||||
|
||||
| Area | Severity | Baseline finding | V2 resolution |
|
||||
| ------------- | -------- | ----------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------ |
|
||||
| Branch health | P0 | PR #5984 was based on an obsolete `dev` commit and conflicted with the current branch. | Rebuild from current `dev`; keep the old head on a backup ref. |
|
||||
| Security | P0 | Submission validation ran in browsers and could be bypassed with direct PocketBase writes. | Enforce validation, ownership, account state, immutable fields, and upload limits in PocketBase hooks and rules. |
|
||||
| Moderation | P0 | There were no moderator roles, posting bans, disabled accounts, least-privilege actions, or durable moderation history. | Add roles, account states, explicit moderation routes, a staff UI, and append-only actions. |
|
||||
| Architecture | P1 | Docs and Next.js defined separate record types, queries, mutations, sorting, voting, and error behavior. | Add `@homarr/workshop` as the only public contract and API client. |
|
||||
| Performance | P1 | The browser downloaded every submission and full content, then searched, sorted, and filtered locally. | Server-side pagination/filtering/sorting; summary views exclude content. |
|
||||
| Product | P1 | Website and app flows were disconnected and sharing still depended on manual content transfer. | Add contextual install and direct share actions backed by canonical exports. |
|
||||
| UX/UI | P1 | A large bespoke Docusaurus component set and a separate Mantine modal produced inconsistent interaction and state behavior. | Use each host's native components with shared flows, copy rules, contracts, and states. |
|
||||
| Accessibility | P1 | Several custom interactive elements bypassed semantic controls and touch targets; focus and reduced-motion behavior was incomplete. | Require semantic controls, visible focus, keyboard operation, 44px touch targets, AA contrast, and reduced motion. |
|
||||
| Operations | P1 | A third-party PocketBase image and incomplete bootstrap docs made builds and recovery non-deterministic. | Build a pinned official binary with checksum verification; document health, backup, restore, OAuth, and upgrades. |
|
||||
| Testing | P1 | Tests covered a narrow client schema and did not exercise API rules, roles, auth states, moderation, or cross-surface flows. | Add contract, backend permission-matrix, UI, and end-to-end coverage. |
|
||||
| Documentation | P2 | Documentation advertised a supported self-hosted Workshop even though V2 is a central Homarr service. | Document the central service and local-development overrides only. |
|
||||
|
||||
## Decisions
|
||||
|
||||
- Public name: **Workshop**.
|
||||
- Launch content: custom widgets (`homarr-custom-widget-v2`) and CSS (`homarr-custom-css-v1`).
|
||||
- Identity: GitHub OAuth only.
|
||||
- Community features: votes and reports; comments are removed.
|
||||
- Hosting: one Homarr-operated service at `store.homarr.dev`; overrides are development-only.
|
||||
- Publishing: immediate, with source inspection, reporting, and staff removal.
|
||||
- Staff: GitHub-authenticated moderators/admins use `/workshop/admin`; PocketBase superusers are bootstrap/emergency access.
|
||||
|
||||
## Launch acceptance
|
||||
|
||||
- [x] Anonymous users can browse, search, filter, inspect, copy, and download without receiving full content in listing responses.
|
||||
- [x] Members can publish, update, delete, vote, and report; ownership and schemas are enforced server-side.
|
||||
- [x] Posting-banned users cannot create or update submissions but can browse, vote, and report.
|
||||
- [x] Disabled accounts cannot authenticate or write, including with an existing token.
|
||||
- [x] Moderators can resolve reports, remove submissions, posting-ban users, and disable/restore non-admin users.
|
||||
- [x] Only admins can change roles; staff actions are append-only and attributable.
|
||||
- [x] Homarr can export/share local widgets and CSS and validate/confirm Workshop installs.
|
||||
- [x] Website and app implement loading, empty, offline, validation, permission, and destructive-action states.
|
||||
- [x] Keyboard semantics, mobile layout, dark mode, reduced motion, and horizontal overflow received a browser smoke test.
|
||||
- [x] Clean PocketBase startup, migration, health, backup, and restore procedures are reproducible and documented.
|
||||
- [ ] Add automated authenticated cross-surface E2E coverage before changing the PR from draft to ready.
|
||||
|
||||
## Deferred after V2
|
||||
|
||||
Comments, private Workshop deployments, automated approval queues, follows, collections, notifications, arbitrary package types, historical version installation, and personalized recommendations.
|
||||
|
||||
## Exit scorecard
|
||||
|
||||
The baseline has three P0, seven P1, and one P2 findings. The rewrite closes every product, security, architecture, moderation, performance, operations, documentation, UX, and DX finding. One P1 release-verification item remains: automated authenticated E2E across both public and in-app surfaces. Keep PR #5984 in draft until it lands.
|
||||
|
||||
| Gate | Result | Evidence |
|
||||
| --------------------------- | ------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| Branch ancestry | Pass | Rebuilt from `dev` at `ba7718bde`; prototype preserved at `origin/feat/widget-store-prototype-backup`. |
|
||||
| Contract | Pass | Five Vitest cases cover widget/CSS fixtures, filenames, limits, screenshot validation, and round trips. |
|
||||
| Backend | Pass | Disposable PocketBase integration matrix covers roles, account states, ownership forgery, schema rejection, duplicate votes, private reports, escalation, deletion cascades, and audit snapshots. |
|
||||
| Backend operations | Pass | PocketBase 0.39.6 image builds with checksum verification; clean migration, health, and listing/detail access boundaries pass. |
|
||||
| Type safety | Pass | Scoped Turbo typecheck passes for Workshop, Docs, and Next.js. |
|
||||
| Lint | Pass | Scoped lint exits clean; remaining warnings predate the rewrite. Workshop has zero warnings. |
|
||||
| Format | Pass | Every changed supported file passes `oxfmt --check`; repository-wide Docs formatting still reports pre-existing drift. |
|
||||
| Production builds | Pass | Docusaurus and Next.js production builds pass under Node 24.18.0. |
|
||||
| Browser smoke | Pass | Desktop and 390px mobile discovery render without console errors or horizontal overflow; semantic labels, skip link, dark mode, and reduced motion are active. |
|
||||
| Automated cross-surface E2E | Open P1 | Authenticated website-to-Homarr publish/install and moderator flows still need browser automation. |
|
||||
|
||||
Final implementation scores (10 is best): security 9, architecture/DX 9, moderation 9, performance 9, responsive UI 9, theming 9, accessibility 8, automated testing 7. Accessibility remains below 9 until authenticated dialogs and destructive flows receive a full keyboard/screen-reader pass.
|
||||
@@ -21,6 +21,7 @@
|
||||
"db:studio": "pnpm -F db studio",
|
||||
"dev": "pnpm -F nextjs dev",
|
||||
"dev:docs": "pnpm -F @homarr/docs dev",
|
||||
"dev:workshop": "docker compose -f apps/store/docker-compose.yml up --build",
|
||||
"docker:dev": "docker compose -f ./development/development.docker-compose.yml up",
|
||||
"format": "turbo format --continue -- --check",
|
||||
"format:fix": "turbo format --continue",
|
||||
@@ -33,6 +34,7 @@
|
||||
"start": "concurrently \"pnpm with-env node apps/tasks/tasks.cjs\" \"pnpm with-env node apps/websocket/wssServer.cjs\" \"pnpm -F nextjs start\"",
|
||||
"test": "cross-env NODE_ENV=development CI=true vitest run --exclude e2e --coverage.enabled ",
|
||||
"test:e2e": "cross-env NODE_ENV=development CI=true vitest e2e",
|
||||
"test:workshop-backend": "bash apps/store/tests/run-integration.sh",
|
||||
"test:ui": "cross-env NODE_ENV=development CI=true vitest --exclude e2e --ui --coverage.enabled",
|
||||
"typecheck": "turbo typecheck",
|
||||
"with-env": "dotenv -e .env --"
|
||||
|
||||
@@ -4454,6 +4454,61 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"workshop": {
|
||||
"title": "Workshop",
|
||||
"loading": "Loading submission",
|
||||
"by": "By {name} · revision {revision}",
|
||||
"type": {
|
||||
"widget": "Widget",
|
||||
"css": "CSS theme"
|
||||
},
|
||||
"search": {
|
||||
"label": "Search",
|
||||
"placeholder": "Search community creations"
|
||||
},
|
||||
"filter": {
|
||||
"type": "Type",
|
||||
"sort": "Sort",
|
||||
"all": "All",
|
||||
"widgets": "Widgets",
|
||||
"css": "CSS themes",
|
||||
"top": "Top rated",
|
||||
"newest": "Newest"
|
||||
},
|
||||
"safety": {
|
||||
"title": "Community content",
|
||||
"description": "Workshop content is provided by community members. Inspect it before installing and keep a current backup.",
|
||||
"inspect": "Review this source before using it. Widget endpoints and CSS can change how your instance behaves."
|
||||
},
|
||||
"empty": {
|
||||
"title": "No creations found",
|
||||
"description": "Adjust the filters or check back after the community has shared more."
|
||||
},
|
||||
"error": {
|
||||
"loadTitle": "Workshop could not load",
|
||||
"publish": "The creation could not be published",
|
||||
"install": "The creation could not be used",
|
||||
"wrongType": "This creation does not match the requested type"
|
||||
},
|
||||
"publish": {
|
||||
"title": "Share in Workshop",
|
||||
"name": "Title",
|
||||
"description": "Description",
|
||||
"changelog": "Changelog"
|
||||
},
|
||||
"action": {
|
||||
"retry": "Try again",
|
||||
"inspect": "Inspect source",
|
||||
"download": "Download",
|
||||
"website": "Open website",
|
||||
"use": "Use creation",
|
||||
"share": "Share in Workshop",
|
||||
"cancel": "Cancel",
|
||||
"publish": "Publish creation",
|
||||
"install": "Install widget",
|
||||
"useCss": "Use CSS"
|
||||
}
|
||||
},
|
||||
"management": {
|
||||
"metaTitle": "Management",
|
||||
"title": {
|
||||
@@ -4477,6 +4532,7 @@
|
||||
"apps": "Apps",
|
||||
"integrations": "Integrations",
|
||||
"customWidgets": "Custom Widgets",
|
||||
"workshop": "Workshop",
|
||||
"searchEngies": "Search engines",
|
||||
"medias": "Medias",
|
||||
"users": {
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
dist/
|
||||
@@ -0,0 +1,46 @@
|
||||
{
|
||||
"name": "@homarr/workshop",
|
||||
"version": "0.1.0",
|
||||
"private": true,
|
||||
"license": "Apache-2.0",
|
||||
"type": "module",
|
||||
"typesVersions": {
|
||||
"*": {
|
||||
"*": [
|
||||
"src/*"
|
||||
]
|
||||
}
|
||||
},
|
||||
"exports": {
|
||||
".": {
|
||||
"types": "./src/index.ts",
|
||||
"import": "./dist/index.js"
|
||||
},
|
||||
"./client": {
|
||||
"types": "./src/client.ts",
|
||||
"import": "./dist/client.js"
|
||||
},
|
||||
"./schema": {
|
||||
"types": "./src/schema.ts",
|
||||
"import": "./dist/schema.js"
|
||||
}
|
||||
},
|
||||
"scripts": {
|
||||
"build": "esbuild src/index.ts src/client.ts src/schema.ts --bundle --format=esm --platform=browser --external:pocketbase --external:zod --external:zod/* --outdir=dist",
|
||||
"clean": "rm -rf .turbo node_modules",
|
||||
"format": "oxfmt .",
|
||||
"lint": "oxlint",
|
||||
"postinstall": "pnpm build",
|
||||
"typecheck": "tsc --noEmit"
|
||||
},
|
||||
"dependencies": {
|
||||
"@homarr/validation": "workspace:^0.1.0",
|
||||
"pocketbase": "catalog:",
|
||||
"zod": "catalog:"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@homarr/tsconfig": "workspace:^0.1.0",
|
||||
"esbuild": "catalog:",
|
||||
"typescript": "catalog:"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,289 @@
|
||||
import PocketBase, { ClientResponseError } from "pocketbase";
|
||||
|
||||
import type {
|
||||
WorkshopAccountState,
|
||||
WorkshopModerationAction,
|
||||
WorkshopReport,
|
||||
WorkshopRole,
|
||||
WorkshopSubmissionDetail,
|
||||
WorkshopSubmissionInput,
|
||||
WorkshopSubmissionSummary,
|
||||
WorkshopSubmissionType,
|
||||
WorkshopUser,
|
||||
WorkshopVote,
|
||||
} from "./schema";
|
||||
|
||||
import {
|
||||
WORKSHOP_API_URL,
|
||||
WorkshopError,
|
||||
schemaVersionForType,
|
||||
workshopAccountStateSchema,
|
||||
workshopErrorCodeSchema,
|
||||
workshopModerationActionSchema,
|
||||
workshopReportSchema,
|
||||
workshopRoleSchema,
|
||||
workshopSubmissionDetailSchema,
|
||||
workshopSubmissionInputSchema,
|
||||
workshopSubmissionSummarySchema,
|
||||
workshopScreenshotsSchema,
|
||||
workshopUserSchema,
|
||||
workshopVoteSchema,
|
||||
} from "./schema";
|
||||
|
||||
export interface WorkshopListOptions {
|
||||
page?: number;
|
||||
perPage?: number;
|
||||
type?: WorkshopSubmissionType | "all";
|
||||
sort?: "top" | "newest";
|
||||
search?: string;
|
||||
author?: string;
|
||||
}
|
||||
|
||||
export interface WorkshopPage<T> {
|
||||
page: number;
|
||||
perPage: number;
|
||||
totalPages: number;
|
||||
totalItems: number;
|
||||
items: T[];
|
||||
}
|
||||
|
||||
const errorCodeForStatus = (status: number) => {
|
||||
if (status === 401) return "authentication_required" as const;
|
||||
if (status === 403) return "forbidden" as const;
|
||||
if (status === 404) return "not_found" as const;
|
||||
if (status === 409) return "conflict" as const;
|
||||
if (status === 429) return "rate_limited" as const;
|
||||
if (status >= 500) return "unavailable" as const;
|
||||
return "unknown" as const;
|
||||
};
|
||||
|
||||
const asWorkshopError = (error: unknown, fallback: string) => {
|
||||
if (error instanceof WorkshopError) return error;
|
||||
if (error instanceof ClientResponseError) {
|
||||
const message = error.data?.message || error.message || fallback;
|
||||
const messageCode =
|
||||
message === "Account is disabled"
|
||||
? "account_disabled"
|
||||
: message === "Account cannot publish submissions"
|
||||
? "posting_banned"
|
||||
: undefined;
|
||||
const parsedCode = workshopErrorCodeSchema.safeParse(messageCode ?? error.data?.data?.code ?? error.data?.code);
|
||||
const code = parsedCode.success ? parsedCode.data : errorCodeForStatus(error.status);
|
||||
return new WorkshopError(code, message, error.status);
|
||||
}
|
||||
return new WorkshopError("unknown", error instanceof Error ? error.message : fallback);
|
||||
};
|
||||
|
||||
export class WorkshopClient {
|
||||
readonly pocketBase: PocketBase;
|
||||
|
||||
constructor(readonly baseUrl = WORKSHOP_API_URL) {
|
||||
this.pocketBase = new PocketBase(baseUrl.replace(/\/$/, ""));
|
||||
}
|
||||
|
||||
get currentUser(): WorkshopUser | null {
|
||||
const record = this.pocketBase.authStore.record;
|
||||
if (!record) return null;
|
||||
return workshopUserSchema.parse({
|
||||
id: record.id,
|
||||
displayName: record.displayName || record.name || record.username || "Community member",
|
||||
avatarUrl: record.avatarUrl || record.avatar || "",
|
||||
role: workshopRoleSchema.catch("member").parse(record.role),
|
||||
state: workshopAccountStateSchema.catch("active").parse(record.state),
|
||||
});
|
||||
}
|
||||
|
||||
subscribeToAuth(listener: (user: WorkshopUser | null) => void) {
|
||||
return this.pocketBase.authStore.onChange(() => listener(this.currentUser), true);
|
||||
}
|
||||
|
||||
async refreshAuth() {
|
||||
if (!this.pocketBase.authStore.isValid) return null;
|
||||
try {
|
||||
await this.pocketBase.collection("users").authRefresh();
|
||||
return this.currentUser;
|
||||
} catch {
|
||||
this.pocketBase.authStore.clear();
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
async signInWithGitHub() {
|
||||
try {
|
||||
await this.pocketBase.collection("users").authWithOAuth2({ provider: "github" });
|
||||
return this.currentUser;
|
||||
} catch (error) {
|
||||
throw asWorkshopError(error, "GitHub sign-in failed");
|
||||
}
|
||||
}
|
||||
|
||||
signOut() {
|
||||
this.pocketBase.authStore.clear();
|
||||
}
|
||||
|
||||
async list(options: WorkshopListOptions = {}): Promise<WorkshopPage<WorkshopSubmissionSummary>> {
|
||||
const filters: string[] = [];
|
||||
if (options.type && options.type !== "all")
|
||||
filters.push(this.pocketBase.filter("type = {:type}", { type: options.type }));
|
||||
if (options.search?.trim())
|
||||
filters.push(this.pocketBase.filter("title ~ {:search}", { search: options.search.trim() }));
|
||||
if (options.author) filters.push(this.pocketBase.filter("author = {:author}", { author: options.author }));
|
||||
try {
|
||||
const result = await this.pocketBase
|
||||
.collection("workshop_listings")
|
||||
.getList(options.page ?? 1, options.perPage ?? 24, {
|
||||
filter: filters.join(" && "),
|
||||
sort: options.sort === "newest" ? "-created" : "-score,-created",
|
||||
});
|
||||
return { ...result, items: result.items.map((item) => workshopSubmissionSummarySchema.parse(item)) };
|
||||
} catch (error) {
|
||||
throw asWorkshopError(error, "Failed to load Workshop submissions");
|
||||
}
|
||||
}
|
||||
|
||||
async get(id: string): Promise<WorkshopSubmissionDetail> {
|
||||
try {
|
||||
return workshopSubmissionDetailSchema.parse(await this.pocketBase.collection("submissions").getOne(id));
|
||||
} catch (error) {
|
||||
throw asWorkshopError(error, "Submission not found");
|
||||
}
|
||||
}
|
||||
|
||||
fileUrl(submissionId: string, filename: string, thumb?: string) {
|
||||
const query = thumb ? `?thumb=${encodeURIComponent(thumb)}` : "";
|
||||
return `${this.baseUrl}/api/files/submissions/${submissionId}/${encodeURIComponent(filename)}${query}`;
|
||||
}
|
||||
|
||||
async create(input: WorkshopSubmissionInput, screenshots: File[] = []) {
|
||||
const parsed = workshopSubmissionInputSchema.parse(input);
|
||||
workshopScreenshotsSchema.parse(screenshots);
|
||||
const data = new FormData();
|
||||
Object.entries(parsed).forEach(([key, value]) => data.set(key, value));
|
||||
data.set("schemaVersion", schemaVersionForType(parsed.type));
|
||||
screenshots.forEach((file) => data.append("screenshots", file));
|
||||
try {
|
||||
const result = await this.pocketBase.collection("submissions").create(data);
|
||||
return this.get(result.id);
|
||||
} catch (error) {
|
||||
throw asWorkshopError(error, "Failed to publish submission");
|
||||
}
|
||||
}
|
||||
|
||||
async update(
|
||||
id: string,
|
||||
input: WorkshopSubmissionInput,
|
||||
screenshots: File[] = [],
|
||||
removedScreenshots: string[] = [],
|
||||
) {
|
||||
const parsed = workshopSubmissionInputSchema.parse(input);
|
||||
workshopScreenshotsSchema.parse(screenshots);
|
||||
const data = new FormData();
|
||||
Object.entries(parsed).forEach(([key, value]) => data.set(key, value));
|
||||
screenshots.forEach((file) => data.append("screenshots+", file));
|
||||
removedScreenshots.forEach((filename) => data.append("screenshots-", filename));
|
||||
try {
|
||||
await this.pocketBase.collection("submissions").update(id, data);
|
||||
return this.get(id);
|
||||
} catch (error) {
|
||||
throw asWorkshopError(error, "Failed to update submission");
|
||||
}
|
||||
}
|
||||
|
||||
async delete(id: string) {
|
||||
try {
|
||||
await this.pocketBase.collection("submissions").delete(id);
|
||||
} catch (error) {
|
||||
throw asWorkshopError(error, "Failed to delete submission");
|
||||
}
|
||||
}
|
||||
|
||||
async vote(submissionId: string, value: 1 | -1): Promise<WorkshopVote | null> {
|
||||
const filter = this.pocketBase.filter("submission = {:submission} && user = {:user}", {
|
||||
submission: submissionId,
|
||||
user: this.currentUser?.id ?? "",
|
||||
});
|
||||
try {
|
||||
const existing = await this.pocketBase
|
||||
.collection("votes")
|
||||
.getFirstListItem(filter)
|
||||
.catch((error: unknown) => {
|
||||
if (error instanceof ClientResponseError && error.status === 404) return null;
|
||||
throw error;
|
||||
});
|
||||
if (existing?.value === value) {
|
||||
await this.pocketBase.collection("votes").delete(existing.id);
|
||||
return null;
|
||||
}
|
||||
const record = existing
|
||||
? await this.pocketBase.collection("votes").update(existing.id, { value })
|
||||
: await this.pocketBase.collection("votes").create({ submission: submissionId, value });
|
||||
return workshopVoteSchema.parse(record);
|
||||
} catch (error) {
|
||||
throw asWorkshopError(error, "Failed to save vote");
|
||||
}
|
||||
}
|
||||
|
||||
async report(submission: string, category: WorkshopReport["category"], explanation: string) {
|
||||
try {
|
||||
return workshopReportSchema.parse(
|
||||
await this.pocketBase.collection("reports").create({ submission, category, explanation }),
|
||||
);
|
||||
} catch (error) {
|
||||
throw asWorkshopError(error, "Failed to submit report");
|
||||
}
|
||||
}
|
||||
|
||||
async moderate(path: string, body: Record<string, unknown>) {
|
||||
try {
|
||||
return await this.pocketBase.send(`/api/workshop/moderation/${path}`, { method: "POST", body });
|
||||
} catch (error) {
|
||||
throw asWorkshopError(error, "Moderation action failed");
|
||||
}
|
||||
}
|
||||
|
||||
removeSubmission(id: string, reason: string) {
|
||||
return this.moderate(`submissions/${id}/remove`, { reason });
|
||||
}
|
||||
|
||||
updateAccountState(id: string, state: WorkshopAccountState, reason: string) {
|
||||
return this.moderate(`users/${id}/state`, { state, reason });
|
||||
}
|
||||
|
||||
updateRole(id: string, role: WorkshopRole, reason: string) {
|
||||
return this.moderate(`users/${id}/role`, { role, reason });
|
||||
}
|
||||
|
||||
resolveReport(id: string, status: "resolved" | "dismissed", reason: string) {
|
||||
return this.moderate(`reports/${id}/resolve`, { status, reason });
|
||||
}
|
||||
|
||||
async listReports(status: "open" | "resolved" | "dismissed" = "open") {
|
||||
try {
|
||||
const result = await this.pocketBase.collection("reports").getList(1, 100, {
|
||||
filter: this.pocketBase.filter("status = {:status}", { status }),
|
||||
sort: "-created",
|
||||
});
|
||||
return result.items.map((item) => workshopReportSchema.parse(item));
|
||||
} catch (error) {
|
||||
throw asWorkshopError(error, "Failed to load reports");
|
||||
}
|
||||
}
|
||||
|
||||
async listUsers() {
|
||||
try {
|
||||
const result = await this.pocketBase.collection("users").getList(1, 200, { sort: "displayName" });
|
||||
return result.items.map((item) => workshopUserSchema.parse(item));
|
||||
} catch (error) {
|
||||
throw asWorkshopError(error, "Failed to load users");
|
||||
}
|
||||
}
|
||||
|
||||
async listModerationActions(): Promise<WorkshopModerationAction[]> {
|
||||
try {
|
||||
const result = await this.pocketBase.collection("moderation_actions").getList(1, 100, { sort: "-created" });
|
||||
return result.items.map((item) => workshopModerationActionSchema.parse(item));
|
||||
} catch (error) {
|
||||
throw asWorkshopError(error, "Failed to load moderation history");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
export * from "./client";
|
||||
export * from "./schema";
|
||||
@@ -0,0 +1,67 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import {
|
||||
CSS_SCHEMA_VERSION,
|
||||
WIDGET_SCHEMA_VERSION,
|
||||
schemaVersionForType,
|
||||
validateWorkshopContent,
|
||||
workshopExportFilename,
|
||||
workshopScreenshotsSchema,
|
||||
workshopSubmissionInputSchema,
|
||||
} from "./schema";
|
||||
|
||||
const validWidget = {
|
||||
$schema: WIDGET_SCHEMA_VERSION,
|
||||
name: "Status",
|
||||
url: "https://example.com/api/status",
|
||||
authType: "none",
|
||||
method: "GET",
|
||||
displayType: "singleValue",
|
||||
displayConfig: {
|
||||
type: "singleValue",
|
||||
jsonPath: "$.value",
|
||||
label: "Status",
|
||||
unit: "",
|
||||
valueSize: "md",
|
||||
labelPosition: "above",
|
||||
},
|
||||
};
|
||||
|
||||
describe("Workshop contracts", () => {
|
||||
it("accepts the canonical custom widget export", () => {
|
||||
expect(validateWorkshopContent("widget", JSON.stringify(validWidget)).success).toBe(true);
|
||||
});
|
||||
|
||||
it("rejects missing and mismatched widget schemas", () => {
|
||||
const { $schema: _, ...missing } = validWidget;
|
||||
expect(validateWorkshopContent("widget", JSON.stringify(missing)).success).toBe(false);
|
||||
expect(
|
||||
validateWorkshopContent("widget", JSON.stringify({ ...validWidget, displayConfig: { type: "raw" } })).success,
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it("enforces CSS and input limits", () => {
|
||||
expect(validateWorkshopContent("css", " ").success).toBe(false);
|
||||
expect(
|
||||
workshopSubmissionInputSchema.safeParse({
|
||||
type: "css",
|
||||
title: "Theme",
|
||||
description: "",
|
||||
content: "body { color: red; }",
|
||||
changelog: "",
|
||||
}).success,
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it("returns stable schema versions and safe filenames", () => {
|
||||
expect(schemaVersionForType("widget")).toBe(WIDGET_SCHEMA_VERSION);
|
||||
expect(schemaVersionForType("css")).toBe(CSS_SCHEMA_VERSION);
|
||||
expect(workshopExportFilename("My / Theme!", "css")).toBe("my-theme.css");
|
||||
});
|
||||
|
||||
it("validates screenshot count, MIME type, and size", () => {
|
||||
expect(workshopScreenshotsSchema.safeParse([{ size: 1024, type: "image/png" }]).success).toBe(true);
|
||||
expect(workshopScreenshotsSchema.safeParse([{ size: 1024, type: "image/svg+xml" }]).success).toBe(false);
|
||||
expect(workshopScreenshotsSchema.safeParse([{ size: 6 * 1024 * 1024, type: "image/webp" }]).success).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,175 @@
|
||||
import { customWidgetImportSchema } from "@homarr/validation/custom-widget";
|
||||
import { z } from "zod";
|
||||
|
||||
export const WORKSHOP_API_URL = "https://store.homarr.dev";
|
||||
export const WORKSHOP_WEB_URL = "https://homarr.dev/workshop";
|
||||
export const WIDGET_SCHEMA_VERSION = "homarr-custom-widget-v2";
|
||||
export const CSS_SCHEMA_VERSION = "homarr-custom-css-v1";
|
||||
export const MAX_CSS_LENGTH = 16_384;
|
||||
export const MAX_CONTENT_LENGTH = 1_000_000;
|
||||
export const MAX_SCREENSHOTS = 5;
|
||||
export const MAX_SCREENSHOT_BYTES = 5 * 1024 * 1024;
|
||||
export const WORKSHOP_SCREENSHOT_MIME_TYPES = ["image/png", "image/jpeg", "image/webp"] as const;
|
||||
|
||||
export const workshopSubmissionTypeSchema = z.enum(["widget", "css"]);
|
||||
export type WorkshopSubmissionType = z.infer<typeof workshopSubmissionTypeSchema>;
|
||||
|
||||
export const workshopRoleSchema = z.enum(["member", "moderator", "admin"]);
|
||||
export type WorkshopRole = z.infer<typeof workshopRoleSchema>;
|
||||
|
||||
export const workshopAccountStateSchema = z.enum(["active", "posting_banned", "disabled"]);
|
||||
export type WorkshopAccountState = z.infer<typeof workshopAccountStateSchema>;
|
||||
|
||||
export const workshopReportCategorySchema = z.enum(["malicious", "spam", "copyright", "inappropriate", "other"]);
|
||||
export const workshopReportStatusSchema = z.enum(["open", "resolved", "dismissed"]);
|
||||
|
||||
export const workshopUserSchema = z.object({
|
||||
id: z.string(),
|
||||
displayName: z.string(),
|
||||
avatarUrl: z.string().optional().default(""),
|
||||
role: workshopRoleSchema,
|
||||
state: workshopAccountStateSchema,
|
||||
});
|
||||
export type WorkshopUser = z.infer<typeof workshopUserSchema>;
|
||||
|
||||
const submissionBaseSchema = z.object({
|
||||
id: z.string(),
|
||||
type: workshopSubmissionTypeSchema,
|
||||
title: z.string(),
|
||||
description: z.string().default(""),
|
||||
schemaVersion: z.string(),
|
||||
screenshots: z.array(z.string()).default([]),
|
||||
revision: z.number().int().positive(),
|
||||
changelog: z.string().default(""),
|
||||
author: z.string(),
|
||||
authorName: z.string(),
|
||||
score: z.number().int(),
|
||||
upvotes: z.number().int(),
|
||||
downvotes: z.number().int(),
|
||||
created: z.string(),
|
||||
updated: z.string(),
|
||||
});
|
||||
|
||||
export const workshopSubmissionSummarySchema = submissionBaseSchema;
|
||||
export type WorkshopSubmissionSummary = z.infer<typeof workshopSubmissionSummarySchema>;
|
||||
|
||||
export const workshopSubmissionDetailSchema = submissionBaseSchema.extend({ content: z.string() });
|
||||
export type WorkshopSubmissionDetail = z.infer<typeof workshopSubmissionDetailSchema>;
|
||||
|
||||
export const workshopSubmissionInputSchema = z
|
||||
.object({
|
||||
type: workshopSubmissionTypeSchema,
|
||||
title: z.string().trim().min(3).max(100),
|
||||
description: z.string().trim().max(2_000).default(""),
|
||||
content: z.string().min(1).max(MAX_CONTENT_LENGTH),
|
||||
changelog: z.string().trim().max(2_000).default(""),
|
||||
})
|
||||
.superRefine((input, context) => {
|
||||
const result = validateWorkshopContent(input.type, input.content);
|
||||
if (!result.success) context.addIssue({ code: "custom", message: result.error, path: ["content"] });
|
||||
});
|
||||
export type WorkshopSubmissionInput = z.infer<typeof workshopSubmissionInputSchema>;
|
||||
|
||||
export const workshopScreenshotSchema = z.object({
|
||||
size: z.number().max(MAX_SCREENSHOT_BYTES, "Each screenshot must be at most 5 MiB"),
|
||||
type: z.enum(WORKSHOP_SCREENSHOT_MIME_TYPES, { error: "Screenshots must be PNG, JPEG, or WebP" }),
|
||||
});
|
||||
export const workshopScreenshotsSchema = z.array(workshopScreenshotSchema).max(MAX_SCREENSHOTS);
|
||||
|
||||
export const workshopVoteSchema = z.object({
|
||||
id: z.string(),
|
||||
submission: z.string(),
|
||||
user: z.string(),
|
||||
value: z.union([z.literal(1), z.literal(-1)]),
|
||||
});
|
||||
export type WorkshopVote = z.infer<typeof workshopVoteSchema>;
|
||||
|
||||
export const workshopReportSchema = z.object({
|
||||
id: z.string(),
|
||||
submission: z.string(),
|
||||
reporter: z.string(),
|
||||
category: workshopReportCategorySchema,
|
||||
explanation: z.string(),
|
||||
status: workshopReportStatusSchema,
|
||||
created: z.string(),
|
||||
updated: z.string(),
|
||||
});
|
||||
export type WorkshopReport = z.infer<typeof workshopReportSchema>;
|
||||
|
||||
export const workshopModerationActionSchema = z.object({
|
||||
id: z.string(),
|
||||
actor: z.string(),
|
||||
action: z.enum(["remove_submission", "set_account_state", "set_role", "resolve_report"]),
|
||||
targetType: z.string(),
|
||||
targetId: z.string(),
|
||||
reason: z.string(),
|
||||
snapshot: z.string().default(""),
|
||||
created: z.string(),
|
||||
});
|
||||
export type WorkshopModerationAction = z.infer<typeof workshopModerationActionSchema>;
|
||||
|
||||
export const workshopErrorCodeSchema = z.enum([
|
||||
"authentication_required",
|
||||
"account_disabled",
|
||||
"posting_banned",
|
||||
"forbidden",
|
||||
"not_found",
|
||||
"invalid_submission",
|
||||
"conflict",
|
||||
"rate_limited",
|
||||
"unavailable",
|
||||
"unknown",
|
||||
]);
|
||||
export type WorkshopErrorCode = z.infer<typeof workshopErrorCodeSchema>;
|
||||
|
||||
export class WorkshopError extends Error {
|
||||
constructor(
|
||||
public readonly code: WorkshopErrorCode,
|
||||
message: string,
|
||||
public readonly status?: number,
|
||||
) {
|
||||
super(message);
|
||||
this.name = "WorkshopError";
|
||||
}
|
||||
}
|
||||
|
||||
export type WorkshopValidationResult = { success: true; data: unknown } | { success: false; error: string };
|
||||
|
||||
export function validateWorkshopContent(type: WorkshopSubmissionType, content: string): WorkshopValidationResult {
|
||||
if (type === "css") {
|
||||
if (!content.trim()) return { success: false, error: "CSS cannot be empty" };
|
||||
if (content.length > MAX_CSS_LENGTH) {
|
||||
return { success: false, error: `CSS must be at most ${MAX_CSS_LENGTH} characters` };
|
||||
}
|
||||
return { success: true, data: content };
|
||||
}
|
||||
|
||||
try {
|
||||
const parsed: unknown = JSON.parse(content);
|
||||
const result = customWidgetImportSchema.safeParse(parsed);
|
||||
if (!result.success || result.data.$schema !== WIDGET_SCHEMA_VERSION) {
|
||||
return {
|
||||
success: false,
|
||||
error: result.success
|
||||
? "Widget schema identifier is required"
|
||||
: (result.error.issues[0]?.message ?? "Invalid widget"),
|
||||
};
|
||||
}
|
||||
return { success: true, data: result.data };
|
||||
} catch {
|
||||
return { success: false, error: "Widget content is not valid JSON" };
|
||||
}
|
||||
}
|
||||
|
||||
export const schemaVersionForType = (type: WorkshopSubmissionType) =>
|
||||
type === "widget" ? WIDGET_SCHEMA_VERSION : CSS_SCHEMA_VERSION;
|
||||
|
||||
export function workshopExportFilename(title: string, type: WorkshopSubmissionType) {
|
||||
const safeTitle =
|
||||
title
|
||||
.trim()
|
||||
.toLowerCase()
|
||||
.replace(/[^a-z0-9_-]+/g, "-")
|
||||
.replace(/^-+|-+$/g, "") || "homarr-workshop";
|
||||
return `${safeTitle}.${type === "widget" ? "json" : "css"}`;
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
{
|
||||
"extends": "@homarr/tsconfig/base.json",
|
||||
"compilerOptions": {
|
||||
"tsBuildInfoFile": "node_modules/.cache/tsbuildinfo.json"
|
||||
},
|
||||
"include": ["*.ts", "src"],
|
||||
"exclude": ["node_modules"]
|
||||
}
|
||||
Generated
+36
@@ -438,6 +438,9 @@ catalogs:
|
||||
pg:
|
||||
specifier: ^8.22.0
|
||||
version: 8.22.0
|
||||
pocketbase:
|
||||
specifier: ^0.26.2
|
||||
version: 0.26.9
|
||||
postcss-preset-mantine:
|
||||
specifier: ^1.18.0
|
||||
version: 1.18.0
|
||||
@@ -694,6 +697,9 @@ importers:
|
||||
'@homarr/definitions':
|
||||
specifier: workspace:^
|
||||
version: link:../../packages/definitions
|
||||
'@homarr/workshop':
|
||||
specifier: workspace:^0.1.0
|
||||
version: link:../../packages/workshop
|
||||
'@mdx-js/react':
|
||||
specifier: ^3.1.1
|
||||
version: 3.1.1(@types/react@19.2.17)(react@19.2.7)
|
||||
@@ -890,6 +896,9 @@ importers:
|
||||
'@homarr/widgets':
|
||||
specifier: workspace:^0.1.0
|
||||
version: link:../../packages/widgets
|
||||
'@homarr/workshop':
|
||||
specifier: workspace:^0.1.0
|
||||
version: link:../../packages/workshop
|
||||
'@mantine/colors-generator':
|
||||
specifier: 'catalog:'
|
||||
version: 9.4.1(chroma-js@3.2.0)
|
||||
@@ -2700,6 +2709,28 @@ importers:
|
||||
specifier: 'catalog:'
|
||||
version: 6.0.3
|
||||
|
||||
packages/workshop:
|
||||
dependencies:
|
||||
'@homarr/validation':
|
||||
specifier: workspace:^0.1.0
|
||||
version: link:../validation
|
||||
pocketbase:
|
||||
specifier: 'catalog:'
|
||||
version: 0.26.9
|
||||
zod:
|
||||
specifier: 'catalog:'
|
||||
version: 4.4.3
|
||||
devDependencies:
|
||||
'@homarr/tsconfig':
|
||||
specifier: workspace:^0.1.0
|
||||
version: link:../../tooling/typescript
|
||||
esbuild:
|
||||
specifier: 'catalog:'
|
||||
version: 0.28.0
|
||||
typescript:
|
||||
specifier: 'catalog:'
|
||||
version: 6.0.3
|
||||
|
||||
tooling/github: {}
|
||||
|
||||
tooling/typescript: {}
|
||||
@@ -12680,6 +12711,9 @@ packages:
|
||||
po-parser@2.1.1:
|
||||
resolution: {integrity: sha512-ECF4zHLbUItpUgE3OTtLKlPjeBN+fKEczj2zYjDfCGOzicNs0GK3Vg2IoAYwx7LH/XYw43fZQP6xnZ4TkNxSLQ==}
|
||||
|
||||
pocketbase@0.26.9:
|
||||
resolution: {integrity: sha512-Tiv1/hNuUzRdvT0d8hF03dfzuefQ1WdSRp1A1q3wzFC/WYhQcbU/Qlaubl/3ZDo6xvFXBS8JAgBS/L+ms7nkVQ==}
|
||||
|
||||
points-on-curve@0.2.0:
|
||||
resolution: {integrity: sha512-0mYKnYYe9ZcqMCWhUjItv/oHjvgEsfKvnUTg8sAtnHr3GVy7rGkXCb6d5cSyqrWqL4k81b9CPg3urd+T7aop3A==}
|
||||
|
||||
@@ -27001,6 +27035,8 @@ snapshots:
|
||||
|
||||
po-parser@2.1.1: {}
|
||||
|
||||
pocketbase@0.26.9: {}
|
||||
|
||||
points-on-curve@0.2.0: {}
|
||||
|
||||
points-on-path@0.2.1:
|
||||
|
||||
@@ -95,6 +95,7 @@ catalog:
|
||||
"@types/xml2js": ^0.4.14
|
||||
posthog-js: ^1.398.2
|
||||
posthog-node: ^5.39.2
|
||||
pocketbase: ^0.26.2
|
||||
"@vitejs/plugin-react": ^6.0.3
|
||||
"@vitest/coverage-v8": ^4.1.9
|
||||
"@vitest/ui": ^4.1.9
|
||||
|
||||
Reference in new issue
Block a user