From 54871bf22e1e5006a3365a416b2b1cda6a660f21 Mon Sep 17 00:00:00 2001 From: ajnart <49837342+ajnart@users.noreply.github.com> Date: Mon, 6 Jul 2026 02:31:22 +0200 Subject: [PATCH] fix(workshop): use shared dayjs formatting --- .opencode/changelog/2026-07-06.md | 9 + AGENTS.md | 2 + .../src/components/store/DetailComments.tsx | 178 ++++++++++ apps/docs/src/components/store/DetailPage.tsx | 329 +----------------- .../src/components/store/DetailSections.tsx | 123 +++++++ apps/docs/src/components/store/StoreApp.tsx | 24 +- apps/docs/src/components/store/format.ts | 6 + apps/docs/src/components/store/store-utils.ts | 16 + apps/docs/src/components/store/useStore.ts | 11 +- 9 files changed, 350 insertions(+), 348 deletions(-) create mode 100644 .opencode/changelog/2026-07-06.md create mode 100644 apps/docs/src/components/store/DetailComments.tsx create mode 100644 apps/docs/src/components/store/DetailSections.tsx create mode 100644 apps/docs/src/components/store/format.ts create mode 100644 apps/docs/src/components/store/store-utils.ts diff --git a/.opencode/changelog/2026-07-06.md b/.opencode/changelog/2026-07-06.md new file mode 100644 index 000000000..0d8361fad --- /dev/null +++ b/.opencode/changelog/2026-07-06.md @@ -0,0 +1,9 @@ +# Changes — 2026-07-06 + +## Workshop Review Cleanup +- **Why**: Address PR review feedback to use Day.js for date/time logic, split the oversized detail component, and avoid duplicated workshop helpers. +- **What**: Added shared Day.js relative-time formatting, shared download/vote helpers, extracted detail comments and detail UI sections into separate components, and recorded the Day.js/shared-helper convention in `AGENTS.md`. +- **Files**: `apps/docs/src/components/store/DetailPage.tsx`, `apps/docs/src/components/store/StoreApp.tsx`, `apps/docs/src/components/store/useStore.ts`, `apps/docs/src/components/store/DetailComments.tsx`, `apps/docs/src/components/store/DetailSections.tsx`, `apps/docs/src/components/store/format.ts`, `apps/docs/src/components/store/store-utils.ts`, `AGENTS.md` +- **Tests**: `pnpm --filter @homarr/docs typecheck`; `pnpm --filter @homarr/docs lint`; targeted `oxfmt --check` for changed store files. +- **Docs**: Updated `AGENTS.md` with date/time and shared-helper conventions. +- **Risks**: Refactor moves code without intentional behavior changes; UI smoke testing in browser not run. diff --git a/AGENTS.md b/AGENTS.md index be1afe9fb..265f7fe07 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -81,3 +81,5 @@ When modifying user-facing code, you MUST also update the corresponding document - UI: Mantine (not Tailwind) — Tailwind is only used in docs app - Icons: @tabler/icons-react - Docs app can import from `@homarr/definitions` for shared types +- Use `dayjs` for date/time formatting or comparisons; do not hand-roll relative time or date math. +- Extract shared helpers/components when the same logic appears in multiple workshop/store files. diff --git a/apps/docs/src/components/store/DetailComments.tsx b/apps/docs/src/components/store/DetailComments.tsx new file mode 100644 index 000000000..477bba48b --- /dev/null +++ b/apps/docs/src/components/store/DetailComments.tsx @@ -0,0 +1,178 @@ +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 { getPocketBase } from "@site/src/lib/pocketbase"; +import { errorMessage } from "@site/src/lib/utils"; + +import { Button } from "@/components/ui/button"; +import { Input } from "@/components/ui/input"; + +import { formatRelativeTime } from "./format"; + +const commentAuthorName = (comment: StoreComment) => + comment.expand?.author?.name || comment.expand?.author?.username || "unknown"; + +interface CommentsSectionProps { + submissionId: string; + pb: ReturnType; + currentUserId?: string; + onRequireAuth: (action: string) => Promise; + onError: (message: string) => void; +} + +export const CommentsSection = ({ submissionId, pb, currentUserId, onRequireAuth, onError }: CommentsSectionProps) => { + 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((comment) => (comment.id === id ? updated : comment))); + 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((comment) => comment.id !== id)); + } catch (caught) { + onError(errorMessage(caught, "Failed to delete comment")); + } + }; + + return ( +
+

Comments

