Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
59 changes: 48 additions & 11 deletions app/bookmarks/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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) {
Expand All @@ -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()}`
}

Expand Down Expand Up @@ -209,13 +213,14 @@ function BookmarksPageInner() {
const [bookmarks, setBookmarks] = useState<BookmarkWithMedia[]>([])
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<BookmarkWithMedia | null>(null)
const searchRef = useRef<ReturnType<typeof setTimeout> | 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)
Expand All @@ -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)
Expand Down Expand Up @@ -326,7 +338,7 @@ function BookmarksPageInner() {
{/* View toggle */}
<div className="flex items-center gap-0.5 bg-zinc-900 border border-zinc-800 rounded-xl p-1 shrink-0">
<button
onClick={() => setViewMode('grid')}
onClick={() => handleSetViewMode('grid')}
className={`p-1.5 rounded-lg transition-all ${
viewMode === 'grid' ? 'bg-zinc-700 text-zinc-100' : 'text-zinc-600 hover:text-zinc-300'
}`}
Expand All @@ -335,14 +347,23 @@ function BookmarksPageInner() {
<LayoutGrid size={14} />
</button>
<button
onClick={() => setViewMode('list')}
onClick={() => handleSetViewMode('list')}
className={`p-1.5 rounded-lg transition-all ${
viewMode === 'list' ? 'bg-zinc-700 text-zinc-100' : 'text-zinc-600 hover:text-zinc-300'
}`}
aria-label="List view"
>
<List size={14} />
</button>
<button
onClick={() => handleSetViewMode('compact')}
className={`p-1.5 rounded-lg transition-all ${
viewMode === 'compact' ? 'bg-zinc-700 text-zinc-100' : 'text-zinc-600 hover:text-zinc-300'
}`}
aria-label="Compact view"
>
<AlignJustify size={14} />
</button>
</div>

</div>
Expand Down Expand Up @@ -457,13 +478,29 @@ function BookmarksPageInner() {
</div>
)}

{/* Compact view */}
{!loading && bookmarks.length > 0 && viewMode === 'compact' && (
<div className="flex flex-col divide-y divide-zinc-800/50 border border-zinc-800 rounded-2xl overflow-hidden max-w-5xl mx-auto">
{bookmarks.map((bookmark) => (
<BookmarkRow key={bookmark.id} bookmark={bookmark} onClick={setOpenBookmark} />
))}
</div>
)}

<Pagination
page={filters.page}
total={total}
limit={PAGE_SIZE}
limit={pageSize}
onChange={(p) => setFilters((prev) => ({ ...prev, page: p }))}
/>
</div>

{openBookmark && (
<BookmarkDetailModal
bookmark={openBookmark}
onClose={() => setOpenBookmark(null)}
/>
)}
</div>
)
}
Expand Down
47 changes: 47 additions & 0 deletions components/bookmark-detail-modal.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<div
className="fixed inset-0 z-50 flex items-start justify-center bg-black/70 backdrop-blur-sm overflow-y-auto"
onClick={onClose}
>
<div
className="relative w-full max-w-xl mx-auto mt-16 mb-16 px-4"
onClick={(e) => e.stopPropagation()}
>
<button
onClick={onClose}
className="absolute -top-10 right-4 p-2 rounded-full text-zinc-400 hover:text-zinc-100 hover:bg-zinc-800 transition-colors"
aria-label="Close"
>
<X size={18} />
</button>
<BookmarkCard bookmark={bookmark} />
</div>
</div>
)
}
204 changes: 204 additions & 0 deletions components/bookmark-row.tsx
Original file line number Diff line number Diff line change
@@ -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
<img
src={src}
alt={name}
className="shrink-0 w-6 h-6 rounded-full object-cover select-none"
loading="lazy"
onError={() => setImgFailed(true)}
/>
)
}

return (
<div
className="shrink-0 w-6 h-6 rounded-full flex items-center justify-center text-white text-[9px] font-bold select-none"
style={{ backgroundColor: bg }}
aria-hidden="true"
>
{initials}
</div>
)
}

// ── 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 (
<div className="relative shrink-0 w-8 h-8 rounded overflow-hidden border border-zinc-700/50">
{/* eslint-disable-next-line @next/next/no-img-element */}
<img
src={thumb}
alt=""
className="w-full h-full object-cover"
loading="lazy"
onError={(e) => { (e.currentTarget as HTMLImageElement).style.display = 'none' }}
/>
{isVideo && (
<div className="absolute inset-0 flex items-center justify-center bg-black/40">
<Play size={8} className="text-white fill-white" />
</div>
)}
</div>
)
}

return (
<div className="shrink-0 w-8 h-8 rounded flex items-center justify-center border border-zinc-700/50 bg-zinc-800/60">
{isVideo
? <Play size={10} className="text-zinc-500" />
: <Image size={10} className="text-zinc-500" />
}
</div>
)
}

// ── 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 (
<div
role="button"
tabIndex={0}
onClick={() => 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 ? (
<MiniAvatar name={bookmark.authorName} handle={bookmark.authorHandle} />
) : (
<div className="shrink-0 w-6 h-6 rounded-full bg-zinc-700" />
)}

{/* Author — fixed width */}
<div className="shrink-0 w-36 min-w-0">
{isKnownAuthor ? (
<>
<p className="text-xs font-semibold text-zinc-200 truncate leading-tight">
{bookmark.authorName}
</p>
<p className="text-[10px] text-zinc-500 truncate leading-tight">
@{bookmark.authorHandle}
</p>
</>
) : (
<p className="text-xs text-zinc-500 truncate">Unknown</p>
)}
</div>

{/* Tweet text snippet — flex-1 */}
<p className="flex-1 min-w-0 text-xs text-zinc-300 truncate">
{cleanText || <span className="text-zinc-600 italic">No text</span>}
</p>

{/* Category dots */}
{bookmark.categories.length > 0 && (
<div className="shrink-0 flex items-center gap-1.5">
{bookmark.categories.map((cat) => (
<span
key={cat.id}
className="relative group/dot shrink-0 cursor-default"
>
<span
className="block w-2 h-2 rounded-full"
style={{ backgroundColor: cat.color }}
/>
<span className="pointer-events-none absolute bottom-full left-1/2 -translate-x-1/2 mb-1.5 px-2 py-1 rounded-md bg-zinc-800 border border-zinc-700 text-[10px] text-zinc-200 whitespace-nowrap opacity-0 group-hover/dot:opacity-100 transition-opacity duration-100 z-10">
{cat.name}
</span>
</span>
))}
</div>
)}

{/* Media indicator */}
{firstMedia ? (
<MediaIndicator item={firstMedia} />
) : (
<div className="shrink-0 w-8" />
)}

{/* Date */}
<span className="shrink-0 w-20 text-right text-[10px] text-zinc-500 tabular-nums">
{dateStr}
</span>
</div>
)
}