diff --git a/.env.example b/.env.example index 318b171e1..b998483df 100644 --- a/.env.example +++ b/.env.example @@ -59,4 +59,11 @@ TURBO_TELEMETRY_DISABLED=1 # ENABLE_KUBERNETES=true # Enable mock integration -UNSAFE_ENABLE_MOCK_INTEGRATION=true \ No newline at end of file +UNSAFE_ENABLE_MOCK_INTEGRATION=true + +# Community marketplace (PocketBase) base URL used by the in-app "Install from marketplace" 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 diff --git a/apps/docs/components.json b/apps/docs/components.json new file mode 100644 index 000000000..15e244db4 --- /dev/null +++ b/apps/docs/components.json @@ -0,0 +1,24 @@ +{ + "$schema": "https://ui.shadcn.com/schema.json", + "style": "base-nova", + "rsc": false, + "tsx": true, + "tailwind": { + "config": "", + "css": "src/css/custom.css", + "baseColor": "mist", + "cssVariables": true, + "prefix": "" + }, + "aliases": { + "components": "@/components", + "utils": "@/lib/utils", + "ui": "@/components/ui", + "lib": "@/lib", + "hooks": "@/hooks" + }, + "iconLibrary": "tabler", + "rtl": false, + "menuColor": "default", + "menuAccent": "subtle" +} diff --git a/apps/docs/docs/management/store/index.mdx b/apps/docs/docs/management/store/index.mdx new file mode 100644 index 000000000..bd8aa7eae --- /dev/null +++ b/apps/docs/docs/management/store/index.mdx @@ -0,0 +1,56 @@ +--- +tags: + - Store + - Custom Widgets + - Custom CSS +--- + +# Community Store + +The [Community Store](pathname:///store) 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 +installing it on your dashboard. + +::: + +## Browsing and voting + +- Filter by **Widgets** or **CSS**, and sort by **Top rated** or **Newest**. +- Sign in with **GitHub** to upvote/downvote, submit your own creations, or report a submission. +- Each submission shows its title, description, author, version, an optional screenshot, and the full content. + +## Submitting + +1. Sign in with GitHub. +2. Click **Submit** and choose the type: + - **Custom widget** — paste the JSON exported from **Manage → Custom Widgets → Export**. + It must match the `homarr-custom-widget-v2` schema (validated before submission). + - **Custom CSS** — paste your stylesheet (max 16384 characters). +3. Add a title, description, optional screenshot, and changelog, then submit. + +Submissions are published immediately. Reported entries are reviewed and may be removed. + +## Installing into Homarr + +You can install store entries without leaving Homarr: + +- **Custom widgets** — go to **Manage → Custom Widgets**, click **Install from store**, 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 + 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 + +The store 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. diff --git a/apps/docs/docusaurus.config.ts b/apps/docs/docusaurus.config.ts index 309b119f0..3f8373f0d 100644 --- a/apps/docs/docusaurus.config.ts +++ b/apps/docs/docusaurus.config.ts @@ -20,6 +20,10 @@ const config: Config = { onBrokenAnchors: "throw", onDuplicateRoutes: "throw", + customFields: { + storeUrl: process.env.STORE_URL ?? "http://localhost:8090", + }, + future: { v4: { removeLegacyPostBuildHeadAttribute: true, @@ -105,6 +109,11 @@ const config: Config = { position: "left", to: "/blog", }, + { + label: "Marketplace", + position: "left", + to: "/marketplace", + }, { label: "About us", position: "left", @@ -259,7 +268,12 @@ const config: Config = { return { name: "resolve-homarr-packages", configureWebpack() { - return { resolve: { symlinks: false } }; + return { + resolve: { + symlinks: false, + alias: { "@": require("path").resolve(__dirname, "src") }, + }, + }; }, }; }, @@ -290,6 +304,18 @@ const config: Config = { }, }; }, + function marketplaceDetailRoutePlugin() { + return { + name: "marketplace-detail-route", + async contentLoaded({ actions }) { + actions.addRoute({ + path: "/marketplace/:id", + component: "@site/src/components/store/DetailPage", + exact: true, + }); + }, + }; + }, "@signalwire/docusaurus-plugin-llms-txt", async function tailwindCssPlugin() { return { diff --git a/apps/docs/package.json b/apps/docs/package.json index f4c7655b9..661c1fb8b 100644 --- a/apps/docs/package.json +++ b/apps/docs/package.json @@ -21,6 +21,7 @@ "verify:search": "node ./scripts/verify-algolia-search.mjs" }, "dependencies": { + "@base-ui/react": "catalog:", "@docusaurus/core": "^3.10.1", "@docusaurus/faster": "^3.10.1", "@docusaurus/plugin-content-docs": "^3.10.1", @@ -33,10 +34,12 @@ "@signalwire/docusaurus-plugin-llms-txt": "^1.2.2", "@tabler/icons-react": "catalog:", "@tailwindcss/postcss": "^4.3.1", + "class-variance-authority": "catalog:", "clsx": "catalog:", "dayjs": "catalog:", "docusaurus-plugin-image-zoom": "^3.0.1", "leader-line-new": "^1.1.9", + "pocketbase": "catalog:", "postcss": "^8.5.15", "posthog-docusaurus": "^2.0.5", "prism-react-renderer": "^2.4.1", @@ -44,7 +47,9 @@ "react": "catalog:", "react-dom": "catalog:", "swiper": "^12.2.0", - "tailwindcss": "^4.3.1" + "tailwind-merge": "catalog:", + "tailwindcss": "^4.3.1", + "zod": "catalog:" }, "devDependencies": { "@docusaurus/module-type-aliases": "^3.10.1", @@ -55,8 +60,7 @@ "@types/react": "catalog:", "@types/react-dom": "catalog:", "cheerio": "^1.2.0", - "typescript": "catalog:", - "zod": "catalog:" + "typescript": "catalog:" }, "peerDependencies": { "@docusaurus/theme-common": "^3.10.1", diff --git a/apps/docs/src/components/store/DetailPage.tsx b/apps/docs/src/components/store/DetailPage.tsx new file mode 100644 index 000000000..d3afd25df --- /dev/null +++ b/apps/docs/src/components/store/DetailPage.tsx @@ -0,0 +1,626 @@ +import React, { useCallback, useEffect, useMemo, useRef, useState } from "react"; +import BrowserOnly from "@docusaurus/BrowserOnly"; +import Link from "@docusaurus/Link"; +import { useLocation } from "@docusaurus/router"; +import useDocusaurusContext from "@docusaurus/useDocusaurusContext"; +import { useColorMode } from "@docusaurus/theme-common"; +import Layout from "@theme/Layout"; +import { + IconArrowBigDown, + IconArrowBigUp, + IconArrowLeft, + IconCheck, + IconChevronLeft, + IconChevronRight, + IconCopy, + IconDownload, + IconEdit, + IconSend, + IconTrash, + IconX, +} from "@tabler/icons-react"; +import { Highlight, themes } from "prism-react-renderer"; +import type { ClientResponseError } from "pocketbase"; + +import type { StoreComment, StoreSubmission, StoreVote } from "@site/src/lib/pocketbase"; +import { getPocketBase, getSubmissionFileUrl } from "@site/src/lib/pocketbase"; +import type { SubmissionType } from "@site/src/lib/store-schema"; + +import { Badge } from "@/components/ui/badge"; +import { Button } from "@/components/ui/button"; +import { Input } from "@/components/ui/input"; +import { cn, errorMessage } from "@/lib/utils"; + +const typeLabels: Record = { css: "CSS", widget: "Widget" }; +const typeBadgeVariants: Record = { css: "secondary", widget: "default" }; +const contentLanguages: Record = { css: "css", widget: "json" }; +const prismThemes = { light: themes.github, dark: themes.dracula } as const; +const copyState = [ + { Icon: IconCopy, label: "Copy", iconClass: "" }, + { Icon: IconCheck, label: "Copied!", iconClass: "text-green-500" }, +] as const; +const scoreClassBySign = { positive: "text-primary", negative: "text-destructive", neutral: "" } as const; +const dotClass = ["bg-white/40", "bg-white"]; + +const commentAuthorName = (c: StoreComment) => c.expand?.author?.name || c.expand?.author?.username || "unknown"; + +const relativeTime = (date: string) => { + const minutes = Math.floor((Date.now() - new Date(date).getTime()) / 60000); + if (minutes < 1) return "just now"; + if (minutes < 60) return `${minutes}m ago`; + const hours = Math.floor(minutes / 60); + if (hours < 24) return `${hours}h ago`; + return `${Math.floor(hours / 24)}d ago`; +}; + +const scoreSign = (score: number): keyof typeof scoreClassBySign => { + if (score > 0) return "positive"; + if (score < 0) return "negative"; + return "neutral"; +}; + +const parseSubmissionId = (pathname: string) => { + const segments = pathname.split("/").filter(Boolean); + const last = segments[segments.length - 1]; + if (!last || last === "marketplace") return null; + return last; +}; + +const downloadJson = (s: StoreSubmission) => { + const url = URL.createObjectURL(new Blob([s.content], { type: "application/json" })); + Object.assign(document.createElement("a"), { + href: url, + download: `${s.title.replace(/[^a-z0-9-_]+/gi, "-").toLowerCase()}.json`, + }).click(); + URL.revokeObjectURL(url); +}; + +const voteDelta = (prev: 1 | -1 | undefined, next: 1 | -1): [up: number, down: number] => { + if (!prev) return next === 1 ? [1, 0] : [0, 1]; + if (prev === next) return next === 1 ? [-1, 0] : [0, -1]; + return next === 1 ? [1, -1] : [-1, 1]; +}; + +const isNotFound = (caught: unknown) => + typeof caught === "object" && caught !== null && "status" in caught && (caught as ClientResponseError).status === 404; + +const DetailSkeleton = () => ( +
+
+
+
+
+
+
+
+
+
+); + +const ScreenshotGallery = ({ urls, title }: { urls: string[]; title: string }) => { + const [idx, setIdx] = useState(0); + + return ( +
+
+ {`${title} +
+ {urls.length > 1 && ( + <> + + +
+ {urls.map((_, i) => ( +
+ + )} +
+ ); +}; + +const colorModeKeys = { dark: "dark", light: "light" } as const; + +const CodeBlock = ({ content, language }: { content: string; language: string }) => { + const { colorMode } = useColorMode(); + const theme = prismThemes[colorModeKeys[colorMode as keyof typeof colorModeKeys] ?? "light"]; + + return ( + + {({ className, style, tokens, getLineProps, getTokenProps }) => ( +
+          {tokens.map((line, i) => (
+            
+ {line.map((token, key) => ( + + ))} +
+ ))} +
+ )} +
+ ); +}; + +const CommentsSection = ({ + submissionId, + pb, + currentUserId, + onRequireAuth, + onError, +}: { + submissionId: string; + pb: ReturnType; + currentUserId?: string; + onRequireAuth: (action: string) => Promise; + onError: (message: string) => void; +}) => { + const [rows, setRows] = useState([]); + const [loading, setLoading] = useState(true); + const [fetchError, setFetchError] = useState(false); + const [newComment, setNewComment] = useState(""); + const [editingId, setEditingId] = useState(null); + const [editContent, setEditContent] = useState(""); + + useEffect(() => { + let cancelled = false; + setLoading(true); + setFetchError(false); + pb.collection("comments") + .getFullList({ + filter: pb.filter("submission = {:id}", { id: submissionId }), + sort: "-created", + expand: "author", + }) + .then((data) => { + if (!cancelled) setRows(data); + }) + .catch(() => { + if (!cancelled) setFetchError(true); + }) + .finally(() => { + if (!cancelled) setLoading(false); + }); + return () => { + cancelled = true; + }; + }, [pb, submissionId]); + + const handleAdd = async () => { + const trimmed = newComment.trim(); + if (!trimmed) return; + const userId = await onRequireAuth("comment"); + if (!userId) return; + try { + const created = await pb + .collection("comments") + .create({ submission: submissionId, content: trimmed, author: userId }, { expand: "author" }); + setRows((prev) => [created, ...prev]); + setNewComment(""); + } catch (caught) { + onError(errorMessage(caught, "Failed to post comment")); + } + }; + + const handleUpdate = async (id: string) => { + const trimmed = editContent.trim(); + if (!trimmed) return; + try { + const updated = await pb.collection("comments").update(id, { content: trimmed }, { expand: "author" }); + setRows((prev) => prev.map((c) => (c.id === id ? updated : c))); + setEditingId(null); + } catch (caught) { + onError(errorMessage(caught, "Failed to update comment")); + } + }; + + const handleDelete = async (id: string) => { + try { + await pb.collection("comments").delete(id); + setRows((prev) => prev.filter((c) => c.id !== id)); + } catch (caught) { + onError(errorMessage(caught, "Failed to delete comment")); + } + }; + + return ( +
+

Comments

+
+ setNewComment(e.target.value)} + onKeyDown={(e) => { + if (e.key === "Enter") void handleAdd(); + }} + /> + +
+ + {loading &&

Loading comments…

} + {!loading && fetchError &&

Failed to load comments

} + {!loading && !fetchError && rows.length === 0 && ( +

No comments yet

+ )} + +
+ {rows.map((comment) => { + const isOwner = currentUserId === comment.author; + const isEditing = editingId === comment.id; + return ( +
+
+ {commentAuthorName(comment)} + {relativeTime(comment.created)} + {isOwner && !isEditing && ( +
+ + +
+ )} +
+ {isEditing ? ( +
+ setEditContent(e.target.value)} + onKeyDown={(e) => { + if (e.key === "Enter") void handleUpdate(comment.id); + }} + /> + + +
+ ) : ( +

{comment.content}

+ )} +
+ ); + })} +
+
+ ); +}; + +const MarketplaceDetail = ({ storeUrl }: { storeUrl: string }) => { + const location = useLocation(); + const submissionId = parseSubmissionId(location.pathname); + const pb = useMemo(() => getPocketBase(storeUrl), [storeUrl]); + + const [submission, setSubmission] = useState(null); + const [userVote, setUserVote] = useState(); + const [user, setUser] = useState(pb.authStore.record); + const [loading, setLoading] = useState(true); + const [notFound, setNotFound] = useState(false); + const [error, setError] = useState(null); + const [copied, setCopied] = useState(false); + const [copyFailed, setCopyFailed] = useState(false); + const copyTimer = useRef>(null); + const voting = useRef(false); + + const requireUserId = useCallback( + async (action: string) => { + if (!pb.authStore.isValid) { + try { + await pb.collection("users").authWithOAuth2({ provider: "github" }); + } catch (caught) { + setError(errorMessage(caught, "Sign in failed")); + return null; + } + } + const userId = pb.authStore.record?.id; + if (!userId) { + setError(`Sign in to ${action}`); + return null; + } + return userId; + }, + [pb], + ); + + useEffect(() => { + if (pb.authStore.isValid) + pb.collection("users") + .authRefresh() + .catch(() => pb.authStore.clear()); + return pb.authStore.onChange(() => setUser(pb.authStore.record)); + }, [pb]); + + useEffect( + () => () => { + if (copyTimer.current) clearTimeout(copyTimer.current); + }, + [], + ); + + useEffect(() => { + if (!submissionId) { + setNotFound(true); + setLoading(false); + return; + } + + let cancelled = false; + setLoading(true); + setNotFound(false); + setError(null); + + const load = async () => { + try { + const record = await pb.collection("marketplace").getOne(submissionId); + if (cancelled) return; + setSubmission(record); + + if (pb.authStore.isValid && pb.authStore.record) { + const votes = await pb.collection("votes").getFullList({ + filter: pb.filter("user = {:uid} && submission = {:sid}", { + uid: pb.authStore.record.id, + sid: submissionId, + }), + }); + if (!cancelled) setUserVote(votes[0]); + } + } catch (caught) { + if (cancelled) return; + if (isNotFound(caught)) setNotFound(true); + else setError(errorMessage(caught, "Failed to load submission")); + } finally { + if (!cancelled) setLoading(false); + } + }; + + void load(); + return () => { + cancelled = true; + }; + }, [pb, submissionId]); + + const screenshotUrls = useMemo( + () => submission?.screenshots?.map((f) => getSubmissionFileUrl(pb.baseURL, submission.id, f)) ?? [], + [submission, pb], + ); + + const handleVote = async (value: 1 | -1) => { + if (!submission || voting.current) return; + voting.current = true; + + const userId = await requireUserId("vote"); + if (!userId) { + voting.current = false; + return; + } + + const prev = userVote; + const isToggleOff = prev?.value === value; + const [upD, downD] = voteDelta(prev?.value, value); + + setUserVote(isToggleOff ? undefined : { ...(prev ?? { id: "", submission: submission.id, user: userId }), value } as StoreVote); + setSubmission((s) => (s ? { ...s, upvotes: s.upvotes + upD, downvotes: s.downvotes + downD } : s)); + + try { + if (!prev) { + await pb.collection("votes").create({ submission: submission.id, value, user: userId }); + const votes = await pb.collection("votes").getFullList({ + filter: pb.filter("user = {:uid} && submission = {:sid}", { uid: userId, sid: submission.id }), + }); + setUserVote(votes[0]); + } else if (isToggleOff) { + await pb.collection("votes").delete(prev.id); + } else { + await pb.collection("votes").update(prev.id, { value }); + } + } catch (caught) { + setUserVote(prev); + setSubmission((s) => (s ? { ...s, upvotes: s.upvotes - upD, downvotes: s.downvotes - downD } : s)); + setError(errorMessage(caught, "Failed to register your vote")); + } finally { + voting.current = false; + } + }; + + const handleDelete = async () => { + if (!submission) return; + try { + await pb.collection("submissions").delete(submission.id); + window.location.href = "/marketplace/"; + } catch (caught) { + setError(errorMessage(caught, "Failed to delete submission")); + } + }; + + const handleCopy = async () => { + if (!submission) return; + try { + await navigator.clipboard.writeText(submission.content); + setCopied(true); + setCopyFailed(false); + if (copyTimer.current) clearTimeout(copyTimer.current); + copyTimer.current = setTimeout(() => setCopied(false), 1500); + } catch { + setCopyFailed(true); + setTimeout(() => setCopyFailed(false), 2000); + } + }; + + if (loading) return ; + + if (notFound || !submission) { + return ( +
+

Submission not found

+

This listing may have been removed or the link is invalid.

+ + Back to Marketplace + +
+ ); + } + + const score = submission.upvotes - submission.downvotes; + const { Icon: CopyIcon, label: copyLabel, iconClass: copyIconClass } = copyState[Number(copied)]; + + return ( +
+ + Back to Marketplace + + + {error && ( +
+ {error} +
+ )} + + {screenshotUrls.length > 0 && ( +
+ +
+ )} + +
+
+
+

{submission.title}

+ {typeLabels[submission.type]} +
+

+ {submission.authorName} · v{submission.version} · {relativeTime(submission.created)} +

+
+ +
+ + + {score} + + +
+
+ + {submission.description && ( +

{submission.description}

+ )} + +
+ + {submission.type === "widget" && ( + + )} + {user?.id === submission.author && ( + + )} +
+ +
+ +
+ +
+ +
+
+ ); +}; + +export default function MarketplaceDetailPage() { + const { siteConfig } = useDocusaurusContext(); + const storeUrl = (siteConfig.customFields?.storeUrl as string | undefined) ?? "http://localhost:8090"; + + return ( + +
+ }> + {() => } + +
+
+ ); +} diff --git a/apps/docs/src/components/store/StoreApp.tsx b/apps/docs/src/components/store/StoreApp.tsx new file mode 100644 index 000000000..d3542547d --- /dev/null +++ b/apps/docs/src/components/store/StoreApp.tsx @@ -0,0 +1,479 @@ +import React, { useEffect, useMemo, useRef, useState } from "react"; +import { + IconArrowBigDown, + IconArrowBigUp, + IconBrandCss3, + IconBrandGithub, + IconCheck, + IconChevronLeft, + IconChevronRight, + IconCopy, + IconDownload, + IconFlag, + IconLogout, + IconPackage, + IconPlus, + IconPuzzle, + IconSearch, + IconTrash, + IconUser, + IconX, +} from "@tabler/icons-react"; + +import { getSubmissionFileUrl, type StoreSubmission } from "@site/src/lib/pocketbase"; +import type { SubmissionType } from "@site/src/lib/store-schema"; + +import { Badge } from "@/components/ui/badge"; +import { Button } from "@/components/ui/button"; +import { + Card, + CardAction, + CardContent, + CardDescription, + CardFooter, + CardHeader, + CardTitle, +} from "@/components/ui/card"; +import { cn } from "@/lib/utils"; + +import { SubmitForm } from "./SubmitForm"; +import type { SortKey, TypeFilter } from "./useStore"; +import { useStore } from "./useStore"; + +const typeIcons: Record> = { + css: IconBrandCss3, + widget: IconPuzzle, +}; +const typeBadgeVariants: Record = { css: "secondary", widget: "default" }; +const filterActiveClass = "bg-background text-foreground shadow-sm"; +const filterInactiveClass = "text-muted-foreground hover:text-foreground"; +const copyState = [ + { Icon: IconCopy, label: "Copy", iconClass: "" }, + { Icon: IconCheck, label: "Copied!", iconClass: "text-green-500" }, +] as const; +const emptyState = { + none: { title: "No submissions yet", hint: "Be the first to share something." }, + 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 }> }[] = [ + { value: "all", label: "All" }, + { value: "widget", label: "Widgets", Icon: IconPuzzle }, + { value: "css", label: "CSS", Icon: IconBrandCss3 }, + { value: "yours", label: "Yours", Icon: IconUser }, +]; +const sortOptions: { value: SortKey; label: string }[] = [ + { value: "top", label: "Top rated" }, + { value: "new", label: "Newest" }, +]; + + +const relativeTime = (date: string) => { + const minutes = Math.floor((Date.now() - new Date(date).getTime()) / 60000); + if (minutes < 1) return "just now"; + if (minutes < 60) return `${minutes}m ago`; + const hours = Math.floor(minutes / 60); + if (hours < 24) return `${hours}h ago`; + return `${Math.floor(hours / 24)}d ago`; +}; + +const downloadJson = (s: StoreSubmission) => { + const url = URL.createObjectURL(new Blob([s.content], { type: "application/json" })); + Object.assign(document.createElement("a"), { + href: url, + download: `${s.title.replace(/[^a-z0-9-_]+/gi, "-").toLowerCase()}.json`, + }).click(); + URL.revokeObjectURL(url); +}; + +export const StoreApp = ({ storeUrl }: { storeUrl: string }) => { + const store = useStore(storeUrl); + const [typeFilter, setTypeFilter] = useState("all"); + const [sort, setSort] = useState("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 sortedIds = useMemo(() => { + const q = search.toLowerCase(); + return store.submissions + .filter((item) => { + if (typeFilter === "yours") return item.author === store.user?.id; + return typeFilter === "all" || item.type === typeFilter; + }) + .filter((item) => !q || item.title.toLowerCase().includes(q)) + .toSorted(store.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]); + + 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 empty = emptyState[store.submissions.length === 0 ? "none" : "filtered"]; + + return ( +
+
+
+

Marketplace

+

Community custom widgets and CSS themes for Homarr.

+
+
+ {store.user ? ( + <> + + + + ) : ( + + )} +
+
+ +
+
+
+ {typeFilters.map((opt) => ( + + ))} +
+ +
+
+ + setSearch(e.target.value)} + /> +
+
+ + {store.error && ( +
+ {store.error} +
+ )} + + {store.loading && ( +
+ {Array.from({ length: 6 }, (_, i) => )} +
+ )} + + {!store.loading && visible.length === 0 && ( +
+ +
+

{empty.title}

+

{empty.hint}

+
+ {store.user && store.submissions.length === 0 && ( + + )} +
+ )} + +
+ {visible.map((submission) => ( + + ))} +
+ + {showSubmit && ( + setShowSubmit(false)} + onSubmit={async (input) => { + if (await store.submit(input)) setShowSubmit(false); + }} + /> + )} +
+ ); +}; + +interface SubmissionCardProps { + submission: StoreSubmission; + pb: ReturnType["pb"]; + userVote?: 1 | -1; + currentUserId?: string; + onVote: (submissionId: string, value: 1 | -1) => void; + onReport: (submissionId: string, reason: string) => void; + onDelete: (submissionId: string) => Promise; +} + +const SubmissionCard = ({ + submission, + pb, + userVote, + currentUserId, + onVote, + onReport, + onDelete, +}: SubmissionCardProps) => { + const isOwner = currentUserId === submission.author; + const [copied, setCopied] = useState(false); + const copyTimer = useRef>(null); + const score = submission.upvotes - submission.downvotes; + const { Icon: CopyIcon, label: copyLabel, iconClass: copyIconClass } = copyState[Number(copied)]; + const screenshotUrls = useMemo( + () => submission.screenshots?.map((f) => getSubmissionFileUrl(pb.baseURL, submission.id, f)) ?? [], + [submission, pb], + ); + + useEffect( + () => () => { + if (copyTimer.current) clearTimeout(copyTimer.current); + }, + [], + ); + + const [copyFailed, setCopyFailed] = useState(false); + const handleCopy = async () => { + try { + await navigator.clipboard.writeText(submission.content); + setCopied(true); + setCopyFailed(false); + if (copyTimer.current) clearTimeout(copyTimer.current); + copyTimer.current = setTimeout(() => setCopied(false), 1500); + } catch { + setCopyFailed(true); + setTimeout(() => setCopyFailed(false), 2000); + } + }; + + const handleReport = () => { + const reason = window.prompt("Why are you reporting this submission?"); + if (reason && reason.trim().length >= 3) void onReport(submission.id, reason.trim()); + }; + + const TypeIcon = typeIcons[submission.type]; + + return ( + + + {screenshotUrls.length > 0 && } + + + + + + {submission.authorName} · v{submission.version} · {relativeTime(submission.created)} + + +
+ + 0 && "text-primary", + score < 0 && "text-destructive", + )} + > + {score} + + +
+
+
+ + + {submission.description && ( +

{submission.description}

+ )} +
+ + +
+ + {submission.type === "widget" && ( + + )} +
+ {isOwner && ( + + )} + +
+
+
+
+ ); +}; + +const SkeletonCard = ({ hasImage }: { hasImage: boolean }) => ( + + {hasImage &&
} + +
+
+
+
+
+ + +
+
+
+
+ + +
+
+
+
+
+ + +); + +const ScreenshotGallery = ({ urls, title }: { urls: string[]; title: string }) => { + const [idx, setIdx] = useState(0); + const dotClass = ["bg-white/40", "bg-white"]; + + return ( +
+
+ {`${title} +
+ {urls.length > 1 && ( + <> + + +
+ {urls.map((_, i) => ( +
+ + )} +
+ ); +}; + diff --git a/apps/docs/src/components/store/SubmitForm.tsx b/apps/docs/src/components/store/SubmitForm.tsx new file mode 100644 index 000000000..79a72eb1c --- /dev/null +++ b/apps/docs/src/components/store/SubmitForm.tsx @@ -0,0 +1,531 @@ +import React, { useCallback, useEffect, useRef, useState } from "react"; +import { + IconBraces, + IconCheck, + IconChevronLeft, + IconChevronRight, + IconFileUpload, + IconLoader2, + IconPalette, + IconPhoto, + IconUpload, + IconX, +} from "@tabler/icons-react"; + +import type { SubmissionType } from "@site/src/lib/store-schema"; +import { validateSubmissionContent } from "@site/src/lib/store-schema"; + +import { Button } from "@/components/ui/button"; +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, +} from "@/components/ui/dialog"; +import { Input } from "@/components/ui/input"; +import { Label } from "@/components/ui/label"; +import { Textarea } from "@/components/ui/textarea"; +import { cn, errorMessage } from "@/lib/utils"; + +import type { SubmitInput } from "./useStore"; + +interface Props { + onClose: () => void; + onSubmit: (input: SubmitInput) => Promise; +} + +const steps = ["Type", "Details", "Media"] as const; +const navDirection = [-1, 1]; +const stepAnimClass = ["submit-step-from-left", "submit-step-from-right"]; +const backLabels = ["Cancel", "Back"]; +const submitLabels = ["Submit", "Submitting…"]; +const connectorClass = ["bg-border", "bg-primary"]; +const dropOverlayClass = ["pointer-events-none opacity-0", "opacity-100"]; + +const placeholders: Record = { + widget: '{\n "$schema": "homarr-custom-widget-v2",\n "name": "My widget",\n ...\n}', + css: ".grid-stack-item-content {\n border-radius: 16px;\n}", +}; +const contentLabels: Record = { + widget: "Widget JSON (homarr-custom-widget-v2)", + css: "Custom CSS", +}; + +const parseJsonObject = (json: string): Record | null => { + try { + const parsed = JSON.parse(json); + if (typeof parsed === "object" && parsed !== null) return parsed; + } catch { + /* invalid json */ + } + return null; +}; + +export const SubmitForm = ({ onClose, onSubmit }: Props) => { + const [step, setStep] = useState(0); + const [direction, setDirection] = useState(1); + const [type, setType] = useState(null); + const [title, setTitle] = useState(""); + const [description, setDescription] = useState(""); + const [content, setContent] = useState(""); + const [screenshots, setScreenshots] = useState([]); + const [previews, setPreviews] = useState([]); + const [error, setError] = useState(null); + const [pending, setPending] = useState(false); + const [jsonDropHint, setJsonDropHint] = useState(false); + const previewUrlsRef = useRef>(new Set()); + + const canAdvance = [type !== null, title.trim().length >= 3 && content.trim().length > 0, true]; + + const goTo = (next: number) => { + setDirection(navDirection[Number(next > step)]); + setStep(next); + }; + + const setContentAndAutofill = useCallback((json: string, currentTitle: string, currentDesc: string) => { + setContent(json); + const meta = parseJsonObject(json); + if (!meta) return; + if (typeof meta.name === "string" && !currentTitle) setTitle(meta.name); + if (typeof meta.description === "string" && !currentDesc) setDescription(String(meta.description)); + }, []); + + const handleJsonFileDrop = useCallback( + (files: FileList | File[]) => { + const jsonFile = Array.from(files).find((f) => f.name.endsWith(".json") || f.type === "application/json"); + if (!jsonFile) return false; + const reader = new FileReader(); + reader.onload = (e) => { + const text = e.target?.result as string; + if (!type) setType("widget"); + setContentAndAutofill(text, title, description); + if (step === 0) goTo(1); + }; + reader.onerror = () => setError("Could not read the file"); + reader.readAsText(jsonFile); + return true; + }, + [type, step, title, description, setContentAndAutofill], + ); + + const addImageFiles = useCallback( + (files: FileList | File[]) => { + const incoming = Array.from(files) + .filter((f) => f.type.startsWith("image/")) + .slice(0, 5 - screenshots.length); + if (incoming.length === 0) return; + setScreenshots((prev) => [...prev, ...incoming].slice(0, 5)); + const urls = incoming.map((f) => { + const url = URL.createObjectURL(f); + previewUrlsRef.current.add(url); + return url; + }); + setPreviews((prev) => [...prev, ...urls].slice(0, 5)); + }, + [screenshots.length], + ); + + const removeScreenshot = (idx: number) => { + const url = previews[idx]; + if (url) { + previewUrlsRef.current.delete(url); + URL.revokeObjectURL(url); + } + setScreenshots((prev) => prev.filter((_, i) => i !== idx)); + setPreviews((prev) => prev.filter((_, i) => i !== idx)); + }; + + useEffect( + () => () => { + previewUrlsRef.current.forEach(URL.revokeObjectURL); + previewUrlsRef.current.clear(); + }, + [], + ); + + const handleGlobalDrop = useCallback( + (e: React.DragEvent) => { + e.preventDefault(); + setJsonDropHint(false); + if (!handleJsonFileDrop(e.dataTransfer.files) && step === 2) addImageFiles(e.dataTransfer.files); + }, + [handleJsonFileDrop, step, addImageFiles], + ); + + const handleSubmit = async () => { + if (!type) return; + const validation = validateSubmissionContent(type, content); + if (!validation.success) { + setError(validation.error); + goTo(1); + return; + } + setError(null); + setPending(true); + try { + await onSubmit({ type, title, description, content, screenshots }); + } catch (caught) { + setError(errorMessage(caught, "Submission failed")); + } finally { + setPending(false); + } + }; + + const onContentChange = (value: string) => { + if (type === "widget") setContentAndAutofill(value, title, description); + else setContent(value); + }; + + return ( + { + if (!open && !pending) onClose(); + }} + > + { + e.preventDefault(); + setJsonDropHint(true); + }} + onDragLeave={() => setJsonDropHint(false)} + onDrop={handleGlobalDrop} + > +
+
+ +
+

Drop JSON file to import widget

+

Title and description will be auto-filled

+
+ + + Share with the community + Submit a custom CSS theme or widget to the marketplace. + + +
+ {steps.map((label, i) => ( + + {i > 0 &&
} + + + ))} +
+ + {error && ( +
+ {error} +
+ )} + +
+
0)])}> + {step === 0 && } + {step === 1 && type && ( + + )} + {step === 2 && ( + + )} +
+
+ + + +
+ {step < 2 && ( + + )} + {step >= 2 && ( + + )} +
+
+ +
+ ); +}; + +const typeCards: { type: SubmissionType; icon: typeof IconBraces; label: string; desc: string }[] = [ + { + type: "widget", + icon: IconBraces, + label: "Custom Widget", + desc: "A JSON-based widget using the homarr-custom-widget-v2 schema", + }, + { type: "css", icon: IconPalette, label: "Custom CSS", desc: "A CSS theme or style override for Homarr dashboards" }, +]; + +const StepType = ({ + value, + onChange, + onJsonDrop, +}: { + value: SubmissionType | null; + onChange: (t: SubmissionType) => void; + onJsonDrop: (files: FileList | File[]) => boolean; +}) => { + const [dropActive, setDropActive] = useState(false); + + return ( +
+
+ {typeCards.map(({ type, icon: Icon, label, desc }) => ( + + ))} +
+ + +
+ ); +}; + +const StepDetails = ({ + type, + title, + setTitle, + description, + setDescription, + content, + onContentChange, +}: { + type: SubmissionType; + title: string; + setTitle: (v: string) => void; + description: string; + setDescription: (v: string) => void; + content: string; + onContentChange: (v: string) => void; +}) => ( +
+
+ +