ci(workshop): add combined deployment image

This commit is contained in:
Thomas Camlong committed 2026-07-14 19:05:13 +02:00
1 parent 89260b8754
commit fd28289f40
39 files changed
+636 -436

No files matched your search

@@ -11,7 +11,7 @@ The [Community Workshop](pathname:///workshop) is where Homarr users share and d
**custom CSS themes** and **custom widgets**. You can browse submissions, upvote the ones
you like, report abuse, and install entries directly into your own Homarr instance.
:::warn
:::warning
The workshop contains community-made content. Widgets may contain unwanted or malicious content and it is your duty to check & verify safety of the content. While we try to remove malicious content as best as we can, we take no responsibility. Always review the widget JSON and all CSS before installing it on your instance and take frequent backups as prevention measures.
@@ -52,10 +52,8 @@ You can install workshop entries without leaving Homarr:
Alternatively, use **Copy** (or **Download** for widgets) on any submission and import it
manually.
## Self-hosting the workshop backend
## Workshop deployment
The workshop is powered by a [PocketBase](https://pocketbase.io) instance (GitHub OAuth, database,
file uploads, and an admin moderation dashboard). The deployment lives in `apps/store/` with a
`docker-compose.yml`, version-controlled collection migrations, and vote-counter hooks. See the
app's `README.md` for GitHub OAuth setup and the `STORE_URL` / `NEXT_PUBLIC_WIDGET_STORE_URL`
configuration.
Workshop is powered by [PocketBase](https://pocketbase.io). The production image compiles the current Docusaurus site and serves it together with the API from one origin. The deployment lives in `apps/workshop/` with a Dockerfile, Compose configuration, version-controlled migrations, hooks, a smoke test, and ACT-local workflow instructions.
Homarr instances use `NEXT_PUBLIC_WORKSHOP_API_URL` to reach the central API and `NEXT_PUBLIC_WORKSHOP_URL` for links to the public page. Docusaurus uses `WORKSHOP_URL` only when its development server runs separately; the combined production image always uses its own origin.
+3 -2
View File
@@ -21,7 +21,8 @@ const config: Config = {
onDuplicateRoutes: "throw",
customFields: {
storeUrl: process.env.STORE_URL ?? "http://localhost:8090",
// Empty means same-origin, which is the production Workshop image shape.
workshopUrl: process.env.WORKSHOP_URL ?? "",
},
future: {
@@ -310,7 +311,7 @@ const config: Config = {
async contentLoaded({ actions }) {
actions.addRoute({
path: "/workshop/:id",
component: "@site/src/components/store/DetailPage",
component: "@site/src/components/workshop/DetailPage",
exact: true,
});
},
@@ -1,7 +1,7 @@
import React, { useEffect, useState } from "react";
import { IconCheck, IconEdit, IconSend, IconTrash, IconX } from "@tabler/icons-react";
import type { StoreComment } from "@site/src/lib/pocketbase";
import type { WorkshopComment } from "@site/src/lib/pocketbase";
import { getPocketBase } from "@site/src/lib/pocketbase";
import { errorMessage } from "@site/src/lib/utils";
@@ -10,7 +10,7 @@ import { Input } from "@/components/ui/input";
import { formatRelativeTime } from "./format";
const commentAuthorName = (comment: StoreComment) =>
const commentAuthorName = (comment: WorkshopComment) =>
comment.expand?.author?.name || comment.expand?.author?.username || "unknown";
interface CommentsSectionProps {
@@ -22,7 +22,7 @@ interface CommentsSectionProps {
}
export const CommentsSection = ({ submissionId, pb, currentUserId, onRequireAuth, onError }: CommentsSectionProps) => {
const [rows, setRows] = useState<StoreComment[]>([]);
const [rows, setRows] = useState<WorkshopComment[]>([]);
const [loading, setLoading] = useState(true);
const [fetchError, setFetchError] = useState(false);
const [newComment, setNewComment] = useState("");
@@ -34,7 +34,7 @@ export const CommentsSection = ({ submissionId, pb, currentUserId, onRequireAuth
setLoading(true);
setFetchError(false);
pb.collection("comments")
.getFullList<StoreComment>({
.getFullList<WorkshopComment>({
filter: pb.filter("submission = {:id}", { id: submissionId }),
sort: "-created",
expand: "author",
@@ -61,7 +61,7 @@ export const CommentsSection = ({ submissionId, pb, currentUserId, onRequireAuth
try {
const created = await pb
.collection("comments")
.create<StoreComment>({ submission: submissionId, content: trimmed, author: userId }, { expand: "author" });
.create<WorkshopComment>({ submission: submissionId, content: trimmed, author: userId }, { expand: "author" });
setRows((prev) => [created, ...prev]);
setNewComment("");
} catch (caught) {
@@ -75,7 +75,7 @@ export const CommentsSection = ({ submissionId, pb, currentUserId, onRequireAuth
try {
const updated = await pb
.collection("comments")
.update<StoreComment>(id, { content: trimmed }, { expand: "author" });
.update<WorkshopComment>(id, { content: trimmed }, { expand: "author" });
setRows((prev) => prev.map((comment) => (comment.id === id ? updated : comment)));
setEditingId(null);
} catch (caught) {
@@ -15,9 +15,9 @@ import {
} from "@tabler/icons-react";
import type { ClientResponseError } from "pocketbase";
import type { StoreSubmission, StoreVote } from "@site/src/lib/pocketbase";
import type { WorkshopSubmission, WorkshopVote } from "@site/src/lib/pocketbase";
import { getPocketBase, getSubmissionFileUrl } from "@site/src/lib/pocketbase";
import type { SubmissionType } from "@site/src/lib/store-schema";
import type { SubmissionType } from "@site/src/lib/workshop-schema";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
@@ -26,7 +26,7 @@ import { cn, errorMessage } from "@site/src/lib/utils";
import { CommentsSection } from "./DetailComments";
import { CodeBlock, DeleteConfirmButton, DetailSkeleton, ScreenshotGallery } from "./DetailSections";
import { formatRelativeTime } from "./format";
import { downloadSubmissionJson, voteDelta } from "./store-utils";
import { downloadSubmissionJson, voteDelta } from "./workshop-utils";
const typeLabels: Record<SubmissionType, string> = { css: "CSS", widget: "Widget" };
const typeDotColors: Record<SubmissionType, string> = { css: "bg-blue-500", widget: "bg-yellow-500" };
@@ -53,13 +53,13 @@ const parseSubmissionId = (pathname: string) => {
const isNotFound = (caught: unknown) =>
typeof caught === "object" && caught !== null && "status" in caught && (caught as ClientResponseError).status === 404;
const MarketplaceDetail = ({ storeUrl }: { storeUrl: string }) => {
const MarketplaceDetail = ({ workshopUrl }: { workshopUrl: string }) => {
const location = useLocation();
const submissionId = parseSubmissionId(location.pathname);
const pb = useMemo(() => getPocketBase(storeUrl), [storeUrl]);
const pb = useMemo(() => getPocketBase(workshopUrl), [workshopUrl]);
const [submission, setSubmission] = useState<StoreSubmission | null>(null);
const [userVote, setUserVote] = useState<StoreVote | undefined>();
const [submission, setSubmission] = useState<WorkshopSubmission | null>(null);
const [userVote, setUserVote] = useState<WorkshopVote | undefined>();
const [user, setUser] = useState(pb.authStore.record);
const [loading, setLoading] = useState(true);
const [notFound, setNotFound] = useState(false);
@@ -120,12 +120,12 @@ const MarketplaceDetail = ({ storeUrl }: { storeUrl: string }) => {
const load = async () => {
try {
const record = await pb.collection("marketplace").getOne<StoreSubmission>(submissionId);
const record = await pb.collection("marketplace").getOne<WorkshopSubmission>(submissionId);
if (cancelled) return;
setSubmission(record);
if (pb.authStore.isValid && pb.authStore.record) {
const votes = await pb.collection("votes").getFullList<StoreVote>({
const votes = await pb.collection("votes").getFullList<WorkshopVote>({
filter: pb.filter("user = {:uid} && submission = {:sid}", {
uid: pb.authStore.record.id,
sid: submissionId,
@@ -170,7 +170,7 @@ const MarketplaceDetail = ({ storeUrl }: { storeUrl: string }) => {
setUserVote(
isToggleOff
? undefined
: ({ ...(prev ?? { id: "", submission: submission.id, user: userId }), value } as StoreVote),
: ({ ...(prev ?? { id: "", submission: submission.id, user: userId }), value } as WorkshopVote),
);
setSubmission((s) => (s ? { ...s, upvotes: s.upvotes + upD, downvotes: s.downvotes + downD } : s));
@@ -178,7 +178,7 @@ const MarketplaceDetail = ({ storeUrl }: { storeUrl: string }) => {
if (!prev) {
const created = await pb
.collection("votes")
.create<StoreVote>({ submission: submission.id, value, user: userId });
.create<WorkshopVote>({ submission: submission.id, value, user: userId });
setUserVote(created);
} else if (isToggleOff) {
await pb.collection("votes").delete(prev.id);
@@ -347,12 +347,14 @@ const MarketplaceDetail = ({ storeUrl }: { storeUrl: string }) => {
export default function MarketplaceDetailPage() {
const { siteConfig } = useDocusaurusContext();
const storeUrl = (siteConfig.customFields?.storeUrl as string | undefined) ?? "http://localhost:8090";
const configuredWorkshopUrl = (siteConfig.customFields?.workshopUrl as string | undefined) ?? "";
return (
<Layout title="Workshop" description="Community custom CSS and custom widgets for Homarr">
<main className="marketplace bg-background text-foreground min-h-[80vh]">
<BrowserOnly fallback={<DetailSkeleton />}>{() => <MarketplaceDetail storeUrl={storeUrl} />}</BrowserOnly>
<BrowserOnly fallback={<DetailSkeleton />}>
{() => <MarketplaceDetail workshopUrl={configuredWorkshopUrl || window.location.origin} />}
</BrowserOnly>
</main>
</Layout>
);
@@ -12,8 +12,8 @@ import {
IconX,
} from "@tabler/icons-react";
import type { SubmissionType } from "@site/src/lib/store-schema";
import { validateSubmissionContent } from "@site/src/lib/store-schema";
import type { SubmissionType } from "@site/src/lib/workshop-schema";
import { validateSubmissionContent } from "@site/src/lib/workshop-schema";
import { Button } from "@/components/ui/button";
import {
@@ -28,7 +28,7 @@ import { Input } from "@/components/ui/input";
import { Textarea } from "@/components/ui/textarea";
import { cn, errorMessage } from "@/lib/utils";
import type { SubmitInput } from "./useStore";
import type { SubmitInput } from "./useWorkshop";
interface Props {
onClose: () => void;
@@ -20,8 +20,8 @@ import {
IconX,
} from "@tabler/icons-react";
import { getSubmissionFileUrl, type StoreSubmission } from "@site/src/lib/pocketbase";
import type { SubmissionType } from "@site/src/lib/store-schema";
import { getSubmissionFileUrl, type WorkshopSubmission } from "@site/src/lib/pocketbase";
import type { SubmissionType } from "@site/src/lib/workshop-schema";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
@@ -38,9 +38,9 @@ import { cn } from "@/lib/utils";
import { SubmitForm } from "./SubmitForm";
import { formatRelativeTime } from "./format";
import { downloadSubmissionJson } from "./store-utils";
import type { SortKey, TypeFilter } from "./useStore";
import { useStore } from "./useStore";
import { downloadSubmissionJson } from "./workshop-utils";
import type { SortKey, TypeFilter } from "./useWorkshop";
import { useWorkshop } from "./useWorkshop";
const typeDotColors: Record<SubmissionType, string> = { css: "bg-blue-500", widget: "bg-yellow-500" };
const typeLabels: Record<SubmissionType, string> = { css: "CSS", widget: "Widget" };
@@ -76,34 +76,34 @@ const stopCardNavigation = (event: React.MouseEvent<HTMLButtonElement>) => {
event.stopPropagation();
};
export const StoreApp = ({ storeUrl }: { storeUrl: string }) => {
const store = useStore(storeUrl);
export const WorkshopApp = ({ workshopUrl }: { workshopUrl: string }) => {
const workshop = useWorkshop(workshopUrl);
const [typeFilter, setTypeFilter] = useState<TypeFilter>("all");
const [sort, setSort] = useState<SortKey>("top");
const [search, setSearch] = useState("");
const [showSubmit, setShowSubmit] = useState(false);
// Stable sort order: only re-sort when filter/sort/search or submission set changes (not vote counts)
const submissionIds = useMemo(() => store.submissions.map((s) => s.id).join(","), [store.submissions]);
const submissionIds = useMemo(() => workshop.submissions.map((s) => s.id).join(","), [workshop.submissions]);
const sortedIds = useMemo(() => {
const q = search.toLowerCase();
return store.submissions
return workshop.submissions
.filter((item) => {
if (typeFilter === "yours") return item.author === store.user?.id;
if (typeFilter === "yours") return item.author === workshop.user?.id;
return typeFilter === "all" || item.type === typeFilter;
})
.filter((item) => !q || item.title.toLowerCase().includes(q))
.toSorted(store.sorters[sort])
.toSorted(workshop.sorters[sort])
.map((s) => s.id);
// eslint-disable-next-line react-hooks/exhaustive-deps -- intentionally keyed on submissionIds string, not the array
}, [submissionIds, store.sorters, typeFilter, sort, search, store.user?.id]);
}, [submissionIds, workshop.sorters, typeFilter, sort, search, workshop.user?.id]);
const visible = useMemo(() => {
const byId = new Map(store.submissions.map((s) => [s.id, s]));
return sortedIds.map((id) => byId.get(id)).filter(Boolean) as StoreSubmission[];
}, [store.submissions, sortedIds]);
const byId = new Map(workshop.submissions.map((s) => [s.id, s]));
return sortedIds.map((id) => byId.get(id)).filter(Boolean) as WorkshopSubmission[];
}, [workshop.submissions, sortedIds]);
const empty = emptyState[store.submissions.length === 0 ? "none" : "filtered"];
const empty = emptyState[workshop.submissions.length === 0 ? "none" : "filtered"];
return (
<div className="mx-auto max-w-7xl px-4 pb-16">
@@ -113,18 +113,18 @@ export const StoreApp = ({ storeUrl }: { storeUrl: string }) => {
<p className="mt-1 text-sm text-muted-foreground">Community custom widgets and CSS themes for Homarr.</p>
</div>
<div className="flex items-center gap-2">
{store.user ? (
{workshop.user ? (
<>
<Button onClick={() => setShowSubmit(true)}>
<IconPlus size={14} /> Share yours
</Button>
<Button variant="ghost" size="sm" onClick={store.logout}>
<IconLogout size={14} /> {store.user?.name || store.user?.username || "Account"}
<Button variant="ghost" size="sm" onClick={workshop.logout}>
<IconLogout size={14} /> {workshop.user?.name || workshop.user?.username || "Account"}
</Button>
</>
) : (
<div className="flex flex-col items-end gap-0.5">
<Button onClick={() => void store.login()}>
<Button onClick={() => void workshop.login()}>
<IconBrandGithub size={14} /> Sign in with GitHub
</Button>
<p className="text-xs text-muted-foreground">Vote and share your creations</p>
@@ -175,13 +175,13 @@ export const StoreApp = ({ storeUrl }: { storeUrl: string }) => {
</div>
</div>
{store.error && (
{workshop.error && (
<div className="mb-4 rounded-lg border border-destructive/30 bg-destructive/10 px-4 py-2.5 text-sm text-destructive">
{store.error}
{workshop.error}
</div>
)}
{store.loading && (
{workshop.loading && (
<div className="columns-1 gap-4 lg:columns-2 xl:columns-3 2xl:columns-4 [&>*]:mb-4">
{Array.from({ length: 6 }, (_, i) => (
<SkeletonCard key={i} hasImage={i % 3 === 0} />
@@ -189,14 +189,14 @@ export const StoreApp = ({ storeUrl }: { storeUrl: string }) => {
</div>
)}
{!store.loading && visible.length === 0 && (
{!workshop.loading && visible.length === 0 && (
<div className="flex flex-col items-center gap-3 rounded-lg border border-dashed border-border py-16">
<IconPackage size={28} stroke={1.5} className="text-muted-foreground" />
<div className="text-center">
<p className="text-sm font-medium">{empty.title}</p>
<p className="mt-0.5 text-xs text-muted-foreground">{empty.hint}</p>
</div>
{store.user && store.submissions.length === 0 && (
{workshop.user && workshop.submissions.length === 0 && (
<Button size="sm" onClick={() => setShowSubmit(true)}>
<IconPlus size={14} /> Create submission
</Button>
@@ -209,12 +209,12 @@ export const StoreApp = ({ storeUrl }: { storeUrl: string }) => {
<SubmissionCard
key={submission.id}
submission={submission}
pb={store.pb}
userVote={store.votes[submission.id]?.value}
currentUserId={store.user?.id}
onVote={store.vote}
onReport={store.report}
onDelete={store.deleteSubmission}
pb={workshop.pb}
userVote={workshop.votes[submission.id]?.value}
currentUserId={workshop.user?.id}
onVote={workshop.vote}
onReport={workshop.report}
onDelete={workshop.deleteSubmission}
/>
))}
</div>
@@ -223,7 +223,7 @@ export const StoreApp = ({ storeUrl }: { storeUrl: string }) => {
<SubmitForm
onClose={() => setShowSubmit(false)}
onSubmit={async (input) => {
if (await store.submit(input)) setShowSubmit(false);
if (await workshop.submit(input)) setShowSubmit(false);
}}
/>
)}
@@ -232,8 +232,8 @@ export const StoreApp = ({ storeUrl }: { storeUrl: string }) => {
};
interface SubmissionCardProps {
submission: StoreSubmission;
pb: ReturnType<typeof useStore>["pb"];
submission: WorkshopSubmission;
pb: ReturnType<typeof useWorkshop>["pb"];
userVote?: 1 | -1;
currentUserId?: string;
onVote: (submissionId: string, value: 1 | -1) => void;
@@ -1,13 +1,13 @@
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import dayjs from "dayjs";
import type { StoreComment, StoreSubmission, StoreVote } from "@site/src/lib/pocketbase";
import type { WorkshopComment, WorkshopSubmission, WorkshopVote } from "@site/src/lib/pocketbase";
import { getPocketBase } from "@site/src/lib/pocketbase";
import type { SubmissionType } from "@site/src/lib/store-schema";
import { schemaVersionByType, validateSubmissionContent } from "@site/src/lib/store-schema";
import type { SubmissionType } from "@site/src/lib/workshop-schema";
import { schemaVersionByType, validateSubmissionContent } from "@site/src/lib/workshop-schema";
import { errorMessage } from "@site/src/lib/utils";
import { voteDelta } from "./store-utils";
import { voteDelta } from "./workshop-utils";
export type SortKey = "top" | "new";
export type TypeFilter = "all" | "yours" | SubmissionType;
@@ -21,13 +21,13 @@ export interface SubmitInput {
}
export interface CommentActions {
fetch: (submissionId: string) => Promise<StoreComment[]>;
add: (submissionId: string, content: string) => Promise<StoreComment | null>;
update: (commentId: string, content: string) => Promise<StoreComment | null>;
fetch: (submissionId: string) => Promise<WorkshopComment[]>;
add: (submissionId: string, content: string) => Promise<WorkshopComment | null>;
update: (commentId: string, content: string) => Promise<WorkshopComment | null>;
delete: (commentId: string) => Promise<boolean>;
}
const sorters: Record<SortKey, (a: StoreSubmission, b: StoreSubmission) => number> = {
const sorters: Record<SortKey, (a: WorkshopSubmission, b: WorkshopSubmission) => number> = {
top: (a, b) => b.upvotes - b.downvotes - (a.upvotes - a.downvotes),
new: (a, b) => dayjs(b.created).valueOf() - dayjs(a.created).valueOf(),
};
@@ -35,10 +35,10 @@ const sorters: Record<SortKey, (a: StoreSubmission, b: StoreSubmission) => numbe
const isNotFoundError = (error: unknown) =>
typeof error === "object" && error !== null && "status" in error && error.status === 404;
export const useStore = (storeUrl: string) => {
const pb = useMemo(() => getPocketBase(storeUrl), [storeUrl]);
const [submissions, setSubmissions] = useState<StoreSubmission[]>([]);
const [votes, setVotes] = useState<Record<string, StoreVote>>({});
export const useWorkshop = (workshopUrl: string) => {
const pb = useMemo(() => getPocketBase(workshopUrl), [workshopUrl]);
const [submissions, setSubmissions] = useState<WorkshopSubmission[]>([]);
const [votes, setVotes] = useState<Record<string, WorkshopVote>>({});
const [user, setUser] = useState(pb.authStore.record);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
@@ -49,7 +49,7 @@ export const useStore = (storeUrl: string) => {
setVotes({});
return;
}
const rows = await pb.collection("votes").getFullList<StoreVote>({
const rows = await pb.collection("votes").getFullList<WorkshopVote>({
filter: pb.filter("user = {:id}", { id: pb.authStore.record.id }),
});
setVotes(Object.fromEntries(rows.map((row) => [row.submission, row])));
@@ -59,7 +59,7 @@ export const useStore = (storeUrl: string) => {
setLoading(true);
setError(null);
try {
setSubmissions(await pb.collection("marketplace").getFullList<StoreSubmission>({ sort: "-created" }));
setSubmissions(await pb.collection("marketplace").getFullList<WorkshopSubmission>({ sort: "-created" }));
await refreshVotes();
} catch (caught) {
setError(errorMessage(caught, "Failed to load the workshop"));
@@ -162,7 +162,7 @@ export const useStore = (storeUrl: string) => {
next[submissionId] = {
...(prev ?? { id: "", submission: submissionId, user: userId, created: "", updated: "" }),
value,
} as StoreVote;
} as WorkshopVote;
return next;
});
setSubmissions((s) =>
@@ -176,7 +176,7 @@ export const useStore = (storeUrl: string) => {
if (!existing) {
const created = await pb
.collection("votes")
.create<StoreVote>({ submission: submissionId, value, user: userId });
.create<WorkshopVote>({ submission: submissionId, value, user: userId });
setVotes((current) => ({ ...current, [submissionId]: created }));
} else if (isToggleOff) {
try {
@@ -186,13 +186,13 @@ export const useStore = (storeUrl: string) => {
}
} else {
try {
const updated = await pb.collection("votes").update<StoreVote>(existing.id, { value });
const updated = await pb.collection("votes").update<WorkshopVote>(existing.id, { value });
setVotes((current) => ({ ...current, [submissionId]: updated }));
} catch (caught) {
if (!isNotFoundError(caught)) throw caught;
const created = await pb
.collection("votes")
.create<StoreVote>({ submission: submissionId, value, user: userId });
.create<WorkshopVote>({ submission: submissionId, value, user: userId });
setVotes((current) => ({ ...current, [submissionId]: created }));
}
}
@@ -268,7 +268,7 @@ export const useStore = (storeUrl: string) => {
const fetchComments = useCallback(
(submissionId: string) =>
pb.collection("comments").getFullList<StoreComment>({
pb.collection("comments").getFullList<WorkshopComment>({
filter: pb.filter("submission = {:id}", { id: submissionId }),
sort: "-created",
expand: "author",
@@ -283,7 +283,7 @@ export const useStore = (storeUrl: string) => {
try {
return await pb
.collection("comments")
.create<StoreComment>({ submission: submissionId, content, author: userId }, { expand: "author" });
.create<WorkshopComment>({ submission: submissionId, content, author: userId }, { expand: "author" });
} catch (caught) {
setError(errorMessage(caught, "Failed to post comment"));
return null;
@@ -295,7 +295,7 @@ export const useStore = (storeUrl: string) => {
const updateComment = useCallback(
async (commentId: string, content: string) => {
try {
return await pb.collection("comments").update<StoreComment>(commentId, { content }, { expand: "author" });
return await pb.collection("comments").update<WorkshopComment>(commentId, { content }, { expand: "author" });
} catch (caught) {
setError(errorMessage(caught, "Failed to update comment"));
return null;
@@ -1,6 +1,6 @@
import type { StoreSubmission } from "@site/src/lib/pocketbase";
import type { WorkshopSubmission } from "@site/src/lib/pocketbase";
export const downloadSubmissionJson = (submission: StoreSubmission) => {
export const downloadSubmissionJson = (submission: WorkshopSubmission) => {
const url = URL.createObjectURL(new Blob([submission.content], { type: "application/json" }));
Object.assign(document.createElement("a"), {
href: url,
+3 -3
View File
@@ -14,7 +14,7 @@ export const getPocketBase = (url: string): PocketBase => {
export const getSubmissionFileUrl = (baseUrl: string, submissionId: string, filename: string) =>
`${baseUrl}/api/files/submissions/${submissionId}/${encodeURIComponent(filename)}`;
export interface StoreSubmission {
export interface WorkshopSubmission {
id: string;
collectionId: string;
collectionName: string;
@@ -34,14 +34,14 @@ export interface StoreSubmission {
created: string;
}
export interface StoreVote {
export interface WorkshopVote {
id: string;
submission: string;
user: string;
value: 1 | -1;
}
export interface StoreComment {
export interface WorkshopComment {
id: string;
submission: string;
author: string;
@@ -1,6 +1,6 @@
import { describe, expect, it } from "vitest";
import { validateSubmissionContent } from "./store-schema";
import { validateSubmissionContent } from "./workshop-schema";
const validWidget = {
$schema: "homarr-custom-widget-v2",
@@ -7,7 +7,7 @@ export const CSS_SCHEMA_VERSION = "homarr-custom-css-v1";
export const MAX_CSS_LENGTH = 16384;
export type StoreValidationResult = { success: true; data: unknown } | { success: false; error: string };
export type WorkshopValidationResult = { success: true; data: unknown } | { success: false; error: string };
const authTypes = ["none", "bearer", "basic", "apiKeyHeader", "apiKeyQuery"] as const;
const methods = ["GET", "POST", "PUT", "DELETE", "PATCH"] as const;
@@ -38,7 +38,7 @@ const widgetSchema = z
message: "displayConfig.type must match displayType",
});
const validateWidgetContent = (raw: string): StoreValidationResult => {
const validateWidgetContent = (raw: string): WorkshopValidationResult => {
let parsed: unknown;
try {
parsed = JSON.parse(raw);
@@ -54,7 +54,7 @@ const validateWidgetContent = (raw: string): StoreValidationResult => {
return { success: true, data: result.data };
};
const validateCssContent = (raw: string): StoreValidationResult => {
const validateCssContent = (raw: string): WorkshopValidationResult => {
if (raw.trim().length === 0) {
return { success: false, error: "CSS cannot be empty" };
}
@@ -67,12 +67,12 @@ const validateCssContent = (raw: string): StoreValidationResult => {
export const submissionValidators = {
widget: validateWidgetContent,
css: validateCssContent,
} satisfies Record<SubmissionType, (raw: string) => StoreValidationResult>;
} satisfies Record<SubmissionType, (raw: string) => WorkshopValidationResult>;
export const schemaVersionByType = {
widget: WIDGET_SCHEMA_VERSION,
css: CSS_SCHEMA_VERSION,
} satisfies Record<SubmissionType, string>;
export const validateSubmissionContent = (type: SubmissionType, raw: string): StoreValidationResult =>
export const validateSubmissionContent = (type: SubmissionType, raw: string): WorkshopValidationResult =>
submissionValidators[type](raw);
+4 -4
View File
@@ -3,17 +3,17 @@ import BrowserOnly from "@docusaurus/BrowserOnly";
import useDocusaurusContext from "@docusaurus/useDocusaurusContext";
import Layout from "@theme/Layout";
export default function StorePage() {
export default function WorkshopPage() {
const { siteConfig } = useDocusaurusContext();
const storeUrl = (siteConfig.customFields?.storeUrl as string | undefined) ?? "http://localhost:8090";
const configuredWorkshopUrl = (siteConfig.customFields?.workshopUrl as string | undefined) ?? "";
return (
<Layout title="Workshop" description="Community custom CSS and custom widgets for Homarr">
<main className="marketplace bg-background text-foreground min-h-[80vh]">
<BrowserOnly fallback={<div style={{ minHeight: "50vh" }} />}>
{() => {
const { StoreApp } = require("@site/src/components/store/StoreApp");
return <StoreApp storeUrl={storeUrl} />;
const { WorkshopApp } = require("@site/src/components/workshop/WorkshopApp");
return <WorkshopApp workshopUrl={configuredWorkshopUrl || window.location.origin} />;
}}
</BrowserOnly>
</main>