Initial commit
This commit is contained in:
commit
38c24fc9dc
166 files changed
+22002
No files matched your search
@@ -0,0 +1,27 @@
|
||||
// Hand-rolled instead of pulling in a charting library — this is one bar
|
||||
// chart on one admin page, and the design brief calls for graphs to be
|
||||
// "tasteful and minimal", not feature-rich.
|
||||
export function DailyBarChart({ data }: { data: { day: string; count: number }[] }) {
|
||||
const max = Math.max(1, ...data.map((d) => d.count))
|
||||
|
||||
return (
|
||||
<div className="flex h-32 items-end gap-1">
|
||||
{data.map((d) => {
|
||||
const heightPct = (d.count / max) * 100
|
||||
const date = new Date(`${d.day}T00:00:00`)
|
||||
const label = date.toLocaleDateString(undefined, { month: 'short', day: 'numeric' })
|
||||
return (
|
||||
<div key={d.day} className="group relative flex h-full flex-1 items-end">
|
||||
<div
|
||||
className="w-full rounded-t bg-accent/70 transition-colors group-hover:bg-accent"
|
||||
style={{ height: `${Math.max(heightPct, d.count > 0 ? 4 : 1)}%` }}
|
||||
/>
|
||||
<div className="pointer-events-none absolute bottom-full left-1/2 z-10 mb-1.5 -translate-x-1/2 whitespace-nowrap rounded-md bg-base-raised px-2 py-1 text-[11px] text-slate-200 opacity-0 shadow-raised transition-opacity group-hover:opacity-100">
|
||||
{label}: {d.count}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
// CSS conic-gradient donut — same "hand-rolled, no charting library" call
|
||||
// as DailyBarChart. A dedicated color per slice comes from the caller so
|
||||
// it stays consistent with wherever else that entity is colored (e.g.
|
||||
// colorForLabel for sources).
|
||||
export interface DonutSlice {
|
||||
label: string
|
||||
value: number
|
||||
color: string
|
||||
}
|
||||
|
||||
export function DonutChart({ data, size = 112 }: { data: DonutSlice[]; size?: number }) {
|
||||
const total = data.reduce((sum, d) => sum + d.value, 0)
|
||||
|
||||
let cursor = 0
|
||||
const stops = data
|
||||
.filter((d) => d.value > 0)
|
||||
.map((d) => {
|
||||
const start = cursor
|
||||
cursor += (d.value / total) * 100
|
||||
return `${d.color} ${start}% ${cursor}%`
|
||||
})
|
||||
|
||||
const gradient =
|
||||
total > 0
|
||||
? `conic-gradient(${stops.join(', ')})`
|
||||
: 'conic-gradient(var(--color-base-border, #262b36) 0% 100%)'
|
||||
|
||||
return (
|
||||
<div className="flex items-center gap-5">
|
||||
<div className="relative shrink-0" style={{ width: size, height: size }}>
|
||||
<div className="h-full w-full rounded-full" style={{ background: gradient }} />
|
||||
<div className="absolute inset-[18%] flex items-center justify-center rounded-full bg-base-surface">
|
||||
<span className="text-sm font-semibold text-slate-200">{total.toLocaleString()}</span>
|
||||
</div>
|
||||
</div>
|
||||
<ul className="space-y-1.5">
|
||||
{data.map((d) => (
|
||||
<li key={d.label} className="flex items-center gap-2 text-xs">
|
||||
<span className="h-2.5 w-2.5 shrink-0 rounded-full" style={{ backgroundColor: d.color }} />
|
||||
<span className="text-slate-300">{d.label}</span>
|
||||
<span className="text-slate-500">
|
||||
{total > 0 ? Math.round((d.value / total) * 100) : 0}%
|
||||
</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
import { Outlet } from 'react-router-dom'
|
||||
import { useNotificationStream } from '../../hooks/useNotificationStream'
|
||||
import { usePushDeepLink } from '../../hooks/usePushDeepLink'
|
||||
import { BottomNav } from './BottomNav'
|
||||
import { Sidebar } from './Sidebar'
|
||||
|
||||
// h-[100dvh] + an internally-scrolling <main>, rather than `position:
|
||||
// fixed` + a scrolling page, is deliberate: fixed-position bottom nav bars
|
||||
// are notoriously unreliable on iOS (dynamic toolbar height, viewport
|
||||
// quirks, inconsistent behavior between Safari/installed-PWA/other
|
||||
// webviews) — a normal flex layout where the nav is just the last child
|
||||
// pins it correctly everywhere without fighting any of that.
|
||||
export function AppShell() {
|
||||
useNotificationStream()
|
||||
usePushDeepLink()
|
||||
|
||||
return (
|
||||
<div className="flex h-[100dvh] bg-base-bg text-slate-100">
|
||||
<Sidebar />
|
||||
<div className="flex min-w-0 flex-1 flex-col">
|
||||
{/* overflow-x-hidden is explicit, not redundant with overflow-y-auto —
|
||||
per the CSS overflow spec, setting only overflow-y makes overflow-x
|
||||
compute to auto too, so long unbroken content (a URL, a long word)
|
||||
could otherwise make this scroll sideways within its own box. */}
|
||||
<main className="safe-top flex-1 overflow-x-hidden overflow-y-auto">
|
||||
<Outlet />
|
||||
</main>
|
||||
<BottomNav />
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
import { NavLink } from 'react-router-dom'
|
||||
import { cn } from '../../lib/utils'
|
||||
import { navItems } from './nav-items'
|
||||
|
||||
export function BottomNav() {
|
||||
return (
|
||||
<nav className="safe-bottom shrink-0 border-t border-base-border bg-base-surface/95 backdrop-blur md:hidden">
|
||||
<ul className="flex items-stretch justify-around">
|
||||
{navItems.map(({ to, label, icon: Icon, end }) => (
|
||||
// min-w-0 is load-bearing: flex items default to min-width: auto,
|
||||
// which blocks shrinking below their content's natural width —
|
||||
// with 5 labeled items that's enough to push the row wider than
|
||||
// narrow phone screens, cutting the last item off entirely.
|
||||
<li key={to} className="min-w-0 flex-1">
|
||||
<NavLink
|
||||
to={to}
|
||||
end={end}
|
||||
className={({ isActive }) =>
|
||||
cn(
|
||||
'flex flex-col items-center gap-1 py-2.5 text-[11px] font-medium transition-colors',
|
||||
isActive ? 'text-accent-bright' : 'text-slate-500 hover:text-slate-300',
|
||||
)
|
||||
}
|
||||
>
|
||||
{({ isActive }) => (
|
||||
<>
|
||||
<Icon className="h-5 w-5" strokeWidth={isActive ? 2.25 : 1.75} />
|
||||
<span className="truncate">{label}</span>
|
||||
</>
|
||||
)}
|
||||
</NavLink>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</nav>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
import { NavLink } from 'react-router-dom'
|
||||
import { cn } from '../../lib/utils'
|
||||
import { navItems } from './nav-items'
|
||||
|
||||
export function Sidebar() {
|
||||
return (
|
||||
<aside className="hidden w-60 shrink-0 flex-col border-r border-base-border bg-base-surface md:flex">
|
||||
<div className="flex items-center gap-2.5 px-5 py-5">
|
||||
<div className="flex h-8 w-8 items-center justify-center rounded-lg bg-accent/15 text-accent-bright">
|
||||
<span className="text-sm font-bold">N</span>
|
||||
</div>
|
||||
<span className="text-[15px] font-semibold tracking-tight text-slate-100">Notizarr</span>
|
||||
</div>
|
||||
<nav className="flex-1 px-3 py-2">
|
||||
<ul className="space-y-0.5">
|
||||
{navItems.map(({ to, label, icon: Icon, end }) => (
|
||||
<li key={to}>
|
||||
<NavLink
|
||||
to={to}
|
||||
end={end}
|
||||
className={({ isActive }) =>
|
||||
cn(
|
||||
'flex items-center gap-3 rounded-lg px-3 py-2 text-sm font-medium transition-colors',
|
||||
isActive
|
||||
? 'bg-accent/10 text-accent-bright'
|
||||
: 'text-slate-400 hover:bg-base-hover hover:text-slate-200',
|
||||
)
|
||||
}
|
||||
>
|
||||
<Icon className="h-[18px] w-[18px]" strokeWidth={1.75} />
|
||||
{label}
|
||||
</NavLink>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</nav>
|
||||
</aside>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
import { Bell, Hash, Search, Settings, Star } from 'lucide-react'
|
||||
|
||||
export const navItems = [
|
||||
{ to: '/', label: 'Inbox', icon: Bell, end: true },
|
||||
{ to: '/topics', label: 'Topics', icon: Hash, end: false },
|
||||
{ to: '/search', label: 'Search', icon: Search, end: false },
|
||||
{ to: '/starred', label: 'Starred', icon: Star, end: false },
|
||||
{ to: '/settings', label: 'Settings', icon: Settings, end: false },
|
||||
] as const
|
||||
@@ -0,0 +1,156 @@
|
||||
import { BellOff, Check, Star } from 'lucide-react'
|
||||
import { useRef, useState } from 'react'
|
||||
import { Link } from 'react-router-dom'
|
||||
import { useSetNotificationState } from '../../hooks/useNotifications'
|
||||
import { cn, colorForLabel, formatRelativeTime } from '../../lib/utils'
|
||||
import { isQuietPriority } from '../../lib/priority'
|
||||
import type { Notification } from '../../types'
|
||||
|
||||
const SWIPE_THRESHOLD = 64
|
||||
|
||||
export function NotificationRow({
|
||||
notification,
|
||||
showTopic = true,
|
||||
}: {
|
||||
notification: Notification
|
||||
showTopic?: boolean
|
||||
}) {
|
||||
const avatarColor = colorForLabel(notification.source)
|
||||
const setState = useSetNotificationState()
|
||||
|
||||
// Hand-rolled swipe-to-toggle-read, touch only — pointer position is
|
||||
// tracked in refs (not state) so a swipe doesn't cause a re-render on
|
||||
// every pixel of movement; `dragX` state only updates for the visual
|
||||
// transform, and `wasSwipe` suppresses the Link's navigation on release
|
||||
// so a swipe doesn't also open the notification.
|
||||
const startX = useRef<number | null>(null)
|
||||
const startY = useRef<number | null>(null)
|
||||
const wasSwipe = useRef(false)
|
||||
const [dragX, setDragX] = useState(0)
|
||||
const [dragging, setDragging] = useState(false)
|
||||
|
||||
const handlePointerDown = (e: React.PointerEvent) => {
|
||||
if (e.pointerType !== 'touch') return
|
||||
startX.current = e.clientX
|
||||
startY.current = e.clientY
|
||||
}
|
||||
|
||||
const handlePointerMove = (e: React.PointerEvent) => {
|
||||
if (startX.current === null || startY.current === null) return
|
||||
const dx = e.clientX - startX.current
|
||||
const dy = e.clientY - startY.current
|
||||
if (Math.abs(dy) > Math.abs(dx) + 8) return // vertical scroll, not a swipe
|
||||
setDragging(true)
|
||||
setDragX(Math.max(-120, Math.min(120, dx)))
|
||||
}
|
||||
|
||||
const endDrag = () => {
|
||||
if (Math.abs(dragX) > SWIPE_THRESHOLD) {
|
||||
wasSwipe.current = true
|
||||
setState.mutate({ id: notification.id, changes: { read: !notification.read } })
|
||||
}
|
||||
startX.current = null
|
||||
startY.current = null
|
||||
setDragging(false)
|
||||
setDragX(0)
|
||||
}
|
||||
|
||||
const handleClick = (e: React.MouseEvent) => {
|
||||
if (wasSwipe.current) {
|
||||
e.preventDefault()
|
||||
wasSwipe.current = false
|
||||
}
|
||||
}
|
||||
|
||||
const revealingRead = dragX !== 0
|
||||
|
||||
return (
|
||||
<div className="relative overflow-hidden">
|
||||
{revealingRead && (
|
||||
<div
|
||||
className={cn(
|
||||
'pointer-events-none absolute inset-0 flex items-center px-5 text-xs font-medium',
|
||||
dragX > 0
|
||||
? 'justify-start bg-emerald-500/15 text-emerald-400'
|
||||
: 'justify-end bg-emerald-500/15 text-emerald-400',
|
||||
)}
|
||||
>
|
||||
<span className="flex items-center gap-1.5">
|
||||
<Check className="h-4 w-4" />
|
||||
{notification.read ? 'Mark unread' : 'Mark read'}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
<Link
|
||||
to={`/topics/${notification.topicSlug}/messages/${notification.id}`}
|
||||
onClick={handleClick}
|
||||
onPointerDown={handlePointerDown}
|
||||
onPointerMove={handlePointerMove}
|
||||
onPointerUp={endDrag}
|
||||
onPointerCancel={endDrag}
|
||||
style={{ transform: `translateX(${dragX}px)`, transition: dragging ? 'none' : 'transform 0.2s' }}
|
||||
className="group relative flex items-start gap-3 bg-base-bg px-4 py-3.5 transition-colors hover:bg-base-hover"
|
||||
>
|
||||
<div className="relative mt-0.5 shrink-0">
|
||||
<div
|
||||
className="flex h-9 w-9 items-center justify-center overflow-hidden rounded-full"
|
||||
style={{ backgroundColor: avatarColor.bg }}
|
||||
>
|
||||
{notification.imageUrl ? (
|
||||
<img src={notification.imageUrl} alt="" className="h-full w-full object-cover" />
|
||||
) : (
|
||||
<span className="text-xs font-semibold uppercase" style={{ color: avatarColor.fg }}>
|
||||
{notification.source.slice(0, 2)}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
{!notification.read && (
|
||||
<span className="absolute -right-0.5 -top-0.5 h-2.5 w-2.5 rounded-full bg-accent ring-2 ring-base-bg" />
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="flex items-start justify-between gap-2">
|
||||
<p
|
||||
className={cn(
|
||||
'truncate text-sm',
|
||||
notification.read ? 'font-medium text-slate-300' : 'font-semibold text-slate-100',
|
||||
)}
|
||||
>
|
||||
{notification.title}
|
||||
</p>
|
||||
{notification.starred && (
|
||||
<Star className="h-3.5 w-3.5 shrink-0 fill-amber-400 text-amber-400" />
|
||||
)}
|
||||
</div>
|
||||
<p className="mt-0.5 line-clamp-2 text-sm text-slate-400">{notification.message}</p>
|
||||
<p className="mt-1 flex items-center gap-1.5 text-xs text-slate-500">
|
||||
{showTopic && (
|
||||
<>
|
||||
<span className="rounded bg-base-raised px-1.5 py-0.5 font-medium text-slate-400">
|
||||
{notification.topicDisplayName}
|
||||
</span>
|
||||
<span aria-hidden>·</span>
|
||||
</>
|
||||
)}
|
||||
<span>{formatRelativeTime(notification.createdAt)}</span>
|
||||
{notification.occurrenceCount > 1 && (
|
||||
<>
|
||||
<span aria-hidden>·</span>
|
||||
<span>×{notification.occurrenceCount}</span>
|
||||
</>
|
||||
)}
|
||||
{isQuietPriority(notification.rawPriority) && (
|
||||
<>
|
||||
<span aria-hidden>·</span>
|
||||
<span className="flex items-center gap-1" title="Sent without a push notification">
|
||||
<BellOff className="h-3 w-3" />
|
||||
</span>
|
||||
</>
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
</Link>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,103 @@
|
||||
import { Smartphone, X } from 'lucide-react'
|
||||
import { Link } from 'react-router-dom'
|
||||
import { useDeleteDevice, useDevices } from '../../hooks/usePushDevices'
|
||||
import { usePushSubscription } from '../../hooks/usePushSubscription'
|
||||
import { formatRelativeTime } from '../../lib/utils'
|
||||
import { friendlyDeviceName } from '../../lib/userAgent'
|
||||
|
||||
function PermissionBadge({ permission }: { permission: string }) {
|
||||
const styles: Record<string, string> = {
|
||||
granted: 'bg-emerald-500/15 text-emerald-400',
|
||||
denied: 'bg-red-500/15 text-red-400',
|
||||
default: 'bg-base-raised text-slate-400',
|
||||
unsupported: 'bg-base-raised text-slate-500',
|
||||
}
|
||||
return (
|
||||
<span className={`rounded px-1.5 py-0.5 text-xs font-medium ${styles[permission] ?? styles.default}`}>
|
||||
{permission}
|
||||
</span>
|
||||
)
|
||||
}
|
||||
|
||||
export function PushSection() {
|
||||
const { permission, subscription, isBusy, error, enable, disable, supported } = usePushSubscription()
|
||||
const { data: devices } = useDevices()
|
||||
const deleteDevice = useDeleteDevice()
|
||||
|
||||
return (
|
||||
<section className="rounded-xl border border-base-border bg-base-surface p-4 shadow-card">
|
||||
<div className="mb-3 flex items-center justify-between">
|
||||
<h2 className="text-sm font-semibold text-slate-200">Push notifications</h2>
|
||||
<PermissionBadge permission={permission} />
|
||||
</div>
|
||||
|
||||
{!supported && (
|
||||
<p className="text-sm text-slate-500">
|
||||
This browser doesn't support Web Push. On iPhone, install Notizarr to the Home Screen first
|
||||
(Share → Add to Home Screen) — Safari only supports push for installed PWAs.
|
||||
</p>
|
||||
)}
|
||||
|
||||
{supported && (
|
||||
<>
|
||||
<p className="mb-3 text-sm text-slate-500">
|
||||
{subscription
|
||||
? 'Push is enabled on this device. New notifications will arrive even when Notizarr is closed.'
|
||||
: 'Enable to get notified here even when the app is closed.'}
|
||||
</p>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
disabled={isBusy || permission === 'denied'}
|
||||
onClick={() => (subscription ? disable() : enable())}
|
||||
className="rounded-lg bg-accent px-3.5 py-2 text-sm font-semibold text-white transition-colors hover:bg-accent-dim disabled:opacity-50"
|
||||
>
|
||||
{subscription ? 'Disable on this device' : 'Enable notifications'}
|
||||
</button>
|
||||
|
||||
{permission === 'denied' && (
|
||||
<p className="mt-2 text-xs text-red-400">
|
||||
Notifications are blocked for this site — re-enable them in your browser's site settings.
|
||||
</p>
|
||||
)}
|
||||
{error && <p className="mt-2 text-xs text-red-400">{error}</p>}
|
||||
</>
|
||||
)}
|
||||
|
||||
{devices && devices.length > 0 && (
|
||||
<div className="mt-4 space-y-2 border-t border-base-border pt-3">
|
||||
<p className="text-xs font-medium uppercase tracking-wide text-slate-500">
|
||||
Devices ({devices.length})
|
||||
</p>
|
||||
{devices.map((device) => (
|
||||
<div key={device.id} className="flex items-center gap-2.5 py-1 text-sm">
|
||||
<Smartphone className="h-4 w-4 shrink-0 text-slate-500" />
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className="truncate text-slate-300">
|
||||
{device.deviceLabel ?? friendlyDeviceName(device.userAgent)}
|
||||
</p>
|
||||
<p className="text-xs text-slate-500">
|
||||
{device.disabledAt ? 'Disabled' : `Last seen ${formatRelativeTime(device.lastSeenAt)}`}
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => deleteDevice.mutate(device.id)}
|
||||
className="rounded p-1 text-slate-500 transition-colors hover:bg-base-hover hover:text-red-400"
|
||||
>
|
||||
<X className="h-4 w-4" />
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<Link
|
||||
to="/settings/diagnostics"
|
||||
className="mt-4 block text-xs font-medium text-accent-bright hover:underline"
|
||||
>
|
||||
Push diagnostics →
|
||||
</Link>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
import { Bell, BellOff, Hash } from 'lucide-react'
|
||||
import { Link } from 'react-router-dom'
|
||||
import { formatRelativeTime } from '../../lib/utils'
|
||||
import type { Topic } from '../../types'
|
||||
|
||||
export function TopicCard({ topic }: { topic: Topic }) {
|
||||
return (
|
||||
<Link
|
||||
to={`/topics/${topic.slug}`}
|
||||
className="group flex items-center gap-4 rounded-xl border border-base-border bg-base-surface p-4 shadow-card transition-all hover:-translate-y-0.5 hover:border-base-border hover:bg-base-hover hover:shadow-raised"
|
||||
>
|
||||
<div
|
||||
className="flex h-10 w-10 shrink-0 items-center justify-center rounded-lg text-lg"
|
||||
style={{ backgroundColor: `${topic.accentColor ?? '#818cf8'}1a` }}
|
||||
>
|
||||
{topic.icon ? (
|
||||
<span aria-hidden>{topic.icon}</span>
|
||||
) : (
|
||||
<Hash className="h-[18px] w-[18px]" style={{ color: topic.accentColor ?? '#818cf8' }} />
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="flex items-center gap-2">
|
||||
<p className="truncate text-sm font-medium text-slate-100">{topic.displayName}</p>
|
||||
{topic.unreadCount > 0 && (
|
||||
<span className="flex h-5 min-w-5 items-center justify-center rounded-full bg-accent px-1.5 text-[11px] font-semibold text-white">
|
||||
{topic.unreadCount}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<p className="mt-0.5 truncate text-xs text-slate-500">
|
||||
{topic.totalCount.toLocaleString()} total
|
||||
{topic.latestAt && <> · latest {formatRelativeTime(topic.latestAt)}</>}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{topic.pushEnabled ? (
|
||||
<Bell className="h-[18px] w-[18px] shrink-0 text-slate-500" strokeWidth={1.75} />
|
||||
) : (
|
||||
<BellOff className="h-[18px] w-[18px] shrink-0 text-slate-600" strokeWidth={1.75} />
|
||||
)}
|
||||
</Link>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
import { Check } from 'lucide-react'
|
||||
import { cn } from '../../lib/utils'
|
||||
|
||||
export function Checkbox({
|
||||
checked,
|
||||
onChange,
|
||||
label,
|
||||
}: {
|
||||
checked: boolean
|
||||
onChange: (checked: boolean) => void
|
||||
label?: string
|
||||
}) {
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
role="checkbox"
|
||||
aria-checked={checked}
|
||||
aria-label={label}
|
||||
onClick={() => onChange(!checked)}
|
||||
className={cn(
|
||||
'flex h-5 w-5 shrink-0 items-center justify-center rounded-md border transition-colors',
|
||||
checked
|
||||
? 'border-accent bg-accent'
|
||||
: 'border-base-border bg-base-raised hover:border-slate-500',
|
||||
)}
|
||||
>
|
||||
{checked && <Check className="h-3.5 w-3.5 text-white" strokeWidth={3} />}
|
||||
</button>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
import type { LucideIcon } from 'lucide-react'
|
||||
import type { ReactNode } from 'react'
|
||||
|
||||
export function EmptyState({
|
||||
icon: Icon,
|
||||
title,
|
||||
description,
|
||||
action,
|
||||
}: {
|
||||
icon: LucideIcon
|
||||
title: string
|
||||
description?: string
|
||||
action?: ReactNode
|
||||
}) {
|
||||
return (
|
||||
<div className="flex flex-col items-center justify-center gap-3 px-6 py-16 text-center animate-fade-in">
|
||||
<div className="flex h-12 w-12 items-center justify-center rounded-full bg-base-raised">
|
||||
<Icon className="h-6 w-6 text-slate-500" strokeWidth={1.75} />
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<p className="text-sm font-medium text-slate-200">{title}</p>
|
||||
{description && <p className="max-w-xs text-sm text-slate-500">{description}</p>}
|
||||
</div>
|
||||
{action}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
import type { ReactNode } from 'react'
|
||||
|
||||
export function PageHeader({
|
||||
title,
|
||||
subtitle,
|
||||
action,
|
||||
}: {
|
||||
title: string
|
||||
subtitle?: string
|
||||
action?: ReactNode
|
||||
}) {
|
||||
return (
|
||||
<div className="safe-top flex items-center justify-between border-b border-base-border px-4 py-4 md:px-6 md:py-5">
|
||||
<div>
|
||||
<h1 className="text-lg font-semibold tracking-tight text-slate-100">{title}</h1>
|
||||
{subtitle && <p className="mt-0.5 text-sm text-slate-500">{subtitle}</p>}
|
||||
</div>
|
||||
{action}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
import { cn } from '../../lib/utils'
|
||||
|
||||
export function Skeleton({ className }: { className?: string }) {
|
||||
return <div className={cn('animate-pulse rounded-md bg-base-raised', className)} />
|
||||
}
|
||||
|
||||
export function TopicCardSkeleton() {
|
||||
return (
|
||||
<div className="flex items-center gap-4 rounded-xl border border-base-border bg-base-surface p-4">
|
||||
<Skeleton className="h-10 w-10 shrink-0 rounded-lg" />
|
||||
<div className="flex-1 space-y-2">
|
||||
<Skeleton className="h-4 w-32" />
|
||||
<Skeleton className="h-3 w-48" />
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export function NotificationRowSkeleton() {
|
||||
return (
|
||||
<div className="flex items-start gap-3 px-4 py-3.5">
|
||||
<Skeleton className="h-9 w-9 shrink-0 rounded-full" />
|
||||
<div className="flex-1 space-y-2 pt-0.5">
|
||||
<Skeleton className="h-3.5 w-40" />
|
||||
<Skeleton className="h-3 w-full max-w-sm" />
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
import { cn } from '../../lib/utils'
|
||||
|
||||
export function Switch({
|
||||
checked,
|
||||
onChange,
|
||||
disabled,
|
||||
label,
|
||||
}: {
|
||||
checked: boolean
|
||||
onChange: (checked: boolean) => void
|
||||
disabled?: boolean
|
||||
label?: string
|
||||
}) {
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
role="switch"
|
||||
aria-checked={checked}
|
||||
aria-label={label}
|
||||
disabled={disabled}
|
||||
onClick={() => onChange(!checked)}
|
||||
className={cn(
|
||||
'relative inline-flex h-6 w-11 shrink-0 items-center rounded-full transition-colors disabled:opacity-50',
|
||||
checked ? 'bg-accent' : 'bg-base-raised',
|
||||
)}
|
||||
>
|
||||
<span
|
||||
className={cn(
|
||||
'inline-block h-[18px] w-[18px] transform rounded-full bg-white shadow transition-transform',
|
||||
checked ? 'translate-x-6' : 'translate-x-1',
|
||||
)}
|
||||
/>
|
||||
</button>
|
||||
)
|
||||
}
|
||||
Reference in new issue
Block a user