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

+2 -2
View File
@@ -62,8 +62,8 @@ TURBO_TELEMETRY_DISABLED=1
# UNSAFE_ENABLE_MOCK_INTEGRATION=true
# Community workshop (PocketBase) base URL used by the in-app "Install from Workshop" buttons
# In production this defaults to https://store.homarr.dev — override for local dev:
# NEXT_PUBLIC_WIDGET_STORE_URL=http://localhost:8090
# Workshop API override for local Homarr development:
# NEXT_PUBLIC_WORKSHOP_API_URL=http://localhost:8090
# Docs site workshop URL used for "Open Workshop" links
# In production this defaults to https://homarr.dev/workshop
# NEXT_PUBLIC_WORKSHOP_URL=http://localhost:3003/workshop
+19
View File
@@ -0,0 +1,19 @@
# Workshop deployment with ACT
The Workshop workflow builds the same image used on the VPS and smoke-tests both Docusaurus and PocketBase. Registry login and pushes are skipped under ACT.
From the repository root:
```sh
pnpm ci:act:workshop
```
The wrapper selects `.github/workflows/deployment-workshop.yml`, derives the active Docker socket (including Docker Desktop and OrbStack contexts), and runs only the `deploy` job using a `workflow_dispatch` event.
To inspect the workflow without running it:
```sh
act workflow_dispatch --list --workflows .github/workflows/deployment-workshop.yml
```
No GitHub or registry secrets are required for the local run.
-56
View File
@@ -1,56 +0,0 @@
name: "[Deployment] Documentation"
on:
push:
branches: ["dev"]
paths:
- "apps/docs/**"
- "packages/definitions/src/docs/**"
- ".github/workflows/deployment-docs.yml"
workflow_dispatch:
permissions:
contents: read
pages: write
id-token: write
concurrency:
group: "pages"
cancel-in-progress: false
env:
SKIP_ENV_VALIDATION: true
TURBO_API: ${{ secrets.TURBO_API }}
TURBO_TEAM: ${{ secrets.TURBO_TEAM }}
TURBO_TOKEN: ${{ secrets.TURBO_TOKEN }}
TURBO_REMOTE_CACHE_SIGNATURE_KEY: ${{ secrets.TURBO_REMOTE_CACHE_SIGNATURE_KEY }}
jobs:
build:
runs-on: ubuntu-latest
timeout-minutes: 10
steps:
- uses: actions/checkout@v7
- name: Setup
uses: ./tooling/github/setup
- name: Build docs
run: pnpm turbo build --filter=@homarr/docs
env:
CI: "true"
- name: Setup Pages
uses: actions/configure-pages@v6
- name: Upload pages artifact
uses: actions/upload-pages-artifact@v5
with:
path: "apps/docs/build"
deploy:
runs-on: ubuntu-latest
needs: build
environment:
name: github-pages
url: ${{ steps.deployment.outputs.page_url }}
steps:
- name: Deploy to GitHub Pages
id: deployment
uses: actions/deploy-pages@v5
+47
View File
@@ -0,0 +1,47 @@
name: "[Deployment] Workshop"
on:
push:
branches: ["dev"]
paths:
- "apps/docs/**"
- "apps/workshop/**"
workflow_dispatch:
permissions:
contents: read
packages: write
concurrency:
group: workshop-deployment
cancel-in-progress: true
jobs:
deploy:
name: Build, test, and publish
runs-on: ubuntu-latest
timeout-minutes: 25
env:
LOCAL_IMAGE: homarr/workshop:ci
REGISTRY_IMAGE: ghcr.io/homarr-labs/workshop
steps:
- name: Check out repository
uses: actions/checkout@v7
- name: Build combined Workshop image
run: docker build --file apps/workshop/Dockerfile --tag "$LOCAL_IMAGE" .
- name: Smoke-test static docs and PocketBase
run: bash apps/workshop/scripts/smoke-test.sh "$LOCAL_IMAGE"
- name: Log in to GitHub Container Registry
if: ${{ env.ACT != 'true' }}
run: echo "${{ github.token }}" | docker login ghcr.io --username "${{ github.actor }}" --password-stdin
- name: Publish immutable and latest tags
if: ${{ env.ACT != 'true' }}
run: |
docker tag "$LOCAL_IMAGE" "$REGISTRY_IMAGE:sha-${GITHUB_SHA}"
docker tag "$LOCAL_IMAGE" "$REGISTRY_IMAGE:latest"
docker push "$REGISTRY_IMAGE:sha-${GITHUB_SHA}"
docker push "$REGISTRY_IMAGE:latest"
@@ -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>
@@ -11,7 +11,7 @@ import { IconInfoCircle } from "@tabler/icons-react";
import { useForm } from "@homarr/form";
import { useI18n, useScopedI18n } from "@homarr/translation/client";
import { InstallCssFromStoreButton } from "~/components/store-install/install-from-store-buttons";
import { InstallCssFromWorkshopButton } from "~/components/workshop-install/install-from-workshop-buttons";
import type { Board } from "../../_types";
import { useSavePartialSettingsMutation } from "./_shared";
import classes from "./customcss.module.css";
@@ -47,7 +47,7 @@ export const CustomCssSettingsContent = ({ board }: Props) => {
</Alert>
<Group justify="space-between">
<InstallCssFromStoreButton onSelect={(css) => form.setFieldValue("customCss", css)} />
<InstallCssFromWorkshopButton onSelect={(css) => form.setFieldValue("customCss", css)} />
<Button type="submit" loading={isPending}>
{t("common.action.saveChanges")}
</Button>
@@ -9,7 +9,7 @@ import { Link } from "@homarr/ui";
import { ManagePageLayout } from "~/components/manage/manage-page-layout";
import { MobileAffixButton } from "~/components/manage/mobile-affix-button";
import { InstallWidgetFromStoreButton } from "~/components/store-install/install-from-store-buttons";
import { InstallWidgetFromWorkshopButton } from "~/components/workshop-install/install-from-workshop-buttons";
import { CustomWidgetBetaBanner } from "./_beta-banner";
import { ImportCustomWidgetButton } from "./_custom-widget-actions";
import { CustomWidgetList } from "./_custom-widget-list";
@@ -29,7 +29,7 @@ export default async function CustomWidgetsPage() {
title={t("page.list.title")}
primaryAction={
<Group gap="xs">
<InstallWidgetFromStoreButton />
<InstallWidgetFromWorkshopButton />
<ImportCustomWidgetButton />
<MobileAffixButton component={Link} href="/manage/custom-widgets/new" leftSection={<IconPlus size={16} />}>
{t("action.create")}
@@ -3,7 +3,7 @@
import { useState } from "react";
import { Button } from "@mantine/core";
import { useDisclosure } from "@mantine/hooks";
import { IconBuildingStore } from "@tabler/icons-react";
import { IconUsersGroup } from "@tabler/icons-react";
import { clientApi } from "@homarr/api/client";
import { revalidatePathActionAsync } from "@homarr/common/client";
@@ -12,7 +12,7 @@ import { useScopedI18n } from "@homarr/translation/client";
import { customWidgetImportSchema } from "@homarr/validation/custom-widget";
import { MobileAffixButton } from "~/components/manage/mobile-affix-button";
import { StoreBrowserModal } from "./store-browser-modal";
import { WorkshopBrowserModal } from "./workshop-browser-modal";
function parseWidgetContent(content: string) {
let parsed: unknown;
@@ -28,7 +28,7 @@ function parseWidgetContent(content: string) {
return { ok: true as const, data: result.data };
}
export const InstallWidgetFromStoreButton = () => {
export const InstallWidgetFromWorkshopButton = () => {
const t = useScopedI18n("customWidget.workshop");
const [opened, { open, close }] = useDisclosure(false);
const [pendingId, setPendingId] = useState<string | null>(null);
@@ -59,10 +59,10 @@ export const InstallWidgetFromStoreButton = () => {
return (
<>
<MobileAffixButton variant="default" leftSection={<IconBuildingStore size={16} />} onClick={open}>
<MobileAffixButton variant="default" leftSection={<IconUsersGroup size={16} />} onClick={open}>
{t("action.installFromWorkshop")}
</MobileAffixButton>
<StoreBrowserModal
<WorkshopBrowserModal
type="widget"
opened={opened}
onClose={close}
@@ -74,16 +74,16 @@ export const InstallWidgetFromStoreButton = () => {
);
};
export const InstallCssFromStoreButton = ({ onSelect }: { onSelect: (css: string) => void }) => {
export const InstallCssFromWorkshopButton = ({ onSelect }: { onSelect: (css: string) => void }) => {
const t = useScopedI18n("customWidget.workshop");
const [opened, { open, close }] = useDisclosure(false);
return (
<>
<Button variant="default" leftSection={<IconBuildingStore size={16} />} onClick={open}>
<Button variant="default" leftSection={<IconUsersGroup size={16} />} onClick={open}>
{t("action.installFromWorkshop")}
</Button>
<StoreBrowserModal
<WorkshopBrowserModal
type="css"
opened={opened}
onClose={close}
@@ -1,6 +1,6 @@
export const STORE_URL = process.env.NEXT_PUBLIC_WIDGET_STORE_URL || "https://store.homarr.dev";
export const WORKSHOP_API_URL = process.env.NEXT_PUBLIC_WORKSHOP_API_URL || "https://workshop.homarr.dev";
export interface StoreSubmission {
export interface WorkshopSubmission {
id: string;
collectionId: string;
collectionName: string;
@@ -24,13 +24,13 @@ interface PBListResponse {
perPage: number;
totalPages: number;
totalItems: number;
items: StoreSubmission[];
items: WorkshopSubmission[];
}
export const getStoreFileUrl = (submission: StoreSubmission, filename: string) =>
`${STORE_URL}/api/files/submissions/${submission.id}/${encodeURIComponent(filename)}`;
export const getWorkshopFileUrl = (submission: WorkshopSubmission, filename: string) =>
`${WORKSHOP_API_URL}/api/files/submissions/${submission.id}/${encodeURIComponent(filename)}`;
export const fetchStoreSubmissions = async (
export const fetchWorkshopSubmissions = async (
type: "css" | "widget",
page = 1,
perPage = 50,
@@ -41,7 +41,7 @@ export const fetchStoreSubmissions = async (
sort: "-upvotes",
filter: `(type='${type}')`,
});
const response = await fetch(`${STORE_URL}/api/collections/marketplace/records?${params}`);
if (!response.ok) throw new Error(`Store request failed (${response.status})`);
const response = await fetch(`${WORKSHOP_API_URL}/api/collections/marketplace/records?${params}`);
if (!response.ok) throw new Error(`Workshop request failed (${response.status})`);
return response.json() as Promise<PBListResponse>;
};
@@ -23,8 +23,8 @@ import {
import { IconChevronLeft, IconChevronRight, IconExternalLink } from "@tabler/icons-react";
import { useQuery } from "@tanstack/react-query";
import type { StoreSubmission } from "./store-api";
import { fetchStoreSubmissions, getStoreFileUrl } from "./store-api";
import type { WorkshopSubmission } from "./workshop-api";
import { fetchWorkshopSubmissions, getWorkshopFileUrl } from "./workshop-api";
const WORKSHOP_URL = process.env.NEXT_PUBLIC_WORKSHOP_URL || "https://homarr.dev/workshop";
@@ -32,22 +32,22 @@ interface Props {
type: "css" | "widget";
opened: boolean;
onClose: () => void;
onSelect: (submission: StoreSubmission) => void;
onSelect: (submission: WorkshopSubmission) => void;
actionLabel: string;
pendingId?: string | null;
}
const displayName = (submission: StoreSubmission) => submission.authorName;
const displayName = (submission: WorkshopSubmission) => submission.authorName;
export const StoreBrowserModal = ({ type, opened, onClose, onSelect, actionLabel, pendingId }: Props) => {
export const WorkshopBrowserModal = ({ type, opened, onClose, onSelect, actionLabel, pendingId }: Props) => {
const [page, setPage] = useState(1);
const handleClose = () => {
setPage(1);
onClose();
};
const { data, isLoading, error } = useQuery({
queryKey: ["store-submissions", type, page],
queryFn: () => fetchStoreSubmissions(type, page),
queryKey: ["workshop-submissions", type, page],
queryFn: () => fetchWorkshopSubmissions(type, page),
enabled: opened,
});
@@ -121,7 +121,7 @@ const SubmissionCard = ({
pending,
onSelect,
}: {
submission: StoreSubmission;
submission: WorkshopSubmission;
actionLabel: string;
pending: boolean;
onSelect: () => void;
@@ -136,7 +136,7 @@ const SubmissionCard = ({
{screenshots.length > 0 && (
<Box pos="relative">
<Image
src={screenshots[imgIdx] ? getStoreFileUrl(submission, screenshots[imgIdx]) : undefined}
src={screenshots[imgIdx] ? getWorkshopFileUrl(submission, screenshots[imgIdx]) : undefined}
alt={submission.title}
h={180}
fit="cover"
-50
View File
@@ -1,50 +0,0 @@
# Homarr Store (PocketBase)
Self-hosted backend for the Homarr community store of **custom CSS** and **custom widgets**.
It handles GitHub OAuth, submissions, upvotes/downvotes, abuse reports, and screenshot
uploads. The browse/submit UI lives in the docs app at `/store`; Homarr installs entries
in-app by reading PocketBase's public REST API.
## Run locally
```bash
cd apps/store
docker compose up
```
- API + dashboard: http://localhost:8090 (admin UI at `/_/`)
- Collections and rules are created automatically by `pb_migrations/`.
- Vote counters and ownership enforcement live in `pb_hooks/`.
## GitHub OAuth setup
1. Create a GitHub OAuth app: https://github.com/settings/developers
- Homepage URL: `https://store.homarr.dev` (or `http://localhost:8090` in dev)
- Authorization callback URL: `https://store.homarr.dev/api/oauth2-redirect`
(dev: `http://localhost:8090/api/oauth2-redirect`)
2. Provide the GitHub credentials and initial admin credentials before the first run so the init migration wires them up and the PocketBase dashboard can be accessed:
```bash
GITHUB_CLIENT_ID=xxx GITHUB_CLIENT_SECRET=yyy PB_ADMIN_EMAIL=admin@example.com PB_ADMIN_PASSWORD=change-me docker compose up
```
If you start without the GitHub credentials, GitHub OAuth is left disabled — enable it later from the admin UI under **Collections -> users -> Options -> OAuth2**. `PB_ADMIN_EMAIL` and `PB_ADMIN_PASSWORD` are used by the PocketBase image to bootstrap the first superuser for the admin UI.
## Collections
| Collection | Purpose |
| ------------- | ---------------------------------------------------------------------------------------------- |
| `users` | Built-in auth collection, GitHub OAuth2 enabled |
| `submissions` | `type` (`css`/`widget`), title, description, content, screenshot, counters, version, changelog |
| `votes` | One row per (submission, user); `value` is `1` or `-1` |
| `reports` | Abuse flags, moderation-only (not publicly readable) |
Submissions are auto-published. Moderate reported entries from the admin UI.
## Schema versions
- Custom widgets store Homarr's `homarr-custom-widget-v2` JSON in `content`.
- Custom CSS stores the raw stylesheet in `content`, tagged `homarr-custom-css-v1`.
`content` is validated client-side against these schemas before submission and before
install (see `apps/docs/src/lib/store-schema.ts`).
-30
View File
@@ -1,30 +0,0 @@
services:
pocketbase:
image: ghcr.io/muchobien/pocketbase:0.26.2
container_name: homarr-store
restart: unless-stopped
environment:
PB_HOST: 0.0.0.0
PB_PORT: 8090
PB_ADMIN_EMAIL: ${PB_ADMIN_EMAIL:-}
PB_ADMIN_PASSWORD: ${PB_ADMIN_PASSWORD:-}
GITHUB_CLIENT_ID: ${GITHUB_CLIENT_ID:-}
GITHUB_CLIENT_SECRET: ${GITHUB_CLIENT_SECRET:-}
ports:
- "${PB_EXPOSE_PORT:-8090}:8090"
command:
- --migrationsDir
- /pb_migrations
volumes:
- pb_data:/pb_data
- ./pb_migrations:/pb_migrations
- ./pb_hooks:/pb_hooks
healthcheck:
test: ["CMD", "wget", "--no-verbose", "--tries=1", "--spider", "http://localhost:8090/api/health"]
interval: 30s
timeout: 10s
retries: 3
start_period: 20s
volumes:
pb_data:
@@ -1,138 +0,0 @@
/// <reference path="../pb_data/types.d.ts" />
migrate(
(app) => {
const users = app.findCollectionByNameOrId("users");
const githubClientId = $os.getenv("GITHUB_CLIENT_ID");
const githubClientSecret = $os.getenv("GITHUB_CLIENT_SECRET");
if (githubClientId && githubClientSecret) {
users.oauth2.enabled = true;
users.oauth2.providers = [
{
name: "github",
clientId: githubClientId,
clientSecret: githubClientSecret,
},
];
}
app.save(users);
const submissions = new Collection({
type: "base",
name: "submissions",
listRule: "",
viewRule: "",
createRule: "@request.auth.id != ''",
updateRule: "author = @request.auth.id",
deleteRule: "author = @request.auth.id",
fields: [
{ name: "type", type: "select", required: true, maxSelect: 1, values: ["css", "widget"] },
{ name: "title", type: "text", required: true, min: 3, max: 100 },
{ name: "description", type: "text", max: 2000 },
{ name: "schemaVersion", type: "text", required: true, max: 100 },
{ name: "content", type: "text", required: true, max: 1000000 },
{
name: "screenshots",
type: "file",
maxSelect: 5,
maxSize: 5242880,
mimeTypes: ["image/png", "image/jpeg", "image/webp", "image/gif"],
},
{ name: "author", type: "relation", required: true, maxSelect: 1, collectionId: users.id, cascadeDelete: true },
{ name: "upvotes", type: "number", onlyInt: true },
{ name: "downvotes", type: "number", onlyInt: true },
{ name: "version", type: "number", onlyInt: true },
{ name: "changelog", type: "text", max: 2000 },
{ name: "created", type: "autodate", onCreate: true },
{ name: "updated", type: "autodate", onCreate: true, onUpdate: true },
],
});
app.save(submissions);
const votes = new Collection({
type: "base",
name: "votes",
listRule: "user = @request.auth.id",
viewRule: "user = @request.auth.id",
createRule: "@request.auth.id != ''",
updateRule: "user = @request.auth.id",
deleteRule: "user = @request.auth.id",
fields: [
{
name: "submission",
type: "relation",
required: true,
maxSelect: 1,
collectionId: submissions.id,
cascadeDelete: true,
},
{ name: "user", type: "relation", required: true, maxSelect: 1, collectionId: users.id, cascadeDelete: true },
{ name: "value", type: "number", required: true, onlyInt: true, min: -1, max: 1 },
{ name: "created", type: "autodate", onCreate: true },
{ name: "updated", type: "autodate", onCreate: true, onUpdate: true },
],
});
votes.addIndex("idx_votes_unique_user_submission", true, "submission, user", "");
app.save(votes);
const reports = new Collection({
type: "base",
name: "reports",
listRule: null,
viewRule: null,
createRule: "@request.auth.id != ''",
updateRule: null,
deleteRule: null,
fields: [
{
name: "submission",
type: "relation",
required: true,
maxSelect: 1,
collectionId: submissions.id,
cascadeDelete: true,
},
{ name: "user", type: "relation", required: true, maxSelect: 1, collectionId: users.id, cascadeDelete: true },
{ name: "reason", type: "text", required: true, min: 3, max: 1000 },
{ name: "created", type: "autodate", onCreate: true },
],
});
app.save(reports);
const comments = new Collection({
type: "base",
name: "comments",
listRule: "",
viewRule: "",
createRule: "@request.auth.id != ''",
updateRule: "author = @request.auth.id",
deleteRule: "author = @request.auth.id",
fields: [
{
name: "submission",
type: "relation",
required: true,
maxSelect: 1,
collectionId: submissions.id,
cascadeDelete: true,
},
{ name: "author", type: "relation", required: true, maxSelect: 1, collectionId: users.id, cascadeDelete: true },
{ name: "content", type: "text", required: true, min: 1, max: 2000 },
{ name: "created", type: "autodate", onCreate: true },
{ name: "updated", type: "autodate", onCreate: true, onUpdate: true },
],
});
comments.addIndex("idx_comments_submission", false, "submission", "");
app.save(comments);
},
(app) => {
for (const name of ["comments", "reports", "votes", "submissions"]) {
try {
app.delete(app.findCollectionByNameOrId(name));
} catch {
// already gone
}
}
},
);
File renamed without changes.
+54
View File
@@ -0,0 +1,54 @@
# syntax=docker/dockerfile:1.10
FROM node:24.18.0-alpine AS docs
WORKDIR /app
RUN corepack enable
COPY . .
RUN --mount=type=cache,id=workshop-pnpm,target=/pnpm/store \
pnpm config set store-dir /pnpm/store && \
pnpm install --frozen-lockfile --filter @homarr/docs...
RUN WORKSHOP_URL="" pnpm --filter @homarr/docs build
FROM alpine:3.22 AS pocketbase
ARG POCKETBASE_VERSION=0.39.6
ARG TARGETARCH
RUN apk add --no-cache ca-certificates curl unzip
RUN case "${TARGETARCH}" in \
amd64) PB_ARCH=amd64 ;; \
arm64) PB_ARCH=arm64 ;; \
arm) PB_ARCH=armv7 ;; \
*) echo "Unsupported architecture: ${TARGETARCH}" >&2; exit 1 ;; \
esac && \
BASE_URL="https://github.com/pocketbase/pocketbase/releases/download/v${POCKETBASE_VERSION}" && \
ARCHIVE="pocketbase_${POCKETBASE_VERSION}_linux_${PB_ARCH}.zip" && \
curl --fail --location --silent --show-error "${BASE_URL}/${ARCHIVE}" --output "/tmp/${ARCHIVE}" && \
curl --fail --location --silent --show-error "${BASE_URL}/checksums.txt" --output /tmp/checksums.txt && \
cd /tmp && \
grep " ${ARCHIVE}$" checksums.txt | sha256sum -c - && \
unzip "/tmp/${ARCHIVE}" pocketbase -d /out
FROM alpine:3.22 AS runtime
RUN apk add --no-cache ca-certificates tzdata
COPY --from=pocketbase /out/pocketbase /usr/local/bin/pocketbase
COPY --from=docs /app/apps/docs/build /pb_public
COPY apps/workshop/pb_hooks /pb_hooks
COPY apps/workshop/pb_migrations /pb_migrations
RUN addgroup -S workshop && \
adduser -S -G workshop -h /home/workshop workshop && \
mkdir -p /pb_data && \
chown -R workshop:workshop /pb_data /pb_public /pb_hooks /pb_migrations
USER workshop
EXPOSE 8090
VOLUME ["/pb_data"]
HEALTHCHECK --interval=10s --timeout=3s --start-period=10s --retries=10 \
CMD wget --quiet --spider http://127.0.0.1:8090/api/health || exit 1
ENTRYPOINT ["/usr/local/bin/pocketbase"]
CMD ["serve", "--http=0.0.0.0:8090", "--dir=/pb_data", "--publicDir=/pb_public", "--hooksDir=/pb_hooks", "--migrationsDir=/pb_migrations", "--indexFallback=true"]
+15
View File
@@ -0,0 +1,15 @@
.git
.github
.env
.env.*
!.env.example
**/.docusaurus
**/.next
**/.turbo
**/build
**/coverage
**/dist
**/node_modules
**/pb_data
.pnpm-store
e2e
+80
View File
@@ -0,0 +1,80 @@
# Homarr Workshop
Workshop ships as one container. Its build stage compiles the current Docusaurus site, and its runtime stage serves those static files and the PocketBase API from the same origin.
## Local production-like run
Configure optional values in the repository-root `.env`, then run:
```sh
pnpm dev:workshop
```
Open:
- documentation: `http://localhost:8090/`;
- Workshop: `http://localhost:8090/workshop/`;
- PocketBase API: `http://localhost:8090/api/`;
- PocketBase dashboard: `http://localhost:8090/_/`.
The `homarr_workshop_deployment_data` volume persists records and uploaded files across image rebuilds. Migrations run automatically when PocketBase starts. It is intentionally separate from volumes created by earlier prototypes with incompatible schemas.
## GitHub OAuth
Create a GitHub OAuth application with these local values:
- homepage: `http://localhost:8090`;
- callback: `http://localhost:8090/api/oauth2-redirect`.
Set these values only in the repository-root `.env`:
```dotenv
GITHUB_CLIENT_ID=
GITHUB_CLIENT_SECRET=
```
For production, use `https://workshop.homarr.dev` and `https://workshop.homarr.dev/api/oauth2-redirect`. Create the first PocketBase superuser through the installer link printed on first startup or through the PocketBase CLI.
## Local image validation
Build and test the exact runtime shape:
```sh
docker build -f apps/workshop/Dockerfile -t homarr/workshop:ci .
pnpm test:workshop-image
```
The smoke test starts a temporary container and verifies the documentation homepage, `/workshop/`, the migrated public collection, and `/api/health`.
## GitHub Actions and ACT
`.github/workflows/deployment-workshop.yml` runs only for changes below `apps/docs/` or `apps/workshop/`, plus manual dispatches. It builds and tests the combined image, then publishes:
- `ghcr.io/homarr-labs/workshop:latest`;
- `ghcr.io/homarr-labs/workshop:sha-<commit>`.
Run the same build and smoke test locally without registry credentials:
```sh
pnpm ci:act:workshop
```
See `.github/act/workshop.md` for the direct ACT command and Docker socket behavior.
## VPS deployment
Copy `apps/workshop/docker-compose.yml` to the checkout on the VPS, authenticate Docker to GHCR if the package is private, and run from the repository root. Compose reads the optional root `.env` automatically:
```sh
docker compose -f apps/workshop/docker-compose.yml pull
docker compose -f apps/workshop/docker-compose.yml up -d --no-build
```
The service binds to `127.0.0.1:8090` by default. Put a TLS reverse proxy in front of it for `workshop.homarr.dev`, forward the original scheme and host, and persist the `homarr_workshop_deployment_data` volume. Back up that volume before PocketBase upgrades.
## Content schema
- Custom widgets use `homarr-custom-widget-v2` JSON.
- Custom CSS uses raw CSS tagged as `homarr-custom-css-v1`.
Client-side validation lives in `apps/docs/src/lib/workshop-schema.ts`. PocketBase collection rules and hooks remain in this directory.
+28
View File
@@ -0,0 +1,28 @@
name: homarr-workshop
services:
workshop:
image: ghcr.io/homarr-labs/workshop:${WORKSHOP_TAG:-latest}
build:
context: ../..
dockerfile: apps/workshop/Dockerfile
restart: unless-stopped
environment:
GITHUB_CLIENT_ID: ${GITHUB_CLIENT_ID:-}
GITHUB_CLIENT_SECRET: ${GITHUB_CLIENT_SECRET:-}
ports:
- "127.0.0.1:${WORKSHOP_PORT:-8090}:8090"
volumes:
- workshop_data:/pb_data
healthcheck:
test: ["CMD", "wget", "--quiet", "--spider", "http://127.0.0.1:8090/api/health"]
interval: 10s
timeout: 3s
retries: 10
start_period: 10s
security_opt:
- no-new-privileges:true
volumes:
workshop_data:
name: homarr_workshop_deployment_data
File renamed without changes.
@@ -0,0 +1,172 @@
/// <reference path="../pb_data/types.d.ts" />
migrate(
(app) => {
const users = app.findCollectionByNameOrId("users");
const githubClientId = $os.getenv("GITHUB_CLIENT_ID");
const githubClientSecret = $os.getenv("GITHUB_CLIENT_SECRET");
if (githubClientId && githubClientSecret) {
users.oauth2.enabled = true;
users.oauth2.providers = [
{
name: "github",
clientId: githubClientId,
clientSecret: githubClientSecret,
},
];
}
app.save(users);
let submissions;
try {
submissions = app.findCollectionByNameOrId("submissions");
} catch {
submissions = new Collection({
type: "base",
name: "submissions",
listRule: "",
viewRule: "",
createRule: "@request.auth.id != ''",
updateRule: "author = @request.auth.id",
deleteRule: "author = @request.auth.id",
fields: [
{ name: "type", type: "select", required: true, maxSelect: 1, values: ["css", "widget"] },
{ name: "title", type: "text", required: true, min: 3, max: 100 },
{ name: "description", type: "text", max: 2000 },
{ name: "schemaVersion", type: "text", required: true, max: 100 },
{ name: "content", type: "text", required: true, max: 1000000 },
{
name: "screenshots",
type: "file",
maxSelect: 5,
maxSize: 5242880,
mimeTypes: ["image/png", "image/jpeg", "image/webp", "image/gif"],
},
{
name: "author",
type: "relation",
required: true,
maxSelect: 1,
collectionId: users.id,
cascadeDelete: true,
},
{ name: "upvotes", type: "number", onlyInt: true },
{ name: "downvotes", type: "number", onlyInt: true },
{ name: "version", type: "number", onlyInt: true },
{ name: "changelog", type: "text", max: 2000 },
{ name: "created", type: "autodate", onCreate: true },
{ name: "updated", type: "autodate", onCreate: true, onUpdate: true },
],
});
app.save(submissions);
}
let votes;
try {
votes = app.findCollectionByNameOrId("votes");
} catch {
votes = new Collection({
type: "base",
name: "votes",
listRule: "user = @request.auth.id",
viewRule: "user = @request.auth.id",
createRule: "@request.auth.id != ''",
updateRule: "user = @request.auth.id",
deleteRule: "user = @request.auth.id",
fields: [
{
name: "submission",
type: "relation",
required: true,
maxSelect: 1,
collectionId: submissions.id,
cascadeDelete: true,
},
{ name: "user", type: "relation", required: true, maxSelect: 1, collectionId: users.id, cascadeDelete: true },
{ name: "value", type: "number", required: true, onlyInt: true, min: -1, max: 1 },
{ name: "created", type: "autodate", onCreate: true },
{ name: "updated", type: "autodate", onCreate: true, onUpdate: true },
],
});
votes.addIndex("idx_votes_unique_user_submission", true, "submission, user", "");
app.save(votes);
}
let reports;
try {
reports = app.findCollectionByNameOrId("reports");
} catch {
reports = new Collection({
type: "base",
name: "reports",
listRule: null,
viewRule: null,
createRule: "@request.auth.id != ''",
updateRule: null,
deleteRule: null,
fields: [
{
name: "submission",
type: "relation",
required: true,
maxSelect: 1,
collectionId: submissions.id,
cascadeDelete: true,
},
{ name: "user", type: "relation", required: true, maxSelect: 1, collectionId: users.id, cascadeDelete: true },
{ name: "reason", type: "text", required: true, min: 3, max: 1000 },
{ name: "created", type: "autodate", onCreate: true },
],
});
app.save(reports);
}
let comments;
try {
comments = app.findCollectionByNameOrId("comments");
} catch {
comments = new Collection({
type: "base",
name: "comments",
listRule: "",
viewRule: "",
createRule: "@request.auth.id != ''",
updateRule: "author = @request.auth.id",
deleteRule: "author = @request.auth.id",
fields: [
{
name: "submission",
type: "relation",
required: true,
maxSelect: 1,
collectionId: submissions.id,
cascadeDelete: true,
},
{
name: "author",
type: "relation",
required: true,
maxSelect: 1,
collectionId: users.id,
cascadeDelete: true,
},
{ name: "content", type: "text", required: true, min: 1, max: 2000 },
{ name: "created", type: "autodate", onCreate: true },
{ name: "updated", type: "autodate", onCreate: true, onUpdate: true },
],
});
comments.addIndex("idx_comments_submission", false, "submission", "");
app.save(comments);
}
},
(app) => {
for (const name of ["comments", "reports", "votes", "submissions"]) {
try {
app.delete(app.findCollectionByNameOrId(name));
} catch {
// already gone
}
}
},
);
@@ -2,6 +2,15 @@
migrate(
(app) => {
// Keep databases created by the original prototype valid after the
// migration file was renamed with the feature.
try {
app.findCollectionByNameOrId("marketplace");
return;
} catch {
// Fresh database; create the public view below.
}
const collection = new Collection({
name: "marketplace",
type: "view",
+30
View File
@@ -0,0 +1,30 @@
#!/bin/sh
set -eu
image="${1:-homarr/workshop:ci}"
container="homarr-workshop-smoke-${$}"
port="${WORKSHOP_SMOKE_PORT:-18090}"
cleanup() {
docker rm --force "$container" >/dev/null 2>&1 || true
}
trap cleanup EXIT INT TERM
docker run --detach --name "$container" --publish "127.0.0.1:${port}:8090" "$image" >/dev/null
attempt=0
until curl --fail --silent "http://127.0.0.1:${port}/api/health" >/dev/null; do
attempt=$((attempt + 1))
if [ "$attempt" -ge 30 ]; then
docker logs "$container"
exit 1
fi
sleep 1
done
curl --fail --silent "http://127.0.0.1:${port}/" | grep --quiet "Homarr"
curl --fail --silent "http://127.0.0.1:${port}/workshop/" | grep --quiet "Workshop"
curl --fail --silent "http://127.0.0.1:${port}/api/collections/marketplace/records?page=1&perPage=1" >/dev/null
curl --fail --silent "http://127.0.0.1:${port}/api/health" | grep --quiet '"code":200'
echo "Workshop image serves Docusaurus and PocketBase successfully"
+3 -1
View File
@@ -17,7 +17,9 @@
"db:studio": "pnpm -F db studio",
"dev": "pnpm -F nextjs dev",
"dev:docs": "pnpm -F @homarr/docs dev",
"dev:workshop": "docker compose -f ./apps/store/docker-compose.yml up",
"dev:workshop": "docker compose -f ./apps/workshop/docker-compose.yml up --build",
"test:workshop-image": "bash ./apps/workshop/scripts/smoke-test.sh",
"ci:act:workshop": "bash ./scripts/ci/workshop-act.sh",
"docker:dev": "docker compose -f ./development/development.docker-compose.yml up",
"format": "turbo format --continue -- --check",
"format:fix": "turbo format --continue",
+22 -27
View File
@@ -572,8 +572,8 @@ catalogs:
version: 5.0.0
overrides:
'@babel/helpers@<7.26.10': '>=7.29.7'
'@babel/runtime@<7.26.10': '>=7.29.7'
'@babel/helpers@<7.26.10': ^7.29.7
'@babel/runtime@<7.26.10': ^7.29.7
axios@>=1.0.0 <1.8.2: '>=1.18.1'
brace-expansion@>=2.0.0 <=2.0.1: '>=5.0.7'
brace-expansion@>=1.0.0 <=1.1.11: '>=5.0.7'
@@ -3450,9 +3450,6 @@ packages:
resolution: {integrity: sha512-Nq8OhGWiZIZGV6hLHoyAKLLcJihP/xFeBMGJoUrxTX2psI8dCifzLhZISFb+VWS3wFMRDmCGw5R+dOySCqPLhw==}
engines: {node: '>=6.9.0'}
'@babel/runtime@8.0.0':
resolution: {integrity: sha512-sL6cvO2IfkSu/iU+zs2S/w01B7A8V7suXSIKEN4hPFFdZoiPGxrj5pAG0lCaqLWiEIrjKzdznIWuaLcxPR53qw==}
'@babel/template@7.29.7':
resolution: {integrity: sha512-puq+Gf35oI24FeN11LkoUQFqv9uwNeWpxXZi/Ji3rRIoKAzKnxRaZ+Gkj0vKS9ZCiTESfng1N9LyOyXvo+m+Gg==}
engines: {node: '>=6.9.0'}
@@ -4278,11 +4275,11 @@ packages:
'@esbuild-kit/core-utils@3.3.2':
resolution: {integrity: sha512-sPRAnw9CdSsRmEtnsl2WXWdyquogVpB3yZ3dgwJfe8zrOzTsV7cJvmwrKVa+0ma5BoiGJ+BoqkMvawbayKUsqQ==}
deprecated: 'Merged into tsx: https://tsx.is'
deprecated: 'Merged into tsx: https://tsx.hirok.io'
'@esbuild-kit/esm-loader@2.6.5':
resolution: {integrity: sha512-FxEMIkJKnodyA1OaCUoEvbYRkoZlLZ4d/eXFu9Fh8CbBBgP5EmZxrfTRyN0qpXZ4vOvqnE5YdRdcrmUUXuU+dA==}
deprecated: 'Merged into tsx: https://tsx.is'
deprecated: 'Merged into tsx: https://tsx.hirok.io'
'@esbuild/aix-ppc64@0.25.12':
resolution: {integrity: sha512-Hhmwd6CInZ3dwpuGTF8fJG6yoWmsToE+vYgD4nytZVxcu1ulHpUQRAB1UJ8+N1Am3Mz4+xOByoQoSZf4D+CpkA==}
@@ -16209,8 +16206,6 @@ snapshots:
'@babel/runtime@7.29.7': {}
'@babel/runtime@8.0.0': {}
'@babel/template@7.29.7':
dependencies:
'@babel/code-frame': 7.29.7
@@ -16879,7 +16874,7 @@ snapshots:
'@babel/preset-env': 7.29.7(@babel/core@7.29.7)
'@babel/preset-react': 7.29.7(@babel/core@7.29.7)
'@babel/preset-typescript': 7.29.7(@babel/core@7.29.7)
'@babel/runtime': 8.0.0
'@babel/runtime': 7.29.7
'@babel/traverse': 7.29.7
'@docusaurus/logger': 3.10.1
'@docusaurus/utils': 3.10.1(@swc/core@1.15.43(@swc/helpers@0.5.23))(clean-css@5.3.3)(cssnano@6.1.2(postcss@8.5.16))(esbuild@0.28.1)(html-minifier-terser@7.2.0)(postcss@8.5.16)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)
@@ -20359,7 +20354,7 @@ snapshots:
'@slorber/react-helmet-async@1.3.0(react-dom@19.2.7(react@19.2.7))(react@19.2.7)':
dependencies:
'@babel/runtime': 8.0.0
'@babel/runtime': 7.29.7
invariant: 2.2.4
prop-types: 15.8.1
react: 19.2.7
@@ -21585,7 +21580,7 @@ snapshots:
'@videojs/http-streaming@3.17.5(video.js@8.23.9)':
dependencies:
'@babel/runtime': 8.0.0
'@babel/runtime': 7.29.7
'@videojs/vhs-utils': 4.1.2
aes-decrypter: 4.0.2
global: 4.4.0
@@ -21596,12 +21591,12 @@ snapshots:
'@videojs/vhs-utils@4.1.2':
dependencies:
'@babel/runtime': 8.0.0
'@babel/runtime': 7.29.7
global: 4.4.0
'@videojs/xhr@2.7.0':
dependencies:
'@babel/runtime': 8.0.0
'@babel/runtime': 7.29.7
global: 4.4.0
is-function: 1.0.2
@@ -21912,7 +21907,7 @@ snapshots:
aes-decrypter@4.0.2:
dependencies:
'@babel/runtime': 8.0.0
'@babel/runtime': 7.29.7
'@videojs/vhs-utils': 4.1.2
global: 4.4.0
pkcs7: 1.0.4
@@ -23437,7 +23432,7 @@ snapshots:
dom-helpers@5.2.1:
dependencies:
'@babel/runtime': 8.0.0
'@babel/runtime': 7.29.7
csstype: 3.2.3
dom-serializer@1.4.1:
@@ -24604,7 +24599,7 @@ snapshots:
history@4.10.1:
dependencies:
'@babel/runtime': 8.0.0
'@babel/runtime': 7.29.7
loose-envify: 1.4.0
resolve-pathname: 3.0.0
tiny-invariant: 1.3.3
@@ -25413,7 +25408,7 @@ snapshots:
m3u8-parser@7.2.0:
dependencies:
'@babel/runtime': 8.0.0
'@babel/runtime': 7.29.7
'@videojs/vhs-utils': 4.1.2
global: 4.4.0
@@ -26169,7 +26164,7 @@ snapshots:
mpd-parser@1.4.0:
dependencies:
'@babel/runtime': 8.0.0
'@babel/runtime': 7.29.7
'@videojs/vhs-utils': 4.1.2
'@xmldom/xmldom': 0.8.13
global: 4.4.0
@@ -26244,7 +26239,7 @@ snapshots:
mux.js@7.1.0:
dependencies:
'@babel/runtime': 8.0.0
'@babel/runtime': 7.29.7
global: 4.4.0
mysql2@3.22.5(@types/node@24.13.2):
@@ -26843,7 +26838,7 @@ snapshots:
pkcs7@1.0.4:
dependencies:
'@babel/runtime': 8.0.0
'@babel/runtime': 7.29.7
pkg-conf@2.1.0:
dependencies:
@@ -27702,7 +27697,7 @@ snapshots:
react-loadable-ssr-addon-v5-slorber@1.0.3(@docusaurus/react-loadable@6.0.0(react@19.2.7))(webpack@5.108.3(@swc/core@1.15.43(@swc/helpers@0.5.23))(esbuild@0.28.1)(postcss@8.5.16)):
dependencies:
'@babel/runtime': 8.0.0
'@babel/runtime': 7.29.7
react-loadable: '@docusaurus/react-loadable@6.0.0(react@19.2.7)'
webpack: 5.108.3(@swc/core@1.15.43(@swc/helpers@0.5.23))(clean-css@5.3.3)(cssnano@6.1.2(postcss@8.5.16))(esbuild@0.28.1)(html-minifier-terser@7.2.0)(postcss@8.5.16)
@@ -27767,13 +27762,13 @@ snapshots:
react-router-config@5.1.1(react-router@5.3.4(react@19.2.7))(react@19.2.7):
dependencies:
'@babel/runtime': 8.0.0
'@babel/runtime': 7.29.7
react: 19.2.7
react-router: 5.3.4(react@19.2.7)
react-router-dom@5.3.4(react@19.2.7):
dependencies:
'@babel/runtime': 8.0.0
'@babel/runtime': 7.29.7
history: 4.10.1
loose-envify: 1.4.0
prop-types: 15.8.1
@@ -27784,7 +27779,7 @@ snapshots:
react-router@5.3.4(react@19.2.7):
dependencies:
'@babel/runtime': 8.0.0
'@babel/runtime': 7.29.7
history: 4.10.1
hoist-non-react-statics: 3.3.2
loose-envify: 1.4.0
@@ -27810,7 +27805,7 @@ snapshots:
react-transition-group@4.4.5(react-dom@19.2.7(react@19.2.7))(react@19.2.7):
dependencies:
'@babel/runtime': 8.0.0
'@babel/runtime': 7.29.7
dom-helpers: 5.2.1
loose-envify: 1.4.0
prop-types: 15.8.1
@@ -29410,7 +29405,7 @@ snapshots:
use-deep-compare-effect@1.8.1(react@19.2.7):
dependencies:
'@babel/runtime': 8.0.0
'@babel/runtime': 7.29.7
dequal: 2.0.3
react: 19.2.7
+2 -2
View File
@@ -217,8 +217,8 @@ minimumReleaseAgeExclude:
# Renovate security update: esbuild@0.28.1
- esbuild@0.28.1
overrides:
"@babel/helpers@<7.26.10": ">=7.29.7"
"@babel/runtime@<7.26.10": ">=7.29.7"
"@babel/helpers@<7.26.10": "^7.29.7"
"@babel/runtime@<7.26.10": "^7.29.7"
"axios@>=1.0.0 <1.8.2": ">=1.18.1"
"brace-expansion@>=2.0.0 <=2.0.1": ">=5.0.7"
"brace-expansion@>=1.0.0 <=1.1.11": ">=5.0.7"
+22
View File
@@ -0,0 +1,22 @@
#!/bin/sh
set -eu
if ! command -v act >/dev/null 2>&1; then
echo "act is required: https://nektosact.com/installation/" >&2
exit 1
fi
socket="${DOCKER_HOST:-}"
if [ -z "$socket" ]; then
socket="$(docker context inspect --format '{{ (index .Endpoints "docker").Host }}' 2>/dev/null || true)"
fi
if [ -z "$socket" ]; then
socket="unix:///var/run/docker.sock"
fi
exec act workflow_dispatch \
--workflows .github/workflows/deployment-workshop.yml \
--job deploy \
--env ACT=true \
--container-daemon-socket "$socket" \
--container-architecture linux/amd64