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:
11 files changed
+65
-64
No files matched your search
+4
-4
@@ -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
|
||||
@@ -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`
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
|
||||
@@ -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} />}
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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
@@ -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",
|
||||
|
||||
Reference in new issue
Block a user