fix(workshop): use shared dayjs formatting
This commit is contained in:
1 parent
74289b8b01
commit
54871bf22e
9 files changed
+350
-348
No files matched your search
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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<typeof getPocketBase>;
|
||||
currentUserId?: string;
|
||||
onRequireAuth: (action: string) => Promise<string | null>;
|
||||
onError: (message: string) => void;
|
||||
}
|
||||
|
||||
export const CommentsSection = ({ submissionId, pb, currentUserId, onRequireAuth, onError }: CommentsSectionProps) => {
|
||||
const [rows, setRows] = useState<StoreComment[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [fetchError, setFetchError] = useState(false);
|
||||
const [newComment, setNewComment] = useState("");
|
||||
const [editingId, setEditingId] = useState<string | null>(null);
|
||||
const [editContent, setEditContent] = useState("");
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
setLoading(true);
|
||||
setFetchError(false);
|
||||
pb.collection("comments")
|
||||
.getFullList<StoreComment>({
|
||||
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<StoreComment>({ 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<StoreComment>(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 (
|
||||
<div className="flex flex-col gap-3">
|
||||
<h2 className="text-sm font-medium">Comments</h2>
|
||||
<div className="flex items-center gap-2">
|
||||
<Input
|
||||
className="flex-1"
|
||||
placeholder="Write a comment…"
|
||||
aria-label="Write a comment"
|
||||
value={newComment}
|
||||
onChange={(event) => setNewComment(event.target.value)}
|
||||
onKeyDown={(event) => {
|
||||
if (event.key === "Enter") void handleAdd();
|
||||
}}
|
||||
/>
|
||||
<Button size="icon-sm" onClick={() => void handleAdd()} aria-label="Post comment">
|
||||
<IconSend size={14} />
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{loading && <p className="py-4 text-center text-sm text-muted-foreground">Loading comments…</p>}
|
||||
{!loading && fetchError && <p className="py-4 text-center text-sm text-destructive">Failed to load comments</p>}
|
||||
{!loading && !fetchError && rows.length === 0 && (
|
||||
<p className="py-4 text-center text-sm text-muted-foreground">No comments yet</p>
|
||||
)}
|
||||
|
||||
<div className="space-y-2">
|
||||
{rows.map((comment) => {
|
||||
const isOwner = currentUserId === comment.author;
|
||||
const isEditing = editingId === comment.id;
|
||||
return (
|
||||
<div key={comment.id} className="rounded-lg bg-muted/30 px-3 py-2 dark:bg-input/20">
|
||||
<div className="flex items-center gap-2 text-xs">
|
||||
<span className="font-medium">{commentAuthorName(comment)}</span>
|
||||
<span className="text-muted-foreground/60">{formatRelativeTime(comment.created)}</span>
|
||||
{isOwner && !isEditing && (
|
||||
<div className="ml-auto flex gap-0.5">
|
||||
<button
|
||||
className="rounded p-1 hover:bg-accent"
|
||||
aria-label="Edit comment"
|
||||
onClick={() => {
|
||||
setEditingId(comment.id);
|
||||
setEditContent(comment.content);
|
||||
}}
|
||||
>
|
||||
<IconEdit size={12} />
|
||||
</button>
|
||||
<button
|
||||
className="rounded p-1 hover:bg-destructive/20 hover:text-destructive"
|
||||
aria-label="Delete comment"
|
||||
onClick={() => void handleDelete(comment.id)}
|
||||
>
|
||||
<IconTrash size={12} />
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{isEditing ? (
|
||||
<div className="mt-2 flex items-center gap-1">
|
||||
<Input
|
||||
className="flex-1"
|
||||
aria-label="Edit comment"
|
||||
value={editContent}
|
||||
onChange={(event) => setEditContent(event.target.value)}
|
||||
onKeyDown={(event) => {
|
||||
if (event.key === "Enter") void handleUpdate(comment.id);
|
||||
}}
|
||||
/>
|
||||
<Button size="icon-sm" aria-label="Save comment" onClick={() => void handleUpdate(comment.id)}>
|
||||
<IconCheck size={12} />
|
||||
</Button>
|
||||
<Button variant="ghost" size="icon-sm" aria-label="Cancel editing" onClick={() => setEditingId(null)}>
|
||||
<IconX size={12} />
|
||||
</Button>
|
||||
</div>
|
||||
) : (
|
||||
<p className="mt-1 text-sm leading-relaxed text-muted-foreground">{comment.content}</p>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -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<SubmissionType, string> = { css: "CSS", widget: "Widget" };
|
||||
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 = [
|
||||
{ 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 = () => (
|
||||
<div className="mx-auto max-w-4xl px-4 py-8 animate-pulse space-y-6">
|
||||
<div className="h-4 w-24 rounded bg-muted" />
|
||||
<div className="aspect-video w-full rounded-lg bg-muted" />
|
||||
<div className="space-y-2">
|
||||
<div className="h-7 w-2/3 rounded bg-muted" />
|
||||
<div className="h-4 w-40 rounded bg-muted" />
|
||||
</div>
|
||||
<div className="h-24 rounded-lg bg-muted" />
|
||||
<div className="h-48 rounded-lg bg-muted" />
|
||||
</div>
|
||||
);
|
||||
|
||||
const ScreenshotGallery = ({ urls, title }: { urls: string[]; title: string }) => {
|
||||
const [idx, setIdx] = useState(0);
|
||||
|
||||
return (
|
||||
<div className="group/gallery relative w-full">
|
||||
<div className="aspect-video overflow-hidden rounded-lg bg-muted">
|
||||
<img className="h-full w-full object-cover" src={urls[idx]} alt={`${title} screenshot ${idx + 1}`} />
|
||||
</div>
|
||||
{urls.length > 1 && (
|
||||
<>
|
||||
<button
|
||||
className="absolute left-3 top-1/2 flex size-8 -translate-y-1/2 items-center justify-center rounded-md bg-background/80 opacity-60 shadow transition-opacity hover:opacity-100"
|
||||
onClick={() => setIdx((i) => (i - 1 + urls.length) % urls.length)}
|
||||
aria-label="Previous screenshot"
|
||||
>
|
||||
<IconChevronLeft size={16} />
|
||||
</button>
|
||||
<button
|
||||
className="absolute right-3 top-1/2 flex size-8 -translate-y-1/2 items-center justify-center rounded-md bg-background/80 opacity-60 shadow transition-opacity hover:opacity-100"
|
||||
onClick={() => setIdx((i) => (i + 1) % urls.length)}
|
||||
aria-label="Next screenshot"
|
||||
>
|
||||
<IconChevronRight size={16} />
|
||||
</button>
|
||||
<div className="absolute bottom-3 left-1/2 flex -translate-x-1/2 gap-1.5 rounded-full bg-black/50 px-2.5 py-1">
|
||||
{urls.map((_, i) => (
|
||||
<button
|
||||
key={i}
|
||||
onClick={() => setIdx(i)}
|
||||
aria-label={`Screenshot ${i + 1}`}
|
||||
className={cn("size-2 rounded-full transition-all", dotClass[Number(i === idx)])}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
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 (
|
||||
<Highlight theme={theme} code={content} language={language}>
|
||||
{({ className, style, tokens, getLineProps, getTokenProps }) => (
|
||||
<pre
|
||||
className={cn(className, "overflow-auto rounded-lg border border-border p-4 text-sm leading-relaxed")}
|
||||
style={style}
|
||||
>
|
||||
{tokens.map((line, i) => (
|
||||
<div key={i} {...getLineProps({ line })}>
|
||||
{line.map((token, key) => (
|
||||
<span key={key} {...getTokenProps({ token })} />
|
||||
))}
|
||||
</div>
|
||||
))}
|
||||
</pre>
|
||||
)}
|
||||
</Highlight>
|
||||
);
|
||||
};
|
||||
|
||||
const CommentsSection = ({
|
||||
submissionId,
|
||||
pb,
|
||||
currentUserId,
|
||||
onRequireAuth,
|
||||
onError,
|
||||
}: {
|
||||
submissionId: string;
|
||||
pb: ReturnType<typeof getPocketBase>;
|
||||
currentUserId?: string;
|
||||
onRequireAuth: (action: string) => Promise<string | null>;
|
||||
onError: (message: string) => void;
|
||||
}) => {
|
||||
const [rows, setRows] = useState<StoreComment[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [fetchError, setFetchError] = useState(false);
|
||||
const [newComment, setNewComment] = useState("");
|
||||
const [editingId, setEditingId] = useState<string | null>(null);
|
||||
const [editContent, setEditContent] = useState("");
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
setLoading(true);
|
||||
setFetchError(false);
|
||||
pb.collection("comments")
|
||||
.getFullList<StoreComment>({
|
||||
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<StoreComment>({ 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<StoreComment>(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 (
|
||||
<div className="flex flex-col gap-3">
|
||||
<h2 className="text-sm font-medium">Comments</h2>
|
||||
<div className="flex items-center gap-2">
|
||||
<Input
|
||||
className="flex-1"
|
||||
placeholder="Write a comment…"
|
||||
aria-label="Write a comment"
|
||||
value={newComment}
|
||||
onChange={(e) => setNewComment(e.target.value)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter") void handleAdd();
|
||||
}}
|
||||
/>
|
||||
<Button size="icon-sm" onClick={() => void handleAdd()} aria-label="Post comment">
|
||||
<IconSend size={14} />
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{loading && <p className="py-4 text-center text-sm text-muted-foreground">Loading comments…</p>}
|
||||
{!loading && fetchError && <p className="py-4 text-center text-sm text-destructive">Failed to load comments</p>}
|
||||
{!loading && !fetchError && rows.length === 0 && (
|
||||
<p className="py-4 text-center text-sm text-muted-foreground">No comments yet</p>
|
||||
)}
|
||||
|
||||
<div className="space-y-2">
|
||||
{rows.map((comment) => {
|
||||
const isOwner = currentUserId === comment.author;
|
||||
const isEditing = editingId === comment.id;
|
||||
return (
|
||||
<div key={comment.id} className="rounded-lg bg-muted/30 px-3 py-2 dark:bg-input/20">
|
||||
<div className="flex items-center gap-2 text-xs">
|
||||
<span className="font-medium">{commentAuthorName(comment)}</span>
|
||||
<span className="text-muted-foreground/60">{relativeTime(comment.created)}</span>
|
||||
{isOwner && !isEditing && (
|
||||
<div className="ml-auto flex gap-0.5">
|
||||
<button
|
||||
className="rounded p-1 hover:bg-accent"
|
||||
aria-label="Edit comment"
|
||||
onClick={() => {
|
||||
setEditingId(comment.id);
|
||||
setEditContent(comment.content);
|
||||
}}
|
||||
>
|
||||
<IconEdit size={12} />
|
||||
</button>
|
||||
<button
|
||||
className="rounded p-1 hover:bg-destructive/20 hover:text-destructive"
|
||||
aria-label="Delete comment"
|
||||
onClick={() => void handleDelete(comment.id)}
|
||||
>
|
||||
<IconTrash size={12} />
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{isEditing ? (
|
||||
<div className="mt-2 flex items-center gap-1">
|
||||
<Input
|
||||
className="flex-1"
|
||||
aria-label="Edit comment"
|
||||
value={editContent}
|
||||
onChange={(e) => setEditContent(e.target.value)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter") void handleUpdate(comment.id);
|
||||
}}
|
||||
/>
|
||||
<Button size="icon-sm" aria-label="Save comment" onClick={() => void handleUpdate(comment.id)}>
|
||||
<IconCheck size={12} />
|
||||
</Button>
|
||||
<Button variant="ghost" size="icon-sm" aria-label="Cancel editing" onClick={() => setEditingId(null)}>
|
||||
<IconX size={12} />
|
||||
</Button>
|
||||
</div>
|
||||
) : (
|
||||
<p className="mt-1 text-sm leading-relaxed text-muted-foreground">{comment.content}</p>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const DeleteConfirmButton = ({ onConfirm }: { onConfirm: () => void }) => {
|
||||
const [pending, setPending] = useState(false);
|
||||
const timer = useRef<ReturnType<typeof setTimeout>>(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 (
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className={cn(
|
||||
pending ? "border-destructive bg-destructive/10 text-destructive" : "text-destructive hover:bg-destructive/10",
|
||||
)}
|
||||
onClick={handleClick}
|
||||
>
|
||||
<IconTrash size={14} /> {pending ? "Confirm delete?" : "Delete"}
|
||||
</Button>
|
||||
);
|
||||
};
|
||||
|
||||
const MarketplaceDetail = ({ storeUrl }: { storeUrl: string }) => {
|
||||
const location = useLocation();
|
||||
const submissionId = parseSubmissionId(location.pathname);
|
||||
@@ -584,7 +271,7 @@ const MarketplaceDetail = ({ storeUrl }: { storeUrl: string }) => {
|
||||
</Badge>
|
||||
</div>
|
||||
<p className="mt-1 text-sm text-muted-foreground">
|
||||
{submission.authorName} · v{submission.version} · {relativeTime(submission.created)}
|
||||
{submission.authorName} · v{submission.version} · {formatRelativeTime(submission.created)}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
@@ -634,7 +321,7 @@ const MarketplaceDetail = ({ storeUrl }: { storeUrl: string }) => {
|
||||
)}
|
||||
</Button>
|
||||
{submission.type === "widget" && (
|
||||
<Button variant="outline" size="sm" onClick={() => downloadJson(submission)}>
|
||||
<Button variant="outline" size="sm" onClick={() => downloadSubmissionJson(submission)}>
|
||||
<IconDownload size={14} /> Download
|
||||
</Button>
|
||||
)}
|
||||
|
||||
@@ -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 = () => (
|
||||
<div className="mx-auto max-w-4xl px-4 py-8 animate-pulse space-y-6">
|
||||
<div className="h-4 w-24 rounded bg-muted" />
|
||||
<div className="aspect-video w-full rounded-lg bg-muted" />
|
||||
<div className="space-y-2">
|
||||
<div className="h-7 w-2/3 rounded bg-muted" />
|
||||
<div className="h-4 w-40 rounded bg-muted" />
|
||||
</div>
|
||||
<div className="h-24 rounded-lg bg-muted" />
|
||||
<div className="h-48 rounded-lg bg-muted" />
|
||||
</div>
|
||||
);
|
||||
|
||||
export const ScreenshotGallery = ({ urls, title }: { urls: string[]; title: string }) => {
|
||||
const [idx, setIdx] = useState(0);
|
||||
|
||||
return (
|
||||
<div className="group/gallery relative w-full">
|
||||
<div className="aspect-video overflow-hidden rounded-lg bg-muted">
|
||||
<img className="h-full w-full object-cover" src={urls[idx]} alt={`${title} screenshot ${idx + 1}`} />
|
||||
</div>
|
||||
{urls.length > 1 && (
|
||||
<>
|
||||
<button
|
||||
className="absolute left-3 top-1/2 flex size-8 -translate-y-1/2 items-center justify-center rounded-md bg-background/80 opacity-60 shadow transition-opacity hover:opacity-100"
|
||||
onClick={() => setIdx((i) => (i - 1 + urls.length) % urls.length)}
|
||||
aria-label="Previous screenshot"
|
||||
>
|
||||
<IconChevronLeft size={16} />
|
||||
</button>
|
||||
<button
|
||||
className="absolute right-3 top-1/2 flex size-8 -translate-y-1/2 items-center justify-center rounded-md bg-background/80 opacity-60 shadow transition-opacity hover:opacity-100"
|
||||
onClick={() => setIdx((i) => (i + 1) % urls.length)}
|
||||
aria-label="Next screenshot"
|
||||
>
|
||||
<IconChevronRight size={16} />
|
||||
</button>
|
||||
<div className="absolute bottom-3 left-1/2 flex -translate-x-1/2 gap-1.5 rounded-full bg-black/50 px-2.5 py-1">
|
||||
{urls.map((_, i) => (
|
||||
<button
|
||||
key={i}
|
||||
onClick={() => setIdx(i)}
|
||||
aria-label={`Screenshot ${i + 1}`}
|
||||
className={cn("size-2 rounded-full transition-all", dotClass[Number(i === idx)])}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export const CodeBlock = ({ content, language }: { content: string; language: string }) => {
|
||||
const { colorMode } = useColorMode();
|
||||
const theme = prismThemes[colorModeKeys[colorMode as keyof typeof colorModeKeys] ?? "light"];
|
||||
|
||||
return (
|
||||
<Highlight theme={theme} code={content} language={language}>
|
||||
{({ className, style, tokens, getLineProps, getTokenProps }) => (
|
||||
<pre
|
||||
className={cn(className, "overflow-auto rounded-lg border border-border p-4 text-sm leading-relaxed")}
|
||||
style={style}
|
||||
>
|
||||
{tokens.map((line, i) => (
|
||||
<div key={i} {...getLineProps({ line })}>
|
||||
{line.map((token, key) => (
|
||||
<span key={key} {...getTokenProps({ token })} />
|
||||
))}
|
||||
</div>
|
||||
))}
|
||||
</pre>
|
||||
)}
|
||||
</Highlight>
|
||||
);
|
||||
};
|
||||
|
||||
export const DeleteConfirmButton = ({ onConfirm }: { onConfirm: () => void }) => {
|
||||
const [pending, setPending] = useState(false);
|
||||
const timer = useRef<ReturnType<typeof setTimeout>>(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 (
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className={cn(
|
||||
pending ? "border-destructive bg-destructive/10 text-destructive" : "text-destructive hover:bg-destructive/10",
|
||||
)}
|
||||
onClick={handleClick}
|
||||
>
|
||||
<IconTrash size={14} /> {pending ? "Confirm delete?" : "Delete"}
|
||||
</Button>
|
||||
);
|
||||
};
|
||||
@@ -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<HTMLButtonElement>) => {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
@@ -348,7 +332,7 @@ const SubmissionCard = ({
|
||||
)}
|
||||
</div>
|
||||
<CardDescription className="text-xs">
|
||||
{submission.authorName} · v{submission.version} · {relativeTime(submission.created)}
|
||||
{submission.authorName} · v{submission.version} · {formatRelativeTime(submission.created)}
|
||||
</CardDescription>
|
||||
<CardAction>
|
||||
<div className="flex items-center gap-px rounded-md border border-border bg-muted/40 p-px">
|
||||
@@ -413,7 +397,7 @@ const SubmissionCard = ({
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="xs"
|
||||
onClick={() => downloadJson(submission)}
|
||||
onClick={() => downloadSubmissionJson(submission)}
|
||||
className="text-muted-foreground hover:text-foreground"
|
||||
>
|
||||
<IconDownload size={13} /> Download
|
||||
|
||||
@@ -0,0 +1,6 @@
|
||||
import dayjs from "dayjs";
|
||||
import relativeTime from "dayjs/plugin/relativeTime";
|
||||
|
||||
dayjs.extend(relativeTime);
|
||||
|
||||
export const formatRelativeTime = (date: string) => dayjs(date).fromNow();
|
||||
@@ -0,0 +1,16 @@
|
||||
import type { StoreSubmission } from "@site/src/lib/pocketbase";
|
||||
|
||||
export const downloadSubmissionJson = (submission: StoreSubmission) => {
|
||||
const url = URL.createObjectURL(new Blob([submission.content], { type: "application/json" }));
|
||||
Object.assign(document.createElement("a"), {
|
||||
href: url,
|
||||
download: `${submission.title.replace(/[^a-z0-9-_]+/gi, "-").toLowerCase()}.json`,
|
||||
}).click();
|
||||
URL.revokeObjectURL(url);
|
||||
};
|
||||
|
||||
export 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];
|
||||
};
|
||||
@@ -1,4 +1,5 @@
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import dayjs from "dayjs";
|
||||
|
||||
import type { StoreComment, StoreSubmission, StoreVote } from "@site/src/lib/pocketbase";
|
||||
import { getPocketBase } from "@site/src/lib/pocketbase";
|
||||
@@ -6,6 +7,8 @@ import type { SubmissionType } from "@site/src/lib/store-schema";
|
||||
import { schemaVersionByType, validateSubmissionContent } from "@site/src/lib/store-schema";
|
||||
import { errorMessage } from "@site/src/lib/utils";
|
||||
|
||||
import { voteDelta } from "./store-utils";
|
||||
|
||||
export type SortKey = "top" | "new";
|
||||
export type TypeFilter = "all" | "yours" | SubmissionType;
|
||||
|
||||
@@ -26,7 +29,7 @@ export interface CommentActions {
|
||||
|
||||
const sorters: Record<SortKey, (a: StoreSubmission, b: StoreSubmission) => number> = {
|
||||
top: (a, b) => b.upvotes - b.downvotes - (a.upvotes - a.downvotes),
|
||||
new: (a, b) => new Date(b.created).getTime() - new Date(a.created).getTime(),
|
||||
new: (a, b) => dayjs(b.created).valueOf() - dayjs(a.created).valueOf(),
|
||||
};
|
||||
|
||||
const isNotFoundError = (error: unknown) =>
|
||||
@@ -137,12 +140,6 @@ export const useStore = (storeUrl: string) => {
|
||||
}
|
||||
}, [ensureAuth]);
|
||||
|
||||
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 vote = useCallback(
|
||||
async (submissionId: string, value: 1 | -1) => {
|
||||
if (votingIds.current.has(submissionId)) return;
|
||||
|
||||
Reference in new issue
Block a user