refactor: rename marketplace to workshop, add colored type dots

Rename all user-facing "Marketplace" references to "Workshop" including
the route (/workshop), navbar label, page titles, button labels, env vars,
docs, and error messages. The PocketBase view collection stays `marketplace`
as an internal API detail.

Replace icon-based type badges with colored dots (blue = CSS, yellow = widget)
on both the filter bar and submission cards for clearer visual distinction.
This commit is contained in:
Thomas Camlong committed 2026-06-24 15:24:48 +02:00
1 parent 1fb7e16d42
commit de8d20a030
11 files changed
+65 -64

No files matched your search

+4 -4
View File
@@ -61,9 +61,9 @@ TURBO_TELEMETRY_DISABLED=1
# Enable mock integration
UNSAFE_ENABLE_MOCK_INTEGRATION=true
# Community marketplace (PocketBase) base URL used by the in-app "Install from marketplace" buttons
# 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
# Docs site marketplace URL used for "Open Marketplace" links
# In production this defaults to https://homarr.dev/marketplace
# NEXT_PUBLIC_MARKETPLACE_URL=http://localhost:3003/marketplace
# 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
+9 -9
View File
@@ -1,19 +1,19 @@
---
tags:
- Store
- Workshop
- Custom Widgets
- Custom CSS
---
# Community Store
# Community Workshop
The [Community Store](pathname:///store) is where Homarr users share and discover
The [Community Workshop](pathname:///workshop) is where Homarr users share and discover
**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.
:::info
The store is community-contributed content. Always review CSS and widget JSON before
The workshop is community-contributed content. Always review CSS and widget JSON before
installing it on your dashboard.
:::
@@ -37,19 +37,19 @@ Submissions are published immediately. Reported entries are reviewed and may be
## Installing into Homarr
You can install store entries without leaving Homarr:
You can install workshop entries without leaving Homarr:
- **Custom widgets** — go to **Manage → Custom Widgets**, click **Install from store**, pick an
- **Custom widgets** — go to **Manage → Custom Widgets**, click **Install from Workshop**, pick an
entry, and it is imported (secrets such as API keys must be re-entered afterwards).
- **Custom CSS** — open a board's **Settings → Custom CSS**, click **Install from store**, and
- **Custom CSS** — open a board's **Settings → Custom CSS**, click **Install from Workshop**, and
pick a theme to load it into the editor before saving.
Alternatively, use **Copy** (or **Download** for widgets) on any submission and import it
manually.
## Self-hosting the store backend
## Self-hosting the workshop backend
The store is powered by a [PocketBase](https://pocketbase.io) instance (GitHub OAuth, database,
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`
+5 -5
View File
@@ -110,9 +110,9 @@ const config: Config = {
to: "/blog",
},
{
label: "Marketplace",
label: "Workshop",
position: "left",
to: "/marketplace",
to: "/workshop",
},
{
label: "About us",
@@ -304,12 +304,12 @@ const config: Config = {
},
};
},
function marketplaceDetailRoutePlugin() {
function workshopDetailRoutePlugin() {
return {
name: "marketplace-detail-route",
name: "workshop-detail-route",
async contentLoaded({ actions }) {
actions.addRoute({
path: "/marketplace/:id",
path: "/workshop/:id",
component: "@site/src/components/store/DetailPage",
exact: true,
});
+12 -9
View File
@@ -32,7 +32,7 @@ import { Input } from "@/components/ui/input";
import { cn, errorMessage } from "@/lib/utils";
const typeLabels: Record<SubmissionType, string> = { css: "CSS", widget: "Widget" };
const typeBadgeVariants: Record<SubmissionType, "secondary" | "default"> = { css: "secondary", widget: "default" };
const typeDotColors: Record<SubmissionType, string> = { css: "bg-blue-500", widget: "bg-yellow-500" };
const contentLanguages: Record<SubmissionType, string> = { css: "css", widget: "json" };
const prismThemes = { light: themes.github, dark: themes.dracula } as const;
const copyState = [
@@ -62,7 +62,7 @@ const scoreSign = (score: number): keyof typeof scoreClassBySign => {
const parseSubmissionId = (pathname: string) => {
const segments = pathname.split("/").filter(Boolean);
const last = segments[segments.length - 1];
if (!last || last === "marketplace") return null;
if (!last || last === "workshop") return null;
return last;
};
@@ -465,7 +465,7 @@ const MarketplaceDetail = ({ storeUrl }: { storeUrl: string }) => {
if (!submission) return;
try {
await pb.collection("submissions").delete(submission.id);
window.location.href = "/marketplace/";
window.location.href = "/workshop/";
} catch (caught) {
setError(errorMessage(caught, "Failed to delete submission"));
}
@@ -492,8 +492,8 @@ const MarketplaceDetail = ({ storeUrl }: { storeUrl: string }) => {
<div className="mx-auto max-w-4xl px-4 py-16 text-center">
<p className="text-lg font-medium">Submission not found</p>
<p className="mt-1 text-sm text-muted-foreground">This listing may have been removed or the link is invalid.</p>
<Link to="/marketplace" className="mt-6 inline-flex items-center gap-1.5 text-sm text-primary hover:underline">
<IconArrowLeft size={14} /> Back to Marketplace
<Link to="/workshop" className="mt-6 inline-flex items-center gap-1.5 text-sm text-primary hover:underline">
<IconArrowLeft size={14} /> Back to Workshop
</Link>
</div>
);
@@ -504,8 +504,8 @@ const MarketplaceDetail = ({ storeUrl }: { storeUrl: string }) => {
return (
<div className="mx-auto max-w-4xl px-4 pb-16 pt-8">
<Link to="/marketplace" className="mb-6 inline-flex items-center gap-1.5 text-sm text-muted-foreground hover:text-foreground">
<IconArrowLeft size={14} /> Back to Marketplace
<Link to="/workshop" className="mb-6 inline-flex items-center gap-1.5 text-sm text-muted-foreground hover:text-foreground">
<IconArrowLeft size={14} /> Back to Workshop
</Link>
{error && (
@@ -524,7 +524,10 @@ const MarketplaceDetail = ({ storeUrl }: { storeUrl: string }) => {
<div>
<div className="flex items-center gap-2">
<h1 className="text-2xl font-semibold tracking-tight">{submission.title}</h1>
<Badge variant={typeBadgeVariants[submission.type]}>{typeLabels[submission.type]}</Badge>
<Badge variant="secondary" className="gap-1.5">
<span className={cn("size-2 rounded-full", typeDotColors[submission.type])} />
{typeLabels[submission.type]}
</Badge>
</div>
<p className="mt-1 text-sm text-muted-foreground">
{submission.authorName} · v{submission.version} · {relativeTime(submission.created)}
@@ -615,7 +618,7 @@ export default function MarketplaceDetailPage() {
const storeUrl = (siteConfig.customFields?.storeUrl as string | undefined) ?? "http://localhost:8090";
return (
<Layout title="Marketplace" description="Community custom CSS and custom widgets for Homarr">
<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} />}
+14 -21
View File
@@ -2,7 +2,6 @@ import React, { useEffect, useMemo, useRef, useState } from "react";
import {
IconArrowBigDown,
IconArrowBigUp,
IconBrandCss3,
IconBrandGithub,
IconCheck,
IconChevronLeft,
@@ -13,10 +12,8 @@ import {
IconLogout,
IconPackage,
IconPlus,
IconPuzzle,
IconSearch,
IconTrash,
IconUser,
IconX,
} from "@tabler/icons-react";
@@ -40,11 +37,8 @@ import { SubmitForm } from "./SubmitForm";
import type { SortKey, TypeFilter } from "./useStore";
import { useStore } from "./useStore";
const typeIcons: Record<SubmissionType, React.ComponentType<{ size: number }>> = {
css: IconBrandCss3,
widget: IconPuzzle,
};
const typeBadgeVariants: Record<SubmissionType, "secondary" | "default"> = { css: "secondary", widget: "default" };
const typeDotColors: Record<SubmissionType, string> = { css: "bg-blue-500", widget: "bg-yellow-500" };
const typeLabels: Record<SubmissionType, string> = { css: "CSS", widget: "Widget" };
const filterActiveClass = "bg-background text-foreground shadow-sm";
const filterInactiveClass = "text-muted-foreground hover:text-foreground";
const copyState = [
@@ -56,11 +50,11 @@ const emptyState = {
filtered: { title: "No matching results", hint: "Try adjusting your filters or search." },
} as const;
const typeFilters: { value: TypeFilter; label: string; Icon?: React.ComponentType<{ size: number }> }[] = [
const typeFilters: { value: TypeFilter; label: string; dot?: string }[] = [
{ value: "all", label: "All" },
{ value: "widget", label: "Widgets", Icon: IconPuzzle },
{ value: "css", label: "CSS", Icon: IconBrandCss3 },
{ value: "yours", label: "Yours", Icon: IconUser },
{ value: "widget", label: "Widgets", dot: typeDotColors.widget },
{ value: "css", label: "CSS", dot: typeDotColors.css },
{ value: "yours", label: "Yours" },
];
const sortOptions: { value: SortKey; label: string }[] = [
{ value: "top", label: "Top rated" },
@@ -119,7 +113,7 @@ export const StoreApp = ({ storeUrl }: { storeUrl: string }) => {
<div className="mx-auto max-w-7xl px-4 pb-16">
<div className="flex flex-col gap-6 py-8 sm:flex-row sm:items-end sm:justify-between">
<div>
<h1 className="text-2xl font-semibold tracking-tight">Marketplace</h1>
<h1 className="text-2xl font-semibold tracking-tight">Workshop</h1>
<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">
@@ -148,11 +142,11 @@ export const StoreApp = ({ storeUrl }: { storeUrl: string }) => {
key={opt.value}
onClick={() => setTypeFilter(opt.value)}
className={cn(
"inline-flex items-center gap-1 rounded px-2.5 py-1 text-xs font-medium transition-colors",
"inline-flex items-center gap-1.5 rounded px-2.5 py-1 text-xs font-medium transition-colors",
typeFilter === opt.value ? filterActiveClass : filterInactiveClass,
)}
>
{opt.Icon && <opt.Icon size={12} />}
{opt.dot && <span className={cn("size-2 rounded-full", opt.dot)} />}
{opt.label}
</button>
))}
@@ -290,21 +284,20 @@ const SubmissionCard = ({
if (reason && reason.trim().length >= 3) void onReport(submission.id, reason.trim());
};
const TypeIcon = typeIcons[submission.type];
return (
<Card className="transition-transform duration-150 hover:scale-[1.02]">
<a href={`/marketplace/${submission.id}/`} className="block">
<a href={`/workshop/${submission.id}/`} className="block">
{screenshotUrls.length > 0 && <ScreenshotGallery urls={screenshotUrls} title={submission.title} />}
</a>
<CardHeader>
<div className="flex items-center gap-2">
<a href={`/marketplace/${submission.id}/`} className="min-w-0 truncate hover:underline">
<a href={`/workshop/${submission.id}/`} className="min-w-0 truncate hover:underline">
<CardTitle className="truncate">{submission.title}</CardTitle>
</a>
<Badge variant={typeBadgeVariants[submission.type]} className="shrink-0 px-1.5">
<TypeIcon size={12} />
<Badge variant="secondary" className="shrink-0 gap-1.5 px-2">
<span className={cn("size-2 rounded-full", typeDotColors[submission.type])} />
{typeLabels[submission.type]}
</Badge>
</div>
<CardDescription className="text-xs">
@@ -211,7 +211,7 @@ export const SubmitForm = ({ onClose, onSubmit }: Props) => {
<DialogHeader>
<DialogTitle>Share with the community</DialogTitle>
<DialogDescription>Submit a custom CSS theme or widget to the marketplace.</DialogDescription>
<DialogDescription>Submit a custom CSS theme or widget to the Workshop.</DialogDescription>
</DialogHeader>
<div className="flex items-center gap-2">
+1 -1
View File
@@ -58,7 +58,7 @@ export const useStore = (storeUrl: string) => {
);
await refreshVotes();
} catch (caught) {
setError(errorMessage(caught, "Failed to load the marketplace"));
setError(errorMessage(caught, "Failed to load the workshop"));
} finally {
setLoading(false);
}
@@ -8,7 +8,7 @@ export default function StorePage() {
const storeUrl = (siteConfig.customFields?.storeUrl as string | undefined) ?? "http://localhost:8090";
return (
<Layout title="Marketplace" description="Community custom CSS and custom widgets for Homarr">
<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" }} />}>
{() => {
@@ -33,14 +33,14 @@ export const InstallWidgetFromStoreButton = () => {
const utils = clientApi.useUtils();
const importMutation = clientApi.customWidget.import.useMutation({
onSuccess: () => {
showSuccessNotification({ title: "Marketplace", message: "Custom widget installed" });
showSuccessNotification({ title: "Workshop", message: "Custom widget installed" });
void utils.customWidget.all.invalidate();
void revalidatePathActionAsync("/manage/custom-widgets");
setPendingId(null);
close();
},
onError: (err) => {
showErrorNotification({ title: "Marketplace", message: err.message || "Failed to install the custom widget" });
showErrorNotification({ title: "Workshop", message: err.message || "Failed to install the custom widget" });
setPendingId(null);
},
});
@@ -48,7 +48,7 @@ export const InstallWidgetFromStoreButton = () => {
const handleSelect = (submission: { id: string; content: string }) => {
const parsed = parseWidgetContent(submission.content);
if (!parsed.ok) {
showErrorNotification({ title: "Marketplace", message: parsed.message });
showErrorNotification({ title: "Workshop", message: parsed.message });
return;
}
setPendingId(submission.id);
@@ -58,7 +58,7 @@ export const InstallWidgetFromStoreButton = () => {
return (
<>
<MobileAffixButton variant="default" leftSection={<IconBuildingStore size={16} />} onClick={open}>
Install from marketplace
Install from Workshop
</MobileAffixButton>
<StoreBrowserModal
type="widget"
@@ -78,7 +78,7 @@ export const InstallCssFromStoreButton = ({ onSelect }: { onSelect: (css: string
return (
<>
<Button variant="default" leftSection={<IconBuildingStore size={16} />} onClick={open}>
Install from marketplace
Install from Workshop
</Button>
<StoreBrowserModal
type="css"
@@ -26,7 +26,7 @@ import { useQuery } from "@tanstack/react-query";
import type { StoreSubmission } from "./store-api";
import { fetchStoreSubmissions, getStoreFileUrl } from "./store-api";
const MARKETPLACE_URL = process.env.NEXT_PUBLIC_MARKETPLACE_URL || "https://homarr.dev/marketplace";
const WORKSHOP_URL = process.env.NEXT_PUBLIC_WORKSHOP_URL || "https://homarr.dev/workshop";
interface Props {
type: "css" | "widget";
@@ -59,15 +59,15 @@ export const StoreBrowserModal = ({ type, opened, onClose, onSelect, actionLabel
onClose={handleClose}
title={
<Group gap="sm">
<Text fw={600}>Install from marketplace</Text>
<Text fw={600}>Install from Workshop</Text>
<Anchor
href={MARKETPLACE_URL}
href={WORKSHOP_URL}
target="_blank"
rel="noopener noreferrer"
size="xs"
style={{ display: "inline-flex", alignItems: "center", gap: 4 }}
>
Open Marketplace <IconExternalLink size={12} />
Open Workshop <IconExternalLink size={12} />
</Anchor>
</Group>
}
@@ -81,7 +81,7 @@ export const StoreBrowserModal = ({ type, opened, onClose, onSelect, actionLabel
</Center>
)}
{error && <Alert color="red">Failed to load the marketplace: {errorText}</Alert>}
{error && <Alert color="red">Failed to load the workshop: {errorText}</Alert>}
{data && data.items.length === 0 && (
<Text c="dimmed" ta="center" py="xl">
@@ -174,6 +174,11 @@ const SubmissionCard = ({
<Stack gap={4} p="sm">
<Group gap="xs" wrap="nowrap">
<Box
w={8}
h={8}
style={{ borderRadius: "50%", flexShrink: 0, backgroundColor: submission.type === "css" ? "var(--mantine-color-blue-5)" : "var(--mantine-color-yellow-5)" }}
/>
<Text fw={600} size="sm" truncate style={{ flex: 1 }}>
{submission.title}
</Text>
@@ -201,10 +206,10 @@ const SubmissionCard = ({
variant="light"
size="sm"
component="a"
href={`${MARKETPLACE_URL}/${submission.id}/`}
href={`${WORKSHOP_URL}/${submission.id}/`}
target="_blank"
rel="noopener noreferrer"
aria-label="View in marketplace"
aria-label="View in workshop"
>
<IconExternalLink size={14} />
</ActionIcon>
+1 -1
View File
@@ -17,7 +17,7 @@
"db:studio": "pnpm -F db studio",
"dev": "pnpm -F nextjs dev",
"dev:docs": "pnpm -F @homarr/docs dev",
"dev:marketplace": "docker compose -f ./apps/store/docker-compose.yml up",
"dev:workshop": "docker compose -f ./apps/store/docker-compose.yml up",
"docker:dev": "docker compose -f ./development/development.docker-compose.yml up",
"format": "turbo format --continue -- --check",
"format:fix": "turbo format --continue",