diff --git a/raggr-frontend/package.json b/raggr-frontend/package.json index 2ff56a9..a07bc6d 100644 --- a/raggr-frontend/package.json +++ b/raggr-frontend/package.json @@ -12,11 +12,15 @@ }, "dependencies": { "axios": "^1.12.2", + "class-variance-authority": "^0.7.1", + "clsx": "^2.1.1", + "lucide-react": "^0.577.0", "marked": "^16.3.0", "npm-watch": "^0.13.0", "react": "^19.1.1", "react-dom": "^19.1.1", "react-markdown": "^10.1.0", + "tailwind-merge": "^3.5.0", "watch": "^1.0.2" }, "devDependencies": { diff --git a/raggr-frontend/src/App.css b/raggr-frontend/src/App.css index d99d3e9..a699380 100644 --- a/raggr-frontend/src/App.css +++ b/raggr-frontend/src/App.css @@ -1,26 +1,44 @@ +@import url('https://fonts.googleapis.com/css2?family=Nunito:wght@400;500;600;700;800&family=Playfair+Display:ital,wght@0,600;0,700;1,600&display=swap'); @import "tailwindcss"; -@import url('https://fonts.googleapis.com/css2?family=Nunito:wght@400;500;600;700&family=Playfair+Display:ital,wght@0,600;0,700;1,600&display=swap'); @theme { - --color-cream: #FBF7F0; - --color-cream-dark: #F3EDE2; + /* === Animal Crossing × Claude Palette === */ + + /* Backgrounds */ + --color-cream: #FAF8F2; + --color-cream-dark: #F0EBDF; --color-warm-white: #FFFDF9; + + /* Forest / Nook Green system */ + --color-forest: #2A4D38; + --color-forest-mid: #345E46; + --color-forest-light: #4D7A5E; + --color-leaf: #5E9E70; + --color-leaf-dark: #3D7A52; + --color-leaf-light: #B8DEC4; + --color-leaf-pale: #EBF7EE; + + /* Amber / warm accents */ --color-amber-glow: #E8943A; + --color-amber-dark: #C97828; --color-amber-soft: #F5C882; - --color-amber-pale: #FFF0D6; - --color-forest: #2D5A3D; - --color-forest-light: #3D763A; - --color-forest-pale: #E8F5E4; + --color-amber-pale: #FFF4E0; + + /* Neutrals */ --color-charcoal: #2C2420; - --color-warm-gray: #8A7E74; - --color-sand: #D4C5B0; - --color-sand-light: #E8DED0; + --color-warm-gray: #7A7268; + --color-sand: #DECFB8; + --color-sand-light: #EDE3D4; --color-blush: #F2D1B3; - --color-sidebar-bg: #2C2420; - --color-sidebar-hover: #3D352F; - --color-sidebar-active: #4A3F38; + + /* Sidebar */ + --color-sidebar-bg: #2A4D38; + --color-sidebar-hover: #345E46; + --color-sidebar-active: #3D6E52; + + /* Fonts */ --font-display: 'Playfair Display', Georgia, serif; - --font-body: 'Nunito', -apple-system, BlinkMacSystemFont, sans-serif; + --font-body: 'Nunito', 'Nunito Sans', system-ui, sans-serif; } * { @@ -36,97 +54,92 @@ body { -moz-osx-font-smoothing: grayscale; } -/* Scrollbar styling */ -::-webkit-scrollbar { - width: 6px; -} -::-webkit-scrollbar-track { - background: transparent; -} -::-webkit-scrollbar-thumb { - background: var(--color-sand); - border-radius: 3px; -} -::-webkit-scrollbar-thumb:hover { - background: var(--color-warm-gray); -} +/* ── Scrollbar ─────────────────────────────────────── */ +::-webkit-scrollbar { width: 5px; } +::-webkit-scrollbar-track { background: transparent; } +::-webkit-scrollbar-thumb { background: var(--color-sand); border-radius: 99px; } +::-webkit-scrollbar-thumb:hover { background: var(--color-warm-gray); } + +/* ── Markdown in answer bubbles ─────────────────────── */ +.markdown-content p { margin: 0.5em 0; line-height: 1.7; } +.markdown-content p:first-child { margin-top: 0; } +.markdown-content p:last-child { margin-bottom: 0; } -/* Markdown content styling in answer bubbles */ .markdown-content h1, .markdown-content h2, .markdown-content h3 { font-family: var(--font-display); font-weight: 600; - margin-top: 1em; - margin-bottom: 0.5em; + margin: 1em 0 0.4em; line-height: 1.3; + color: var(--color-charcoal); } -.markdown-content h1 { font-size: 1.25rem; } -.markdown-content h2 { font-size: 1.1rem; } -.markdown-content h3 { font-size: 1rem; } - -.markdown-content p { - margin: 0.5em 0; - line-height: 1.65; -} +.markdown-content h1 { font-size: 1.2rem; } +.markdown-content h2 { font-size: 1.05rem; } +.markdown-content h3 { font-size: 0.95rem; } .markdown-content ul, -.markdown-content ol { - padding-left: 1.5em; - margin: 0.5em 0; -} - -.markdown-content li { - margin: 0.25em 0; - line-height: 1.6; -} +.markdown-content ol { padding-left: 1.4em; margin: 0.5em 0; } +.markdown-content li { margin: 0.3em 0; line-height: 1.6; } .markdown-content code { - background: rgba(0, 0, 0, 0.06); + background: rgba(0,0,0,0.06); padding: 0.15em 0.4em; - border-radius: 4px; - font-size: 0.88em; - font-family: 'SF Mono', 'Fira Code', monospace; + border-radius: 5px; + font-size: 0.85em; + font-family: 'SF Mono', 'Fira Code', 'Cascadia Code', monospace; } .markdown-content pre { background: var(--color-charcoal); - color: #F3EDE2; - padding: 1em; - border-radius: 8px; + color: #F0EBDF; + padding: 1em 1.1em; + border-radius: 12px; overflow-x: auto; - margin: 0.75em 0; -} - -.markdown-content pre code { - background: none; - padding: 0; - color: inherit; + margin: 0.8em 0; } +.markdown-content pre code { background: none; padding: 0; color: inherit; } .markdown-content a { - color: var(--color-forest); + color: var(--color-leaf-dark); text-decoration: underline; text-underline-offset: 2px; } .markdown-content blockquote { - border-left: 3px solid var(--color-amber-glow); + border-left: 3px solid var(--color-amber-soft); padding-left: 1em; margin: 0.75em 0; color: var(--color-warm-gray); font-style: italic; } -/* Loading skeleton animation */ -@keyframes shimmer { - 0% { background-position: -200% 0; } - 100% { background-position: 200% 0; } +.markdown-content strong { font-weight: 700; } +.markdown-content em { font-style: italic; } + +/* ── Animations ─────────────────────────────────────── */ +@keyframes fadeSlideUp { + from { opacity: 0; transform: translateY(10px); } + to { opacity: 1; transform: translateY(0); } +} +.message-enter { + animation: fadeSlideUp 0.3s ease-out forwards; } +@keyframes catPulse { + 0%, 80%, 100% { opacity: 0.25; transform: scale(0.75); } + 40% { opacity: 1; transform: scale(1); } +} +.loading-dot { animation: catPulse 1.4s ease-in-out infinite; } +.loading-dot:nth-child(2) { animation-delay: 0.2s; } +.loading-dot:nth-child(3) { animation-delay: 0.4s; } + +@keyframes shimmer { + 0% { background-position: -200% 0; } + 100% { background-position: 200% 0; } +} .skeleton-shimmer { - background: linear-gradient( - 90deg, + background: linear-gradient(90deg, var(--color-sand-light) 25%, var(--color-cream) 50%, var(--color-sand-light) 75% @@ -135,36 +148,26 @@ body { animation: shimmer 1.8s ease-in-out infinite; } -/* Fade-in animation for messages */ -@keyframes fadeSlideUp { - from { - opacity: 0; - transform: translateY(12px); - } - to { - opacity: 1; - transform: translateY(0); - } +/* ── Toggle switch ──────────────────────────────────── */ +.toggle-track { + width: 36px; + height: 20px; + border-radius: 99px; + background: var(--color-sand); + position: relative; + transition: background 0.2s; + cursor: pointer; } - -.message-enter { - animation: fadeSlideUp 0.35s ease-out forwards; -} - -/* Subtle pulse for loading dots */ -@keyframes catPulse { - 0%, 80%, 100% { opacity: 0.3; transform: scale(0.8); } - 40% { opacity: 1; transform: scale(1); } -} - -.loading-dot { - animation: catPulse 1.4s ease-in-out infinite; -} -.loading-dot:nth-child(2) { animation-delay: 0.2s; } -.loading-dot:nth-child(3) { animation-delay: 0.4s; } - -/* Textarea focus glow */ -textarea:focus { - outline: none; - box-shadow: 0 0 0 2px var(--color-amber-soft); +.toggle-track.checked { background: var(--color-leaf); } +.toggle-thumb { + width: 14px; + height: 14px; + background: white; + border-radius: 99px; + position: absolute; + top: 3px; + left: 3px; + transition: transform 0.2s; + box-shadow: 0 1px 3px rgba(0,0,0,0.15); } +.toggle-track.checked .toggle-thumb { transform: translateX(16px); } diff --git a/raggr-frontend/src/components/AdminPanel.tsx b/raggr-frontend/src/components/AdminPanel.tsx index e4f12ea..e017ccf 100644 --- a/raggr-frontend/src/components/AdminPanel.tsx +++ b/raggr-frontend/src/components/AdminPanel.tsx @@ -1,5 +1,17 @@ import { useEffect, useState } from "react"; +import { X, Phone, PhoneOff, Pencil, Check } from "lucide-react"; import { userService, type AdminUserRecord } from "../api/userService"; +import { cn } from "../lib/utils"; +import { Button } from "./ui/button"; +import { Input } from "./ui/input"; +import { + Table, + TableBody, + TableCell, + TableHead, + TableHeader, + TableRow, +} from "./ui/table"; type Props = { onClose: () => void; @@ -24,8 +36,8 @@ export const AdminPanel = ({ onClose }: Props) => { const startEdit = (user: AdminUserRecord) => { setEditingId(user.id); setEditValue(user.whatsapp_number ?? ""); - setRowError((prev) => ({ ...prev, [user.id]: "" })); - setRowSuccess((prev) => ({ ...prev, [user.id]: "" })); + setRowError((p) => ({ ...p, [user.id]: "" })); + setRowSuccess((p) => ({ ...p, [user.id]: "" })); }; const cancelEdit = () => { @@ -34,33 +46,33 @@ export const AdminPanel = ({ onClose }: Props) => { }; const saveWhatsapp = async (userId: string) => { - setRowError((prev) => ({ ...prev, [userId]: "" })); + setRowError((p) => ({ ...p, [userId]: "" })); try { const updated = await userService.adminSetWhatsapp(userId, editValue); - setUsers((prev) => prev.map((u) => (u.id === userId ? updated : u))); - setRowSuccess((prev) => ({ ...prev, [userId]: "Saved" })); + setUsers((p) => p.map((u) => (u.id === userId ? updated : u))); + setRowSuccess((p) => ({ ...p, [userId]: "Saved ✓" })); setEditingId(null); - setTimeout(() => setRowSuccess((prev) => ({ ...prev, [userId]: "" })), 2000); + setTimeout(() => setRowSuccess((p) => ({ ...p, [userId]: "" })), 2000); } catch (err) { - setRowError((prev) => ({ - ...prev, + setRowError((p) => ({ + ...p, [userId]: err instanceof Error ? err.message : "Failed to save", })); } }; const unlinkWhatsapp = async (userId: string) => { - setRowError((prev) => ({ ...prev, [userId]: "" })); + setRowError((p) => ({ ...p, [userId]: "" })); try { await userService.adminUnlinkWhatsapp(userId); - setUsers((prev) => - prev.map((u) => (u.id === userId ? { ...u, whatsapp_number: null } : u)), + setUsers((p) => + p.map((u) => (u.id === userId ? { ...u, whatsapp_number: null } : u)), ); - setRowSuccess((prev) => ({ ...prev, [userId]: "Unlinked" })); - setTimeout(() => setRowSuccess((prev) => ({ ...prev, [userId]: "" })), 2000); + setRowSuccess((p) => ({ ...p, [userId]: "Unlinked ✓" })); + setTimeout(() => setRowSuccess((p) => ({ ...p, [userId]: "" })), 2000); } catch (err) { - setRowError((prev) => ({ - ...prev, + setRowError((p) => ({ + ...p, [userId]: err instanceof Error ? err.message : "Failed to unlink", })); } @@ -68,110 +80,152 @@ export const AdminPanel = ({ onClose }: Props) => { return (
| Username | -Actions | -
|---|
| {user.username} | -{user.email} | -
+
- setEditValue(e.target.value)}
placeholder="whatsapp:+15551234567"
+ className="w-52"
autoFocus
+ onKeyDown={(e) =>
+ e.key === "Enter" && saveWhatsapp(user.id)
+ }
/>
{rowError[user.id] && (
- {rowError[user.id]}
+
+ {rowError[user.id]}
+
)}
) : (
-
-
+
+
{user.whatsapp_number ?? "—"}
{rowSuccess[user.id] && (
- {rowSuccess[user.id]}
+
+ {rowSuccess[user.id]}
+
)}
{rowError[user.id] && (
- {rowError[user.id]}
+
+ {rowError[user.id]}
+
)}
)}
- |
-
+
+
-
) : (
-
-
)}
- |
-