first commit
This commit is contained in:
commit
d416fe4cce
69 changed files with 8269 additions and 0 deletions
120
frontend/src/components/AuthPanel.tsx
Normal file
120
frontend/src/components/AuthPanel.tsx
Normal file
|
|
@ -0,0 +1,120 @@
|
|||
import { useState } from 'react'
|
||||
|
||||
import { useAuth } from '../context/AuthContext'
|
||||
import type { IdentifierType } from '../lib/crypto'
|
||||
|
||||
const identifierOptions: Array<{ label: string; value: IdentifierType }> = [
|
||||
{ label: 'Email', value: 'email' },
|
||||
{ label: 'Username', value: 'username' },
|
||||
{ label: 'Mobile number', value: 'mobile' },
|
||||
]
|
||||
|
||||
export function AuthPanel() {
|
||||
const { session, isAuthenticated, signIn, signUp, signOut } = useAuth()
|
||||
const [mode, setMode] = useState<'login' | 'signup'>('signup')
|
||||
const [identifierType, setIdentifierType] = useState<IdentifierType>('email')
|
||||
const [identifier, setIdentifier] = useState('')
|
||||
const [password, setPassword] = useState('')
|
||||
const [status, setStatus] = useState<string>('')
|
||||
const [isSubmitting, setIsSubmitting] = useState(false)
|
||||
|
||||
async function handleSubmit(event: React.FormEvent<HTMLFormElement>) {
|
||||
event.preventDefault()
|
||||
setIsSubmitting(true)
|
||||
setStatus('')
|
||||
|
||||
try {
|
||||
if (mode === 'signup') {
|
||||
await signUp({ identifierType, identifier, password })
|
||||
setStatus('Account created and session initialized in memory.')
|
||||
} else {
|
||||
await signIn({ identifierType, identifier, password })
|
||||
setStatus('Signed in and encryption key restored in memory.')
|
||||
}
|
||||
setPassword('')
|
||||
} catch (error) {
|
||||
setStatus(error instanceof Error ? error.message : 'Authentication failed.')
|
||||
} finally {
|
||||
setIsSubmitting(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<section className="auth-shell">
|
||||
<div className="auth-card">
|
||||
<div className="auth-card__header">
|
||||
<p className="eyebrow">Phase 2 Auth</p>
|
||||
<h2>{mode === 'signup' ? 'Create an account' : 'Sign in'}</h2>
|
||||
<p className="lede compact">
|
||||
Use email, username, or mobile number with a password. The encryption key stays in memory only.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="auth-tabs" role="tablist" aria-label="Authentication mode">
|
||||
<button type="button" className={mode === 'signup' ? 'tab active' : 'tab'} onClick={() => setMode('signup')}>
|
||||
Sign up
|
||||
</button>
|
||||
<button type="button" className={mode === 'login' ? 'tab active' : 'tab'} onClick={() => setMode('login')}>
|
||||
Log in
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<form className="auth-form" onSubmit={handleSubmit}>
|
||||
<label>
|
||||
Identifier type
|
||||
<select value={identifierType} onChange={(event) => setIdentifierType(event.target.value as IdentifierType)}>
|
||||
{identifierOptions.map((option) => (
|
||||
<option key={option.value} value={option.value}>
|
||||
{option.label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
|
||||
<label>
|
||||
{identifierType === 'email' ? 'Email' : identifierType === 'username' ? 'Username' : 'Mobile number'}
|
||||
<input
|
||||
value={identifier}
|
||||
onChange={(event) => setIdentifier(event.target.value)}
|
||||
placeholder={identifierType === 'mobile' ? '+15555550123' : 'Enter your identifier'}
|
||||
autoComplete={identifierType === 'email' ? 'email' : 'off'}
|
||||
/>
|
||||
</label>
|
||||
|
||||
<label>
|
||||
Password
|
||||
<input
|
||||
type="password"
|
||||
value={password}
|
||||
onChange={(event) => setPassword(event.target.value)}
|
||||
placeholder="Enter your password"
|
||||
autoComplete={mode === 'signup' ? 'new-password' : 'current-password'}
|
||||
/>
|
||||
</label>
|
||||
|
||||
<button type="submit" disabled={isSubmitting}>
|
||||
{isSubmitting ? 'Working…' : mode === 'signup' ? 'Create account' : 'Sign in'}
|
||||
</button>
|
||||
</form>
|
||||
|
||||
{status ? <p className="status">{status}</p> : null}
|
||||
</div>
|
||||
|
||||
<aside className="session-card">
|
||||
<h3>Active session</h3>
|
||||
{isAuthenticated && session ? (
|
||||
<div className="session-details">
|
||||
<p>Owner ID: {session.ownerId}</p>
|
||||
<p>Identifier: {session.identifierType} / {session.identifier}</p>
|
||||
<p>Access token is held in memory.</p>
|
||||
<button type="button" className="secondary" onClick={signOut}>
|
||||
Sign out
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
<p>No active session.</p>
|
||||
)}
|
||||
</aside>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
354
frontend/src/components/DataWorkspace.tsx
Normal file
354
frontend/src/components/DataWorkspace.tsx
Normal file
|
|
@ -0,0 +1,354 @@
|
|||
import { useEffect, useState } from 'react'
|
||||
import { Clock, FileText, KeyRound, Lock, Pencil, Plus, Trash2 } from 'lucide-react'
|
||||
|
||||
import { useAuth } from '../context/AuthContext'
|
||||
import { useWorkspace } from '../context/WorkspaceContext'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { Label } from '@/components/ui/label'
|
||||
import { Textarea } from '@/components/ui/textarea'
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
|
||||
type DraftState = {
|
||||
title: string
|
||||
notes: string
|
||||
}
|
||||
|
||||
const emptyDraft: DraftState = { title: '', notes: '' }
|
||||
|
||||
function formatDate(value: string) {
|
||||
return new Date(value).toLocaleString(undefined, {
|
||||
dateStyle: 'medium',
|
||||
timeStyle: 'short',
|
||||
})
|
||||
}
|
||||
|
||||
export function DataWorkspace() {
|
||||
const { session } = useAuth()
|
||||
const {
|
||||
childrenByParent,
|
||||
selection,
|
||||
parentOfSelection,
|
||||
status,
|
||||
createIntent,
|
||||
requestCreate,
|
||||
createParentPage,
|
||||
createChildPage,
|
||||
updateSelection,
|
||||
deleteSelection,
|
||||
} = useWorkspace()
|
||||
|
||||
const [creating, setCreating] = useState<'parent' | 'child' | null>(null)
|
||||
const [editing, setEditing] = useState(false)
|
||||
const [draft, setDraft] = useState<DraftState>(emptyDraft)
|
||||
const [isSubmitting, setIsSubmitting] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
if (createIntent !== null) {
|
||||
setEditing(false)
|
||||
setDraft(emptyDraft)
|
||||
setCreating(createIntent)
|
||||
requestCreate(null)
|
||||
}
|
||||
}, [createIntent, requestCreate])
|
||||
|
||||
if (session === null) {
|
||||
return null
|
||||
}
|
||||
|
||||
function startCreateParent() {
|
||||
setEditing(false)
|
||||
setDraft(emptyDraft)
|
||||
setCreating('parent')
|
||||
}
|
||||
|
||||
function startCreateChild() {
|
||||
setEditing(false)
|
||||
setDraft(emptyDraft)
|
||||
setCreating('child')
|
||||
}
|
||||
|
||||
function startEdit() {
|
||||
if (selection === null) {
|
||||
return
|
||||
}
|
||||
setCreating(null)
|
||||
setDraft(
|
||||
selection.kind === 'parent'
|
||||
? { title: selection.parent.title, notes: selection.parent.notes }
|
||||
: { title: selection.child.title, notes: selection.child.notes },
|
||||
)
|
||||
setEditing(true)
|
||||
}
|
||||
|
||||
async function handleCreateSubmit(event: React.FormEvent<HTMLFormElement>) {
|
||||
event.preventDefault()
|
||||
if (creating === null) {
|
||||
return
|
||||
}
|
||||
|
||||
setIsSubmitting(true)
|
||||
try {
|
||||
if (creating === 'parent') {
|
||||
await createParentPage(draft.title, draft.notes)
|
||||
} else {
|
||||
const parent = parentOfSelection
|
||||
if (parent === null) {
|
||||
setCreating(null)
|
||||
return
|
||||
}
|
||||
await createChildPage(parent.id, draft.title, draft.notes)
|
||||
}
|
||||
setCreating(null)
|
||||
setDraft(emptyDraft)
|
||||
} catch {
|
||||
// error already surfaced through workspace status
|
||||
} finally {
|
||||
setIsSubmitting(false)
|
||||
}
|
||||
}
|
||||
|
||||
async function handleEditSubmit(event: React.FormEvent<HTMLFormElement>) {
|
||||
event.preventDefault()
|
||||
if (selection === null) {
|
||||
return
|
||||
}
|
||||
|
||||
setIsSubmitting(true)
|
||||
try {
|
||||
await updateSelection(draft.title, draft.notes)
|
||||
setEditing(false)
|
||||
setDraft(emptyDraft)
|
||||
} catch {
|
||||
// error already surfaced through workspace status
|
||||
} finally {
|
||||
setIsSubmitting(false)
|
||||
}
|
||||
}
|
||||
|
||||
async function handleDelete() {
|
||||
if (selection === null) {
|
||||
return
|
||||
}
|
||||
|
||||
setIsSubmitting(true)
|
||||
try {
|
||||
await deleteSelection()
|
||||
setEditing(false)
|
||||
setCreating(null)
|
||||
setDraft(emptyDraft)
|
||||
} catch {
|
||||
// error already surfaced through workspace status
|
||||
} finally {
|
||||
setIsSubmitting(false)
|
||||
}
|
||||
}
|
||||
|
||||
const selectedTitle =
|
||||
selection === null
|
||||
? ''
|
||||
: selection.kind === 'parent'
|
||||
? selection.parent.title
|
||||
: selection.child.title
|
||||
|
||||
return (
|
||||
<div className="mx-auto max-w-4xl space-y-8 p-6 sm:p-10 lg:p-12">
|
||||
<section className="flex flex-col gap-5 lg:flex-row lg:items-end lg:justify-between">
|
||||
<div className="max-w-2xl space-y-3">
|
||||
<p className="text-xs font-semibold uppercase tracking-[0.22em] text-primary">Workspace</p>
|
||||
<h1 className="text-3xl font-bold tracking-tight sm:text-4xl">
|
||||
Welcome back,{' '}
|
||||
<span className="text-primary drop-shadow-[0_0_14px_rgba(34,211,238,0.6)]">
|
||||
{session.identifier}
|
||||
</span>
|
||||
</h1>
|
||||
<p className="text-sm leading-relaxed text-muted-foreground">
|
||||
Browse your page tree in the sidebar, then read and edit pages here. Each page is sealed with
|
||||
AES-256-GCM in this browser — the server never sees its content.
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<Badge variant="secondary" className="gap-1.5 border-primary/30 bg-primary/10 py-1 text-primary">
|
||||
<Lock className="h-3 w-3" />
|
||||
Session encrypted
|
||||
</Badge>
|
||||
<Badge variant="outline" className="py-1 font-mono text-[11px]">
|
||||
{session.ownerId.slice(0, 12)}…
|
||||
</Badge>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="min-w-0 rounded-2xl border border-border bg-card/60 p-6 shadow-[0_0_44px_-22px_rgba(34,211,238,0.35)] sm:p-8">
|
||||
{creating !== null || editing ? (
|
||||
<form
|
||||
onSubmit={creating !== null ? handleCreateSubmit : handleEditSubmit}
|
||||
className="space-y-4"
|
||||
>
|
||||
<div>
|
||||
<p className="text-xs font-semibold uppercase tracking-[0.18em] text-primary">
|
||||
{creating === 'parent'
|
||||
? 'New top-level page'
|
||||
: creating === 'child'
|
||||
? `New child page ${parentOfSelection !== null ? `under "${parentOfSelection.title || 'untitled page'}"` : ''}`
|
||||
: `Editing "${selectedTitle || 'untitled page'}"`}
|
||||
</p>
|
||||
<h2 className="mt-1 text-2xl font-bold tracking-tight">
|
||||
{creating === 'parent'
|
||||
? 'Create a page'
|
||||
: creating === 'child'
|
||||
? 'Create a child page'
|
||||
: 'Edit page'}
|
||||
</h2>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-2">
|
||||
<Label htmlFor="draft-title" className="text-xs text-muted-foreground">
|
||||
Title
|
||||
</Label>
|
||||
<Input
|
||||
id="draft-title"
|
||||
value={draft.title}
|
||||
onChange={(event) => setDraft((current) => ({ ...current, title: event.target.value }))}
|
||||
placeholder="e.g. Project notes"
|
||||
autoFocus
|
||||
/>
|
||||
</div>
|
||||
<div className="grid gap-2">
|
||||
<Label htmlFor="draft-notes" className="text-xs text-muted-foreground">
|
||||
Notes
|
||||
</Label>
|
||||
<Textarea
|
||||
id="draft-notes"
|
||||
value={draft.notes}
|
||||
onChange={(event) => setDraft((current) => ({ ...current, notes: event.target.value }))}
|
||||
placeholder="Anything you want to keep private..."
|
||||
rows={8}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<Button type="submit" disabled={isSubmitting} className="gap-2">
|
||||
{creating !== null ? (
|
||||
<>
|
||||
<Plus className="h-4 w-4" />
|
||||
{creating === 'parent' ? 'Create page' : 'Create child page'}
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Pencil className="h-4 w-4" />
|
||||
Save changes
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
onClick={() => {
|
||||
setCreating(null)
|
||||
setEditing(false)
|
||||
setDraft(emptyDraft)
|
||||
}}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
) : selection === null ? (
|
||||
<div className="flex flex-col items-center gap-3 py-16 text-center">
|
||||
<span className="flex h-12 w-12 items-center justify-center rounded-full bg-secondary text-primary/70">
|
||||
<FileText className="h-6 w-6" />
|
||||
</span>
|
||||
<h2 className="text-lg font-semibold">Select a page from the sidebar</h2>
|
||||
<p className="max-w-sm text-sm leading-relaxed text-muted-foreground">
|
||||
Your pages live in the My Pages section of the sidebar. Click any page to view it here, or use
|
||||
the + icon there to create a top-level page.
|
||||
</p>
|
||||
<Button size="sm" className="mt-1 gap-2" onClick={startCreateParent}>
|
||||
<Plus className="h-4 w-4" />
|
||||
Create a top-level page
|
||||
</Button>
|
||||
</div>
|
||||
) : (
|
||||
<div>
|
||||
<div className="flex flex-wrap items-center justify-between gap-3">
|
||||
<p className="text-xs font-semibold uppercase tracking-[0.18em] text-primary">
|
||||
{selection.kind === 'parent' ? 'Parent page' : 'Child page'}
|
||||
</p>
|
||||
<div className="flex items-center gap-1.5">
|
||||
<Button variant="outline" size="sm" className="gap-2" onClick={startCreateChild}>
|
||||
<Plus className="h-3.5 w-3.5" />
|
||||
Create child
|
||||
</Button>
|
||||
<Button variant="outline" size="sm" className="gap-2" onClick={startEdit}>
|
||||
<Pencil className="h-3.5 w-3.5" />
|
||||
Edit
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className={cn(
|
||||
'gap-2 text-destructive hover:bg-destructive/10 hover:text-destructive',
|
||||
isSubmitting && 'opacity-60'
|
||||
)}
|
||||
onClick={handleDelete}
|
||||
>
|
||||
<Trash2 className="h-3.5 w-3.5" />
|
||||
Delete
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<h2 className="mt-3 text-3xl font-bold tracking-tight sm:text-4xl">
|
||||
{selectedTitle || '(untitled page)'}
|
||||
</h2>
|
||||
|
||||
<div className="mt-3 flex flex-wrap items-center gap-2 text-xs text-muted-foreground">
|
||||
<span className="flex items-center gap-1.5">
|
||||
<Clock className="h-3.5 w-3.5" />
|
||||
Created{' '}
|
||||
{formatDate(
|
||||
selection.kind === 'parent' ? selection.parent.createdAt : selection.child.createdAt,
|
||||
)}
|
||||
</span>
|
||||
{selection.kind === 'parent' ? (
|
||||
<Badge variant="secondary" className="text-[10px]">
|
||||
{childrenByParent[selection.parent.id]?.length ?? 0} child pages
|
||||
</Badge>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
<div className="mt-6 border-t border-border/60 pt-6">
|
||||
{selection.kind === 'parent' ? (
|
||||
selection.parent.notes ? (
|
||||
<p className="whitespace-pre-wrap text-[15px] leading-relaxed text-foreground/90">
|
||||
{selection.parent.notes}
|
||||
</p>
|
||||
) : (
|
||||
<p className="text-sm italic text-muted-foreground">
|
||||
This page has no notes yet. Hit Edit to add some.
|
||||
</p>
|
||||
)
|
||||
) : selection.child.notes ? (
|
||||
<p className="whitespace-pre-wrap text-[15px] leading-relaxed text-foreground/90">
|
||||
{selection.child.notes}
|
||||
</p>
|
||||
) : (
|
||||
<p className="text-sm italic text-muted-foreground">
|
||||
This page has no notes yet. Hit Edit to add some.
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
|
||||
{status ? (
|
||||
<div className="flex items-center gap-2 rounded-xl border border-[rgba(34,211,238,0.35)] bg-[rgba(34,211,238,0.08)] px-4 py-3 text-sm text-primary">
|
||||
<KeyRound className="h-4 w-4 shrink-0" />
|
||||
{status}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
29
frontend/src/components/layout/AppLayout.tsx
Normal file
29
frontend/src/components/layout/AppLayout.tsx
Normal file
|
|
@ -0,0 +1,29 @@
|
|||
import { useState } from 'react'
|
||||
import { Outlet } from 'react-router-dom'
|
||||
import { Header } from './Header'
|
||||
import { Sidebar } from './Sidebar'
|
||||
import { Sheet, SheetContent } from '@/components/ui/sheet'
|
||||
|
||||
export function AppLayout() {
|
||||
const [sidebarOpen, setSidebarOpen] = useState(false)
|
||||
|
||||
return (
|
||||
<div className="flex h-screen flex-col bg-background">
|
||||
<Header onMenuToggle={() => setSidebarOpen((open) => !open)} />
|
||||
|
||||
<div className="flex flex-1 overflow-hidden">
|
||||
<Sidebar className="hidden md:flex" />
|
||||
|
||||
<Sheet open={sidebarOpen} onOpenChange={setSidebarOpen}>
|
||||
<SheetContent side="left" className="w-[250px] border-sidebar-border bg-sidebar p-0">
|
||||
<Sidebar />
|
||||
</SheetContent>
|
||||
</Sheet>
|
||||
|
||||
<main className="flex-1 overflow-y-auto">
|
||||
<Outlet />
|
||||
</main>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
218
frontend/src/components/layout/Header.tsx
Normal file
218
frontend/src/components/layout/Header.tsx
Normal file
|
|
@ -0,0 +1,218 @@
|
|||
import { useEffect, useState } from 'react'
|
||||
import { Link } from 'react-router-dom'
|
||||
import {
|
||||
Bell,
|
||||
CheckCircle2,
|
||||
Fingerprint,
|
||||
FolderOpen,
|
||||
LogIn,
|
||||
LogOut,
|
||||
Menu,
|
||||
Search,
|
||||
ShieldCheck,
|
||||
UserPlus,
|
||||
} from 'lucide-react'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { Avatar, AvatarFallback } from '@/components/ui/avatar'
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuLabel,
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuTrigger,
|
||||
} from '@/components/ui/dropdown-menu'
|
||||
import { useAuth } from '@/context/AuthContext'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
interface HeaderProps {
|
||||
onMenuToggle: () => void
|
||||
}
|
||||
|
||||
const searchPlaceholders = [
|
||||
'Search in platform...',
|
||||
'Search about functions used...',
|
||||
'Search about securities provided...',
|
||||
'Search for records and notes...',
|
||||
]
|
||||
|
||||
const identifierLabel = (type: string) =>
|
||||
type === 'email' ? 'Email' : type === 'mobile' ? 'Mobile' : 'Username'
|
||||
|
||||
export function Header({ onMenuToggle }: HeaderProps) {
|
||||
const { session, isAuthenticated, signOut } = useAuth()
|
||||
const [placeholderIndex, setPlaceholderIndex] = useState(0)
|
||||
const [hideBell, setHideBell] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
if (isAuthenticated) {
|
||||
const id = setTimeout(() => setHideBell(true), 800)
|
||||
return () => clearTimeout(id)
|
||||
}
|
||||
setHideBell(false)
|
||||
}, [isAuthenticated])
|
||||
|
||||
useEffect(() => {
|
||||
const id = setInterval(() => {
|
||||
setPlaceholderIndex((i) => (i + 1) % searchPlaceholders.length)
|
||||
}, 3000)
|
||||
return () => clearInterval(id)
|
||||
}, [])
|
||||
|
||||
const notificationMessage =
|
||||
isAuthenticated && session
|
||||
? `Hey ${session.identifier}, you're one step away from encrypting your data. Head to your workspace to keep your records and passwords safe.`
|
||||
: 'Hey there! You are one step away from encrypting your data. Create an account so your passwords and records stay private.'
|
||||
|
||||
return (
|
||||
<header className="sticky top-0 z-40 grid h-16 grid-cols-[auto_1fr] items-center border-b border-sidebar-border bg-card/95 backdrop-blur supports-[backdrop-filter]:bg-card/80">
|
||||
<div className="flex h-full w-[170px] items-center border-r border-sidebar-border bg-[#04101d] pl-4 md:w-[250px]">
|
||||
<Link to="/" className="group flex items-center gap-2.5">
|
||||
<span className="flex h-9 w-9 shrink-0 items-center justify-center rounded-xl bg-gradient-to-br from-cyan-300 to-sky-600 text-[#03222e] shadow-[0_0_24px_-4px_rgba(34,211,238,0.8)] transition-all group-hover:shadow-[0_0_36px_-4px_rgba(34,211,238,1)]">
|
||||
<Fingerprint className="h-5 w-5" />
|
||||
</span>
|
||||
<span className="leading-tight">
|
||||
<span className="block text-[15px] font-semibold tracking-tight text-cyan-50">Zero Knowledge</span>
|
||||
<span className="hidden text-[11px] font-normal text-cyan-400/60 md:block">Secure Data Platform</span>
|
||||
</span>
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2 px-3 sm:px-5">
|
||||
<Button variant="ghost" size="icon" className="shrink-0 md:hidden" onClick={onMenuToggle}>
|
||||
<Menu className="h-5 w-5" />
|
||||
<span className="sr-only">Toggle sidebar</span>
|
||||
</Button>
|
||||
|
||||
<div className="relative w-full max-w-xl">
|
||||
<Search className="absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-muted-foreground" />
|
||||
<Input
|
||||
type="search"
|
||||
placeholder={searchPlaceholders[placeholderIndex]}
|
||||
className="h-9 bg-muted/60 pl-9 pr-4"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<span className="ml-2 hidden items-center gap-2 text-[11px] font-medium uppercase tracking-[0.18em] text-cyan-400/60 xl:flex">
|
||||
<span className="h-1.5 w-1.5 animate-pulse rounded-full bg-cyan-400 shadow-[0_0_10px_rgba(34,211,238,1)]" />
|
||||
Zero-knowledge encryption active
|
||||
</span>
|
||||
|
||||
<div className="ml-auto flex items-center gap-1.5 sm:gap-2">
|
||||
<div
|
||||
className={cn(
|
||||
'transition-opacity duration-700 ease-out',
|
||||
isAuthenticated && 'pointer-events-none opacity-0',
|
||||
hideBell && 'hidden',
|
||||
)}
|
||||
>
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button variant="ghost" size="icon" className="relative">
|
||||
<Bell className="h-4 w-4" />
|
||||
<span className="absolute right-1.5 top-1.5 h-2 w-2 rounded-full bg-primary" />
|
||||
<span className="sr-only">Notifications</span>
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent className="w-80" align="end" forceMount>
|
||||
<div className="p-4">
|
||||
<div className="flex items-start gap-3">
|
||||
<span className="flex h-9 w-9 shrink-0 items-center justify-center rounded-full bg-secondary text-primary">
|
||||
<ShieldCheck className="h-5 w-5" />
|
||||
</span>
|
||||
<div className="space-y-1.5">
|
||||
<p className="text-sm font-semibold leading-none">One step away from encryption</p>
|
||||
<p className="text-xs leading-relaxed text-muted-foreground">{notificationMessage}</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-3">
|
||||
{isAuthenticated ? (
|
||||
<Button size="sm" className="w-full" asChild>
|
||||
<Link to="/workspace">
|
||||
<FolderOpen className="h-4 w-4" />
|
||||
Go to workspace
|
||||
</Link>
|
||||
</Button>
|
||||
) : (
|
||||
<Button size="sm" className="w-full" asChild>
|
||||
<Link to="/signup">Create account</Link>
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</div>
|
||||
|
||||
{isAuthenticated && session ? (
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<button className="flex items-center gap-2 rounded-full border border-border bg-background py-1 pl-1 pr-3 transition-colors hover:bg-accent">
|
||||
<Avatar className="h-7 w-7">
|
||||
<AvatarFallback className="bg-primary text-[10px] text-primary-foreground">
|
||||
{session.identifier.slice(0, 2).toUpperCase()}
|
||||
</AvatarFallback>
|
||||
</Avatar>
|
||||
<span className="hidden max-w-[140px] truncate text-xs font-medium text-foreground sm:block">
|
||||
{session.identifier}
|
||||
</span>
|
||||
</button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent className="w-64" align="end" forceMount>
|
||||
<DropdownMenuLabel className="font-normal">
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<p className="text-sm font-medium leading-none">{session.identifier}</p>
|
||||
<p className="text-xs leading-none text-muted-foreground">
|
||||
{identifierLabel(session.identifierType)}
|
||||
</p>
|
||||
<div className="flex items-center gap-1.5 pt-1">
|
||||
<Badge variant="secondary" className="gap-1 text-[10px]">
|
||||
<CheckCircle2 className="h-3 w-3 text-emerald-600" />
|
||||
Signed in
|
||||
</Badge>
|
||||
<Badge variant="outline" className="font-mono text-[10px]">
|
||||
{session.ownerId.slice(0, 10)}…
|
||||
</Badge>
|
||||
</div>
|
||||
</div>
|
||||
</DropdownMenuLabel>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem asChild>
|
||||
<Link to="/workspace" className="cursor-pointer">
|
||||
<FolderOpen className="h-4 w-4" />
|
||||
My Workspace
|
||||
</Link>
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem
|
||||
onClick={signOut}
|
||||
className="cursor-pointer text-destructive focus:text-destructive"
|
||||
>
|
||||
<LogOut className="h-4 w-4" />
|
||||
Sign out
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
) : (
|
||||
<div className="flex items-center gap-1.5">
|
||||
<Button variant="ghost" size="sm" asChild>
|
||||
<Link to="/login">
|
||||
<LogIn className="h-4 w-4" />
|
||||
<span className="hidden sm:inline">Log in</span>
|
||||
</Link>
|
||||
</Button>
|
||||
<Button size="sm" asChild>
|
||||
<Link to="/signup">
|
||||
<UserPlus className="h-4 w-4" />
|
||||
<span className="hidden sm:inline">Sign up</span>
|
||||
</Link>
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
)
|
||||
}
|
||||
245
frontend/src/components/layout/Sidebar.tsx
Normal file
245
frontend/src/components/layout/Sidebar.tsx
Normal file
|
|
@ -0,0 +1,245 @@
|
|||
import { NavLink, Link, useNavigate } from 'react-router-dom'
|
||||
import {
|
||||
ChevronDown,
|
||||
ChevronRight,
|
||||
FileText,
|
||||
FolderOpen,
|
||||
Home,
|
||||
Info,
|
||||
LogOut,
|
||||
Plus,
|
||||
ShieldCheck,
|
||||
} from 'lucide-react'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { Separator } from '@/components/ui/separator'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { useAuth } from '@/context/AuthContext'
|
||||
import { useWorkspace } from '@/context/WorkspaceContext'
|
||||
|
||||
interface SidebarProps {
|
||||
className?: string
|
||||
}
|
||||
|
||||
const mainNav = [
|
||||
{ to: '/', label: 'Home', icon: Home },
|
||||
{ to: '/about', label: 'About', icon: Info },
|
||||
{ to: '/security', label: 'Security', icon: ShieldCheck },
|
||||
]
|
||||
|
||||
export function Sidebar({ className }: SidebarProps) {
|
||||
const { isAuthenticated, signOut } = useAuth()
|
||||
const navigate = useNavigate()
|
||||
const {
|
||||
parents,
|
||||
childrenByParent,
|
||||
expandedParents,
|
||||
selection,
|
||||
parentOfSelection,
|
||||
toggleExpand,
|
||||
selectParent,
|
||||
selectChild,
|
||||
requestCreate,
|
||||
} = useWorkspace()
|
||||
|
||||
const createHint =
|
||||
parentOfSelection === null
|
||||
? 'Creates a new top-level page'
|
||||
: `Creates a child under "${parentOfSelection.title || 'untitled page'}"`
|
||||
|
||||
function handleCreateParent() {
|
||||
requestCreate('parent')
|
||||
navigate('/workspace')
|
||||
}
|
||||
|
||||
function handleCreateChild() {
|
||||
requestCreate('child')
|
||||
navigate('/workspace')
|
||||
}
|
||||
|
||||
return (
|
||||
<aside
|
||||
className={cn(
|
||||
'flex h-full w-[250px] flex-col border-r border-sidebar-border bg-sidebar text-sidebar-foreground',
|
||||
className
|
||||
)}
|
||||
>
|
||||
<nav className="flex-1 overflow-y-auto px-2 py-4">
|
||||
<p className="px-3 pb-2 text-[11px] font-semibold uppercase tracking-wider text-primary/50">
|
||||
Navigation
|
||||
</p>
|
||||
<div className="space-y-1">
|
||||
{mainNav.map((item) => (
|
||||
<NavLink
|
||||
key={item.to}
|
||||
to={item.to}
|
||||
end={item.to === '/'}
|
||||
className={({ isActive }) =>
|
||||
cn(
|
||||
'flex items-center gap-3 rounded-md px-3 py-2 text-sm font-medium transition-colors hover:bg-sidebar-accent hover:text-sidebar-accent-foreground',
|
||||
isActive
|
||||
? 'bg-sidebar-accent text-sidebar-accent-foreground shadow-[inset_0_0_0_1px_rgba(34,211,238,0.35)]'
|
||||
: 'text-sidebar-foreground/70'
|
||||
)
|
||||
}
|
||||
>
|
||||
<item.icon className="h-4 w-4" />
|
||||
{item.label}
|
||||
</NavLink>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{isAuthenticated ? (
|
||||
<>
|
||||
<p className="px-3 pb-2 pt-5 text-[11px] font-semibold uppercase tracking-wider text-primary/50">
|
||||
Account
|
||||
</p>
|
||||
<div className="space-y-1">
|
||||
<NavLink
|
||||
to="/workspace"
|
||||
className={({ isActive }) =>
|
||||
cn(
|
||||
'flex items-center gap-3 rounded-md px-3 py-2 text-sm font-medium transition-colors hover:bg-sidebar-accent hover:text-sidebar-accent-foreground',
|
||||
isActive
|
||||
? 'bg-sidebar-accent text-sidebar-accent-foreground shadow-[inset_0_0_0_1px_rgba(34,211,238,0.35)]'
|
||||
: 'text-sidebar-foreground/70'
|
||||
)
|
||||
}
|
||||
>
|
||||
<FolderOpen className="h-4 w-4" />
|
||||
My Workspace
|
||||
</NavLink>
|
||||
</div>
|
||||
|
||||
<div className="mt-5 space-y-1">
|
||||
<div className="flex items-center justify-between px-3 pb-1">
|
||||
<p className="text-[11px] font-semibold uppercase tracking-wider text-primary/50">My Pages</p>
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleCreateParent}
|
||||
title="Create a new top-level page"
|
||||
aria-label="Create a new top-level page"
|
||||
className="flex h-6 w-6 items-center justify-center rounded-md text-primary/70 transition-colors hover:bg-sidebar-accent hover:text-primary"
|
||||
>
|
||||
<Plus className="h-4 w-4" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<Button size="sm" variant="secondary" className="w-full justify-start gap-2" onClick={handleCreateChild}>
|
||||
<Plus className="h-4 w-4" />
|
||||
Create
|
||||
</Button>
|
||||
<p className="px-1 text-[10px] leading-snug text-muted-foreground">{createHint}</p>
|
||||
</div>
|
||||
|
||||
<div className="mt-2 max-h-[38vh] space-y-0.5 overflow-y-auto pr-1">
|
||||
{parents.length === 0 ? (
|
||||
<div className="rounded-lg border border-dashed border-border px-3 py-6 text-center">
|
||||
<p className="text-xs text-muted-foreground">No pages yet. Click + to create one.</p>
|
||||
</div>
|
||||
) : (
|
||||
parents.map((parent) => {
|
||||
const isExpanded = expandedParents.has(parent.id)
|
||||
const isParentActive =
|
||||
selection !== null && selection.kind === 'parent' && selection.parent.id === parent.id
|
||||
const children = childrenByParent[parent.id] ?? []
|
||||
|
||||
return (
|
||||
<div key={parent.id}>
|
||||
<div
|
||||
className={cn(
|
||||
'group flex items-center gap-1 rounded-md py-1 pl-1 pr-2 transition-colors',
|
||||
isParentActive
|
||||
? 'bg-sidebar-accent text-sidebar-accent-foreground shadow-[inset_0_0_0_1px_rgba(34,211,238,0.35)]'
|
||||
: 'hover:bg-accent/60'
|
||||
)}
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => toggleExpand(parent.id)}
|
||||
className="flex h-5 w-5 shrink-0 items-center justify-center rounded text-muted-foreground hover:text-foreground"
|
||||
aria-label={isExpanded ? 'Collapse page' : 'Expand page'}
|
||||
>
|
||||
{isExpanded ? (
|
||||
<ChevronDown className="h-3.5 w-3.5" />
|
||||
) : (
|
||||
<ChevronRight className="h-3.5 w-3.5" />
|
||||
)}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => selectParent(parent)}
|
||||
className={cn(
|
||||
'flex min-w-0 flex-1 items-center gap-2 py-0.5 text-left text-sm',
|
||||
isParentActive ? 'font-medium' : 'font-normal'
|
||||
)}
|
||||
>
|
||||
<FileText className="h-3.5 w-3.5 shrink-0 text-primary/60" />
|
||||
<span className="truncate">{parent.title || '(untitled page)'}</span>
|
||||
</button>
|
||||
<span className="text-[10px] text-muted-foreground">{children.length}</span>
|
||||
</div>
|
||||
|
||||
{isExpanded ? (
|
||||
<div className="ml-[13px] border-l border-border pl-2.5">
|
||||
{children.length === 0 ? (
|
||||
<p className="py-1.5 pl-2 text-[11px] italic text-muted-foreground">
|
||||
No child pages yet
|
||||
</p>
|
||||
) : (
|
||||
children.map((child) => {
|
||||
const isChildActive =
|
||||
selection !== null &&
|
||||
selection.kind === 'child' &&
|
||||
selection.child.id === child.id
|
||||
|
||||
return (
|
||||
<button
|
||||
key={child.id}
|
||||
type="button"
|
||||
onClick={() => selectChild(child)}
|
||||
className={cn(
|
||||
'flex w-full items-center gap-2 rounded-md py-1.5 pl-2 pr-2 text-left text-sm transition-colors',
|
||||
isChildActive
|
||||
? 'bg-sidebar-accent text-sidebar-accent-foreground shadow-[inset_0_0_0_1px_rgba(34,211,238,0.35)]'
|
||||
: 'text-sidebar-foreground/70 hover:bg-accent/60'
|
||||
)}
|
||||
>
|
||||
<FileText className="h-3.5 w-3.5 shrink-0 text-primary/40" />
|
||||
<span className="truncate">{child.title || '(untitled child)'}</span>
|
||||
</button>
|
||||
)
|
||||
})
|
||||
)}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
)
|
||||
})
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
) : null}
|
||||
</nav>
|
||||
|
||||
<Separator className="bg-sidebar-border" />
|
||||
|
||||
<div className="px-3 py-3">
|
||||
{isAuthenticated ? (
|
||||
<Button variant="outline" size="sm" className="w-full justify-start gap-2" onClick={signOut}>
|
||||
<LogOut className="h-4 w-4" />
|
||||
Sign out
|
||||
</Button>
|
||||
) : (
|
||||
<div className="flex flex-col gap-2">
|
||||
<Button size="sm" asChild>
|
||||
<Link to="/signup">Sign up</Link>
|
||||
</Button>
|
||||
<Button variant="outline" size="sm" asChild>
|
||||
<Link to="/login">Log in</Link>
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</aside>
|
||||
)
|
||||
}
|
||||
48
frontend/src/components/ui/avatar.tsx
Normal file
48
frontend/src/components/ui/avatar.tsx
Normal file
|
|
@ -0,0 +1,48 @@
|
|||
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
frontend/src/components/ui/badge.tsx
Normal file
36
frontend/src/components/ui/badge.tsx
Normal 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-md 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 shadow hover:bg-primary/80",
|
||||
secondary:
|
||||
"border-transparent bg-secondary text-secondary-foreground hover:bg-secondary/80",
|
||||
destructive:
|
||||
"border-transparent bg-destructive text-destructive-foreground shadow 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 }
|
||||
57
frontend/src/components/ui/button.tsx
Normal file
57
frontend/src/components/ui/button.tsx
Normal file
|
|
@ -0,0 +1,57 @@
|
|||
import * as React from "react"
|
||||
import { Slot } from "@radix-ui/react-slot"
|
||||
import { cva, type VariantProps } from "class-variance-authority"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
const buttonVariants = cva(
|
||||
"inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium transition-colors focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0",
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
default:
|
||||
"bg-primary text-primary-foreground shadow hover:bg-primary/90",
|
||||
destructive:
|
||||
"bg-destructive text-destructive-foreground shadow-sm hover:bg-destructive/90",
|
||||
outline:
|
||||
"border border-input bg-background shadow-sm hover:bg-accent hover:text-accent-foreground",
|
||||
secondary:
|
||||
"bg-secondary text-secondary-foreground shadow-sm hover:bg-secondary/80",
|
||||
ghost: "hover:bg-accent hover:text-accent-foreground",
|
||||
link: "text-primary underline-offset-4 hover:underline",
|
||||
},
|
||||
size: {
|
||||
default: "h-9 px-4 py-2",
|
||||
sm: "h-8 rounded-md px-3 text-xs",
|
||||
lg: "h-10 rounded-md px-8",
|
||||
icon: "h-9 w-9",
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
variant: "default",
|
||||
size: "default",
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
export interface ButtonProps
|
||||
extends React.ButtonHTMLAttributes<HTMLButtonElement>,
|
||||
VariantProps<typeof buttonVariants> {
|
||||
asChild?: boolean
|
||||
}
|
||||
|
||||
const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(
|
||||
({ className, variant, size, asChild = false, ...props }, ref) => {
|
||||
const Comp = asChild ? Slot : "button"
|
||||
return (
|
||||
<Comp
|
||||
className={cn(buttonVariants({ variant, size, className }))}
|
||||
ref={ref}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
)
|
||||
Button.displayName = "Button"
|
||||
|
||||
export { Button, buttonVariants }
|
||||
76
frontend/src/components/ui/card.tsx
Normal file
76
frontend/src/components/ui/card.tsx
Normal file
|
|
@ -0,0 +1,76 @@
|
|||
import * as React from "react"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
const Card = React.forwardRef<
|
||||
HTMLDivElement,
|
||||
React.HTMLAttributes<HTMLDivElement>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<div
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"rounded-xl border bg-card text-card-foreground shadow",
|
||||
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<
|
||||
HTMLDivElement,
|
||||
React.HTMLAttributes<HTMLDivElement>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<div
|
||||
ref={ref}
|
||||
className={cn("font-semibold leading-none tracking-tight", className)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
CardTitle.displayName = "CardTitle"
|
||||
|
||||
const CardDescription = React.forwardRef<
|
||||
HTMLDivElement,
|
||||
React.HTMLAttributes<HTMLDivElement>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<div
|
||||
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, CardHeader, CardFooter, CardTitle, CardDescription, CardContent }
|
||||
199
frontend/src/components/ui/dropdown-menu.tsx
Normal file
199
frontend/src/components/ui/dropdown-menu.tsx
Normal file
|
|
@ -0,0 +1,199 @@
|
|||
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 gap-2 rounded-sm px-2 py-1.5 text-sm outline-none focus:bg-accent data-[state=open]:bg-accent [&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0",
|
||||
inset && "pl-8",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
<ChevronRight className="ml-auto" />
|
||||
</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 origin-[--radix-dropdown-menu-content-transform-origin]",
|
||||
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 max-h-[var(--radix-dropdown-menu-content-available-height)] min-w-[8rem] overflow-y-auto overflow-x-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 origin-[--radix-dropdown-menu-content-transform-origin]",
|
||||
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 gap-2 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 [&>svg]:size-4 [&>svg]:shrink-0",
|
||||
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,
|
||||
}
|
||||
22
frontend/src/components/ui/input.tsx
Normal file
22
frontend/src/components/ui/input.tsx
Normal file
|
|
@ -0,0 +1,22 @@
|
|||
import * as React from "react"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
const Input = React.forwardRef<HTMLInputElement, React.ComponentProps<"input">>(
|
||||
({ className, type, ...props }, ref) => {
|
||||
return (
|
||||
<input
|
||||
type={type}
|
||||
className={cn(
|
||||
"flex h-9 w-full rounded-md border border-input bg-transparent px-3 py-1 text-base shadow-sm transition-colors file:border-0 file:bg-transparent file:text-sm file:font-medium file:text-foreground placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-50 md:text-sm",
|
||||
className
|
||||
)}
|
||||
ref={ref}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
)
|
||||
Input.displayName = "Input"
|
||||
|
||||
export { Input }
|
||||
24
frontend/src/components/ui/label.tsx
Normal file
24
frontend/src/components/ui/label.tsx
Normal file
|
|
@ -0,0 +1,24 @@
|
|||
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 }
|
||||
31
frontend/src/components/ui/separator.tsx
Normal file
31
frontend/src/components/ui/separator.tsx
Normal 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 }
|
||||
138
frontend/src/components/ui/sheet.tsx
Normal file
138
frontend/src/components/ui/sheet.tsx
Normal file
|
|
@ -0,0 +1,138 @@
|
|||
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=closed]:duration-300 data-[state=open]:duration-500 data-[state=open]:animate-in data-[state=closed]:animate-out",
|
||||
{
|
||||
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}
|
||||
>
|
||||
<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>
|
||||
{children}
|
||||
</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,
|
||||
}
|
||||
22
frontend/src/components/ui/textarea.tsx
Normal file
22
frontend/src/components/ui/textarea.tsx
Normal file
|
|
@ -0,0 +1,22 @@
|
|||
import * as React from "react"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
const Textarea = React.forwardRef<
|
||||
HTMLTextAreaElement,
|
||||
React.ComponentProps<"textarea">
|
||||
>(({ className, ...props }, ref) => {
|
||||
return (
|
||||
<textarea
|
||||
className={cn(
|
||||
"flex min-h-[60px] w-full rounded-md border border-input bg-transparent px-3 py-2 text-base shadow-sm placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-50 md:text-sm",
|
||||
className
|
||||
)}
|
||||
ref={ref}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
})
|
||||
Textarea.displayName = "Textarea"
|
||||
|
||||
export { Textarea }
|
||||
32
frontend/src/components/ui/tooltip.tsx
Normal file
32
frontend/src/components/ui/tooltip.tsx
Normal file
|
|
@ -0,0 +1,32 @@
|
|||
"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.Portal>
|
||||
<TooltipPrimitive.Content
|
||||
ref={ref}
|
||||
sideOffset={sideOffset}
|
||||
className={cn(
|
||||
"z-50 overflow-hidden rounded-md bg-primary px-3 py-1.5 text-xs text-primary-foreground 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 origin-[--radix-tooltip-content-transform-origin]",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
</TooltipPrimitive.Portal>
|
||||
))
|
||||
TooltipContent.displayName = TooltipPrimitive.Content.displayName
|
||||
|
||||
export { Tooltip, TooltipTrigger, TooltipContent, TooltipProvider }
|
||||
Loading…
Add table
Add a link
Reference in a new issue