feat: Phase 2 — auth, node/edge modals, API wiring

- Login page: full-screen dark, grid bg, JWT flow
- Auth store (Zustand) + API client (axios + interceptors)
- NodeModal: add/edit form (type, label, hostname, IP, check method)
- EdgeModal: link type picker + VLAN ID + label on connect
- App: auth gate, canvas load from API (fallback to demo), Ctrl+S save
- CanvasContainer: expose onConnect prop for edge modal flow
This commit is contained in:
Pouzor
2026-03-06 23:33:55 +01:00
parent 9bb34c99cc
commit 150302c3f7
10 changed files with 776 additions and 29 deletions
+115 -26
View File
@@ -1,5 +1,6 @@
import { useEffect, useCallback } from 'react'
import { ReactFlowProvider } from '@xyflow/react'
import { useEffect, useCallback, useState } from 'react'
import { ReactFlowProvider, type Connection } from '@xyflow/react'
import { type Node } from '@xyflow/react'
import { TooltipProvider } from '@/components/ui/tooltip'
import { Toaster } from '@/components/ui/sonner'
import { toast } from 'sonner'
@@ -7,18 +8,53 @@ import { CanvasContainer } from '@/components/canvas/CanvasContainer'
import { Sidebar } from '@/components/panels/Sidebar'
import { Toolbar } from '@/components/panels/Toolbar'
import { DetailPanel } from '@/components/panels/DetailPanel'
import { LoginPage } from '@/components/LoginPage'
import { NodeModal } from '@/components/modals/NodeModal'
import { EdgeModal } from '@/components/modals/EdgeModal'
import { useCanvasStore } from '@/stores/canvasStore'
import { useAuthStore } from '@/stores/authStore'
import { canvasApi } from '@/api/client'
import { demoNodes, demoEdges } from '@/utils/demoData'
import type { NodeData, EdgeData } from '@/types'
export default function App() {
const { loadCanvas, markSaved, selectedNodeId } = useCanvasStore()
const { loadCanvas, markSaved, selectedNodeId, addNode, updateNode, onConnect, nodes } = useCanvasStore()
const { isAuthenticated } = useAuthStore()
// Load demo data on start
const [addNodeOpen, setAddNodeOpen] = useState(false)
const [editNodeId, setEditNodeId] = useState<string | null>(null)
const [pendingConnection, setPendingConnection] = useState<Connection | null>(null)
// Load canvas on auth
useEffect(() => {
loadCanvas(demoNodes, demoEdges)
}, [loadCanvas])
if (!isAuthenticated) return
canvasApi.load()
.then((res) => {
const { nodes: apiNodes, edges: apiEdges } = res.data
if (apiNodes.length > 0) {
// Map API response to React Flow nodes
const rfNodes = apiNodes.map((n: NodeData & { id: string; pos_x: number; pos_y: number }) => ({
id: n.id,
type: n.type,
position: { x: n.pos_x, y: n.pos_y },
data: n,
}))
const rfEdges = apiEdges.map((e: EdgeData & { id: string; source: string; target: string }) => ({
id: e.id,
source: e.source,
target: e.target,
type: e.type,
data: e,
}))
loadCanvas(rfNodes, rfEdges)
} else {
loadCanvas(demoNodes, demoEdges)
}
})
.catch(() => loadCanvas(demoNodes, demoEdges))
}, [isAuthenticated, loadCanvas])
// Ctrl+S shortcut
// Ctrl+S
useEffect(() => {
const handler = (e: KeyboardEvent) => {
if ((e.ctrlKey || e.metaKey) && e.key === 's') {
@@ -30,45 +66,98 @@ export default function App() {
return () => window.removeEventListener('keydown', handler)
})
const handleSave = useCallback(() => {
// TODO: POST /api/v1/canvas/save
markSaved()
toast.success('Canvas saved')
}, [markSaved])
const handleSave = useCallback(async () => {
try {
const nodePositions = nodes.map((n) => ({ id: n.id, x: n.position.x, y: n.position.y }))
await canvasApi.save({ node_positions: nodePositions, viewport: {} })
markSaved()
toast.success('Canvas saved')
} catch {
// Backend not running — mark saved anyway in dev
markSaved()
toast.success('Canvas saved (local)')
}
}, [nodes, markSaved])
const handleScan = useCallback(() => {
toast.info('Network scan not yet implemented')
const handleAddNode = useCallback((data: Partial<NodeData>) => {
const id = crypto.randomUUID()
const newNode: Node<NodeData> = {
id,
type: data.type ?? 'generic',
position: { x: 300, y: 300 },
data: { status: 'unknown', services: [], ...data } as NodeData,
}
addNode(newNode)
toast.success(`Added "${data.label}"`)
}, [addNode])
const handleEditNode = useCallback((id: string) => {
setEditNodeId(id)
}, [])
const handleAutoLayout = useCallback(() => {
toast.info('Auto-layout not yet implemented')
const handleUpdateNode = useCallback((data: Partial<NodeData>) => {
if (!editNodeId) return
updateNode(editNodeId, data)
setEditNodeId(null)
}, [editNodeId, updateNode])
const handleEdgeConnect = useCallback((connection: Connection) => {
setPendingConnection(connection)
}, [])
const handleExport = useCallback(() => {
toast.info('Export not yet implemented')
}, [])
const handleEdgeConfirm = useCallback((edgeData: EdgeData) => {
if (!pendingConnection) return
onConnect({ ...pendingConnection, ...edgeData })
setPendingConnection(null)
}, [pendingConnection, onConnect])
const handleEditNode = useCallback((_id: string) => {
toast.info('Edit node — not yet implemented')
}, [])
const editNode = editNodeId ? nodes.find((n) => n.id === editNodeId) : null
if (!isAuthenticated) return <LoginPage />
return (
<TooltipProvider>
<ReactFlowProvider>
<div className="flex h-screen w-screen overflow-hidden bg-[#0d1117]">
<Sidebar
onAddNode={() => toast.info('Add node — not yet implemented')}
onScan={handleScan}
onAddNode={() => setAddNodeOpen(true)}
onScan={() => toast.info('Network scan not yet implemented')}
onSave={handleSave}
/>
<div className="flex flex-col flex-1 min-w-0">
<Toolbar onSave={handleSave} onAutoLayout={handleAutoLayout} onExport={handleExport} />
<Toolbar
onSave={handleSave}
onAutoLayout={() => toast.info('Auto-layout not yet implemented')}
onExport={() => toast.info('Export not yet implemented')}
/>
<div className="flex flex-1 min-h-0">
<CanvasContainer />
<CanvasContainer onConnect={handleEdgeConnect} />
{selectedNodeId && <DetailPanel onEdit={handleEditNode} />}
</div>
</div>
</div>
<NodeModal
open={addNodeOpen}
onClose={() => setAddNodeOpen(false)}
onSubmit={handleAddNode}
title="Add Node"
/>
<NodeModal
open={!!editNodeId}
onClose={() => setEditNodeId(null)}
onSubmit={handleUpdateNode}
initial={editNode?.data}
title="Edit Node"
/>
<EdgeModal
open={!!pendingConnection}
onClose={() => setPendingConnection(null)}
onSubmit={handleEdgeConfirm}
/>
<Toaster theme="dark" position="bottom-right" />
</ReactFlowProvider>
</TooltipProvider>
+42
View File
@@ -0,0 +1,42 @@
import axios from 'axios'
import { useAuthStore } from '@/stores/authStore'
export const api = axios.create({
baseURL: '/api/v1',
})
api.interceptors.request.use((config) => {
const token = useAuthStore.getState().token
if (token) config.headers.Authorization = `Bearer ${token}`
return config
})
api.interceptors.response.use(
(r) => r,
(err) => {
if (err.response?.status === 401) useAuthStore.getState().logout()
return Promise.reject(err)
}
)
export const authApi = {
login: (username: string, password: string) =>
api.post<{ access_token: string }>('/auth/login', { username, password }),
}
export const canvasApi = {
load: () => api.get('/canvas'),
save: (payload: { node_positions: { id: string; x: number; y: number }[]; viewport: object }) =>
api.post('/canvas/save', payload),
}
export const nodesApi = {
create: (data: object) => api.post('/nodes', data),
update: (id: string, data: object) => api.patch(`/nodes/${id}`, data),
delete: (id: string) => api.delete(`/nodes/${id}`),
}
export const edgesApi = {
create: (data: object) => api.post('/edges', data),
delete: (id: string) => api.delete(`/edges/${id}`),
}
+103
View File
@@ -0,0 +1,103 @@
import { useState } from 'react'
import { Network, Loader2 } from 'lucide-react'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label'
import { authApi } from '@/api/client'
import { useAuthStore } from '@/stores/authStore'
export function LoginPage() {
const [username, setUsername] = useState('')
const [password, setPassword] = useState('')
const [error, setError] = useState('')
const [loading, setLoading] = useState(false)
const login = useAuthStore((s) => s.login)
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault()
setError('')
setLoading(true)
try {
const res = await authApi.login(username, password)
login(res.data.access_token)
} catch {
setError('Invalid username or password')
} finally {
setLoading(false)
}
}
return (
<div className="flex h-screen w-screen items-center justify-center bg-[#0d1117]">
{/* Grid background */}
<div
className="absolute inset-0 opacity-[0.03]"
style={{
backgroundImage: 'linear-gradient(#00d4ff 1px, transparent 1px), linear-gradient(90deg, #00d4ff 1px, transparent 1px)',
backgroundSize: '48px 48px',
}}
/>
<div className="relative w-full max-w-sm px-4">
{/* Logo */}
<div className="flex flex-col items-center gap-3 mb-8">
<div className="flex items-center justify-center w-14 h-14 rounded-xl bg-[#00d4ff]/10 border border-[#00d4ff]/20">
<Network size={28} className="text-[#00d4ff]" />
</div>
<div className="text-center">
<h1 className="text-xl font-semibold text-foreground tracking-wide">Homelable</h1>
<p className="text-xs text-muted-foreground mt-0.5">HomeLab Visualizer</p>
</div>
</div>
{/* Form */}
<form
onSubmit={handleSubmit}
className="flex flex-col gap-4 bg-[#161b22] border border-[#30363d] rounded-xl p-6"
>
<div className="flex flex-col gap-1.5">
<Label htmlFor="username" className="text-xs text-muted-foreground">Username</Label>
<Input
id="username"
autoComplete="username"
value={username}
onChange={(e) => setUsername(e.target.value)}
className="bg-[#21262d] border-[#30363d] focus-visible:ring-[#00d4ff]/50 text-sm"
placeholder="admin"
required
/>
</div>
<div className="flex flex-col gap-1.5">
<Label htmlFor="password" className="text-xs text-muted-foreground">Password</Label>
<Input
id="password"
type="password"
autoComplete="current-password"
value={password}
onChange={(e) => setPassword(e.target.value)}
className="bg-[#21262d] border-[#30363d] focus-visible:ring-[#00d4ff]/50 text-sm"
required
/>
</div>
{error && (
<p className="text-xs text-[#f85149] text-center">{error}</p>
)}
<Button
type="submit"
disabled={loading}
className="mt-1 bg-[#00d4ff] text-[#0d1117] hover:bg-[#00d4ff]/90 font-medium"
>
{loading ? <Loader2 size={15} className="animate-spin" /> : 'Sign in'}
</Button>
</form>
<p className="text-center text-[10px] text-muted-foreground/40 mt-4">
Credentials configured in <span className="font-mono">config.yml</span>
</p>
</div>
</div>
)
}
@@ -7,6 +7,7 @@ import {
BackgroundVariant,
type Node,
type Edge,
type Connection,
} from '@xyflow/react'
import '@xyflow/react/dist/style.css'
import { useCanvasStore } from '@/stores/canvasStore'
@@ -14,10 +15,14 @@ import { nodeTypes } from './nodes'
import { edgeTypes } from './edges'
import type { NodeData, EdgeData } from '@/types'
export function CanvasContainer() {
interface CanvasContainerProps {
onConnect?: (connection: Connection) => void
}
export function CanvasContainer({ onConnect: onConnectProp }: CanvasContainerProps) {
const {
nodes, edges,
onNodesChange, onEdgesChange, onConnect,
onNodesChange, onEdgesChange,
setSelectedNode,
} = useCanvasStore()
@@ -36,7 +41,7 @@ export function CanvasContainer() {
edges={edges as Edge<EdgeData>[]}
onNodesChange={onNodesChange}
onEdgesChange={onEdgesChange}
onConnect={onConnect}
onConnect={onConnectProp}
onNodeClick={onNodeClick}
onPaneClick={onPaneClick}
nodeTypes={nodeTypes}
@@ -0,0 +1,92 @@
import { useState } from 'react'
import { Dialog, DialogContent, DialogHeader, DialogTitle } from '@/components/ui/dialog'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label'
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'
import { EDGE_TYPE_LABELS, type EdgeData, type EdgeType } from '@/types'
const EDGE_TYPES = Object.entries(EDGE_TYPE_LABELS) as [EdgeType, string][]
interface EdgeModalProps {
open: boolean
onClose: () => void
onSubmit: (data: EdgeData) => void
}
export function EdgeModal({ open, onClose, onSubmit }: EdgeModalProps) {
const [type, setType] = useState<EdgeType>('ethernet')
const [label, setLabel] = useState('')
const [vlanId, setVlanId] = useState('')
const handleSubmit = (e: React.FormEvent) => {
e.preventDefault()
onSubmit({
type,
label: label || undefined,
vlan_id: type === 'vlan' && vlanId ? parseInt(vlanId) : undefined,
})
onClose()
setType('ethernet')
setLabel('')
setVlanId('')
}
return (
<Dialog open={open} onOpenChange={(o) => !o && onClose()}>
<DialogContent className="bg-[#161b22] border-[#30363d] text-foreground max-w-xs">
<DialogHeader>
<DialogTitle className="text-sm font-semibold">Connect Nodes</DialogTitle>
</DialogHeader>
<form onSubmit={handleSubmit} className="flex flex-col gap-3 mt-2">
<div className="flex flex-col gap-1.5">
<Label className="text-xs text-muted-foreground">Link Type</Label>
<Select value={type} onValueChange={(v) => setType(v as EdgeType)}>
<SelectTrigger className="bg-[#21262d] border-[#30363d] text-sm h-8">
<SelectValue />
</SelectTrigger>
<SelectContent className="bg-[#21262d] border-[#30363d]">
{EDGE_TYPES.map(([value, label]) => (
<SelectItem key={value} value={value} className="text-sm">{label}</SelectItem>
))}
</SelectContent>
</Select>
</div>
{type === 'vlan' && (
<div className="flex flex-col gap-1.5">
<Label className="text-xs text-muted-foreground">VLAN ID</Label>
<Input
type="number"
min={1}
max={4094}
value={vlanId}
onChange={(e) => setVlanId(e.target.value)}
placeholder="e.g. 20"
className="bg-[#21262d] border-[#30363d] font-mono text-sm h-8"
/>
</div>
)}
<div className="flex flex-col gap-1.5">
<Label className="text-xs text-muted-foreground">Label <span className="text-muted-foreground/50">(optional)</span></Label>
<Input
value={label}
onChange={(e) => setLabel(e.target.value)}
placeholder="e.g. 1G, trunk..."
className="bg-[#21262d] border-[#30363d] text-sm h-8"
/>
</div>
<div className="flex justify-end gap-2 pt-1">
<Button type="button" variant="ghost" size="sm" onClick={onClose}>Cancel</Button>
<Button type="submit" size="sm" className="bg-[#00d4ff] text-[#0d1117] hover:bg-[#00d4ff]/90">
Connect
</Button>
</div>
</form>
</DialogContent>
</Dialog>
)
}
@@ -0,0 +1,162 @@
import { useState, useEffect } from 'react'
import { Dialog, DialogContent, DialogHeader, DialogTitle } from '@/components/ui/dialog'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label'
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'
import { NODE_TYPE_LABELS, type NodeData, type NodeType, type CheckMethod } from '@/types'
const NODE_TYPES = Object.entries(NODE_TYPE_LABELS) as [NodeType, string][]
const CHECK_METHODS: CheckMethod[] = ['ping', 'http', 'https', 'tcp', 'ssh', 'prometheus', 'health']
const DEFAULT_DATA: Partial<NodeData> = {
type: 'server',
label: '',
hostname: '',
ip: '',
status: 'unknown',
check_method: 'ping',
services: [],
}
interface NodeModalProps {
open: boolean
onClose: () => void
onSubmit: (data: Partial<NodeData>) => void
initial?: Partial<NodeData>
title?: string
}
export function NodeModal({ open, onClose, onSubmit, initial, title = 'Add Node' }: NodeModalProps) {
const [form, setForm] = useState<Partial<NodeData>>({ ...DEFAULT_DATA, ...initial })
useEffect(() => {
setForm({ ...DEFAULT_DATA, ...initial })
}, [initial, open])
const set = (key: keyof NodeData, value: unknown) =>
setForm((f) => ({ ...f, [key]: value }))
const handleSubmit = (e: React.FormEvent) => {
e.preventDefault()
if (!form.label?.trim()) return
onSubmit(form)
onClose()
}
return (
<Dialog open={open} onOpenChange={(o) => !o && onClose()}>
<DialogContent className="bg-[#161b22] border-[#30363d] text-foreground max-w-md">
<DialogHeader>
<DialogTitle className="text-sm font-semibold">{title}</DialogTitle>
</DialogHeader>
<form onSubmit={handleSubmit} className="flex flex-col gap-4 mt-2">
<div className="grid grid-cols-2 gap-3">
{/* Type */}
<div className="flex flex-col gap-1.5 col-span-2">
<Label className="text-xs text-muted-foreground">Type</Label>
<Select value={form.type} onValueChange={(v) => set('type', v as NodeType)}>
<SelectTrigger className="bg-[#21262d] border-[#30363d] text-sm h-8">
<SelectValue />
</SelectTrigger>
<SelectContent className="bg-[#21262d] border-[#30363d]">
{NODE_TYPES.map(([value, label]) => (
<SelectItem key={value} value={value} className="text-sm">
{label}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
{/* Label */}
<div className="flex flex-col gap-1.5 col-span-2">
<Label className="text-xs text-muted-foreground">Label *</Label>
<Input
value={form.label ?? ''}
onChange={(e) => set('label', e.target.value)}
placeholder="My Server"
className="bg-[#21262d] border-[#30363d] text-sm h-8"
required
/>
</div>
{/* Hostname */}
<div className="flex flex-col gap-1.5">
<Label className="text-xs text-muted-foreground">Hostname</Label>
<Input
value={form.hostname ?? ''}
onChange={(e) => set('hostname', e.target.value)}
placeholder="server.lan"
className="bg-[#21262d] border-[#30363d] font-mono text-sm h-8"
/>
</div>
{/* IP */}
<div className="flex flex-col gap-1.5">
<Label className="text-xs text-muted-foreground">IP Address</Label>
<Input
value={form.ip ?? ''}
onChange={(e) => set('ip', e.target.value)}
placeholder="192.168.1.x"
className="bg-[#21262d] border-[#30363d] font-mono text-sm h-8"
/>
</div>
{/* Check method */}
<div className="flex flex-col gap-1.5">
<Label className="text-xs text-muted-foreground">Check Method</Label>
<Select value={form.check_method ?? 'ping'} onValueChange={(v) => set('check_method', v as CheckMethod)}>
<SelectTrigger className="bg-[#21262d] border-[#30363d] text-sm h-8">
<SelectValue />
</SelectTrigger>
<SelectContent className="bg-[#21262d] border-[#30363d]">
{CHECK_METHODS.map((m) => (
<SelectItem key={m} value={m} className="text-sm font-mono">{m}</SelectItem>
))}
</SelectContent>
</Select>
</div>
{/* Check target */}
<div className="flex flex-col gap-1.5">
<Label className="text-xs text-muted-foreground">Check Target</Label>
<Input
value={form.check_target ?? ''}
onChange={(e) => set('check_target', e.target.value)}
placeholder="http://..."
className="bg-[#21262d] border-[#30363d] font-mono text-sm h-8"
/>
</div>
{/* Notes */}
<div className="flex flex-col gap-1.5 col-span-2">
<Label className="text-xs text-muted-foreground">Notes</Label>
<Input
value={form.notes ?? ''}
onChange={(e) => set('notes', e.target.value)}
placeholder="Optional notes"
className="bg-[#21262d] border-[#30363d] text-sm h-8"
/>
</div>
</div>
<div className="flex justify-end gap-2 pt-1">
<Button type="button" variant="ghost" size="sm" onClick={onClose}>
Cancel
</Button>
<Button
type="submit"
size="sm"
className="bg-[#00d4ff] text-[#0d1117] hover:bg-[#00d4ff]/90"
>
{title === 'Add Node' ? 'Add' : 'Save'}
</Button>
</div>
</form>
</DialogContent>
</Dialog>
)
}
+20
View File
@@ -0,0 +1,20 @@
import * as React from "react"
import { Input as InputPrimitive } from "@base-ui/react/input"
import { cn } from "@/lib/utils"
function Input({ className, type, ...props }: React.ComponentProps<"input">) {
return (
<InputPrimitive
type={type}
data-slot="input"
className={cn(
"h-8 w-full min-w-0 rounded-lg border border-input bg-transparent px-2.5 py-1 text-base transition-colors outline-none file:inline-flex file:h-6 file:border-0 file:bg-transparent file:text-sm file:font-medium file:text-foreground placeholder:text-muted-foreground focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50 disabled:pointer-events-none disabled:cursor-not-allowed disabled:bg-input/50 disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-3 aria-invalid:ring-destructive/20 md:text-sm dark:bg-input/30 dark:disabled:bg-input/80 dark:aria-invalid:border-destructive/50 dark:aria-invalid:ring-destructive/40",
className
)}
{...props}
/>
)
}
export { Input }
+18
View File
@@ -0,0 +1,18 @@
import * as React from "react"
import { cn } from "@/lib/utils"
function Label({ className, ...props }: React.ComponentProps<"label">) {
return (
<label
data-slot="label"
className={cn(
"flex items-center gap-2 text-sm leading-none font-medium select-none group-data-[disabled=true]:pointer-events-none group-data-[disabled=true]:opacity-50 peer-disabled:cursor-not-allowed peer-disabled:opacity-50",
className
)}
{...props}
/>
)
}
export { Label }
+201
View File
@@ -0,0 +1,201 @@
"use client"
import * as React from "react"
import { Select as SelectPrimitive } from "@base-ui/react/select"
import { cn } from "@/lib/utils"
import { ChevronDownIcon, CheckIcon, ChevronUpIcon } from "lucide-react"
const Select = SelectPrimitive.Root
function SelectGroup({ className, ...props }: SelectPrimitive.Group.Props) {
return (
<SelectPrimitive.Group
data-slot="select-group"
className={cn("scroll-my-1 p-1", className)}
{...props}
/>
)
}
function SelectValue({ className, ...props }: SelectPrimitive.Value.Props) {
return (
<SelectPrimitive.Value
data-slot="select-value"
className={cn("flex flex-1 text-left", className)}
{...props}
/>
)
}
function SelectTrigger({
className,
size = "default",
children,
...props
}: SelectPrimitive.Trigger.Props & {
size?: "sm" | "default"
}) {
return (
<SelectPrimitive.Trigger
data-slot="select-trigger"
data-size={size}
className={cn(
"flex w-fit items-center justify-between gap-1.5 rounded-lg border border-input bg-transparent py-2 pr-2 pl-2.5 text-sm whitespace-nowrap transition-colors outline-none select-none focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50 disabled:cursor-not-allowed disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-3 aria-invalid:ring-destructive/20 data-placeholder:text-muted-foreground data-[size=default]:h-8 data-[size=sm]:h-7 data-[size=sm]:rounded-[min(var(--radius-md),10px)] *:data-[slot=select-value]:line-clamp-1 *:data-[slot=select-value]:flex *:data-[slot=select-value]:items-center *:data-[slot=select-value]:gap-1.5 dark:bg-input/30 dark:hover:bg-input/50 dark:aria-invalid:border-destructive/50 dark:aria-invalid:ring-destructive/40 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
className
)}
{...props}
>
{children}
<SelectPrimitive.Icon
render={
<ChevronDownIcon className="pointer-events-none size-4 text-muted-foreground" />
}
/>
</SelectPrimitive.Trigger>
)
}
function SelectContent({
className,
children,
side = "bottom",
sideOffset = 4,
align = "center",
alignOffset = 0,
alignItemWithTrigger = true,
...props
}: SelectPrimitive.Popup.Props &
Pick<
SelectPrimitive.Positioner.Props,
"align" | "alignOffset" | "side" | "sideOffset" | "alignItemWithTrigger"
>) {
return (
<SelectPrimitive.Portal>
<SelectPrimitive.Positioner
side={side}
sideOffset={sideOffset}
align={align}
alignOffset={alignOffset}
alignItemWithTrigger={alignItemWithTrigger}
className="isolate z-50"
>
<SelectPrimitive.Popup
data-slot="select-content"
data-align-trigger={alignItemWithTrigger}
className={cn("relative isolate z-50 max-h-(--available-height) w-(--anchor-width) min-w-36 origin-(--transform-origin) overflow-x-hidden overflow-y-auto rounded-lg bg-popover text-popover-foreground shadow-md ring-1 ring-foreground/10 duration-100 data-[align-trigger=true]:animate-none data-[side=bottom]:slide-in-from-top-2 data-[side=inline-end]:slide-in-from-left-2 data-[side=inline-start]:slide-in-from-right-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 data-open:animate-in data-open:fade-in-0 data-open:zoom-in-95 data-closed:animate-out data-closed:fade-out-0 data-closed:zoom-out-95", className )}
{...props}
>
<SelectScrollUpButton />
<SelectPrimitive.List>{children}</SelectPrimitive.List>
<SelectScrollDownButton />
</SelectPrimitive.Popup>
</SelectPrimitive.Positioner>
</SelectPrimitive.Portal>
)
}
function SelectLabel({
className,
...props
}: SelectPrimitive.GroupLabel.Props) {
return (
<SelectPrimitive.GroupLabel
data-slot="select-label"
className={cn("px-1.5 py-1 text-xs text-muted-foreground", className)}
{...props}
/>
)
}
function SelectItem({
className,
children,
...props
}: SelectPrimitive.Item.Props) {
return (
<SelectPrimitive.Item
data-slot="select-item"
className={cn(
"relative flex w-full cursor-default items-center gap-1.5 rounded-md py-1 pr-8 pl-1.5 text-sm outline-hidden select-none focus:bg-accent focus:text-accent-foreground not-data-[variant=destructive]:focus:**:text-accent-foreground data-disabled:pointer-events-none data-disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4 *:[span]:last:flex *:[span]:last:items-center *:[span]:last:gap-2",
className
)}
{...props}
>
<SelectPrimitive.ItemText className="flex flex-1 shrink-0 gap-2 whitespace-nowrap">
{children}
</SelectPrimitive.ItemText>
<SelectPrimitive.ItemIndicator
render={
<span className="pointer-events-none absolute right-2 flex size-4 items-center justify-center" />
}
>
<CheckIcon className="pointer-events-none" />
</SelectPrimitive.ItemIndicator>
</SelectPrimitive.Item>
)
}
function SelectSeparator({
className,
...props
}: SelectPrimitive.Separator.Props) {
return (
<SelectPrimitive.Separator
data-slot="select-separator"
className={cn("pointer-events-none -mx-1 my-1 h-px bg-border", className)}
{...props}
/>
)
}
function SelectScrollUpButton({
className,
...props
}: React.ComponentProps<typeof SelectPrimitive.ScrollUpArrow>) {
return (
<SelectPrimitive.ScrollUpArrow
data-slot="select-scroll-up-button"
className={cn(
"top-0 z-10 flex w-full cursor-default items-center justify-center bg-popover py-1 [&_svg:not([class*='size-'])]:size-4",
className
)}
{...props}
>
<ChevronUpIcon
/>
</SelectPrimitive.ScrollUpArrow>
)
}
function SelectScrollDownButton({
className,
...props
}: React.ComponentProps<typeof SelectPrimitive.ScrollDownArrow>) {
return (
<SelectPrimitive.ScrollDownArrow
data-slot="select-scroll-down-button"
className={cn(
"bottom-0 z-10 flex w-full cursor-default items-center justify-center bg-popover py-1 [&_svg:not([class*='size-'])]:size-4",
className
)}
{...props}
>
<ChevronDownIcon
/>
</SelectPrimitive.ScrollDownArrow>
)
}
export {
Select,
SelectContent,
SelectGroup,
SelectItem,
SelectLabel,
SelectScrollDownButton,
SelectScrollUpButton,
SelectSeparator,
SelectTrigger,
SelectValue,
}
+15
View File
@@ -0,0 +1,15 @@
import { create } from 'zustand'
interface AuthState {
token: string | null
isAuthenticated: boolean
login: (token: string) => void
logout: () => void
}
export const useAuthStore = create<AuthState>((set) => ({
token: null,
isAuthenticated: false,
login: (token) => set({ token, isAuthenticated: true }),
logout: () => set({ token: null, isAuthenticated: false }),
}))