feat: unify Workshop experience
This commit is contained in:
27 files changed
+1184
-483
No files matched your search
@@ -21,6 +21,8 @@ In Homarr, open **Management → Workshop** to browse, search, filter, inspect,
|
||||
|
||||
Recently loaded listings and details are saved in the browser for up to 24 hours. If Workshop is temporarily unreachable, Homarr shows the saved catalog with a clear offline notice; browsing the rest of Homarr is unaffected. Publishing, voting, reporting, and installation remain unavailable until the connection returns.
|
||||
|
||||
The public site also caches pages you have visited. If your network drops, a previously opened Workshop or documentation page can still load while the saved catalog remains on the device. Use **Check again** after connectivity returns.
|
||||
|
||||
- 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.
|
||||
@@ -39,4 +41,4 @@ Signed-in members can vote to help useful creations rank higher. Report maliciou
|
||||
|
||||
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 `workshop.homarr.dev`. Running the backend locally is supported for contributors developing or testing this feature, not as a private production marketplace.
|
||||
The Workshop is a central service operated by Homarr at `homarr.dev`. The documentation website and Workshop API ship in one image and use the same origin. Running it locally is supported for contributors developing or testing this feature, not as a private production marketplace.
|
||||
@@ -3,10 +3,9 @@ import type { Config } from "@docusaurus/types";
|
||||
import { themes as prismThemes } from "prism-react-renderer";
|
||||
const a11yEmoji = require("@fec/remark-a11y-emoji");
|
||||
|
||||
const workshopApiUrl =
|
||||
process.env.NODE_ENV !== "production"
|
||||
? (process.env.WORKSHOP_API_URL ?? process.env.NEXT_PUBLIC_WORKSHOP_API_URL ?? "https://workshop.homarr.dev")
|
||||
: "https://workshop.homarr.dev";
|
||||
// Production uses PocketBase's own origin. The explicit override exists only
|
||||
// for the standalone Docusaurus development server.
|
||||
const workshopApiUrl = process.env.WORKSHOP_API_URL ?? process.env.NEXT_PUBLIC_WORKSHOP_API_URL ?? "";
|
||||
|
||||
const config: Config = {
|
||||
title: "Homarr documentation",
|
||||
@@ -310,6 +309,7 @@ const config: Config = {
|
||||
],
|
||||
"docusaurus-plugin-image-zoom",
|
||||
require.resolve("./plugins/validate-docs-coverage"),
|
||||
require.resolve("./plugins/offline-manifest.mjs"),
|
||||
function disableExpensiveBundlerOptimizationPlugin() {
|
||||
return {
|
||||
name: "disable-expensive-bundler-optimizations",
|
||||
|
||||
@@ -0,0 +1,26 @@
|
||||
import { readdir, writeFile } from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
|
||||
const collectFiles = async (directory, root = directory) => {
|
||||
const entries = await readdir(directory, { withFileTypes: true });
|
||||
const files = await Promise.all(
|
||||
entries.map((entry) => {
|
||||
const absolutePath = path.join(directory, entry.name);
|
||||
return entry.isDirectory() ? collectFiles(absolutePath, root) : path.relative(root, absolutePath);
|
||||
}),
|
||||
);
|
||||
return files.flat();
|
||||
};
|
||||
|
||||
export default function offlineManifestPlugin() {
|
||||
return {
|
||||
name: "workshop-offline-manifest",
|
||||
async postBuild({ outDir }) {
|
||||
const assetsDirectory = path.join(outDir, "assets");
|
||||
const assets = (await collectFiles(assetsDirectory))
|
||||
.filter((file) => /\.(?:css|js|woff2?)$/.test(file))
|
||||
.map((file) => `/assets/${file.split(path.sep).join("/")}`);
|
||||
await writeFile(path.join(outDir, "workshop-assets.json"), JSON.stringify([...assets, "/img/logo.png"]));
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -1,16 +1,11 @@
|
||||
import React, { useEffect, useMemo, useState } from "react";
|
||||
import useDocusaurusContext from "@docusaurus/useDocusaurusContext";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
|
||||
import {
|
||||
WORKSHOP_API_URL,
|
||||
WorkshopClient,
|
||||
type WorkshopAccountState,
|
||||
type WorkshopRole,
|
||||
type WorkshopUser,
|
||||
} from "@homarr/workshop";
|
||||
import { WorkshopClient, type WorkshopAccountState, type WorkshopRole, type WorkshopUser } from "@homarr/workshop";
|
||||
|
||||
import { WorkshopQueryProvider } from "./WorkshopQueryProvider";
|
||||
import { WorkshopDialog, WorkshopServiceState } from "./WorkshopUi";
|
||||
import styles from "./workshop.module.css";
|
||||
|
||||
type PendingAction =
|
||||
@@ -29,9 +24,10 @@ export function WorkshopAdmin() {
|
||||
function WorkshopAdminContent() {
|
||||
const { siteConfig } = useDocusaurusContext();
|
||||
const client = useMemo(
|
||||
() => new WorkshopClient((siteConfig.customFields?.workshopApiUrl as string | undefined) ?? WORKSHOP_API_URL),
|
||||
() => new WorkshopClient((siteConfig.customFields?.workshopApiUrl as string | undefined) || window.location.origin),
|
||||
[siteConfig],
|
||||
);
|
||||
const queryClient = useQueryClient();
|
||||
const [user, setUser] = useState<WorkshopUser | null>(null);
|
||||
const [pending, setPending] = useState<PendingAction | null>(null);
|
||||
const [error, setError] = useState("");
|
||||
@@ -53,6 +49,24 @@ function WorkshopAdminContent() {
|
||||
const reports = moderationQuery.data?.reports ?? [];
|
||||
const users = moderationQuery.data?.users ?? [];
|
||||
const actions = moderationQuery.data?.actions ?? [];
|
||||
const actionMutation = useMutation({
|
||||
mutationKey: ["workshop", "moderation", "action"],
|
||||
mutationFn: async ({ action, reason }: { action: PendingAction; reason: string }) => {
|
||||
if (action.kind === "report") await client.resolveReport(action.id, action.status, reason);
|
||||
else if (action.kind === "state") await client.updateAccountState(action.id, action.state, reason);
|
||||
else await client.updateRole(action.id, action.role, reason);
|
||||
},
|
||||
onSuccess: async () => {
|
||||
setPending(null);
|
||||
await queryClient.invalidateQueries({ queryKey: ["workshop", "moderation"] });
|
||||
},
|
||||
});
|
||||
const signInMutation = useMutation({
|
||||
mutationKey: ["workshop", "auth", "moderator"],
|
||||
mutationFn: () => client.signInWithGitHub(),
|
||||
onSuccess: setUser,
|
||||
onError: (caught) => setError(caught instanceof Error ? caught.message : "GitHub sign-in is unavailable"),
|
||||
});
|
||||
|
||||
useEffect(() => client.subscribeToAuth(setUser), [client]);
|
||||
useEffect(() => {
|
||||
@@ -83,16 +97,13 @@ function WorkshopAdminContent() {
|
||||
)}
|
||||
<button
|
||||
className="button button--primary"
|
||||
onClick={async () => {
|
||||
disabled={signInMutation.isPending}
|
||||
onClick={() => {
|
||||
setError("");
|
||||
try {
|
||||
setUser(await client.signInWithGitHub());
|
||||
} catch (caught) {
|
||||
setError(caught instanceof Error ? caught.message : "GitHub sign-in is unavailable");
|
||||
}
|
||||
signInMutation.mutate();
|
||||
}}
|
||||
>
|
||||
Sign in with GitHub
|
||||
{signInMutation.isPending ? "Connecting…" : "Sign in with GitHub"}
|
||||
</button>
|
||||
</main>
|
||||
);
|
||||
@@ -115,10 +126,11 @@ function WorkshopAdminContent() {
|
||||
</div>
|
||||
</header>
|
||||
{(error || moderationQuery.isError) && (
|
||||
<div className={styles.error} role="alert">
|
||||
{error || "Moderation data is unavailable. Try again when Workshop reconnects."}{" "}
|
||||
{moderationQuery.isError && <button onClick={() => void moderationQuery.refetch()}>Try again</button>}
|
||||
</div>
|
||||
<WorkshopServiceState
|
||||
cached={Boolean(moderationQuery.data)}
|
||||
message={error || "Moderation data is unavailable. No action can be applied until Workshop reconnects."}
|
||||
onRetry={() => void moderationQuery.refetch()}
|
||||
/>
|
||||
)}
|
||||
<section className={styles.adminSection}>
|
||||
<h2>Open reports</h2>
|
||||
@@ -258,13 +270,7 @@ function WorkshopAdminContent() {
|
||||
<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 moderationQuery.refetch();
|
||||
}}
|
||||
onConfirm={(reason) => actionMutation.mutateAsync({ action: pending, reason })}
|
||||
/>
|
||||
)}
|
||||
</main>
|
||||
@@ -293,45 +299,43 @@ function ReasonDialog({
|
||||
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>
|
||||
<WorkshopDialog titleId="moderation-reason-title" onClose={onClose}>
|
||||
<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>
|
||||
</WorkshopDialog>
|
||||
);
|
||||
}
|
||||
@@ -8,14 +8,16 @@ import {
|
||||
IconCopy,
|
||||
IconDownload,
|
||||
IconFlag,
|
||||
IconHeart,
|
||||
IconPackage,
|
||||
IconSearch,
|
||||
IconSparkles,
|
||||
IconUpload,
|
||||
} from "@tabler/icons-react";
|
||||
|
||||
import {
|
||||
MAX_CONTENT_LENGTH,
|
||||
MAX_SCREENSHOTS,
|
||||
WORKSHOP_API_URL,
|
||||
WorkshopClient,
|
||||
type WorkshopSubmissionDetail,
|
||||
type WorkshopSubmissionInput,
|
||||
@@ -27,11 +29,12 @@ import {
|
||||
import { keepPreviousData, useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
|
||||
import { WorkshopQueryProvider } from "./WorkshopQueryProvider";
|
||||
import { WorkshopDialog, WorkshopServiceState } from "./WorkshopUi";
|
||||
import styles from "./workshop.module.css";
|
||||
|
||||
const useWorkshop = () => {
|
||||
const { siteConfig } = useDocusaurusContext();
|
||||
const apiUrl = (siteConfig.customFields?.workshopApiUrl as string | undefined) ?? WORKSHOP_API_URL;
|
||||
const apiUrl = (siteConfig.customFields?.workshopApiUrl as string | undefined) || window.location.origin;
|
||||
return useMemo(() => new WorkshopClient(apiUrl), [apiUrl]);
|
||||
};
|
||||
|
||||
@@ -66,6 +69,12 @@ function WorkshopAppContent() {
|
||||
const [mine, setMine] = useState(false);
|
||||
const [error, setError] = useState("");
|
||||
const [showForm, setShowForm] = useState(false);
|
||||
const signInMutation = useMutation({
|
||||
mutationKey: ["workshop", "auth", "sign-in"],
|
||||
mutationFn: () => client.signInWithGitHub(),
|
||||
onSuccess: async () => queryClient.invalidateQueries({ queryKey: ["workshop"] }),
|
||||
onError: (caught) => setError(caught instanceof Error ? caught.message : "Sign-in failed"),
|
||||
});
|
||||
|
||||
useEffect(() => client.subscribeToAuth(setUser), [client]);
|
||||
useEffect(() => {
|
||||
@@ -92,21 +101,24 @@ function WorkshopAppContent() {
|
||||
const items = listQuery.data?.items ?? [];
|
||||
const totalPages = listQuery.data?.totalPages ?? 1;
|
||||
|
||||
const signIn = async () => {
|
||||
try {
|
||||
await client.signInWithGitHub();
|
||||
await queryClient.invalidateQueries({ queryKey: ["workshop"] });
|
||||
} catch (caught) {
|
||||
setError(caught instanceof Error ? caught.message : "Sign-in failed");
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<main className={styles.page}>
|
||||
<header className={styles.hero}>
|
||||
<div>
|
||||
<span className={styles.eyebrow}>Made by the Homarr community</span>
|
||||
<h1>Workshop</h1>
|
||||
<p>Community widgets and CSS themes, ready to inspect and use in Homarr.</p>
|
||||
<p>Discover useful creations, inspect every line, then bring the ones you trust into Homarr.</p>
|
||||
<ul className={styles.heroPromises} aria-label="Workshop principles">
|
||||
<li>
|
||||
<IconSparkles size={16} /> Community-made
|
||||
</li>
|
||||
<li>
|
||||
<IconPackage size={16} /> Ready to export
|
||||
</li>
|
||||
<li>
|
||||
<IconHeart size={16} /> Ranked by members
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
<div className={styles.accountActions}>
|
||||
{user ? (
|
||||
@@ -124,8 +136,12 @@ function WorkshopAppContent() {
|
||||
</button>
|
||||
</>
|
||||
) : (
|
||||
<button className="button button--primary" onClick={() => void signIn()}>
|
||||
<IconBrandGithub size={18} /> Sign in with GitHub
|
||||
<button
|
||||
className="button button--primary"
|
||||
disabled={signInMutation.isPending}
|
||||
onClick={() => signInMutation.mutate()}
|
||||
>
|
||||
<IconBrandGithub size={18} /> {signInMutation.isPending ? "Connecting…" : "Sign in with GitHub"}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
@@ -194,14 +210,11 @@ function WorkshopAppContent() {
|
||||
</div>
|
||||
|
||||
{(error || listQuery.isError || listQuery.fetchStatus === "paused") && (
|
||||
<div className={styles.error} role="alert">
|
||||
<strong>{items.length ? "Showing saved Workshop results." : "Workshop is unavailable."}</strong>{" "}
|
||||
{error ||
|
||||
(items.length
|
||||
? "Some details and actions may be unavailable until the service reconnects."
|
||||
: "Homarr and the documentation remain available while the community service is offline.")}{" "}
|
||||
<button onClick={() => void listQuery.refetch()}>Try again</button>
|
||||
</div>
|
||||
<WorkshopServiceState
|
||||
cached={items.length > 0}
|
||||
message={error || undefined}
|
||||
onRetry={() => void listQuery.refetch()}
|
||||
/>
|
||||
)}
|
||||
{listQuery.isPending ? (
|
||||
<WorkshopSkeleton />
|
||||
@@ -213,8 +226,20 @@ function WorkshopAppContent() {
|
||||
</div>
|
||||
) : listQuery.isError || listQuery.fetchStatus === "paused" ? null : (
|
||||
<section className={styles.empty}>
|
||||
<IconSearch size={32} aria-hidden />
|
||||
<h2>No creations found</h2>
|
||||
<p>Adjust the filters, or share the first creation in this category.</p>
|
||||
<p>Try a broader search or clear the filters to explore everything the community has shared.</p>
|
||||
<button
|
||||
className="button button--secondary button--sm"
|
||||
onClick={() => {
|
||||
setSearch("");
|
||||
setType("all");
|
||||
setSort("top");
|
||||
setPage(1);
|
||||
}}
|
||||
>
|
||||
Clear filters
|
||||
</button>
|
||||
</section>
|
||||
)}
|
||||
|
||||
@@ -265,7 +290,9 @@ function WorkshopCard({ client, item }: { client: WorkshopClient; item: Workshop
|
||||
</span>
|
||||
</div>
|
||||
<h2>
|
||||
<Link to={`/workshop/${item.id}`}>{item.title}</Link>
|
||||
<Link className={styles.cardLink} to={`/workshop/${item.id}`}>
|
||||
{item.title}
|
||||
</Link>
|
||||
</h2>
|
||||
<p>{item.description || "No description provided."}</p>
|
||||
<small>
|
||||
@@ -463,51 +490,49 @@ function ModerationRemovalForm({
|
||||
onRemoved: () => void;
|
||||
}) {
|
||||
const [reason, setReason] = useState("");
|
||||
const [error, setError] = useState("");
|
||||
const [pending, setPending] = useState(false);
|
||||
const removeMutation = useMutation({
|
||||
mutationKey: ["workshop", "moderation", "remove", submission.id],
|
||||
mutationFn: () => client.removeSubmission(submission.id, reason),
|
||||
onSuccess: onRemoved,
|
||||
});
|
||||
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>
|
||||
<WorkshopDialog titleId="remove-submission-title" onClose={onClose}>
|
||||
<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={(event) => {
|
||||
event.preventDefault();
|
||||
removeMutation.mutate();
|
||||
}}
|
||||
>
|
||||
<label>
|
||||
Reason
|
||||
<textarea
|
||||
required
|
||||
minLength={3}
|
||||
maxLength={1000}
|
||||
value={reason}
|
||||
onChange={(event) => setReason(event.target.value)}
|
||||
/>
|
||||
</label>
|
||||
{removeMutation.error && (
|
||||
<p className={styles.error} role="alert">
|
||||
{removeMutation.error instanceof Error ? removeMutation.error.message : "Removal failed"}
|
||||
</p>
|
||||
)}
|
||||
<div className={styles.formActions}>
|
||||
<button type="button" className="button button--secondary" onClick={onClose}>
|
||||
Cancel
|
||||
</button>
|
||||
<button disabled={removeMutation.isPending} className="button button--danger">
|
||||
{removeMutation.isPending ? "Removing…" : "Remove permanently"}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</WorkshopDialog>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -529,125 +554,112 @@ function SubmissionForm({
|
||||
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 retainedScreenshotCount = (existing?.screenshots.length ?? 0) - removedScreenshots.length;
|
||||
const remainingScreenshotSlots = Math.max(0, MAX_SCREENSHOTS - retainedScreenshotCount);
|
||||
useEffect(() => {
|
||||
setScreenshots((current) => current.slice(0, remainingScreenshotSlots));
|
||||
}, [remainingScreenshotSlots]);
|
||||
const submit = async (event: React.FormEvent) => {
|
||||
event.preventDefault();
|
||||
setPending(true);
|
||||
setError("");
|
||||
const input: WorkshopSubmissionInput = { type, title, description, content, changelog };
|
||||
try {
|
||||
const saveMutation = useMutation({
|
||||
mutationKey: ["workshop", existing ? "update" : "create", existing?.id],
|
||||
mutationFn: async (input: WorkshopSubmissionInput) => {
|
||||
if (!client.currentUser) await client.signInWithGitHub();
|
||||
if (existing) {
|
||||
await client.update(existing.id, input, screenshots, removedScreenshots);
|
||||
} else {
|
||||
await client.create(input, screenshots);
|
||||
return client.update(existing.id, input, screenshots, removedScreenshots);
|
||||
}
|
||||
await onSaved();
|
||||
} catch (caught) {
|
||||
setError(caught instanceof Error ? caught.message : "Could not save submission");
|
||||
} finally {
|
||||
setPending(false);
|
||||
}
|
||||
};
|
||||
return client.create(input, screenshots);
|
||||
},
|
||||
onSuccess: onSaved,
|
||||
});
|
||||
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
|
||||
<WorkshopDialog titleId="submission-form-title" onClose={onClose}>
|
||||
<h2 id="submission-form-title">{existing ? "Update creation" : "Share with the community"}</h2>
|
||||
<form
|
||||
onSubmit={(event) => {
|
||||
event.preventDefault();
|
||||
saveMutation.mutate({ type, title, description, content, changelog });
|
||||
}}
|
||||
>
|
||||
<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
|
||||
maxLength={MAX_CONTENT_LENGTH}
|
||||
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, {remainingScreenshotSlots} available)
|
||||
<input
|
||||
type="file"
|
||||
accept="image/png,image/jpeg,image/webp"
|
||||
multiple
|
||||
onChange={(event) =>
|
||||
setScreenshots(Array.from(event.target.files ?? []).slice(0, remainingScreenshotSlots))
|
||||
}
|
||||
/>
|
||||
</label>
|
||||
{existing?.screenshots.map((screenshot) => (
|
||||
<label className={styles.checkbox} key={screenshot}>
|
||||
<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
|
||||
maxLength={MAX_CONTENT_LENGTH}
|
||||
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, {remainingScreenshotSlots} available)
|
||||
<input
|
||||
type="file"
|
||||
accept="image/png,image/jpeg,image/webp"
|
||||
multiple
|
||||
type="checkbox"
|
||||
checked={removedScreenshots.includes(screenshot)}
|
||||
onChange={(event) =>
|
||||
setScreenshots(Array.from(event.target.files ?? []).slice(0, remainingScreenshotSlots))
|
||||
setRemovedScreenshots((current) =>
|
||||
event.target.checked
|
||||
? [...current, screenshot]
|
||||
: current.filter((filename) => filename !== screenshot),
|
||||
)
|
||||
}
|
||||
/>
|
||||
Remove existing screenshot {screenshot}
|
||||
</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>
|
||||
))}
|
||||
{saveMutation.error && (
|
||||
<p className={styles.error} role="alert">
|
||||
{saveMutation.error instanceof Error ? saveMutation.error.message : "Could not save submission"}
|
||||
</p>
|
||||
)}
|
||||
<div className={styles.formActions}>
|
||||
<button type="button" className="button button--secondary" onClick={onClose}>
|
||||
Cancel
|
||||
</button>
|
||||
<button disabled={saveMutation.isPending} className="button button--primary">
|
||||
{saveMutation.isPending ? "Saving…" : existing ? "Save update" : "Publish creation"}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</WorkshopDialog>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -662,53 +674,58 @@ function ReportForm({
|
||||
}) {
|
||||
const [category, setCategory] = useState<"malicious" | "spam" | "copyright" | "inappropriate" | "other">("malicious");
|
||||
const [explanation, setExplanation] = useState("");
|
||||
const [error, setError] = useState("");
|
||||
const reportMutation = useMutation({
|
||||
mutationKey: ["workshop", "report", submissionId],
|
||||
mutationFn: async () => {
|
||||
if (!client.currentUser) await client.signInWithGitHub();
|
||||
return client.report(submissionId, category, explanation);
|
||||
},
|
||||
onSuccess: onClose,
|
||||
});
|
||||
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>
|
||||
<WorkshopDialog titleId="report-title" onClose={onClose}>
|
||||
<h2 id="report-title">Report submission</h2>
|
||||
<form
|
||||
onSubmit={(event) => {
|
||||
event.preventDefault();
|
||||
reportMutation.mutate();
|
||||
}}
|
||||
>
|
||||
<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>
|
||||
{reportMutation.error && (
|
||||
<p className={styles.error} role="alert">
|
||||
{reportMutation.error instanceof Error ? reportMutation.error.message : "Could not send report"}
|
||||
</p>
|
||||
)}
|
||||
<div className={styles.formActions}>
|
||||
<button type="button" className="button button--secondary" onClick={onClose}>
|
||||
Cancel
|
||||
</button>
|
||||
<button disabled={reportMutation.isPending} className="button button--primary">
|
||||
{reportMutation.isPending ? "Sending…" : "Submit report"}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</WorkshopDialog>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,69 @@
|
||||
import React, { type PropsWithChildren, useEffect, useRef } from "react";
|
||||
import Link from "@docusaurus/Link";
|
||||
import { IconBooks, IconCloudOff, IconRefresh } from "@tabler/icons-react";
|
||||
|
||||
import styles from "./workshop.module.css";
|
||||
|
||||
export function WorkshopServiceState({
|
||||
cached,
|
||||
message,
|
||||
onRetry,
|
||||
}: {
|
||||
cached: boolean;
|
||||
message?: string;
|
||||
onRetry: () => void;
|
||||
}) {
|
||||
return (
|
||||
<section className={styles.serviceState} aria-live="polite">
|
||||
<span className={styles.serviceStateIcon} aria-hidden>
|
||||
<IconCloudOff size={24} />
|
||||
</span>
|
||||
<div>
|
||||
<h2>{cached ? "Browsing saved creations" : "Workshop is taking a break"}</h2>
|
||||
<p>
|
||||
{message ??
|
||||
(cached
|
||||
? "The catalog saved on this device is still available. Live details and community actions will return when the service reconnects."
|
||||
: "Community creations cannot be reached right now. Homarr and its documentation remain fully available.")}
|
||||
</p>
|
||||
<div className={styles.serviceStateActions}>
|
||||
<button className="button button--primary button--sm" onClick={onRetry}>
|
||||
<IconRefresh size={16} /> Check again
|
||||
</button>
|
||||
<Link className="button button--secondary button--sm" to="/docs/management/workshop">
|
||||
<IconBooks size={16} /> Read the guide
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
export function WorkshopDialog({
|
||||
titleId,
|
||||
onClose,
|
||||
children,
|
||||
}: PropsWithChildren<{ titleId: string; onClose: () => void }>) {
|
||||
const ref = useRef<HTMLDialogElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
const dialog = ref.current;
|
||||
if (!dialog) return;
|
||||
dialog.showModal();
|
||||
return () => dialog.close();
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<dialog
|
||||
ref={ref}
|
||||
className={styles.dialog}
|
||||
aria-labelledby={titleId}
|
||||
onCancel={(event) => {
|
||||
event.preventDefault();
|
||||
onClose();
|
||||
}}
|
||||
>
|
||||
<div className={styles.dialogSurface}>{children}</div>
|
||||
</dialog>
|
||||
);
|
||||
}
|
||||
@@ -1,164 +1,343 @@
|
||||
.page {
|
||||
max-width: 1240px;
|
||||
--workshop-ease: cubic-bezier(0.25, 1, 0.5, 1);
|
||||
max-width: 1180px;
|
||||
min-height: 70vh;
|
||||
margin: 0 auto;
|
||||
padding: 2.5rem 1.25rem 5rem;
|
||||
padding: clamp(2rem, 5vw, 4.5rem) 1.25rem 5rem;
|
||||
}
|
||||
|
||||
.hero {
|
||||
display: flex;
|
||||
align-items: flex-end;
|
||||
justify-content: space-between;
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1fr) auto;
|
||||
align-items: center;
|
||||
gap: 2rem;
|
||||
margin-bottom: 1.5rem;
|
||||
margin-bottom: 2rem;
|
||||
}
|
||||
|
||||
.eyebrow {
|
||||
display: block;
|
||||
margin-bottom: 0.45rem;
|
||||
color: var(--ifm-color-primary);
|
||||
font-size: 0.76rem;
|
||||
font-weight: 750;
|
||||
letter-spacing: 0.09em;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.hero h1,
|
||||
.detail h1 {
|
||||
margin: 0;
|
||||
font-size: 2.25rem;
|
||||
letter-spacing: -0.025em;
|
||||
font-size: clamp(2.35rem, 6vw, 4.25rem);
|
||||
line-height: 0.98;
|
||||
letter-spacing: -0.045em;
|
||||
}
|
||||
|
||||
.hero p {
|
||||
max-width: 65ch;
|
||||
margin: 0.5rem 0 0;
|
||||
max-width: 62ch;
|
||||
margin: 0.85rem 0 0;
|
||||
color: var(--ifm-color-emphasis-700);
|
||||
font-size: clamp(1rem, 2vw, 1.12rem);
|
||||
line-height: 1.65;
|
||||
}
|
||||
|
||||
.heroPromises {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.55rem 1rem;
|
||||
padding: 0;
|
||||
margin: 1.1rem 0 0;
|
||||
color: var(--ifm-color-emphasis-700);
|
||||
font-size: 0.82rem;
|
||||
list-style: none;
|
||||
}
|
||||
|
||||
.heroPromises li,
|
||||
.accountActions,
|
||||
.detailActions,
|
||||
.formActions {
|
||||
.formActions,
|
||||
.serviceStateActions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.75rem;
|
||||
}
|
||||
|
||||
.heroPromises li {
|
||||
gap: 0.35rem;
|
||||
}
|
||||
|
||||
.accountActions,
|
||||
.detailActions,
|
||||
.formActions,
|
||||
.serviceStateActions {
|
||||
flex-wrap: wrap;
|
||||
gap: 0.7rem;
|
||||
}
|
||||
|
||||
.accountActions :global(.button),
|
||||
.detailActions :global(.button) {
|
||||
.detailActions :global(.button),
|
||||
.serviceStateActions :global(.button) {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: 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);
|
||||
padding: 0.9rem 1rem;
|
||||
margin-bottom: 1rem;
|
||||
border: 1px solid color-mix(in srgb, var(--ifm-color-warning) 55%, var(--ifm-color-emphasis-300));
|
||||
border-radius: 12px;
|
||||
background: color-mix(in srgb, var(--ifm-color-warning) 8%, var(--ifm-background-surface-color));
|
||||
}
|
||||
|
||||
.safety svg {
|
||||
flex: none;
|
||||
margin-top: 0.1rem;
|
||||
}
|
||||
|
||||
.safety p {
|
||||
margin: 0;
|
||||
color: var(--ifm-color-emphasis-800);
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
.toolbar {
|
||||
display: flex;
|
||||
display: grid;
|
||||
grid-template-columns: minmax(260px, 1fr) 150px 150px auto;
|
||||
align-items: end;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.75rem;
|
||||
padding: 0.75rem;
|
||||
margin-bottom: 1.5rem;
|
||||
padding: 0.85rem;
|
||||
margin-bottom: 1.25rem;
|
||||
border: 1px solid var(--ifm-color-emphasis-300);
|
||||
border-radius: 10px;
|
||||
border-radius: 14px;
|
||||
background: var(--ifm-background-surface-color);
|
||||
box-shadow: 0 8px 28px rgb(0 0 0 / 0.04);
|
||||
}
|
||||
|
||||
.toolbar label,
|
||||
.dialog label {
|
||||
display: grid;
|
||||
gap: 0.3rem;
|
||||
font-size: 0.8rem;
|
||||
font-weight: 600;
|
||||
gap: 0.35rem;
|
||||
font-size: 0.78rem;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.toolbar input,
|
||||
.toolbar select,
|
||||
.dialog input,
|
||||
.dialog select,
|
||||
.dialog textarea {
|
||||
width: 100%;
|
||||
min-height: 44px;
|
||||
padding: 0.55rem 0.7rem;
|
||||
padding: 0.6rem 0.72rem;
|
||||
border: 1px solid var(--ifm-color-emphasis-400);
|
||||
border-radius: 7px;
|
||||
border-radius: 9px;
|
||||
color: var(--ifm-font-color-base);
|
||||
background: var(--ifm-background-surface-color);
|
||||
background: var(--ifm-background-color);
|
||||
font: inherit;
|
||||
transition:
|
||||
border-color 150ms ease,
|
||||
box-shadow 150ms ease;
|
||||
}
|
||||
|
||||
.toolbar input:focus-visible,
|
||||
.toolbar select:focus-visible,
|
||||
.dialog input:focus-visible,
|
||||
.dialog select:focus-visible,
|
||||
.dialog textarea:focus-visible,
|
||||
.pagination button:focus-visible,
|
||||
.rowActions button:focus-visible {
|
||||
outline: 2px solid var(--ifm-color-primary);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
|
||||
.search {
|
||||
position: relative;
|
||||
flex: 1 1 280px;
|
||||
}
|
||||
|
||||
.search svg {
|
||||
position: absolute;
|
||||
left: 0.75rem;
|
||||
bottom: 0.8rem;
|
||||
z-index: 1;
|
||||
left: 0.8rem;
|
||||
bottom: 0.82rem;
|
||||
color: var(--ifm-color-emphasis-600);
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.search input {
|
||||
width: 100%;
|
||||
padding-left: 2.25rem;
|
||||
padding-left: 2.35rem;
|
||||
}
|
||||
|
||||
.checkbox {
|
||||
display: flex !important;
|
||||
grid-auto-flow: column;
|
||||
align-items: center;
|
||||
min-height: 44px;
|
||||
}
|
||||
|
||||
.checkbox input {
|
||||
width: auto;
|
||||
min-height: auto;
|
||||
}
|
||||
|
||||
.serviceState {
|
||||
display: grid;
|
||||
grid-template-columns: auto minmax(0, 1fr);
|
||||
gap: 1rem;
|
||||
padding: clamp(1.1rem, 3vw, 1.5rem);
|
||||
margin: 1.25rem 0;
|
||||
border: 1px solid color-mix(in srgb, var(--ifm-color-warning) 45%, var(--ifm-color-emphasis-300));
|
||||
border-radius: 14px;
|
||||
background: color-mix(in srgb, var(--ifm-color-warning) 7%, var(--ifm-background-surface-color));
|
||||
}
|
||||
|
||||
.serviceStateIcon {
|
||||
display: grid;
|
||||
width: 44px;
|
||||
height: 44px;
|
||||
place-items: center;
|
||||
border-radius: 10px;
|
||||
color: var(--ifm-color-warning-darkest);
|
||||
background: color-mix(in srgb, var(--ifm-color-warning) 18%, transparent);
|
||||
}
|
||||
|
||||
.serviceState h2 {
|
||||
margin: 0 0 0.25rem;
|
||||
font-size: 1.05rem;
|
||||
}
|
||||
|
||||
.serviceState p {
|
||||
max-width: 70ch;
|
||||
margin: 0 0 0.85rem;
|
||||
color: var(--ifm-color-emphasis-700);
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
.grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(270px, 1fr));
|
||||
grid-template-columns: repeat(auto-fill, minmax(min(100%, 270px), 1fr));
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.card {
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
border: 1px solid var(--ifm-color-emphasis-300);
|
||||
border-radius: 12px;
|
||||
border-radius: 14px;
|
||||
background: var(--ifm-background-surface-color);
|
||||
box-shadow: 0 4px 14px rgb(0 0 0 / 0.035);
|
||||
transition:
|
||||
transform 200ms var(--workshop-ease),
|
||||
border-color 200ms ease,
|
||||
box-shadow 200ms var(--workshop-ease);
|
||||
}
|
||||
|
||||
.card:hover,
|
||||
.card:focus-within {
|
||||
border-color: color-mix(in srgb, var(--ifm-color-primary) 55%, var(--ifm-color-emphasis-300));
|
||||
box-shadow: 0 14px 34px rgb(0 0 0 / 0.1);
|
||||
transform: translateY(-3px);
|
||||
}
|
||||
|
||||
.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);
|
||||
background: color-mix(in srgb, var(--ifm-color-primary) 6%, var(--ifm-color-emphasis-100));
|
||||
font: 750 1.45rem var(--ifm-font-family-monospace);
|
||||
}
|
||||
|
||||
.cardBody {
|
||||
padding: 1rem;
|
||||
}
|
||||
|
||||
.cardBody h2 {
|
||||
margin: 0.4rem 0;
|
||||
margin: 0.45rem 0;
|
||||
font-size: 1.08rem;
|
||||
}
|
||||
|
||||
.cardLink::after {
|
||||
position: absolute;
|
||||
content: "";
|
||||
inset: 0;
|
||||
}
|
||||
|
||||
.cardLink:focus-visible {
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.card:has(.cardLink:focus-visible) {
|
||||
outline: 3px solid var(--ifm-color-primary);
|
||||
outline-offset: 3px;
|
||||
}
|
||||
|
||||
.cardBody p {
|
||||
min-height: 2.7em;
|
||||
margin: 0 0 0.65rem;
|
||||
margin: 0 0 0.7rem;
|
||||
color: var(--ifm-color-emphasis-700);
|
||||
font-size: 0.9rem;
|
||||
font-size: 0.88rem;
|
||||
}
|
||||
|
||||
.cardBody small {
|
||||
color: var(--ifm-color-emphasis-600);
|
||||
}
|
||||
|
||||
.cardMeta {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
color: var(--ifm-color-emphasis-700);
|
||||
font-size: 0.75rem;
|
||||
font-weight: 700;
|
||||
font-size: 0.72rem;
|
||||
font-weight: 750;
|
||||
letter-spacing: 0.035em;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.error {
|
||||
padding: 0.85rem 1rem;
|
||||
margin: 1rem 0;
|
||||
border-radius: 8px;
|
||||
border: 1px solid color-mix(in srgb, var(--ifm-color-danger) 45%, transparent);
|
||||
border-radius: 10px;
|
||||
color: var(--ifm-color-danger-contrast-foreground);
|
||||
background: var(--ifm-color-danger-contrast-background);
|
||||
}
|
||||
|
||||
.empty {
|
||||
padding: 4rem 1rem;
|
||||
display: grid;
|
||||
max-width: 620px;
|
||||
min-height: 310px;
|
||||
place-items: center;
|
||||
align-content: center;
|
||||
gap: 0.45rem;
|
||||
padding: 3rem 1.25rem;
|
||||
margin: 0 auto;
|
||||
text-align: center;
|
||||
border: 1px dashed var(--ifm-color-emphasis-400);
|
||||
border-radius: 12px;
|
||||
border-radius: 16px;
|
||||
color: var(--ifm-color-emphasis-700);
|
||||
}
|
||||
|
||||
.empty h2,
|
||||
.empty p {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.empty svg {
|
||||
color: var(--ifm-color-primary);
|
||||
}
|
||||
|
||||
.empty :global(.button) {
|
||||
margin-top: 0.75rem;
|
||||
}
|
||||
|
||||
.pagination {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
@@ -166,134 +345,248 @@
|
||||
gap: 1rem;
|
||||
margin-top: 2rem;
|
||||
}
|
||||
|
||||
.pagination button {
|
||||
min-height: 44px;
|
||||
padding: 0.5rem 0.8rem;
|
||||
padding: 0.5rem 0.85rem;
|
||||
}
|
||||
|
||||
.skeleton {
|
||||
min-height: 310px;
|
||||
border-radius: 12px;
|
||||
border-radius: 14px;
|
||||
background: var(--ifm-color-emphasis-200);
|
||||
animation: pulse 1.5s ease-in-out infinite;
|
||||
}
|
||||
|
||||
.detail {
|
||||
margin-top: 1.5rem;
|
||||
margin-top: 1.75rem;
|
||||
}
|
||||
|
||||
.detail header {
|
||||
max-width: 75ch;
|
||||
}
|
||||
|
||||
.detail h1 {
|
||||
margin-top: 0.5rem;
|
||||
font-size: clamp(2.1rem, 5vw, 3.6rem);
|
||||
}
|
||||
|
||||
.type {
|
||||
display: inline-block;
|
||||
margin-bottom: 0.5rem;
|
||||
color: var(--ifm-color-primary);
|
||||
font-weight: 700;
|
||||
font-size: 0.78rem;
|
||||
font-weight: 750;
|
||||
letter-spacing: 0.07em;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.gallery {
|
||||
display: flex;
|
||||
gap: 1rem;
|
||||
overflow-x: auto;
|
||||
padding-bottom: 0.6rem;
|
||||
margin: 1.5rem 0;
|
||||
scroll-snap-type: x mandatory;
|
||||
}
|
||||
|
||||
.gallery img {
|
||||
width: min(720px, 85vw);
|
||||
max-height: 480px;
|
||||
object-fit: contain;
|
||||
border-radius: 10px;
|
||||
border: 1px solid var(--ifm-color-emphasis-300);
|
||||
border-radius: 12px;
|
||||
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;
|
||||
border: 1px solid var(--ifm-color-emphasis-300);
|
||||
border-radius: 10px;
|
||||
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;
|
||||
width: min(700px, calc(100vw - 2rem));
|
||||
max-height: min(90vh, 900px);
|
||||
padding: 0;
|
||||
overflow: visible;
|
||||
border: 0;
|
||||
border-radius: 16px;
|
||||
color: var(--ifm-font-color-base);
|
||||
background: transparent;
|
||||
box-shadow: 0 24px 80px rgb(0 0 0 / 0.35);
|
||||
}
|
||||
|
||||
.dialog::backdrop {
|
||||
background: rgb(0 0 0 / 0.68);
|
||||
backdrop-filter: blur(3px);
|
||||
}
|
||||
|
||||
.dialogSurface {
|
||||
max-height: min(90vh, 900px);
|
||||
overflow-y: auto;
|
||||
padding: 1.25rem;
|
||||
border-radius: 12px;
|
||||
padding: clamp(1.15rem, 4vw, 1.6rem);
|
||||
border: 1px solid var(--ifm-color-emphasis-300);
|
||||
border-radius: 16px;
|
||||
background: var(--ifm-background-color);
|
||||
}
|
||||
|
||||
.dialogSurface > h2 {
|
||||
margin-top: 0;
|
||||
}
|
||||
|
||||
.dialog form {
|
||||
display: grid;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.dialog textarea {
|
||||
min-height: 88px;
|
||||
min-height: 96px;
|
||||
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;
|
||||
border: 1px solid var(--ifm-color-emphasis-300);
|
||||
border-radius: 12px;
|
||||
}
|
||||
|
||||
.tableWrap table {
|
||||
width: 100%;
|
||||
margin: 0;
|
||||
border-collapse: collapse;
|
||||
}
|
||||
|
||||
.tableWrap th,
|
||||
.tableWrap td {
|
||||
padding: 0.7rem;
|
||||
padding: 0.75rem;
|
||||
border: 0;
|
||||
border-bottom: 1px solid var(--ifm-color-emphasis-300);
|
||||
text-align: left;
|
||||
vertical-align: top;
|
||||
}
|
||||
|
||||
.tableWrap tr:last-child td {
|
||||
border-bottom: 0;
|
||||
}
|
||||
|
||||
.rowActions {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.4rem;
|
||||
}
|
||||
|
||||
.rowActions button {
|
||||
min-height: 36px;
|
||||
}
|
||||
|
||||
@keyframes pulse {
|
||||
50% {
|
||||
opacity: 0.55;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 820px) {
|
||||
.hero {
|
||||
grid-template-columns: 1fr;
|
||||
gap: 1.4rem;
|
||||
}
|
||||
|
||||
.accountActions > * {
|
||||
flex: 1 1 auto;
|
||||
}
|
||||
|
||||
.toolbar {
|
||||
grid-template-columns: 1fr 1fr;
|
||||
}
|
||||
|
||||
.search {
|
||||
grid-column: 1 / -1;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 520px) {
|
||||
.page {
|
||||
padding-inline: 1rem;
|
||||
}
|
||||
|
||||
.hero h1 {
|
||||
font-size: 2.75rem;
|
||||
}
|
||||
|
||||
.heroPromises {
|
||||
display: grid;
|
||||
}
|
||||
|
||||
.accountActions,
|
||||
.detailActions,
|
||||
.formActions,
|
||||
.serviceStateActions {
|
||||
align-items: stretch;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.accountActions > *,
|
||||
.detailActions > *,
|
||||
.formActions > *,
|
||||
.serviceStateActions > * {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.toolbar {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.search {
|
||||
grid-column: auto;
|
||||
}
|
||||
|
||||
.serviceState {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.pagination {
|
||||
justify-content: space-between;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.skeleton {
|
||||
animation: none;
|
||||
}
|
||||
|
||||
.card {
|
||||
transition: none;
|
||||
}
|
||||
|
||||
.card:hover,
|
||||
.card:focus-within {
|
||||
transform: none;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
import React, { type PropsWithChildren, useEffect } from "react";
|
||||
|
||||
export default function Root({ children }: PropsWithChildren) {
|
||||
useEffect(() => {
|
||||
if (process.env.NODE_ENV === "production" && "serviceWorker" in navigator) {
|
||||
void navigator.serviceWorker.register("/workshop-sw.js", { scope: "/" });
|
||||
}
|
||||
}, []);
|
||||
|
||||
return children;
|
||||
}
|
||||
Vendored
+46
@@ -0,0 +1,46 @@
|
||||
const CACHE_NAME = "homarr-site-v2";
|
||||
const SHELL_PATHS = ["/", "/workshop/", "/docs/management/workshop/", "/img/logo.png"];
|
||||
|
||||
self.addEventListener("install", (event) => {
|
||||
event.waitUntil(
|
||||
caches.open(CACHE_NAME).then(async (cache) => {
|
||||
const manifest = await fetch("/workshop-assets.json")
|
||||
.then((response) => response.json())
|
||||
.catch(() => []);
|
||||
await Promise.allSettled([...SHELL_PATHS, ...manifest].map((path) => cache.add(path)));
|
||||
}),
|
||||
);
|
||||
self.skipWaiting();
|
||||
});
|
||||
|
||||
self.addEventListener("activate", (event) => {
|
||||
event.waitUntil(
|
||||
caches
|
||||
.keys()
|
||||
.then((keys) => Promise.all(keys.filter((key) => key !== CACHE_NAME).map((key) => caches.delete(key))))
|
||||
.then(() => self.clients.claim()),
|
||||
);
|
||||
});
|
||||
|
||||
self.addEventListener("fetch", (event) => {
|
||||
const request = event.request;
|
||||
const url = new URL(request.url);
|
||||
if (request.method !== "GET" || url.origin !== self.location.origin || url.pathname.startsWith("/api/")) return;
|
||||
|
||||
event.respondWith(
|
||||
fetch(request)
|
||||
.then((response) => {
|
||||
if (response.ok) {
|
||||
const copy = response.clone();
|
||||
event.waitUntil(caches.open(CACHE_NAME).then((cache) => cache.put(request, copy)));
|
||||
}
|
||||
return response;
|
||||
})
|
||||
.catch(async () => {
|
||||
const cached = await caches.match(request);
|
||||
if (cached) return cached;
|
||||
if (request.mode === "navigate") return (await caches.match("/workshop/")) ?? Response.error();
|
||||
return Response.error();
|
||||
}),
|
||||
);
|
||||
});
|
||||
@@ -12,6 +12,7 @@ import {
|
||||
Group,
|
||||
Image,
|
||||
Modal,
|
||||
Paper,
|
||||
Pagination,
|
||||
Select,
|
||||
SimpleGrid,
|
||||
@@ -25,6 +26,7 @@ import { useDebouncedValue, useDisclosure } from "@mantine/hooks";
|
||||
import {
|
||||
IconAlertTriangle,
|
||||
IconBrandGithub,
|
||||
IconBuildingStore,
|
||||
IconDownload,
|
||||
IconExternalLink,
|
||||
IconSearch,
|
||||
@@ -113,10 +115,34 @@ function WorkshopBrowserContent({ initialType = "all", lockedType, onUse, useLab
|
||||
|
||||
return (
|
||||
<Stack gap="lg">
|
||||
<Paper withBorder p="lg" radius="md">
|
||||
<Group justify="space-between" align="flex-start" wrap="wrap">
|
||||
<Group align="flex-start" wrap="nowrap">
|
||||
<Box
|
||||
p="sm"
|
||||
bg="var(--mantine-color-default-hover)"
|
||||
style={{ borderRadius: "var(--mantine-radius-md)", lineHeight: 0 }}
|
||||
>
|
||||
<IconBuildingStore size={24} aria-hidden />
|
||||
</Box>
|
||||
<Box>
|
||||
<Text fw={700} size="lg">
|
||||
{t("intro.title")}
|
||||
</Text>
|
||||
<Text c="dimmed" size="sm" maw={620}>
|
||||
{t("intro.description")}
|
||||
</Text>
|
||||
</Box>
|
||||
</Group>
|
||||
<Badge color={online ? "green" : "yellow"} variant="light">
|
||||
{online ? t("status.connected") : t("status.offline")}
|
||||
</Badge>
|
||||
</Group>
|
||||
</Paper>
|
||||
<Alert icon={<IconAlertTriangle size={18} />} color="yellow" title={t("safety.title")}>
|
||||
{t("safety.description")}
|
||||
</Alert>
|
||||
<Group align="end">
|
||||
<SimpleGrid cols={{ base: 1, sm: lockedType ? 2 : 3 }} spacing="sm">
|
||||
<TextInput
|
||||
label={t("search.label")}
|
||||
placeholder={t("search.placeholder")}
|
||||
@@ -126,7 +152,6 @@ function WorkshopBrowserContent({ initialType = "all", lockedType, onUse, useLab
|
||||
setSearch(event.currentTarget.value);
|
||||
setPage(1);
|
||||
}}
|
||||
style={{ flex: 1 }}
|
||||
/>
|
||||
<Select
|
||||
label={t("filter.type")}
|
||||
@@ -154,7 +179,7 @@ function WorkshopBrowserContent({ initialType = "all", lockedType, onUse, useLab
|
||||
{ value: "newest", label: t("filter.newest") },
|
||||
]}
|
||||
/>
|
||||
</Group>
|
||||
</SimpleGrid>
|
||||
{(query.isError || query.fetchStatus === "paused") && (
|
||||
<Alert
|
||||
icon={<IconWifiOff size={18} />}
|
||||
@@ -169,11 +194,17 @@ function WorkshopBrowserContent({ initialType = "all", lockedType, onUse, useLab
|
||||
? t("offline.unavailableDescription")
|
||||
: t("offline.description")}
|
||||
</Text>
|
||||
<Group>
|
||||
<Group wrap="wrap">
|
||||
<Button size="xs" variant="light" onClick={() => void query.refetch()}>
|
||||
{t("action.retry")}
|
||||
</Button>
|
||||
<Button size="xs" variant="subtle" component="a" href={WORKSHOP_WEB_URL} target="_blank">
|
||||
<Button
|
||||
size="xs"
|
||||
variant="subtle"
|
||||
component="a"
|
||||
href="https://homarr.dev/docs/management/workshop"
|
||||
target="_blank"
|
||||
>
|
||||
{t("action.website")}
|
||||
</Button>
|
||||
</Group>
|
||||
@@ -269,6 +300,18 @@ function WorkshopBrowserContent({ initialType = "all", lockedType, onUse, useLab
|
||||
</Text>
|
||||
</Group>
|
||||
<Text>{selected.description}</Text>
|
||||
{selected.screenshots.length > 0 && (
|
||||
<SimpleGrid cols={{ base: 1, sm: 2 }}>
|
||||
{selected.screenshots.map((screenshot, index) => (
|
||||
<Image
|
||||
key={screenshot}
|
||||
src={client.fileUrl(selected.id, screenshot, "960x640")}
|
||||
radius="sm"
|
||||
alt={t("screenshotAlt", { title: selected.title, number: index + 1 })}
|
||||
/>
|
||||
))}
|
||||
</SimpleGrid>
|
||||
)}
|
||||
<Alert icon={<IconAlertTriangle size={18} />} color="yellow">
|
||||
{t("safety.inspect")}
|
||||
</Alert>
|
||||
|
||||
@@ -1,4 +1,26 @@
|
||||
FROM alpine:3.22 AS download
|
||||
# syntax=docker/dockerfile:1.10
|
||||
|
||||
FROM node:24.18.0-alpine AS website
|
||||
WORKDIR /app
|
||||
|
||||
RUN corepack enable pnpm
|
||||
COPY pnpm-lock.yaml pnpm-workspace.yaml package.json turbo.json ./
|
||||
COPY patches ./patches
|
||||
RUN --mount=type=cache,id=workshop-pnpm-store,target=/pnpm/store \
|
||||
pnpm config set store-dir /pnpm/store && \
|
||||
pnpm --config.node-linker=isolated fetch --ignore-scripts && \
|
||||
rm -rf node_modules
|
||||
|
||||
COPY apps/docs ./apps/docs
|
||||
COPY packages/definitions ./packages/definitions
|
||||
COPY packages/validation ./packages/validation
|
||||
COPY packages/workshop ./packages/workshop
|
||||
COPY tooling/typescript ./tooling/typescript
|
||||
RUN --mount=type=cache,id=workshop-pnpm-store,target=/pnpm/store \
|
||||
pnpm install --recursive --offline --frozen-lockfile && \
|
||||
CI=true SKIP_ENV_VALIDATION=true pnpm turbo build --filter=@homarr/docs
|
||||
|
||||
FROM alpine:3.22 AS pocketbase
|
||||
|
||||
ARG TARGETARCH
|
||||
ARG POCKETBASE_VERSION=0.39.6
|
||||
@@ -20,10 +42,15 @@ RUN case "${TARGETARCH}" in \
|
||||
FROM alpine:3.22
|
||||
|
||||
RUN apk add --no-cache ca-certificates 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
|
||||
COPY --from=pocketbase /out/pocketbase /usr/local/bin/pocketbase
|
||||
COPY --from=website /app/apps/docs/build /pb_public
|
||||
COPY apps/workshop/entrypoint.sh /usr/local/bin/workshop-entrypoint
|
||||
COPY apps/workshop/pb_hooks /pb_hooks
|
||||
COPY apps/workshop/pb_migrations /pb_migrations
|
||||
|
||||
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
|
||||
RUN chmod +x /usr/local/bin/workshop-entrypoint && \
|
||||
mkdir -p /pb_data && \
|
||||
chown -R pocketbase:pocketbase /pb_data /pb_hooks /pb_migrations /pb_public
|
||||
|
||||
USER pocketbase
|
||||
EXPOSE 8090
|
||||
|
||||
@@ -0,0 +1,19 @@
|
||||
**
|
||||
!package.json
|
||||
!pnpm-lock.yaml
|
||||
!pnpm-workspace.yaml
|
||||
!turbo.json
|
||||
!patches/**
|
||||
!apps/docs/**
|
||||
!apps/workshop/entrypoint.sh
|
||||
!apps/workshop/pb_hooks/**
|
||||
!apps/workshop/pb_migrations/**
|
||||
!packages/definitions/**
|
||||
!packages/validation/**
|
||||
!packages/workshop/**
|
||||
!tooling/typescript/**
|
||||
**/node_modules
|
||||
**/.docusaurus
|
||||
**/build
|
||||
**/dist
|
||||
**/.turbo
|
||||
@@ -1,21 +1,21 @@
|
||||
# Homarr Workshop backend
|
||||
# Homarr Workshop service
|
||||
|
||||
PocketBase provides the central Workshop API at `workshop.homarr.dev`. It is not a per-instance Homarr service. Local deployment is for development and integration testing.
|
||||
The Workshop image serves the compiled Homarr documentation and the PocketBase community API from one origin: `https://homarr.dev`. It is not a per-instance Homarr service. Local deployment is for development and integration testing.
|
||||
|
||||
## Local startup
|
||||
|
||||
1. Copy the repository-root `.env.example` to `.env` and create a GitHub OAuth app.
|
||||
2. Set its callback URL to `http://localhost:<PB_EXPOSE_PORT>/api/oauth2-redirect` (port `8090` by default).
|
||||
3. Set `GITHUB_CLIENT_ID`, `GITHUB_CLIENT_SECRET`, `PB_SUPERUSER_EMAIL`, and `PB_SUPERUSER_PASSWORD`.
|
||||
4. Run `pnpm dev:workshop` from the repository root. It starts PocketBase and the Docusaurus Workshop together and removes obsolete Compose containers.
|
||||
4. Run `pnpm dev:workshop` from the repository root. It builds the single Workshop image, starts it, and removes obsolete Compose containers.
|
||||
|
||||
Open the Workshop at `http://localhost:3003/workshop`. The API is at `http://localhost:<PB_EXPOSE_PORT>` and the emergency PocketBase dashboard is at `http://localhost:<PB_EXPOSE_PORT>/_/` (port `8090` by default). `0.0.0.0` in PocketBase's server log is its container bind address, not a browser URL. Day-to-day moderation belongs in `/workshop/admin`, not the PocketBase dashboard.
|
||||
Open the Workshop at `http://localhost:<PB_EXPOSE_PORT>/workshop`. The API is at `/api` on the same origin and the emergency PocketBase dashboard is at `/_/` (port `8090` by default). `0.0.0.0` in PocketBase's server log is its container bind address, not a browser URL. Day-to-day moderation belongs in `/workshop/admin`, not the PocketBase dashboard.
|
||||
|
||||
Use `pnpm dev:workshop:backend` when only the API is needed. All Workshop configuration is read from the repository-root `.env`; there is no app-specific environment file. If port 8090 is unavailable, set `PB_EXPOSE_PORT` there. The website derives its local API URL from that port unless `WORKSHOP_API_URL` is explicitly set.
|
||||
All Workshop configuration is read from the repository-root `.env`; there is no app-specific environment file. If port 8090 is unavailable, set `PB_EXPOSE_PORT` there. `pnpm dev:workshop:website` remains available for Docusaurus HMR and connects to the local container through `WORKSHOP_API_URL`.
|
||||
|
||||
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.
|
||||
The multi-stage image builds Docusaurus, downloads the official PocketBase `0.39.6` release, verifies it against that release's `checksums.txt`, and copies the versioned migrations, hooks, and static website into the runtime image.
|
||||
|
||||
GitHub OAuth configuration is synchronized from the environment on every boot, including for an existing development volume. Local CORS is restricted to the documented Homarr and Docusaurus origins in `PB_ALLOWED_ORIGINS`; production must set it to `https://homarr.dev`.
|
||||
GitHub OAuth configuration is synchronized from the environment on every boot, including for an existing development volume. `PB_ALLOWED_ORIGINS` defaults to `*` because arbitrary self-hosted Homarr origins consume the central API. PocketBase authenticates writes with bearer tokens and does not use cookies; CORS is not an authorization boundary.
|
||||
|
||||
## Roles and account states
|
||||
|
||||
@@ -41,6 +41,7 @@ Restore only into the same PocketBase version after preserving the current volum
|
||||
|
||||
## Production checklist
|
||||
|
||||
- Run `ghcr.io/homarr-labs/workshop:dev` behind the `homarr.dev` TLS endpoint; the image owns both the website and `/api`.
|
||||
- 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.
|
||||
|
||||
@@ -3,24 +3,23 @@ name: homarr-workshop
|
||||
services:
|
||||
workshop:
|
||||
build:
|
||||
context: .
|
||||
context: ../..
|
||||
dockerfile: apps/workshop/Dockerfile
|
||||
args:
|
||||
POCKETBASE_VERSION: 0.39.6
|
||||
image: homarr/workshop-pocketbase:0.39.6
|
||||
image: homarr/workshop: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:-}
|
||||
PB_PUBLIC_URL: http://localhost:${PB_EXPOSE_PORT:-8090}
|
||||
PB_ALLOWED_ORIGINS: ${PB_ALLOWED_ORIGINS:-http://localhost:3000,http://127.0.0.1:3000,http://localhost:3003,http://127.0.0.1:3003,http://localhost:7575,http://127.0.0.1:7575}
|
||||
PB_PUBLIC_URL: ${PB_PUBLIC_URL:-http://localhost:${PB_EXPOSE_PORT:-8090}}
|
||||
PB_ALLOWED_ORIGINS: ${PB_ALLOWED_ORIGINS:-*}
|
||||
ports:
|
||||
- "127.0.0.1:${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
|
||||
|
||||
|
||||
@@ -5,12 +5,14 @@ if [ -n "${PB_SUPERUSER_EMAIL:-}" ] && [ -n "${PB_SUPERUSER_PASSWORD:-}" ]; then
|
||||
pocketbase superuser upsert "${PB_SUPERUSER_EMAIL}" "${PB_SUPERUSER_PASSWORD}" --dir=/pb_data
|
||||
fi
|
||||
|
||||
echo "Workshop API: ${PB_PUBLIC_URL:-http://localhost:8090}"
|
||||
echo "Workshop website: http://localhost:3003/workshop"
|
||||
echo "Workshop: ${PB_PUBLIC_URL:-http://localhost:8090}/workshop"
|
||||
echo "Workshop API: ${PB_PUBLIC_URL:-http://localhost:8090}/api"
|
||||
|
||||
exec pocketbase serve \
|
||||
--http=0.0.0.0:8090 \
|
||||
--origins="${PB_ALLOWED_ORIGINS:-http://localhost:3000,http://127.0.0.1:3000,http://localhost:3003,http://127.0.0.1:3003,http://localhost:7575,http://127.0.0.1:7575}" \
|
||||
--origins="${PB_ALLOWED_ORIGINS:-*}" \
|
||||
--dir=/pb_data \
|
||||
--hooksDir=/pb_hooks \
|
||||
--migrationsDir=/pb_migrations
|
||||
--migrationsDir=/pb_migrations \
|
||||
--publicDir=/pb_public \
|
||||
--indexFallback=true
|
||||
Reference in new issue
Block a user