Selaa lähdekoodia

feat: 集成 shadcn/ui 组件库,添加明暗主题切换

- 引入 shadcn/ui (New York style) 替换所有页面的按钮、表格、下拉框、开关、对话框等组件
- 添加 next-themes 支持亮色/暗色模式切换,默认暗色
- 重写 globals.css 使用 oklch 色彩变量,支持亮暗双主题
- Badge 组件扩展 success/warning 变体
- Positions 页面默认隐藏 closed 仓位,可通过开关显示
- Positions 表格 Created 字段精确到分钟,按时间倒序排列

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
zhangchunrui 1 viikko sitten
vanhempi
commit
5e85b709c7

+ 18 - 0
components.json

@@ -0,0 +1,18 @@
+{
+  "$schema": "https://ui.shadcn.com/schema.json",
+  "style": "new-york",
+  "rsc": false,
+  "tsx": true,
+  "tailwind": {
+    "config": "",
+    "css": "src/app/globals.css",
+    "baseColor": "zinc",
+    "cssVariables": true
+  },
+  "aliases": {
+    "components": "@/components",
+    "utils": "@/lib/utils",
+    "ui": "@/components/ui",
+    "hooks": "@/hooks"
+  }
+}

+ 8 - 1
package.json

@@ -19,13 +19,19 @@
     "better-sqlite3": "^11.10.0",
     "bn.js": "^5.2.3",
     "bs58": "^6.0.0",
+    "class-variance-authority": "^0.7.1",
+    "clsx": "^2.1.1",
     "decimal.js": "^10.6.0",
     "ky": "^1.14.3",
     "lodash-es": "^4.17.23",
+    "lucide-react": "^0.577.0",
     "next": "16.1.6",
+    "next-themes": "^0.4.6",
+    "radix-ui": "^1.4.3",
     "react": "19.2.3",
     "react-dom": "19.2.3",
-    "swr": "^2.4.1"
+    "swr": "^2.4.1",
+    "tailwind-merge": "^3.5.0"
   },
   "devDependencies": {
     "@tailwindcss/postcss": "^4",
@@ -39,6 +45,7 @@
     "eslint-config-next": "16.1.6",
     "prettier": "^3.8.1",
     "tailwindcss": "^4",
+    "tw-animate-css": "^1.4.0",
     "typescript": "^5"
   }
 }

Tiedoston diff-näkymää rajattu, sillä se on liian suuri
+ 886 - 160
pnpm-lock.yaml


+ 96 - 87
src/app/addresses/page.tsx

@@ -2,6 +2,12 @@
 
 import { useState } from 'react'
 import useSWR from 'swr'
+import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
+import { Input } from '@/components/ui/input'
+import { Label } from '@/components/ui/label'
+import { Button } from '@/components/ui/button'
+import { Switch } from '@/components/ui/switch'
+import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'
 
 const fetcher = (url: string) => fetch(url).then((r) => r.json())
 
