From 98ae513c25ff2253a8a3fd59131255cb8b8a3c4e Mon Sep 17 00:00:00 2001 From: Sean Rose Date: Tue, 10 Mar 2026 22:25:51 +0800 Subject: [PATCH] Add compact list view mode to bookmarks page Introduces a third view mode (alongside grid and list) that renders a dense, Gmail-style single-row list showing 100 items per page. Each row shows a mini avatar, author, truncated tweet text, colored category dots with hover tooltips, a media thumbnail, and date. Clicking a row opens a full-card detail modal. Made-with: Cursor --- app/bookmarks/page.tsx | 59 ++++++-- components/bookmark-detail-modal.tsx | 47 ++++++ components/bookmark-row.tsx | 204 +++++++++++++++++++++++++++ 3 files changed, 299 insertions(+), 11 deletions(-) create mode 100644 components/bookmark-detail-modal.tsx create mode 100644 components/bookmark-row.tsx diff --git a/app/bookmarks/page.tsx b/app/bookmarks/page.tsx index 260fa5e..389d655 100644 --- a/app/bookmarks/page.tsx +++ b/app/bookmarks/page.tsx @@ -9,15 +9,19 @@ import { ChevronRight, LayoutGrid, List, + AlignJustify, X, ChevronDown, ArrowUpDown, } from 'lucide-react' import * as Select from '@radix-ui/react-select' import BookmarkCard from '@/components/bookmark-card' +import BookmarkRow from '@/components/bookmark-row' +import BookmarkDetailModal from '@/components/bookmark-detail-modal' import type { BookmarkWithMedia, BookmarksResponse } from '@/lib/types' -const PAGE_SIZE = 24 +const DEFAULT_PAGE_SIZE = 24 +const COMPACT_PAGE_SIZE = 100 interface Filters { q: string @@ -39,7 +43,7 @@ const DEFAULT_FILTERS: Filters = { uncategorized: false, } -function buildUrl(filters: Filters): string { +function buildUrl(filters: Filters, limit: number): string { const params = new URLSearchParams() if (filters.q) params.set('q', filters.q) if (filters.uncategorized) { @@ -51,7 +55,7 @@ function buildUrl(filters: Filters): string { if (filters.source) params.set('source', filters.source) params.set('sort', filters.sort) params.set('page', String(filters.page)) - params.set('limit', String(PAGE_SIZE)) + params.set('limit', String(limit)) return `/api/bookmarks?${params.toString()}` } @@ -209,13 +213,14 @@ function BookmarksPageInner() { const [bookmarks, setBookmarks] = useState([]) const [total, setTotal] = useState(0) const [loading, setLoading] = useState(true) - const [viewMode, setViewMode] = useState<'grid' | 'list'>('grid') + const [viewMode, setViewMode] = useState<'grid' | 'list' | 'compact'>('grid') + const [openBookmark, setOpenBookmark] = useState(null) const searchRef = useRef | null>(null) - const fetchBookmarks = useCallback(async (f: Filters) => { + const fetchBookmarks = useCallback(async (f: Filters, limit: number) => { setLoading(true) try { - const res = await fetch(buildUrl(f)) + const res = await fetch(buildUrl(f, limit)) if (!res.ok) throw new Error('Failed to fetch') const data: BookmarksResponse = await res.json() setBookmarks(data.bookmarks) @@ -229,9 +234,16 @@ function BookmarksPageInner() { } }, []) + const pageSize = viewMode === 'compact' ? COMPACT_PAGE_SIZE : DEFAULT_PAGE_SIZE + useEffect(() => { - fetchBookmarks(filters) - }, [fetchBookmarks, filters]) + fetchBookmarks(filters, pageSize) + }, [fetchBookmarks, filters, pageSize]) + + function handleSetViewMode(mode: 'grid' | 'list' | 'compact') { + setViewMode(mode) + setFilters((prev) => ({ ...prev, page: 1 })) + } function updateSearch(q: string) { setSearchInput(q) @@ -326,7 +338,7 @@ function BookmarksPageInner() { {/* View toggle */}
+
@@ -457,13 +478,29 @@ function BookmarksPageInner() { )} + {/* Compact view */} + {!loading && bookmarks.length > 0 && viewMode === 'compact' && ( +
+ {bookmarks.map((bookmark) => ( + + ))} +
+ )} + setFilters((prev) => ({ ...prev, page: p }))} /> + + {openBookmark && ( + setOpenBookmark(null)} + /> + )} ) } diff --git a/components/bookmark-detail-modal.tsx b/components/bookmark-detail-modal.tsx new file mode 100644 index 0000000..7bd8388 --- /dev/null +++ b/components/bookmark-detail-modal.tsx @@ -0,0 +1,47 @@ +'use client' + +import { useEffect, useCallback } from 'react' +import { X } from 'lucide-react' +import BookmarkCard from '@/components/bookmark-card' +import type { BookmarkWithMedia } from '@/lib/types' + +interface BookmarkDetailModalProps { + bookmark: BookmarkWithMedia + onClose: () => void +} + +export default function BookmarkDetailModal({ bookmark, onClose }: BookmarkDetailModalProps) { + const handleEscape = useCallback((e: KeyboardEvent) => { + if (e.key === 'Escape') onClose() + }, [onClose]) + + useEffect(() => { + document.addEventListener('keydown', handleEscape) + document.body.style.overflow = 'hidden' + return () => { + document.removeEventListener('keydown', handleEscape) + document.body.style.overflow = '' + } + }, [handleEscape]) + + return ( +
+
e.stopPropagation()} + > + + +
+
+ ) +} diff --git a/components/bookmark-row.tsx b/components/bookmark-row.tsx new file mode 100644 index 0000000..7783767 --- /dev/null +++ b/components/bookmark-row.tsx @@ -0,0 +1,204 @@ +'use client' + +import { useState } from 'react' +import { Image, Play } from 'lucide-react' +import type { BookmarkWithMedia } from '@/lib/types' + +// ── Helpers ───────────────────────────────────────────────────────────────── + +const TCO_REGEX = /https?:\/\/t\.co\/[^\s]+/g + +function stripTcoUrls(text: string): string { + return text.replace(TCO_REGEX, '').trim() +} + +function isVideoUrl(url: string): boolean { + return url.includes('video.twimg.com') || url.includes('.mp4') +} + +function formatDate(dateStr: string | null): string { + if (!dateStr) return '' + const d = new Date(dateStr) + const now = new Date() + const diffDays = Math.floor((now.getTime() - d.getTime()) / 86400000) + if (diffDays === 0) return 'Today' + if (diffDays === 1) return 'Yesterday' + if (diffDays < 7) return `${diffDays}d ago` + return d.toLocaleDateString('en-US', { month: 'short', day: 'numeric', year: d.getFullYear() !== now.getFullYear() ? 'numeric' : undefined }) +} + +const COLOR_PALETTE = [ + '#6366f1', '#8b5cf6', '#ec4899', '#f59e0b', + '#10b981', '#3b82f6', '#ef4444', '#14b8a6', +] + +function stringToColor(str: string): string { + let hash = 0 + for (let i = 0; i < str.length; i++) { + hash = str.charCodeAt(i) + ((hash << 5) - hash) + } + return COLOR_PALETTE[Math.abs(hash) % COLOR_PALETTE.length] +} + +function getInitials(name: string): string { + const parts = name.trim().split(/\s+/) + if (parts.length === 1) return parts[0].slice(0, 2).toUpperCase() + return (parts[0][0] + parts[parts.length - 1][0]).toUpperCase() +} + +// ── Avatar ─────────────────────────────────────────────────────────────────── + +function MiniAvatar({ name, handle }: { name: string; handle: string }) { + const [imgFailed, setImgFailed] = useState(false) + const bg = stringToColor(handle) + const initials = getInitials(name) + const cleanHandle = handle.replace(/^@/, '') + const src = cleanHandle && cleanHandle !== 'unknown' + ? `https://unavatar.io/twitter/${cleanHandle}` + : null + + if (src && !imgFailed) { + return ( + // eslint-disable-next-line @next/next/no-img-element + {name} setImgFailed(true)} + /> + ) + } + + return ( + + ) +} + +// ── Media Indicator ────────────────────────────────────────────────────────── + +function MediaIndicator({ item }: { item: BookmarkWithMedia['mediaItems'][number] }) { + const isVideo = item.type === 'video' || isVideoUrl(item.url) + const thumb = item.thumbnailUrl && !isVideoUrl(item.thumbnailUrl) + ? item.thumbnailUrl + : (!isVideoUrl(item.url) ? item.url : null) + + if (thumb) { + return ( +
+ {/* eslint-disable-next-line @next/next/no-img-element */} + { (e.currentTarget as HTMLImageElement).style.display = 'none' }} + /> + {isVideo && ( +
+ +
+ )} +
+ ) + } + + return ( +
+ {isVideo + ? + : + } +
+ ) +} + +// ── Main Row ───────────────────────────────────────────────────────────────── + +interface BookmarkRowProps { + bookmark: BookmarkWithMedia + onClick: (bookmark: BookmarkWithMedia) => void +} + +export default function BookmarkRow({ bookmark, onClick }: BookmarkRowProps) { + const isKnownAuthor = bookmark.authorHandle !== 'unknown' + const cleanText = stripTcoUrls(bookmark.text) + const dateStr = formatDate(bookmark.tweetCreatedAt ?? bookmark.importedAt ?? null) + const firstMedia = bookmark.mediaItems[0] ?? null + + return ( +
onClick(bookmark)} + onKeyDown={(e) => { if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); onClick(bookmark) } }} + className="flex items-center gap-3 px-4 py-2.5 cursor-pointer hover:bg-zinc-800/60 transition-colors group" + > + {/* Avatar */} + {isKnownAuthor ? ( + + ) : ( +
+ )} + + {/* Author — fixed width */} +
+ {isKnownAuthor ? ( + <> +

+ {bookmark.authorName} +

+

+ @{bookmark.authorHandle} +

+ + ) : ( +

Unknown

+ )} +
+ + {/* Tweet text snippet — flex-1 */} +

+ {cleanText || No text} +

+ + {/* Category dots */} + {bookmark.categories.length > 0 && ( +
+ {bookmark.categories.map((cat) => ( + + + + {cat.name} + + + ))} +
+ )} + + {/* Media indicator */} + {firstMedia ? ( + + ) : ( +
+ )} + + {/* Date */} + + {dateStr} + +
+ ) +}