+
+ setNewComment(event.target.value)} + onKeyDown={(event) => { + if (event.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)} + {formatRelativeTime(comment.created)} + {isOwner && !isEditing && ( +
+ + +
+ )} +
+ {isEditing ? ( +
+ setEditContent(event.target.value)} + onKeyDown={(event) => { + if (event.key === "Enter") void handleUpdate(comment.id); + }} + /> + + +
+ ) : ( +

{comment.content}

+ )} +
+ ); + })} +
+
+ ); +}; diff --git a/apps/docs/src/components/store/DetailPage.tsx b/apps/docs/src/components/store/DetailPage.tsx index 3ec1ce11b..82c3513a5 100644 --- a/apps/docs/src/components/store/DetailPage.tsx +++ b/apps/docs/src/components/store/DetailPage.tsx @@ -3,55 +3,39 @@ 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 type { 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 "@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"; + const typeLabels: Record = { css: "CSS", widget: "Widget" }; const typeDotColors: Record = { css: "bg-blue-500", widget: "bg-yellow-500" }; 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-foreground", 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"; @@ -66,306 +50,9 @@ const parseSubmissionId = (pathname: string) => { 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 DeleteConfirmButton = ({ onConfirm }: { onConfirm: () => void }) => { - const [pending, setPending] = useState(false); - const timer = useRef>(null); - - useEffect( - () => () => { - if (timer.current) clearTimeout(timer.current); - }, - [], - ); - - const handleClick = () => { - if (!pending) { - setPending(true); - timer.current = setTimeout(() => setPending(false), 3000); - return; - } - setPending(false); - onConfirm(); - }; - - return ( - - ); -}; - const MarketplaceDetail = ({ storeUrl }: { storeUrl: string }) => { const location = useLocation(); const submissionId = parseSubmissionId(location.pathname); @@ -584,7 +271,7 @@ const MarketplaceDetail = ({ storeUrl }: { storeUrl: string }) => {

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

@@ -634,7 +321,7 @@ const MarketplaceDetail = ({ storeUrl }: { storeUrl: string }) => { )} {submission.type === "widget" && ( - )} diff --git a/apps/docs/src/components/store/DetailSections.tsx b/apps/docs/src/components/store/DetailSections.tsx new file mode 100644 index 000000000..98ed2e135 --- /dev/null +++ b/apps/docs/src/components/store/DetailSections.tsx @@ -0,0 +1,123 @@ +import React, { useEffect, useRef, useState } from "react"; +import { useColorMode } from "@docusaurus/theme-common"; +import { IconChevronLeft, IconChevronRight, IconTrash } from "@tabler/icons-react"; +import { Highlight, themes } from "prism-react-renderer"; + +import { Button } from "@/components/ui/button"; +import { cn } from "@site/src/lib/utils"; + +const prismThemes = { light: themes.github, dark: themes.dracula } as const; +const colorModeKeys = { dark: "dark", light: "light" } as const; +const dotClass = ["bg-white/40", "bg-white"]; + +export const DetailSkeleton = () => ( +
+
+
+
+
+
+
+
+
+
+); + +export const ScreenshotGallery = ({ urls, title }: { urls: string[]; title: string }) => { + const [idx, setIdx] = useState(0); + + return ( +
+
+ {`${title} +
+ {urls.length > 1 && ( + <> + + +
+ {urls.map((_, i) => ( +
+ + )} +
+ ); +}; + +export 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) => ( + + ))} +
+ ))} +
+ )} +
+ ); +}; + +export const DeleteConfirmButton = ({ onConfirm }: { onConfirm: () => void }) => { + const [pending, setPending] = useState(false); + const timer = useRef>(null); + + useEffect( + () => () => { + if (timer.current) clearTimeout(timer.current); + }, + [], + ); + + const handleClick = () => { + if (!pending) { + setPending(true); + timer.current = setTimeout(() => setPending(false), 3000); + return; + } + setPending(false); + onConfirm(); + }; + + return ( + + ); +}; diff --git a/apps/docs/src/components/store/StoreApp.tsx b/apps/docs/src/components/store/StoreApp.tsx index 1d934183e..54c86edd6 100644 --- a/apps/docs/src/components/store/StoreApp.tsx +++ b/apps/docs/src/components/store/StoreApp.tsx @@ -37,6 +37,8 @@ import { 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"; @@ -69,24 +71,6 @@ const sortOptions: { value: SortKey; label: string }[] = [ { 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); -}; - const stopCardNavigation = (event: React.MouseEvent) => { event.preventDefault(); event.stopPropagation(); @@ -348,7 +332,7 @@ const SubmissionCard = ({ )}
- {submission.authorName} · v{submission.version} · {relativeTime(submission.created)} + {submission.authorName} · v{submission.version} · {formatRelativeTime(submission.created)}
@@ -413,7 +397,7 @@ const SubmissionCard = ({