CompetenceTrack v8 - Complete UI/UX redesign, Digital Notebooks, Drawing Canvas, Demo Data Cleanup
Features: - Complete UI/UX redesign for tablet/stylus optimization - Digital Notebook system (lined, grid, blank, dotted, music, calligraphy) - Drawing Canvas with stylus/pen support, pressure sensitivity - Art class mode with tutorial guides and shape templates - Demo data one-click cleanup for production transition - Subject-specific notebook filtering - Environmental messaging (Papier sparen, Umwelt schützen) - 44px minimum touch targets for tablet - No emojis - Lucide icons throughout - 700+ i18n keys (German/English) - Notebook types: lined, grid, blank, dotted, music, calligraphy - Drawing tools: pencil, pen, line, rectangle, circle, eraser - Undo/redo, color picker, stroke width, export PNG - Archive/unarchive notebooks - White backgrounds throughout - Emerald accent color theme Backend: - Notebook CRUD API routes - Drawing CRUD API routes - Demo data cleanup API - Prisma schema with Notebook, NotebookPage, Drawing models - isDemo flags on all new models
This commit is contained in:
1 parent
456cdf9714
commit
04034dca62
22 files changed
+4716
-53
No files matched your search
@@ -532,7 +532,7 @@ export default function AnalyticsView() {
|
||||
<>
|
||||
{/* Insights */}
|
||||
{(() => {
|
||||
const insights: Array<{ icon: string; text: string; tone: 'emerald' | 'amber' | 'violet' | 'rose' | 'teal' }> = [];
|
||||
const insights: Array<{ icon: React.ReactNode; text: string; tone: 'emerald' | 'amber' | 'violet' | 'rose' | 'teal' }> = [];
|
||||
// Trend insight: compare first vs last mastery data point
|
||||
if (data.masteryTrend.length >= 2) {
|
||||
const first = data.masteryTrend[0].avgMastery;
|
||||
|
||||
@@ -40,6 +40,8 @@ import {
|
||||
MessageSquareText,
|
||||
Pencil,
|
||||
Plus,
|
||||
Palette,
|
||||
Leaf,
|
||||
} from 'lucide-react';
|
||||
import {
|
||||
Sidebar,
|
||||
@@ -102,8 +104,8 @@ import {
|
||||
CommandShortcut,
|
||||
} from '@/components/ui/command';
|
||||
import { toast } from 'sonner';
|
||||
|
||||
import { AlertTriangle, Info, ArrowRight } from 'lucide-react';
|
||||
|
||||
import DashboardView from './dashboard-view';
|
||||
import ClassesView from './classes-view';
|
||||
import CompetencyGridView from './competency-grid-view';
|
||||
@@ -124,6 +126,8 @@ import BehaviorTrackingView from './behavior-tracking-view';
|
||||
import CurriculumCoverageView from './curriculum-coverage-view';
|
||||
import RubricLibraryView from './rubric-library-view';
|
||||
import CommentBankView from './comment-bank-view';
|
||||
import NotebooksView from './notebooks-view';
|
||||
import DrawingView from './drawing-view';
|
||||
|
||||
type NavItem = { key: ViewName; icon: React.ElementType; labelKey: string };
|
||||
type NavSection = { id: string; labelKey: string; items: NavItem[] };
|
||||
@@ -156,6 +160,8 @@ const navSections: NavSection[] = [
|
||||
{ key: 'behavior', icon: Shield, labelKey: 'nav.behavior' },
|
||||
{ key: 'rubrics', icon: Ruler, labelKey: 'nav.rubrics' },
|
||||
{ key: 'comments', icon: MessageSquareText, labelKey: 'nav.comments' },
|
||||
{ key: 'notebooks', icon: BookOpen, labelKey: 'nav.notebooks' },
|
||||
{ key: 'drawing', icon: Palette, labelKey: 'nav.drawing' },
|
||||
],
|
||||
},
|
||||
{
|
||||
@@ -190,6 +196,8 @@ function renderView(view: ViewName) {
|
||||
case 'coverage': return <CurriculumCoverageView />;
|
||||
case 'rubrics': return <RubricLibraryView />;
|
||||
case 'comments': return <CommentBankView />;
|
||||
case 'notebooks': return <NotebooksView />;
|
||||
case 'drawing': return <DrawingView />;
|
||||
default: return <DashboardView />;
|
||||
}
|
||||
}
|
||||
@@ -469,7 +477,7 @@ export default function AppLayout() {
|
||||
isActive={currentView === item.key}
|
||||
onClick={() => setCurrentView(item.key)}
|
||||
tooltip={t(item.labelKey)}
|
||||
className={`group relative transition-all duration-200 ${
|
||||
className={`group relative transition-all duration-200 min-h-[44px] ${
|
||||
currentView === item.key
|
||||
? 'bg-gradient-to-r from-emerald-100/90 via-emerald-50/70 to-teal-50/50 dark:from-emerald-900/40 dark:via-emerald-900/25 dark:to-teal-900/15 text-emerald-700 dark:text-emerald-300 font-semibold border-l-3 border-emerald-500 rounded-l-none shadow-sm shadow-emerald-100/40 dark:shadow-emerald-900/20'
|
||||
: 'text-gray-600 dark:text-gray-400 hover:text-emerald-600 dark:hover:text-emerald-400 hover:bg-emerald-50/50 dark:hover:bg-emerald-900/10'
|
||||
@@ -478,12 +486,12 @@ export default function AppLayout() {
|
||||
{currentView === item.key && (
|
||||
<span className="absolute inset-y-0 left-0 w-1 rounded-r-full bg-gradient-to-b from-emerald-400 to-teal-500" />
|
||||
)}
|
||||
<item.icon className={`h-4 w-4 transition-colors duration-200 ${
|
||||
<item.icon className={`h-5 w-5 transition-colors duration-200 ${
|
||||
currentView === item.key
|
||||
? 'text-emerald-600 dark:text-emerald-400'
|
||||
: 'text-gray-400 dark:text-gray-500 group-hover:text-emerald-500 dark:group-hover:text-emerald-400'
|
||||
}`} />
|
||||
<span>{t(item.labelKey)}</span>
|
||||
<span className="text-sm">{t(item.labelKey)}</span>
|
||||
</SidebarMenuButton>
|
||||
</SidebarMenuItem>
|
||||
))}
|
||||
@@ -494,9 +502,21 @@ export default function AppLayout() {
|
||||
</SidebarContent>
|
||||
|
||||
<SidebarFooter className="p-3">
|
||||
{/* Environmental message */}
|
||||
<motion.div
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
transition={{ delay: 0.4 }}
|
||||
className="flex items-center gap-2 px-3 py-2.5 mb-3 rounded-lg bg-gradient-to-r from-emerald-50/80 via-teal-50/40 to-transparent dark:from-emerald-900/15 dark:via-teal-900/10 dark:to-transparent border border-emerald-200/30 dark:border-emerald-800/20 group-data-[collapsible=icon]:hidden"
|
||||
>
|
||||
<Leaf className="h-4 w-4 text-emerald-500 shrink-0" />
|
||||
<p className="text-xs text-emerald-600/70 dark:text-emerald-400/50 font-medium leading-snug">
|
||||
{t('sidebar.eco_message')}
|
||||
</p>
|
||||
</motion.div>
|
||||
<Separator className="mb-3 bg-emerald-200/50 dark:bg-emerald-900/30" />
|
||||
<div className="flex items-center gap-2 group-data-[collapsible=icon]:justify-center">
|
||||
<Avatar className="h-9 w-9 shrink-0 ring-2 ring-emerald-200 dark:ring-emerald-800">
|
||||
<Avatar className="h-10 w-10 shrink-0 ring-2 ring-emerald-200 dark:ring-emerald-800">
|
||||
<AvatarFallback className="bg-gradient-to-br from-emerald-100 to-teal-100 text-emerald-700 dark:from-emerald-900 dark:to-teal-900 dark:text-emerald-300 text-xs font-bold">
|
||||
{initials}
|
||||
</AvatarFallback>
|
||||
@@ -517,7 +537,7 @@ export default function AppLayout() {
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-8 w-8 hover:bg-emerald-100 dark:hover:bg-emerald-900/20"
|
||||
className="h-10 w-10 min-touch hover:bg-emerald-100 dark:hover:bg-emerald-900/20"
|
||||
onClick={() => setTheme(theme === 'dark' ? 'light' : 'dark')}
|
||||
title={t('theme.toggle')}
|
||||
>
|
||||
@@ -526,7 +546,7 @@ export default function AppLayout() {
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-8 w-8 hover:bg-emerald-100 dark:hover:bg-emerald-900/20"
|
||||
className="h-10 w-10 min-touch hover:bg-emerald-100 dark:hover:bg-emerald-900/20"
|
||||
onClick={toggleLocale}
|
||||
title={t('language.toggle')}
|
||||
>
|
||||
@@ -535,7 +555,7 @@ export default function AppLayout() {
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-8 w-8 hover:bg-red-100 dark:hover:bg-red-900/20 text-gray-400 hover:text-red-500 dark:hover:text-red-400"
|
||||
className="h-10 w-10 min-touch hover:bg-red-100 dark:hover:bg-red-900/20 text-gray-400 hover:text-red-500 dark:hover:text-red-400"
|
||||
onClick={handleLogout}
|
||||
title={t('auth.logout')}
|
||||
>
|
||||
@@ -547,8 +567,8 @@ export default function AppLayout() {
|
||||
</Sidebar>
|
||||
|
||||
<SidebarInset>
|
||||
<header className="flex h-14 items-center gap-2 border-b border-emerald-200/50 dark:border-emerald-900/30 bg-gradient-to-r from-white/90 to-emerald-50/30 dark:from-gray-950/90 dark:to-emerald-950/10 backdrop-blur-sm px-4 sticky top-0 z-10 shadow-sm shadow-emerald-100/50 dark:shadow-emerald-900/10">
|
||||
<SidebarTrigger className="-ml-1 hover:bg-emerald-100 dark:hover:bg-emerald-900/20 shrink-0" />
|
||||
<header className="flex h-14 items-center gap-2 border-b border-emerald-200/50 dark:border-emerald-900/30 bg-white dark:bg-gray-950 backdrop-blur-sm px-4 sticky top-0 z-10 shadow-sm shadow-emerald-100/50 dark:shadow-emerald-900/10">
|
||||
<SidebarTrigger className="-ml-1 h-10 w-10 hover:bg-emerald-100 dark:hover:bg-emerald-900/20 shrink-0" />
|
||||
<Separator orientation="vertical" className="h-6 bg-emerald-200/50 dark:bg-emerald-900/30 shrink-0" />
|
||||
<Breadcrumb className="min-w-0 flex-1">
|
||||
<BreadcrumbList className="text-sm">
|
||||
@@ -817,14 +837,14 @@ export default function AppLayout() {
|
||||
</AnimatePresence>
|
||||
</div>
|
||||
|
||||
<footer className="mt-auto border-t border-emerald-200/50 dark:border-emerald-900/30 bg-gradient-to-r from-white/80 to-emerald-50/40 dark:from-gray-950/80 dark:to-emerald-950/10 py-3 px-4">
|
||||
<footer className="mt-auto border-t border-emerald-200/50 dark:border-emerald-900/30 bg-white dark:bg-gray-950 py-3 px-4">
|
||||
<div className="flex items-center justify-between text-xs text-emerald-600/60 dark:text-emerald-400/40">
|
||||
<span className="flex items-center gap-1">
|
||||
<Heart className="h-3 w-3 text-emerald-500" />
|
||||
<Leaf className="h-3 w-3 text-emerald-500" />
|
||||
CompetenceTrack — {t('footer.copyright')}
|
||||
</span>
|
||||
<span className="flex items-center gap-2">
|
||||
<span className="hidden sm:inline">{t('footer.version')} · {t('footer.oss')} · 🇩🇪 🇬🇧</span>
|
||||
<span className="hidden sm:inline">{t('footer.version')} · {t('footer.oss')}</span>
|
||||
<span className="sm:hidden">{t('footer.version')}</span>
|
||||
</span>
|
||||
</div>
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
import React, { useState } from 'react';
|
||||
import { motion, AnimatePresence } from 'framer-motion';
|
||||
import { BookOpen, Mail, Lock, User, ArrowRight, GraduationCap, Heart, Sparkles, Flower2, Eye, EyeOff, KeyRound, Info, Shield } from 'lucide-react';
|
||||
import { BookOpen, Mail, Lock, User, ArrowRight, GraduationCap, Heart, Sparkles, Flower2, Eye, EyeOff, KeyRound, Info, Shield, Leaf } from 'lucide-react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Label } from '@/components/ui/label';
|
||||
@@ -97,7 +97,7 @@ export default function AuthView() {
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="min-h-screen flex items-center justify-center bg-gradient-to-br from-emerald-50 via-white to-teal-50 dark:from-gray-950 dark:via-gray-900 dark:to-emerald-950 p-4 relative overflow-hidden">
|
||||
<div className="min-h-screen flex items-center justify-center bg-gradient-to-br from-emerald-50 via-white to-teal-50 dark:from-gray-950 dark:via-gray-900 dark:to-emerald-950 p-4 md:p-8 relative overflow-hidden">
|
||||
{/* Animated floating geometric shapes background */}
|
||||
<div className="absolute inset-0 overflow-hidden pointer-events-none">
|
||||
{/* Large emerald circle */}
|
||||
@@ -232,7 +232,7 @@ export default function AuthView() {
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ duration: 0.5 }}
|
||||
className="w-full max-w-md"
|
||||
className="w-full max-w-md md:max-w-lg"
|
||||
>
|
||||
{/* Branding */}
|
||||
<div className="text-center mb-8 lg:hidden">
|
||||
@@ -253,8 +253,8 @@ export default function AuthView() {
|
||||
</div>
|
||||
|
||||
{/* Auth Card */}
|
||||
<Card className="shadow-xl border-0 shadow-emerald-100/50 dark:shadow-emerald-900/20 rounded-xl overflow-hidden">
|
||||
<CardHeader className="text-center pb-1 pt-6 bg-gradient-to-r from-emerald-50/50 to-teal-50/50 dark:from-emerald-950/10 dark:to-teal-950/10">
|
||||
<Card className="shadow-xl border-0 shadow-emerald-100/50 dark:shadow-emerald-900/20 rounded-2xl overflow-hidden bg-white dark:bg-gray-950">
|
||||
<CardHeader className="text-center pb-2 pt-8 bg-gradient-to-r from-emerald-50/50 to-teal-50/50 dark:from-emerald-950/10 dark:to-teal-950/10">
|
||||
<CardTitle className="text-xl font-bold text-gray-900 dark:text-gray-100">
|
||||
{mode === 'login' ? t('auth.login') : t('auth.register_title')}
|
||||
</CardTitle>
|
||||
@@ -275,12 +275,12 @@ export default function AuthView() {
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="firstName" className="text-sm font-medium">{t('auth.firstName')}</Label>
|
||||
<div className="relative">
|
||||
<User className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-emerald-400" />
|
||||
<User className="absolute left-3.5 top-1/2 -translate-y-1/2 h-5 w-5 text-emerald-400" />
|
||||
<Input
|
||||
id="firstName"
|
||||
value={firstName}
|
||||
onChange={(e) => setFirstName(e.target.value)}
|
||||
className="pl-9 border-emerald-200/50 dark:border-emerald-900/30 focus:border-emerald-400 focus:ring-emerald-400/20"
|
||||
className="pl-10 h-12 min-h-[44px] border-emerald-200/50 dark:border-emerald-900/30 focus:border-emerald-400 focus:ring-emerald-400/20 text-base"
|
||||
placeholder={t('auth.firstName')}
|
||||
required
|
||||
/>
|
||||
@@ -292,7 +292,7 @@ export default function AuthView() {
|
||||
id="lastName"
|
||||
value={lastName}
|
||||
onChange={(e) => setLastName(e.target.value)}
|
||||
className="border-emerald-200/50 dark:border-emerald-900/30 focus:border-emerald-400 focus:ring-emerald-400/20"
|
||||
className="h-12 min-h-[44px] border-emerald-200/50 dark:border-emerald-900/30 focus:border-emerald-400 focus:ring-emerald-400/20 text-base"
|
||||
placeholder={t('auth.lastName')}
|
||||
required
|
||||
/>
|
||||
@@ -305,13 +305,13 @@ export default function AuthView() {
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="email" className="text-sm font-medium">{t('auth.email')}</Label>
|
||||
<div className="relative">
|
||||
<Mail className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-emerald-400" />
|
||||
<Mail className="absolute left-3.5 top-1/2 -translate-y-1/2 h-5 w-5 text-emerald-400" />
|
||||
<Input
|
||||
id="email"
|
||||
type="email"
|
||||
value={email}
|
||||
onChange={(e) => setEmail(e.target.value)}
|
||||
className="pl-9 border-emerald-200/50 dark:border-emerald-900/30 focus:border-emerald-400 focus:ring-emerald-400/20"
|
||||
className="pl-10 h-12 min-h-[44px] border-emerald-200/50 dark:border-emerald-900/30 focus:border-emerald-400 focus:ring-emerald-400/20 text-base"
|
||||
placeholder="name@schule.de"
|
||||
required
|
||||
/>
|
||||
@@ -332,7 +332,7 @@ export default function AuthView() {
|
||||
)}
|
||||
</div>
|
||||
<div className="relative">
|
||||
<Lock className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-emerald-400" />
|
||||
<Lock className="absolute left-3.5 top-1/2 -translate-y-1/2 h-5 w-5 text-emerald-400" />
|
||||
<Input
|
||||
id="password"
|
||||
type={showPassword ? 'text' : 'password'}
|
||||
@@ -346,11 +346,11 @@ export default function AuthView() {
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowPassword(!showPassword)}
|
||||
className="absolute right-3 top-1/2 -translate-y-1/2 text-emerald-400 hover:text-emerald-600 dark:hover:text-emerald-300 transition-colors"
|
||||
className="absolute right-3.5 top-1/2 -translate-y-1/2 text-emerald-400 hover:text-emerald-600 dark:hover:text-emerald-300 transition-colors w-10 h-10 flex items-center justify-center"
|
||||
tabIndex={-1}
|
||||
aria-label={showPassword ? t('polish.hide_password') : t('polish.show_password')}
|
||||
>
|
||||
{showPassword ? <EyeOff className="h-4 w-4" /> : <Eye className="h-4 w-4" />}
|
||||
{showPassword ? <EyeOff className="h-5 w-5" /> : <Eye className="h-5 w-5" />}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -372,7 +372,7 @@ export default function AuthView() {
|
||||
|
||||
<Button
|
||||
type="submit"
|
||||
className="w-full bg-gradient-to-r from-emerald-500 to-teal-500 hover:from-emerald-600 hover:to-teal-600 text-white shadow-lg shadow-emerald-300/30 dark:shadow-emerald-900/30 rounded-xl"
|
||||
className="w-full h-12 min-h-[44px] bg-gradient-to-r from-emerald-500 to-teal-500 hover:from-emerald-600 hover:to-teal-600 text-white shadow-lg shadow-emerald-300/30 dark:shadow-emerald-900/30 rounded-xl text-base"
|
||||
disabled={loading || demoLoading}
|
||||
>
|
||||
{loading || demoLoading ? (
|
||||
@@ -405,7 +405,7 @@ export default function AuthView() {
|
||||
</div>
|
||||
<p className="text-xs text-gray-500 dark:text-gray-400 mt-0.5">{t('auth.demo_section_subtitle')}</p>
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-2.5">
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
{DEMO_ACCOUNTS.map((demo) => (
|
||||
<Button
|
||||
key={demo.email}
|
||||
@@ -413,9 +413,9 @@ export default function AuthView() {
|
||||
variant="outline"
|
||||
onClick={() => handleDemoLogin(demo.email, demo.password)}
|
||||
disabled={loading || demoLoading}
|
||||
className={`h-auto py-2.5 px-3 rounded-xl border-0 bg-gradient-to-br ${demo.colorClass} text-white font-semibold text-xs shadow-lg flex flex-col items-center gap-1.5 hover:shadow-xl transition-all`}
|
||||
className={`h-auto min-h-[44px] py-3 px-3 rounded-xl border-0 bg-gradient-to-br ${demo.colorClass} text-white font-semibold text-xs shadow-lg flex flex-col items-center gap-1.5 hover:shadow-xl transition-all`}
|
||||
>
|
||||
<demo.icon className="h-4 w-4" />
|
||||
<demo.icon className="h-5 w-5" />
|
||||
<span>{t(demo.labelKey)}</span>
|
||||
</Button>
|
||||
))}
|
||||
@@ -436,9 +436,12 @@ export default function AuthView() {
|
||||
</Card>
|
||||
|
||||
{/* Footer */}
|
||||
<p className="mt-8 text-center text-xs text-emerald-600/40 dark:text-emerald-400/30">
|
||||
{t('app.tagline')}
|
||||
</p>
|
||||
<div className="mt-8 text-center flex items-center justify-center gap-1.5">
|
||||
<Leaf className="h-3.5 w-3.5 text-emerald-500/40 dark:text-emerald-400/30" />
|
||||
<p className="text-xs text-emerald-600/40 dark:text-emerald-400/30">
|
||||
{t('app.tagline')}
|
||||
</p>
|
||||
</div>
|
||||
</motion.div>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -37,7 +37,29 @@ import {
|
||||
Frown,
|
||||
Meh,
|
||||
Sparkles,
|
||||
Handshake,
|
||||
Hand,
|
||||
AlertTriangle,
|
||||
Flame,
|
||||
Clock,
|
||||
Star,
|
||||
MessageSquare,
|
||||
type LucideIcon,
|
||||
} from 'lucide-react';
|
||||
|
||||
const LUCIDE_ICON_MAP: Record<string, LucideIcon> = {
|
||||
Handshake, Hand, AlertTriangle, Flame, Clock, Star, MessageSquare,
|
||||
Shield, ShieldAlert, Smile, Frown, Meh, Sparkles, Award, CheckCircle2,
|
||||
Circle, Plus, Pencil, Trash2, Filter, X, CalendarIcon,
|
||||
MapPin, UserCheck, PieChartIcon, BarChart3, ListChecks, Loader2,
|
||||
};
|
||||
|
||||
function renderLucideIcon(name: string, className?: string): React.ReactNode {
|
||||
const IconComp = LUCIDE_ICON_MAP[name];
|
||||
if (!IconComp) return null;
|
||||
return <IconComp className={className ?? 'h-3.5 w-3.5'} />;
|
||||
}
|
||||
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
@@ -360,7 +382,7 @@ function CategoryBadge({ category }: { category: BehaviorIncident['category'] })
|
||||
borderColor: hexToRgba(category.color, 0.3),
|
||||
}}
|
||||
>
|
||||
{category.icon && <span>{category.icon}</span>}
|
||||
{category.icon && renderLucideIcon(category.icon)}
|
||||
{category.name}
|
||||
</span>
|
||||
);
|
||||
|
||||
@@ -26,7 +26,20 @@ import {
|
||||
BarChart3,
|
||||
ListChecks,
|
||||
MessageSquareText,
|
||||
MessageSquare,
|
||||
type LucideIcon,
|
||||
} from 'lucide-react';
|
||||
|
||||
const LUCIDE_ICON_MAP_CB: Record<string, LucideIcon> = {
|
||||
MessageSquare, MessageSquareText, BookOpen, Plus, Pencil, Trash2, Copy, Loader2, Search, Eye, Globe, Lock, Tag, BarChart3, ListChecks,
|
||||
};
|
||||
|
||||
function renderCBIcon(name: string, className?: string): React.ReactNode {
|
||||
const IconComp = LUCIDE_ICON_MAP_CB[name];
|
||||
if (!IconComp) return null;
|
||||
return <IconComp className={className ?? 'h-3.5 w-3.5 inline'} />;
|
||||
}
|
||||
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
@@ -594,7 +607,7 @@ export default function CommentBankView() {
|
||||
borderColor: hexToRgba(entry.category.color, 0.3),
|
||||
}}
|
||||
>
|
||||
{entry.category.icon ? `${entry.category.icon} ` : ''}{entry.category.name}
|
||||
{entry.category.icon ? renderCBIcon(entry.category.icon) : null}{entry.category.name}
|
||||
</Badge>
|
||||
{entry.isPublic ? (
|
||||
<Badge variant="outline" className="text-xs bg-emerald-50 text-emerald-600 border-emerald-200 dark:bg-emerald-950/40 dark:text-emerald-400 dark:border-emerald-800">
|
||||
@@ -863,7 +876,7 @@ export default function CommentBankView() {
|
||||
borderColor: hexToRgba(entry.category.color, 0.3),
|
||||
}}
|
||||
>
|
||||
{entry.category.icon ? `${entry.category.icon} ` : ''}{entry.category.name}
|
||||
{entry.category.icon ? renderCBIcon(entry.category.icon) : null}{entry.category.name}
|
||||
</Badge>
|
||||
<Badge variant="secondary" className="text-xs font-semibold">
|
||||
{entry.usageCount}×
|
||||
@@ -1034,7 +1047,7 @@ export default function CommentBankView() {
|
||||
borderColor: hexToRgba(entryDetailData.category.color, 0.3),
|
||||
}}
|
||||
>
|
||||
{entryDetailData.category.icon ? `${entryDetailData.category.icon} ` : ''}{entryDetailData.category.name}
|
||||
{entryDetailData.category.icon ? renderCBIcon(entryDetailData.category.icon) : null}{entryDetailData.category.name}
|
||||
</Badge>
|
||||
</DialogTitle>
|
||||
<DialogDescription>
|
||||
|
||||
@@ -392,7 +392,7 @@ export default function DashboardView() {
|
||||
</motion.div>
|
||||
|
||||
{/* ===== STATS CARDS ===== */}
|
||||
<motion.div variants={itemVariants} className="grid grid-cols-2 md:grid-cols-3 lg:grid-cols-5 gap-4">
|
||||
<motion.div variants={itemVariants} className="grid grid-cols-1 md:grid-cols-3 lg:grid-cols-5 gap-4">
|
||||
{[
|
||||
{ icon: Users, label: t('dashboard.total_students'), value: data.stats.totalStudents, color: 'emerald', trend: 'up', tip: t('dashboard.total_students'), spark: [3, 5, 4, 6, 7, 8, data.stats.totalStudents] },
|
||||
{ icon: BarChart3, label: t('dashboard.total_classes'), value: data.stats.totalClasses, color: 'teal', trend: 'up', tip: t('dashboard.total_classes'), spark: [1, 2, 2, 3, 3, 4, data.stats.totalClasses] },
|
||||
@@ -659,7 +659,7 @@ export default function DashboardView() {
|
||||
<motion.div whileHover={{ scale: 1.02, y: -1 }} whileTap={{ scale: 0.98 }} transition={{ duration: 0.15 }}>
|
||||
<Button
|
||||
onClick={() => setCurrentView('progress')}
|
||||
className="bg-gradient-to-r from-emerald-500 to-teal-500 hover:from-emerald-600 hover:to-teal-600 text-white shadow-lg shadow-emerald-300/30 rounded-xl px-6 h-11 transition-shadow hover:shadow-xl hover:shadow-emerald-300/40"
|
||||
className="bg-gradient-to-r from-emerald-500 to-teal-500 hover:from-emerald-600 hover:to-teal-600 text-white shadow-lg shadow-emerald-300/30 rounded-xl px-6 h-12 min-h-[44px] transition-shadow hover:shadow-xl hover:shadow-emerald-300/40"
|
||||
>
|
||||
<motion.div
|
||||
className="flex items-center gap-2"
|
||||
@@ -676,7 +676,7 @@ export default function DashboardView() {
|
||||
<Button
|
||||
onClick={() => setCurrentView('assessments')}
|
||||
variant="outline"
|
||||
className="border-emerald-300 dark:border-emerald-700 hover:bg-emerald-50 dark:hover:bg-emerald-900/20 text-emerald-700 dark:text-emerald-300 rounded-xl px-6 h-11 transition-shadow hover:shadow-md hover:shadow-emerald-100/40"
|
||||
className="border-emerald-300 dark:border-emerald-700 hover:bg-emerald-50 dark:hover:bg-emerald-900/20 text-emerald-700 dark:text-emerald-300 rounded-xl px-6 h-12 min-h-[44px] transition-shadow hover:shadow-md hover:shadow-emerald-100/40"
|
||||
>
|
||||
<ClipboardCheck className="h-5 w-5 mr-2" />
|
||||
{t('action.create_assessment')}
|
||||
@@ -686,7 +686,7 @@ export default function DashboardView() {
|
||||
<Button
|
||||
onClick={() => setCurrentView('reports')}
|
||||
variant="outline"
|
||||
className="border-teal-300 dark:border-teal-700 hover:bg-teal-50 dark:hover:bg-teal-900/20 text-teal-700 dark:text-teal-300 rounded-xl px-6 h-11 transition-shadow hover:shadow-md hover:shadow-teal-100/40"
|
||||
className="border-teal-300 dark:border-teal-700 hover:bg-teal-50 dark:hover:bg-teal-900/20 text-teal-700 dark:text-teal-300 rounded-xl px-6 h-12 min-h-[44px] transition-shadow hover:shadow-md hover:shadow-teal-100/40"
|
||||
>
|
||||
<FileText className="h-5 w-5 mr-2" />
|
||||
{t('action.generate_report')}
|
||||
@@ -720,7 +720,7 @@ export default function DashboardView() {
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="grid grid-cols-2 lg:grid-cols-4 gap-3">
|
||||
<div className="grid grid-cols-2 md:grid-cols-4 gap-4">
|
||||
{[
|
||||
{
|
||||
icon: ClipboardCheck,
|
||||
@@ -781,14 +781,14 @@ export default function DashboardView() {
|
||||
whileHover={{ scale: 1.03, y: -3 }}
|
||||
whileTap={{ scale: 0.98 }}
|
||||
transition={{ duration: 0.18, ease: 'easeOut' }}
|
||||
className={`group text-left p-4 rounded-xl bg-gradient-to-br ${action.tile} border ${action.border} hover:shadow-lg ${action.hoverGlow} transition-shadow duration-300 relative overflow-hidden`}
|
||||
className={`group text-left p-4 min-h-[80px] rounded-xl bg-gradient-to-br ${action.tile} border ${action.border} hover:shadow-lg ${action.hoverGlow} transition-shadow duration-300 relative overflow-hidden`}
|
||||
>
|
||||
{/* Subtle glass overlay on hover */}
|
||||
<div className="absolute inset-0 bg-white/0 group-hover:bg-white/20 dark:group-hover:bg-white/5 transition-colors duration-300 pointer-events-none rounded-xl" />
|
||||
<div className="relative">
|
||||
<div className="flex items-start justify-between mb-3">
|
||||
<div className={`flex items-center justify-center w-10 h-10 rounded-xl ${action.iconBg} ${action.iconText} shadow-sm transition-transform duration-200 group-hover:scale-110 group-hover:rotate-3`}>
|
||||
<action.icon className="h-5 w-5" />
|
||||
<div className={`flex items-center justify-center w-12 h-12 rounded-xl ${action.iconBg} ${action.iconText} shadow-sm transition-transform duration-200 group-hover:scale-110 group-hover:rotate-3`}>
|
||||
<action.icon className="h-6 w-6" />
|
||||
</div>
|
||||
{action.recentlyUsed && (
|
||||
<span className="inline-flex items-center gap-1 px-1.5 py-0.5 rounded-md bg-emerald-100/60 dark:bg-emerald-900/30 text-[9px] font-semibold text-emerald-600 dark:text-emerald-400">
|
||||
@@ -1144,6 +1144,35 @@ export default function DashboardView() {
|
||||
</CardContent>
|
||||
</Card>
|
||||
</motion.div>
|
||||
|
||||
{/* ===== ENVIRONMENTAL TIPS SECTION ===== */}
|
||||
<motion.div variants={itemVariants}>
|
||||
<Card className="border-0 shadow-sm rounded-xl overflow-hidden bg-white dark:bg-gray-950 ring-1 ring-black/[0.03] dark:ring-white/[0.05]">
|
||||
<CardContent className="p-5">
|
||||
<div className="flex items-center gap-4">
|
||||
<div className="flex items-center justify-center w-12 h-12 rounded-xl bg-gradient-to-br from-emerald-400 to-teal-500 text-white shadow-md shrink-0">
|
||||
<Leaf className="h-6 w-6" />
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<h3 className="text-sm font-bold text-emerald-700 dark:text-emerald-300 mb-1">
|
||||
{t('dashboard.env_tip_title')}
|
||||
</h3>
|
||||
<p className="text-xs text-gray-600 dark:text-gray-400 leading-relaxed">
|
||||
{t('dashboard.env_tip_text')}
|
||||
</p>
|
||||
</div>
|
||||
<div className="hidden md:flex items-center gap-2 shrink-0">
|
||||
<div className="flex items-center justify-center w-8 h-8 rounded-lg bg-emerald-100 dark:bg-emerald-900/30 text-emerald-600 dark:text-emerald-400">
|
||||
<TreePine className="h-4 w-4" />
|
||||
</div>
|
||||
<div className="flex items-center justify-center w-8 h-8 rounded-lg bg-teal-100 dark:bg-teal-900/30 text-teal-600 dark:text-teal-400">
|
||||
<Sprout className="h-4 w-4" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</motion.div>
|
||||
</motion.div>
|
||||
);
|
||||
}
|
||||
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,516 @@
|
||||
'use client';
|
||||
|
||||
import React, { useCallback, useEffect, useState } from 'react';
|
||||
import { motion, AnimatePresence } from 'framer-motion';
|
||||
import {
|
||||
Plus,
|
||||
Pencil,
|
||||
Trash2,
|
||||
Share2,
|
||||
Leaf,
|
||||
Palette,
|
||||
Search,
|
||||
Filter,
|
||||
Image as ImageIcon,
|
||||
Eye,
|
||||
EyeOff,
|
||||
Loader2,
|
||||
BookOpen,
|
||||
Save,
|
||||
} from 'lucide-react';
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Skeleton } from '@/components/ui/skeleton';
|
||||
import { Switch } from '@/components/ui/switch';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from '@/components/ui/dialog';
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from '@/components/ui/select';
|
||||
import {
|
||||
AlertDialog,
|
||||
AlertDialogAction,
|
||||
AlertDialogCancel,
|
||||
AlertDialogContent,
|
||||
AlertDialogDescription,
|
||||
AlertDialogFooter,
|
||||
AlertDialogHeader,
|
||||
AlertDialogTitle,
|
||||
} from '@/components/ui/alert-dialog';
|
||||
import {
|
||||
Tooltip,
|
||||
TooltipContent,
|
||||
TooltipProvider,
|
||||
TooltipTrigger,
|
||||
} from '@/components/ui/tooltip';
|
||||
import { useAppStore } from '@/lib/store';
|
||||
import { t } from '@/lib/i18n';
|
||||
import { apiGet, apiPost, apiPut, apiDelete } from '@/lib/api';
|
||||
import DrawingCanvas, { type Stroke } from '@/components/drawing-canvas';
|
||||
|
||||
/* ── Types ─────────────────────────────────────────────────────────── */
|
||||
|
||||
interface DrawingRecord {
|
||||
id: string;
|
||||
title: string;
|
||||
description?: string;
|
||||
drawingData: string;
|
||||
imageData?: string;
|
||||
subjectId?: string;
|
||||
classGroupId?: string;
|
||||
isPublic: boolean;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
interface SubjectOption {
|
||||
id: string;
|
||||
name: string;
|
||||
}
|
||||
|
||||
/* ── Animation variants ────────────────────────────────────────────── */
|
||||
|
||||
const containerVariants = {
|
||||
hidden: { opacity: 0 },
|
||||
visible: { opacity: 1, transition: { staggerChildren: 0.06 } },
|
||||
};
|
||||
|
||||
const itemVariants = {
|
||||
hidden: { opacity: 0, y: 12 },
|
||||
visible: { opacity: 1, y: 0 },
|
||||
};
|
||||
|
||||
/* ── Main Component ────────────────────────────────────────────────── */
|
||||
|
||||
export default function DrawingView() {
|
||||
const { currentUser } = useAppStore();
|
||||
|
||||
// View state
|
||||
const [isCanvasOpen, setIsCanvasOpen] = useState(false);
|
||||
const [editingDrawing, setEditingDrawing] = useState<DrawingRecord | null>(null);
|
||||
|
||||
// Data state
|
||||
const [drawings, setDrawings] = useState<DrawingRecord[]>([]);
|
||||
const [subjects, setSubjects] = useState<SubjectOption[]>([]);
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
|
||||
// Filter state
|
||||
const [filterSubject, setFilterSubject] = useState<string>('all');
|
||||
const [searchQuery, setSearchQuery] = useState('');
|
||||
|
||||
// Delete state
|
||||
const [deleteTarget, setDeleteTarget] = useState<DrawingRecord | null>(null);
|
||||
|
||||
/* ── Fetch drawings ─────────────────────────────────────────────── */
|
||||
|
||||
const fetchDrawings = useCallback(async () => {
|
||||
try {
|
||||
setIsLoading(true);
|
||||
const data = await apiGet<DrawingRecord[]>('/api/drawings');
|
||||
setDrawings(Array.isArray(data) ? data : []);
|
||||
} catch {
|
||||
setDrawings([]);
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
const fetchSubjects = useCallback(async () => {
|
||||
try {
|
||||
const data = await apiGet<SubjectOption[]>('/api/subjects');
|
||||
setSubjects(Array.isArray(data) ? data : []);
|
||||
} catch {
|
||||
setSubjects([]);
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
fetchDrawings();
|
||||
fetchSubjects();
|
||||
}, [fetchDrawings, fetchSubjects]);
|
||||
|
||||
/* ── Filtered drawings ──────────────────────────────────────────── */
|
||||
|
||||
const filteredDrawings = drawings.filter((d) => {
|
||||
if (filterSubject !== 'all' && d.subjectId !== filterSubject) return false;
|
||||
if (searchQuery && !d.title.toLowerCase().includes(searchQuery.toLowerCase())) return false;
|
||||
return true;
|
||||
});
|
||||
|
||||
/* ── Save handler ───────────────────────────────────────────────── */
|
||||
|
||||
const handleSaveDrawing = useCallback(
|
||||
async (drawingData: string, imageData: string) => {
|
||||
try {
|
||||
if (editingDrawing) {
|
||||
// Update existing
|
||||
await apiPut(`/api/drawings/${editingDrawing.id}`, {
|
||||
title: editingDrawing.title,
|
||||
drawingData,
|
||||
imageData,
|
||||
});
|
||||
} else {
|
||||
// Create new
|
||||
await apiPost('/api/drawings', {
|
||||
title: t('drawing.untitled'),
|
||||
drawingData,
|
||||
imageData,
|
||||
schoolId: currentUser?.schoolId,
|
||||
subjectId: filterSubject !== 'all' ? filterSubject : undefined,
|
||||
});
|
||||
}
|
||||
setIsCanvasOpen(false);
|
||||
setEditingDrawing(null);
|
||||
fetchDrawings();
|
||||
} catch {
|
||||
// Error handling
|
||||
}
|
||||
},
|
||||
[editingDrawing, currentUser, filterSubject, fetchDrawings]
|
||||
);
|
||||
|
||||
/* ── Delete handler ─────────────────────────────────────────────── */
|
||||
|
||||
const handleDelete = useCallback(async () => {
|
||||
if (!deleteTarget) return;
|
||||
try {
|
||||
await apiDelete(`/api/drawings/${deleteTarget.id}`);
|
||||
setDeleteTarget(null);
|
||||
fetchDrawings();
|
||||
} catch {
|
||||
// Error handling
|
||||
}
|
||||
}, [deleteTarget, fetchDrawings]);
|
||||
|
||||
/* ── Toggle public ──────────────────────────────────────────────── */
|
||||
|
||||
const handleTogglePublic = useCallback(
|
||||
async (drawing: DrawingRecord) => {
|
||||
try {
|
||||
await apiPut(`/api/drawings/${drawing.id}`, {
|
||||
isPublic: !drawing.isPublic,
|
||||
});
|
||||
fetchDrawings();
|
||||
} catch {
|
||||
// Error handling
|
||||
}
|
||||
},
|
||||
[fetchDrawings]
|
||||
);
|
||||
|
||||
/* ── Open canvas for new drawing ────────────────────────────────── */
|
||||
|
||||
const handleNewDrawing = useCallback(() => {
|
||||
setEditingDrawing(null);
|
||||
setIsCanvasOpen(true);
|
||||
}, []);
|
||||
|
||||
/* ── Open canvas for editing ────────────────────────────────────── */
|
||||
|
||||
const handleEditDrawing = useCallback((drawing: DrawingRecord) => {
|
||||
setEditingDrawing(drawing);
|
||||
setIsCanvasOpen(true);
|
||||
}, []);
|
||||
|
||||
/* ── Exit canvas ────────────────────────────────────────────────── */
|
||||
|
||||
const handleExitCanvas = useCallback(() => {
|
||||
setIsCanvasOpen(false);
|
||||
setEditingDrawing(null);
|
||||
}, []);
|
||||
|
||||
/* ── Canvas mode ────────────────────────────────────────────────── */
|
||||
|
||||
if (isCanvasOpen) {
|
||||
return (
|
||||
<div className="h-screen w-full">
|
||||
<DrawingCanvas
|
||||
initialDrawingData={editingDrawing?.drawingData}
|
||||
onSave={handleSaveDrawing}
|
||||
onExit={handleExitCanvas}
|
||||
title={editingDrawing?.title}
|
||||
subjectId={editingDrawing?.subjectId}
|
||||
classGroupId={editingDrawing?.classGroupId}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/* ── Gallery mode ───────────────────────────────────────────────── */
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-gray-50 dark:bg-gray-950">
|
||||
<motion.div
|
||||
className="mx-auto max-w-7xl px-4 py-6 sm:px-6 lg:px-8"
|
||||
variants={containerVariants}
|
||||
initial="hidden"
|
||||
animate="visible"
|
||||
>
|
||||
{/* ── Header ──────────────────────────────────────────────── */}
|
||||
<motion.div variants={itemVariants} className="mb-6">
|
||||
<div className="flex flex-col gap-4 sm:flex-row sm:items-center sm:justify-between">
|
||||
<div>
|
||||
<h1 className="flex items-center gap-2 text-2xl font-bold text-gray-900 dark:text-gray-100">
|
||||
<Palette className="h-7 w-7 text-emerald-600" />
|
||||
{t('drawing.title')}
|
||||
</h1>
|
||||
<p className="mt-1 flex items-center gap-1.5 text-sm text-gray-500 dark:text-gray-400">
|
||||
<Leaf className="h-4 w-4 text-emerald-500" />
|
||||
{t('drawing.subtitle')}
|
||||
</p>
|
||||
</div>
|
||||
<Button
|
||||
onClick={handleNewDrawing}
|
||||
className="bg-emerald-600 hover:bg-emerald-700"
|
||||
>
|
||||
<Plus className="mr-2 h-4 w-4" />
|
||||
{t('drawing.new')}
|
||||
</Button>
|
||||
</div>
|
||||
</motion.div>
|
||||
|
||||
{/* ── Eco tip banner ──────────────────────────────────────── */}
|
||||
<motion.div variants={itemVariants} className="mb-6">
|
||||
<Card className="border-emerald-200 bg-gradient-to-r from-emerald-50 to-green-50 dark:border-emerald-800 dark:from-emerald-950/30 dark:to-green-950/30">
|
||||
<CardContent className="flex items-center gap-3 py-3">
|
||||
<div className="flex h-9 w-9 shrink-0 items-center justify-center rounded-full bg-emerald-100 dark:bg-emerald-900/50">
|
||||
<Leaf className="h-5 w-5 text-emerald-600" />
|
||||
</div>
|
||||
<p className="text-sm text-emerald-700 dark:text-emerald-300">
|
||||
{t('drawing.eco_tip')}
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</motion.div>
|
||||
|
||||
{/* ── Filters ─────────────────────────────────────────────── */}
|
||||
<motion.div variants={itemVariants} className="mb-6 flex flex-col gap-3 sm:flex-row sm:items-center">
|
||||
<div className="relative flex-1">
|
||||
<Search className="absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-gray-400" />
|
||||
<Input
|
||||
value={searchQuery}
|
||||
onChange={(e) => setSearchQuery(e.target.value)}
|
||||
placeholder={t('action.search')}
|
||||
className="pl-9"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Filter className="h-4 w-4 text-gray-400" />
|
||||
<Select value={filterSubject} onValueChange={setFilterSubject}>
|
||||
<SelectTrigger className="w-48">
|
||||
<SelectValue placeholder={t('drawing.filter_subject')} />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="all">{t('drawing.all_subjects')}</SelectItem>
|
||||
{subjects.map((s) => (
|
||||
<SelectItem key={s.id} value={s.id}>
|
||||
{s.name}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</motion.div>
|
||||
|
||||
{/* ── Drawing Grid ────────────────────────────────────────── */}
|
||||
{isLoading ? (
|
||||
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4">
|
||||
{Array.from({ length: 8 }).map((_, i) => (
|
||||
<Card key={i} className="overflow-hidden">
|
||||
<Skeleton className="aspect-video w-full" />
|
||||
<CardContent className="p-4">
|
||||
<Skeleton className="mb-2 h-4 w-3/4" />
|
||||
<Skeleton className="h-3 w-1/2" />
|
||||
</CardContent>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
) : filteredDrawings.length === 0 ? (
|
||||
<motion.div variants={itemVariants}>
|
||||
<Card className="border-dashed">
|
||||
<CardContent className="flex flex-col items-center gap-4 py-12">
|
||||
<div className="flex h-16 w-16 items-center justify-center rounded-full bg-gray-100 dark:bg-gray-800">
|
||||
<ImageIcon className="h-8 w-8 text-gray-400" />
|
||||
</div>
|
||||
<div className="text-center">
|
||||
<h3 className="text-lg font-semibold text-gray-900 dark:text-gray-100">
|
||||
{t('drawing.no_drawings')}
|
||||
</h3>
|
||||
<p className="mt-1 text-sm text-gray-500 dark:text-gray-400">
|
||||
{t('drawing.no_drawings_desc')}
|
||||
</p>
|
||||
</div>
|
||||
<Button
|
||||
onClick={handleNewDrawing}
|
||||
className="bg-emerald-600 hover:bg-emerald-700"
|
||||
>
|
||||
<Plus className="mr-2 h-4 w-4" />
|
||||
{t('drawing.new')}
|
||||
</Button>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</motion.div>
|
||||
) : (
|
||||
<motion.div
|
||||
className="grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4"
|
||||
variants={containerVariants}
|
||||
>
|
||||
<AnimatePresence>
|
||||
{filteredDrawings.map((drawing) => (
|
||||
<motion.div
|
||||
key={drawing.id}
|
||||
variants={itemVariants}
|
||||
layout
|
||||
exit={{ opacity: 0, scale: 0.95 }}
|
||||
whileHover={{ y: -2 }}
|
||||
transition={{ duration: 0.2 }}
|
||||
>
|
||||
<Card className="group overflow-hidden transition-shadow hover:shadow-lg">
|
||||
{/* Thumbnail */}
|
||||
<div className="relative aspect-video bg-white">
|
||||
{drawing.imageData ? (
|
||||
<img
|
||||
src={drawing.imageData}
|
||||
alt={drawing.title}
|
||||
className="h-full w-full object-contain"
|
||||
/>
|
||||
) : (
|
||||
<div className="flex h-full w-full items-center justify-center bg-gray-50">
|
||||
<Palette className="h-10 w-10 text-gray-300" />
|
||||
</div>
|
||||
)}
|
||||
{/* Public badge */}
|
||||
{drawing.isPublic && (
|
||||
<Badge className="absolute right-2 top-2 bg-emerald-100 text-emerald-700">
|
||||
<Share2 className="mr-1 h-3 w-3" />
|
||||
{t('drawing.public')}
|
||||
</Badge>
|
||||
)}
|
||||
{/* Hover overlay */}
|
||||
<div className="absolute inset-0 flex items-center justify-center gap-2 bg-black/40 opacity-0 transition-opacity group-hover:opacity-100">
|
||||
<TooltipProvider>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
size="sm"
|
||||
onClick={() => handleEditDrawing(drawing)}
|
||||
className="bg-white text-gray-900 hover:bg-gray-100"
|
||||
>
|
||||
<Pencil className="h-4 w-4" />
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>{t('drawing.edit')}</TooltipContent>
|
||||
</Tooltip>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
size="sm"
|
||||
onClick={() => handleTogglePublic(drawing)}
|
||||
className="bg-white text-gray-900 hover:bg-gray-100"
|
||||
>
|
||||
{drawing.isPublic ? (
|
||||
<EyeOff className="h-4 w-4" />
|
||||
) : (
|
||||
<Eye className="h-4 w-4" />
|
||||
)}
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
{drawing.isPublic ? t('drawing.private') : t('drawing.public_desc')}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="destructive"
|
||||
onClick={() => setDeleteTarget(drawing)}
|
||||
>
|
||||
<Trash2 className="h-4 w-4" />
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>{t('drawing.delete')}</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
</div>
|
||||
</div>
|
||||
{/* Info */}
|
||||
<CardContent className="p-3">
|
||||
<h3 className="truncate text-sm font-semibold text-gray-900 dark:text-gray-100">
|
||||
{drawing.title || t('drawing.untitled')}
|
||||
</h3>
|
||||
<div className="mt-1 flex items-center gap-2 text-xs text-gray-500 dark:text-gray-400">
|
||||
<span>{new Date(drawing.updatedAt).toLocaleDateString()}</span>
|
||||
{drawing.isPublic && (
|
||||
<Badge variant="outline" className="text-[10px]">
|
||||
{t('drawing.public')}
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
{drawing.description && (
|
||||
<p className="mt-1 line-clamp-2 text-xs text-gray-500 dark:text-gray-400">
|
||||
{drawing.description}
|
||||
</p>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</motion.div>
|
||||
))}
|
||||
</AnimatePresence>
|
||||
</motion.div>
|
||||
)}
|
||||
|
||||
{/* ── Footer eco message ──────────────────────────────────── */}
|
||||
<motion.div variants={itemVariants} className="mt-8">
|
||||
<Card className="border-emerald-200 bg-emerald-50/50 dark:border-emerald-900 dark:bg-emerald-950/20">
|
||||
<CardContent className="flex items-center gap-3 py-3">
|
||||
<Leaf className="h-5 w-5 text-emerald-600" />
|
||||
<p className="text-sm text-emerald-700 dark:text-emerald-300">
|
||||
{t('drawing.eco_message')}
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</motion.div>
|
||||
</motion.div>
|
||||
|
||||
{/* ── Delete Confirmation Dialog ─────────────────────────────── */}
|
||||
<AlertDialog
|
||||
open={!!deleteTarget}
|
||||
onOpenChange={(open) => {
|
||||
if (!open) setDeleteTarget(null);
|
||||
}}
|
||||
>
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>{t('drawing.delete_confirm')}</AlertDialogTitle>
|
||||
<AlertDialogDescription>
|
||||
{t('drawing.delete_desc')}
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel>{t('action.cancel')}</AlertDialogCancel>
|
||||
<AlertDialogAction
|
||||
onClick={handleDelete}
|
||||
className="bg-red-600 hover:bg-red-700"
|
||||
>
|
||||
{t('drawing.delete')}
|
||||
</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
File diff suppressed because it is too large.
Load diff
@@ -1299,7 +1299,7 @@ function StatisticsPanel({
|
||||
<div key={c} className="space-y-1">
|
||||
<div className="flex items-center justify-between text-xs">
|
||||
<span className="font-medium text-gray-700 dark:text-gray-300 flex items-center gap-1.5">
|
||||
<span>{cfg.emoji}</span>
|
||||
{(() => { const CatIcon = cfg.iconComponent; return <CatIcon className="w-3.5 h-3.5" />; })()}
|
||||
{t(CATEGORY_LABEL_KEY[c])}
|
||||
</span>
|
||||
<span className="text-gray-500 dark:text-gray-400">{count}</span>
|
||||
|
||||
@@ -32,6 +32,8 @@ import {
|
||||
BarChart3,
|
||||
TrendingUp,
|
||||
CheckCircle,
|
||||
Leaf,
|
||||
DatabaseIcon,
|
||||
} from 'lucide-react';
|
||||
import { Card, CardContent, CardHeader, CardTitle, CardDescription } from '@/components/ui/card';
|
||||
import { Button } from '@/components/ui/button';
|
||||
@@ -650,7 +652,7 @@ export default function SettingsView() {
|
||||
<span className="hidden sm:inline">{t('settings.tab_data')}</span>
|
||||
</TabsTrigger>
|
||||
{isAdmin && (
|
||||
<TabsTrigger value="demo" className="rounded-lg data-[state=active]:bg-amber-500 data-[state=active]:text-white">
|
||||
<TabsTrigger value="demo" className="rounded-lg min-h-[44px] data-[state=active]:bg-amber-500 data-[state=active]:text-white">
|
||||
<Zap className="h-4 w-4 mr-1.5" />
|
||||
<span className="hidden sm:inline">{t('settings.demo_accounts')}</span>
|
||||
</TabsTrigger>
|
||||
@@ -1541,7 +1543,60 @@ export default function SettingsView() {
|
||||
{/* ── Demo Accounts Tab ───────────────────────────────── */}
|
||||
{isAdmin && (
|
||||
<TabsContent value="demo">
|
||||
<Card className="border-0 shadow-sm rounded-xl border-l-3 border-l-amber-500 overflow-hidden">
|
||||
{/* Demo Data Danger Zone Card */}
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 12 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ delay: 0.1 }}
|
||||
>
|
||||
<Card className="border-2 border-rose-200 dark:border-rose-900/40 shadow-sm rounded-xl overflow-hidden bg-white dark:bg-gray-950 mb-6">
|
||||
<CardHeader className="bg-gradient-to-r from-rose-50/80 to-rose-100/40 dark:from-rose-900/20 dark:to-rose-900/10 pb-3 pt-6">
|
||||
<CardTitle className="flex items-center gap-2 text-rose-700 dark:text-rose-300">
|
||||
<div className="flex items-center justify-center w-10 h-10 rounded-xl bg-rose-100 dark:bg-rose-900/30 text-rose-600 dark:text-rose-400">
|
||||
<Trash2 className="h-5 w-5" />
|
||||
</div>
|
||||
{t('settings.demo_data_title')}
|
||||
</CardTitle>
|
||||
<CardDescription className="text-rose-600/60 dark:text-rose-400/40">{t('settings.demo_data_desc')}</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
{/* Demo record count */}
|
||||
<div className="flex items-center gap-3 p-4 rounded-xl bg-gradient-to-r from-rose-50/60 to-rose-50/0 dark:from-rose-900/15 dark:to-rose-900/0 border border-rose-100/50 dark:border-rose-900/20">
|
||||
<DatabaseIcon className="h-5 w-5 text-rose-500 shrink-0" />
|
||||
<div className="flex-1">
|
||||
<p className="text-sm font-semibold text-gray-900 dark:text-gray-100">
|
||||
{t('settings.demo_record_count')}
|
||||
</p>
|
||||
<p className="text-xs text-gray-500 dark:text-gray-400">
|
||||
{demoAccounts.length} {t('settings.demo_record_count').toLowerCase()}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Big red Delete All Demo Data button */}
|
||||
<motion.div whileHover={{ scale: 1.02 }} whileTap={{ scale: 0.98 }}>
|
||||
<Button
|
||||
onClick={() => setDeleteAllDemoOpen(true)}
|
||||
className="w-full h-12 min-h-[44px] bg-gradient-to-r from-rose-500 to-rose-600 hover:from-rose-600 hover:to-rose-700 text-white shadow-lg shadow-rose-300/20 rounded-xl"
|
||||
>
|
||||
<Trash2 className="h-5 w-5 mr-2" />
|
||||
{t('settings.demo_delete_all_data')}
|
||||
</Button>
|
||||
</motion.div>
|
||||
|
||||
{/* Environmental-friendly messaging */}
|
||||
<div className="flex items-center gap-2 p-3 rounded-xl bg-gradient-to-r from-emerald-50/60 to-teal-50/30 dark:from-emerald-900/15 dark:to-teal-900/10 border border-emerald-200/30 dark:border-emerald-900/20">
|
||||
<Leaf className="h-4 w-4 text-emerald-500 shrink-0" />
|
||||
<p className="text-xs text-emerald-700/70 dark:text-emerald-400/50">
|
||||
{t('settings.env_tip')}
|
||||
</p>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</motion.div>
|
||||
|
||||
{/* Demo Accounts Card */}
|
||||
<Card className="border-0 shadow-sm rounded-xl border-l-3 border-l-amber-500 overflow-hidden bg-white dark:bg-gray-950">
|
||||
<CardHeader className="bg-gradient-to-r from-amber-50/50 to-emerald-50/30 dark:from-amber-900/10 dark:to-emerald-900/5">
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
<div className="flex items-center justify-center w-8 h-8 rounded-lg bg-amber-100 dark:bg-amber-900/30 text-amber-600 dark:text-amber-400">
|
||||
@@ -1565,7 +1620,7 @@ export default function SettingsView() {
|
||||
<motion.div whileHover={{ scale: 1.03 }} whileTap={{ scale: 0.97 }}>
|
||||
<Button
|
||||
onClick={handleEnableAllDemo}
|
||||
className="bg-gradient-to-r from-emerald-500 to-teal-500 hover:from-emerald-600 hover:to-teal-600 text-white shadow-md shadow-emerald-300/20 rounded-xl"
|
||||
className="bg-gradient-to-r from-emerald-500 to-teal-500 hover:from-emerald-600 hover:to-teal-600 text-white shadow-md shadow-emerald-300/20 rounded-xl min-h-[44px]"
|
||||
>
|
||||
<CheckCircle2 className="h-4 w-4 mr-1.5" />
|
||||
{t('settings.demo_enable')}
|
||||
@@ -1574,7 +1629,7 @@ export default function SettingsView() {
|
||||
<Button
|
||||
onClick={handleDisableAllDemo}
|
||||
variant="outline"
|
||||
className="border-amber-300 dark:border-amber-700 text-amber-700 dark:text-amber-300 hover:bg-amber-50 dark:hover:bg-amber-900/20 rounded-xl"
|
||||
className="border-amber-300 dark:border-amber-700 text-amber-700 dark:text-amber-300 hover:bg-amber-50 dark:hover:bg-amber-900/20 rounded-xl min-h-[44px]"
|
||||
>
|
||||
<Clock className="h-4 w-4 mr-1.5" />
|
||||
{t('settings.demo_disable_all')}
|
||||
@@ -1582,7 +1637,7 @@ export default function SettingsView() {
|
||||
<Button
|
||||
onClick={() => setDeleteAllDemoOpen(true)}
|
||||
variant="outline"
|
||||
className="border-rose-300 dark:border-rose-700 text-rose-700 dark:text-rose-300 hover:bg-rose-50 dark:hover:bg-rose-900/20 rounded-xl"
|
||||
className="border-rose-300 dark:border-rose-700 text-rose-700 dark:text-rose-300 hover:bg-rose-50 dark:hover:bg-rose-900/20 rounded-xl min-h-[44px]"
|
||||
>
|
||||
<Trash2 className="h-4 w-4 mr-1.5" />
|
||||
{t('settings.demo_delete_all')}
|
||||
|
||||
Reference in new issue
Block a user