initial commit on Federted servers
release gate / check (push) Failing after 8m35s

This commit is contained in:
Andrew S Erwin
2026-10-19 16:23:55 -05:00
commit b9721ff13f
217 changed files with 31534 additions and 0 deletions
+187
View File
@@ -0,0 +1,187 @@
'use client'
import { useState, useCallback, useEffect } from 'react'
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter } from '@/components/ui/dialog'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import { Textarea } from '@/components/ui/textarea'
import { Badge } from '@/components/ui/badge'
import { Link2, Loader2, Plus, X } from 'lucide-react'
import { toast } from 'sonner'
import { invalidateReference } from '@/lib/reference-data'
interface Props {
open: boolean
onClose: () => void
onCreated: () => void
}
export function AddBookmarkDialog({ open, onClose, onCreated }: Props) {
const [url, setUrl] = useState('')
const [title, setTitle] = useState('')
const [description, setDescription] = useState('')
const [notes, setNotes] = useState('')
const [dueDate, setDueDate] = useState('')
const [kanban, setKanban] = useState('INBOX')
const [loading, setLoading] = useState(false)
const [fetching, setFetching] = useState(false)
const [tags, setTags] = useState<{ id: string; name: string; color: string }[]>([])
const [allTags, setAllTags] = useState<any[]>([])
const [newTagName, setNewTagName] = useState('')
useEffect(() => {
if (open) {
fetch('/api/tags').then(r => r.json()).then(setAllTags).catch(() => {})
const timer = setTimeout(() => { setUrl(''); setTitle(''); setDescription(''); setNotes(''); setDueDate(''); setKanban('INBOX'); setTags([]) }, 0)
return () => clearTimeout(timer)
}
}, [open])
const fetchPreview = useCallback(async () => {
if (!url) return
setFetching(true)
try {
const res = await fetch(`/api/preview?url=${encodeURIComponent(url)}`)
if (res.ok) {
const data = await res.json()
if (data?.ogTitle && !title) setTitle(data.ogTitle)
if (data?.ogDescription && !description) setDescription(data.ogDescription)
}
} catch { /* ignore */ }
setFetching(false)
}, [url, title, description])
const handleSubmit = useCallback(async () => {
if (!url) { toast.error('URL is required'); return }
setLoading(true)
try {
const res = await fetch('/api/bookmarks', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
url,
title: title || undefined,
description: description || undefined,
notes: notes || undefined,
dueDate: dueDate || undefined,
kanbanStatus: kanban,
tagIds: tags.map(t => t.id),
}),
})
if (res.ok) {
toast.success('Bookmark created')
onCreated()
} else {
const data = await res.json().catch(() => ({}))
toast.error(data?.error ?? 'Failed')
}
} catch { toast.error('Error creating bookmark') }
setLoading(false)
}, [url, title, description, notes, dueDate, kanban, tags, onCreated])
const createTag = useCallback(async () => {
if (!newTagName) return
try {
const res = await fetch('/api/tags', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ name: newTagName }),
})
if (res.ok) {
const tag = await res.json()
setTags(prev => [...prev, { id: tag.id, name: tag.name, color: tag.color }])
setAllTags(prev => [...prev, tag])
setNewTagName('')
invalidateReference('tags')
}
} catch { /* ignore */ }
}, [newTagName])
return (
<Dialog open={open} onOpenChange={() => onClose()}>
<DialogContent className="max-w-md">
<DialogHeader>
<DialogTitle className="flex items-center gap-2"><Link2 className="h-5 w-5" /> Add Bookmark</DialogTitle>
</DialogHeader>
<div className="space-y-4">
<div>
<label className="text-xs font-medium text-muted-foreground">URL *</label>
<div className="relative">
<Input
placeholder="https://..."
value={url}
onChange={(e: React.ChangeEvent<HTMLInputElement>) => setUrl(e.target.value)}
onBlur={fetchPreview}
/>
{fetching && <Loader2 className="absolute right-3 top-1/2 -translate-y-1/2 h-4 w-4 animate-spin text-muted-foreground" />}
</div>
</div>
<div>
<label className="text-xs font-medium text-muted-foreground">Title</label>
<Input value={title} onChange={(e: React.ChangeEvent<HTMLInputElement>) => setTitle(e.target.value)} placeholder="Auto-filled from URL" />
</div>
<div>
<label className="text-xs font-medium text-muted-foreground">Description</label>
<Input value={description} onChange={(e: React.ChangeEvent<HTMLInputElement>) => setDescription(e.target.value)} />
</div>
<div>
<label className="text-xs font-medium text-muted-foreground">Notes (Markdown)</label>
<Textarea value={notes} onChange={(e: React.ChangeEvent<HTMLTextAreaElement>) => setNotes(e.target.value)} rows={3} />
</div>
<div className="grid grid-cols-2 gap-3">
<div>
<label className="text-xs font-medium text-muted-foreground">Due Date</label>
<Input type="date" value={dueDate} onChange={(e: React.ChangeEvent<HTMLInputElement>) => setDueDate(e.target.value)} />
</div>
<div>
<label className="text-xs font-medium text-muted-foreground">Status</label>
<select
value={kanban}
onChange={(e: React.ChangeEvent<HTMLSelectElement>) => setKanban(e.target.value)}
className="w-full rounded-md border bg-background px-3 py-2 text-sm"
>
<option value="INBOX">Inbox</option>
<option value="TODO">To Do</option>
<option value="IN_PROGRESS">In Progress</option>
<option value="DONE">Done</option>
</select>
</div>
</div>
<div>
<label className="text-xs font-medium text-muted-foreground">Tags</label>
<div className="flex flex-wrap gap-1.5 mb-2">
{tags.map(t => (
<Badge key={t.id} variant="secondary" className="gap-1" style={{ backgroundColor: `${t.color}20`, color: t.color }}>
{t.name}
<button onClick={() => setTags(prev => prev.filter(x => x.id !== t.id))}><X className="h-3 w-3" /></button>
</Badge>
))}
</div>
<div className="flex gap-1.5">
<select
onChange={(e: React.ChangeEvent<HTMLSelectElement>) => {
const tag = allTags.find((t: any) => t.id === e.target.value)
if (tag && !tags.find(t => t.id === tag.id)) setTags(prev => [...prev, { id: tag.id, name: tag.name, color: tag.color }])
e.target.value = ''
}}
className="flex-1 rounded-md border bg-background px-3 py-1.5 text-sm"
defaultValue=""
>
<option value="" disabled>Select tag...</option>
{allTags.map((t: any) => <option key={t.id} value={t.id}>{t.name}</option>)}
</select>
<div className="flex gap-1">
<Input placeholder="New tag" value={newTagName} onChange={(e: React.ChangeEvent<HTMLInputElement>) => setNewTagName(e.target.value)} className="w-24" />
<Button variant="outline" size="icon-sm" onClick={createTag}><Plus className="h-3 w-3" /></Button>
</div>
</div>
</div>
</div>
<DialogFooter>
<Button variant="outline" onClick={onClose}>Cancel</Button>
<Button onClick={handleSubmit} loading={loading}>Save Bookmark</Button>
</DialogFooter>
</DialogContent>
</Dialog>
)
}
+15
View File
@@ -0,0 +1,15 @@
'use client'
import { SessionProvider } from 'next-auth/react'
import type { Session } from 'next-auth'
import { AppShell } from '@/components/app-shell'
// Nested provider seeded with the server session: useSession() in AppShell
// has data during SSR, so the signed-in shell is server-rendered.
export function AppShellWrapper({ session, children }: { session: Session; children: React.ReactNode }) {
return (
<SessionProvider session={session}>
<AppShell>{children}</AppShell>
</SessionProvider>
)
}
+164
View File
@@ -0,0 +1,164 @@
'use client'
import { useState, useEffect, useCallback, useRef } from 'react'
import { useSession } from 'next-auth/react'
import { usePathname, useRouter } from 'next/navigation'
import { Sidebar } from '@/components/sidebar'
import { TopBar } from '@/components/top-bar'
import { AddBookmarkDialog } from '@/components/add-bookmark-dialog'
import { QuickFind } from '@/components/quick-find'
export type Bookmark = {
id: string
url: string
title: string | null
description: string | null
favicon: string | null
ogImage: string | null
ogTitle: string | null
ogDescription: string | null
notes: string | null
dueDate: string | null
// Owner-only fields (review PRIV-02): shared readers receive a narrowed
// projection without contacts, recipients, alerts or mailbox watches.
alertAt?: string | null
alertSent?: boolean
kanbanStatus: string
kanbanColumnId?: string | null
// Owner-only board order within a column. Shared-reader projections omit it.
kanbanPosition?: number
imapWatchEnabled?: boolean
imapQuery?: string | null
isPublic: boolean
ownerId: string
contactId?: string | null
// Optimistic concurrency revision (review CR-04): every owner-driven PUT
// must send the version it edited from; 409 means a newer edit exists.
version?: number
contact?: any | null
tags: { tag: { id: string; name: string; color: string; ownerId?: string } }[]
sharedWith?: any[]
owner?: any
createdAt: string
updatedAt: string
}
export type TagWithCount = {
id: string
name: string
color: string
ownerId: string
_count: { bookmarks: number }
sharedWith?: any[]
}
export function AppShell({ children }: { children: React.ReactNode }) {
const { data: session } = useSession()
const router = useRouter()
const pathname = usePathname()
const [sidebarOpen, setSidebarOpen] = useState(true)
const [addDialogOpen, setAddDialogOpen] = useState(false)
const [quickFindOpen, setQuickFindOpen] = useState(false)
const [alerts, setAlerts] = useState<any[]>([])
const [me, setMe] = useState<{ isAdmin: boolean; viewingAs: { name: string | null; email: string } | null } | null>(null)
useEffect(() => {
if (!session?.user) return
fetch('/api/me')
.then((r) => (r.ok ? r.json() : null))
.then(setMe)
.catch((e) => console.error('Failed to load account info', e))
}, [session?.user])
const exitViewAs = useCallback(async () => {
try {
await fetch('/api/admin/view-as', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ userId: null }) })
} catch { /* navigating to /admin surfaces any persistent failure */ }
window.location.href = '/admin'
}, [])
// Poll alerts. The API returns every un-dismissed alert on each poll, so
// track already-notified IDs per tab to avoid re-firing every 5 minutes.
const notifiedAlerts = useRef<Set<string>>(new Set())
useEffect(() => {
if (!session?.user) return
const check = async () => {
try {
const res = await fetch('/api/alerts/check')
if (res.ok) {
const data = await res.json()
setAlerts(data ?? [])
if (data?.length && typeof Notification !== 'undefined' && Notification.permission === 'granted') {
for (const a of data) {
const id = String(a?.id ?? a?.bookmarkId ?? '')
if (!id || notifiedAlerts.current.has(id)) continue
notifiedAlerts.current.add(id)
new Notification('computer alert', { body: `${a?.title ?? 'Bookmark'} is due!`, icon: '/favicon.svg' })
}
}
}
} catch { /* ignore */ }
}
check()
const interval = setInterval(check, 5 * 60 * 1000)
return () => clearInterval(interval)
}, [session?.user])
// Request notification permission
useEffect(() => {
if (typeof Notification !== 'undefined' && Notification.permission === 'default') {
Notification.requestPermission()
}
}, [])
const refreshPage = useCallback(() => {
window.dispatchEvent(new Event('computer:bookmarks-changed'))
router.refresh()
}, [router])
const dismissAlert = useCallback(async (bookmarkId: string) => {
try {
const res = await fetch(`/api/alerts/${bookmarkId}`, { method: 'DELETE' })
if (!res.ok) return
} catch { return }
setAlerts((prev: any[]) => (prev ?? []).filter((a: any) => a?.id !== bookmarkId))
}, [])
if (!session?.user) return null
return (
<div className="flex h-screen overflow-hidden bg-background">
<Sidebar open={sidebarOpen} onToggle={() => setSidebarOpen(!sidebarOpen)} onQuickFind={() => setQuickFindOpen(true)} pathname={pathname} isAdmin={!!me?.isAdmin} />
<div className="flex flex-1 flex-col overflow-hidden">
{me?.viewingAs && (
<div className="flex items-center justify-center gap-3 bg-amber-500 px-4 py-1.5 text-sm font-medium text-amber-950">
<span>Admin view — viewing as {me.viewingAs.name || me.viewingAs.email}</span>
<button onClick={exitViewAs} className="rounded bg-amber-950/15 px-2 py-0.5 text-xs font-semibold hover:bg-amber-950/25">Exit</button>
</div>
)}
<TopBar
user={session.user as any}
alerts={alerts}
onDismissAlert={dismissAlert}
onAddBookmark={() => setAddDialogOpen(true)}
onToggleSidebar={() => setSidebarOpen(!sidebarOpen)}
onQuickFind={() => setQuickFindOpen(true)}
/>
<main className="flex-1 overflow-auto p-6">
{children}
</main>
</div>
<AddBookmarkDialog
open={addDialogOpen}
onClose={() => setAddDialogOpen(false)}
onCreated={() => { setAddDialogOpen(false); refreshPage() }}
/>
<QuickFind
open={quickFindOpen}
onOpenChange={setQuickFindOpen}
isAdmin={!!me?.isAdmin}
onAddBookmark={() => setAddDialogOpen(true)}
/>
</div>
)
}
+101
View File
@@ -0,0 +1,101 @@
'use client'
import { ExternalLink, Calendar as CalendarIcon, Clock } from 'lucide-react'
import { Badge } from '@/components/ui/badge'
import { cn } from '@/lib/utils'
import type { Bookmark } from '@/components/app-shell'
interface BookmarkCardProps {
bookmark: Bookmark
onClick?: () => void
compact?: boolean
}
export function BookmarkCard({ bookmark, onClick, compact }: BookmarkCardProps) {
const domain = (() => { try { return new URL(bookmark?.url ?? '').hostname } catch { return '' } })()
const tags = bookmark?.tags ?? []
return (
<div
onClick={onClick}
className={cn(
'group relative flex cursor-pointer flex-col rounded-lg border bg-card transition-all duration-normal hover:shadow-md hover:-translate-y-0.5 hover:border-primary/20 overflow-hidden',
compact ? 'p-3' : 'p-0'
)}
>
{!compact && bookmark?.ogImage && (
<div className="relative w-full aspect-video bg-muted">
<img
src={bookmark.ogImage}
alt={bookmark?.title ?? 'Bookmark'}
referrerPolicy="no-referrer"
className="object-cover w-full h-full"
onError={(e: any) => { e.target.style.display = 'none' }}
/>
</div>
)}
<div className={cn('flex flex-col gap-2', compact ? '' : 'p-4')}>
<div className="flex items-start gap-2">
{bookmark?.favicon && (
<img
src={bookmark.favicon}
alt=""
referrerPolicy="no-referrer"
className="h-4 w-4 mt-0.5 rounded shrink-0"
onError={(e: any) => { e.target.style.display = 'none' }}
/>
)}
<div className="min-w-0 flex-1">
<h3 className="text-sm font-medium leading-tight truncate">
{bookmark?.title ?? bookmark?.url ?? 'Untitled'}
</h3>
<p className="text-xs text-muted-foreground truncate mt-0.5">{domain}</p>
</div>
<a
href={bookmark?.url ?? '#'}
target="_blank"
rel="noopener noreferrer"
onClick={(e: React.MouseEvent) => e.stopPropagation()}
className="shrink-0 opacity-0 group-hover:opacity-100 transition-opacity"
>
<ExternalLink className="h-4 w-4 text-muted-foreground hover:text-primary" />
</a>
</div>
{!compact && bookmark?.ogDescription && (
<p className="text-xs text-muted-foreground line-clamp-2">{bookmark.ogDescription}</p>
)}
<div className="flex flex-wrap items-center gap-1.5">
{tags.slice(0, 3).map((bt: any) => (
<Badge
key={bt?.tag?.id}
variant="secondary"
className="text-[10px] px-1.5 py-0"
style={{ backgroundColor: `${bt?.tag?.color ?? '#6366f1'}20`, color: bt?.tag?.color ?? '#6366f1' }}
>
{bt?.tag?.name ?? ''}
</Badge>
))}
{tags.length > 3 && (
<span className="text-[10px] text-muted-foreground">+{tags.length - 3}</span>
)}
</div>
{(bookmark?.dueDate || bookmark?.alertAt) && (
<div className="flex items-center gap-2 text-xs text-muted-foreground">
{bookmark?.dueDate && (
<span className="flex items-center gap-1">
<CalendarIcon className="h-3 w-3" />
{new Date(bookmark.dueDate).toLocaleDateString('en-US', { month: 'short', day: 'numeric', timeZone: 'UTC' })}
</span>
)}
{bookmark?.alertAt && (
<span className="flex items-center gap-1">
<Clock className="h-3 w-3" />
Alert set
</span>
)}
</div>
)}
</div>
</div>
)
}
+391
View File
@@ -0,0 +1,391 @@
'use client'
import { useState, useEffect, useCallback } from 'react'
import { Sheet, SheetContent, SheetHeader, SheetTitle } from '@/components/ui/sheet'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import { Badge } from '@/components/ui/badge'
import { Textarea } from '@/components/ui/textarea'
import { ExternalLink, Edit, Save, X, Trash2, Share2, Globe, Users, Calendar, Clock, Mail, Tag } from 'lucide-react'
import { toast } from 'sonner'
import type { Bookmark } from '@/components/app-shell'
import ReactMarkdown from 'react-markdown'
import remarkGfm from 'remark-gfm'
import { BookmarkExtras } from '@/components/bookmark-extras'
import { fetchReference } from '@/lib/reference-data'
import { format } from 'date-fns'
interface Props {
bookmark: Bookmark | null
onClose: () => void
onUpdate: () => void
}
export function BookmarkDetailSheet({ bookmark, onClose, onUpdate }: Props) {
const [editing, setEditing] = useState(false)
const [notes, setNotes] = useState('')
const [kanban, setKanban] = useState('INBOX')
const [dueDate, setDueDate] = useState('')
const [alertAt, setAlertAt] = useState('')
const [isPublic, setIsPublic] = useState(false)
const [imapEnabled, setImapEnabled] = useState(false)
const [imapQuery, setImapQuery] = useState('')
const [shareSearch, setShareSearch] = useState('')
const [shareResults, setShareResults] = useState<any[]>([])
const [saving, setSaving] = useState(false)
const [effectiveId, setEffectiveId] = useState<string | null>(null)
const [columns, setColumns] = useState<any[]>([])
const [contacts, setContacts] = useState<any[]>([])
const [columnId, setColumnId] = useState('')
const [contactId, setContactId] = useState('')
const [title, setTitle] = useState('')
const [url, setUrl] = useState('')
const [description, setDescription] = useState('')
const [tagIds, setTagIds] = useState<string[]>([])
const [allTags, setAllTags] = useState<{ id: string; name: string; color: string; ownerId?: string }[]>([])
// Review CR-04: set when a save hit a 409. The form keeps the unsaved
// draft; the banner is the explicit, persistent resolution path (a toast
// action alone would sit under the sheet's stacking context and vanish).
const [conflict, setConflict] = useState(false)
const canEdit = !!bookmark && effectiveId === bookmark.ownerId
useEffect(() => {
if (!bookmark) return
let cancelled = false
// Cached + deduplicated: opening the sheet repeatedly no longer fires
// four identical requests each time (fetchReference never rejects).
;(async () => {
const [me, cols, people, tags] = await Promise.all([
fetchReference<{ effectiveUser?: { id: string } }>('me'),
fetchReference<any[]>('columns'),
fetchReference<any[]>('contacts'),
fetchReference<{ id: string; name: string; color: string; ownerId?: string }[]>('tags'),
])
if (cancelled) return
if (me?.effectiveUser?.id) setEffectiveId(me.effectiveUser.id)
if (cols) setColumns(cols)
if (people) setContacts(people)
if (tags) setAllTags(tags)
})()
return () => { cancelled = true }
}, [bookmark])
useEffect(() => {
if (bookmark) {
const timer = setTimeout(() => {
setNotes(bookmark?.notes ?? '')
setKanban(bookmark?.kanbanStatus ?? 'INBOX')
setDueDate(bookmark?.dueDate ? new Date(bookmark.dueDate).toISOString().split('T')[0] : '')
setAlertAt(bookmark?.alertAt ? format(new Date(bookmark.alertAt), "yyyy-MM-dd'T'HH:mm") : '')
setIsPublic(bookmark?.isPublic ?? false)
setImapEnabled(bookmark?.imapWatchEnabled ?? false)
setImapQuery(bookmark?.imapQuery ?? '')
setEditing(false)
setColumnId(bookmark.kanbanColumnId || '')
setContactId(bookmark.contactId || '')
setTitle(bookmark.title || '')
setUrl(bookmark.url)
setDescription(bookmark.description || '')
setTagIds(bookmark.tags.map(({ tag }) => tag.id))
// A refreshed bookmark prop (after an explicit reload) clears the
// conflict state along with the form.
setConflict(false)
}, 0)
return () => clearTimeout(timer)
}
}, [bookmark])
const handleSave = useCallback(async () => {
if (!bookmark) return
setSaving(true)
try {
const res = await fetch(`/api/bookmarks/${bookmark.id}`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
notes,
title,
url,
description: description || null,
tagIds: tagIds.length === bookmark.tags.length && bookmark.tags.every(({ tag }) => tagIds.includes(tag.id)) ? undefined : tagIds,
kanbanColumnId: columnId || null,
contactId: contactId || null,
kanbanStatus: kanban,
dueDate: dueDate || null,
alertAt: alertAt ? new Date(alertAt).toISOString() : null,
isPublic,
imapWatchEnabled: imapEnabled,
imapQuery: imapQuery || null,
// Optimistic concurrency (review CR-04): the save carries the
// version this form was opened from; a newer edit elsewhere is a
// 409 and this draft is NOT lost silently.
version: bookmark.version ?? 0,
}),
})
if (res.ok) {
toast.success('Bookmark updated')
setEditing(false)
setConflict(false)
onUpdate()
} else if (res.status === 409) {
// Review CR-04: keep the unsaved form exactly as it is; the inline
// conflict banner below is the explicit resolution path. Reloading
// is a deliberate user choice so anything worth keeping can be
// copied first.
setConflict(true)
toast.error('This bookmark was changed elsewhere — your edits were NOT saved')
} else {
toast.error('Failed to update')
}
} catch { toast.error('Error saving') }
setSaving(false)
}, [bookmark, notes, title, url, description, tagIds, columnId, contactId, kanban, dueDate, alertAt, isPublic, imapEnabled, imapQuery, onUpdate])
const handleDelete = useCallback(async () => {
if (!bookmark || !confirm('Delete this bookmark?')) return
const response = await fetch(`/api/bookmarks/${bookmark.id}`, { method: 'DELETE' })
if (!response.ok) { toast.error('Could not delete bookmark'); return }
toast.success('Bookmark deleted')
onClose()
onUpdate()
}, [bookmark, onClose, onUpdate])
const searchUsers = useCallback(async (q: string) => {
setShareSearch(q)
if (q.length < 2) { setShareResults([]); return }
try {
const res = await fetch(`/api/users/search?q=${encodeURIComponent(q)}`)
if (res.ok) setShareResults(await res.json())
} catch { /* ignore */ }
}, [])
const shareWith = useCallback(async (userId: string) => {
if (!bookmark) return
const response = await fetch(`/api/bookmarks/${bookmark.id}/share`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ userIds: [userId] }),
})
if (!response.ok) { toast.error('Could not share bookmark'); return }
toast.success('Shared!')
setShareSearch('')
setShareResults([])
onUpdate()
}, [bookmark, onUpdate])
const togglePublic = useCallback(async () => {
if (!bookmark) return
const response = await fetch(`/api/bookmarks/${bookmark.id}/share`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ isPublic: !isPublic }),
})
if (!response.ok) { toast.error('Could not change public access'); return }
setIsPublic(!isPublic)
toast.success(isPublic ? 'Made private' : 'Made public')
onUpdate()
}, [bookmark, isPublic, onUpdate])
const domain = (() => { try { return bookmark ? new URL(bookmark.url).hostname : '' } catch { return '' } })()
return (
<Sheet open={!!bookmark} onOpenChange={() => onClose()}>
<SheetContent className="w-full sm:max-w-lg overflow-y-auto">
<SheetHeader>
<SheetTitle className="flex items-center gap-2">
{bookmark?.favicon && (
<img src={bookmark.favicon} alt="" referrerPolicy="no-referrer" className="h-5 w-5 rounded" onError={(e: any) => { e.target.style.display = 'none' }} />
)}
<span className="truncate">{bookmark?.title ?? 'Untitled'}</span>
</SheetTitle>
</SheetHeader>
<div className="mt-4 space-y-6">
{/* URL */}
<a
href={bookmark?.url ?? '#'}
target="_blank"
rel="noopener noreferrer"
className="flex items-center gap-2 rounded-lg bg-primary/10 px-4 py-3 text-sm font-medium text-primary hover:bg-primary/20 transition-colors"
>
<ExternalLink className="h-4 w-4 shrink-0" />
<span className="truncate">{bookmark?.url ?? ''}</span>
</a>
{canEdit && <div className="space-y-3">
<label className="block text-xs font-medium" htmlFor="bookmark-url">URL<Input id="bookmark-url" value={url} onChange={e => setUrl(e.target.value)} className="mt-1" /></label>
<label className="block text-xs font-medium" htmlFor="bookmark-description">Description<Textarea id="bookmark-description" value={description} onChange={e => setDescription(e.target.value)} rows={2} className="mt-1" /></label>
</div>}
{/* OG Preview */}
{bookmark?.ogImage && (
<div className="rounded-lg border overflow-hidden">
<img src={bookmark.ogImage} alt="" referrerPolicy="no-referrer" className="w-full aspect-video object-cover" onError={(e: any) => { e.target.style.display = 'none' }} />
<div className="p-3">
<p className="text-sm font-medium">{bookmark?.ogTitle ?? bookmark?.title}</p>
<p className="text-xs text-muted-foreground mt-1">{bookmark?.ogDescription}</p>
<p className="text-xs text-muted-foreground mt-1">{domain}</p>
</div>
</div>
)}
{/* Tags */}
<div>
<p className="text-xs font-medium text-muted-foreground mb-1.5 flex items-center gap-1"><Tag className="h-3 w-3" /> Tags</p>
<div className="flex flex-wrap gap-1.5">
{(bookmark?.tags ?? []).map((bt: any) => (
<Badge key={bt?.tag?.id} variant="secondary" style={{ backgroundColor: `${bt?.tag?.color ?? '#6366f1'}20`, color: bt?.tag?.color ?? '#6366f1' }}>
{bt?.tag?.name ?? ''}
</Badge>
))}
{(bookmark?.tags?.length ?? 0) === 0 && <span className="text-xs text-muted-foreground">No tags</span>}
</div>
{canEdit && bookmark && <div className="mt-2 space-y-1">
{[...new Map([...allTags, ...bookmark.tags.map(({ tag }) => tag)].map(tag => [tag.id, tag])).values()].map(tag => <label key={tag.id} className="flex items-center gap-2 text-sm">
<input type="checkbox" checked={tagIds.includes(tag.id)} onChange={e => setTagIds(previous => e.target.checked ? [...previous, tag.id] : previous.filter(id => id !== tag.id))} />{tag.name}
{/* Group-wide tag sharing is a product decision; make the audience
visible before a tag is attached to private material (review). */}
{tag.ownerId && effectiveId && tag.ownerId !== effectiveId && (
<span className="text-xs text-muted-foreground">(shared tag — everyone with this tag can see bookmarks under it)</span>
)}
</label>)}
</div>}
</div>
{/* Notes */}
{canEdit && <div className="space-y-2"><label className="text-xs font-medium">Title</label><Input value={title} onChange={e => setTitle(e.target.value)} /></div>}
<div>
<div className="flex items-center justify-between mb-1.5">
<p className="text-xs font-medium text-muted-foreground">Notes</p>
<Button disabled={!canEdit} variant="ghost" size="xs" onClick={() => setEditing(!editing)}>
{editing ? <X className="h-3 w-3" /> : <Edit className="h-3 w-3" />}
</Button>
</div>
{editing ? (
<Textarea value={notes} onChange={(e: React.ChangeEvent<HTMLTextAreaElement>) => setNotes(e.target.value)} rows={6} placeholder="Markdown notes..." />
) : (
<div className="prose prose-sm dark:prose-invert max-w-none rounded-lg bg-muted/50 p-3 min-h-[60px]">
{notes ? <ReactMarkdown remarkPlugins={[remarkGfm]}>{notes}</ReactMarkdown> : <span className="text-muted-foreground text-xs">No notes</span>}
</div>
)}
</div>
{/* Kanban Status */}
<fieldset disabled={!canEdit} className="space-y-4">
<div className="grid grid-cols-2 gap-3">
<label className="text-xs font-medium">Column<select className="mt-1 w-full rounded border bg-background p-2" value={columnId} onChange={e => setColumnId(e.target.value)}><option value="">Use legacy status</option>{columns.map(c => <option key={c.id} value={c.id}>{c.label}</option>)}</select></label>
<label className="text-xs font-medium">Contact<select className="mt-1 w-full rounded border bg-background p-2" value={contactId} onChange={e => setContactId(e.target.value)}><option value="">No contact</option>{contacts.map(c => <option key={c.id} value={c.id}>{c.displayName}</option>)}</select></label>
</div>
<div>
<p className="text-xs font-medium text-muted-foreground mb-1.5">Status</p>
<div className="flex gap-1.5">
{['INBOX', 'TODO', 'IN_PROGRESS', 'DONE'].map((s: string) => (
<Button
key={s}
variant={kanban === s ? 'default' : 'outline'}
size="xs"
onClick={() => setKanban(s)}
>
{s.replace('_', ' ')}
</Button>
))}
</div>
</div>
{/* Due Date & Alert */}
<div className="grid grid-cols-2 gap-3">
<div>
<p className="text-xs font-medium text-muted-foreground mb-1.5 flex items-center gap-1"><Calendar className="h-3 w-3" /> Due Date</p>
<Input type="date" value={dueDate} onChange={(e: React.ChangeEvent<HTMLInputElement>) => setDueDate(e.target.value)} />
</div>
<div>
<p className="text-xs font-medium text-muted-foreground mb-1.5 flex items-center gap-1"><Clock className="h-3 w-3" /> Alert</p>
<Input type="datetime-local" value={alertAt} onChange={(e: React.ChangeEvent<HTMLInputElement>) => setAlertAt(e.target.value)} />
</div>
</div>
{/* Contact */}
{bookmark?.contact && (
<div>
<p className="text-xs font-medium text-muted-foreground mb-1.5 flex items-center gap-1"><Users className="h-3 w-3" /> Contact</p>
<div className="flex items-center gap-2 rounded-lg bg-muted/50 px-3 py-2">
<div className="h-8 w-8 rounded-full bg-primary/20 flex items-center justify-center text-xs font-bold text-primary">
{(bookmark.contact?.displayName ?? '?')[0]?.toUpperCase()}
</div>
<div>
<p className="text-sm font-medium">{bookmark.contact?.displayName}</p>
<p className="text-xs text-muted-foreground">{bookmark.contact?.email}</p>
</div>
</div>
</div>
)}
{/* IMAP Watch */}
<div>
<p className="text-xs font-medium text-muted-foreground mb-1.5 flex items-center gap-1"><Mail className="h-3 w-3" /> Mailbox Watch</p>
<div className="flex items-center gap-2">
<label className="flex items-center gap-2 text-sm">
<input type="checkbox" checked={imapEnabled} onChange={() => setImapEnabled(!imapEnabled)} className="rounded" />
Enabled
</label>
{imapEnabled && (
<Input placeholder="Search query (subject/from)" value={imapQuery} onChange={(e: React.ChangeEvent<HTMLInputElement>) => setImapQuery(e.target.value)} className="flex-1" />
)}
</div>
</div>
{/* Sharing */}
<div>
<p className="text-xs font-medium text-muted-foreground mb-1.5 flex items-center gap-1"><Share2 className="h-3 w-3" /> Sharing</p>
<div className="space-y-2">
<Button variant={isPublic ? 'default' : 'outline'} size="sm" onClick={togglePublic} className="gap-1.5">
<Globe className="h-3 w-3" />
{isPublic ? 'Public' : 'Private'}
</Button>
{isPublic && (
<div className="space-y-1 text-xs text-muted-foreground">
<p>
Public link: <code className="bg-muted px-1 rounded">/share/bookmark/{bookmark?.id}</code>
</p>
<p>Anyone with the link sees the title, description, <span className="font-medium text-foreground">notes</span>, due date, tags and preview. Contacts, alerts, mailbox watches and your identity stay private.</p>
</div>
)}
<Input placeholder="Search users to share with..." value={shareSearch} onChange={(e: React.ChangeEvent<HTMLInputElement>) => searchUsers(e.target.value)} />
{shareResults.map((u: any) => (
<button key={u?.id} onClick={() => shareWith(u?.id)} className="flex items-center gap-2 w-full rounded-md px-2 py-1.5 hover:bg-muted text-sm">
<span className="h-6 w-6 rounded-full bg-primary/20 flex items-center justify-center text-xs font-bold text-primary">
{(u?.name ?? u?.email ?? '?')[0]?.toUpperCase()}
</span>
{u?.name ?? u?.email}
</button>
))}
</div>
</div>
{/* Actions */}
</fieldset>
{conflict && canEdit && (
<div role="alert" className="rounded-md border border-destructive/40 bg-destructive/10 p-3 text-sm space-y-2">
<p className="font-medium text-destructive">This bookmark was changed elsewhere</p>
<p className="text-muted-foreground">
Your edits are kept in this form but were NOT saved — saving again would overwrite the
newer version. Copy anything you want to keep, then load the latest version.
</p>
<Button size="sm" variant="outline" onClick={() => onUpdate()}>Load latest version</Button>
</div>
)}
{bookmark && <BookmarkExtras bookmark={bookmark} canEdit={canEdit} onUpdate={onUpdate} />}
{!canEdit && <p className="text-xs text-muted-foreground">Shared bookmark — read only</p>}
{canEdit &&
<div className="flex items-center gap-2 pt-2 border-t">
<Button onClick={handleSave} loading={saving} className="flex-1 gap-1.5">
<Save className="h-4 w-4" /> Save Changes
</Button>
<Button variant="destructive" size="icon" onClick={handleDelete}>
<Trash2 className="h-4 w-4" />
</Button>
</div>}
</div>
</SheetContent>
</Sheet>
)
}
+53
View File
@@ -0,0 +1,53 @@
'use client'
import { useCallback, useEffect, useState } from 'react'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import { toast } from 'sonner'
import type { Bookmark } from '@/components/app-shell'
export function BookmarkExtras({ bookmark, canEdit, onUpdate }: { bookmark: Bookmark; canEdit: boolean; onUpdate: () => void }) {
const [sources, setSources] = useState<any[]>([])
const [signals, setSignals] = useState<any[]>([])
const [pages, setPages] = useState<any[]>([])
const [from, setFrom] = useState('')
const [subject, setSubject] = useState('')
const [busy, setBusy] = useState(false)
const [shares, setShares] = useState<any[]>([])
const load = useCallback(async () => {
const [signalResponse, bookmarkResponse] = await Promise.all([
fetch(`/api/bookmarks/${bookmark.id}/signals`), fetch(`/api/bookmarks/${bookmark.id}`),
])
if (signalResponse.ok) { const data = await signalResponse.json(); setSources(data.sources); setSignals(data.signals) }
if (bookmarkResponse.ok) { const data = await bookmarkResponse.json(); setPages(data.pageLinks ?? []); setShares(data.sharedWith ?? []) }
}, [bookmark.id])
useEffect(() => { const timer = setTimeout(() => load().catch(() => toast.error('Could not load bookmark details')), 0); return () => clearTimeout(timer) }, [load])
const change = async (url: string, method: string, body?: any) => {
setBusy(true)
try {
const response = await fetch(url, { method, headers: { 'Content-Type': 'application/json' }, body: body ? JSON.stringify(body) : undefined })
if (!response.ok) { toast.error((await response.json()).error || 'Request failed'); return }
await load(); onUpdate()
} catch { toast.error('Request failed') } finally { setBusy(false) }
}
return <div className="space-y-4 border-t pt-4">
<a className="text-sm text-primary underline" href={`/api/bookmarks/${bookmark.id}/markdown`}>Download Markdown</a>
{canEdit && shares.length > 0 && <div><h3 className="text-sm font-medium">Shared with</h3>{shares.map(share => <div key={share.userId} className="flex items-center justify-between text-xs"><span>{share.user?.name || share.user?.email}</span><Button size="xs" variant="ghost" onClick={() => change(`/api/bookmarks/${bookmark.id}/share/${share.userId}`, 'DELETE')}>Stop sharing</Button></div>)}</div>}
<div><h3 className="text-sm font-medium">On pages</h3>
{pages.length ? pages.map(link => <a key={link.pageId} className="block text-sm text-primary" href={`/pages?id=${link.pageId}`}>{link.page.title}</a>) : <p className="text-xs text-muted-foreground">Not linked to a page yet</p>}
</div>
<div className="space-y-2"><h3 className="text-sm font-medium">Signals</h3>
{canEdit && <>
<div className="flex gap-2"><Input placeholder="From address (optional)" value={from} onChange={e => setFrom(e.target.value)} /><Input placeholder="Subject contains" value={subject} onChange={e => setSubject(e.target.value)} /></div>
<div className="flex gap-2"><Button size="sm" disabled={busy} onClick={() => change(`/api/bookmarks/${bookmark.id}/signals`, 'POST', { type: 'email', config: { from, subject } })}>Add email watcher</Button>
<Button size="sm" variant="outline" disabled={busy} onClick={() => change(`/api/bookmarks/${bookmark.id}/signals`, 'POST', { action: 'check' })}>Check now</Button></div>
</>}
{sources.map(source => <div key={source.id} className="rounded border p-2 text-xs">
<p>{source.type}: {source.config.from || 'any sender'} / {source.config.subject || 'any subject'}</p><p className="text-muted-foreground">{source.lastStatus || 'Not checked yet'}</p>
{canEdit && <div className="flex gap-2"><Button size="xs" variant="ghost" onClick={() => change(`/api/signal-sources/${source.id}`, 'PATCH', { enabled: !source.enabled })}>{source.enabled ? 'Pause' : 'Enable'}</Button><Button size="xs" variant="ghost" onClick={() => change(`/api/signal-sources/${source.id}`, 'DELETE')}>Remove</Button></div>}
</div>)}
{signals.map(signal => <div key={signal.id} className="rounded bg-muted p-2 text-sm"><p>{signal.title}</p><p className="text-xs text-muted-foreground">{signal.summary}</p></div>)}
{!signals.length && <p className="text-xs text-muted-foreground">No matching events yet. Configure a mailbox in Settings to check email watchers.</p>}
</div>
</div>
}
@@ -0,0 +1,50 @@
'use client'
// IMPORTANT: Do not remove this component.
// It handles a known Next.js dev server race condition where dynamic chunks
// imported by next/dynamic haven't been compiled yet and cause webpack to throw
// a ChunkLoadError
import { useEffect } from 'react'
// A dev server that keeps serving stale chunk URLs raises a ChunkLoadError on every
// render pass, so an unthrottled reload turns one bad chunk into a continuous reload
// loop that wipes whatever the user was typing.
const RELOAD_THROTTLE_MS = 60000
const RELOAD_KEY = 'chunkReloadAt'
function shouldReload(): boolean {
const now = Date.now()
try {
const last = Number(window.sessionStorage.getItem(RELOAD_KEY)) || 0
if (now - last < RELOAD_THROTTLE_MS) {
return false
}
window.sessionStorage.setItem(RELOAD_KEY, String(now))
} catch {
// Without a durable timestamp the throttle cannot latch across reloads, so stay put
// rather than risk the loop; the user can still reload by hand.
return false
}
return true
}
export function ChunkLoadErrorHandler() {
useEffect(() => {
const handler = (event: ErrorEvent) => {
if (
event.error?.name === 'ChunkLoadError' ||
event.error?.message?.includes('Loading chunk')
) {
event.preventDefault()
if (shouldReload()) {
window.location.reload()
}
}
}
window.addEventListener('error', handler)
return () => window.removeEventListener('error', handler)
}, [])
return null
}
+20
View File
@@ -0,0 +1,20 @@
'use client'
import { ReactNode, useSyncExternalStore } from 'react'
const subscribe = () => () => {}
/** True only after the component has mounted on the client. Use to gate browser-only values. */
export function useMounted(): boolean {
return useSyncExternalStore(subscribe, () => true, () => false)
}
/**
* Renders children only after client mount, so SSR output stays deterministic.
* Use for anything that differs between server and client (window/localStorage reads,
* user timezone/locale values, random ids, live clocks, third-party widgets).
* Pass a `fallback` matching the final size to avoid layout shift.
*/
export function ClientOnly({ children, fallback = null }: { children: ReactNode; fallback?: ReactNode }) {
const mounted = useMounted()
return <>{mounted ? children : fallback}</>
}
+54
View File
@@ -0,0 +1,54 @@
'use client'
import { useLinkPreview } from '@/lib/link-preview'
const hostOf = (u: string) => {
try { return new URL(u).hostname.replace(/^www\./, '') } catch { return u }
}
// Notion-style inline preview for a link that sits alone on its own line in
// page Markdown. While the preview loads (or if nothing resolves) the card
// still shows the link text/host, so content never disappears or shifts.
export function LinkPreviewCard({ url, label }: { url: string; label?: string }) {
const preview = useLinkPreview(url)
const title = preview?.ogTitle || preview?.title || label || url
const description = preview?.ogDescription || preview?.description || null
return (
<a
href={url}
target="_blank"
rel="noopener noreferrer"
className="my-2 flex gap-3 rounded-lg bg-card p-3 no-underline shadow-sm ring-1 ring-border/50 transition-shadow hover:shadow-md"
>
{preview?.ogImage ? (
// eslint-disable-next-line @next/next/no-img-element
<img
src={preview.ogImage}
alt=""
referrerPolicy="no-referrer"
className="h-16 w-24 shrink-0 rounded bg-muted object-cover"
onError={(e) => { (e.target as HTMLImageElement).style.display = 'none' }}
/>
) : null}
<span className="min-w-0 flex-1">
<span className="flex items-center gap-1.5">
{preview?.favicon ? (
// eslint-disable-next-line @next/next/no-img-element
<img
src={preview.favicon}
alt=""
referrerPolicy="no-referrer"
className="h-3.5 w-3.5 shrink-0"
onError={(e) => { (e.target as HTMLImageElement).style.display = 'none' }}
/>
) : null}
<span className="truncate text-sm font-medium text-foreground">{title}</span>
</span>
{description ? (
<span className="mt-0.5 line-clamp-2 block text-xs text-muted-foreground">{description}</span>
) : null}
<span className="mt-1 block text-[11px] text-muted-foreground/80">{hostOf(url)}</span>
</span>
</a>
)
}
+18
View File
@@ -0,0 +1,18 @@
'use client'
import { SessionProvider } from 'next-auth/react'
import { ThemeProvider } from '@/components/theme-provider'
import { Toaster } from '@/components/ui/sonner'
import { ChunkLoadErrorHandler } from '@/components/chunk-load-error-handler'
export function Providers({ children }: { children: React.ReactNode }) {
return (
<SessionProvider>
<ThemeProvider attribute="class" defaultTheme="dark" enableSystem disableTransitionOnChange>
{children}
<Toaster />
<ChunkLoadErrorHandler />
</ThemeProvider>
</SessionProvider>
)
}
+226
View File
@@ -0,0 +1,226 @@
'use client'
// Notion-style "Quick Find": a Cmd/Ctrl+K palette to jump to any page,
// bookmark, tag, or view, and to run the few workspace commands
// (new page, add bookmark, toggle theme). Search is read-only; mutations
// stay on their owning screens.
import { useCallback, useEffect, useState } from 'react'
import { useRouter } from 'next/navigation'
import { useTheme } from 'next-themes'
import { toast } from 'sonner'
import {
Calendar,
ExternalLink,
FilePlus2,
FileText,
Kanban,
Link2,
Moon,
Plus,
Search,
Settings,
Shield,
Sun,
Tags,
Users,
} from 'lucide-react'
import {
CommandDialog,
CommandEmpty,
CommandGroup,
CommandInput,
CommandItem,
CommandList,
CommandSeparator,
} from '@/components/ui/command'
import { nextUntitledTitle, notifyPagesChanged, type PageSummary } from '@/lib/pages'
import type { TagWithCount } from '@/components/app-shell'
type BookmarkHit = { id: string; url: string; title: string | null; favicon: string | null; ogTitle: string | null }
const views = [
{ href: '/bookmarks', label: 'All Bookmarks', icon: Link2 },
{ href: '/calendar', label: 'Calendar', icon: Calendar },
{ href: '/tags', label: 'Tags', icon: Tags },
{ href: '/kanban', label: 'Kanban', icon: Kanban },
{ href: '/contacts', label: 'Contacts', icon: Users },
{ href: '/settings', label: 'Settings', icon: Settings },
]
const host = (u: string) => { try { return new URL(u).hostname.replace(/^www\./, '') } catch { return u } }
interface QuickFindProps {
open: boolean
onOpenChange: (open: boolean) => void
isAdmin?: boolean
onAddBookmark?: () => void
}
export function QuickFind({ open, onOpenChange, isAdmin = false, onAddBookmark }: QuickFindProps) {
const router = useRouter()
const { theme, setTheme } = useTheme()
const [query, setQuery] = useState('')
const [pages, setPages] = useState<PageSummary[]>([])
const [tags, setTags] = useState<TagWithCount[]>([])
const [bookmarks, setBookmarks] = useState<BookmarkHit[]>([])
// Notion binds both Cmd/Ctrl+K and Cmd/Ctrl+P to Quick Find.
useEffect(() => {
const down = (e: KeyboardEvent) => {
if ((e.metaKey || e.ctrlKey) && (e.key === 'k' || e.key === 'K')) {
e.preventDefault()
onOpenChange(!open)
}
}
document.addEventListener('keydown', down)
return () => document.removeEventListener('keydown', down)
}, [open, onOpenChange])
// Load the workspace index each time the palette opens; results stay fresh
// without polling while it is closed.
useEffect(() => {
if (!open) return
fetch('/api/pages').then((r) => (r.ok ? r.json() : [])).then((d: PageSummary[]) => setPages(d ?? [])).catch(() => {})
fetch('/api/tags').then((r) => (r.ok ? r.json() : [])).then((d: TagWithCount[]) => setTags(d ?? [])).catch(() => {})
}, [open])
// Bookmark search hits the API (debounced) because the server matches
// notes and URLs too, not just titles.
useEffect(() => {
if (!open) return
const q = query.trim()
const t = setTimeout(async () => {
if (q.length < 2) { setBookmarks([]); return }
try {
const params = new URLSearchParams({ search: q, limit: '6' })
const res = await fetch(`/api/bookmarks?${params}`)
if (res.ok) setBookmarks(((await res.json()) as BookmarkHit[]).slice(0, 6))
} catch { /* transient; retried on next keystroke */ }
}, q.length < 2 ? 0 : 200)
return () => clearTimeout(t)
}, [query, open])
const go = useCallback((href: string) => {
onOpenChange(false)
setQuery('')
router.push(href)
}, [onOpenChange, router])
const createPage = useCallback(async () => {
onOpenChange(false)
setQuery('')
const title = nextUntitledTitle(pages.map((p) => p.title))
try {
const res = await fetch('/api/pages', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ title }) })
if (!res.ok) { toast.error('Could not create page'); return }
const p = await res.json()
notifyPagesChanged()
router.push(`/pages?id=${p.id}`)
} catch { toast.error('Could not create page') }
}, [onOpenChange, pages, router])
return (
<CommandDialog open={open} onOpenChange={(next) => { onOpenChange(next); if (!next) setQuery('') }}>
<CommandInput
placeholder="Search pages, bookmarks, tags, and commands…"
value={query}
onValueChange={(v) => { setQuery(v); }}
/>
<CommandList>
<CommandEmpty>No results found.</CommandEmpty>
<CommandGroup heading="Commands">
<CommandItem value="new page create" onSelect={createPage}>
<FilePlus2 className="mr-2 h-4 w-4 text-muted-foreground" />
New page
</CommandItem>
{onAddBookmark && (
<CommandItem value="add bookmark new link url" onSelect={() => { onOpenChange(false); setQuery(''); onAddBookmark() }}>
<Plus className="mr-2 h-4 w-4 text-muted-foreground" />
Add bookmark
</CommandItem>
)}
<CommandItem value="toggle theme dark light mode appearance" onSelect={() => { onOpenChange(false); setTheme(theme === 'dark' ? 'light' : 'dark') }}>
{theme === 'dark' ? <Sun className="mr-2 h-4 w-4 text-muted-foreground" /> : <Moon className="mr-2 h-4 w-4 text-muted-foreground" />}
Toggle dark mode
</CommandItem>
</CommandGroup>
<CommandSeparator />
<CommandGroup heading="Views">
{views.map((v) => (
<CommandItem key={v.href} value={`view go to ${v.label}`} onSelect={() => go(v.href)}>
<v.icon className="mr-2 h-4 w-4 text-muted-foreground" />
{v.label}
</CommandItem>
))}
{isAdmin && (
<CommandItem value="view go to admin administration" onSelect={() => go('/admin')}>
<Shield className="mr-2 h-4 w-4 text-muted-foreground" />
Admin
</CommandItem>
)}
</CommandGroup>
{pages.length > 0 && (
<>
<CommandSeparator />
<CommandGroup heading="Pages">
{pages.map((p) => (
<CommandItem key={p.id} value={`page ${p.title}`} onSelect={() => go(`/pages?id=${p.id}`)}>
<FileText className="mr-2 h-4 w-4 text-muted-foreground" />
<span className="truncate">{p.title}</span>
</CommandItem>
))}
</CommandGroup>
</>
)}
{tags.length > 0 && (
<>
<CommandSeparator />
<CommandGroup heading="Tags">
{tags.map((t) => (
<CommandItem key={t.id} value={`tag ${t.name}`} onSelect={() => go(`/tags?tag=${t.id}`)}>
<Tags className="mr-2 h-4 w-4" style={{ color: t.color }} />
<span className="truncate">{t.name}</span>
<span className="ml-auto text-xs text-muted-foreground">{t._count?.bookmarks ?? 0}</span>
</CommandItem>
))}
</CommandGroup>
</>
)}
{query.trim().length >= 2 && (
<>
<CommandSeparator />
<CommandGroup heading="Search">
<CommandItem value={`zz-search-bookmarks-for-${query}`} onSelect={() => go(`/bookmarks?search=${encodeURIComponent(query.trim())}`)}>
<Search className="mr-2 h-4 w-4 text-muted-foreground" />
Search bookmarks for “{query.trim()}”
</CommandItem>
</CommandGroup>
</>
)}
{bookmarks.length > 0 && (
<>
<CommandSeparator />
<CommandGroup heading="Bookmarks">
{bookmarks.map((b) => (
<CommandItem key={b.id} value={`bookmark ${b.title ?? ''} ${b.ogTitle ?? ''} ${b.url}`} onSelect={() => go(`/bookmarks?open=${b.id}`)}>
{b.favicon ? (
// eslint-disable-next-line @next/next/no-img-element
<img src={b.favicon} alt="" referrerPolicy="no-referrer" className="mr-2 h-4 w-4 rounded" onError={(e) => { (e.target as HTMLImageElement).style.visibility = 'hidden' }} />
) : (
<Link2 className="mr-2 h-4 w-4 text-muted-foreground" />
)}
<span className="min-w-0 flex-1">
<span className="block truncate">{b.title || b.ogTitle || b.url}</span>
<span className="block truncate text-xs text-muted-foreground">{host(b.url)}</span>
</span>
<ExternalLink className="ml-2 h-3.5 w-3.5 shrink-0 text-muted-foreground" />
</CommandItem>
))}
</CommandGroup>
</>
)}
</CommandList>
</CommandDialog>
)
}
+80
View File
@@ -0,0 +1,80 @@
'use client'
import { useMounted } from '@/components/client-only'
/**
* Hydration-safe date/number formatting.
*
* Locale APIs (`toLocaleDateString`, `Intl.*`) default to the runtime's locale and
* timezone, which differ between server (UTC) and browser — a classic hydration
* mismatch. These components format with an explicit locale + timeZone so SSR and
* client render identical text. Set `localize` to re-render in the visitor's own
* locale/timezone after mount (safe: wrapped in suppressHydrationWarning).
*/
const DEFAULT_LOCALE = 'en-US'
const DEFAULT_TIME_ZONE = 'UTC'
type SafeDateProps = {
date: Date | string | number
options?: Intl.DateTimeFormatOptions
locale?: string
localize?: boolean
className?: string
}
export function SafeDate({ date, options, locale = DEFAULT_LOCALE, localize = false, className }: SafeDateProps) {
const mounted = useMounted()
const d = date instanceof Date ? date : new Date(date)
const useLocal = localize && mounted
// toLocaleDateString throws on timeStyle (and toLocaleTimeString on dateStyle),
// so date+time option mixes render via toLocaleString.
const text = (options?.timeStyle ? d.toLocaleString : d.toLocaleDateString).call(d, useLocal ? undefined : locale, {
timeZone: useLocal ? undefined : DEFAULT_TIME_ZONE,
...options,
})
return (
<span suppressHydrationWarning className={className}>
{text}
</span>
)
}
export function SafeTime({ date, options, locale = DEFAULT_LOCALE, localize = false, className }: SafeDateProps) {
const mounted = useMounted()
const d = date instanceof Date ? date : new Date(date)
const useLocal = localize && mounted
const text = (options?.dateStyle ? d.toLocaleString : d.toLocaleTimeString).call(d, useLocal ? undefined : locale, {
timeZone: useLocal ? undefined : DEFAULT_TIME_ZONE,
...options,
})
return (
<span suppressHydrationWarning className={className}>
{text}
</span>
)
}
type SafeNumberProps = {
value: number
options?: Intl.NumberFormatOptions
locale?: string
/** ISO 4217 code, e.g. 'USD' — shorthand for style: 'currency'. */
currency?: string
localize?: boolean
className?: string
}
export function SafeNumber({ value, options, locale = DEFAULT_LOCALE, currency, localize = false, className }: SafeNumberProps) {
const mounted = useMounted()
const useLocal = localize && mounted
const text = value.toLocaleString(useLocal ? undefined : locale, {
...(currency ? { style: 'currency' as const, currency } : {}),
...options,
})
return (
<span suppressHydrationWarning className={className}>
{text}
</span>
)
}
+370
View File
@@ -0,0 +1,370 @@
'use client'
import Link from 'next/link'
import { useRouter, useSearchParams } from 'next/navigation'
import { Suspense, useCallback, useEffect, useRef, useState, useSyncExternalStore } from 'react'
import { cn } from '@/lib/utils'
import {
Calendar, Tags, Users, Kanban, Link2, Settings, ChevronLeft, ChevronRight, ChevronDown,
FileText, Pin, PinOff, Plus, Shield, Search, MoreHorizontal, Trash2, Pencil, Layers, GripVertical,
} from 'lucide-react'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import {
DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuSeparator, DropdownMenuTrigger,
} from '@/components/ui/dropdown-menu'
import { toast } from 'sonner'
import { PAGES_CHANGED, nextUntitledTitle, notifyPagesChanged, type PageSummary } from '@/lib/pages'
import { reorderPages } from '@/lib/page-order'
// Screens below are filters/views over the same bookmark collection, not
// separate data types. Pages are the primary interface objects.
const viewItems = [
{ href: '/bookmarks', label: 'All Bookmarks', icon: Link2 },
{ href: '/calendar', label: 'Calendar', icon: Calendar },
{ href: '/tags', label: 'Tags', icon: Tags },
{ href: '/kanban', label: 'Kanban', icon: Kanban },
{ href: '/collections', label: 'Collections', icon: Layers },
{ href: '/contacts', label: 'Contacts', icon: Users },
]
// Section collapse state persists per browser. useSyncExternalStore keeps it
// hydration-safe (server snapshot uses the default; the client snapshot reads
// localStorage after mount) without setState inside an effect.
const SECTION_STORE_EVENT = 'computer:sidebar-sections'
function readSection(key: string, fallback: boolean): boolean {
try {
const stored = window.localStorage.getItem(key)
return stored === '0' || stored === '1' ? stored === '1' : fallback
} catch { return fallback }
}
function usePersistentState(key: string, initial: boolean) {
const subscribe = useCallback((onChange: () => void) => {
window.addEventListener('storage', onChange)
window.addEventListener(SECTION_STORE_EVENT, onChange)
return () => {
window.removeEventListener('storage', onChange)
window.removeEventListener(SECTION_STORE_EVENT, onChange)
}
}, [])
const getSnapshot = useCallback(() => readSection(key, initial), [key, initial])
const getServerSnapshot = useCallback(() => initial, [initial])
const value = useSyncExternalStore(subscribe, getSnapshot, getServerSnapshot)
const update = useCallback((next: boolean) => {
try { window.localStorage.setItem(key, next ? '1' : '0') } catch { /* non-fatal */ }
window.dispatchEvent(new Event(SECTION_STORE_EVENT))
}, [key])
return [value, update] as const
}
interface SidebarProps {
open: boolean
onToggle: () => void
onQuickFind: () => void
pathname: string
isAdmin?: boolean
}
export function Sidebar({ open, onToggle, onQuickFind, pathname, isAdmin = false }: SidebarProps) {
return (
<aside
className={cn(
'flex flex-col border-r bg-[hsl(var(--sidebar))] text-[hsl(var(--sidebar-foreground))] transition-all duration-300 ease-in-out',
open ? 'w-60' : 'w-16'
)}
>
<div className="flex items-center gap-2 px-3 py-3">
<Link href="/pages" className="flex min-w-0 items-center gap-2">
<div className="flex h-6 w-6 shrink-0 items-center justify-center rounded bg-primary text-primary-foreground">
<Link2 className="h-3.5 w-3.5" />
</div>
{open && <span className="truncate font-display text-sm font-bold tracking-tight">computer</span>}
</Link>
</div>
<Suspense fallback={<div className="flex-1" />}>
<SidebarNav open={open} pathname={pathname} isAdmin={isAdmin} />
</Suspense>
<div className="space-y-0.5 border-t p-2">
<button
onClick={onQuickFind}
title="Quick find (Ctrl/⌘ K)"
className="flex w-full items-center gap-2 rounded-lg px-3 py-1.5 text-sm text-muted-foreground transition-colors hover:bg-accent/50 hover:text-foreground"
>
<Search className="h-4 w-4 shrink-0" />
{open && <span className="flex-1 text-left">Quick find</span>}
{open && <kbd className="rounded border bg-background px-1 text-[10px] font-medium text-muted-foreground">⌘K</kbd>}
</button>
<Link
href="/settings"
className={cn(
'flex w-full items-center gap-2 rounded-lg px-3 py-1.5 text-sm transition-colors',
pathname === '/settings' ? 'bg-accent font-medium text-accent-foreground' : 'text-muted-foreground hover:bg-accent/50 hover:text-foreground'
)}
>
<Settings className="h-4 w-4 shrink-0" />
{open && <span>Settings</span>}
</Link>
<Button variant="ghost" size="icon-sm" onClick={onToggle} className="w-full" aria-label={open ? 'Collapse sidebar' : 'Expand sidebar'}>
{open ? <ChevronLeft className="h-4 w-4" /> : <ChevronRight className="h-4 w-4" />}
</Button>
</div>
</aside>
)
}
function SectionHeader({ label, open, expanded, onToggle, action }: {
label: string
open: boolean
expanded: boolean
onToggle: () => void
action?: { label: string; onClick: () => void }
}) {
if (!open) return null
return (
<div className="group/header flex items-center gap-0.5 rounded-md pr-1 hover:bg-accent/40">
<button onClick={onToggle} className="flex flex-1 items-center gap-1 px-2 py-1 text-left" aria-expanded={expanded}>
<ChevronDown className={cn('h-3 w-3 shrink-0 text-muted-foreground transition-transform', !expanded && '-rotate-90')} />
<span className="text-[11px] font-semibold uppercase tracking-wider text-muted-foreground">{label}</span>
</button>
{action && (
<button
onClick={action.onClick}
title={action.label}
aria-label={action.label}
className="shrink-0 rounded p-1 text-muted-foreground opacity-0 transition-opacity hover:bg-accent hover:text-foreground group-hover/header:opacity-100 focus-visible:opacity-100"
>
<Plus className="h-3.5 w-3.5" />
</button>
)}
</div>
)
}
function SidebarNav({ open, pathname, isAdmin = false }: { open: boolean; pathname: string; isAdmin?: boolean }) {
const router = useRouter()
const searchParams = useSearchParams()
const [pages, setPages] = useState<PageSummary[]>([])
const [pagesExpanded, setPagesExpanded] = usePersistentState('computer:sidebar:pages', true)
const [viewsExpanded, setViewsExpanded] = usePersistentState('computer:sidebar:views', true)
const [renamingId, setRenamingId] = useState<string | null>(null)
const [renameValue, setRenameValue] = useState('')
const [pageDrop, setPageDrop] = useState<{ id: string; after: boolean } | null>(null)
const draggedPageRef = useRef<string | null>(null)
const activePageId = pathname === '/pages' ? searchParams.get('id') : null
const load = useCallback(() => {
fetch('/api/pages')
.then((r) => (r.ok ? r.json() : []))
.then((d: PageSummary[]) => setPages(d ?? []))
.catch((e) => console.error('Failed to load pages', e))
}, [])
useEffect(() => {
load()
window.addEventListener(PAGES_CHANGED, load)
return () => window.removeEventListener(PAGES_CHANGED, load)
}, [load])
const createPage = async () => {
const title = nextUntitledTitle(pages.map((p) => p.title))
try {
const res = await fetch('/api/pages', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ title }) })
if (!res.ok) { toast.error('Could not create page'); return }
const p = await res.json()
notifyPagesChanged()
router.push(`/pages?id=${p.id}`)
} catch { toast.error('Could not create page') }
}
const patchPage = async (id: string, body: Record<string, unknown>) => {
try {
const res = await fetch(`/api/pages/${id}`, { method: 'PATCH', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body) })
if (!res.ok) { toast.error('Could not update page'); return }
load()
notifyPagesChanged()
} catch { toast.error('Could not update page') }
}
const startRename = (p: PageSummary) => {
setRenamingId(p.id)
setRenameValue(p.title)
}
const commitRename = async (p: PageSummary) => {
const t = renameValue.trim()
setRenamingId(null)
if (!t || t === p.title) return
await patchPage(p.id, { title: t })
}
const removePage = async (p: PageSummary) => {
if (!confirm(`Delete page "${p.title}"? Bookmarks are kept.`)) return
try {
const res = await fetch(`/api/pages/${p.id}`, { method: 'DELETE' })
if (!res.ok) { toast.error('Could not delete page'); return }
load()
notifyPagesChanged()
} catch { toast.error('Could not delete page') }
}
const savePageOrder = async (next: PageSummary[]) => {
setPages(next)
try {
const res = await fetch('/api/pages', {
method: 'PUT', headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ order: next.map((p) => p.id) }),
})
if (!res.ok) { toast.error('Could not reorder pages'); load(); return }
notifyPagesChanged()
} catch {
toast.error('Could not reorder pages')
load()
}
}
const dropPage = (target: PageSummary, after: boolean) => {
const source = draggedPageRef.current
draggedPageRef.current = null
setPageDrop(null)
if (!source) return
const next = reorderPages(pages, source, target.id, after)
if (next === 'blocked') { toast.error('Pinned and unpinned pages keep separate groups'); return }
if (next) void savePageOrder(next)
}
const viewEntries = isAdmin ? [...viewItems, { href: '/admin', label: 'Admin', icon: Shield }] : viewItems
return (
<nav className="flex-1 space-y-0.5 overflow-y-auto p-2">
<SectionHeader label="Pages" open={open} expanded={pagesExpanded} onToggle={() => setPagesExpanded(!pagesExpanded)} action={{ label: 'New page', onClick: createPage }} />
{(pagesExpanded || !open) && pages.map((p) => {
const isActive = pathname === '/pages' && activePageId === p.id
if (renamingId === p.id) {
return (
<div key={p.id} className="px-1 py-0.5">
<Input
autoFocus
value={renameValue}
onChange={(e) => setRenameValue(e.target.value)}
onBlur={() => commitRename(p)}
onKeyDown={(e) => {
if (e.key === 'Enter') { e.preventDefault(); (e.target as HTMLInputElement).blur() }
if (e.key === 'Escape') setRenamingId(null)
}}
className="h-7 text-sm"
aria-label={`Rename page ${p.title}`}
/>
</div>
)
}
return (
<div
key={p.id}
data-page-id={p.id}
onDragOver={(e) => {
if (!draggedPageRef.current || draggedPageRef.current === p.id) return
e.preventDefault()
e.dataTransfer.dropEffect = 'move'
const rect = e.currentTarget.getBoundingClientRect()
setPageDrop({ id: p.id, after: e.clientY > rect.top + rect.height / 2 })
}}
onDragLeave={(e) => {
if (!e.currentTarget.contains(e.relatedTarget as Node | null)) setPageDrop(null)
}}
onDrop={(e) => {
e.preventDefault()
const rect = e.currentTarget.getBoundingClientRect()
dropPage(p, e.clientY > rect.top + rect.height / 2)
}}
className={cn(
'group/row flex items-center rounded-lg pr-1 transition-colors',
isActive ? 'bg-accent text-accent-foreground' : 'hover:bg-accent/50',
pageDrop?.id === p.id && 'ring-1 ring-primary/50'
)}
>
{open && (
<span
data-page-drag-handle
draggable
onDragStart={(e) => {
draggedPageRef.current = p.id
e.dataTransfer.effectAllowed = 'move'
e.dataTransfer.setData('text/plain', p.id)
}}
onDragEnd={() => { draggedPageRef.current = null; setPageDrop(null) }}
className="ml-1 shrink-0 cursor-grab text-muted-foreground/40 opacity-0 transition-opacity hover:text-muted-foreground active:cursor-grabbing group-hover/row:opacity-100 focus-visible:opacity-100"
title="Drag to reorder page"
aria-label={`Drag page ${p.title}`}
>
<GripVertical className="h-3.5 w-3.5" />
</span>
)}
<Link href={`/pages?id=${p.id}`} className="flex min-w-0 flex-1 items-center gap-2 px-2 py-1.5 text-sm">
{p.pinned ? <Pin className="h-3.5 w-3.5 shrink-0 text-muted-foreground" /> : <FileText className="h-3.5 w-3.5 shrink-0 text-muted-foreground" />}
{(open || isActive) && <span className={cn('truncate', isActive ? 'font-medium' : 'text-muted-foreground')}>{p.title}</span>}
</Link>
{open && (
<div className="flex shrink-0 items-center gap-0.5 opacity-0 transition-opacity focus-within:opacity-100 group-hover/row:opacity-100">
<button
onClick={createPage}
title="New page"
aria-label="New page"
className="rounded p-0.5 text-muted-foreground hover:bg-accent hover:text-foreground"
>
<Plus className="h-3.5 w-3.5" />
</button>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<button title="Page actions" aria-label={`Actions for ${p.title}`} className="rounded p-0.5 text-muted-foreground hover:bg-accent hover:text-foreground">
<MoreHorizontal className="h-3.5 w-3.5" />
</button>
</DropdownMenuTrigger>
<DropdownMenuContent align="start" className="w-44">
<DropdownMenuItem onClick={() => patchPage(p.id, { pinned: !p.pinned })}>
{p.pinned ? <PinOff className="mr-2 h-3.5 w-3.5" /> : <Pin className="mr-2 h-3.5 w-3.5" />}
{p.pinned ? 'Unpin' : 'Pin to sidebar'}
</DropdownMenuItem>
<DropdownMenuItem onClick={() => startRename(p)}>
<Pencil className="mr-2 h-3.5 w-3.5" />
Rename
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem className="text-destructive focus:text-destructive" onClick={() => removePage(p)}>
<Trash2 className="mr-2 h-3.5 w-3.5" />
Delete
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</div>
)}
</div>
)
})}
{open && pagesExpanded && pages.length === 0 && (
<p className="px-3 py-1 text-xs text-muted-foreground/70">No pages yet</p>
)}
<div className="pt-2">
<SectionHeader label="Views" open={open} expanded={viewsExpanded} onToggle={() => setViewsExpanded(!viewsExpanded)} />
{(viewsExpanded || !open) && viewEntries.map((item) => {
const isActive = pathname === item.href || pathname?.startsWith(item.href + '/')
return (
<Link
key={item.href}
href={item.href}
className={cn(
'flex items-center gap-2 rounded-lg px-2 py-1.5 text-sm transition-colors',
isActive ? 'bg-accent font-medium text-accent-foreground' : 'text-muted-foreground hover:bg-accent/50 hover:text-foreground'
)}
>
<item.icon className="h-4 w-4 shrink-0" />
{open && <span className="truncate">{item.label}</span>}
</Link>
)
})}
</div>
</nav>
)
}
+10
View File
@@ -0,0 +1,10 @@
"use client"
import * as React from "react"
import { ThemeProvider as NextThemesProvider } from "next-themes"
type ThemeProviderProps = React.ComponentProps<typeof NextThemesProvider>
export function ThemeProvider({ children, ...props }: ThemeProviderProps) {
return <NextThemesProvider {...props}>{children}</NextThemesProvider>
}
+21
View File
@@ -0,0 +1,21 @@
'use client'
import { Moon, Sun } from 'lucide-react'
import { useTheme } from 'next-themes'
import { Button } from '@/components/ui/button'
export function ThemeToggle() {
const { theme, setTheme } = useTheme()
return (
<Button
variant="ghost"
size="icon"
onClick={() => setTheme(theme === 'dark' ? 'light' : 'dark')}
>
<Sun className="h-4 w-4 rotate-0 scale-100 transition-all dark:-rotate-90 dark:scale-0" />
<Moon className="absolute h-4 w-4 rotate-90 scale-0 transition-all dark:rotate-0 dark:scale-100" />
<span className="sr-only">Toggle theme</span>
</Button>
)
}
+183
View File
@@ -0,0 +1,183 @@
'use client'
import { useEffect, useState } from 'react'
import { signOut } from 'next-auth/react'
import { usePathname } from 'next/navigation'
import Link from 'next/link'
import { Search, Bell, Plus, Menu, LogOut, Sun, Moon } from 'lucide-react'
import { toast } from 'sonner'
import { Button } from '@/components/ui/button'
import {
Breadcrumb,
BreadcrumbItem,
BreadcrumbLink,
BreadcrumbList,
BreadcrumbPage,
BreadcrumbSeparator,
} from '@/components/ui/breadcrumb'
import { useTheme } from 'next-themes'
import { useMounted } from '@/components/client-only'
import { ACTIVE_PAGE_CHANGED } from '@/lib/pages'
// Notion keeps the top bar minimal: a breadcrumb path on the left, a subtle
// Quick Find pill, and ghost icon buttons on the right.
const SECTION_LABELS: Record<string, string> = {
'/bookmarks': 'All Bookmarks',
'/calendar': 'Calendar',
'/tags': 'Tags',
'/kanban': 'Kanban',
'/contacts': 'Contacts',
'/settings': 'Settings',
'/admin': 'Admin',
'/pages': 'Pages',
}
interface TopBarProps {
user: { id: string; name?: string | null; email?: string | null; image?: string | null }
alerts: any[]
onDismissAlert: (id: string) => void
onAddBookmark: () => void
onToggleSidebar: () => void
onQuickFind: () => void
}
export function TopBar({ user, alerts, onDismissAlert, onAddBookmark, onToggleSidebar, onQuickFind }: TopBarProps) {
const [showAlerts, setShowAlerts] = useState(false)
const pathname = usePathname()
const mounted = useMounted()
const { theme, setTheme } = useTheme()
const [activePageTitle, setActivePageTitle] = useState<string | null>(null)
// The pages workspace announces the open page so breadcrumbs can show it.
// The title only renders on /pages, so route changes need no reset effect.
useEffect(() => {
const handler = (e: Event) => setActivePageTitle((e as CustomEvent<string | null>).detail)
window.addEventListener(ACTIVE_PAGE_CHANGED, handler)
return () => window.removeEventListener(ACTIVE_PAGE_CHANGED, handler)
}, [])
const pageTitle = pathname === '/pages' ? activePageTitle : null
useEffect(() => {
const handler = (e: MouseEvent) => {
const el = document.getElementById('topbar-alerts')
if (el && !el.contains(e.target as Node)) setShowAlerts(false)
}
document.addEventListener('mousedown', handler)
return () => document.removeEventListener('mousedown', handler)
}, [])
const section = SECTION_LABELS[pathname] ?? 'computer'
const isMac = mounted && typeof navigator !== 'undefined' && /mac|iphone|ipad/i.test(navigator.userAgent)
const quickFindHint = !mounted ? '⌘K' : isMac ? '⌘K' : 'Ctrl K'
// Review CR-02: local sign-out is always available, but it is NOT the same
// claim as confirmed global revocation. Ask the trusted server to revoke
// (Go tokenVersion bump) first and report honestly when that fails — other
// devices and already-issued assertions then survive until the backend
// recovers, and the user is told so instead of being silently logged out.
const [signingOut, setSigningOut] = useState(false)
const doSignOut = async () => {
if (signingOut) return
setSigningOut(true)
let revoked = false
try {
const res = await fetch('/api/session/revoke', { method: 'POST' })
const data = await res.json().catch(() => null)
revoked = res.ok && data?.revoked === true
} catch { /* unreachable Next route — treated as a revocation failure below */ }
if (!revoked) {
toast.error('Signed out on this device, but global revocation failed — other sessions may remain signed in until the backend recovers.')
}
void signOut({ redirectTo: '/login' })
}
return (
<header className="flex items-center gap-2 border-b bg-card px-3 py-2">
<Button variant="ghost" size="icon-sm" onClick={onToggleSidebar} className="lg:hidden">
<Menu className="h-4 w-4" />
</Button>
<Breadcrumb className="min-w-0 flex-1">
<BreadcrumbList className="flex-nowrap">
<BreadcrumbItem>
<BreadcrumbLink asChild className="font-medium">
<Link href="/pages">computer</Link>
</BreadcrumbLink>
</BreadcrumbItem>
<BreadcrumbSeparator />
<BreadcrumbItem className="min-w-0">
{pageTitle ? (
<>
<BreadcrumbLink asChild>
<Link href="/pages">Pages</Link>
</BreadcrumbLink>
<BreadcrumbSeparator />
<BreadcrumbPage className="truncate font-normal text-foreground">{pageTitle}</BreadcrumbPage>
</>
) : (
<BreadcrumbPage className="truncate font-normal text-foreground">{section}</BreadcrumbPage>
)}
</BreadcrumbItem>
</BreadcrumbList>
</Breadcrumb>
<button
onClick={onQuickFind}
aria-label="Quick find"
className="flex shrink-0 items-center gap-2 rounded-md border border-border/60 bg-muted/40 px-2.5 py-1.5 text-sm text-muted-foreground transition-colors hover:bg-muted hover:text-foreground"
>
<Search className="h-3.5 w-3.5" />
<span className="hidden md:inline">Quick find</span>
<kbd className="pointer-events-none hidden select-none rounded border bg-background px-1.5 text-[10px] font-medium md:inline-flex">{quickFindHint}</kbd>
</button>
<Button variant="ghost" size="sm" onClick={onAddBookmark} className="shrink-0 gap-1.5">
<Plus className="h-4 w-4" />
<span className="hidden sm:inline">New</span>
</Button>
<Button variant="ghost" size="icon-sm" className="shrink-0" onClick={() => setTheme(theme === 'dark' ? 'light' : 'dark')}>
<Sun className="h-4 w-4 rotate-0 scale-100 transition-all dark:-rotate-90 dark:scale-0" />
<Moon className="absolute h-4 w-4 rotate-90 scale-0 transition-all dark:rotate-0 dark:scale-100" />
</Button>
<div className="relative shrink-0" id="topbar-alerts">
<Button variant="ghost" size="icon-sm" onClick={() => setShowAlerts(!showAlerts)}>
<Bell className="h-4 w-4" />
{(alerts?.length ?? 0) > 0 && (
<span className="absolute -top-0.5 -right-0.5 flex h-4 w-4 items-center justify-center rounded-full bg-destructive text-[10px] text-destructive-foreground">
{alerts?.length ?? 0}
</span>
)}
</Button>
{showAlerts && (
<div className="absolute right-0 top-full mt-2 w-80 rounded-lg border bg-card p-2 shadow-lg z-50">
<p className="px-2 py-1 text-xs font-medium text-muted-foreground">Alerts</p>
{(alerts?.length ?? 0) === 0 && (
<p className="px-2 py-4 text-sm text-center text-muted-foreground">No pending alerts</p>
)}
{(alerts ?? []).map((a: any) => (
<div key={a?.id} className="flex items-center gap-2 rounded-md px-2 py-1.5 hover:bg-muted">
<div className="flex-1 min-w-0">
<p className="text-sm font-medium truncate">{a?.title ?? 'Untitled'}</p>
<p className="text-xs text-muted-foreground">Alert due</p>
</div>
<Button variant="ghost" size="xs" onClick={() => onDismissAlert(a?.id)}>Dismiss</Button>
</div>
))}
</div>
)}
</div>
<div className="flex shrink-0 items-center gap-2 pl-1">
<div className="flex h-7 w-7 items-center justify-center rounded-full bg-primary text-primary-foreground text-xs font-bold">
{(user?.name ?? user?.email ?? '?')[0]?.toUpperCase()}
</div>
<span className="hidden sm:inline text-sm font-medium">{user?.name ?? user?.email ?? 'User'}</span>
<Button variant="ghost" size="icon-sm" onClick={doSignOut} disabled={signingOut} aria-label="Sign out">
<LogOut className="h-4 w-4" />
</Button>
</div>
</header>
)
}
+58
View File
@@ -0,0 +1,58 @@
'use client';
import * as React from 'react';
import * as AccordionPrimitive from '@radix-ui/react-accordion';
import { ChevronDown } from 'lucide-react';
import { cn } from '@/lib/utils';
const Accordion = AccordionPrimitive.Root;
const AccordionItem = React.forwardRef<
React.ElementRef<typeof AccordionPrimitive.Item>,
React.ComponentPropsWithoutRef<typeof AccordionPrimitive.Item>
>(({ className, ...props }, ref) => (
<AccordionPrimitive.Item
ref={ref}
className={cn('border-b', className)}
{...props}
/>
));
AccordionItem.displayName = 'AccordionItem';
const AccordionTrigger = React.forwardRef<
React.ElementRef<typeof AccordionPrimitive.Trigger>,
React.ComponentPropsWithoutRef<typeof AccordionPrimitive.Trigger>
>(({ className, children, ...props }, ref) => (
<AccordionPrimitive.Header className="flex">
<AccordionPrimitive.Trigger
ref={ref}
className={cn(
'flex flex-1 items-center justify-between py-4 font-medium transition-all hover:underline [&[data-state=open]>svg]:rotate-180',
className
)}
{...props}
>
{children}
<ChevronDown className="h-4 w-4 shrink-0 transition-transform duration-200" />
</AccordionPrimitive.Trigger>
</AccordionPrimitive.Header>
));
AccordionTrigger.displayName = AccordionPrimitive.Trigger.displayName;
const AccordionContent = React.forwardRef<
React.ElementRef<typeof AccordionPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof AccordionPrimitive.Content>
>(({ className, children, ...props }, ref) => (
<AccordionPrimitive.Content
ref={ref}
className="overflow-hidden text-sm transition-all data-[state=closed]:animate-accordion-up data-[state=open]:animate-accordion-down"
{...props}
>
<div className={cn('pb-4 pt-0', className)}>{children}</div>
</AccordionPrimitive.Content>
));
AccordionContent.displayName = AccordionPrimitive.Content.displayName;
export { Accordion, AccordionItem, AccordionTrigger, AccordionContent };
+141
View File
@@ -0,0 +1,141 @@
'use client';
import * as React from 'react';
import * as AlertDialogPrimitive from '@radix-ui/react-alert-dialog';
import { cn } from '@/lib/utils';
import { buttonVariants } from '@/components/ui/button';
const AlertDialog = AlertDialogPrimitive.Root;
const AlertDialogTrigger = AlertDialogPrimitive.Trigger;
const AlertDialogPortal = AlertDialogPrimitive.Portal;
const AlertDialogOverlay = React.forwardRef<
React.ElementRef<typeof AlertDialogPrimitive.Overlay>,
React.ComponentPropsWithoutRef<typeof AlertDialogPrimitive.Overlay>
>(({ className, ...props }, ref) => (
<AlertDialogPrimitive.Overlay
className={cn(
'fixed inset-0 z-50 bg-black/80 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0',
className
)}
{...props}
ref={ref}
/>
));
AlertDialogOverlay.displayName = AlertDialogPrimitive.Overlay.displayName;
const AlertDialogContent = React.forwardRef<
React.ElementRef<typeof AlertDialogPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof AlertDialogPrimitive.Content>
>(({ className, ...props }, ref) => (
<AlertDialogPortal>
<AlertDialogOverlay />
<AlertDialogPrimitive.Content
ref={ref}
className={cn(
'fixed left-[50%] top-[50%] z-50 grid w-full max-w-lg translate-x-[-50%] translate-y-[-50%] gap-4 border bg-background p-6 shadow-lg duration-200 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[state=closed]:slide-out-to-left-1/2 data-[state=closed]:slide-out-to-top-[48%] data-[state=open]:slide-in-from-left-1/2 data-[state=open]:slide-in-from-top-[48%] sm:rounded-lg',
className
)}
{...props}
/>
</AlertDialogPortal>
));
AlertDialogContent.displayName = AlertDialogPrimitive.Content.displayName;
const AlertDialogHeader = ({
className,
...props
}: React.HTMLAttributes<HTMLDivElement>) => (
<div
className={cn(
'flex flex-col space-y-2 text-center sm:text-left',
className
)}
{...props}
/>
);
AlertDialogHeader.displayName = 'AlertDialogHeader';
const AlertDialogFooter = ({
className,
...props
}: React.HTMLAttributes<HTMLDivElement>) => (
<div
className={cn(
'flex flex-col-reverse sm:flex-row sm:justify-end sm:space-x-2',
className
)}
{...props}
/>
);
AlertDialogFooter.displayName = 'AlertDialogFooter';
const AlertDialogTitle = React.forwardRef<
React.ElementRef<typeof AlertDialogPrimitive.Title>,
React.ComponentPropsWithoutRef<typeof AlertDialogPrimitive.Title>
>(({ className, ...props }, ref) => (
<AlertDialogPrimitive.Title
ref={ref}
className={cn('text-lg font-semibold', className)}
{...props}
/>
));
AlertDialogTitle.displayName = AlertDialogPrimitive.Title.displayName;
const AlertDialogDescription = React.forwardRef<
React.ElementRef<typeof AlertDialogPrimitive.Description>,
React.ComponentPropsWithoutRef<typeof AlertDialogPrimitive.Description>
>(({ className, ...props }, ref) => (
<AlertDialogPrimitive.Description
ref={ref}
className={cn('text-sm text-muted-foreground', className)}
{...props}
/>
));
AlertDialogDescription.displayName =
AlertDialogPrimitive.Description.displayName;
const AlertDialogAction = React.forwardRef<
React.ElementRef<typeof AlertDialogPrimitive.Action>,
React.ComponentPropsWithoutRef<typeof AlertDialogPrimitive.Action>
>(({ className, ...props }, ref) => (
<AlertDialogPrimitive.Action
ref={ref}
className={cn(buttonVariants(), className)}
{...props}
/>
));
AlertDialogAction.displayName = AlertDialogPrimitive.Action.displayName;
const AlertDialogCancel = React.forwardRef<
React.ElementRef<typeof AlertDialogPrimitive.Cancel>,
React.ComponentPropsWithoutRef<typeof AlertDialogPrimitive.Cancel>
>(({ className, ...props }, ref) => (
<AlertDialogPrimitive.Cancel
ref={ref}
className={cn(
buttonVariants({ variant: 'outline' }),
'mt-2 sm:mt-0',
className
)}
{...props}
/>
));
AlertDialogCancel.displayName = AlertDialogPrimitive.Cancel.displayName;
export {
AlertDialog,
AlertDialogPortal,
AlertDialogOverlay,
AlertDialogTrigger,
AlertDialogContent,
AlertDialogHeader,
AlertDialogFooter,
AlertDialogTitle,
AlertDialogDescription,
AlertDialogAction,
AlertDialogCancel,
};
+59
View File
@@ -0,0 +1,59 @@
import * as React from 'react';
import { cva, type VariantProps } from 'class-variance-authority';
import { cn } from '@/lib/utils';
const alertVariants = cva(
'relative w-full rounded-lg border p-4 [&>svg~*]:pl-7 [&>svg+div]:translate-y-[-3px] [&>svg]:absolute [&>svg]:left-4 [&>svg]:top-4 [&>svg]:text-foreground',
{
variants: {
variant: {
default: 'bg-background text-foreground',
destructive:
'border-destructive/50 text-destructive dark:border-destructive [&>svg]:text-destructive',
},
},
defaultVariants: {
variant: 'default',
},
}
);
const Alert = React.forwardRef<
HTMLDivElement,
React.HTMLAttributes<HTMLDivElement> & VariantProps<typeof alertVariants>
>(({ className, variant, ...props }, ref) => (
<div
ref={ref}
role="alert"
className={cn(alertVariants({ variant }), className)}
{...props}
/>
));
Alert.displayName = 'Alert';
const AlertTitle = React.forwardRef<
HTMLParagraphElement,
React.HTMLAttributes<HTMLHeadingElement>
>(({ className, ...props }, ref) => (
<h5
ref={ref}
className={cn('mb-1 font-medium leading-none tracking-tight', className)}
{...props}
/>
));
AlertTitle.displayName = 'AlertTitle';
const AlertDescription = React.forwardRef<
HTMLParagraphElement,
React.HTMLAttributes<HTMLParagraphElement>
>(({ className, ...props }, ref) => (
<div
ref={ref}
className={cn('text-sm [&_p]:leading-relaxed', className)}
{...props}
/>
));
AlertDescription.displayName = 'AlertDescription';
export { Alert, AlertTitle, AlertDescription };
+144
View File
@@ -0,0 +1,144 @@
'use client'
import { motion } from 'framer-motion'
const viewportConfig = { once: true, margin: '-60px' as `${number}px` }
export function FadeIn({
children, delay = 0, duration = 0.4, className,
}: {
children: React.ReactNode; delay?: number; duration?: number; className?: string
}) {
return (
<motion.div
initial={{ opacity: 0, y: 8 }}
whileInView={{ opacity: 1, y: 0 }}
viewport={viewportConfig}
transition={{ duration, delay, ease: 'easeOut' }}
className={className}
>
{children}
</motion.div>
)
}
export function ScaleIn({
children, delay = 0, className,
}: {
children: React.ReactNode; delay?: number; className?: string
}) {
return (
<motion.div
initial={{ opacity: 0, scale: 0.95 }}
whileInView={{ opacity: 1, scale: 1 }}
viewport={viewportConfig}
transition={{ duration: 0.3, delay, ease: 'easeOut' }}
className={className}
>
{children}
</motion.div>
)
}
const slideDirections = {
bottom: { y: 20, x: 0 },
top: { y: -20, x: 0 },
left: { x: -20, y: 0 },
right: { x: 20, y: 0 },
}
export function SlideIn({
children, from = 'bottom', delay = 0, className,
}: {
children: React.ReactNode; from?: keyof typeof slideDirections; delay?: number; className?: string
}) {
return (
<motion.div
initial={{ opacity: 0, ...slideDirections[from] }}
whileInView={{ opacity: 1, x: 0, y: 0 }}
viewport={viewportConfig}
transition={{ duration: 0.4, delay, ease: 'easeOut' }}
className={className}
>
{children}
</motion.div>
)
}
export function Stagger({
children, staggerDelay = 0.08, className,
}: {
children: React.ReactNode; staggerDelay?: number; className?: string
}) {
return (
<motion.div
variants={{ show: { transition: { staggerChildren: staggerDelay } } }}
initial="hidden"
whileInView="show"
viewport={viewportConfig}
className={className}
>
{children}
</motion.div>
)
}
export function StaggerItem({
children, className,
}: {
children: React.ReactNode; className?: string
}) {
return (
<motion.div
variants={{
hidden: { opacity: 0, y: 16 },
show: { opacity: 1, y: 0, transition: { duration: 0.4, ease: 'easeOut' } },
}}
className={className}
>
{children}
</motion.div>
)
}
export function HoverLift({
children, className,
}: {
children: React.ReactNode; className?: string
}) {
return (
<motion.div
whileHover={{ y: -2, boxShadow: 'var(--shadow-lg)' }}
transition={{ duration: 0.15, ease: 'easeOut' }}
className={className}
>
{children}
</motion.div>
)
}
export function PressScale({
children, className,
}: {
children: React.ReactNode; className?: string
}) {
return (
<motion.div
whileTap={{ scale: 0.98 }}
transition={{ duration: 0.1, ease: 'easeOut' }}
className={className}
>
{children}
</motion.div>
)
}
export function SkeletonPulse({ className }: { className?: string }) {
return (
<motion.div
animate={{ opacity: [0.4, 1, 0.4] }}
transition={{ duration: 1.5, repeat: Infinity, ease: 'easeInOut' }}
className={`rounded-md bg-muted ${className ?? ''}`}
/>
)
}
+7
View File
@@ -0,0 +1,7 @@
'use client';
import * as AspectRatioPrimitive from '@radix-ui/react-aspect-ratio';
const AspectRatio = AspectRatioPrimitive.Root;
export { AspectRatio };
+50
View File
@@ -0,0 +1,50 @@
'use client';
import * as React from 'react';
import * as AvatarPrimitive from '@radix-ui/react-avatar';
import { cn } from '@/lib/utils';
const Avatar = React.forwardRef<
React.ElementRef<typeof AvatarPrimitive.Root>,
React.ComponentPropsWithoutRef<typeof AvatarPrimitive.Root>
>(({ className, ...props }, ref) => (
<AvatarPrimitive.Root
ref={ref}
className={cn(
'relative flex h-10 w-10 shrink-0 overflow-hidden rounded-full',
className
)}
{...props}
/>
));
Avatar.displayName = AvatarPrimitive.Root.displayName;
const AvatarImage = React.forwardRef<
React.ElementRef<typeof AvatarPrimitive.Image>,
React.ComponentPropsWithoutRef<typeof AvatarPrimitive.Image>
>(({ className, ...props }, ref) => (
<AvatarPrimitive.Image
ref={ref}
className={cn('aspect-square h-full w-full', className)}
{...props}
/>
));
AvatarImage.displayName = AvatarPrimitive.Image.displayName;
const AvatarFallback = React.forwardRef<
React.ElementRef<typeof AvatarPrimitive.Fallback>,
React.ComponentPropsWithoutRef<typeof AvatarPrimitive.Fallback>
>(({ className, ...props }, ref) => (
<AvatarPrimitive.Fallback
ref={ref}
className={cn(
'flex h-full w-full items-center justify-center rounded-full bg-muted',
className
)}
{...props}
/>
));
AvatarFallback.displayName = AvatarPrimitive.Fallback.displayName;
export { Avatar, AvatarImage, AvatarFallback };
+36
View File
@@ -0,0 +1,36 @@
import * as React from 'react';
import { cva, type VariantProps } from 'class-variance-authority';
import { cn } from '@/lib/utils';
const badgeVariants = cva(
'inline-flex items-center rounded-full border px-2.5 py-0.5 text-xs font-semibold transition-colors focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2',
{
variants: {
variant: {
default:
'border-transparent bg-primary text-primary-foreground hover:bg-primary/80',
secondary:
'border-transparent bg-secondary text-secondary-foreground hover:bg-secondary/80',
destructive:
'border-transparent bg-destructive text-destructive-foreground hover:bg-destructive/80',
outline: 'text-foreground',
},
},
defaultVariants: {
variant: 'default',
},
}
);
export interface BadgeProps
extends React.HTMLAttributes<HTMLDivElement>,
VariantProps<typeof badgeVariants> {}
function Badge({ className, variant, ...props }: BadgeProps) {
return (
<div className={cn(badgeVariants({ variant }), className)} {...props} />
);
}
export { Badge, badgeVariants };
+115
View File
@@ -0,0 +1,115 @@
import * as React from 'react';
import { Slot } from '@radix-ui/react-slot';
import { ChevronRight, MoreHorizontal } from 'lucide-react';
import { cn } from '@/lib/utils';
const Breadcrumb = React.forwardRef<
HTMLElement,
React.ComponentPropsWithoutRef<'nav'> & {
separator?: React.ReactNode;
}
>(({ ...props }, ref) => <nav ref={ref} aria-label="breadcrumb" {...props} />);
Breadcrumb.displayName = 'Breadcrumb';
const BreadcrumbList = React.forwardRef<
HTMLOListElement,
React.ComponentPropsWithoutRef<'ol'>
>(({ className, ...props }, ref) => (
<ol
ref={ref}
className={cn(
'flex flex-wrap items-center gap-1.5 break-words text-sm text-muted-foreground sm:gap-2.5',
className
)}
{...props}
/>
));
BreadcrumbList.displayName = 'BreadcrumbList';
const BreadcrumbItem = React.forwardRef<
HTMLLIElement,
React.ComponentPropsWithoutRef<'li'>
>(({ className, ...props }, ref) => (
<li
ref={ref}
className={cn('inline-flex items-center gap-1.5', className)}
{...props}
/>
));
BreadcrumbItem.displayName = 'BreadcrumbItem';
const BreadcrumbLink = React.forwardRef<
HTMLAnchorElement,
React.ComponentPropsWithoutRef<'a'> & {
asChild?: boolean;
}
>(({ asChild, className, ...props }, ref) => {
const Comp = asChild ? Slot : 'a';
return (
<Comp
ref={ref}
className={cn('transition-colors hover:text-foreground', className)}
{...props}
/>
);
});
BreadcrumbLink.displayName = 'BreadcrumbLink';
const BreadcrumbPage = React.forwardRef<
HTMLSpanElement,
React.ComponentPropsWithoutRef<'span'>
>(({ className, ...props }, ref) => (
<span
ref={ref}
role="link"
aria-disabled="true"
aria-current="page"
className={cn('font-normal text-foreground', className)}
{...props}
/>
));
BreadcrumbPage.displayName = 'BreadcrumbPage';
const BreadcrumbSeparator = ({
children,
className,
...props
}: React.ComponentProps<'li'>) => (
<li
role="presentation"
aria-hidden="true"
className={cn('[&>svg]:size-3.5', className)}
{...props}
>
{children ?? <ChevronRight />}
</li>
);
BreadcrumbSeparator.displayName = 'BreadcrumbSeparator';
const BreadcrumbEllipsis = ({
className,
...props
}: React.ComponentProps<'span'>) => (
<span
role="presentation"
aria-hidden="true"
className={cn('flex h-9 w-9 items-center justify-center', className)}
{...props}
>
<MoreHorizontal className="h-4 w-4" />
<span className="sr-only">More</span>
</span>
);
BreadcrumbEllipsis.displayName = 'BreadcrumbElipssis';
export {
Breadcrumb,
BreadcrumbList,
BreadcrumbItem,
BreadcrumbLink,
BreadcrumbPage,
BreadcrumbSeparator,
BreadcrumbEllipsis,
};
+72
View File
@@ -0,0 +1,72 @@
import * as React from "react"
import { Slot, Slottable } from "@radix-ui/react-slot"
import { cva, type VariantProps } from "class-variance-authority"
import { Loader2 } from "lucide-react"
import { cn } from "@/lib/utils"
const buttonVariants = cva(
"inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-lg text-sm font-medium transition-all duration-fast focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background active:scale-[0.98] disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:w-4 [&_svg]:h-4 [&_svg]:shrink-0",
{
variants: {
variant: {
default:
"bg-primary text-primary-foreground shadow-sm hover:bg-primary/90 hover:shadow-md",
destructive:
"bg-destructive text-destructive-foreground shadow-sm hover:bg-destructive/90 hover:shadow-md",
outline:
"border border-input bg-background shadow-sm hover:bg-accent hover:text-accent-foreground hover:border-accent hover:shadow-sm",
secondary:
"bg-secondary text-secondary-foreground shadow-sm hover:bg-secondary/80",
ghost:
"hover:bg-accent hover:text-accent-foreground",
"glass-dark":
"bg-white/10 backdrop-blur-md border border-white/20 [box-shadow:inset_0_0_0_1px_rgba(255,255,255,0.1)] text-foreground shadow-sm hover:bg-white/25 hover:border-white/40 hover:shadow-lg focus-visible:ring-white/50 focus-visible:ring-ring/0",
"glass-light":
"bg-black/[0.06] backdrop-blur-md border border-black/10 [box-shadow:inset_0_0_0_1px_rgba(255,255,255,0.5)] text-gray-900 shadow-sm hover:bg-black/[0.12] hover:border-black/20 hover:shadow-lg focus-visible:ring-black/30 focus-visible:ring-ring/0",
link:
"text-primary underline-offset-4 hover:underline focus-visible:underline focus-visible:ring-0 focus-visible:ring-offset-0",
},
size: {
default: "h-10 px-4 py-2",
xs: "h-7 rounded-md px-2 text-xs",
sm: "h-9 rounded-md px-3",
lg: "h-11 rounded-lg px-6 text-base",
icon: "h-10 w-10",
"icon-sm": "h-8 w-8 rounded-md",
},
},
defaultVariants: {
variant: "default",
size: "default",
},
}
)
export interface ButtonProps
extends React.ButtonHTMLAttributes<HTMLButtonElement>,
VariantProps<typeof buttonVariants> {
asChild?: boolean
loading?: boolean
}
const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(
({ className, variant, size, asChild = false, loading = false, disabled, children, ...props }, ref) => {
const Comp = asChild ? Slot : "button"
return (
<Comp
className={cn(buttonVariants({ variant, size, className }))}
ref={ref}
disabled={disabled || loading}
aria-busy={loading || undefined}
{...props}
>
{loading && <Loader2 className="h-4 w-4 animate-spin" />}
<Slottable>{loading && asChild ? null : children}</Slottable>
</Comp>
)
}
)
Button.displayName = "Button"
export { Button, buttonVariants }
+66
View File
@@ -0,0 +1,66 @@
'use client';
import * as React from 'react';
import { ChevronLeft, ChevronRight } from 'lucide-react';
import { DayPicker } from 'react-day-picker';
import { cn } from '@/lib/utils';
import { buttonVariants } from '@/components/ui/button';
export type CalendarProps = React.ComponentProps<typeof DayPicker>;
function Calendar({
className,
classNames,
showOutsideDays = true,
...props
}: CalendarProps) {
return (
<DayPicker
showOutsideDays={showOutsideDays}
className={cn('p-3', className)}
classNames={{
months: 'flex flex-col sm:flex-row space-y-4 sm:space-x-4 sm:space-y-0',
month: 'space-y-4',
caption: 'flex justify-center pt-1 relative items-center',
caption_label: 'text-sm font-medium',
nav: 'space-x-1 flex items-center',
nav_button: cn(
buttonVariants({ variant: 'outline' }),
'h-7 w-7 bg-transparent p-0 opacity-50 hover:opacity-100'
),
nav_button_previous: 'absolute left-1',
nav_button_next: 'absolute right-1',
table: 'w-full border-collapse space-y-1',
head_row: 'flex',
head_cell:
'text-muted-foreground rounded-md w-9 font-normal text-[0.8rem]',
row: 'flex w-full mt-2',
cell: 'h-9 w-9 text-center text-sm p-0 relative [&:has([aria-selected].day-range-end)]:rounded-r-md [&:has([aria-selected].day-outside)]:bg-accent/50 [&:has([aria-selected])]:bg-accent first:[&:has([aria-selected])]:rounded-l-md last:[&:has([aria-selected])]:rounded-r-md focus-within:relative focus-within:z-20',
day: cn(
buttonVariants({ variant: 'ghost' }),
'h-9 w-9 p-0 font-normal aria-selected:opacity-100'
),
day_range_end: 'day-range-end',
day_selected:
'bg-primary text-primary-foreground hover:bg-primary hover:text-primary-foreground focus:bg-primary focus:text-primary-foreground',
day_today: 'bg-accent text-accent-foreground',
day_outside:
'day-outside text-muted-foreground opacity-50 aria-selected:bg-accent/50 aria-selected:text-muted-foreground aria-selected:opacity-30',
day_disabled: 'text-muted-foreground opacity-50',
day_range_middle:
'aria-selected:bg-accent aria-selected:text-accent-foreground',
day_hidden: 'invisible',
...classNames,
}}
components={{
IconLeft: ({ ...props }) => <ChevronLeft className="h-4 w-4" />,
IconRight: ({ ...props }) => <ChevronRight className="h-4 w-4" />,
}}
{...props}
/>
);
}
Calendar.displayName = 'Calendar';
export { Calendar };
+106
View File
@@ -0,0 +1,106 @@
import * as React from "react"
import { cva, type VariantProps } from "class-variance-authority"
import { cn } from "@/lib/utils"
const cardVariants = cva(
"rounded-lg border bg-card text-card-foreground shadow-sm",
{
variants: {
variant: {
default: "",
interactive:
"transition-all duration-normal hover:shadow-md hover:-translate-y-0.5 hover:border-primary/20",
"glass-dark":
"bg-white/10 backdrop-blur-xl border border-white/20 shadow-md [box-shadow:inset_0_0_0_1px_rgba(255,255,255,0.15)]",
"glass-dark-interactive":
"bg-white/10 backdrop-blur-xl border border-white/20 shadow-md [box-shadow:inset_0_0_0_1px_rgba(255,255,255,0.15)] transition-all duration-normal hover:bg-white/15 hover:border-white/30 hover:shadow-lg hover:-translate-y-0.5",
"glass-light":
"bg-black/[0.04] backdrop-blur-xl border border-black/[0.08] shadow-md [box-shadow:inset_0_0_0_1px_rgba(255,255,255,0.5)] text-gray-900",
"glass-light-interactive":
"bg-black/[0.04] backdrop-blur-xl border border-black/[0.08] shadow-md [box-shadow:inset_0_0_0_1px_rgba(255,255,255,0.5)] text-gray-900 transition-all duration-normal hover:bg-black/[0.08] hover:border-black/[0.15] hover:shadow-lg hover:-translate-y-0.5",
ghost:
"border-transparent shadow-none bg-transparent",
},
},
defaultVariants: {
variant: "default",
},
}
)
export interface CardProps
extends React.HTMLAttributes<HTMLDivElement>,
VariantProps<typeof cardVariants> {}
const Card = React.forwardRef<HTMLDivElement, CardProps>(
({ className, variant, ...props }, ref) => (
<div
ref={ref}
className={cn(cardVariants({ variant, className }))}
{...props}
/>
)
)
Card.displayName = "Card"
const CardHeader = React.forwardRef<
HTMLDivElement,
React.HTMLAttributes<HTMLDivElement>
>(({ className, ...props }, ref) => (
<div
ref={ref}
className={cn("flex flex-col space-y-1.5 p-6", className)}
{...props}
/>
))
CardHeader.displayName = "CardHeader"
const CardTitle = React.forwardRef<
HTMLParagraphElement,
React.HTMLAttributes<HTMLHeadingElement>
>(({ className, ...props }, ref) => (
<h3
ref={ref}
className={cn(
"text-2xl font-semibold leading-none tracking-tight",
className
)}
{...props}
/>
))
CardTitle.displayName = "CardTitle"
const CardDescription = React.forwardRef<
HTMLParagraphElement,
React.HTMLAttributes<HTMLParagraphElement>
>(({ className, ...props }, ref) => (
<p
ref={ref}
className={cn("text-sm text-muted-foreground", className)}
{...props}
/>
))
CardDescription.displayName = "CardDescription"
const CardContent = React.forwardRef<
HTMLDivElement,
React.HTMLAttributes<HTMLDivElement>
>(({ className, ...props }, ref) => (
<div ref={ref} className={cn("p-6 pt-0", className)} {...props} />
))
CardContent.displayName = "CardContent"
const CardFooter = React.forwardRef<
HTMLDivElement,
React.HTMLAttributes<HTMLDivElement>
>(({ className, ...props }, ref) => (
<div
ref={ref}
className={cn("flex items-center p-6 pt-0", className)}
{...props}
/>
))
CardFooter.displayName = "CardFooter"
export { Card, cardVariants, CardHeader, CardFooter, CardTitle, CardDescription, CardContent }
+263
View File
@@ -0,0 +1,263 @@
'use client';
import * as React from 'react';
import useEmblaCarousel, {
type UseEmblaCarouselType,
} from 'embla-carousel-react';
import { ArrowLeft, ArrowRight } from 'lucide-react';
import { cn } from '@/lib/utils';
import { Button } from '@/components/ui/button';
type CarouselApi = UseEmblaCarouselType[1];
type UseCarouselParameters = Parameters<typeof useEmblaCarousel>;
type CarouselOptions = UseCarouselParameters[0];
type CarouselPlugin = UseCarouselParameters[1];
type CarouselProps = {
opts?: CarouselOptions;
plugins?: CarouselPlugin;
orientation?: 'horizontal' | 'vertical';
setApi?: (api: CarouselApi) => void;
};
type CarouselContextProps = {
carouselRef: ReturnType<typeof useEmblaCarousel>[0];
api: ReturnType<typeof useEmblaCarousel>[1];
scrollPrev: () => void;
scrollNext: () => void;
canScrollPrev: boolean;
canScrollNext: boolean;
} & CarouselProps;
const CarouselContext = React.createContext<CarouselContextProps | null>(null);
function useCarousel() {
const context = React.useContext(CarouselContext);
if (!context) {
throw new Error('useCarousel must be used within a <Carousel />');
}
return context;
}
const Carousel = React.forwardRef<
HTMLDivElement,
React.HTMLAttributes<HTMLDivElement> & CarouselProps
>(
(
{
orientation = 'horizontal',
opts,
setApi,
plugins,
className,
children,
...props
},
ref
) => {
const [carouselRef, api] = useEmblaCarousel(
{
...opts,
axis: orientation === 'horizontal' ? 'x' : 'y',
},
plugins
);
const [canScrollPrev, setCanScrollPrev] = React.useState(false);
const [canScrollNext, setCanScrollNext] = React.useState(false);
const onSelect = React.useCallback((api: CarouselApi) => {
if (!api) {
return;
}
setCanScrollPrev(api.canScrollPrev());
setCanScrollNext(api.canScrollNext());
}, []);
const scrollPrev = React.useCallback(() => {
api?.scrollPrev();
}, [api]);
const scrollNext = React.useCallback(() => {
api?.scrollNext();
}, [api]);
const handleKeyDown = React.useCallback(
(event: React.KeyboardEvent<HTMLDivElement>) => {
if (event.key === 'ArrowLeft') {
event.preventDefault();
scrollPrev();
} else if (event.key === 'ArrowRight') {
event.preventDefault();
scrollNext();
}
},
[scrollPrev, scrollNext]
);
React.useEffect(() => {
if (!api || !setApi) {
return;
}
setApi(api);
}, [api, setApi]);
React.useEffect(() => {
if (!api) {
return;
}
const timer = setTimeout(() => onSelect(api), 0);
api.on('reInit', onSelect);
api.on('select', onSelect);
return () => {
clearTimeout(timer);
api?.off('select', onSelect);
};
}, [api, onSelect]);
return (
<CarouselContext.Provider
value={{
carouselRef,
api: api,
opts,
orientation:
orientation || (opts?.axis === 'y' ? 'vertical' : 'horizontal'),
scrollPrev,
scrollNext,
canScrollPrev,
canScrollNext,
}}
>
<div
ref={ref}
onKeyDownCapture={handleKeyDown}
className={cn('relative', className)}
role="region"
aria-roledescription="carousel"
{...props}
>
{children}
</div>
</CarouselContext.Provider>
);
}
);
Carousel.displayName = 'Carousel';
const CarouselContent = React.forwardRef<
HTMLDivElement,
React.HTMLAttributes<HTMLDivElement>
>(({ className, ...props }, ref) => {
const { carouselRef, orientation } = useCarousel();
return (
<div ref={carouselRef} className="overflow-hidden">
<div
ref={ref}
className={cn(
'flex',
orientation === 'horizontal' ? '-ml-4' : '-mt-4 flex-col',
className
)}
{...props}
/>
</div>
);
});
CarouselContent.displayName = 'CarouselContent';
const CarouselItem = React.forwardRef<
HTMLDivElement,
React.HTMLAttributes<HTMLDivElement>
>(({ className, ...props }, ref) => {
const { orientation } = useCarousel();
return (
<div
ref={ref}
role="group"
aria-roledescription="slide"
className={cn(
'min-w-0 shrink-0 grow-0 basis-full',
orientation === 'horizontal' ? 'pl-4' : 'pt-4',
className
)}
{...props}
/>
);
});
CarouselItem.displayName = 'CarouselItem';
const CarouselPrevious = React.forwardRef<
HTMLButtonElement,
React.ComponentProps<typeof Button>
>(({ className, variant = 'outline', size = 'icon', ...props }, ref) => {
const { orientation, scrollPrev, canScrollPrev } = useCarousel();
return (
<Button
ref={ref}
variant={variant}
size={size}
className={cn(
'absolute h-8 w-8 rounded-full',
orientation === 'horizontal'
? '-left-12 top-1/2 -translate-y-1/2'
: '-top-12 left-1/2 -translate-x-1/2 rotate-90',
className
)}
disabled={!canScrollPrev}
onClick={scrollPrev}
{...props}
>
<ArrowLeft className="h-4 w-4" />
<span className="sr-only">Previous slide</span>
</Button>
);
});
CarouselPrevious.displayName = 'CarouselPrevious';
const CarouselNext = React.forwardRef<
HTMLButtonElement,
React.ComponentProps<typeof Button>
>(({ className, variant = 'outline', size = 'icon', ...props }, ref) => {
const { orientation, scrollNext, canScrollNext } = useCarousel();
return (
<Button
ref={ref}
variant={variant}
size={size}
className={cn(
'absolute h-8 w-8 rounded-full',
orientation === 'horizontal'
? '-right-12 top-1/2 -translate-y-1/2'
: '-bottom-12 left-1/2 -translate-x-1/2 rotate-90',
className
)}
disabled={!canScrollNext}
onClick={scrollNext}
{...props}
>
<ArrowRight className="h-4 w-4" />
<span className="sr-only">Next slide</span>
</Button>
);
});
CarouselNext.displayName = 'CarouselNext';
export {
type CarouselApi,
Carousel,
CarouselContent,
CarouselItem,
CarouselPrevious,
CarouselNext,
};
+30
View File
@@ -0,0 +1,30 @@
'use client';
import * as React from 'react';
import * as CheckboxPrimitive from '@radix-ui/react-checkbox';
import { Check } from 'lucide-react';
import { cn } from '@/lib/utils';
const Checkbox = React.forwardRef<
React.ElementRef<typeof CheckboxPrimitive.Root>,
React.ComponentPropsWithoutRef<typeof CheckboxPrimitive.Root>
>(({ className, ...props }, ref) => (
<CheckboxPrimitive.Root
ref={ref}
className={cn(
'peer h-4 w-4 shrink-0 rounded-sm border border-primary ring-offset-background focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50 data-[state=checked]:bg-primary data-[state=checked]:text-primary-foreground',
className
)}
{...props}
>
<CheckboxPrimitive.Indicator
className={cn('flex items-center justify-center text-current')}
>
<Check className="h-4 w-4" />
</CheckboxPrimitive.Indicator>
</CheckboxPrimitive.Root>
));
Checkbox.displayName = CheckboxPrimitive.Root.displayName;
export { Checkbox };
+11
View File
@@ -0,0 +1,11 @@
'use client';
import * as CollapsiblePrimitive from '@radix-ui/react-collapsible';
const Collapsible = CollapsiblePrimitive.Root;
const CollapsibleTrigger = CollapsiblePrimitive.CollapsibleTrigger;
const CollapsibleContent = CollapsiblePrimitive.CollapsibleContent;
export { Collapsible, CollapsibleTrigger, CollapsibleContent };
+155
View File
@@ -0,0 +1,155 @@
'use client';
import * as React from 'react';
import { type DialogProps } from '@radix-ui/react-dialog';
import { Command as CommandPrimitive } from 'cmdk';
import { Search } from 'lucide-react';
import { cn } from '@/lib/utils';
import { Dialog, DialogContent } from '@/components/ui/dialog';
const Command = React.forwardRef<
React.ElementRef<typeof CommandPrimitive>,
React.ComponentPropsWithoutRef<typeof CommandPrimitive>
>(({ className, ...props }, ref) => (
<CommandPrimitive
ref={ref}
className={cn(
'flex h-full w-full flex-col overflow-hidden rounded-md bg-popover text-popover-foreground',
className
)}
{...props}
/>
));
Command.displayName = CommandPrimitive.displayName;
type CommandDialogProps = DialogProps
const CommandDialog = ({ children, ...props }: CommandDialogProps) => {
return (
<Dialog {...props}>
<DialogContent className="overflow-hidden p-0 shadow-lg">
<Command className="[&_[cmdk-group-heading]]:px-2 [&_[cmdk-group-heading]]:font-medium [&_[cmdk-group-heading]]:text-muted-foreground [&_[cmdk-group]:not([hidden])_~[cmdk-group]]:pt-0 [&_[cmdk-group]]:px-2 [&_[cmdk-input-wrapper]_svg]:h-5 [&_[cmdk-input-wrapper]_svg]:w-5 [&_[cmdk-input]]:h-12 [&_[cmdk-item]]:px-2 [&_[cmdk-item]]:py-3 [&_[cmdk-item]_svg]:h-5 [&_[cmdk-item]_svg]:w-5">
{children}
</Command>
</DialogContent>
</Dialog>
);
};
const CommandInput = React.forwardRef<
React.ElementRef<typeof CommandPrimitive.Input>,
React.ComponentPropsWithoutRef<typeof CommandPrimitive.Input>
>(({ className, ...props }, ref) => (
<div className="flex items-center border-b px-3" cmdk-input-wrapper="">
<Search className="mr-2 h-4 w-4 shrink-0 opacity-50" />
<CommandPrimitive.Input
ref={ref}
className={cn(
'flex h-11 w-full rounded-md bg-transparent py-3 text-sm outline-none placeholder:text-muted-foreground disabled:cursor-not-allowed disabled:opacity-50',
className
)}
{...props}
/>
</div>
));
CommandInput.displayName = CommandPrimitive.Input.displayName;
const CommandList = React.forwardRef<
React.ElementRef<typeof CommandPrimitive.List>,
React.ComponentPropsWithoutRef<typeof CommandPrimitive.List>
>(({ className, ...props }, ref) => (
<CommandPrimitive.List
ref={ref}
className={cn('max-h-[300px] overflow-y-auto overflow-x-hidden', className)}
{...props}
/>
));
CommandList.displayName = CommandPrimitive.List.displayName;
const CommandEmpty = React.forwardRef<
React.ElementRef<typeof CommandPrimitive.Empty>,
React.ComponentPropsWithoutRef<typeof CommandPrimitive.Empty>
>((props, ref) => (
<CommandPrimitive.Empty
ref={ref}
className="py-6 text-center text-sm"
{...props}
/>
));
CommandEmpty.displayName = CommandPrimitive.Empty.displayName;
const CommandGroup = React.forwardRef<
React.ElementRef<typeof CommandPrimitive.Group>,
React.ComponentPropsWithoutRef<typeof CommandPrimitive.Group>
>(({ className, ...props }, ref) => (
<CommandPrimitive.Group
ref={ref}
className={cn(
'overflow-hidden p-1 text-foreground [&_[cmdk-group-heading]]:px-2 [&_[cmdk-group-heading]]:py-1.5 [&_[cmdk-group-heading]]:text-xs [&_[cmdk-group-heading]]:font-medium [&_[cmdk-group-heading]]:text-muted-foreground',
className
)}
{...props}
/>
));
CommandGroup.displayName = CommandPrimitive.Group.displayName;
const CommandSeparator = React.forwardRef<
React.ElementRef<typeof CommandPrimitive.Separator>,
React.ComponentPropsWithoutRef<typeof CommandPrimitive.Separator>
>(({ className, ...props }, ref) => (
<CommandPrimitive.Separator
ref={ref}
className={cn('-mx-1 h-px bg-border', className)}
{...props}
/>
));
CommandSeparator.displayName = CommandPrimitive.Separator.displayName;
const CommandItem = React.forwardRef<
React.ElementRef<typeof CommandPrimitive.Item>,
React.ComponentPropsWithoutRef<typeof CommandPrimitive.Item>
>(({ className, ...props }, ref) => (
<CommandPrimitive.Item
ref={ref}
className={cn(
"relative flex cursor-default select-none items-center rounded-sm px-2 py-1.5 text-sm outline-none data-[disabled=true]:pointer-events-none data-[selected='true']:bg-accent data-[selected=true]:text-accent-foreground data-[disabled=true]:opacity-50",
className
)}
{...props}
/>
));
CommandItem.displayName = CommandPrimitive.Item.displayName;
const CommandShortcut = ({
className,
...props
}: React.HTMLAttributes<HTMLSpanElement>) => {
return (
<span
className={cn(
'ml-auto text-xs tracking-widest text-muted-foreground',
className
)}
{...props}
/>
);
};
CommandShortcut.displayName = 'CommandShortcut';
export {
Command,
CommandDialog,
CommandInput,
CommandList,
CommandEmpty,
CommandGroup,
CommandItem,
CommandShortcut,
CommandSeparator,
};
+200
View File
@@ -0,0 +1,200 @@
'use client';
import * as React from 'react';
import * as ContextMenuPrimitive from '@radix-ui/react-context-menu';
import { Check, ChevronRight, Circle } from 'lucide-react';
import { cn } from '@/lib/utils';
const ContextMenu = ContextMenuPrimitive.Root;
const ContextMenuTrigger = ContextMenuPrimitive.Trigger;
const ContextMenuGroup = ContextMenuPrimitive.Group;
const ContextMenuPortal = ContextMenuPrimitive.Portal;
const ContextMenuSub = ContextMenuPrimitive.Sub;
const ContextMenuRadioGroup = ContextMenuPrimitive.RadioGroup;
const ContextMenuSubTrigger = React.forwardRef<
React.ElementRef<typeof ContextMenuPrimitive.SubTrigger>,
React.ComponentPropsWithoutRef<typeof ContextMenuPrimitive.SubTrigger> & {
inset?: boolean;
}
>(({ className, inset, children, ...props }, ref) => (
<ContextMenuPrimitive.SubTrigger
ref={ref}
className={cn(
'flex cursor-default select-none items-center rounded-sm px-2 py-1.5 text-sm outline-none focus:bg-accent focus:text-accent-foreground data-[state=open]:bg-accent data-[state=open]:text-accent-foreground',
inset && 'pl-8',
className
)}
{...props}
>
{children}
<ChevronRight className="ml-auto h-4 w-4" />
</ContextMenuPrimitive.SubTrigger>
));
ContextMenuSubTrigger.displayName = ContextMenuPrimitive.SubTrigger.displayName;
const ContextMenuSubContent = React.forwardRef<
React.ElementRef<typeof ContextMenuPrimitive.SubContent>,
React.ComponentPropsWithoutRef<typeof ContextMenuPrimitive.SubContent>
>(({ className, ...props }, ref) => (
<ContextMenuPrimitive.SubContent
ref={ref}
className={cn(
'z-50 min-w-[8rem] overflow-hidden rounded-md border bg-popover p-1 text-popover-foreground shadow-md data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2',
className
)}
{...props}
/>
));
ContextMenuSubContent.displayName = ContextMenuPrimitive.SubContent.displayName;
const ContextMenuContent = React.forwardRef<
React.ElementRef<typeof ContextMenuPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof ContextMenuPrimitive.Content>
>(({ className, ...props }, ref) => (
<ContextMenuPrimitive.Portal>
<ContextMenuPrimitive.Content
ref={ref}
className={cn(
'z-50 min-w-[8rem] overflow-hidden rounded-md border bg-popover p-1 text-popover-foreground shadow-md animate-in fade-in-80 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2',
className
)}
{...props}
/>
</ContextMenuPrimitive.Portal>
));
ContextMenuContent.displayName = ContextMenuPrimitive.Content.displayName;
const ContextMenuItem = React.forwardRef<
React.ElementRef<typeof ContextMenuPrimitive.Item>,
React.ComponentPropsWithoutRef<typeof ContextMenuPrimitive.Item> & {
inset?: boolean;
}
>(({ className, inset, ...props }, ref) => (
<ContextMenuPrimitive.Item
ref={ref}
className={cn(
'relative flex cursor-default select-none items-center rounded-sm px-2 py-1.5 text-sm outline-none focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50',
inset && 'pl-8',
className
)}
{...props}
/>
));
ContextMenuItem.displayName = ContextMenuPrimitive.Item.displayName;
const ContextMenuCheckboxItem = React.forwardRef<
React.ElementRef<typeof ContextMenuPrimitive.CheckboxItem>,
React.ComponentPropsWithoutRef<typeof ContextMenuPrimitive.CheckboxItem>
>(({ className, children, checked, ...props }, ref) => (
<ContextMenuPrimitive.CheckboxItem
ref={ref}
className={cn(
'relative flex cursor-default select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm outline-none focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50',
className
)}
checked={checked}
{...props}
>
<span className="absolute left-2 flex h-3.5 w-3.5 items-center justify-center">
<ContextMenuPrimitive.ItemIndicator>
<Check className="h-4 w-4" />
</ContextMenuPrimitive.ItemIndicator>
</span>
{children}
</ContextMenuPrimitive.CheckboxItem>
));
ContextMenuCheckboxItem.displayName =
ContextMenuPrimitive.CheckboxItem.displayName;
const ContextMenuRadioItem = React.forwardRef<
React.ElementRef<typeof ContextMenuPrimitive.RadioItem>,
React.ComponentPropsWithoutRef<typeof ContextMenuPrimitive.RadioItem>
>(({ className, children, ...props }, ref) => (
<ContextMenuPrimitive.RadioItem
ref={ref}
className={cn(
'relative flex cursor-default select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm outline-none focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50',
className
)}
{...props}
>
<span className="absolute left-2 flex h-3.5 w-3.5 items-center justify-center">
<ContextMenuPrimitive.ItemIndicator>
<Circle className="h-2 w-2 fill-current" />
</ContextMenuPrimitive.ItemIndicator>
</span>
{children}
</ContextMenuPrimitive.RadioItem>
));
ContextMenuRadioItem.displayName = ContextMenuPrimitive.RadioItem.displayName;
const ContextMenuLabel = React.forwardRef<
React.ElementRef<typeof ContextMenuPrimitive.Label>,
React.ComponentPropsWithoutRef<typeof ContextMenuPrimitive.Label> & {
inset?: boolean;
}
>(({ className, inset, ...props }, ref) => (
<ContextMenuPrimitive.Label
ref={ref}
className={cn(
'px-2 py-1.5 text-sm font-semibold text-foreground',
inset && 'pl-8',
className
)}
{...props}
/>
));
ContextMenuLabel.displayName = ContextMenuPrimitive.Label.displayName;
const ContextMenuSeparator = React.forwardRef<
React.ElementRef<typeof ContextMenuPrimitive.Separator>,
React.ComponentPropsWithoutRef<typeof ContextMenuPrimitive.Separator>
>(({ className, ...props }, ref) => (
<ContextMenuPrimitive.Separator
ref={ref}
className={cn('-mx-1 my-1 h-px bg-border', className)}
{...props}
/>
));
ContextMenuSeparator.displayName = ContextMenuPrimitive.Separator.displayName;
const ContextMenuShortcut = ({
className,
...props
}: React.HTMLAttributes<HTMLSpanElement>) => {
return (
<span
className={cn(
'ml-auto text-xs tracking-widest text-muted-foreground',
className
)}
{...props}
/>
);
};
ContextMenuShortcut.displayName = 'ContextMenuShortcut';
export {
ContextMenu,
ContextMenuTrigger,
ContextMenuContent,
ContextMenuItem,
ContextMenuCheckboxItem,
ContextMenuRadioItem,
ContextMenuLabel,
ContextMenuSeparator,
ContextMenuShortcut,
ContextMenuGroup,
ContextMenuPortal,
ContextMenuSub,
ContextMenuSubContent,
ContextMenuSubTrigger,
ContextMenuRadioGroup,
};
+65
View File
@@ -0,0 +1,65 @@
import * as React from "react";
import { format } from "date-fns";
import { Calendar as CalendarIcon } from "lucide-react";
import { DateRange } from "react-day-picker";
import { cn } from "@/lib/utils";
import { Button } from "@/components/ui/button";
import { Calendar } from "@/components/ui/calendar";
import {
Popover,
PopoverContent,
PopoverTrigger,
} from "@/components/ui/popover";
interface DateRangePickerProps {
value: DateRange;
onChange: (value: DateRange) => void;
className?: string;
}
export function DateRangePicker({
value,
onChange,
className,
}: DateRangePickerProps) {
return (
<div className={cn("grid gap-2", className)}>
<Popover>
<PopoverTrigger asChild>
<Button
id="date"
variant={"outline"}
className={cn(
"w-[300px] justify-start text-left font-normal",
!value && "text-muted-foreground"
)}
>
<CalendarIcon className="mr-2 h-4 w-4" />
{value?.from ? (
value.to ? (
<>
{format(value.from, "LLL dd, y")} -{" "}
{format(value.to, "LLL dd, y")}
</>
) : (
format(value.from, "LLL dd, y")
)
) : (
<span>Pick a date range</span>
)}
</Button>
</PopoverTrigger>
<PopoverContent className="w-auto p-0" align="start">
<Calendar
initialFocus
mode="range"
defaultMonth={value?.from}
selected={value}
onSelect={(value) => onChange(value as DateRange)}
numberOfMonths={2}
/>
</PopoverContent>
</Popover>
</div>
);
}
+122
View File
@@ -0,0 +1,122 @@
'use client';
import * as React from 'react';
import * as DialogPrimitive from '@radix-ui/react-dialog';
import { X } from 'lucide-react';
import { cn } from '@/lib/utils';
const Dialog = DialogPrimitive.Root;
const DialogTrigger = DialogPrimitive.Trigger;
const DialogPortal = DialogPrimitive.Portal;
const DialogClose = DialogPrimitive.Close;
const DialogOverlay = React.forwardRef<
React.ElementRef<typeof DialogPrimitive.Overlay>,
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Overlay>
>(({ className, ...props }, ref) => (
<DialogPrimitive.Overlay
ref={ref}
className={cn(
'fixed inset-0 z-50 bg-black/80 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0',
className
)}
{...props}
/>
));
DialogOverlay.displayName = DialogPrimitive.Overlay.displayName;
const DialogContent = React.forwardRef<
React.ElementRef<typeof DialogPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Content>
>(({ className, children, ...props }, ref) => (
<DialogPortal>
<DialogOverlay />
<DialogPrimitive.Content
ref={ref}
className={cn(
'fixed left-[50%] top-[50%] z-50 grid w-full max-w-lg translate-x-[-50%] translate-y-[-50%] gap-4 border bg-background p-6 shadow-lg duration-200 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[state=closed]:slide-out-to-left-1/2 data-[state=closed]:slide-out-to-top-[48%] data-[state=open]:slide-in-from-left-1/2 data-[state=open]:slide-in-from-top-[48%] sm:rounded-lg',
className
)}
{...props}
>
{children}
<DialogPrimitive.Close className="absolute right-4 top-4 rounded-sm opacity-70 ring-offset-background transition-opacity hover:opacity-100 focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:pointer-events-none data-[state=open]:bg-accent data-[state=open]:text-muted-foreground">
<X className="h-4 w-4" />
<span className="sr-only">Close</span>
</DialogPrimitive.Close>
</DialogPrimitive.Content>
</DialogPortal>
));
DialogContent.displayName = DialogPrimitive.Content.displayName;
const DialogHeader = ({
className,
...props
}: React.HTMLAttributes<HTMLDivElement>) => (
<div
className={cn(
'flex flex-col space-y-1.5 text-center sm:text-left',
className
)}
{...props}
/>
);
DialogHeader.displayName = 'DialogHeader';
const DialogFooter = ({
className,
...props
}: React.HTMLAttributes<HTMLDivElement>) => (
<div
className={cn(
'flex flex-col-reverse sm:flex-row sm:justify-end sm:space-x-2',
className
)}
{...props}
/>
);
DialogFooter.displayName = 'DialogFooter';
const DialogTitle = React.forwardRef<
React.ElementRef<typeof DialogPrimitive.Title>,
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Title>
>(({ className, ...props }, ref) => (
<DialogPrimitive.Title
ref={ref}
className={cn(
'text-lg font-semibold leading-none tracking-tight',
className
)}
{...props}
/>
));
DialogTitle.displayName = DialogPrimitive.Title.displayName;
const DialogDescription = React.forwardRef<
React.ElementRef<typeof DialogPrimitive.Description>,
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Description>
>(({ className, ...props }, ref) => (
<DialogPrimitive.Description
ref={ref}
className={cn('text-sm text-muted-foreground', className)}
{...props}
/>
));
DialogDescription.displayName = DialogPrimitive.Description.displayName;
export {
Dialog,
DialogPortal,
DialogOverlay,
DialogClose,
DialogTrigger,
DialogContent,
DialogHeader,
DialogFooter,
DialogTitle,
DialogDescription,
};
+118
View File
@@ -0,0 +1,118 @@
'use client';
import * as React from 'react';
import { Drawer as DrawerPrimitive } from 'vaul';
import { cn } from '@/lib/utils';
const Drawer = ({
shouldScaleBackground = true,
...props
}: React.ComponentProps<typeof DrawerPrimitive.Root>) => (
<DrawerPrimitive.Root
shouldScaleBackground={shouldScaleBackground}
{...props}
/>
);
Drawer.displayName = 'Drawer';
const DrawerTrigger = DrawerPrimitive.Trigger;
const DrawerPortal = DrawerPrimitive.Portal;
const DrawerClose = DrawerPrimitive.Close;
const DrawerOverlay = React.forwardRef<
React.ElementRef<typeof DrawerPrimitive.Overlay>,
React.ComponentPropsWithoutRef<typeof DrawerPrimitive.Overlay>
>(({ className, ...props }, ref) => (
<DrawerPrimitive.Overlay
ref={ref}
className={cn('fixed inset-0 z-50 bg-black/80', className)}
{...props}
/>
));
DrawerOverlay.displayName = DrawerPrimitive.Overlay.displayName;
const DrawerContent = React.forwardRef<
React.ElementRef<typeof DrawerPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof DrawerPrimitive.Content>
>(({ className, children, ...props }, ref) => (
<DrawerPortal>
<DrawerOverlay />
<DrawerPrimitive.Content
ref={ref}
className={cn(
'fixed inset-x-0 bottom-0 z-50 mt-24 flex h-auto flex-col rounded-t-[10px] border bg-background',
className
)}
{...props}
>
<div className="mx-auto mt-4 h-2 w-[100px] rounded-full bg-muted" />
{children}
</DrawerPrimitive.Content>
</DrawerPortal>
));
DrawerContent.displayName = 'DrawerContent';
const DrawerHeader = ({
className,
...props
}: React.HTMLAttributes<HTMLDivElement>) => (
<div
className={cn('grid gap-1.5 p-4 text-center sm:text-left', className)}
{...props}
/>
);
DrawerHeader.displayName = 'DrawerHeader';
const DrawerFooter = ({
className,
...props
}: React.HTMLAttributes<HTMLDivElement>) => (
<div
className={cn('mt-auto flex flex-col gap-2 p-4', className)}
{...props}
/>
);
DrawerFooter.displayName = 'DrawerFooter';
const DrawerTitle = React.forwardRef<
React.ElementRef<typeof DrawerPrimitive.Title>,
React.ComponentPropsWithoutRef<typeof DrawerPrimitive.Title>
>(({ className, ...props }, ref) => (
<DrawerPrimitive.Title
ref={ref}
className={cn(
'text-lg font-semibold leading-none tracking-tight',
className
)}
{...props}
/>
));
DrawerTitle.displayName = DrawerPrimitive.Title.displayName;
const DrawerDescription = React.forwardRef<
React.ElementRef<typeof DrawerPrimitive.Description>,
React.ComponentPropsWithoutRef<typeof DrawerPrimitive.Description>
>(({ className, ...props }, ref) => (
<DrawerPrimitive.Description
ref={ref}
className={cn('text-sm text-muted-foreground', className)}
{...props}
/>
));
DrawerDescription.displayName = DrawerPrimitive.Description.displayName;
export {
Drawer,
DrawerPortal,
DrawerOverlay,
DrawerTrigger,
DrawerClose,
DrawerContent,
DrawerHeader,
DrawerFooter,
DrawerTitle,
DrawerDescription,
};
+200
View File
@@ -0,0 +1,200 @@
'use client';
import * as React from 'react';
import * as DropdownMenuPrimitive from '@radix-ui/react-dropdown-menu';
import { Check, ChevronRight, Circle } from 'lucide-react';
import { cn } from '@/lib/utils';
const DropdownMenu = DropdownMenuPrimitive.Root;
const DropdownMenuTrigger = DropdownMenuPrimitive.Trigger;
const DropdownMenuGroup = DropdownMenuPrimitive.Group;
const DropdownMenuPortal = DropdownMenuPrimitive.Portal;
const DropdownMenuSub = DropdownMenuPrimitive.Sub;
const DropdownMenuRadioGroup = DropdownMenuPrimitive.RadioGroup;
const DropdownMenuSubTrigger = React.forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.SubTrigger>,
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.SubTrigger> & {
inset?: boolean;
}
>(({ className, inset, children, ...props }, ref) => (
<DropdownMenuPrimitive.SubTrigger
ref={ref}
className={cn(
'flex cursor-default select-none items-center rounded-sm px-2 py-1.5 text-sm outline-none focus:bg-accent data-[state=open]:bg-accent',
inset && 'pl-8',
className
)}
{...props}
>
{children}
<ChevronRight className="ml-auto h-4 w-4" />
</DropdownMenuPrimitive.SubTrigger>
));
DropdownMenuSubTrigger.displayName =
DropdownMenuPrimitive.SubTrigger.displayName;
const DropdownMenuSubContent = React.forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.SubContent>,
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.SubContent>
>(({ className, ...props }, ref) => (
<DropdownMenuPrimitive.SubContent
ref={ref}
className={cn(
'z-50 min-w-[8rem] overflow-hidden rounded-md border bg-popover p-1 text-popover-foreground shadow-lg data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2',
className
)}
{...props}
/>
));
DropdownMenuSubContent.displayName =
DropdownMenuPrimitive.SubContent.displayName;
const DropdownMenuContent = React.forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Content>
>(({ className, sideOffset = 4, ...props }, ref) => (
<DropdownMenuPrimitive.Portal>
<DropdownMenuPrimitive.Content
ref={ref}
sideOffset={sideOffset}
className={cn(
'z-50 min-w-[8rem] overflow-hidden rounded-md border bg-popover p-1 text-popover-foreground shadow-md data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2',
className
)}
{...props}
/>
</DropdownMenuPrimitive.Portal>
));
DropdownMenuContent.displayName = DropdownMenuPrimitive.Content.displayName;
const DropdownMenuItem = React.forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.Item>,
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Item> & {
inset?: boolean;
}
>(({ className, inset, ...props }, ref) => (
<DropdownMenuPrimitive.Item
ref={ref}
className={cn(
'relative flex cursor-default select-none items-center rounded-sm px-2 py-1.5 text-sm outline-none transition-colors focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50',
inset && 'pl-8',
className
)}
{...props}
/>
));
DropdownMenuItem.displayName = DropdownMenuPrimitive.Item.displayName;
const DropdownMenuCheckboxItem = React.forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.CheckboxItem>,
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.CheckboxItem>
>(({ className, children, checked, ...props }, ref) => (
<DropdownMenuPrimitive.CheckboxItem
ref={ref}
className={cn(
'relative flex cursor-default select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm outline-none transition-colors focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50',
className
)}
checked={checked}
{...props}
>
<span className="absolute left-2 flex h-3.5 w-3.5 items-center justify-center">
<DropdownMenuPrimitive.ItemIndicator>
<Check className="h-4 w-4" />
</DropdownMenuPrimitive.ItemIndicator>
</span>
{children}
</DropdownMenuPrimitive.CheckboxItem>
));
DropdownMenuCheckboxItem.displayName =
DropdownMenuPrimitive.CheckboxItem.displayName;
const DropdownMenuRadioItem = React.forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.RadioItem>,
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.RadioItem>
>(({ className, children, ...props }, ref) => (
<DropdownMenuPrimitive.RadioItem
ref={ref}
className={cn(
'relative flex cursor-default select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm outline-none transition-colors focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50',
className
)}
{...props}
>
<span className="absolute left-2 flex h-3.5 w-3.5 items-center justify-center">
<DropdownMenuPrimitive.ItemIndicator>
<Circle className="h-2 w-2 fill-current" />
</DropdownMenuPrimitive.ItemIndicator>
</span>
{children}
</DropdownMenuPrimitive.RadioItem>
));
DropdownMenuRadioItem.displayName = DropdownMenuPrimitive.RadioItem.displayName;
const DropdownMenuLabel = React.forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.Label>,
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Label> & {
inset?: boolean;
}
>(({ className, inset, ...props }, ref) => (
<DropdownMenuPrimitive.Label
ref={ref}
className={cn(
'px-2 py-1.5 text-sm font-semibold',
inset && 'pl-8',
className
)}
{...props}
/>
));
DropdownMenuLabel.displayName = DropdownMenuPrimitive.Label.displayName;
const DropdownMenuSeparator = React.forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.Separator>,
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Separator>
>(({ className, ...props }, ref) => (
<DropdownMenuPrimitive.Separator
ref={ref}
className={cn('-mx-1 my-1 h-px bg-muted', className)}
{...props}
/>
));
DropdownMenuSeparator.displayName = DropdownMenuPrimitive.Separator.displayName;
const DropdownMenuShortcut = ({
className,
...props
}: React.HTMLAttributes<HTMLSpanElement>) => {
return (
<span
className={cn('ml-auto text-xs tracking-widest opacity-60', className)}
{...props}
/>
);
};
DropdownMenuShortcut.displayName = 'DropdownMenuShortcut';
export {
DropdownMenu,
DropdownMenuTrigger,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuCheckboxItem,
DropdownMenuRadioItem,
DropdownMenuLabel,
DropdownMenuSeparator,
DropdownMenuShortcut,
DropdownMenuGroup,
DropdownMenuPortal,
DropdownMenuSub,
DropdownMenuSubContent,
DropdownMenuSubTrigger,
DropdownMenuRadioGroup,
};
+179
View File
@@ -0,0 +1,179 @@
'use client';
import * as React from 'react';
import * as LabelPrimitive from '@radix-ui/react-label';
import { Slot } from '@radix-ui/react-slot';
import {
Controller,
ControllerProps,
FieldPath,
FieldValues,
FormProvider,
useFormContext,
} from 'react-hook-form';
import { cn } from '@/lib/utils';
import { Label } from '@/components/ui/label';
const Form = FormProvider;
type FormFieldContextValue<
TFieldValues extends FieldValues = FieldValues,
TName extends FieldPath<TFieldValues> = FieldPath<TFieldValues>
> = {
name: TName;
};
const FormFieldContext = React.createContext<FormFieldContextValue>(
{} as FormFieldContextValue
);
const FormField = <
TFieldValues extends FieldValues = FieldValues,
TName extends FieldPath<TFieldValues> = FieldPath<TFieldValues>
>({
...props
}: ControllerProps<TFieldValues, TName>) => {
return (
<FormFieldContext.Provider value={{ name: props.name }}>
<Controller {...props} />
</FormFieldContext.Provider>
);
};
const useFormField = () => {
const fieldContext = React.useContext(FormFieldContext);
const itemContext = React.useContext(FormItemContext);
const { getFieldState, formState } = useFormContext();
const fieldState = getFieldState(fieldContext.name, formState);
if (!fieldContext) {
throw new Error('useFormField should be used within <FormField>');
}
const { id } = itemContext;
return {
id,
name: fieldContext.name,
formItemId: `${id}-form-item`,
formDescriptionId: `${id}-form-item-description`,
formMessageId: `${id}-form-item-message`,
...fieldState,
};
};
type FormItemContextValue = {
id: string;
};
const FormItemContext = React.createContext<FormItemContextValue>(
{} as FormItemContextValue
);
const FormItem = React.forwardRef<
HTMLDivElement,
React.HTMLAttributes<HTMLDivElement>
>(({ className, ...props }, ref) => {
const id = React.useId();
return (
<FormItemContext.Provider value={{ id }}>
<div ref={ref} className={cn('space-y-2', className)} {...props} />
</FormItemContext.Provider>
);
});
FormItem.displayName = 'FormItem';
const FormLabel = React.forwardRef<
React.ElementRef<typeof LabelPrimitive.Root>,
React.ComponentPropsWithoutRef<typeof LabelPrimitive.Root>
>(({ className, ...props }, ref) => {
const { error, formItemId } = useFormField();
return (
<Label
ref={ref}
className={cn(error && 'text-destructive', className)}
htmlFor={formItemId}
{...props}
/>
);
});
FormLabel.displayName = 'FormLabel';
const FormControl = React.forwardRef<
React.ElementRef<typeof Slot>,
React.ComponentPropsWithoutRef<typeof Slot>
>(({ ...props }, ref) => {
const { error, formItemId, formDescriptionId, formMessageId } =
useFormField();
return (
<Slot
ref={ref}
id={formItemId}
aria-describedby={
!error
? `${formDescriptionId}`
: `${formDescriptionId} ${formMessageId}`
}
aria-invalid={!!error}
{...props}
/>
);
});
FormControl.displayName = 'FormControl';
const FormDescription = React.forwardRef<
HTMLParagraphElement,
React.HTMLAttributes<HTMLParagraphElement>
>(({ className, ...props }, ref) => {
const { formDescriptionId } = useFormField();
return (
<p
ref={ref}
id={formDescriptionId}
className={cn('text-sm text-muted-foreground', className)}
{...props}
/>
);
});
FormDescription.displayName = 'FormDescription';
const FormMessage = React.forwardRef<
HTMLParagraphElement,
React.HTMLAttributes<HTMLParagraphElement>
>(({ className, children, ...props }, ref) => {
const { error, formMessageId } = useFormField();
const body = error ? String(error?.message) : children;
if (!body) {
return null;
}
return (
<p
ref={ref}
id={formMessageId}
className={cn('text-sm font-medium text-destructive', className)}
{...props}
>
{body}
</p>
);
});
FormMessage.displayName = 'FormMessage';
export {
useFormField,
Form,
FormItem,
FormLabel,
FormControl,
FormDescription,
FormMessage,
FormField,
};
+29
View File
@@ -0,0 +1,29 @@
'use client';
import * as React from 'react';
import * as HoverCardPrimitive from '@radix-ui/react-hover-card';
import { cn } from '@/lib/utils';
const HoverCard = HoverCardPrimitive.Root;
const HoverCardTrigger = HoverCardPrimitive.Trigger;
const HoverCardContent = React.forwardRef<
React.ElementRef<typeof HoverCardPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof HoverCardPrimitive.Content>
>(({ className, align = 'center', sideOffset = 4, ...props }, ref) => (
<HoverCardPrimitive.Content
ref={ref}
align={align}
sideOffset={sideOffset}
className={cn(
'z-50 w-64 rounded-md border bg-popover p-4 text-popover-foreground shadow-md outline-none data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2',
className
)}
{...props}
/>
));
HoverCardContent.displayName = HoverCardPrimitive.Content.displayName;
export { HoverCard, HoverCardTrigger, HoverCardContent };
+71
View File
@@ -0,0 +1,71 @@
'use client';
import * as React from 'react';
import { OTPInput, OTPInputContext } from 'input-otp';
import { Dot } from 'lucide-react';
import { cn } from '@/lib/utils';
const InputOTP = React.forwardRef<
React.ElementRef<typeof OTPInput>,
React.ComponentPropsWithoutRef<typeof OTPInput>
>(({ className, containerClassName, ...props }, ref) => (
<OTPInput
ref={ref}
containerClassName={cn(
'flex items-center gap-2 has-[:disabled]:opacity-50',
containerClassName
)}
className={cn('disabled:cursor-not-allowed', className)}
{...props}
/>
));
InputOTP.displayName = 'InputOTP';
const InputOTPGroup = React.forwardRef<
React.ElementRef<'div'>,
React.ComponentPropsWithoutRef<'div'>
>(({ className, ...props }, ref) => (
<div ref={ref} className={cn('flex items-center', className)} {...props} />
));
InputOTPGroup.displayName = 'InputOTPGroup';
const InputOTPSlot = React.forwardRef<
React.ElementRef<'div'>,
React.ComponentPropsWithoutRef<'div'> & { index: number }
>(({ index, className, ...props }, ref) => {
const inputOTPContext = React.useContext(OTPInputContext);
const { char, hasFakeCaret, isActive } = inputOTPContext.slots[index];
return (
<div
ref={ref}
className={cn(
'relative flex h-10 w-10 items-center justify-center border-y border-r border-input text-sm transition-all first:rounded-l-md first:border-l last:rounded-r-md',
isActive && 'z-10 ring-2 ring-ring ring-offset-background',
className
)}
{...props}
>
{char}
{hasFakeCaret && (
<div className="pointer-events-none absolute inset-0 flex items-center justify-center">
<div className="h-4 w-px animate-caret-blink bg-foreground duration-1000" />
</div>
)}
</div>
);
});
InputOTPSlot.displayName = 'InputOTPSlot';
const InputOTPSeparator = React.forwardRef<
React.ElementRef<'div'>,
React.ComponentPropsWithoutRef<'div'>
>(({ ...props }, ref) => (
<div ref={ref} role="separator" {...props}>
<Dot />
</div>
));
InputOTPSeparator.displayName = 'InputOTPSeparator';
export { InputOTP, InputOTPGroup, InputOTPSlot, InputOTPSeparator };
+47
View File
@@ -0,0 +1,47 @@
import * as React from "react"
import { cva, type VariantProps } from "class-variance-authority"
import { cn } from "@/lib/utils"
const inputVariants = cva(
"flex w-full rounded-lg border bg-background text-sm transition-all duration-fast file:border-0 file:bg-transparent file:text-sm file:font-medium placeholder:text-muted-foreground focus-visible:outline-none disabled:cursor-not-allowed disabled:opacity-50",
{
variants: {
variant: {
default: "border-input hover:border-ring/50 focus-visible:border-input focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background",
error: "border-destructive hover:border-destructive focus-visible:border-destructive focus-visible:ring-2 focus-visible:ring-destructive focus-visible:ring-offset-2 focus-visible:ring-offset-background",
success: "border-emerald-500 hover:border-emerald-500 focus-visible:border-emerald-500 focus-visible:ring-2 focus-visible:ring-emerald-500/50 focus-visible:ring-offset-2 focus-visible:ring-offset-background",
ghost: "border-transparent bg-muted/50 hover:bg-muted focus-visible:bg-background focus-visible:border-input focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background",
},
size: {
default: "h-10 px-3 py-2",
sm: "h-8 px-2.5 py-1 text-xs rounded-md",
lg: "h-12 px-4 py-3 text-base rounded-lg",
},
},
defaultVariants: {
variant: "default",
size: "default",
},
}
)
export interface InputProps
extends Omit<React.InputHTMLAttributes<HTMLInputElement>, "size">,
VariantProps<typeof inputVariants> {}
const Input = React.forwardRef<HTMLInputElement, InputProps>(
({ className, type, variant, size, ...props }, ref) => {
return (
<input
type={type}
className={cn(inputVariants({ variant, size, className }))}
ref={ref}
{...props}
/>
)
}
)
Input.displayName = "Input"
export { Input, inputVariants }
+26
View File
@@ -0,0 +1,26 @@
"use client"
import * as React from "react"
import * as LabelPrimitive from "@radix-ui/react-label"
import { cva, type VariantProps } from "class-variance-authority"
import { cn } from "@/lib/utils"
const labelVariants = cva(
"text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70"
)
const Label = React.forwardRef<
React.ElementRef<typeof LabelPrimitive.Root>,
React.ComponentPropsWithoutRef<typeof LabelPrimitive.Root> &
VariantProps<typeof labelVariants>
>(({ className, ...props }, ref) => (
<LabelPrimitive.Root
ref={ref}
className={cn(labelVariants(), className)}
{...props}
/>
))
Label.displayName = LabelPrimitive.Root.displayName
export { Label }
+236
View File
@@ -0,0 +1,236 @@
'use client';
import * as React from 'react';
import * as MenubarPrimitive from '@radix-ui/react-menubar';
import { Check, ChevronRight, Circle } from 'lucide-react';
import { cn } from '@/lib/utils';
const MenubarMenu = MenubarPrimitive.Menu;
const MenubarGroup = MenubarPrimitive.Group;
const MenubarPortal = MenubarPrimitive.Portal;
const MenubarSub = MenubarPrimitive.Sub;
const MenubarRadioGroup = MenubarPrimitive.RadioGroup;
const Menubar = React.forwardRef<
React.ElementRef<typeof MenubarPrimitive.Root>,
React.ComponentPropsWithoutRef<typeof MenubarPrimitive.Root>
>(({ className, ...props }, ref) => (
<MenubarPrimitive.Root
ref={ref}
className={cn(
'flex h-10 items-center space-x-1 rounded-md border bg-background p-1',
className
)}
{...props}
/>
));
Menubar.displayName = MenubarPrimitive.Root.displayName;
const MenubarTrigger = React.forwardRef<
React.ElementRef<typeof MenubarPrimitive.Trigger>,
React.ComponentPropsWithoutRef<typeof MenubarPrimitive.Trigger>
>(({ className, ...props }, ref) => (
<MenubarPrimitive.Trigger
ref={ref}
className={cn(
'flex cursor-default select-none items-center rounded-sm px-3 py-1.5 text-sm font-medium outline-none focus:bg-accent focus:text-accent-foreground data-[state=open]:bg-accent data-[state=open]:text-accent-foreground',
className
)}
{...props}
/>
));
MenubarTrigger.displayName = MenubarPrimitive.Trigger.displayName;
const MenubarSubTrigger = React.forwardRef<
React.ElementRef<typeof MenubarPrimitive.SubTrigger>,
React.ComponentPropsWithoutRef<typeof MenubarPrimitive.SubTrigger> & {
inset?: boolean;
}
>(({ className, inset, children, ...props }, ref) => (
<MenubarPrimitive.SubTrigger
ref={ref}
className={cn(
'flex cursor-default select-none items-center rounded-sm px-2 py-1.5 text-sm outline-none focus:bg-accent focus:text-accent-foreground data-[state=open]:bg-accent data-[state=open]:text-accent-foreground',
inset && 'pl-8',
className
)}
{...props}
>
{children}
<ChevronRight className="ml-auto h-4 w-4" />
</MenubarPrimitive.SubTrigger>
));
MenubarSubTrigger.displayName = MenubarPrimitive.SubTrigger.displayName;
const MenubarSubContent = React.forwardRef<
React.ElementRef<typeof MenubarPrimitive.SubContent>,
React.ComponentPropsWithoutRef<typeof MenubarPrimitive.SubContent>
>(({ className, ...props }, ref) => (
<MenubarPrimitive.SubContent
ref={ref}
className={cn(
'z-50 min-w-[8rem] overflow-hidden rounded-md border bg-popover p-1 text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2',
className
)}
{...props}
/>
));
MenubarSubContent.displayName = MenubarPrimitive.SubContent.displayName;
const MenubarContent = React.forwardRef<
React.ElementRef<typeof MenubarPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof MenubarPrimitive.Content>
>(
(
{ className, align = 'start', alignOffset = -4, sideOffset = 8, ...props },
ref
) => (
<MenubarPrimitive.Portal>
<MenubarPrimitive.Content
ref={ref}
align={align}
alignOffset={alignOffset}
sideOffset={sideOffset}
className={cn(
'z-50 min-w-[12rem] overflow-hidden rounded-md border bg-popover p-1 text-popover-foreground shadow-md data-[state=open]:animate-in data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2',
className
)}
{...props}
/>
</MenubarPrimitive.Portal>
)
);
MenubarContent.displayName = MenubarPrimitive.Content.displayName;
const MenubarItem = React.forwardRef<
React.ElementRef<typeof MenubarPrimitive.Item>,
React.ComponentPropsWithoutRef<typeof MenubarPrimitive.Item> & {
inset?: boolean;
}
>(({ className, inset, ...props }, ref) => (
<MenubarPrimitive.Item
ref={ref}
className={cn(
'relative flex cursor-default select-none items-center rounded-sm px-2 py-1.5 text-sm outline-none focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50',
inset && 'pl-8',
className
)}
{...props}
/>
));
MenubarItem.displayName = MenubarPrimitive.Item.displayName;
const MenubarCheckboxItem = React.forwardRef<
React.ElementRef<typeof MenubarPrimitive.CheckboxItem>,
React.ComponentPropsWithoutRef<typeof MenubarPrimitive.CheckboxItem>
>(({ className, children, checked, ...props }, ref) => (
<MenubarPrimitive.CheckboxItem
ref={ref}
className={cn(
'relative flex cursor-default select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm outline-none focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50',
className
)}
checked={checked}
{...props}
>
<span className="absolute left-2 flex h-3.5 w-3.5 items-center justify-center">
<MenubarPrimitive.ItemIndicator>
<Check className="h-4 w-4" />
</MenubarPrimitive.ItemIndicator>
</span>
{children}
</MenubarPrimitive.CheckboxItem>
));
MenubarCheckboxItem.displayName = MenubarPrimitive.CheckboxItem.displayName;
const MenubarRadioItem = React.forwardRef<
React.ElementRef<typeof MenubarPrimitive.RadioItem>,
React.ComponentPropsWithoutRef<typeof MenubarPrimitive.RadioItem>
>(({ className, children, ...props }, ref) => (
<MenubarPrimitive.RadioItem
ref={ref}
className={cn(
'relative flex cursor-default select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm outline-none focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50',
className
)}
{...props}
>
<span className="absolute left-2 flex h-3.5 w-3.5 items-center justify-center">
<MenubarPrimitive.ItemIndicator>
<Circle className="h-2 w-2 fill-current" />
</MenubarPrimitive.ItemIndicator>
</span>
{children}
</MenubarPrimitive.RadioItem>
));
MenubarRadioItem.displayName = MenubarPrimitive.RadioItem.displayName;
const MenubarLabel = React.forwardRef<
React.ElementRef<typeof MenubarPrimitive.Label>,
React.ComponentPropsWithoutRef<typeof MenubarPrimitive.Label> & {
inset?: boolean;
}
>(({ className, inset, ...props }, ref) => (
<MenubarPrimitive.Label
ref={ref}
className={cn(
'px-2 py-1.5 text-sm font-semibold',
inset && 'pl-8',
className
)}
{...props}
/>
));
MenubarLabel.displayName = MenubarPrimitive.Label.displayName;
const MenubarSeparator = React.forwardRef<
React.ElementRef<typeof MenubarPrimitive.Separator>,
React.ComponentPropsWithoutRef<typeof MenubarPrimitive.Separator>
>(({ className, ...props }, ref) => (
<MenubarPrimitive.Separator
ref={ref}
className={cn('-mx-1 my-1 h-px bg-muted', className)}
{...props}
/>
));
MenubarSeparator.displayName = MenubarPrimitive.Separator.displayName;
const MenubarShortcut = ({
className,
...props
}: React.HTMLAttributes<HTMLSpanElement>) => {
return (
<span
className={cn(
'ml-auto text-xs tracking-widest text-muted-foreground',
className
)}
{...props}
/>
);
};
MenubarShortcut.displayname = 'MenubarShortcut';
export {
Menubar,
MenubarMenu,
MenubarTrigger,
MenubarContent,
MenubarItem,
MenubarSeparator,
MenubarLabel,
MenubarCheckboxItem,
MenubarRadioGroup,
MenubarRadioItem,
MenubarPortal,
MenubarSubContent,
MenubarSubTrigger,
MenubarGroup,
MenubarSub,
MenubarShortcut,
};
+128
View File
@@ -0,0 +1,128 @@
import * as React from 'react';
import * as NavigationMenuPrimitive from '@radix-ui/react-navigation-menu';
import { cva } from 'class-variance-authority';
import { ChevronDown } from 'lucide-react';
import { cn } from '@/lib/utils';
const NavigationMenu = React.forwardRef<
React.ElementRef<typeof NavigationMenuPrimitive.Root>,
React.ComponentPropsWithoutRef<typeof NavigationMenuPrimitive.Root>
>(({ className, children, ...props }, ref) => (
<NavigationMenuPrimitive.Root
ref={ref}
className={cn(
'relative z-10 flex max-w-max flex-1 items-center justify-center',
className
)}
{...props}
>
{children}
<NavigationMenuViewport />
</NavigationMenuPrimitive.Root>
));
NavigationMenu.displayName = NavigationMenuPrimitive.Root.displayName;
const NavigationMenuList = React.forwardRef<
React.ElementRef<typeof NavigationMenuPrimitive.List>,
React.ComponentPropsWithoutRef<typeof NavigationMenuPrimitive.List>
>(({ className, ...props }, ref) => (
<NavigationMenuPrimitive.List
ref={ref}
className={cn(
'group flex flex-1 list-none items-center justify-center space-x-1',
className
)}
{...props}
/>
));
NavigationMenuList.displayName = NavigationMenuPrimitive.List.displayName;
const NavigationMenuItem = NavigationMenuPrimitive.Item;
const navigationMenuTriggerStyle = cva(
'group inline-flex h-10 w-max items-center justify-center rounded-md bg-background px-4 py-2 text-sm font-medium transition-colors hover:bg-accent hover:text-accent-foreground focus:bg-accent focus:text-accent-foreground focus:outline-none disabled:pointer-events-none disabled:opacity-50 data-[active]:bg-accent/50 data-[state=open]:bg-accent/50'
);
const NavigationMenuTrigger = React.forwardRef<
React.ElementRef<typeof NavigationMenuPrimitive.Trigger>,
React.ComponentPropsWithoutRef<typeof NavigationMenuPrimitive.Trigger>
>(({ className, children, ...props }, ref) => (
<NavigationMenuPrimitive.Trigger
ref={ref}
className={cn(navigationMenuTriggerStyle(), 'group', className)}
{...props}
>
{children}{' '}
<ChevronDown
className="relative top-[1px] ml-1 h-3 w-3 transition duration-200 group-data-[state=open]:rotate-180"
aria-hidden="true"
/>
</NavigationMenuPrimitive.Trigger>
));
NavigationMenuTrigger.displayName = NavigationMenuPrimitive.Trigger.displayName;
const NavigationMenuContent = React.forwardRef<
React.ElementRef<typeof NavigationMenuPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof NavigationMenuPrimitive.Content>
>(({ className, ...props }, ref) => (
<NavigationMenuPrimitive.Content
ref={ref}
className={cn(
'left-0 top-0 w-full data-[motion^=from-]:animate-in data-[motion^=to-]:animate-out data-[motion^=from-]:fade-in data-[motion^=to-]:fade-out data-[motion=from-end]:slide-in-from-right-52 data-[motion=from-start]:slide-in-from-left-52 data-[motion=to-end]:slide-out-to-right-52 data-[motion=to-start]:slide-out-to-left-52 md:absolute md:w-auto ',
className
)}
{...props}
/>
));
NavigationMenuContent.displayName = NavigationMenuPrimitive.Content.displayName;
const NavigationMenuLink = NavigationMenuPrimitive.Link;
const NavigationMenuViewport = React.forwardRef<
React.ElementRef<typeof NavigationMenuPrimitive.Viewport>,
React.ComponentPropsWithoutRef<typeof NavigationMenuPrimitive.Viewport>
>(({ className, ...props }, ref) => (
<div className={cn('absolute left-0 top-full flex justify-center')}>
<NavigationMenuPrimitive.Viewport
className={cn(
'origin-top-center relative mt-1.5 h-[var(--radix-navigation-menu-viewport-height)] w-full overflow-hidden rounded-md border bg-popover text-popover-foreground shadow-lg data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-90 md:w-[var(--radix-navigation-menu-viewport-width)]',
className
)}
ref={ref}
{...props}
/>
</div>
));
NavigationMenuViewport.displayName =
NavigationMenuPrimitive.Viewport.displayName;
const NavigationMenuIndicator = React.forwardRef<
React.ElementRef<typeof NavigationMenuPrimitive.Indicator>,
React.ComponentPropsWithoutRef<typeof NavigationMenuPrimitive.Indicator>
>(({ className, ...props }, ref) => (
<NavigationMenuPrimitive.Indicator
ref={ref}
className={cn(
'top-full z-[1] flex h-1.5 items-end justify-center overflow-hidden data-[state=visible]:animate-in data-[state=hidden]:animate-out data-[state=hidden]:fade-out data-[state=visible]:fade-in',
className
)}
{...props}
>
<div className="relative top-[60%] h-2 w-2 rotate-45 rounded-tl-sm bg-border shadow-md" />
</NavigationMenuPrimitive.Indicator>
));
NavigationMenuIndicator.displayName =
NavigationMenuPrimitive.Indicator.displayName;
export {
navigationMenuTriggerStyle,
NavigationMenu,
NavigationMenuList,
NavigationMenuItem,
NavigationMenuContent,
NavigationMenuTrigger,
NavigationMenuLink,
NavigationMenuIndicator,
NavigationMenuViewport,
};
+117
View File
@@ -0,0 +1,117 @@
import * as React from 'react';
import { ChevronLeft, ChevronRight, MoreHorizontal } from 'lucide-react';
import { cn } from '@/lib/utils';
import { ButtonProps, buttonVariants } from '@/components/ui/button';
const Pagination = ({ className, ...props }: React.ComponentProps<'nav'>) => (
<nav
role="navigation"
aria-label="pagination"
className={cn('mx-auto flex w-full justify-center', className)}
{...props}
/>
);
Pagination.displayName = 'Pagination';
const PaginationContent = React.forwardRef<
HTMLUListElement,
React.ComponentProps<'ul'>
>(({ className, ...props }, ref) => (
<ul
ref={ref}
className={cn('flex flex-row items-center gap-1', className)}
{...props}
/>
));
PaginationContent.displayName = 'PaginationContent';
const PaginationItem = React.forwardRef<
HTMLLIElement,
React.ComponentProps<'li'>
>(({ className, ...props }, ref) => (
<li ref={ref} className={cn('', className)} {...props} />
));
PaginationItem.displayName = 'PaginationItem';
type PaginationLinkProps = {
isActive?: boolean;
} & Pick<ButtonProps, 'size'> &
React.ComponentProps<'a'>;
const PaginationLink = ({
className,
isActive,
size = 'icon',
...props
}: PaginationLinkProps) => (
<a
aria-current={isActive ? 'page' : undefined}
className={cn(
buttonVariants({
variant: isActive ? 'outline' : 'ghost',
size,
}),
className
)}
{...props}
/>
);
PaginationLink.displayName = 'PaginationLink';
const PaginationPrevious = ({
className,
...props
}: React.ComponentProps<typeof PaginationLink>) => (
<PaginationLink
aria-label="Go to previous page"
size="default"
className={cn('gap-1 pl-2.5', className)}
{...props}
>
<ChevronLeft className="h-4 w-4" />
<span>Previous</span>
</PaginationLink>
);
PaginationPrevious.displayName = 'PaginationPrevious';
const PaginationNext = ({
className,
...props
}: React.ComponentProps<typeof PaginationLink>) => (
<PaginationLink
aria-label="Go to next page"
size="default"
className={cn('gap-1 pr-2.5', className)}
{...props}
>
<span>Next</span>
<ChevronRight className="h-4 w-4" />
</PaginationLink>
);
PaginationNext.displayName = 'PaginationNext';
const PaginationEllipsis = ({
className,
...props
}: React.ComponentProps<'span'>) => (
<span
aria-hidden
className={cn('flex h-9 w-9 items-center justify-center', className)}
{...props}
>
<MoreHorizontal className="h-4 w-4" />
<span className="sr-only">More pages</span>
</span>
);
PaginationEllipsis.displayName = 'PaginationEllipsis';
export {
Pagination,
PaginationContent,
PaginationEllipsis,
PaginationItem,
PaginationLink,
PaginationNext,
PaginationPrevious,
};
+31
View File
@@ -0,0 +1,31 @@
'use client';
import * as React from 'react';
import * as PopoverPrimitive from '@radix-ui/react-popover';
import { cn } from '@/lib/utils';
const Popover = PopoverPrimitive.Root;
const PopoverTrigger = PopoverPrimitive.Trigger;
const PopoverContent = React.forwardRef<
React.ElementRef<typeof PopoverPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof PopoverPrimitive.Content>
>(({ className, align = 'center', sideOffset = 4, ...props }, ref) => (
<PopoverPrimitive.Portal>
<PopoverPrimitive.Content
ref={ref}
align={align}
sideOffset={sideOffset}
className={cn(
'z-50 w-72 rounded-md border bg-popover p-4 text-popover-foreground shadow-md outline-none data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2',
className
)}
{...props}
/>
</PopoverPrimitive.Portal>
));
PopoverContent.displayName = PopoverPrimitive.Content.displayName;
export { Popover, PopoverTrigger, PopoverContent };
+28
View File
@@ -0,0 +1,28 @@
'use client';
import * as React from 'react';
import * as ProgressPrimitive from '@radix-ui/react-progress';
import { cn } from '@/lib/utils';
const Progress = React.forwardRef<
React.ElementRef<typeof ProgressPrimitive.Root>,
React.ComponentPropsWithoutRef<typeof ProgressPrimitive.Root>
>(({ className, value, ...props }, ref) => (
<ProgressPrimitive.Root
ref={ref}
className={cn(
'relative h-4 w-full overflow-hidden rounded-full bg-secondary',
className
)}
{...props}
>
<ProgressPrimitive.Indicator
className="h-full w-full flex-1 bg-primary transition-all"
style={{ transform: `translateX(-${100 - (value || 0)}%)` }}
/>
</ProgressPrimitive.Root>
));
Progress.displayName = ProgressPrimitive.Root.displayName;
export { Progress };
+44
View File
@@ -0,0 +1,44 @@
'use client';
import * as React from 'react';
import * as RadioGroupPrimitive from '@radix-ui/react-radio-group';
import { Circle } from 'lucide-react';
import { cn } from '@/lib/utils';
const RadioGroup = React.forwardRef<
React.ElementRef<typeof RadioGroupPrimitive.Root>,
React.ComponentPropsWithoutRef<typeof RadioGroupPrimitive.Root>
>(({ className, ...props }, ref) => {
return (
<RadioGroupPrimitive.Root
className={cn('grid gap-2', className)}
{...props}
ref={ref}
/>
);
});
RadioGroup.displayName = RadioGroupPrimitive.Root.displayName;
const RadioGroupItem = React.forwardRef<
React.ElementRef<typeof RadioGroupPrimitive.Item>,
React.ComponentPropsWithoutRef<typeof RadioGroupPrimitive.Item>
>(({ className, ...props }, ref) => {
return (
<RadioGroupPrimitive.Item
ref={ref}
className={cn(
'aspect-square h-4 w-4 rounded-full border border-primary text-primary ring-offset-background focus:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50',
className
)}
{...props}
>
<RadioGroupPrimitive.Indicator className="flex items-center justify-center">
<Circle className="h-2.5 w-2.5 fill-current text-current" />
</RadioGroupPrimitive.Indicator>
</RadioGroupPrimitive.Item>
);
});
RadioGroupItem.displayName = RadioGroupPrimitive.Item.displayName;
export { RadioGroup, RadioGroupItem };
+45
View File
@@ -0,0 +1,45 @@
'use client';
import { GripVertical } from 'lucide-react';
import * as ResizablePrimitive from 'react-resizable-panels';
import { cn } from '@/lib/utils';
const ResizablePanelGroup = ({
className,
...props
}: React.ComponentProps<typeof ResizablePrimitive.PanelGroup>) => (
<ResizablePrimitive.PanelGroup
className={cn(
'flex h-full w-full data-[panel-group-direction=vertical]:flex-col',
className
)}
{...props}
/>
);
const ResizablePanel = ResizablePrimitive.Panel;
const ResizableHandle = ({
withHandle,
className,
...props
}: React.ComponentProps<typeof ResizablePrimitive.PanelResizeHandle> & {
withHandle?: boolean;
}) => (
<ResizablePrimitive.PanelResizeHandle
className={cn(
'relative flex w-px items-center justify-center bg-border after:absolute after:inset-y-0 after:left-1/2 after:w-1 after:-translate-x-1/2 focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring focus-visible:ring-offset-1 data-[panel-group-direction=vertical]:h-px data-[panel-group-direction=vertical]:w-full data-[panel-group-direction=vertical]:after:left-0 data-[panel-group-direction=vertical]:after:h-1 data-[panel-group-direction=vertical]:after:w-full data-[panel-group-direction=vertical]:after:-translate-y-1/2 data-[panel-group-direction=vertical]:after:translate-x-0 [&[data-panel-group-direction=vertical]>div]:rotate-90',
className
)}
{...props}
>
{withHandle && (
<div className="z-10 flex h-4 w-3 items-center justify-center rounded-sm border bg-border">
<GripVertical className="h-2.5 w-2.5" />
</div>
)}
</ResizablePrimitive.PanelResizeHandle>
);
export { ResizablePanelGroup, ResizablePanel, ResizableHandle };
+48
View File
@@ -0,0 +1,48 @@
'use client';
import * as React from 'react';
import * as ScrollAreaPrimitive from '@radix-ui/react-scroll-area';
import { cn } from '@/lib/utils';
const ScrollArea = React.forwardRef<
React.ElementRef<typeof ScrollAreaPrimitive.Root>,
React.ComponentPropsWithoutRef<typeof ScrollAreaPrimitive.Root>
>(({ className, children, ...props }, ref) => (
<ScrollAreaPrimitive.Root
ref={ref}
className={cn('relative overflow-hidden', className)}
{...props}
>
<ScrollAreaPrimitive.Viewport className="h-full w-full rounded-[inherit]">
{children}
</ScrollAreaPrimitive.Viewport>
<ScrollBar />
<ScrollAreaPrimitive.Corner />
</ScrollAreaPrimitive.Root>
));
ScrollArea.displayName = ScrollAreaPrimitive.Root.displayName;
const ScrollBar = React.forwardRef<
React.ElementRef<typeof ScrollAreaPrimitive.ScrollAreaScrollbar>,
React.ComponentPropsWithoutRef<typeof ScrollAreaPrimitive.ScrollAreaScrollbar>
>(({ className, orientation = 'vertical', ...props }, ref) => (
<ScrollAreaPrimitive.ScrollAreaScrollbar
ref={ref}
orientation={orientation}
className={cn(
'flex touch-none select-none transition-colors',
orientation === 'vertical' &&
'h-full w-2.5 border-l border-l-transparent p-[1px]',
orientation === 'horizontal' &&
'h-2.5 flex-col border-t border-t-transparent p-[1px]',
className
)}
{...props}
>
<ScrollAreaPrimitive.ScrollAreaThumb className="relative flex-1 rounded-full bg-border" />
</ScrollAreaPrimitive.ScrollAreaScrollbar>
));
ScrollBar.displayName = ScrollAreaPrimitive.ScrollAreaScrollbar.displayName;
export { ScrollArea, ScrollBar };
+160
View File
@@ -0,0 +1,160 @@
'use client';
import * as React from 'react';
import * as SelectPrimitive from '@radix-ui/react-select';
import { Check, ChevronDown, ChevronUp } from 'lucide-react';
import { cn } from '@/lib/utils';
const Select = SelectPrimitive.Root;
const SelectGroup = SelectPrimitive.Group;
const SelectValue = SelectPrimitive.Value;
const SelectTrigger = React.forwardRef<
React.ElementRef<typeof SelectPrimitive.Trigger>,
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Trigger>
>(({ className, children, ...props }, ref) => (
<SelectPrimitive.Trigger
ref={ref}
className={cn(
'flex h-10 w-full items-center justify-between rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background placeholder:text-muted-foreground focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50 [&>span]:line-clamp-1',
className
)}
{...props}
>
{children}
<SelectPrimitive.Icon asChild>
<ChevronDown className="h-4 w-4 opacity-50" />
</SelectPrimitive.Icon>
</SelectPrimitive.Trigger>
));
SelectTrigger.displayName = SelectPrimitive.Trigger.displayName;
const SelectScrollUpButton = React.forwardRef<
React.ElementRef<typeof SelectPrimitive.ScrollUpButton>,
React.ComponentPropsWithoutRef<typeof SelectPrimitive.ScrollUpButton>
>(({ className, ...props }, ref) => (
<SelectPrimitive.ScrollUpButton
ref={ref}
className={cn(
'flex cursor-default items-center justify-center py-1',
className
)}
{...props}
>
<ChevronUp className="h-4 w-4" />
</SelectPrimitive.ScrollUpButton>
));
SelectScrollUpButton.displayName = SelectPrimitive.ScrollUpButton.displayName;
const SelectScrollDownButton = React.forwardRef<
React.ElementRef<typeof SelectPrimitive.ScrollDownButton>,
React.ComponentPropsWithoutRef<typeof SelectPrimitive.ScrollDownButton>
>(({ className, ...props }, ref) => (
<SelectPrimitive.ScrollDownButton
ref={ref}
className={cn(
'flex cursor-default items-center justify-center py-1',
className
)}
{...props}
>
<ChevronDown className="h-4 w-4" />
</SelectPrimitive.ScrollDownButton>
));
SelectScrollDownButton.displayName =
SelectPrimitive.ScrollDownButton.displayName;
const SelectContent = React.forwardRef<
React.ElementRef<typeof SelectPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Content>
>(({ className, children, position = 'popper', ...props }, ref) => (
<SelectPrimitive.Portal>
<SelectPrimitive.Content
ref={ref}
className={cn(
'relative z-50 max-h-96 min-w-[8rem] overflow-hidden rounded-md border bg-popover text-popover-foreground shadow-md data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2',
position === 'popper' &&
'data-[side=bottom]:translate-y-1 data-[side=left]:-translate-x-1 data-[side=right]:translate-x-1 data-[side=top]:-translate-y-1',
className
)}
position={position}
{...props}
>
<SelectScrollUpButton />
<SelectPrimitive.Viewport
className={cn(
'p-1',
position === 'popper' &&
'h-[var(--radix-select-trigger-height)] w-full min-w-[var(--radix-select-trigger-width)]'
)}
>
{children}
</SelectPrimitive.Viewport>
<SelectScrollDownButton />
</SelectPrimitive.Content>
</SelectPrimitive.Portal>
));
SelectContent.displayName = SelectPrimitive.Content.displayName;
const SelectLabel = React.forwardRef<
React.ElementRef<typeof SelectPrimitive.Label>,
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Label>
>(({ className, ...props }, ref) => (
<SelectPrimitive.Label
ref={ref}
className={cn('py-1.5 pl-8 pr-2 text-sm font-semibold', className)}
{...props}
/>
));
SelectLabel.displayName = SelectPrimitive.Label.displayName;
const SelectItem = React.forwardRef<
React.ElementRef<typeof SelectPrimitive.Item>,
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Item>
>(({ className, children, ...props }, ref) => (
<SelectPrimitive.Item
ref={ref}
className={cn(
'relative flex w-full cursor-default select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm outline-none focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50',
className
)}
{...props}
>
<span className="absolute left-2 flex h-3.5 w-3.5 items-center justify-center">
<SelectPrimitive.ItemIndicator>
<Check className="h-4 w-4" />
</SelectPrimitive.ItemIndicator>
</span>
<SelectPrimitive.ItemText>{children}</SelectPrimitive.ItemText>
</SelectPrimitive.Item>
));
SelectItem.displayName = SelectPrimitive.Item.displayName;
const SelectSeparator = React.forwardRef<
React.ElementRef<typeof SelectPrimitive.Separator>,
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Separator>
>(({ className, ...props }, ref) => (
<SelectPrimitive.Separator
ref={ref}
className={cn('-mx-1 my-1 h-px bg-muted', className)}
{...props}
/>
));
SelectSeparator.displayName = SelectPrimitive.Separator.displayName;
export {
Select,
SelectGroup,
SelectValue,
SelectTrigger,
SelectContent,
SelectLabel,
SelectItem,
SelectSeparator,
SelectScrollUpButton,
SelectScrollDownButton,
};
+31
View File
@@ -0,0 +1,31 @@
'use client';
import * as React from 'react';
import * as SeparatorPrimitive from '@radix-ui/react-separator';
import { cn } from '@/lib/utils';
const Separator = React.forwardRef<
React.ElementRef<typeof SeparatorPrimitive.Root>,
React.ComponentPropsWithoutRef<typeof SeparatorPrimitive.Root>
>(
(
{ className, orientation = 'horizontal', decorative = true, ...props },
ref
) => (
<SeparatorPrimitive.Root
ref={ref}
decorative={decorative}
orientation={orientation}
className={cn(
'shrink-0 bg-border',
orientation === 'horizontal' ? 'h-[1px] w-full' : 'h-full w-[1px]',
className
)}
{...props}
/>
)
);
Separator.displayName = SeparatorPrimitive.Root.displayName;
export { Separator };
+140
View File
@@ -0,0 +1,140 @@
'use client';
import * as React from 'react';
import * as SheetPrimitive from '@radix-ui/react-dialog';
import { cva, type VariantProps } from 'class-variance-authority';
import { X } from 'lucide-react';
import { cn } from '@/lib/utils';
const Sheet = SheetPrimitive.Root;
const SheetTrigger = SheetPrimitive.Trigger;
const SheetClose = SheetPrimitive.Close;
const SheetPortal = SheetPrimitive.Portal;
const SheetOverlay = React.forwardRef<
React.ElementRef<typeof SheetPrimitive.Overlay>,
React.ComponentPropsWithoutRef<typeof SheetPrimitive.Overlay>
>(({ className, ...props }, ref) => (
<SheetPrimitive.Overlay
className={cn(
'fixed inset-0 z-50 bg-black/80 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0',
className
)}
{...props}
ref={ref}
/>
));
SheetOverlay.displayName = SheetPrimitive.Overlay.displayName;
const sheetVariants = cva(
'fixed z-50 gap-4 bg-background p-6 shadow-lg transition ease-in-out data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:duration-300 data-[state=open]:duration-500',
{
variants: {
side: {
top: 'inset-x-0 top-0 border-b data-[state=closed]:slide-out-to-top data-[state=open]:slide-in-from-top',
bottom:
'inset-x-0 bottom-0 border-t data-[state=closed]:slide-out-to-bottom data-[state=open]:slide-in-from-bottom',
left: 'inset-y-0 left-0 h-full w-3/4 border-r data-[state=closed]:slide-out-to-left data-[state=open]:slide-in-from-left sm:max-w-sm',
right:
'inset-y-0 right-0 h-full w-3/4 border-l data-[state=closed]:slide-out-to-right data-[state=open]:slide-in-from-right sm:max-w-sm',
},
},
defaultVariants: {
side: 'right',
},
}
);
interface SheetContentProps
extends React.ComponentPropsWithoutRef<typeof SheetPrimitive.Content>,
VariantProps<typeof sheetVariants> {}
const SheetContent = React.forwardRef<
React.ElementRef<typeof SheetPrimitive.Content>,
SheetContentProps
>(({ side = 'right', className, children, ...props }, ref) => (
<SheetPortal>
<SheetOverlay />
<SheetPrimitive.Content
ref={ref}
className={cn(sheetVariants({ side }), className)}
{...props}
>
{children}
<SheetPrimitive.Close className="absolute right-4 top-4 rounded-sm opacity-70 ring-offset-background transition-opacity hover:opacity-100 focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:pointer-events-none data-[state=open]:bg-secondary">
<X className="h-4 w-4" />
<span className="sr-only">Close</span>
</SheetPrimitive.Close>
</SheetPrimitive.Content>
</SheetPortal>
));
SheetContent.displayName = SheetPrimitive.Content.displayName;
const SheetHeader = ({
className,
...props
}: React.HTMLAttributes<HTMLDivElement>) => (
<div
className={cn(
'flex flex-col space-y-2 text-center sm:text-left',
className
)}
{...props}
/>
);
SheetHeader.displayName = 'SheetHeader';
const SheetFooter = ({
className,
...props
}: React.HTMLAttributes<HTMLDivElement>) => (
<div
className={cn(
'flex flex-col-reverse sm:flex-row sm:justify-end sm:space-x-2',
className
)}
{...props}
/>
);
SheetFooter.displayName = 'SheetFooter';
const SheetTitle = React.forwardRef<
React.ElementRef<typeof SheetPrimitive.Title>,
React.ComponentPropsWithoutRef<typeof SheetPrimitive.Title>
>(({ className, ...props }, ref) => (
<SheetPrimitive.Title
ref={ref}
className={cn('text-lg font-semibold text-foreground', className)}
{...props}
/>
));
SheetTitle.displayName = SheetPrimitive.Title.displayName;
const SheetDescription = React.forwardRef<
React.ElementRef<typeof SheetPrimitive.Description>,
React.ComponentPropsWithoutRef<typeof SheetPrimitive.Description>
>(({ className, ...props }, ref) => (
<SheetPrimitive.Description
ref={ref}
className={cn('text-sm text-muted-foreground', className)}
{...props}
/>
));
SheetDescription.displayName = SheetPrimitive.Description.displayName;
export {
Sheet,
SheetPortal,
SheetOverlay,
SheetTrigger,
SheetClose,
SheetContent,
SheetHeader,
SheetFooter,
SheetTitle,
SheetDescription,
};
+15
View File
@@ -0,0 +1,15 @@
import { cn } from '@/lib/utils';
function Skeleton({
className,
...props
}: React.HTMLAttributes<HTMLDivElement>) {
return (
<div
className={cn('animate-pulse rounded-md bg-muted', className)}
{...props}
/>
);
}
export { Skeleton };
+28
View File
@@ -0,0 +1,28 @@
'use client';
import * as React from 'react';
import * as SliderPrimitive from '@radix-ui/react-slider';
import { cn } from '@/lib/utils';
const Slider = React.forwardRef<
React.ElementRef<typeof SliderPrimitive.Root>,
React.ComponentPropsWithoutRef<typeof SliderPrimitive.Root>
>(({ className, ...props }, ref) => (
<SliderPrimitive.Root
ref={ref}
className={cn(
'relative flex w-full touch-none select-none items-center',
className
)}
{...props}
>
<SliderPrimitive.Track className="relative h-2 w-full grow overflow-hidden rounded-full bg-secondary">
<SliderPrimitive.Range className="absolute h-full bg-primary" />
</SliderPrimitive.Track>
<SliderPrimitive.Thumb className="block h-5 w-5 rounded-full border-2 border-primary bg-background ring-offset-background transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50" />
</SliderPrimitive.Root>
));
Slider.displayName = SliderPrimitive.Root.displayName;
export { Slider };
+31
View File
@@ -0,0 +1,31 @@
'use client';
import { useTheme } from 'next-themes';
import { Toaster as Sonner } from 'sonner';
type ToasterProps = React.ComponentProps<typeof Sonner>;
const Toaster = ({ ...props }: ToasterProps) => {
const { theme = 'system' } = useTheme();
return (
<Sonner
theme={theme as ToasterProps['theme']}
className="toaster group"
toastOptions={{
classNames: {
toast:
'group toast group-[.toaster]:bg-background group-[.toaster]:text-foreground group-[.toaster]:border-border group-[.toaster]:shadow-lg',
description: 'group-[.toast]:text-muted-foreground',
actionButton:
'group-[.toast]:bg-primary group-[.toast]:text-primary-foreground',
cancelButton:
'group-[.toast]:bg-muted group-[.toast]:text-muted-foreground',
},
}}
{...props}
/>
);
};
export { Toaster };
+29
View File
@@ -0,0 +1,29 @@
'use client';
import * as React from 'react';
import * as SwitchPrimitives from '@radix-ui/react-switch';
import { cn } from '@/lib/utils';
const Switch = React.forwardRef<
React.ElementRef<typeof SwitchPrimitives.Root>,
React.ComponentPropsWithoutRef<typeof SwitchPrimitives.Root>
>(({ className, ...props }, ref) => (
<SwitchPrimitives.Root
className={cn(
'peer inline-flex h-6 w-11 shrink-0 cursor-pointer items-center rounded-full border-2 border-transparent transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background disabled:cursor-not-allowed disabled:opacity-50 data-[state=checked]:bg-primary data-[state=unchecked]:bg-input',
className
)}
{...props}
ref={ref}
>
<SwitchPrimitives.Thumb
className={cn(
'pointer-events-none block h-5 w-5 rounded-full bg-background shadow-lg ring-0 transition-transform data-[state=checked]:translate-x-5 data-[state=unchecked]:translate-x-0'
)}
/>
</SwitchPrimitives.Root>
));
Switch.displayName = SwitchPrimitives.Root.displayName;
export { Switch };
+117
View File
@@ -0,0 +1,117 @@
import * as React from 'react';
import { cn } from '@/lib/utils';
const Table = React.forwardRef<
HTMLTableElement,
React.HTMLAttributes<HTMLTableElement>
>(({ className, ...props }, ref) => (
<div className="relative w-full overflow-auto">
<table
ref={ref}
className={cn('w-full caption-bottom text-sm', className)}
{...props}
/>
</div>
));
Table.displayName = 'Table';
const TableHeader = React.forwardRef<
HTMLTableSectionElement,
React.HTMLAttributes<HTMLTableSectionElement>
>(({ className, ...props }, ref) => (
<thead ref={ref} className={cn('[&_tr]:border-b', className)} {...props} />
));
TableHeader.displayName = 'TableHeader';
const TableBody = React.forwardRef<
HTMLTableSectionElement,
React.HTMLAttributes<HTMLTableSectionElement>
>(({ className, ...props }, ref) => (
<tbody
ref={ref}
className={cn('[&_tr:last-child]:border-0', className)}
{...props}
/>
));
TableBody.displayName = 'TableBody';
const TableFooter = React.forwardRef<
HTMLTableSectionElement,
React.HTMLAttributes<HTMLTableSectionElement>
>(({ className, ...props }, ref) => (
<tfoot
ref={ref}
className={cn(
'border-t bg-muted/50 font-medium [&>tr]:last:border-b-0',
className
)}
{...props}
/>
));
TableFooter.displayName = 'TableFooter';
const TableRow = React.forwardRef<
HTMLTableRowElement,
React.HTMLAttributes<HTMLTableRowElement>
>(({ className, ...props }, ref) => (
<tr
ref={ref}
className={cn(
'border-b transition-colors hover:bg-muted/50 data-[state=selected]:bg-muted',
className
)}
{...props}
/>
));
TableRow.displayName = 'TableRow';
const TableHead = React.forwardRef<
HTMLTableCellElement,
React.ThHTMLAttributes<HTMLTableCellElement>
>(({ className, ...props }, ref) => (
<th
ref={ref}
className={cn(
'h-12 px-4 text-left align-middle font-medium text-muted-foreground [&:has([role=checkbox])]:pr-0',
className
)}
{...props}
/>
));
TableHead.displayName = 'TableHead';
const TableCell = React.forwardRef<
HTMLTableCellElement,
React.TdHTMLAttributes<HTMLTableCellElement>
>(({ className, ...props }, ref) => (
<td
ref={ref}
className={cn('p-4 align-middle [&:has([role=checkbox])]:pr-0', className)}
{...props}
/>
));
TableCell.displayName = 'TableCell';
const TableCaption = React.forwardRef<
HTMLTableCaptionElement,
React.HTMLAttributes<HTMLTableCaptionElement>
>(({ className, ...props }, ref) => (
<caption
ref={ref}
className={cn('mt-4 text-sm text-muted-foreground', className)}
{...props}
/>
));
TableCaption.displayName = 'TableCaption';
export {
Table,
TableHeader,
TableBody,
TableFooter,
TableHead,
TableRow,
TableCell,
TableCaption,
};
+55
View File
@@ -0,0 +1,55 @@
'use client';
import * as React from 'react';
import * as TabsPrimitive from '@radix-ui/react-tabs';
import { cn } from '@/lib/utils';
const Tabs = TabsPrimitive.Root;
const TabsList = React.forwardRef<
React.ElementRef<typeof TabsPrimitive.List>,
React.ComponentPropsWithoutRef<typeof TabsPrimitive.List>
>(({ className, ...props }, ref) => (
<TabsPrimitive.List
ref={ref}
className={cn(
'inline-flex h-10 items-center justify-center rounded-md bg-muted p-1 text-muted-foreground',
className
)}
{...props}
/>
));
TabsList.displayName = TabsPrimitive.List.displayName;
const TabsTrigger = React.forwardRef<
React.ElementRef<typeof TabsPrimitive.Trigger>,
React.ComponentPropsWithoutRef<typeof TabsPrimitive.Trigger>
>(({ className, ...props }, ref) => (
<TabsPrimitive.Trigger
ref={ref}
className={cn(
'inline-flex items-center justify-center whitespace-nowrap rounded-sm px-3 py-1.5 text-sm font-medium ring-offset-background transition-all focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50 data-[state=active]:bg-background data-[state=active]:text-foreground data-[state=active]:shadow-sm',
className
)}
{...props}
/>
));
TabsTrigger.displayName = TabsPrimitive.Trigger.displayName;
const TabsContent = React.forwardRef<
React.ElementRef<typeof TabsPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof TabsPrimitive.Content>
>(({ className, ...props }, ref) => (
<TabsPrimitive.Content
ref={ref}
className={cn(
'mt-2 ring-offset-background focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2',
className
)}
{...props}
/>
));
TabsContent.displayName = TabsPrimitive.Content.displayName;
export { Tabs, TabsList, TabsTrigger, TabsContent };
+66
View File
@@ -0,0 +1,66 @@
import { useState } from 'react'
import { Card, CardContent, CardHeader, CardTitle } from './card'
import { Badge } from './badge'
import { Checkbox } from './checkbox'
import { Button } from './button'
import { Pencil, Trash2 } from 'lucide-react'
import { motion } from 'framer-motion'
interface TaskCardProps {
id: string
title: string
description?: string
category: string
completed: boolean
onComplete: (id: string, completed: boolean) => void
onDelete: (id: string) => void
onEdit: (id: string) => void
}
export function TaskCard({
id,
title,
description,
category,
completed,
onComplete,
onDelete,
onEdit,
}: TaskCardProps) {
return (
<motion.div
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0, y: -20 }}
transition={{ duration: 0.2 }}
>
<Card className={`w-full ${completed ? 'opacity-60' : ''}`}>
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
<div className="flex items-center space-x-2">
<Checkbox
checked={completed}
onCheckedChange={(checked) => onComplete(id, checked as boolean)}
/>
<CardTitle className={`text-lg ${completed ? 'line-through' : ''}`}>
{title}
</CardTitle>
</div>
<div className="flex space-x-2">
<Button variant="ghost" size="icon" onClick={() => onEdit(id)}>
<Pencil className="h-4 w-4" />
</Button>
<Button variant="ghost" size="icon" onClick={() => onDelete(id)}>
<Trash2 className="h-4 w-4" />
</Button>
</div>
</CardHeader>
<CardContent>
{description && <p className="text-sm text-muted-foreground">{description}</p>}
<Badge variant="secondary" className="mt-2">
{category}
</Badge>
</CardContent>
</Card>
</motion.div>
)
}
+40
View File
@@ -0,0 +1,40 @@
import * as React from 'react';
import { cva, type VariantProps } from 'class-variance-authority';
import { cn } from '@/lib/utils';
const textareaVariants = cva(
'flex min-h-[80px] w-full rounded-lg border bg-background px-3 py-2 text-sm transition-all duration-fast placeholder:text-muted-foreground focus-visible:outline-none disabled:cursor-not-allowed disabled:opacity-50',
{
variants: {
variant: {
default: 'border-input hover:border-ring/50 focus-visible:border-input focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background',
error: 'border-destructive hover:border-destructive focus-visible:border-destructive focus-visible:ring-2 focus-visible:ring-destructive focus-visible:ring-offset-2 focus-visible:ring-offset-background',
success: 'border-emerald-500 hover:border-emerald-500 focus-visible:border-emerald-500 focus-visible:ring-2 focus-visible:ring-emerald-500/50 focus-visible:ring-offset-2 focus-visible:ring-offset-background',
ghost: 'border-transparent bg-muted/50 hover:bg-muted focus-visible:bg-background focus-visible:border-input focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background',
},
},
defaultVariants: {
variant: 'default',
},
}
);
export interface TextareaProps
extends React.TextareaHTMLAttributes<HTMLTextAreaElement>,
VariantProps<typeof textareaVariants> {}
const Textarea = React.forwardRef<HTMLTextAreaElement, TextareaProps>(
({ className, variant, ...props }, ref) => {
return (
<textarea
className={cn(textareaVariants({ variant, className }))}
ref={ref}
{...props}
/>
);
}
);
Textarea.displayName = 'Textarea';
export { Textarea, textareaVariants };
+61
View File
@@ -0,0 +1,61 @@
'use client';
import * as React from 'react';
import * as ToggleGroupPrimitive from '@radix-ui/react-toggle-group';
import { type VariantProps } from 'class-variance-authority';
import { cn } from '@/lib/utils';
import { toggleVariants } from '@/components/ui/toggle';
const ToggleGroupContext = React.createContext<
VariantProps<typeof toggleVariants>
>({
size: 'default',
variant: 'default',
});
const ToggleGroup = React.forwardRef<
React.ElementRef<typeof ToggleGroupPrimitive.Root>,
React.ComponentPropsWithoutRef<typeof ToggleGroupPrimitive.Root> &
VariantProps<typeof toggleVariants>
>(({ className, variant, size, children, ...props }, ref) => (
<ToggleGroupPrimitive.Root
ref={ref}
className={cn('flex items-center justify-center gap-1', className)}
{...props}
>
<ToggleGroupContext.Provider value={{ variant, size }}>
{children}
</ToggleGroupContext.Provider>
</ToggleGroupPrimitive.Root>
));
ToggleGroup.displayName = ToggleGroupPrimitive.Root.displayName;
const ToggleGroupItem = React.forwardRef<
React.ElementRef<typeof ToggleGroupPrimitive.Item>,
React.ComponentPropsWithoutRef<typeof ToggleGroupPrimitive.Item> &
VariantProps<typeof toggleVariants>
>(({ className, children, variant, size, ...props }, ref) => {
const context = React.useContext(ToggleGroupContext);
return (
<ToggleGroupPrimitive.Item
ref={ref}
className={cn(
toggleVariants({
variant: context.variant || variant,
size: context.size || size,
}),
className
)}
{...props}
>
{children}
</ToggleGroupPrimitive.Item>
);
});
ToggleGroupItem.displayName = ToggleGroupPrimitive.Item.displayName;
export { ToggleGroup, ToggleGroupItem };
+45
View File
@@ -0,0 +1,45 @@
'use client';
import * as React from 'react';
import * as TogglePrimitive from '@radix-ui/react-toggle';
import { cva, type VariantProps } from 'class-variance-authority';
import { cn } from '@/lib/utils';
const toggleVariants = cva(
'inline-flex items-center justify-center rounded-md text-sm font-medium ring-offset-background transition-colors hover:bg-muted hover:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50 data-[state=on]:bg-accent data-[state=on]:text-accent-foreground',
{
variants: {
variant: {
default: 'bg-transparent',
outline:
'border border-input bg-transparent hover:bg-accent hover:text-accent-foreground',
},
size: {
default: 'h-10 px-3',
sm: 'h-9 px-2.5',
lg: 'h-11 px-5',
},
},
defaultVariants: {
variant: 'default',
size: 'default',
},
}
);
const Toggle = React.forwardRef<
React.ElementRef<typeof TogglePrimitive.Root>,
React.ComponentPropsWithoutRef<typeof TogglePrimitive.Root> &
VariantProps<typeof toggleVariants>
>(({ className, variant, size, ...props }, ref) => (
<TogglePrimitive.Root
ref={ref}
className={cn(toggleVariants({ variant, size, className }))}
{...props}
/>
));
Toggle.displayName = TogglePrimitive.Root.displayName;
export { Toggle, toggleVariants };
+30
View File
@@ -0,0 +1,30 @@
'use client';
import * as React from 'react';
import * as TooltipPrimitive from '@radix-ui/react-tooltip';
import { cn } from '@/lib/utils';
const TooltipProvider = TooltipPrimitive.Provider;
const Tooltip = TooltipPrimitive.Root;
const TooltipTrigger = TooltipPrimitive.Trigger;
const TooltipContent = React.forwardRef<
React.ElementRef<typeof TooltipPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof TooltipPrimitive.Content>
>(({ className, sideOffset = 4, ...props }, ref) => (
<TooltipPrimitive.Content
ref={ref}
sideOffset={sideOffset}
className={cn(
'z-50 overflow-hidden rounded-md border bg-popover px-3 py-1.5 text-sm text-popover-foreground shadow-md animate-in fade-in-0 zoom-in-95 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2',
className
)}
{...props}
/>
));
TooltipContent.displayName = TooltipPrimitive.Content.displayName;
export { Tooltip, TooltipTrigger, TooltipContent, TooltipProvider };