diff --git a/CHANGELOG.md b/CHANGELOG.md index f725324f..ba2900ab 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,23 @@ This project adheres to [Semantic Versioning](https://semver.org/). --- +## v1.3.0 — 2026-03-30 + +### ✨ Features +- feat: add footer with attribution to techdiary.dev in GistCodeImageDialog (b7c15b9) +- feat: add clipboard copy functionality to GistCodeImageDialog (01831a8) +- feat: add image export functionality to GistViewer (f9c31ba) + +### 🐛 Bug Fixes +- fix: improve error handling in bookmark and reaction services (6199497) + +### 🔧 Other Changes +- refactor: streamline Gist retrieval logic and enhance error handling (062cac5) +- refactor: enhance bookmarks handling and improve state management (6368aaa) +- docs: update CLAUDE.md to reflect authentication and Gist enhancements (14c7236) + +--- + ## v1.2.0 — 2026-03-30 ### ✨ Features diff --git a/CLAUDE.md b/CLAUDE.md index dfafdc12..18c88e65 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -33,7 +33,7 @@ This file provides guidance to Claude Code (claude.ai/code) when working with co - **Styling**: Tailwind CSS 4, shadcn/ui components - **Backend**: Next.js Server Actions, Drizzle ORM (migrations only) - **Database**: PostgreSQL -- **Authentication**: GitHub OAuth +- **Authentication**: WorkOS (primary), GitHub OAuth (legacy fallback) - **Search**: MeilSearch - **File Storage**: Cloudinary / Cloudflare R2 - **State Management**: Jotai, TanStack Query, React Hook Form with Zod validation @@ -52,6 +52,7 @@ This file provides guidance to Claude Code (claude.ai/code) when working with co - Route groups using Next.js App Router: - `(home)` - Main homepage and article feed - `(dashboard-editor)` - Protected dashboard routes + - `gists` - Gist browsing, creation, and viewing - `[username]` - User profile pages - `[username]/[articleHandle]` - Individual article pages - API routes in `/api/` for OAuth and development @@ -79,6 +80,7 @@ Key entities and their relationships: - **Tags** - Article categorization - **Bookmarks** - User content saving - **Reactions** - Emoji-based reactions (LOVE, FIRE, WOW, etc.) +- **Gists** - Code snippets with multiple files (`gists` + `gist_files` tables) - **User Sessions** - Session management - **User Socials** - OAuth provider connections @@ -208,7 +210,7 @@ persistenceRepository.article.paginate({ where, orderBy, limit, page }) persistenceRepository.article.find({ where, columns, joins }) ``` -Available repositories: `user`, `userSocial`, `userSession`, `article`, `bookmark`, `comment`, `reaction`, `articleTagPivot`, `tags`, `series`, `seriesItems`, `kv`. +Available repositories: `user`, `userSocial`, `userSession`, `article`, `bookmark`, `comment`, `reaction`, `articleTagPivot`, `tags`, `series`, `seriesItems`, `kv`, `gist`, `gistFile`. For complex multi-join queries, raw SQL is executed directly via `pgClient.executeSQL()`. diff --git a/package.json b/package.json index 661b9e02..b4247e55 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "techdiary.dev-next", - "version": "1.2.0", + "version": "1.3.0", "private": true, "scripts": { "dev": "next dev --turbo", @@ -56,6 +56,7 @@ "lottie-react": "^2.4.1", "lucide-react": "^0.484.0", "meilisearch": "^0.51.0", + "modern-screenshot": "^4.6.8", "next": "^16.2.1", "next-themes": "^0.4.6", "pg": "^8.14.1", diff --git a/src/app/dashboard/bookmarks/page.tsx b/src/app/dashboard/bookmarks/page.tsx index b2418422..765341de 100644 --- a/src/app/dashboard/bookmarks/page.tsx +++ b/src/app/dashboard/bookmarks/page.tsx @@ -27,6 +27,12 @@ interface BookmarkData { meta: BookmarkMeta; } +function isBookmarksSuccess( + page: Awaited> | undefined +): page is BookmarkData { + return Boolean(page && "meta" in page && "nodes" in page); +} + const BookmarksPage = () => { const { _t } = useTranslation(); const feedInfiniteQuery = useInfiniteQuery({ @@ -35,15 +41,16 @@ const BookmarksPage = () => { myBookmarks({ limit: 10, page: pageParam, offset: 0 }), initialPageParam: 1, getNextPageParam: (lastPage) => { - const _page = lastPage?.meta?.currentPage ?? 1; - const _totalPages = lastPage?.meta?.totalPages ?? 1; + if (!isBookmarksSuccess(lastPage)) return null; + const _page = lastPage.meta.currentPage; + const _totalPages = lastPage.meta.totalPages; return _page + 1 <= _totalPages ? _page + 1 : null; }, }); const hasItems = useMemo(() => { - const length = feedInfiniteQuery.data?.pages.flat()[0]?.nodes.length ?? 0; - return length > 0; + const firstOk = feedInfiniteQuery.data?.pages.find(isBookmarksSuccess); + return (firstOk?.nodes.length ?? 0) > 0; }, [feedInfiniteQuery]); const appConfirm = useAppConfirm(); @@ -65,8 +72,9 @@ const BookmarksPage = () => {
))} - {feedInfiniteQuery.data?.pages.map((page) => { - return page?.nodes.map((bookmark) => ( + {feedInfiniteQuery.data?.pages.flatMap((page) => { + if (!isBookmarksSuccess(page)) return []; + return page.nodes.map((bookmark) => (
({ + reaction_type, + count: 0, + is_reacted: false, + reactor_user_ids: [], + })); +} + export async function toogleReaction( _input: z.infer ) { @@ -66,7 +84,7 @@ export async function toogleReaction( is_reacted: true, }; } catch (error) { - handleActionException(error); + return handleActionException(error); } } @@ -117,18 +135,17 @@ export async function getResourceReactions( } // Return all types, filling missing ones with count: 0 - return ["LOVE", "UNICORN", "WOW", "FIRE", "CRY", "HAHA"].map( - (reaction_type) => { - const entry = reactionMap.get(reaction_type); - return { - reaction_type, - count: entry?.count ?? 0, - is_reacted: entry?.is_reacted ?? false, - reactor_user_ids: entry?.reactor_user_ids ?? [], - }; - } - ); + return REACTION_TYPES.map((reaction_type) => { + const entry = reactionMap.get(reaction_type); + return { + reaction_type, + count: entry?.count ?? 0, + is_reacted: entry?.is_reacted ?? false, + reactor_user_ids: entry?.reactor_user_ids ?? [], + }; + }); } catch (error) { handleActionException(error); + return emptyReactionStatuses(); } } diff --git a/src/components/Gist/GistCodeImageDialog.tsx b/src/components/Gist/GistCodeImageDialog.tsx new file mode 100644 index 00000000..49eed88e --- /dev/null +++ b/src/components/Gist/GistCodeImageDialog.tsx @@ -0,0 +1,484 @@ +"use client"; + +import { Button } from "@/components/ui/button"; +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, +} from "@/components/ui/dialog"; +import { Label } from "@/components/ui/label"; +import { cn } from "@/lib/utils"; +import { + dracula, + nightOwl, + nord, + okaidia, + oneDark, + shadesOfPurple, + vscDarkPlus, +} from "react-syntax-highlighter/dist/esm/styles/prism"; +import { Prism as SyntaxHighlighter } from "react-syntax-highlighter"; +import { useCallback, useMemo, useRef, useState } from "react"; +import { toast } from "sonner"; +import { ClipboardCopy, Download, Loader2 } from "lucide-react"; +import { domToBlob } from "modern-screenshot"; + +type PrismTheme = typeof vscDarkPlus; + +const THEMES: { id: string; label: string; style: PrismTheme }[] = [ + { id: "vsc", label: "VS Code Dark+", style: vscDarkPlus }, + { id: "night", label: "Night Owl", style: nightOwl }, + { id: "dracula", label: "Dracula", style: dracula }, + { id: "one", label: "One Dark", style: oneDark }, + { id: "okaidia", label: "Okaidia", style: okaidia }, + { id: "purple", label: "Shades of Purple", style: shadesOfPurple }, + { id: "nord", label: "Nord", style: nord }, +]; + +const BACKGROUNDS: { id: string; label: string; css: string }[] = [ + { + id: "aurora", + label: "Aurora", + css: "linear-gradient(145deg, #0f0c29 0%, #302b63 45%, #24243e 100%)", + }, + { + id: "ember", + label: "Ember", + css: "linear-gradient(135deg, #1a1a2e 0%, #16213e 50%, #0f3460 100%)", + }, + { + id: "forest", + label: "Forest", + css: "linear-gradient(135deg, #134e5e 0%, #71b280 100%)", + }, + { + id: "sunset", + label: "Sunset", + css: "linear-gradient(135deg, #2d1b4e 0%, #8b2500 50%, #ff6b35 100%)", + }, + { id: "void", label: "Void", css: "#0d1117" }, + { id: "slate", label: "Slate", css: "#1e293b" }, +]; + +function guessLanguage(filename: string, fileLanguage?: string | null): string { + const l = fileLanguage?.trim().toLowerCase(); + if (l) { + if (l === "md" || l === "markdown") return "markdown"; + return l; + } + const ext = filename.split(".").pop()?.toLowerCase() ?? ""; + const map: Record = { + ts: "typescript", + tsx: "tsx", + js: "javascript", + jsx: "jsx", + mjs: "javascript", + cjs: "javascript", + py: "python", + rb: "ruby", + go: "go", + rs: "rust", + md: "markdown", + json: "json", + css: "css", + scss: "scss", + html: "markup", + xml: "markup", + svg: "markup", + yml: "yaml", + yaml: "yaml", + sh: "bash", + bash: "bash", + zsh: "bash", + sql: "sql", + graphql: "graphql", + vue: "markup", + java: "java", + kt: "kotlin", + swift: "swift", + php: "php", + }; + return map[ext] ?? "text"; +} + +interface GistCodeImageDialogProps { + open: boolean; + onOpenChange: (open: boolean) => void; + filename: string; + content: string; + language?: string | null; +} + +export default function GistCodeImageDialog({ + open, + onOpenChange, + filename, + content, + language, +}: GistCodeImageDialogProps) { + const captureRef = useRef(null); + const [themeId, setThemeId] = useState(THEMES[0].id); + const [bgId, setBgId] = useState(BACKGROUNDS[0].id); + const [padding, setPadding] = useState(40); + const [frameWidth, setFrameWidth] = useState(920); + const [lineNumbers, setLineNumbers] = useState(false); + const [exporting, setExporting] = useState(false); + + const theme = useMemo( + () => THEMES.find((t) => t.id === themeId) ?? THEMES[0], + [themeId], + ); + const background = useMemo( + () => BACKGROUNDS.find((b) => b.id === bgId) ?? BACKGROUNDS[0], + [bgId], + ); + const lang = useMemo( + () => guessLanguage(filename, language), + [filename, language], + ); + + /** + * Radix Dialog centers with transform; rasterizing that subtree skews the SVG/canvas. + * Clone to document.body with transform:none so export matches the preview. + */ + const createCaptureBlob = useCallback(async (): Promise => { + const el = captureRef.current; + if (!el) return null; + + await document.fonts.ready; + await new Promise((resolve) => { + requestAnimationFrame(() => requestAnimationFrame(() => resolve())); + }); + + let clone: HTMLElement | null = null; + try { + const naturalW = Math.ceil(Math.max(el.scrollWidth, el.offsetWidth)); + clone = el.cloneNode(true) as HTMLElement; + clone.style.position = "fixed"; + clone.style.left = "0"; + clone.style.top = "0"; + clone.style.width = `${naturalW}px`; + clone.style.maxWidth = "none"; + clone.style.height = "auto"; + clone.style.minHeight = "0"; + clone.style.margin = "0"; + clone.style.transform = "none"; + clone.style.boxSizing = "border-box"; + clone.style.overflow = "visible"; + clone.style.zIndex = "40"; + clone.style.pointerEvents = "none"; + document.body.appendChild(clone); + + await new Promise((resolve) => { + requestAnimationFrame(() => requestAnimationFrame(() => resolve())); + }); + + const w2 = Math.ceil(Math.max(clone.offsetWidth, clone.scrollWidth)); + const h2 = Math.ceil(clone.scrollHeight); + const scale = Math.min(4, Math.max(3, window.devicePixelRatio || 2)); + const blob = await domToBlob(clone, { + scale, + width: w2, + height: h2, + backgroundColor: null, + }); + + if (!blob || blob.size < 32) return null; + return blob; + } finally { + clone?.remove(); + } + }, []); + + const handleExport = useCallback(async () => { + setExporting(true); + try { + const blob = await createCaptureBlob(); + if (!blob) { + toast.error("Could not create image"); + return; + } + + const safeName = + filename.replace(/[^a-zA-Z0-9._-]+/g, "_").slice(0, 80) || "code"; + const url = URL.createObjectURL(blob); + const link = document.createElement("a"); + link.download = `${safeName}.png`; + link.href = url; + link.click(); + URL.revokeObjectURL(url); + toast.success("Image downloaded"); + } catch (e) { + console.error(e); + toast.error("Could not create image"); + } finally { + setExporting(false); + } + }, [createCaptureBlob, filename]); + + const handleCopyImage = useCallback(async () => { + if (!navigator.clipboard?.write || typeof ClipboardItem === "undefined") { + toast.error("Copying images is not supported in this browser"); + return; + } + + setExporting(true); + try { + const blob = await createCaptureBlob(); + if (!blob) { + toast.error("Could not create image"); + return; + } + + const type = blob.type && blob.type.startsWith("image/") ? blob.type : "image/png"; + await navigator.clipboard.write([new ClipboardItem({ [type]: blob })]); + toast.success("Image copied to clipboard"); + } catch (e) { + console.error(e); + toast.error("Could not copy image"); + } finally { + setExporting(false); + } + }, [createCaptureBlob]); + + return ( + + + + Code image + + +
+
+
+ + +
+
+ + +
+
+ +
+
+ + setPadding(Number(e.target.value))} + className="w-full accent-primary" + /> +
+
+ + setFrameWidth(Number(e.target.value))} + className="w-full accent-primary" + /> +
+
+ + +
+ +
+
+
+
+ + + + + {filename} + +
+ + {content} + +
+
+ + techdiary.dev + +
+
+
+ + + + + + +
+
+ ); +} diff --git a/src/components/Gist/GistViewer.tsx b/src/components/Gist/GistViewer.tsx index 3da8e99a..5d04740b 100644 --- a/src/components/Gist/GistViewer.tsx +++ b/src/components/Gist/GistViewer.tsx @@ -18,6 +18,8 @@ import { Link2Icon, FileIcon, } from "@radix-ui/react-icons"; +import { ImageIcon } from "lucide-react"; +import GistCodeImageDialog from "@/components/Gist/GistCodeImageDialog"; import { useMutation, useQueryClient } from "@tanstack/react-query"; import { useRouter } from "next/navigation"; import { useState } from "react"; @@ -86,6 +88,9 @@ export default function GistViewer({ const router = useRouter(); const queryClient = useQueryClient(); const appConfirm = useAppConfirm(); + const [imageExportFile, setImageExportFile] = useState( + null + ); const renderFileContent = (file: GistFile) => { const ext = file.filename ? file.filename.split(".").pop() : undefined; @@ -257,6 +262,16 @@ export default function GistViewer({
+ )} + + { + if (!open) setImageExportFile(null); + }} + filename={imageExportFile?.filename ?? ""} + content={imageExportFile?.content ?? ""} + language={imageExportFile?.language} + />
); } diff --git a/src/components/render-props/ResourceBookmarkable.tsx b/src/components/render-props/ResourceBookmarkable.tsx index e1e5f513..3c9dc43c 100644 --- a/src/components/render-props/ResourceBookmarkable.tsx +++ b/src/components/render-props/ResourceBookmarkable.tsx @@ -32,9 +32,10 @@ export const ResourceBookmarkable: React.FC = ({ mutationFn: () => bookmarkAction.toggleResourceBookmark({ resource_id, resource_type }), - // Ensure state is accurate after success onSuccess: (data) => { - setBookmarked(data?.bookmarked ?? false); + if (data && "bookmarked" in data) { + setBookmarked(data.bookmarked); + } }, });