@@ -54,103 +60,105 @@ function AddressItem({
   }
 
   return (
-    <div className="py-3 border-b border-zinc-800/50 last:border-0">
+    <div className="py-3 border-b border-border last:border-0">
       <div className="flex items-center justify-between">
         <div className="flex-1 min-w-0">
           <div className="flex items-center gap-2">
-            <code className="text-xs text-zinc-300 truncate">{addr.address}</code>
-            {addr.label && <span className="text-xs text-zinc-500">({addr.label})</span>}
+            <code className="text-xs text-foreground truncate">{addr.address}</code>
+            {addr.label && <span className="text-xs text-muted-foreground">({addr.label})</span>}
           </div>
-          <div className="flex items-center gap-3 mt-1 text-[10px] text-zinc-500">
+          <div className="flex items-center gap-3 mt-1 text-[10px] text-muted-foreground">
             <span>Added {new Date(addr.created_at + 'Z').toLocaleDateString()}</span>
             <span>
               Multiplier:{' '}
-              <span className="text-zinc-400">
+              <span className="text-muted-foreground">
                 {addr.copy_multiplier != null ? `${addr.copy_multiplier}x` : 'default'}
               </span>
             </span>
             <span>
               Max USD:{' '}
-              <span className="text-zinc-400">
+              <span className="text-muted-foreground">
                 {addr.copy_max_usd != null ? `$${addr.copy_max_usd}` : 'default'}
               </span>
             </span>
             <span>
               Referrer:{' '}
-              <span className="text-zinc-400">
+              <span className="text-muted-foreground">
                 {addr.referrer_mode === 'follow_target' ? 'follow target' : 'self'}
               </span>
             </span>
           </div>
         </div>
         <div className="flex items-center gap-2 ml-4">
-          <button
+          <Button
+            variant="ghost"
+            size="sm"
             onClick={() => setEditing(!editing)}
-            className="text-xs px-2 py-1 rounded bg-zinc-500/20 text-zinc-400 hover:bg-zinc-500/30"
+            className="text-xs h-7"
           >
             {editing ? 'Cancel' : 'Settings'}
-          </button>
-          <button
-            onClick={() => onToggle(addr.id, !addr.enabled)}
-            className={`text-xs px-2 py-1 rounded ${
-              addr.enabled ? 'bg-green-500/20 text-green-400' : 'bg-zinc-500/20 text-zinc-400'
-            }`}
-          >
-            {addr.enabled ? 'Enabled' : 'Disabled'}
-          </button>
-          <button
+          </Button>
+          <Switch
+            checked={!!addr.enabled}
+            onCheckedChange={(checked) => onToggle(addr.id, checked)}
+          />
+          <Button
+            variant="destructive"
+            size="sm"
             onClick={() => onDelete(addr.id)}
-            className="text-xs px-2 py-1 rounded bg-red-500/20 text-red-400 hover:bg-red-500/30"
+            className="text-xs h-7"
           >
             Delete
-          </button>
+          </Button>
         </div>
       </div>
       {editing && (
         <div className="mt-2 flex items-end gap-2 pl-1">
           <div>
-            <label className="block text-[10px] text-zinc-500 mb-0.5">Multiplier</label>
-            <input
+            <Label className="block text-[10px] text-muted-foreground mb-0.5">Multiplier</Label>
+            <Input
               type="number"
               step="0.1"
               min="0.01"
               placeholder="default"
               value={multiplier}
               onChange={(e) => setMultiplier(e.target.value)}
-              className="w-24 px-2 py-1 text-xs rounded border border-[var(--border)] bg-[var(--background)] text-[var(--foreground)] focus:outline-none focus:border-indigo-500"
+              className="w-24 h-8 text-xs"
             />
           </div>
           <div>
-            <label className="block text-[10px] text-zinc-500 mb-0.5">Max USD</label>
-            <input
+            <Label className="block text-[10px] text-muted-foreground mb-0.5">Max USD</Label>
+            <Input
               type="number"
               step="100"
               min="1"
               placeholder="default"
               value={maxUsd}
               onChange={(e) => setMaxUsd(e.target.value)}
-              className="w-28 px-2 py-1 text-xs rounded border border-[var(--border)] bg-[var(--background)] text-[var(--foreground)] focus:outline-none focus:border-indigo-500"
+              className="w-28 h-8 text-xs"
             />
           </div>
           <div>
-            <label className="block text-[10px] text-zinc-500 mb-0.5">Referrer Mode</label>
-            <select
-              value={referrerMode}
-              onChange={(e) => setReferrerMode(e.target.value as 'self' | 'follow_target')}
-              className="w-32 px-2 py-1 text-xs rounded border border-[var(--border)] bg-[var(--background)] text-[var(--foreground)] focus:outline-none focus:border-indigo-500"
-            >
-              <option value="self">Self</option>
-              <option value="follow_target">Follow Target</option>
-            </select>
+            <Label className="block text-[10px] text-muted-foreground mb-0.5">Referrer Mode</Label>
+            <Select value={referrerMode} onValueChange={(v) => setReferrerMode(v as 'self' | 'follow_target')}>
+              <SelectTrigger className="w-32 h-8 text-xs">
+                <SelectValue />
+              </SelectTrigger>
+              <SelectContent>
+                <SelectItem value="self">Self</SelectItem>
+                <SelectItem value="follow_target">Follow Target</SelectItem>
+              </SelectContent>
+            </Select>
           </div>
-          <button
+          <Button
             onClick={handleSave}
             disabled={saving}
-            className="px-3 py-1 text-xs rounded bg-indigo-500 text-white hover:bg-indigo-400 disabled:opacity-50 transition-colors"
+            size="sm"
+            className="text-xs h-8"
           >
             {saving ? 'Saving...' : 'Save'}
-          </button>
-          <span className="text-[10px] text-zinc-600">Leave empty to use global defaults</span>
+          </Button>
+          <span className="text-[10px] text-muted-foreground">Leave empty to use global defaults</span>
         </div>
       )}
     </div>
@@ -206,54 +214,55 @@ export default function AddressesPage() {
     <div className="space-y-6">
       <h2 className="text-lg font-semibold">Watched Addresses</h2>
 
-      <form
-        onSubmit={handleAdd}
-        className="rounded-lg border border-[var(--border)] bg-[var(--card)] p-4 space-y-3"
-      >
-        <h3 className="text-sm font-medium">Add Address</h3>
-        <div className="flex gap-2">
-          <input
-            type="text"
-            placeholder="Solana wallet address"
-            value={newAddress}
-            onChange={(e) => setNewAddress(e.target.value)}
-            className="flex-1 px-3 py-1.5 text-sm rounded border border-[var(--border)] bg-[var(--background)] text-[var(--foreground)] focus:outline-none focus:border-indigo-500"
-          />
-          <input
-            type="text"
-            placeholder="Label (optional)"
-            value={newLabel}
-            onChange={(e) => setNewLabel(e.target.value)}
-            className="w-40 px-3 py-1.5 text-sm rounded border border-[var(--border)] bg-[var(--background)] text-[var(--foreground)] focus:outline-none focus:border-indigo-500"
-          />
-          <button
-            type="submit"
-            disabled={adding || !newAddress.trim()}
-            className="px-4 py-1.5 text-sm rounded bg-indigo-500 text-white hover:bg-indigo-400 disabled:opacity-50 transition-colors"
-          >
-            Add
-          </button>
-        </div>
-      </form>
+      <Card>
+        <CardHeader>
+          <CardTitle className="text-sm">Add Address</CardTitle>
+        </CardHeader>
+        <CardContent>
+          <form onSubmit={handleAdd} className="flex gap-2">
+            <Input
+              type="text"
+              placeholder="Solana wallet address"
+              value={newAddress}
+              onChange={(e) => setNewAddress(e.target.value)}
+              className="flex-1"
+            />
+            <Input
+              type="text"
+              placeholder="Label (optional)"
+              value={newLabel}
+              onChange={(e) => setNewLabel(e.target.value)}
+              className="w-40"
+            />
+            <Button type="submit" disabled={adding || !newAddress.trim()}>
+              Add
+            </Button>
+          </form>
+        </CardContent>
+      </Card>
 
-      <div className="rounded-lg border border-[var(--border)] bg-[var(--card)] p-4">
-        <h3 className="text-sm font-medium mb-3">Addresses ({addresses?.length || 0})</h3>
-        {!addresses?.length ? (
-          <p className="text-xs text-zinc-500">No addresses added yet</p>
-        ) : (
-          <div>
-            {(addresses as AddressRow[]).map((addr) => (
-              <AddressItem
-                key={addr.id}
-                addr={addr}
-                onToggle={handleToggle}
-                onDelete={handleDelete}
-                onUpdate={() => mutate()}
-              />
-            ))}
-          </div>
-        )}
-      </div>
+      <Card>
+        <CardHeader>
+          <CardTitle className="text-sm">Addresses ({addresses?.length || 0})</CardTitle>
+        </CardHeader>
+        <CardContent>
+          {!addresses?.length ? (
+            <p className="text-xs text-muted-foreground">No addresses added yet</p>
+          ) : (
+            <div>
+              {(addresses as AddressRow[]).map((addr) => (
+                <AddressItem
+                  key={addr.id}
+                  addr={addr}
+                  onToggle={handleToggle}
+                  onDelete={handleDelete}
+                  onUpdate={() => mutate()}
+                />
+              ))}
+            </div>
+          )}
+        </CardContent>
+      </Card>
     </div>
   )
 }

+ 80 - 19
src/app/globals.css

@@ -1,29 +1,90 @@
 @import 'tailwindcss';
+@import 'tw-animate-css';
 
-:root {
-  --background: #0f1117;
-  --foreground: #e4e4e7;
-  --card: #1a1b23;
-  --border: #27272a;
-  --accent: #6366f1;
-  --accent-hover: #818cf8;
-  --success: #22c55e;
-  --danger: #ef4444;
-  --warning: #f59e0b;
-  --muted: #71717a;
-}
+@custom-variant dark (&:is(.dark *));
 
 @theme inline {
   --color-background: var(--background);
   --color-foreground: var(--foreground);
+  --color-card: var(--card);
+  --color-card-foreground: var(--card-foreground);
+  --color-popover: var(--popover);
+  --color-popover-foreground: var(--popover-foreground);
+  --color-primary: var(--primary);
+  --color-primary-foreground: var(--primary-foreground);
+  --color-secondary: var(--secondary);
+  --color-secondary-foreground: var(--secondary-foreground);
+  --color-muted: var(--muted);
+  --color-muted-foreground: var(--muted-foreground);
+  --color-accent: var(--accent);
+  --color-accent-foreground: var(--accent-foreground);
+  --color-destructive: var(--destructive);
+  --color-border: var(--border);
+  --color-input: var(--input);
+  --color-ring: var(--ring);
+  --color-success: var(--success);
+  --color-success-foreground: var(--success-foreground);
+  --color-warning: var(--warning);
+  --color-warning-foreground: var(--warning-foreground);
+  --radius-sm: calc(var(--radius) - 4px);
+  --radius-md: calc(var(--radius) - 2px);
+  --radius-lg: var(--radius);
+  --radius-xl: calc(var(--radius) + 4px);
+}
+
+/* Light theme */
+:root {
+  --radius: 0.5rem;
+  --background: oklch(0.985 0 0);
+  --foreground: oklch(0.145 0 0);
+  --card: oklch(1 0 0);
+  --card-foreground: oklch(0.145 0 0);
+  --popover: oklch(1 0 0);
+  --popover-foreground: oklch(0.145 0 0);
+  --primary: oklch(0.51 0.18 270);
+  --primary-foreground: oklch(0.985 0 0);
+  --secondary: oklch(0.96 0 0);
+  --secondary-foreground: oklch(0.205 0 0);
+  --muted: oklch(0.96 0 0);
+  --muted-foreground: oklch(0.45 0 0);
+  --accent: oklch(0.96 0 0);
+  --accent-foreground: oklch(0.205 0 0);
+  --destructive: oklch(0.577 0.245 27.33);
+  --border: oklch(0.90 0 0);
+  --input: oklch(0.90 0 0);
+  --ring: oklch(0.51 0.18 270);
+  --success: oklch(0.648 0.2 145);
+  --success-foreground: oklch(0.985 0 0);
+  --warning: oklch(0.769 0.188 70.08);
+  --warning-foreground: oklch(0.145 0 0);
+}
+
+/* Dark theme */
+.dark {
+  --background: oklch(0.141 0.005 286);
+  --foreground: oklch(0.918 0.005 286);
+  --card: oklch(0.175 0.006 286);
+  --card-foreground: oklch(0.918 0.005 286);
+  --popover: oklch(0.175 0.006 286);
+  --popover-foreground: oklch(0.918 0.005 286);
+  --primary: oklch(0.55 0.18 270);
+  --primary-foreground: oklch(0.985 0 0);
+  --secondary: oklch(0.228 0.006 286);
+  --secondary-foreground: oklch(0.918 0.005 286);
+  --muted: oklch(0.228 0.006 286);
+  --muted-foreground: oklch(0.553 0.005 286);
+  --accent: oklch(0.228 0.006 286);
+  --accent-foreground: oklch(0.918 0.005 286);
+  --destructive: oklch(0.577 0.245 27.33);
+  --border: oklch(0.243 0.006 286);
+  --input: oklch(0.243 0.006 286);
+  --ring: oklch(0.55 0.18 270);
+  --success: oklch(0.648 0.2 145);
+  --success-foreground: oklch(0.985 0 0);
+  --warning: oklch(0.769 0.188 70.08);
+  --warning-foreground: oklch(0.145 0 0);
 }
 
 body {
-  background: var(--background);
-  color: var(--foreground);
-  font-family:
-    'Inter',
-    system-ui,
-    -apple-system,
-    sans-serif;
+  font-family: 'Inter', system-ui, -apple-system, sans-serif;
 }

+ 93 - 90
src/app/history/page.tsx

@@ -1,6 +1,9 @@
 'use client'
 
 import useSWR from 'swr'
+import { Card, CardContent } from '@/components/ui/card'
+import { Badge } from '@/components/ui/badge'
+import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@/components/ui/table'
 
 const fetcher = (url: string) => fetch(url).then((r) => r.json())
 
@@ -9,105 +12,105 @@ export default function HistoryPage() {
 
   const rows = data || []
 
+  const statusBadge = (status: string) => {
+    switch (status) {
+      case 'success':
+        return <Badge variant="success">{status}</Badge>
+      case 'failed':
+        return <Badge variant="destructive">{status}</Badge>
+      case 'executing':
+        return <Badge variant="warning">{status}</Badge>
+      case 'skipped':
+      default:
+        return <Badge variant="secondary">{status}</Badge>
+    }
+  }
+
   return (
     <div className="space-y-6">
       <h2 className="text-lg font-semibold">Copy Trade History</h2>
 
-      <div className="rounded-lg border border-[var(--border)] bg-[var(--card)] p-4">
-        {rows.length === 0 ? (
-          <p className="text-xs text-zinc-500">No history yet</p>
-        ) : (
-          <div className="overflow-x-auto">
-            <table className="w-full text-xs">
-              <thead>
-                <tr className="text-zinc-500 border-b border-[var(--border)]">
-                  <th className="text-left py-2 pr-3">Time</th>
-                  <th className="text-left py-2 pr-3">Operation</th>
-                  <th className="text-left py-2 pr-3">Target</th>
-                  <th className="text-left py-2 pr-3">Target TX</th>
-                  <th className="text-left py-2 pr-3">Our TX</th>
-                  <th className="text-left py-2 pr-3">Amount A</th>
-                  <th className="text-left py-2 pr-3">Amount B</th>
-                  <th className="text-left py-2 pr-3">Status</th>
-                  <th className="text-left py-2">Error</th>
-                </tr>
-              </thead>
-              <tbody>
-                {rows.map(
-                  (row: {
-                    id: number
-                    created_at: string
-                    operation: string
-                    target_address: string
-                    target_tx_sig: string
-                    our_tx_sig: string | null
-                    our_amount_a: string | null
-                    our_amount_b: string | null
-                    status: string
-                    error_message: string | null
-                  }) => (
-                    <tr key={row.id} className="border-b border-zinc-800/50">
-                      <td className="py-2 pr-3 text-zinc-500 whitespace-nowrap">
-                        {new Date(row.created_at + 'Z').toLocaleString()}
-                      </td>
-                      <td className="py-2 pr-3 whitespace-nowrap">{row.operation}</td>
-                      <td className="py-2 pr-3 text-zinc-400">
-                        {row.target_address.slice(0, 4)}...{row.target_address.slice(-4)}
-                      </td>
-                      <td className="py-2 pr-3">
-                        <a
-                          href={`https://solscan.io/tx/${row.target_tx_sig}`}
-                          target="_blank"
-                          rel="noopener noreferrer"
-                          className="text-indigo-400 hover:underline"
-                        >
-                          {row.target_tx_sig.slice(0, 8)}...
-                        </a>
-                      </td>
-                      <td className="py-2 pr-3">
-                        {row.our_tx_sig ? (
+      <Card>
+        <CardContent className="p-4">
+          {rows.length === 0 ? (
+            <p className="text-xs text-muted-foreground">No history yet</p>
+          ) : (
+            <div className="overflow-x-auto">
+              <Table className="text-xs">
+                <TableHeader>
+                  <TableRow>
+                    <TableHead>Time</TableHead>
+                    <TableHead>Operation</TableHead>
+                    <TableHead>Target</TableHead>
+                    <TableHead>Target TX</TableHead>
+                    <TableHead>Our TX</TableHead>
+                    <TableHead>Amount A</TableHead>
+                    <TableHead>Amount B</TableHead>
+                    <TableHead>Status</TableHead>
+                    <TableHead>Error</TableHead>
+                  </TableRow>
+                </TableHeader>
+                <TableBody>
+                  {rows.map(
+                    (row: {
+                      id: number
+                      created_at: string
+                      operation: string
+                      target_address: string
+                      target_tx_sig: string
+                      our_tx_sig: string | null
+                      our_amount_a: string | null
+                      our_amount_b: string | null
+                      status: string
+                      error_message: string | null
+                    }) => (
+                      <TableRow key={row.id}>
+                        <TableCell className="text-muted-foreground whitespace-nowrap">
+                          {new Date(row.created_at + 'Z').toLocaleString()}
+                        </TableCell>
+                        <TableCell className="whitespace-nowrap">{row.operation}</TableCell>
+                        <TableCell className="text-muted-foreground">
+                          {row.target_address.slice(0, 4)}...{row.target_address.slice(-4)}
+                        </TableCell>
+                        <TableCell>
                           <a
-                            href={`https://solscan.io/tx/${row.our_tx_sig}`}
+                            href={`https://solscan.io/tx/${row.target_tx_sig}`}
                             target="_blank"
                             rel="noopener noreferrer"
-                            className="text-indigo-400 hover:underline"
+                            className="text-primary hover:underline"
                           >
-                            {row.our_tx_sig.slice(0, 8)}...
+                            {row.target_tx_sig.slice(0, 8)}...
                           </a>
-                        ) : (
-                          <span className="text-zinc-500">-</span>
-                        )}
-                      </td>
-                      <td className="py-2 pr-3 text-zinc-400">{row.our_amount_a || '-'}</td>
-                      <td className="py-2 pr-3 text-zinc-400">{row.our_amount_b || '-'}</td>
-                      <td className="py-2 pr-3">
-                        <span
-                          className={`px-1.5 py-0.5 rounded text-[10px] ${
-                            row.status === 'success'
-                              ? 'bg-green-500/20 text-green-400'
-                              : row.status === 'failed'
-                                ? 'bg-red-500/20 text-red-400'
-                                : row.status === 'executing'
-                                  ? 'bg-yellow-500/20 text-yellow-400'
-                                  : row.status === 'skipped'
-                                    ? 'bg-zinc-500/20 text-zinc-400'
-                                    : 'bg-blue-500/20 text-blue-400'
-                          }`}
-                        >
-                          {row.status}
-                        </span>
-                      </td>
-                      <td className="py-2 text-red-400 text-[10px] max-w-[200px] truncate">
-                        {row.error_message || ''}
-                      </td>
-                    </tr>
-                  ),
-                )}
-              </tbody>
-            </table>
-          </div>
-        )}
-      </div>
+                        </TableCell>
+                        <TableCell>
+                          {row.our_tx_sig ? (
+                            <a
+                              href={`https://solscan.io/tx/${row.our_tx_sig}`}
+                              target="_blank"
+                              rel="noopener noreferrer"
+                              className="text-primary hover:underline"
+                            >
+                              {row.our_tx_sig.slice(0, 8)}...
+                            </a>
+                          ) : (
+                            <span className="text-muted-foreground">-</span>
+                          )}
+                        </TableCell>
+                        <TableCell className="text-muted-foreground">{row.our_amount_a || '-'}</TableCell>
+                        <TableCell className="text-muted-foreground">{row.our_amount_b || '-'}</TableCell>
+                        <TableCell>{statusBadge(row.status)}</TableCell>
+                        <TableCell className="text-destructive text-[10px] max-w-[200px] truncate">
+                          {row.error_message || ''}
+                        </TableCell>
+                      </TableRow>
+                    ),
+                  )}
+                </TableBody>
+              </Table>
+            </div>
+          )}
+        </CardContent>
+      </Card>
     </div>
   )
 }

+ 11 - 8
src/app/layout.tsx

@@ -2,6 +2,7 @@ import type { Metadata } from 'next'
 import './globals.css'
 import { Sidebar } from '@/components/layout/sidebar'
 import { Header } from '@/components/layout/header'
+import { Providers } from '@/components/providers'
 
 export const metadata: Metadata = {
   title: 'Byreal Copy Trade',
@@ -10,15 +11,17 @@ export const metadata: Metadata = {
 
 export default function RootLayout({ children }: { children: React.ReactNode }) {
   return (
-    <html lang="en">
-      <body className="antialiased">
-        <div className="flex min-h-screen">
-          <Sidebar />
-          <div className="flex-1 flex flex-col">
-            <Header />
-            <main className="flex-1 p-6">{children}</main>
+    <html lang="en" suppressHydrationWarning>
+      <body className="antialiased bg-background text-foreground">
+        <Providers>
+          <div className="flex min-h-screen">
+            <Sidebar />
+            <div className="flex-1 flex flex-col">
+              <Header />
+              <main className="flex-1 p-6">{children}</main>
+            </div>
           </div>
-        </div>
+        </Providers>
       </body>
     </html>
   )

+ 210 - 197
src/app/page.tsx

@@ -2,6 +2,10 @@
 
 import { useState } from 'react'
 import useSWR from 'swr'
+import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
+import { Badge } from '@/components/ui/badge'
+import { Button } from '@/components/ui/button'
+import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@/components/ui/table'
 
 const fetcher = (url: string) => fetch(url).then((r) => r.json())
 
@@ -49,47 +53,50 @@ function StatusCard() {
   }
 
   return (
-    <div className="rounded-lg border border-[var(--border)] bg-[var(--card)] p-4">
-      <div className="flex items-center justify-between mb-3">
-        <h3 className="text-sm font-medium">Monitor Status</h3>
-        <span
-          className={`text-xs px-2 py-0.5 rounded-full ${
-            status?.running ? 'bg-green-500/20 text-green-400' : 'bg-zinc-500/20 text-zinc-400'
-          }`}
-        >
-          {status?.running ? 'Running' : 'Stopped'}
-        </span>
-      </div>
-      <p className="text-xs text-zinc-500 mb-3">Watching {status?.watchedCount || 0} addresses</p>
-      {error && (
-        <p className="text-xs text-red-400 mb-2 bg-red-500/10 px-2 py-1 rounded">{error}</p>
-      )}
-      {status?.errors?.length > 0 && (
-        <div className="mb-3 max-h-20 overflow-y-auto space-y-1">
-          {status.errors.map((err: string, i: number) => (
-            <p key={i} className="text-[10px] text-red-400/80 bg-red-500/5 px-2 py-0.5 rounded truncate" title={err}>
-              {err}
-            </p>
-          ))}
+    <Card>
+      <CardContent className="p-4">
+        <div className="flex items-center justify-between mb-3">
+          <h3 className="text-sm font-medium">Monitor Status</h3>
+          {status?.running ? (
+            <Badge variant="success">Running</Badge>
+          ) : (
+            <Badge variant="secondary">Stopped</Badge>
+          )}
         </div>
-      )}
-      <div className="flex gap-2">
-        <button
-          onClick={handleStart}
-          disabled={status?.running || loading}
-          className="px-3 py-1.5 text-xs rounded bg-indigo-500 text-white hover:bg-indigo-400 disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
-        >
-          {loading && !status?.running ? 'Starting...' : 'Start'}
-        </button>
-        <button
-          onClick={handleStop}
-          disabled={!status?.running || loading}
-          className="px-3 py-1.5 text-xs rounded bg-red-500/20 text-red-400 hover:bg-red-500/30 disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
-        >
-          {loading && status?.running ? 'Stopping...' : 'Stop'}
-        </button>
-      </div>
-    </div>
+        <p className="text-xs text-muted-foreground mb-3">Watching {status?.watchedCount || 0} addresses</p>
+        {error && (
+          <p className="text-xs text-red-400 mb-2 bg-red-500/10 px-2 py-1 rounded">{error}</p>
+        )}
+        {status?.errors?.length > 0 && (
+          <div className="mb-3 max-h-20 overflow-y-auto space-y-1">
+            {status.errors.map((err: string, i: number) => (
+              <p key={i} className="text-[10px] text-red-400/80 bg-red-500/5 px-2 py-0.5 rounded truncate" title={err}>
+                {err}
+              </p>
+            ))}
+          </div>
+        )}
+        <div className="flex gap-2">
+          <Button
+            onClick={handleStart}
+            disabled={status?.running || loading}
+            size="sm"
+            className="text-xs"
+          >
+            {loading && !status?.running ? 'Starting...' : 'Start'}
+          </Button>
+          <Button
+            onClick={handleStop}
+            disabled={!status?.running || loading}
+            variant="destructive"
+            size="sm"
+            className="text-xs"
+          >
+            {loading && status?.running ? 'Stopping...' : 'Stop'}
+          </Button>
+        </div>
+      </CardContent>
+    </Card>
   )
 }
 
@@ -97,25 +104,27 @@ function WalletBalances() {
   const { data } = useSWR('/api/wallet/balance', fetcher, { refreshInterval: 15000 })
 
   return (
-    <div className="rounded-lg border border-[var(--border)] bg-[var(--card)] p-4">
-      <h3 className="text-sm font-medium mb-3">Wallet Balances</h3>
-      {data?.error ? (
-        <p className="text-xs text-zinc-500">Configure SOL_SECRET_KEY to view balances</p>
-      ) : (
-        <div className="max-h-32 overflow-y-auto space-y-1.5">
-          <div className="flex justify-between text-xs">
-            <span className="text-zinc-500">SOL</span>
-            <span>{data?.sol?.toFixed(4) || '...'}</span>
-          </div>
-          {data?.tokens?.map((t: { mint: string; symbol: string; amount: number }) => (
-            <div key={t.mint} className="flex justify-between text-xs">
-              <span className="text-zinc-500">{t.symbol}</span>
-              <span>{t.amount.toFixed(4)}</span>
+    <Card>
+      <CardContent className="p-4">
+        <h3 className="text-sm font-medium mb-3">Wallet Balances</h3>
+        {data?.error ? (
+          <p className="text-xs text-muted-foreground">Configure SOL_SECRET_KEY to view balances</p>
+        ) : (
+          <div className="max-h-32 overflow-y-auto space-y-1.5">
+            <div className="flex justify-between text-xs">
+              <span className="text-muted-foreground">SOL</span>
+              <span>{data?.sol?.toFixed(4) || '...'}</span>
             </div>
-          ))}
-        </div>
-      )}
-    </div>
+            {data?.tokens?.map((t: { mint: string; symbol: string; amount: number }) => (
+              <div key={t.mint} className="flex justify-between text-xs">
+                <span className="text-muted-foreground">{t.symbol}</span>
+                <span>{t.amount.toFixed(4)}</span>
+              </div>
+            ))}
+          </div>
+        )}
+      </CardContent>
+    </Card>
   )
 }
 
@@ -181,85 +190,87 @@ function ActivePositions() {
   }
 
   return (
-    <div className="rounded-lg border border-[var(--border)] bg-[var(--card)] p-4">
-      <div className="flex items-center justify-between mb-3">
-        <h3 className="text-sm font-medium">Active Positions</h3>
-        <span className="text-xs text-zinc-500">{rows.length} open</span>
-      </div>
-      {rows.length === 0 ? (
-        <p className="text-xs text-zinc-500">No active positions</p>
-      ) : (
-        <div className="space-y-2 max-h-64 overflow-y-auto">
-          {rows.map((row) => (
-            <div
-              key={row.id}
-              className="flex items-center justify-between gap-3 rounded-md bg-zinc-800/40 px-3 py-2"
-            >
-              <div className="min-w-0 flex-1 space-y-0.5">
-                <div className="flex items-center gap-2 text-xs">
-                  {row.pool_label ? (
-                    <span className="font-medium text-zinc-200">{row.pool_label}</span>
-                  ) : (
-                    <span className="font-mono text-zinc-300">
-                      {row.pool_id.slice(0, 6)}...{row.pool_id.slice(-4)}
-                    </span>
-                  )}
-                  {row.price_lower && row.price_upper ? (
-                    <span className="text-[10px] text-zinc-500">
-                      {formatPrice(row.price_lower)} ~ {formatPrice(row.price_upper)}
-                    </span>
-                  ) : (
-                    <span className="text-[10px] text-zinc-500">
-                      [{row.tick_lower} ~ {row.tick_upper}]
-                    </span>
-                  )}
-                </div>
-                <div className="flex items-center gap-2 text-[10px] text-zinc-500">
-                  <span>
-                    Target:{' '}
-                    <a
-                      href={`https://solscan.io/account/${row.target_address}`}
-                      target="_blank"
-                      rel="noopener noreferrer"
-                      className="text-indigo-400/70 hover:underline"
-                    >
-                      {row.target_address.slice(0, 4)}...{row.target_address.slice(-4)}
-                    </a>
-                  </span>
-                  {row.our_nft_mint && (
+    <Card>
+      <CardContent className="p-4">
+        <div className="flex items-center justify-between mb-3">
+          <h3 className="text-sm font-medium">Active Positions</h3>
+          <span className="text-xs text-muted-foreground">{rows.length} open</span>
+        </div>
+        {rows.length === 0 ? (
+          <p className="text-xs text-muted-foreground">No active positions</p>
+        ) : (
+          <div className="space-y-2 max-h-64 overflow-y-auto">
+            {rows.map((row) => (
+              <div
+                key={row.id}
+                className="flex items-center justify-between gap-3 rounded-md bg-muted px-3 py-2"
+              >
+                <div className="min-w-0 flex-1 space-y-0.5">
+                  <div className="flex items-center gap-2 text-xs">
+                    {row.pool_label ? (
+                      <span className="font-medium text-foreground">{row.pool_label}</span>
+                    ) : (
+                      <span className="font-mono text-foreground">
+                        {row.pool_id.slice(0, 6)}...{row.pool_id.slice(-4)}
+                      </span>
+                    )}
+                    {row.price_lower && row.price_upper ? (
+                      <span className="text-[10px] text-muted-foreground">
+                        {formatPrice(row.price_lower)} ~ {formatPrice(row.price_upper)}
+                      </span>
+                    ) : (
+                      <span className="text-[10px] text-muted-foreground">
+                        [{row.tick_lower} ~ {row.tick_upper}]
+                      </span>
+                    )}
+                  </div>
+                  <div className="flex items-center gap-2 text-[10px] text-muted-foreground">
                     <span>
-                      NFT:{' '}
+                      Target:{' '}
                       <a
-                        href={`https://solscan.io/token/${row.our_nft_mint}`}
+                        href={`https://solscan.io/account/${row.target_address}`}
                         target="_blank"
                         rel="noopener noreferrer"
-                        className="text-indigo-400/70 hover:underline"
+                        className="text-primary/70 hover:underline"
                       >
-                        {row.our_nft_mint.slice(0, 6)}...
+                        {row.target_address.slice(0, 4)}...{row.target_address.slice(-4)}
                       </a>
                     </span>
+                    {row.our_nft_mint && (
+                      <span>
+                        NFT:{' '}
+                        <a
+                          href={`https://solscan.io/token/${row.our_nft_mint}`}
+                          target="_blank"
+                          rel="noopener noreferrer"
+                          className="text-primary/70 hover:underline"
+                        >
+                          {row.our_nft_mint.slice(0, 6)}...
+                        </a>
+                      </span>
+                    )}
+                  </div>
+                </div>
+                <div className="flex items-center gap-2 shrink-0">
+                  {row.our_nft_mint && (
+                    <Button
+                      onClick={() => handleClose(row)}
+                      disabled={closingId === row.id}
+                      variant="outline"
+                      size="sm"
+                      className="h-6 text-[10px]"
+                    >
+                      {closingId === row.id ? 'Closing...' : 'Close'}
+                    </Button>
                   )}
+                  <Badge variant="success">active</Badge>
                 </div>
               </div>
-              <div className="flex items-center gap-2 shrink-0">
-                {row.our_nft_mint && (
-                  <button
-                    onClick={() => handleClose(row)}
-                    disabled={closingId === row.id}
-                    className="px-2 py-0.5 text-[10px] rounded bg-orange-500/20 text-orange-400 hover:bg-orange-500/30 disabled:opacity-50 transition-colors"
-                  >
-                    {closingId === row.id ? 'Closing...' : 'Close'}
-                  </button>
-                )}
-                <span className="px-1.5 py-0.5 rounded text-[10px] bg-green-500/20 text-green-400">
-                  active
-                </span>
-              </div>
-            </div>
-          ))}
-        </div>
-      )}
-    </div>
+            ))}
+          </div>
+        )}
+      </CardContent>
+    </Card>
   )
 }
 
@@ -269,77 +280,79 @@ function RecentCopies() {
   const rows = data || []
 
   return (
-    <div className="rounded-lg border border-[var(--border)] bg-[var(--card)] p-4">
-      <h3 className="text-sm font-medium mb-3">Recent Copy Operations</h3>
-      {rows.length === 0 ? (
-        <p className="text-xs text-zinc-500">No copy operations yet</p>
-      ) : (
-        <div className="overflow-x-auto">
-          <table className="w-full text-xs">
-            <thead>
-              <tr className="text-zinc-500 border-b border-[var(--border)]">
-                <th className="text-left py-2 pr-3">Time</th>
-                <th className="text-left py-2 pr-3">Operation</th>
-                <th className="text-left py-2 pr-3">Target</th>
-                <th className="text-left py-2 pr-3">Status</th>
-                <th className="text-left py-2">TX</th>
-              </tr>
-            </thead>
-            <tbody>
-              {rows.map(
-                (row: {
-                  id: number
-                  created_at: string
-                  operation: string
-                  target_address: string
-                  status: string
-                  our_tx_sig: string | null
-                }) => (
-                  <tr key={row.id} className="border-b border-zinc-800/50">
-                    <td className="py-2 pr-3 text-zinc-500">
-                      {new Date(row.created_at + 'Z').toLocaleTimeString()}
-                    </td>
-                    <td className="py-2 pr-3">{row.operation}</td>
-                    <td className="py-2 pr-3 text-zinc-500">
-                      {row.target_address.slice(0, 4)}...{row.target_address.slice(-4)}
-                    </td>
-                    <td className="py-2 pr-3">
-                      <span
-                        className={`px-1.5 py-0.5 rounded text-[10px] ${
-                          row.status === 'success'
-                            ? 'bg-green-500/20 text-green-400'
-                            : row.status === 'failed'
-                              ? 'bg-red-500/20 text-red-400'
-                              : row.status === 'executing'
-                                ? 'bg-yellow-500/20 text-yellow-400'
-                                : 'bg-zinc-500/20 text-zinc-400'
-                        }`}
-                      >
-                        {row.status}
-                      </span>
-                    </td>
-                    <td className="py-2">
-                      {row.our_tx_sig ? (
-                        <a
-                          href={`https://solscan.io/tx/${row.our_tx_sig}`}
-                          target="_blank"
-                          rel="noopener noreferrer"
-                          className="text-indigo-400 hover:underline"
+    <Card>
+      <CardContent className="p-4">
+        <h3 className="text-sm font-medium mb-3">Recent Copy Operations</h3>
+        {rows.length === 0 ? (
+          <p className="text-xs text-muted-foreground">No copy operations yet</p>
+        ) : (
+          <div className="overflow-x-auto">
+            <Table className="text-xs">
+              <TableHeader>
+                <TableRow>
+                  <TableHead className="py-2 pr-3">Time</TableHead>
+                  <TableHead className="py-2 pr-3">Operation</TableHead>
+                  <TableHead className="py-2 pr-3">Target</TableHead>
+                  <TableHead className="py-2 pr-3">Status</TableHead>
+                  <TableHead className="py-2">TX</TableHead>
+                </TableRow>
+              </TableHeader>
+              <TableBody>
+                {rows.map(
+                  (row: {
+                    id: number
+                    created_at: string
+                    operation: string
+                    target_address: string
+                    status: string
+                    our_tx_sig: string | null
+                  }) => (
+                    <TableRow key={row.id}>
+                      <TableCell className="py-2 pr-3 text-muted-foreground">
+                        {new Date(row.created_at + 'Z').toLocaleTimeString()}
+                      </TableCell>
+                      <TableCell className="py-2 pr-3">{row.operation}</TableCell>
+                      <TableCell className="py-2 pr-3 text-muted-foreground">
+                        {row.target_address.slice(0, 4)}...{row.target_address.slice(-4)}
+                      </TableCell>
+                      <TableCell className="py-2 pr-3">
+                        <Badge
+                          variant={
+                            row.status === 'success'
+                              ? 'success'
+                              : row.status === 'failed'
+                                ? 'destructive'
+                                : row.status === 'executing'
+                                  ? 'warning'
+                                  : 'secondary'
+                          }
                         >
-                          {row.our_tx_sig.slice(0, 8)}...
-                        </a>
-                      ) : (
-                        <span className="text-zinc-500">-</span>
-                      )}
-                    </td>
-                  </tr>
-                ),
-              )}
-            </tbody>
-          </table>
-        </div>
-      )}
-    </div>
+                          {row.status}
+                        </Badge>
+                      </TableCell>
+                      <TableCell className="py-2">
+                        {row.our_tx_sig ? (
+                          <a
+                            href={`https://solscan.io/tx/${row.our_tx_sig}`}
+                            target="_blank"
+                            rel="noopener noreferrer"
+                            className="text-primary hover:underline"
+                          >
+                            {row.our_tx_sig.slice(0, 8)}...
+                          </a>
+                        ) : (
+                          <span className="text-muted-foreground">-</span>
+                        )}
+                      </TableCell>
+                    </TableRow>
+                  ),
+                )}
+              </TableBody>
+            </Table>
+          </div>
+        )}
+      </CardContent>
+    </Card>
   )
 }
 

+ 177 - 122
src/app/positions/page.tsx

@@ -2,6 +2,22 @@
 
 import { useState } from 'react'
 import useSWR from 'swr'
+import { Card, CardContent } from '@/components/ui/card'
+import { Badge } from '@/components/ui/badge'
+import { Button } from '@/components/ui/button'
+import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@/components/ui/table'
+import { Switch } from '@/components/ui/switch'
+import { Label } from '@/components/ui/label'
+import {
+  AlertDialog,
+  AlertDialogAction,
+  AlertDialogCancel,
+  AlertDialogContent,
+  AlertDialogDescription,
+  AlertDialogFooter,
+  AlertDialogHeader,
+  AlertDialogTitle,
+} from '@/components/ui/alert-dialog'
 
 const fetcher = (url: string) => fetch(url).then((r) => r.json())
 
@@ -40,17 +56,13 @@ export default function PositionsPage() {
   const [closingId, setClosingId] = useState<number | null>(null)
   const [deletingId, setDeletingId] = useState<number | null>(null)
   const [error, setError] = useState<string | null>(null)
+  const [confirmAction, setConfirmAction] = useState<{ type: 'close' | 'delete'; row: PositionRow } | null>(null)
+  const [showClosed, setShowClosed] = useState(false)
 
-  const rows: PositionRow[] = positions || []
+  const allRows: PositionRow[] = positions || []
+  const rows = showClosed ? allRows : allRows.filter((r) => r.status !== 'closed')
 
   const handleClose = async (row: PositionRow) => {
-    if (
-      !confirm(
-        `Close position ${row.our_nft_mint?.slice(0, 8)}...? This will remove all liquidity on-chain and swap tokens back to USDC.`,
-      )
-    ) {
-      return
-    }
     setClosingId(row.id)
     setError(null)
     try {
@@ -73,13 +85,6 @@ export default function PositionsPage() {
   }
 
   const handleDelete = async (row: PositionRow) => {
-    if (
-      !confirm(
-        `Delete position mapping #${row.id}? This only removes the record from the database, not from the blockchain.`,
-      )
-    ) {
-      return
-    }
     setDeletingId(row.id)
     setError(null)
     try {
@@ -97,9 +102,27 @@ export default function PositionsPage() {
     }
   }
 
+  const handleConfirm = () => {
+    if (!confirmAction) return
+    if (confirmAction.type === 'close') {
+      handleClose(confirmAction.row)
+    } else {
+      handleDelete(confirmAction.row)
+    }
+    setConfirmAction(null)
+  }
+
   return (
     <div className="space-y-6">
-      <h2 className="text-lg font-semibold">Position Mappings</h2>
+      <div className="flex items-center justify-between">
+        <h2 className="text-lg font-semibold">Position Mappings</h2>
+        <div className="flex items-center gap-2">
+          <Switch checked={showClosed} onCheckedChange={setShowClosed} id="show-closed" />
+          <Label htmlFor="show-closed" className="text-xs text-muted-foreground cursor-pointer">
+            Show closed
+          </Label>
+        </div>
+      </div>
 
       {error && (
         <div className="rounded-lg border border-red-500/30 bg-red-500/10 p-3 text-xs text-red-400">
@@ -110,120 +133,152 @@ export default function PositionsPage() {
         </div>
       )}
 
-      <div className="rounded-lg border border-[var(--border)] bg-[var(--card)] p-4">
-        {rows.length === 0 ? (
-          <p className="text-xs text-zinc-500">No position mappings yet</p>
-        ) : (
-          <div className="overflow-x-auto">
-            <table className="w-full text-xs">
-              <thead>
-                <tr className="text-zinc-500 border-b border-[var(--border)]">
-                  <th className="text-left py-2 pr-3">Target</th>
-                  <th className="text-left py-2 pr-3">Target NFT</th>
-                  <th className="text-left py-2 pr-3">Our NFT</th>
-                  <th className="text-left py-2 pr-3">Pool</th>
-                  <th className="text-left py-2 pr-3">Size</th>
-                  <th className="text-left py-2 pr-3">Price Range</th>
-                  <th className="text-left py-2 pr-3">Status</th>
-                  <th className="text-left py-2 pr-3">Created</th>
-                  <th className="text-left py-2">Actions</th>
-                </tr>
-              </thead>
-              <tbody>
-                {rows.map((row) => (
-                  <tr key={row.id} className="border-b border-zinc-800/50">
-                    <td className="py-2 pr-3 text-zinc-400">
-                      {row.target_address.slice(0, 4)}...{row.target_address.slice(-4)}
-                    </td>
-                    <td className="py-2 pr-3">
-                      <a
-                        href={`https://solscan.io/token/${row.target_nft_mint}`}
-                        target="_blank"
-                        rel="noopener noreferrer"
-                        className="text-indigo-400 hover:underline"
-                      >
-                        {row.target_nft_mint.slice(0, 6)}...
-                      </a>
-                    </td>
-                    <td className="py-2 pr-3">
-                      {row.our_nft_mint ? (
+      <Card>
+        <CardContent className="p-0">
+          {rows.length === 0 ? (
+            <p className="text-xs text-muted-foreground p-4">No position mappings yet</p>
+          ) : (
+            <div className="overflow-x-auto">
+              <Table>
+                <TableHeader>
+                  <TableRow>
+                    <TableHead>Target</TableHead>
+                    <TableHead>Target NFT</TableHead>
+                    <TableHead>Our NFT</TableHead>
+                    <TableHead>Pool</TableHead>
+                    <TableHead>Size</TableHead>
+                    <TableHead>Price Range</TableHead>
+                    <TableHead>Status</TableHead>
+                    <TableHead>Created</TableHead>
+                    <TableHead>Actions</TableHead>
+                  </TableRow>
+                </TableHeader>
+                <TableBody>
+                  {rows.map((row) => (
+                    <TableRow key={row.id}>
+                      <TableCell className="text-muted-foreground">
+                        {row.target_address.slice(0, 4)}...{row.target_address.slice(-4)}
+                      </TableCell>
+                      <TableCell>
                         <a
-                          href={`https://solscan.io/token/${row.our_nft_mint}`}
+                          href={`https://solscan.io/token/${row.target_nft_mint}`}
                           target="_blank"
                           rel="noopener noreferrer"
-                          className="text-indigo-400 hover:underline"
+                          className="text-primary hover:underline"
                         >
-                          {row.our_nft_mint.slice(0, 6)}...
+                          {row.target_nft_mint.slice(0, 6)}...
                         </a>
-                      ) : (
-                        <span className="text-zinc-500">pending</span>
-                      )}
-                    </td>
-                    <td className="py-2 pr-3 text-zinc-400">
-                      {row.pool_label || `${row.pool_id.slice(0, 6)}...`}
-                    </td>
-                    <td className="py-2 pr-3">
-                      {row.size_usd ? (
-                        <div>
-                          <span className="text-green-400 font-medium">${row.size_usd}</span>
-                          <div className="text-zinc-500 text-[10px] leading-tight mt-0.5">
-                            {row.amount_a && row.symbol_a && `${row.amount_a} ${row.symbol_a}`}
-                            {row.amount_a && row.amount_b && ' + '}
-                            {row.amount_b && row.symbol_b && `${row.amount_b} ${row.symbol_b}`}
-                          </div>
-                        </div>
-                      ) : (
-                        <span className="text-zinc-500">-</span>
-                      )}
-                    </td>
-                    <td className="py-2 pr-3 text-zinc-400">
-                      {row.price_lower && row.price_upper
-                        ? `${formatPrice(row.price_lower)} ~ ${formatPrice(row.price_upper)}`
-                        : `${row.tick_lower} ~ ${row.tick_upper}`}
-                    </td>
-                    <td className="py-2 pr-3">
-                      <span
-                        className={`px-1.5 py-0.5 rounded text-[10px] ${
-                          row.status === 'active'
-                            ? 'bg-green-500/20 text-green-400'
-                            : row.status === 'closed'
-                              ? 'bg-zinc-500/20 text-zinc-400'
-                              : 'bg-red-500/20 text-red-400'
-                        }`}
-                      >
-                        {row.status}
-                      </span>
-                    </td>
-                    <td className="py-2 pr-3 text-zinc-500">
-                      {new Date(row.created_at + 'Z').toLocaleDateString()}
-                    </td>
-                    <td className="py-2">
-                      <div className="flex gap-1.5">
-                        {row.status === 'active' && row.our_nft_mint && (
-                          <button
-                            onClick={() => handleClose(row)}
-                            disabled={closingId === row.id}
-                            className="px-2 py-0.5 text-[10px] rounded bg-orange-500/20 text-orange-400 hover:bg-orange-500/30 disabled:opacity-50 transition-colors"
+                      </TableCell>
+                      <TableCell>
+                        {row.our_nft_mint ? (
+                          <a
+                            href={`https://solscan.io/token/${row.our_nft_mint}`}
+                            target="_blank"
+                            rel="noopener noreferrer"
+                            className="text-primary hover:underline"
                           >
-                            {closingId === row.id ? 'Closing...' : 'Close'}
-                          </button>
+                            {row.our_nft_mint.slice(0, 6)}...
+                          </a>
+                        ) : (
+                          <span className="text-muted-foreground">pending</span>
+                        )}
+                      </TableCell>
+                      <TableCell className="text-muted-foreground">
+                        {row.pool_label || `${row.pool_id.slice(0, 6)}...`}
+                      </TableCell>
+                      <TableCell>
+                        {row.size_usd ? (
+                          <div>
+                            <span className="text-green-400 font-medium">${row.size_usd}</span>
+                            <div className="text-muted-foreground text-[10px] leading-tight mt-0.5">
+                              {row.amount_a && row.symbol_a && `${row.amount_a} ${row.symbol_a}`}
+                              {row.amount_a && row.amount_b && ' + '}
+                              {row.amount_b && row.symbol_b && `${row.amount_b} ${row.symbol_b}`}
+                            </div>
+                          </div>
+                        ) : (
+                          <span className="text-muted-foreground">-</span>
                         )}
-                        <button
-                          onClick={() => handleDelete(row)}
-                          disabled={deletingId === row.id}
-                          className="px-2 py-0.5 text-[10px] rounded bg-red-500/20 text-red-400 hover:bg-red-500/30 disabled:opacity-50 transition-colors"
+                      </TableCell>
+                      <TableCell className="text-muted-foreground">
+                        {row.price_lower && row.price_upper
+                          ? `${formatPrice(row.price_lower)} ~ ${formatPrice(row.price_upper)}`
+                          : `${row.tick_lower} ~ ${row.tick_upper}`}
+                      </TableCell>
+                      <TableCell>
+                        <Badge
+                          variant={
+                            row.status === 'active'
+                              ? 'success'
+                              : row.status === 'closed'
+                                ? 'secondary'
+                                : 'destructive'
+                          }
                         >
-                          {deletingId === row.id ? 'Deleting...' : 'Delete'}
-                        </button>
-                      </div>
-                    </td>
-                  </tr>
-                ))}
-              </tbody>
-            </table>
-          </div>
-        )}
-      </div>
+                          {row.status}
+                        </Badge>
+                      </TableCell>
+                      <TableCell className="text-muted-foreground">
+                        {new Date(row.created_at + 'Z').toLocaleString(undefined, {
+                          month: '2-digit',
+                          day: '2-digit',
+                          hour: '2-digit',
+                          minute: '2-digit',
+                        })}
+                      </TableCell>
+                      <TableCell>
+                        <div className="flex gap-1.5">
+                          {row.status === 'active' && row.our_nft_mint && (
+                            <Button
+                              variant="outline"
+                              size="sm"
+                              onClick={() => setConfirmAction({ type: 'close', row })}
+                              disabled={closingId === row.id}
+                              className="text-[10px] h-6 px-2 text-orange-400 border-orange-500/30 hover:bg-orange-500/10"
+                            >
+                              {closingId === row.id ? 'Closing...' : 'Close'}
+                            </Button>
+                          )}
+                          <Button
+                            variant="destructive"
+                            size="sm"
+                            onClick={() => setConfirmAction({ type: 'delete', row })}
+                            disabled={deletingId === row.id}
+                            className="text-[10px] h-6 px-2"
+                          >
+                            {deletingId === row.id ? 'Deleting...' : 'Delete'}
+                          </Button>
+                        </div>
+                      </TableCell>
+                    </TableRow>
+                  ))}
+                </TableBody>
+              </Table>
+            </div>
+          )}
+        </CardContent>
+      </Card>
+
+      <AlertDialog open={!!confirmAction} onOpenChange={(open) => !open && setConfirmAction(null)}>
+        <AlertDialogContent>
+          <AlertDialogHeader>
+            <AlertDialogTitle>
+              {confirmAction?.type === 'close' ? 'Close Position' : 'Delete Position'}
+            </AlertDialogTitle>
+            <AlertDialogDescription>
+              {confirmAction?.type === 'close'
+                ? `Close position ${confirmAction.row.our_nft_mint?.slice(0, 8)}...? This will remove all liquidity on-chain and swap tokens back to USDC.`
+                : `Delete position mapping #${confirmAction?.row.id}? This only removes the record from the database, not from the blockchain.`}
+            </AlertDialogDescription>
+          </AlertDialogHeader>
+          <AlertDialogFooter>
+            <AlertDialogCancel>Cancel</AlertDialogCancel>
+            <AlertDialogAction onClick={handleConfirm}>
+              {confirmAction?.type === 'close' ? 'Close Position' : 'Delete'}
+            </AlertDialogAction>
+          </AlertDialogFooter>
+        </AlertDialogContent>
+      </AlertDialog>
     </div>
   )
 }

+ 99 - 98
src/app/settings/page.tsx

@@ -2,6 +2,11 @@
 
 import { useState, useEffect } from 'react'
 import useSWR from 'swr'
+import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
+import { Input } from '@/components/ui/input'
+import { Label } from '@/components/ui/label'
+import { Switch } from '@/components/ui/switch'
+import { Button } from '@/components/ui/button'
 
 const fetcher = (url: string) => fetch(url).then((r) => r.json())
 
@@ -66,112 +71,108 @@ export default function SettingsPage() {
     <div className="space-y-6">
       <h2 className="text-lg font-semibold">Settings</h2>
 
-      <div className="rounded-lg border border-[var(--border)] bg-[var(--card)] p-4 space-y-4">
-        <h3 className="text-sm font-medium">Wallet Info</h3>
-        <div className="text-xs space-y-1">
+      <Card>
+        <CardHeader>
+          <CardTitle className="text-sm font-medium">Wallet Info</CardTitle>
+        </CardHeader>
+        <CardContent className="text-xs space-y-1">
           <div className="flex gap-2">
-            <span className="text-zinc-500 w-20">Address:</span>
-            <code className="text-zinc-300">{wallet?.address || 'Not configured'}</code>
+            <span className="text-muted-foreground w-20">Address:</span>
+            <code className="text-foreground">{wallet?.address || 'Not configured'}</code>
           </div>
           <div className="flex gap-2">
-            <span className="text-zinc-500 w-20">SOL:</span>
+            <span className="text-muted-foreground w-20">SOL:</span>
             <span>{wallet?.sol?.toFixed(4) || '-'}</span>
           </div>
-        </div>
-      </div>
+        </CardContent>
+      </Card>
 
-      <div className="rounded-lg border border-[var(--border)] bg-[var(--card)] p-4 space-y-4">
-        <h3 className="text-sm font-medium">Copy Trading Parameters (Global Defaults)</h3>
-        <p className="text-[10px] text-zinc-600">
-          These are global defaults. Each watched address can override Multiplier and Max USD in the
-          Addresses page.
-        </p>
-        <div className="space-y-3">
-          <div>
-            <label className="block text-xs text-zinc-500 mb-1">
-              Default Multiplier (1.0 = same as target, 2.0 = 2x, 0.5 = half)
-            </label>
-            <input
-              type="number"
-              step="0.1"
-              min="0.01"
-              max="100"
-              value={multiplier}
-              onChange={(e) => setMultiplier(e.target.value)}
-              className="w-48 px-3 py-1.5 text-sm rounded border border-[var(--border)] bg-[var(--background)] text-[var(--foreground)] focus:outline-none focus:border-indigo-500"
-            />
-          </div>
-          <div>
-            <label className="block text-xs text-zinc-500 mb-1">
-              Default Max USD per Copy (cap total position value in USD)
-            </label>
-            <input
-              type="number"
-              step="100"
-              min="1"
-              max="1000000"
-              value={maxUsd}
-              onChange={(e) => setMaxUsd(e.target.value)}
-              className="w-48 px-3 py-1.5 text-sm rounded border border-[var(--border)] bg-[var(--background)] text-[var(--foreground)] focus:outline-none focus:border-indigo-500"
-            />
-          </div>
-          <div>
-            <label className="block text-xs text-zinc-500 mb-1">
-              Slippage Tolerance (0.02 = 2%)
-            </label>
-            <input
-              type="number"
-              step="0.01"
-              min="0.001"
-              max="0.5"
-              value={slippage}
-              onChange={(e) => setSlippage(e.target.value)}
-              className="w-48 px-3 py-1.5 text-sm rounded border border-[var(--border)] bg-[var(--background)] text-[var(--foreground)] focus:outline-none focus:border-indigo-500"
-            />
-          </div>
-          <div>
-            <label className="block text-xs text-zinc-500 mb-1">Poll Interval (ms)</label>
-            <input
-              type="number"
-              step="1000"
-              min="1000"
-              max="60000"
-              value={pollInterval}
-              onChange={(e) => setPollInterval(e.target.value)}
-              className="w-48 px-3 py-1.5 text-sm rounded border border-[var(--border)] bg-[var(--background)] text-[var(--foreground)] focus:outline-none focus:border-indigo-500"
-            />
-          </div>
-          <div className="flex items-center gap-3">
-            <button
-              type="button"
-              onClick={() => setSwapAfterClose(!swapAfterClose)}
-              className={`relative inline-flex h-5 w-9 items-center rounded-full transition-colors ${
-                swapAfterClose ? 'bg-indigo-500' : 'bg-zinc-600'
-              }`}
-            >
-              <span
-                className={`inline-block h-3.5 w-3.5 rounded-full bg-white transition-transform ${
-                  swapAfterClose ? 'translate-x-4.5' : 'translate-x-0.5'
-                }`}
+      <Card>
+        <CardHeader>
+          <CardTitle className="text-sm font-medium">Copy Trading Parameters (Global Defaults)</CardTitle>
+        </CardHeader>
+        <CardContent className="space-y-4">
+          <p className="text-[10px] text-muted-foreground">
+            These are global defaults. Each watched address can override Multiplier and Max USD in the
+            Addresses page.
+          </p>
+          <div className="space-y-3">
+            <div>
+              <Label className="block text-xs text-muted-foreground mb-1">
+                Default Multiplier (1.0 = same as target, 2.0 = 2x, 0.5 = half)
+              </Label>
+              <Input
+                type="number"
+                step="0.1"
+                min="0.01"
+                max="100"
+                value={multiplier}
+                onChange={(e) => setMultiplier(e.target.value)}
+                className="w-48"
+              />
+            </div>
+            <div>
+              <Label className="block text-xs text-muted-foreground mb-1">
+                Default Max USD per Copy (cap total position value in USD)
+              </Label>
+              <Input
+                type="number"
+                step="100"
+                min="1"
+                max="1000000"
+                value={maxUsd}
+                onChange={(e) => setMaxUsd(e.target.value)}
+                className="w-48"
+              />
+            </div>
+            <div>
+              <Label className="block text-xs text-muted-foreground mb-1">
+                Slippage Tolerance (0.02 = 2%)
+              </Label>
+              <Input
+                type="number"
+                step="0.01"
+                min="0.001"
+                max="0.5"
+                value={slippage}
+                onChange={(e) => setSlippage(e.target.value)}
+                className="w-48"
+              />
+            </div>
+            <div>
+              <Label className="block text-xs text-muted-foreground mb-1">Poll Interval (ms)</Label>
+              <Input
+                type="number"
+                step="1000"
+                min="1000"
+                max="60000"
+                value={pollInterval}
+                onChange={(e) => setPollInterval(e.target.value)}
+                className="w-48"
+              />
+            </div>
+            <div className="flex items-center gap-3">
+              <Switch
+                checked={swapAfterClose}
+                onCheckedChange={setSwapAfterClose}
               />
-            </button>
-            <label className="text-xs text-zinc-500">
-              Swap tokens back to USDC after closing position
-            </label>
+              <Label className="text-xs text-muted-foreground">
+                Swap tokens back to USDC after closing position
+              </Label>
+            </div>
           </div>
-        </div>
-        <button
-          onClick={handleSave}
-          disabled={saving}
-          className="px-4 py-1.5 text-sm rounded bg-indigo-500 text-white hover:bg-indigo-400 disabled:opacity-50 transition-colors"
-        >
-          {saving ? 'Saving...' : 'Save Settings'}
-        </button>
-        <p className="text-[10px] text-zinc-600">
-          Note: Changes take effect on the next copy operation. Env variables (SOL_ENDPOINT,
-          SOL_SECRET_KEY, JUPITER_API_KEY) must be set in .env.local.
-        </p>
-      </div>
+          <Button
+            onClick={handleSave}
+            disabled={saving}
+          >
+            {saving ? 'Saving...' : 'Save Settings'}
+          </Button>
+          <p className="text-[10px] text-muted-foreground">
+            Note: Changes take effect on the next copy operation. Env variables (SOL_ENDPOINT,
+            SOL_SECRET_KEY, JUPITER_API_KEY) must be set in .env.local.
+          </p>
+        </CardContent>
+      </Card>
     </div>
   )
 }

+ 5 - 3
src/components/layout/header.tsx

@@ -1,6 +1,7 @@
 'use client'
 
 import useSWR from 'swr'
+import { ThemeToggle } from '@/components/theme-toggle'
 
 const fetcher = (url: string) => fetch(url).then((r) => r.json())
 
@@ -8,17 +9,18 @@ export function Header() {
   const { data } = useSWR('/api/wallet/balance', fetcher, { refreshInterval: 30000 })
 
   return (
-    <header className="border-b border-[var(--border)] px-6 py-3 flex items-center justify-between bg-[var(--card)]">
+    <header className="border-b border-border px-6 py-3 flex items-center justify-between bg-card">
       <h2 className="text-sm font-medium">Copy Trading Dashboard</h2>
       <div className="flex items-center gap-4 text-xs">
         {data?.address && (
-          <span className="text-[var(--muted)]">
+          <span className="text-muted-foreground">
             {data.address.slice(0, 4)}...{data.address.slice(-4)}
           </span>
         )}
         {data?.sol !== undefined && (
-          <span className="text-[var(--foreground)]">{data.sol.toFixed(4)} SOL</span>
+          <span className="text-foreground">{data.sol.toFixed(4)} SOL</span>
         )}
+        <ThemeToggle />
       </div>
     </header>
   )

+ 6 - 6
src/components/layout/sidebar.tsx

@@ -15,10 +15,10 @@ export function Sidebar() {
   const pathname = usePathname()
 
   return (
-    <aside className="w-56 min-h-screen border-r border-[var(--border)] bg-[var(--card)] p-4 flex flex-col">
+    <aside className="w-56 min-h-screen border-r border-border bg-card p-4 flex flex-col">
       <div className="mb-8">
-        <h1 className="text-lg font-bold text-[var(--accent)]">Byreal Copy Trade</h1>
-        <p className="text-xs text-[var(--muted)] mt-1">CLMM Position Copier</p>
+        <h1 className="text-lg font-bold text-primary">Byreal Copy Trade</h1>
+        <p className="text-xs text-muted-foreground mt-1">CLMM Position Copier</p>
       </div>
       <nav className="flex flex-col gap-1">
         {NAV_ITEMS.map((item) => {
@@ -27,10 +27,10 @@ export function Sidebar() {
             <Link
               key={item.href}
               href={item.href}
-              className={`px-3 py-2 rounded text-sm transition-colors ${
+              className={`px-3 py-2 rounded-md text-sm transition-colors ${
                 active
-                  ? 'bg-[var(--accent)] text-white'
-                  : 'text-[var(--muted)] hover:text-[var(--foreground)] hover:bg-[var(--border)]'
+                  ? 'bg-primary text-primary-foreground'
+                  : 'text-muted-foreground hover:text-foreground hover:bg-muted'
               }`}
             >
               {item.label}

+ 12 - 0
src/components/providers.tsx

@@ -0,0 +1,12 @@
+'use client'
+
+import { ThemeProvider } from 'next-themes'
+import { TooltipProvider } from '@/components/ui/tooltip'
+
+export function Providers({ children }: { children: React.ReactNode }) {
+  return (
+    <ThemeProvider attribute="class" defaultTheme="dark" enableSystem disableTransitionOnChange>
+      <TooltipProvider>{children}</TooltipProvider>
+    </ThemeProvider>
+  )
+}

+ 21 - 0
src/components/theme-toggle.tsx

@@ -0,0 +1,21 @@
+'use client'
+
+import { Moon, Sun } from 'lucide-react'
+import { useTheme } from 'next-themes'
+import { Button } from '@/components/ui/button'
+
+export function ThemeToggle() {
+  const { theme, setTheme } = useTheme()
+  return (
+    <Button
+      variant="ghost"
+      size="icon"
+      onClick={() => setTheme(theme === 'dark' ? 'light' : 'dark')}
+      className="h-8 w-8"
+    >
+      <Sun className="h-4 w-4 rotate-0 scale-100 transition-all dark:-rotate-90 dark:scale-0" />
+      <Moon className="absolute h-4 w-4 rotate-90 scale-0 transition-all dark:rotate-0 dark:scale-100" />
+      <span className="sr-only">Toggle theme</span>
+    </Button>
+  )
+}

+ 196 - 0
src/components/ui/alert-dialog.tsx

@@ -0,0 +1,196 @@
+"use client"
+
+import * as React from "react"
+import { AlertDialog as AlertDialogPrimitive } from "radix-ui"
+
+import { cn } from "@/lib/utils"
+import { Button } from "@/components/ui/button"
+
+function AlertDialog({
+  ...props
+}: React.ComponentProps<typeof AlertDialogPrimitive.Root>) {
+  return <AlertDialogPrimitive.Root data-slot="alert-dialog" {...props} />
+}
+
+function AlertDialogTrigger({
+  ...props
+}: React.ComponentProps<typeof AlertDialogPrimitive.Trigger>) {
+  return (
+    <AlertDialogPrimitive.Trigger data-slot="alert-dialog-trigger" {...props} />
+  )
+}
+
+function AlertDialogPortal({
+  ...props
+}: React.ComponentProps<typeof AlertDialogPrimitive.Portal>) {
+  return (
+    <AlertDialogPrimitive.Portal data-slot="alert-dialog-portal" {...props} />
+  )
+}
+
+function AlertDialogOverlay({
+  className,
+  ...props
+}: React.ComponentProps<typeof AlertDialogPrimitive.Overlay>) {
+  return (
+    <AlertDialogPrimitive.Overlay
+      data-slot="alert-dialog-overlay"
+      className={cn(
+        "fixed inset-0 z-50 bg-black/50 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:animate-in data-[state=open]:fade-in-0",
+        className
+      )}
+      {...props}
+    />
+  )
+}
+
+function AlertDialogContent({
+  className,
+  size = "default",
+  ...props
+}: React.ComponentProps<typeof AlertDialogPrimitive.Content> & {
+  size?: "default" | "sm"
+}) {
+  return (
+    <AlertDialogPortal>
+      <AlertDialogOverlay />
+      <AlertDialogPrimitive.Content
+        data-slot="alert-dialog-content"
+        data-size={size}
+        className={cn(
+          "group/alert-dialog-content fixed top-[50%] left-[50%] z-50 grid w-full max-w-[calc(100%-2rem)] translate-x-[-50%] translate-y-[-50%] gap-4 rounded-lg border bg-background p-6 shadow-lg duration-200 data-[size=sm]:max-w-xs data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 data-[state=open]:animate-in data-[state=open]:fade-in-0 data-[state=open]:zoom-in-95 data-[size=default]:sm:max-w-lg",
+          className
+        )}
+        {...props}
+      />
+    </AlertDialogPortal>
+  )
+}
+
+function AlertDialogHeader({
+  className,
+  ...props
+}: React.ComponentProps<"div">) {
+  return (
+    <div
+      data-slot="alert-dialog-header"
+      className={cn(
+        "grid grid-rows-[auto_1fr] place-items-center gap-1.5 text-center has-data-[slot=alert-dialog-media]:grid-rows-[auto_auto_1fr] has-data-[slot=alert-dialog-media]:gap-x-6 sm:group-data-[size=default]/alert-dialog-content:place-items-start sm:group-data-[size=default]/alert-dialog-content:text-left sm:group-data-[size=default]/alert-dialog-content:has-data-[slot=alert-dialog-media]:grid-rows-[auto_1fr]",
+        className
+      )}
+      {...props}
+    />
+  )
+}
+
+function AlertDialogFooter({
+  className,
+  ...props
+}: React.ComponentProps<"div">) {
+  return (
+    <div
+      data-slot="alert-dialog-footer"
+      className={cn(
+        "flex flex-col-reverse gap-2 group-data-[size=sm]/alert-dialog-content:grid group-data-[size=sm]/alert-dialog-content:grid-cols-2 sm:flex-row sm:justify-end",
+        className
+      )}
+      {...props}
+    />
+  )
+}
+
+function AlertDialogTitle({
+  className,
+  ...props
+}: React.ComponentProps<typeof AlertDialogPrimitive.Title>) {
+  return (
+    <AlertDialogPrimitive.Title
+      data-slot="alert-dialog-title"
+      className={cn(
+        "text-lg font-semibold sm:group-data-[size=default]/alert-dialog-content:group-has-data-[slot=alert-dialog-media]/alert-dialog-content:col-start-2",
+        className
+      )}
+      {...props}
+    />
+  )
+}
+
+function AlertDialogDescription({
+  className,
+  ...props
+}: React.ComponentProps<typeof AlertDialogPrimitive.Description>) {
+  return (
+    <AlertDialogPrimitive.Description
+      data-slot="alert-dialog-description"
+      className={cn("text-sm text-muted-foreground", className)}
+      {...props}
+    />
+  )
+}
+
+function AlertDialogMedia({
+  className,
+  ...props
+}: React.ComponentProps<"div">) {
+  return (
+    <div
+      data-slot="alert-dialog-media"
+      className={cn(
+        "mb-2 inline-flex size-16 items-center justify-center rounded-md bg-muted sm:group-data-[size=default]/alert-dialog-content:row-span-2 *:[svg:not([class*='size-'])]:size-8",
+        className
+      )}
+      {...props}
+    />
+  )
+}
+
+function AlertDialogAction({
+  className,
+  variant = "default",
+  size = "default",
+  ...props
+}: React.ComponentProps<typeof AlertDialogPrimitive.Action> &
+  Pick<React.ComponentProps<typeof Button>, "variant" | "size">) {
+  return (
+    <Button variant={variant} size={size} asChild>
+      <AlertDialogPrimitive.Action
+        data-slot="alert-dialog-action"
+        className={cn(className)}
+        {...props}
+      />
+    </Button>
+  )
+}
+
+function AlertDialogCancel({
+  className,
+  variant = "outline",
+  size = "default",
+  ...props
+}: React.ComponentProps<typeof AlertDialogPrimitive.Cancel> &
+  Pick<React.ComponentProps<typeof Button>, "variant" | "size">) {
+  return (
+    <Button variant={variant} size={size} asChild>
+      <AlertDialogPrimitive.Cancel
+        data-slot="alert-dialog-cancel"
+        className={cn(className)}
+        {...props}
+      />
+    </Button>
+  )
+}
+
+export {
+  AlertDialog,
+  AlertDialogAction,
+  AlertDialogCancel,
+  AlertDialogContent,
+  AlertDialogDescription,
+  AlertDialogFooter,
+  AlertDialogHeader,
+  AlertDialogMedia,
+  AlertDialogOverlay,
+  AlertDialogPortal,
+  AlertDialogTitle,
+  AlertDialogTrigger,
+}

+ 52 - 0
src/components/ui/badge.tsx

@@ -0,0 +1,52 @@
+import * as React from "react"
+import { cva, type VariantProps } from "class-variance-authority"
+import { Slot } from "radix-ui"
+
+import { cn } from "@/lib/utils"
+
+const badgeVariants = cva(
+  "inline-flex w-fit shrink-0 items-center justify-center gap-1 overflow-hidden rounded-full border border-transparent px-2 py-0.5 text-xs font-medium whitespace-nowrap transition-[color,box-shadow] focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 aria-invalid:border-destructive aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 [&>svg]:pointer-events-none [&>svg]:size-3",
+  {
+    variants: {
+      variant: {
+        default: "bg-primary text-primary-foreground [a&]:hover:bg-primary/90",
+        secondary:
+          "bg-secondary text-secondary-foreground [a&]:hover:bg-secondary/90",
+        destructive:
+          "bg-destructive text-white focus-visible:ring-destructive/20 dark:bg-destructive/60 dark:focus-visible:ring-destructive/40 [a&]:hover:bg-destructive/90",
+        outline:
+          "border-border text-foreground [a&]:hover:bg-accent [a&]:hover:text-accent-foreground",
+        ghost: "[a&]:hover:bg-accent [a&]:hover:text-accent-foreground",
+        link: "text-primary underline-offset-4 [a&]:hover:underline",
+        success:
+          "bg-success/20 text-success border-success/30 [a&]:hover:bg-success/30",
+        warning:
+          "bg-warning/20 text-warning border-warning/30 [a&]:hover:bg-warning/30",
+      },
+    },
+    defaultVariants: {
+      variant: "default",
+    },
+  }
+)
+
+function Badge({
+  className,
+  variant = "default",
+  asChild = false,
+  ...props
+}: React.ComponentProps<"span"> &
+  VariantProps<typeof badgeVariants> & { asChild?: boolean }) {
+  const Comp = asChild ? Slot.Root : "span"
+
+  return (
+    <Comp
+      data-slot="badge"
+      data-variant={variant}
+      className={cn(badgeVariants({ variant }), className)}
+      {...props}
+    />
+  )
+}
+
+export { Badge, badgeVariants }

+ 64 - 0
src/components/ui/button.tsx

@@ -0,0 +1,64 @@
+import * as React from "react"
+import { cva, type VariantProps } from "class-variance-authority"
+import { Slot } from "radix-ui"
+
+import { cn } from "@/lib/utils"
+
+const buttonVariants = cva(
+  "inline-flex shrink-0 items-center justify-center gap-2 rounded-md text-sm font-medium whitespace-nowrap transition-all outline-none focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 disabled:pointer-events-none disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
+  {
+    variants: {
+      variant: {
+        default: "bg-primary text-primary-foreground hover:bg-primary/90",
+        destructive:
+          "bg-destructive text-white hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:bg-destructive/60 dark:focus-visible:ring-destructive/40",
+        outline:
+          "border bg-background shadow-xs hover:bg-accent hover:text-accent-foreground dark:border-input dark:bg-input/30 dark:hover:bg-input/50",
+        secondary:
+          "bg-secondary text-secondary-foreground hover:bg-secondary/80",
+        ghost:
+          "hover:bg-accent hover:text-accent-foreground dark:hover:bg-accent/50",
+        link: "text-primary underline-offset-4 hover:underline",
+      },
+      size: {
+        default: "h-9 px-4 py-2 has-[>svg]:px-3",
+        xs: "h-6 gap-1 rounded-md px-2 text-xs has-[>svg]:px-1.5 [&_svg:not([class*='size-'])]:size-3",
+        sm: "h-8 gap-1.5 rounded-md px-3 has-[>svg]:px-2.5",
+        lg: "h-10 rounded-md px-6 has-[>svg]:px-4",
+        icon: "size-9",
+        "icon-xs": "size-6 rounded-md [&_svg:not([class*='size-'])]:size-3",
+        "icon-sm": "size-8",
+        "icon-lg": "size-10",
+      },
+    },
+    defaultVariants: {
+      variant: "default",
+      size: "default",
+    },
+  }
+)
+
+function Button({
+  className,
+  variant = "default",
+  size = "default",
+  asChild = false,
+  ...props
+}: React.ComponentProps<"button"> &
+  VariantProps<typeof buttonVariants> & {
+    asChild?: boolean
+  }) {
+  const Comp = asChild ? Slot.Root : "button"
+
+  return (
+    <Comp
+      data-slot="button"
+      data-variant={variant}
+      data-size={size}
+      className={cn(buttonVariants({ variant, size, className }))}
+      {...props}
+    />
+  )
+}
+
+export { Button, buttonVariants }

+ 92 - 0
src/components/ui/card.tsx

@@ -0,0 +1,92 @@
+import * as React from "react"
+
+import { cn } from "@/lib/utils"
+
+function Card({ className, ...props }: React.ComponentProps<"div">) {
+  return (
+    <div
+      data-slot="card"
+      className={cn(
+        "flex flex-col gap-6 rounded-xl border bg-card py-6 text-card-foreground shadow-sm",
+        className
+      )}
+      {...props}
+    />
+  )
+}
+
+function CardHeader({ className, ...props }: React.ComponentProps<"div">) {
+  return (
+    <div
+      data-slot="card-header"
+      className={cn(
+        "@container/card-header grid auto-rows-min grid-rows-[auto_auto] items-start gap-2 px-6 has-data-[slot=card-action]:grid-cols-[1fr_auto] [.border-b]:pb-6",
+        className
+      )}
+      {...props}
+    />
+  )
+}
+
+function CardTitle({ className, ...props }: React.ComponentProps<"div">) {
+  return (
+    <div
+      data-slot="card-title"
+      className={cn("leading-none font-semibold", className)}
+      {...props}
+    />
+  )
+}
+
+function CardDescription({ className, ...props }: React.ComponentProps<"div">) {
+  return (
+    <div
+      data-slot="card-description"
+      className={cn("text-sm text-muted-foreground", className)}
+      {...props}
+    />
+  )
+}
+
+function CardAction({ className, ...props }: React.ComponentProps<"div">) {
+  return (
+    <div
+      data-slot="card-action"
+      className={cn(
+        "col-start-2 row-span-2 row-start-1 self-start justify-self-end",
+        className
+      )}
+      {...props}
+    />
+  )
+}
+
+function CardContent({ className, ...props }: React.ComponentProps<"div">) {
+  return (
+    <div
+      data-slot="card-content"
+      className={cn("px-6", className)}
+      {...props}
+    />
+  )
+}
+
+function CardFooter({ className, ...props }: React.ComponentProps<"div">) {
+  return (
+    <div
+      data-slot="card-footer"
+      className={cn("flex items-center px-6 [.border-t]:pt-6", className)}
+      {...props}
+    />
+  )
+}
+
+export {
+  Card,
+  CardHeader,
+  CardFooter,
+  CardTitle,
+  CardAction,
+  CardDescription,
+  CardContent,
+}

+ 21 - 0
src/components/ui/input.tsx

@@ -0,0 +1,21 @@
+import * as React from "react"
+
+import { cn } from "@/lib/utils"
+
+function Input({ className, type, ...props }: React.ComponentProps<"input">) {
+  return (
+    <input
+      type={type}
+      data-slot="input"
+      className={cn(
+        "h-9 w-full min-w-0 rounded-md border border-input bg-transparent px-3 py-1 text-base shadow-xs transition-[color,box-shadow] outline-none selection:bg-primary selection:text-primary-foreground file:inline-flex file:h-7 file:border-0 file:bg-transparent file:text-sm file:font-medium file:text-foreground placeholder:text-muted-foreground disabled:pointer-events-none disabled:cursor-not-allowed disabled:opacity-50 md:text-sm dark:bg-input/30",
+        "focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50",
+        "aria-invalid:border-destructive aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40",
+        className
+      )}
+      {...props}
+    />
+  )
+}
+
+export { Input }

+ 24 - 0
src/components/ui/label.tsx

@@ -0,0 +1,24 @@
+"use client"
+
+import * as React from "react"
+import { Label as LabelPrimitive } from "radix-ui"
+
+import { cn } from "@/lib/utils"
+
+function Label({
+  className,
+  ...props
+}: React.ComponentProps<typeof LabelPrimitive.Root>) {
+  return (
+    <LabelPrimitive.Root
+      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 }

+ 190 - 0
src/components/ui/select.tsx

@@ -0,0 +1,190 @@
+"use client"
+
+import * as React from "react"
+import { CheckIcon, ChevronDownIcon, ChevronUpIcon } from "lucide-react"
+import { Select as SelectPrimitive } from "radix-ui"
+
+import { cn } from "@/lib/utils"
+
+function Select({
+  ...props
+}: React.ComponentProps<typeof SelectPrimitive.Root>) {
+  return <SelectPrimitive.Root data-slot="select" {...props} />
+}
+
+function SelectGroup({
+  ...props
+}: React.ComponentProps<typeof SelectPrimitive.Group>) {
+  return <SelectPrimitive.Group data-slot="select-group" {...props} />
+}
+
+function SelectValue({
+  ...props
+}: React.ComponentProps<typeof SelectPrimitive.Value>) {
+  return <SelectPrimitive.Value data-slot="select-value" {...props} />
+}
+
+function SelectTrigger({
+  className,
+  size = "default",
+  children,
+  ...props
+}: React.ComponentProps<typeof SelectPrimitive.Trigger> & {
+  size?: "sm" | "default"
+}) {
+  return (
+    <SelectPrimitive.Trigger
+      data-slot="select-trigger"
+      data-size={size}
+      className={cn(
+        "flex w-fit items-center justify-between gap-2 rounded-md border border-input bg-transparent px-3 py-2 text-sm whitespace-nowrap shadow-xs transition-[color,box-shadow] outline-none focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 disabled:cursor-not-allowed disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-destructive/20 data-[placeholder]:text-muted-foreground data-[size=default]:h-9 data-[size=sm]:h-8 *:data-[slot=select-value]:line-clamp-1 *:data-[slot=select-value]:flex *:data-[slot=select-value]:items-center *:data-[slot=select-value]:gap-2 dark:bg-input/30 dark:hover:bg-input/50 dark:aria-invalid:ring-destructive/40 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4 [&_svg:not([class*='text-'])]:text-muted-foreground",
+        className
+      )}
+      {...props}
+    >
+      {children}
+      <SelectPrimitive.Icon asChild>
+        <ChevronDownIcon className="size-4 opacity-50" />
+      </SelectPrimitive.Icon>
+    </SelectPrimitive.Trigger>
+  )
+}
+
+function SelectContent({
+  className,
+  children,
+  position = "item-aligned",
+  align = "center",
+  ...props
+}: React.ComponentProps<typeof SelectPrimitive.Content>) {
+  return (
+    <SelectPrimitive.Portal>
+      <SelectPrimitive.Content
+        data-slot="select-content"
+        className={cn(
+          "relative z-50 max-h-(--radix-select-content-available-height) min-w-[8rem] origin-(--radix-select-content-transform-origin) overflow-x-hidden overflow-y-auto rounded-md border bg-popover text-popover-foreground shadow-md 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 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 data-[state=open]:animate-in data-[state=open]:fade-in-0 data-[state=open]:zoom-in-95",
+          position === "popper" &&
+            "data-[side=bottom]:translate-y-1 data-[side=left]:-translate-x-1 data-[side=right]:translate-x-1 data-[side=top]:-translate-y-1",
+          className
+        )}
+        position={position}
+        align={align}
+        {...props}
+      >
+        <SelectScrollUpButton />
+        <SelectPrimitive.Viewport
+          className={cn(
+            "p-1",
+            position === "popper" &&
+              "h-[var(--radix-select-trigger-height)] w-full min-w-[var(--radix-select-trigger-width)] scroll-my-1"
+          )}
+        >
+          {children}
+        </SelectPrimitive.Viewport>
+        <SelectScrollDownButton />
+      </SelectPrimitive.Content>
+    </SelectPrimitive.Portal>
+  )
+}
+
+function SelectLabel({
+  className,
+  ...props
+}: React.ComponentProps<typeof SelectPrimitive.Label>) {
+  return (
+    <SelectPrimitive.Label
+      data-slot="select-label"
+      className={cn("px-2 py-1.5 text-xs text-muted-foreground", className)}
+      {...props}
+    />
+  )
+}
+
+function SelectItem({
+  className,
+  children,
+  ...props
+}: React.ComponentProps<typeof SelectPrimitive.Item>) {
+  return (
+    <SelectPrimitive.Item
+      data-slot="select-item"
+      className={cn(
+        "relative flex w-full cursor-default items-center gap-2 rounded-sm py-1.5 pr-8 pl-2 text-sm outline-hidden select-none focus:bg-accent 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 [&_svg:not([class*='text-'])]:text-muted-foreground *:[span]:last:flex *:[span]:last:items-center *:[span]:last:gap-2",
+        className
+      )}
+      {...props}
+    >
+      <span
+        data-slot="select-item-indicator"
+        className="absolute right-2 flex size-3.5 items-center justify-center"
+      >
+        <SelectPrimitive.ItemIndicator>
+          <CheckIcon className="size-4" />
+        </SelectPrimitive.ItemIndicator>
+      </span>
+      <SelectPrimitive.ItemText>{children}</SelectPrimitive.ItemText>
+    </SelectPrimitive.Item>
+  )
+}
+
+function SelectSeparator({
+  className,
+  ...props
+}: React.ComponentProps<typeof SelectPrimitive.Separator>) {
+  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.ScrollUpButton>) {
+  return (
+    <SelectPrimitive.ScrollUpButton
+      data-slot="select-scroll-up-button"
+      className={cn(
+        "flex cursor-default items-center justify-center py-1",
+        className
+      )}
+      {...props}
+    >
+      <ChevronUpIcon className="size-4" />
+    </SelectPrimitive.ScrollUpButton>
+  )
+}
+
+function SelectScrollDownButton({
+  className,
+  ...props
+}: React.ComponentProps<typeof SelectPrimitive.ScrollDownButton>) {
+  return (
+    <SelectPrimitive.ScrollDownButton
+      data-slot="select-scroll-down-button"
+      className={cn(
+        "flex cursor-default items-center justify-center py-1",
+        className
+      )}
+      {...props}
+    >
+      <ChevronDownIcon className="size-4" />
+    </SelectPrimitive.ScrollDownButton>
+  )
+}
+
+export {
+  Select,
+  SelectContent,
+  SelectGroup,
+  SelectItem,
+  SelectLabel,
+  SelectScrollDownButton,
+  SelectScrollUpButton,
+  SelectSeparator,
+  SelectTrigger,
+  SelectValue,
+}

+ 33 - 0
src/components/ui/switch.tsx

@@ -0,0 +1,33 @@
+import * as React from "react"
+import { Switch as SwitchPrimitive } from "radix-ui"
+
+import { cn } from "@/lib/utils"
+
+function Switch({
+  className,
+  size = "default",
+  ...props
+}: React.ComponentProps<typeof SwitchPrimitive.Root> & {
+  size?: "sm" | "default"
+}) {
+  return (
+    <SwitchPrimitive.Root
+      data-slot="switch"
+      data-size={size}
+      className={cn(
+        "peer group/switch inline-flex shrink-0 items-center rounded-full border border-transparent shadow-xs transition-all outline-none focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 disabled:cursor-not-allowed disabled:opacity-50 data-[size=default]:h-[1.15rem] data-[size=default]:w-8 data-[size=sm]:h-3.5 data-[size=sm]:w-6 data-[state=checked]:bg-primary data-[state=unchecked]:bg-input dark:data-[state=unchecked]:bg-input/80",
+        className
+      )}
+      {...props}
+    >
+      <SwitchPrimitive.Thumb
+        data-slot="switch-thumb"
+        className={cn(
+          "pointer-events-none block rounded-full bg-background ring-0 transition-transform group-data-[size=default]/switch:size-4 group-data-[size=sm]/switch:size-3 data-[state=checked]:translate-x-[calc(100%-2px)] data-[state=unchecked]:translate-x-0 dark:data-[state=checked]:bg-primary-foreground dark:data-[state=unchecked]:bg-foreground"
+        )}
+      />
+    </SwitchPrimitive.Root>
+  )
+}
+
+export { Switch }

+ 114 - 0
src/components/ui/table.tsx

@@ -0,0 +1,114 @@
+import * as React from "react"
+
+import { cn } from "@/lib/utils"
+
+function Table({ className, ...props }: React.ComponentProps<"table">) {
+  return (
+    <div
+      data-slot="table-container"
+      className="relative w-full overflow-x-auto"
+    >
+      <table
+        data-slot="table"
+        className={cn("w-full caption-bottom text-sm", className)}
+        {...props}
+      />
+    </div>
+  )
+}
+
+function TableHeader({ className, ...props }: React.ComponentProps<"thead">) {
+  return (
+    <thead
+      data-slot="table-header"
+      className={cn("[&_tr]:border-b", className)}
+      {...props}
+    />
+  )
+}
+
+function TableBody({ className, ...props }: React.ComponentProps<"tbody">) {
+  return (
+    <tbody
+      data-slot="table-body"
+      className={cn("[&_tr:last-child]:border-0", className)}
+      {...props}
+    />
+  )
+}
+
+function TableFooter({ className, ...props }: React.ComponentProps<"tfoot">) {
+  return (
+    <tfoot
+      data-slot="table-footer"
+      className={cn(
+        "border-t bg-muted/50 font-medium [&>tr]:last:border-b-0",
+        className
+      )}
+      {...props}
+    />
+  )
+}
+
+function TableRow({ className, ...props }: React.ComponentProps<"tr">) {
+  return (
+    <tr
+      data-slot="table-row"
+      className={cn(
+        "border-b transition-colors hover:bg-muted/50 data-[state=selected]:bg-muted",
+        className
+      )}
+      {...props}
+    />
+  )
+}
+
+function TableHead({ className, ...props }: React.ComponentProps<"th">) {
+  return (
+    <th
+      data-slot="table-head"
+      className={cn(
+        "h-10 px-2 text-left align-middle font-medium whitespace-nowrap text-foreground [&:has([role=checkbox])]:pr-0 [&>[role=checkbox]]:translate-y-[2px]",
+        className
+      )}
+      {...props}
+    />
+  )
+}
+
+function TableCell({ className, ...props }: React.ComponentProps<"td">) {
+  return (
+    <td
+      data-slot="table-cell"
+      className={cn(
+        "p-2 align-middle whitespace-nowrap [&:has([role=checkbox])]:pr-0 [&>[role=checkbox]]:translate-y-[2px]",
+        className
+      )}
+      {...props}
+    />
+  )
+}
+
+function TableCaption({
+  className,
+  ...props
+}: React.ComponentProps<"caption">) {
+  return (
+    <caption
+      data-slot="table-caption"
+      className={cn("mt-4 text-sm text-muted-foreground", className)}
+      {...props}
+    />
+  )
+}
+
+export {
+  Table,
+  TableHeader,
+  TableBody,
+  TableFooter,
+  TableHead,
+  TableRow,
+  TableCell,
+  TableCaption,
+}

+ 55 - 0
src/components/ui/tooltip.tsx

@@ -0,0 +1,55 @@
+import * as React from "react"
+import { Tooltip as TooltipPrimitive } from "radix-ui"
+
+import { cn } from "@/lib/utils"
+
+function TooltipProvider({
+  delayDuration = 0,
+  ...props
+}: React.ComponentProps<typeof TooltipPrimitive.Provider>) {
+  return (
+    <TooltipPrimitive.Provider
+      data-slot="tooltip-provider"
+      delayDuration={delayDuration}
+      {...props}
+    />
+  )
+}
+
+function Tooltip({
+  ...props
+}: React.ComponentProps<typeof TooltipPrimitive.Root>) {
+  return <TooltipPrimitive.Root data-slot="tooltip" {...props} />
+}
+
+function TooltipTrigger({
+  ...props
+}: React.ComponentProps<typeof TooltipPrimitive.Trigger>) {
+  return <TooltipPrimitive.Trigger data-slot="tooltip-trigger" {...props} />
+}
+
+function TooltipContent({
+  className,
+  sideOffset = 0,
+  children,
+  ...props
+}: React.ComponentProps<typeof TooltipPrimitive.Content>) {
+  return (
+    <TooltipPrimitive.Portal>
+      <TooltipPrimitive.Content
+        data-slot="tooltip-content"
+        sideOffset={sideOffset}
+        className={cn(
+          "z-50 w-fit origin-(--radix-tooltip-content-transform-origin) animate-in rounded-md bg-foreground px-3 py-1.5 text-xs text-balance text-background fade-in-0 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 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95",
+          className
+        )}
+        {...props}
+      >
+        {children}
+        <TooltipPrimitive.Arrow className="z-50 size-2.5 translate-y-[calc(-50%_-_2px)] rotate-45 rounded-[2px] bg-foreground fill-foreground" />
+      </TooltipPrimitive.Content>
+    </TooltipPrimitive.Portal>
+  )
+}
+
+export { Tooltip, TooltipTrigger, TooltipContent, TooltipProvider }

+ 6 - 0
src/lib/utils.ts

@@ -0,0 +1,6 @@
+import { type ClassValue, clsx } from 'clsx'
+import { twMerge } from 'tailwind-merge'
+
+export function cn(...inputs: ClassValue[]) {
+  return twMerge(clsx(inputs))
+}

Kaikkia tiedostoja ei voida näyttää, sillä liian monta tiedostoa muuttui tässä diffissä