ci(workshop): add combined deployment image
This commit is contained in:
39 files changed
+636
-436
No files matched your search
+2
-2
@@ -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
|
||||
@@ -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.
|
||||
@@ -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
|
||||
@@ -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"
|
||||
+5
-7
@@ -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.
|
||||
@@ -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,
|
||||
});
|
||||
},
|
||||
|
||||
+6
-6
@@ -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
-13
@@ -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>
|
||||
);
|
||||
File renamed without changes.
+3
-3
@@ -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;
|
||||
+34
-34
@@ -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;
|
||||
File renamed without changes.
+21
-21
@@ -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;
|
||||
+2
-2
@@ -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,
|
||||
@@ -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);
|
||||
@@ -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")}
|
||||
|
||||
+8
-8
@@ -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}
|
||||
+8
-8
@@ -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>;
|
||||
};
|
||||
+9
-9
@@ -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"
|
||||
@@ -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`).
|
||||
@@ -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.
@@ -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"]
|
||||
@@ -0,0 +1,15 @@
|
||||
.git
|
||||
.github
|
||||
.env
|
||||
.env.*
|
||||
!.env.example
|
||||
**/.docusaurus
|
||||
**/.next
|
||||
**/.turbo
|
||||
**/build
|
||||
**/coverage
|
||||
**/dist
|
||||
**/node_modules
|
||||
**/pb_data
|
||||
.pnpm-store
|
||||
e2e
|
||||
@@ -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.
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
},
|
||||
);
|
||||
+9
@@ -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",
|
||||
Executable
+30
@@ -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
@@ -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",
|
||||
|
||||
Generated
+22
-27
@@ -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
@@ -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"
|
||||
|
||||
Executable
+22
@@ -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
|
||||
Reference in new issue
Block a user