2424 lines
97 KiB
TypeScript
2424 lines
97 KiB
TypeScript
'use client';
|
|
|
|
import React, { useEffect, useState, useMemo, useCallback, useRef } from 'react';
|
|
import { motion, AnimatePresence } from 'framer-motion';
|
|
import {
|
|
BookOpen, Book, Archive, Grid3X3, AlignLeft, File,
|
|
Palette, Leaf, Plus, Star, Trash2, ChevronLeft,
|
|
MoreHorizontal, Music, PenTool, Search, X,
|
|
Share2, Eye, EyeOff, Edit3, Hash, Bookmark,
|
|
PenLine, Layers, BookMarked, Globe, Sparkles,
|
|
Bold, Italic as ItalicIcon, Underline as UnderlineIcon,
|
|
List, ListOrdered, AlignLeftIcon, AlignCenterIcon, AlignRightIcon,
|
|
Heading1, Heading2, Heading3, CheckCircle2,
|
|
User as UserIcon, GraduationCap, FileDown,
|
|
Copy, FlaskConical, Languages, Calculator, Paintbrush, Megaphone,
|
|
Strikethrough, Type, Highlighter, GripVertical,
|
|
} from 'lucide-react';
|
|
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
|
import { Button } from '@/components/ui/button';
|
|
import { Input } from '@/components/ui/input';
|
|
import { Label } from '@/components/ui/label';
|
|
import { Badge } from '@/components/ui/badge';
|
|
import { Textarea } from '@/components/ui/textarea';
|
|
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription, DialogFooter } from '@/components/ui/dialog';
|
|
import { AlertDialog, AlertDialogAction, AlertDialogCancel, AlertDialogContent, AlertDialogDescription, AlertDialogFooter, AlertDialogHeader, AlertDialogTitle } from '@/components/ui/alert-dialog';
|
|
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
|
|
import { ScrollArea } from '@/components/ui/scroll-area';
|
|
import { Separator } from '@/components/ui/separator';
|
|
import { Switch } from '@/components/ui/switch';
|
|
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip';
|
|
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover';
|
|
import { useAppStore } from '@/lib/store';
|
|
import { t } from '@/lib/i18n';
|
|
import { apiGet, apiPost, apiPut, apiDelete } from '@/lib/api';
|
|
import { toast } from 'sonner';
|
|
|
|
// ─── Types ───────────────────────────────────────────────────────────
|
|
|
|
interface NotebookPage {
|
|
id: string;
|
|
notebookId: string;
|
|
pageNumber: number;
|
|
title: string | null;
|
|
contentType: string;
|
|
textContent: string | null;
|
|
drawingData: string | null;
|
|
background: string;
|
|
isBookmark: boolean;
|
|
createdAt: string;
|
|
updatedAt: string;
|
|
}
|
|
|
|
interface Notebook {
|
|
id: string;
|
|
schoolId: string;
|
|
ownerId: string;
|
|
ownerType: string;
|
|
subjectId: string | null;
|
|
classGroupId: string | null;
|
|
title: string;
|
|
description: string | null;
|
|
notebookType: string;
|
|
color: string;
|
|
icon: string | null;
|
|
isArchived: boolean;
|
|
isPublic: boolean;
|
|
sortOrder: number;
|
|
createdAt: string;
|
|
updatedAt: string;
|
|
pages?: NotebookPage[];
|
|
_count?: { pages: number };
|
|
subject?: { id: string; name: string } | null;
|
|
classGroup?: { id: string; name: string } | null;
|
|
owner?: { id: string; firstName: string; lastName: string; role: string } | null;
|
|
}
|
|
|
|
interface Subject {
|
|
id: string;
|
|
name: string;
|
|
schoolId: string;
|
|
}
|
|
|
|
interface ClassGroup {
|
|
id: string;
|
|
name: string;
|
|
schoolId: string;
|
|
gradeLevel: number;
|
|
}
|
|
|
|
// ─── Constants ───────────────────────────────────────────────────────
|
|
|
|
const NOTEBOOK_TYPES: Array<{ key: string; icon: React.ElementType; labelKey: string; descKey: string }> = [
|
|
{ key: 'lined', icon: AlignLeft, labelKey: 'notebooks.type_lined', descKey: 'notebooks.type_lined_desc' },
|
|
{ key: 'grid', icon: Grid3X3, labelKey: 'notebooks.type_grid', descKey: 'notebooks.type_grid_desc' },
|
|
{ key: 'blank', icon: File, labelKey: 'notebooks.type_blank', descKey: 'notebooks.type_blank_desc' },
|
|
{ key: 'dotted', icon: MoreHorizontal, labelKey: 'notebooks.type_dotted', descKey: 'notebooks.type_dotted_desc' },
|
|
{ key: 'music', icon: Music, labelKey: 'notebooks.type_music', descKey: 'notebooks.type_music_desc' },
|
|
{ key: 'calligraphy', icon: PenTool, labelKey: 'notebooks.type_calligraphy', descKey: 'notebooks.type_calligraphy_desc' },
|
|
];
|
|
|
|
const COVER_COLORS = [
|
|
{ key: 'emerald', hex: '#10b981', labelKey: 'notebooks.color_emerald' },
|
|
{ key: 'blue', hex: '#3b82f6', labelKey: 'notebooks.color_blue' },
|
|
{ key: 'red', hex: '#ef4444', labelKey: 'notebooks.color_red' },
|
|
{ key: 'yellow', hex: '#f59e0b', labelKey: 'notebooks.color_yellow' },
|
|
{ key: 'purple', hex: '#8b5cf6', labelKey: 'notebooks.color_purple' },
|
|
{ key: 'orange', hex: '#f97316', labelKey: 'notebooks.color_orange' },
|
|
{ key: 'teal', hex: '#14b8a6', labelKey: 'notebooks.color_teal' },
|
|
{ key: 'pink', hex: '#ec4899', labelKey: 'notebooks.color_pink' },
|
|
];
|
|
|
|
const ICON_OPTIONS = [
|
|
'BookOpen', 'Book', 'PenLine', 'Music', 'PenTool', 'Palette',
|
|
'Leaf', 'Star', 'Globe', 'Sparkles', 'Hash', 'Layers',
|
|
];
|
|
|
|
const ICON_MAP: Record<string, React.ElementType> = {
|
|
BookOpen, Book, PenLine, Music, PenTool, Palette,
|
|
Leaf, Star, Globe, Sparkles, Hash, Layers,
|
|
};
|
|
|
|
// ─── Template Definitions ────────────────────────────────────────────
|
|
|
|
interface NotebookTemplate {
|
|
key: string;
|
|
titleKey: string;
|
|
descKey: string;
|
|
notebookType: string;
|
|
color: string;
|
|
icon: string;
|
|
iconComponent: React.ElementType;
|
|
pages: Array<{ title: string | null; content: string }>;
|
|
}
|
|
|
|
const NOTEBOOK_TEMPLATES: NotebookTemplate[] = [
|
|
{
|
|
key: 'math',
|
|
titleKey: 'notebooks.template_math',
|
|
descKey: 'notebooks.template_math_desc',
|
|
notebookType: 'grid',
|
|
color: '#3b82f6',
|
|
icon: 'Hash',
|
|
iconComponent: Calculator,
|
|
pages: [
|
|
{ title: 'Aufgaben', content: '# Aufgaben\n\n1. \n2. \n3. \n' },
|
|
{ title: 'Rechnungen', content: '# Rechnungen\n\n' },
|
|
{ title: 'Formeln', content: '# Formeln\n\n' },
|
|
{ title: 'Geometrie', content: '# Geometrie\n\n' },
|
|
{ title: 'Ergebnisse', content: '# Ergebnisse\n\n' },
|
|
],
|
|
},
|
|
{
|
|
key: 'german',
|
|
titleKey: 'notebooks.template_german',
|
|
descKey: 'notebooks.template_german_desc',
|
|
notebookType: 'lined',
|
|
color: '#ef4444',
|
|
icon: 'BookOpen',
|
|
iconComponent: BookOpen,
|
|
pages: [
|
|
{ title: 'Aufsaetze', content: '# Aufsaetze\n\n' },
|
|
{ title: 'Lesetagebuch', content: '# Lesetagebuch\n\n' },
|
|
{ title: 'Grammatik', content: '# Grammatik\n\n' },
|
|
{ title: 'Rechtschreibung', content: '# Rechtschreibung\n\n' },
|
|
{ title: 'Kreatives Schreiben', content: '# Kreatives Schreiben\n\n' },
|
|
],
|
|
},
|
|
{
|
|
key: 'english',
|
|
titleKey: 'notebooks.template_english',
|
|
descKey: 'notebooks.template_english_desc',
|
|
notebookType: 'lined',
|
|
color: '#f59e0b',
|
|
icon: 'Globe',
|
|
iconComponent: Languages,
|
|
pages: [
|
|
{ title: 'Vocabulary', content: '# Vocabulary\n\n| English | Deutsch |\n|---------|--------|\n| | |\n| | |\n' },
|
|
{ title: 'Grammar', content: '# Grammar\n\n' },
|
|
{ title: 'Reading', content: '# Reading\n\n' },
|
|
{ title: 'Writing', content: '# Writing\n\n' },
|
|
{ title: 'Exercises', content: '# Exercises\n\n' },
|
|
],
|
|
},
|
|
{
|
|
key: 'art',
|
|
titleKey: 'notebooks.template_art',
|
|
descKey: 'notebooks.template_art_desc',
|
|
notebookType: 'blank',
|
|
color: '#8b5cf6',
|
|
icon: 'Palette',
|
|
iconComponent: Paintbrush,
|
|
pages: [
|
|
{ title: 'Skizzen', content: '' },
|
|
{ title: 'Farbstudien', content: '' },
|
|
{ title: 'Komposition', content: '' },
|
|
{ title: 'Perspektive', content: '' },
|
|
{ title: 'Portfolio', content: '' },
|
|
],
|
|
},
|
|
{
|
|
key: 'music',
|
|
titleKey: 'notebooks.template_music',
|
|
descKey: 'notebooks.template_music_desc',
|
|
notebookType: 'music',
|
|
color: '#10b981',
|
|
icon: 'Music',
|
|
iconComponent: Music,
|
|
pages: [
|
|
{ title: 'Noten', content: '' },
|
|
{ title: 'Rhythmus', content: '' },
|
|
{ title: 'Melodie', content: '' },
|
|
{ title: 'Harmonie', content: '' },
|
|
{ title: 'Komposition', content: '' },
|
|
],
|
|
},
|
|
{
|
|
key: 'science',
|
|
titleKey: 'notebooks.template_science',
|
|
descKey: 'notebooks.template_science_desc',
|
|
notebookType: 'grid',
|
|
color: '#14b8a6',
|
|
icon: 'Book',
|
|
iconComponent: FlaskConical,
|
|
pages: [
|
|
{ title: 'Versuche', content: '# Versuche\n\n**Fragestellung:**\n\n**Vermutung:**\n\n**Durchfuehrung:**\n\n**Beobachtung:**\n\n**Ergebnis:**\n' },
|
|
{ title: 'Beobachtungen', content: '# Beobachtungen\n\n' },
|
|
{ title: 'Ergebnisse', content: '# Ergebnisse\n\n' },
|
|
{ title: 'Versuchsprotokoll', content: '# Versuchsprotokoll\n\nDatum:\n\nMaterial:\n\n' },
|
|
{ title: 'Fragen', content: '# Fragen\n\n' },
|
|
],
|
|
},
|
|
];
|
|
|
|
// ─── CSS Background Patterns ─────────────────────────────────────────
|
|
|
|
function getPageBackgroundCSS(type: string): React.CSSProperties {
|
|
switch (type) {
|
|
case 'lined':
|
|
return {
|
|
backgroundImage: `repeating-linear-gradient(0deg, transparent, transparent 31px, #d1d5db 31px, #d1d5db 32px)`,
|
|
backgroundSize: '100% 32px',
|
|
backgroundPosition: '0 16px',
|
|
};
|
|
case 'grid':
|
|
return {
|
|
backgroundImage: `
|
|
repeating-linear-gradient(0deg, transparent, transparent 31px, #d1d5db 31px, #d1d5db 32px),
|
|
repeating-linear-gradient(90deg, transparent, transparent 31px, #d1d5db 31px, #d1d5db 32px)
|
|
`,
|
|
backgroundSize: '32px 32px',
|
|
};
|
|
case 'dotted':
|
|
return {
|
|
backgroundImage: `radial-gradient(circle, #9ca3af 1px, transparent 1px)`,
|
|
backgroundSize: '24px 24px',
|
|
};
|
|
case 'music':
|
|
return {
|
|
backgroundImage: `
|
|
repeating-linear-gradient(0deg, transparent, transparent 7px, #d1d5db 7px, #d1d5db 8px),
|
|
repeating-linear-gradient(0deg, transparent, transparent 15px, #9ca3af 15px, #9ca3af 16px),
|
|
repeating-linear-gradient(0deg, transparent, transparent 23px, #9ca3af 23px, #9ca3af 24px),
|
|
repeating-linear-gradient(0deg, transparent, transparent 31px, #9ca3af 31px, #9ca3af 32px),
|
|
repeating-linear-gradient(0deg, transparent, transparent 39px, #d1d5db 39px, #d1d5db 40px)
|
|
`,
|
|
backgroundSize: '100% 40px',
|
|
};
|
|
case 'calligraphy':
|
|
return {
|
|
backgroundImage: `
|
|
repeating-linear-gradient(90deg, transparent, transparent 59px, #e5e7eb 59px, #e5e7eb 60px),
|
|
repeating-linear-gradient(0deg, transparent, transparent 31px, #d1d5db 31px, #d1d5db 32px)
|
|
`,
|
|
backgroundSize: '60px 32px',
|
|
backgroundPosition: '0 16px',
|
|
};
|
|
case 'blank':
|
|
return {};
|
|
default:
|
|
return {};
|
|
}
|
|
}
|
|
|
|
// ─── WYSIWYG Rich Text Toolbar ───────────────────────────────────────
|
|
|
|
const TEXT_COLORS = [
|
|
{ key: 'black', color: '#1f2937', labelKey: 'notebooks.toolbar_color_black' },
|
|
{ key: 'red', color: '#dc2626', labelKey: 'notebooks.toolbar_color_red' },
|
|
{ key: 'blue', color: '#2563eb', labelKey: 'notebooks.toolbar_color_blue' },
|
|
{ key: 'green', color: '#16a34a', labelKey: 'notebooks.toolbar_color_green' },
|
|
{ key: 'orange', color: '#ea580c', labelKey: 'notebooks.toolbar_color_orange' },
|
|
];
|
|
|
|
const HIGHLIGHT_COLORS = [
|
|
{ key: 'yellow', color: '#fef08a', labelKey: 'notebooks.toolbar_highlight_yellow' },
|
|
{ key: 'green', color: '#bbf7d0', labelKey: 'notebooks.toolbar_highlight_green' },
|
|
{ key: 'blue', color: '#bfdbfe', labelKey: 'notebooks.toolbar_highlight_blue' },
|
|
{ key: 'pink', color: '#fbcfe8', labelKey: 'notebooks.toolbar_highlight_pink' },
|
|
{ key: 'none', color: 'transparent', labelKey: 'notebooks.toolbar_highlight_none' },
|
|
];
|
|
|
|
function WysiwygToolbar({ editorRef, onFormatChange }: { editorRef: React.RefObject<HTMLDivElement | null>; onFormatChange: () => void }) {
|
|
const [activeStates, setActiveStates] = useState<Record<string, boolean>>({});
|
|
|
|
const updateActiveStates = useCallback(() => {
|
|
setActiveStates({
|
|
bold: document.queryCommandState('bold'),
|
|
italic: document.queryCommandState('italic'),
|
|
underline: document.queryCommandState('underline'),
|
|
strikethrough: document.queryCommandState('strikeThrough'),
|
|
justifyLeft: document.queryCommandState('justifyLeft'),
|
|
justifyCenter: document.queryCommandState('justifyCenter'),
|
|
justifyRight: document.queryCommandState('justifyRight'),
|
|
});
|
|
}, []);
|
|
|
|
const execCommand = useCallback((command: string, value?: string) => {
|
|
document.execCommand(command, false, value);
|
|
editorRef.current?.focus();
|
|
updateActiveStates();
|
|
onFormatChange();
|
|
}, [editorRef, updateActiveStates, onFormatChange]);
|
|
|
|
const applyHeading = useCallback((level: number) => {
|
|
document.execCommand('formatBlock', false, `h${level}`);
|
|
editorRef.current?.focus();
|
|
updateActiveStates();
|
|
onFormatChange();
|
|
}, [editorRef, updateActiveStates, onFormatChange]);
|
|
|
|
const applyTextColor = useCallback((color: string) => {
|
|
document.execCommand('foreColor', false, color);
|
|
editorRef.current?.focus();
|
|
updateActiveStates();
|
|
onFormatChange();
|
|
}, [editorRef, updateActiveStates, onFormatChange]);
|
|
|
|
const applyHighlight = useCallback((color: string) => {
|
|
if (color === 'transparent') {
|
|
document.execCommand('removeFormat', false);
|
|
} else {
|
|
document.execCommand('hiliteColor', false, color);
|
|
}
|
|
editorRef.current?.focus();
|
|
updateActiveStates();
|
|
onFormatChange();
|
|
}, [editorRef, updateActiveStates, onFormatChange]);
|
|
|
|
const fmtBtnClass = (active: boolean) =>
|
|
`min-h-[36px] min-w-[36px] h-9 w-9 p-0 shrink-0 ${active ? 'bg-gray-200 dark:bg-gray-600 text-gray-900 dark:text-gray-100' : ''}`;
|
|
|
|
return (
|
|
<TooltipProvider delayDuration={300}>
|
|
<div className="flex items-center gap-1 px-3 py-2 border-b border-gray-200 dark:border-gray-700 bg-gray-50 dark:bg-gray-800/50 overflow-x-auto">
|
|
{/* Format group */}
|
|
<Tooltip>
|
|
<TooltipTrigger asChild>
|
|
<Button variant="ghost" size="sm" onClick={() => execCommand('bold')} className={fmtBtnClass(activeStates.bold)}>
|
|
<Bold className="w-4 h-4" />
|
|
</Button>
|
|
</TooltipTrigger>
|
|
<TooltipContent side="bottom" className="text-xs">{t('notebooks.toolbar_bold')}</TooltipContent>
|
|
</Tooltip>
|
|
<Tooltip>
|
|
<TooltipTrigger asChild>
|
|
<Button variant="ghost" size="sm" onClick={() => execCommand('italic')} className={fmtBtnClass(activeStates.italic)}>
|
|
<ItalicIcon className="w-4 h-4" />
|
|
</Button>
|
|
</TooltipTrigger>
|
|
<TooltipContent side="bottom" className="text-xs">{t('notebooks.toolbar_italic')}</TooltipContent>
|
|
</Tooltip>
|
|
<Tooltip>
|
|
<TooltipTrigger asChild>
|
|
<Button variant="ghost" size="sm" onClick={() => execCommand('underline')} className={fmtBtnClass(activeStates.underline)}>
|
|
<UnderlineIcon className="w-4 h-4" />
|
|
</Button>
|
|
</TooltipTrigger>
|
|
<TooltipContent side="bottom" className="text-xs">{t('notebooks.toolbar_underline')}</TooltipContent>
|
|
</Tooltip>
|
|
<Tooltip>
|
|
<TooltipTrigger asChild>
|
|
<Button variant="ghost" size="sm" onClick={() => execCommand('strikeThrough')} className={fmtBtnClass(activeStates.strikethrough)}>
|
|
<Strikethrough className="w-4 h-4" />
|
|
</Button>
|
|
</TooltipTrigger>
|
|
<TooltipContent side="bottom" className="text-xs">{t('notebooks.toolbar_strikethrough')}</TooltipContent>
|
|
</Tooltip>
|
|
|
|
<Separator orientation="vertical" className="h-5 mx-1" />
|
|
|
|
{/* Heading group */}
|
|
<Tooltip>
|
|
<TooltipTrigger asChild>
|
|
<Button variant="ghost" size="sm" onClick={() => applyHeading(1)} className="min-h-[36px] min-w-[36px] h-9 w-9 p-0 shrink-0">
|
|
<Heading1 className="w-4 h-4" />
|
|
</Button>
|
|
</TooltipTrigger>
|
|
<TooltipContent side="bottom" className="text-xs">{t('notebooks.toolbar_heading1')}</TooltipContent>
|
|
</Tooltip>
|
|
<Tooltip>
|
|
<TooltipTrigger asChild>
|
|
<Button variant="ghost" size="sm" onClick={() => applyHeading(2)} className="min-h-[36px] min-w-[36px] h-9 w-9 p-0 shrink-0">
|
|
<Heading2 className="w-4 h-4" />
|
|
</Button>
|
|
</TooltipTrigger>
|
|
<TooltipContent side="bottom" className="text-xs">{t('notebooks.toolbar_heading2')}</TooltipContent>
|
|
</Tooltip>
|
|
<Tooltip>
|
|
<TooltipTrigger asChild>
|
|
<Button variant="ghost" size="sm" onClick={() => applyHeading(3)} className="min-h-[36px] min-w-[36px] h-9 w-9 p-0 shrink-0">
|
|
<Heading3 className="w-4 h-4" />
|
|
</Button>
|
|
</TooltipTrigger>
|
|
<TooltipContent side="bottom" className="text-xs">{t('notebooks.toolbar_heading3')}</TooltipContent>
|
|
</Tooltip>
|
|
|
|
<Separator orientation="vertical" className="h-5 mx-1" />
|
|
|
|
{/* List group */}
|
|
<Tooltip>
|
|
<TooltipTrigger asChild>
|
|
<Button variant="ghost" size="sm" onClick={() => execCommand('insertUnorderedList')} className="min-h-[36px] min-w-[36px] h-9 w-9 p-0 shrink-0">
|
|
<List className="w-4 h-4" />
|
|
</Button>
|
|
</TooltipTrigger>
|
|
<TooltipContent side="bottom" className="text-xs">{t('notebooks.toolbar_bullet_list')}</TooltipContent>
|
|
</Tooltip>
|
|
<Tooltip>
|
|
<TooltipTrigger asChild>
|
|
<Button variant="ghost" size="sm" onClick={() => execCommand('insertOrderedList')} className="min-h-[36px] min-w-[36px] h-9 w-9 p-0 shrink-0">
|
|
<ListOrdered className="w-4 h-4" />
|
|
</Button>
|
|
</TooltipTrigger>
|
|
<TooltipContent side="bottom" className="text-xs">{t('notebooks.toolbar_numbered_list')}</TooltipContent>
|
|
</Tooltip>
|
|
|
|
<Separator orientation="vertical" className="h-5 mx-1" />
|
|
|
|
{/* Alignment group */}
|
|
<Tooltip>
|
|
<TooltipTrigger asChild>
|
|
<Button variant="ghost" size="sm" onClick={() => execCommand('justifyLeft')} className={fmtBtnClass(activeStates.justifyLeft)}>
|
|
<AlignLeftIcon className="w-4 h-4" />
|
|
</Button>
|
|
</TooltipTrigger>
|
|
<TooltipContent side="bottom" className="text-xs">{t('notebooks.toolbar_align_left')}</TooltipContent>
|
|
</Tooltip>
|
|
<Tooltip>
|
|
<TooltipTrigger asChild>
|
|
<Button variant="ghost" size="sm" onClick={() => execCommand('justifyCenter')} className={fmtBtnClass(activeStates.justifyCenter)}>
|
|
<AlignCenterIcon className="w-4 h-4" />
|
|
</Button>
|
|
</TooltipTrigger>
|
|
<TooltipContent side="bottom" className="text-xs">{t('notebooks.toolbar_align_center')}</TooltipContent>
|
|
</Tooltip>
|
|
<Tooltip>
|
|
<TooltipTrigger asChild>
|
|
<Button variant="ghost" size="sm" onClick={() => execCommand('justifyRight')} className={fmtBtnClass(activeStates.justifyRight)}>
|
|
<AlignRightIcon className="w-4 h-4" />
|
|
</Button>
|
|
</TooltipTrigger>
|
|
<TooltipContent side="bottom" className="text-xs">{t('notebooks.toolbar_align_right')}</TooltipContent>
|
|
</Tooltip>
|
|
|
|
<Separator orientation="vertical" className="h-5 mx-1" />
|
|
|
|
{/* Text color */}
|
|
<Popover>
|
|
<Tooltip>
|
|
<TooltipTrigger asChild>
|
|
<PopoverTrigger asChild>
|
|
<Button variant="ghost" size="sm" className="min-h-[36px] min-w-[36px] h-9 w-9 p-0 shrink-0">
|
|
<Type className="w-4 h-4" />
|
|
</Button>
|
|
</PopoverTrigger>
|
|
</TooltipTrigger>
|
|
<TooltipContent side="bottom" className="text-xs">{t('notebooks.toolbar_text_color')}</TooltipContent>
|
|
</Tooltip>
|
|
<PopoverContent className="w-auto p-2" align="start">
|
|
<div className="flex items-center gap-1.5">
|
|
{TEXT_COLORS.map((tc) => (
|
|
<button
|
|
key={tc.key}
|
|
onClick={() => applyTextColor(tc.color)}
|
|
className="w-7 h-7 rounded-full border-2 border-gray-200 dark:border-gray-600 hover:scale-110 transition-transform"
|
|
style={{ backgroundColor: tc.color }}
|
|
title={t(tc.labelKey)}
|
|
/>
|
|
))}
|
|
</div>
|
|
</PopoverContent>
|
|
</Popover>
|
|
|
|
{/* Highlight color */}
|
|
<Popover>
|
|
<Tooltip>
|
|
<TooltipTrigger asChild>
|
|
<PopoverTrigger asChild>
|
|
<Button variant="ghost" size="sm" className="min-h-[36px] min-w-[36px] h-9 w-9 p-0 shrink-0">
|
|
<Highlighter className="w-4 h-4" />
|
|
</Button>
|
|
</PopoverTrigger>
|
|
</TooltipTrigger>
|
|
<TooltipContent side="bottom" className="text-xs">{t('notebooks.toolbar_highlight')}</TooltipContent>
|
|
</Tooltip>
|
|
<PopoverContent className="w-auto p-2" align="start">
|
|
<div className="flex items-center gap-1.5">
|
|
{HIGHLIGHT_COLORS.map((hc) => (
|
|
<button
|
|
key={hc.key}
|
|
onClick={() => applyHighlight(hc.color)}
|
|
className={`w-7 h-7 rounded-full border-2 hover:scale-110 transition-transform ${
|
|
hc.key === 'none'
|
|
? 'border-gray-300 dark:border-gray-500 bg-white dark:bg-gray-800 flex items-center justify-center'
|
|
: 'border-gray-200 dark:border-gray-600'
|
|
}`}
|
|
style={hc.key !== 'none' ? { backgroundColor: hc.color } : {}}
|
|
title={t(hc.labelKey)}
|
|
>
|
|
{hc.key === 'none' && <X className="w-3.5 h-3.5 text-gray-400" />}
|
|
</button>
|
|
))}
|
|
</div>
|
|
</PopoverContent>
|
|
</Popover>
|
|
</div>
|
|
</TooltipProvider>
|
|
);
|
|
}
|
|
|
|
// ─── Notebook Card Component ─────────────────────────────────────────
|
|
|
|
function NotebookCard({
|
|
notebook,
|
|
subjectName,
|
|
onOpen,
|
|
onArchive,
|
|
onDelete,
|
|
onShare,
|
|
onDuplicate,
|
|
isShared = false,
|
|
}: {
|
|
notebook: Notebook;
|
|
subjectName: string | null;
|
|
onOpen: () => void;
|
|
onArchive: () => void;
|
|
onDelete: () => void;
|
|
onShare?: () => void;
|
|
onDuplicate?: () => void;
|
|
isShared?: boolean;
|
|
}) {
|
|
const pageCount = notebook._count?.pages ?? notebook.pages?.length ?? 0;
|
|
const IconComponent = notebook.icon ? ICON_MAP[notebook.icon] ?? BookOpen : BookOpen;
|
|
const typeInfo = NOTEBOOK_TYPES.find(nt => nt.key === notebook.notebookType) ?? NOTEBOOK_TYPES[0];
|
|
const TypeIcon = typeInfo.icon;
|
|
const ownerName = notebook.owner
|
|
? `${notebook.owner.firstName} ${notebook.owner.lastName}`
|
|
: null;
|
|
|
|
return (
|
|
<motion.div
|
|
whileHover={{ rotateY: -3, scale: 1.02 }}
|
|
transition={{ type: 'spring', stiffness: 300 }}
|
|
style={{ perspective: 800 }}
|
|
>
|
|
<Card
|
|
className="relative overflow-hidden cursor-pointer group transition-shadow duration-300 hover:shadow-lg border-0"
|
|
style={{ boxShadow: `4px 4px 12px rgba(0,0,0,0.15), 1px 1px 3px rgba(0,0,0,0.1)` }}
|
|
onClick={onOpen}
|
|
>
|
|
{/* Cover section */}
|
|
<div
|
|
className="relative h-28 flex items-center justify-center overflow-hidden"
|
|
style={{
|
|
background: `linear-gradient(135deg, ${notebook.color}, ${notebook.color}cc)`,
|
|
}}
|
|
>
|
|
{/* Book spine effect */}
|
|
<div
|
|
className="absolute left-0 top-0 bottom-0 w-3 opacity-80"
|
|
style={{ background: `linear-gradient(90deg, ${notebook.color}99, ${notebook.color}66)` }}
|
|
/>
|
|
<IconComponent className="w-12 h-12 text-white/90 drop-shadow-md" />
|
|
{/* Public badge */}
|
|
{notebook.isPublic && !isShared && (
|
|
<Badge className="absolute top-2 right-2 bg-white/90 text-gray-700 text-xs border-0 shadow-sm">
|
|
<Globe className="w-3 h-3 mr-1" />
|
|
{t('notebooks.shared')}
|
|
</Badge>
|
|
)}
|
|
{/* Shared badge */}
|
|
{isShared && (
|
|
<Badge className="absolute top-2 right-2 bg-amber-50 text-amber-700 text-xs border-0 shadow-sm">
|
|
<Share2 className="w-3 h-3 mr-1" />
|
|
{t('notebooks.shared_notebook')}
|
|
</Badge>
|
|
)}
|
|
{/* Archive overlay */}
|
|
{notebook.isArchived && (
|
|
<div className="absolute inset-0 bg-gray-500/40 flex items-center justify-center">
|
|
<Badge className="bg-gray-600 text-white border-0">
|
|
<Archive className="w-3 h-3 mr-1" />
|
|
{t('notebooks.archived')}
|
|
</Badge>
|
|
</div>
|
|
)}
|
|
</div>
|
|
|
|
<CardContent className="p-4 space-y-2">
|
|
<div className="font-semibold text-base truncate text-gray-900 dark:text-gray-100">
|
|
{notebook.title}
|
|
</div>
|
|
|
|
<div className="flex items-center gap-2 flex-wrap">
|
|
{/* Type badge */}
|
|
<Badge variant="outline" className="text-xs gap-1">
|
|
<TypeIcon className="w-3 h-3" />
|
|
{t(typeInfo.labelKey)}
|
|
</Badge>
|
|
{/* Subject badge */}
|
|
{subjectName && (
|
|
<Badge variant="secondary" className="text-xs">
|
|
{subjectName}
|
|
</Badge>
|
|
)}
|
|
</div>
|
|
|
|
{/* Owner name for shared notebooks */}
|
|
{isShared && ownerName && (
|
|
<div className="flex items-center gap-1.5 text-xs text-gray-500 dark:text-gray-400">
|
|
<UserIcon className="w-3 h-3" />
|
|
<span>{ownerName}</span>
|
|
</div>
|
|
)}
|
|
|
|
{/* Page count */}
|
|
<div className="flex items-center justify-between text-sm text-gray-500 dark:text-gray-400">
|
|
<span className="flex items-center gap-1">
|
|
<BookMarked className="w-3.5 h-3.5" />
|
|
{t('notebooks.page_count', { count: pageCount })}
|
|
</span>
|
|
</div>
|
|
|
|
{/* Eco message */}
|
|
<div className="flex items-center gap-1.5 text-xs text-emerald-600 dark:text-emerald-400 pt-1">
|
|
<Leaf className="w-3.5 h-3.5" />
|
|
<span>{t('notebooks.eco_tip')}</span>
|
|
</div>
|
|
|
|
{/* Action buttons — only show for own notebooks, not shared */}
|
|
{!isShared && (
|
|
<div className="flex items-center gap-1 pt-2 opacity-0 group-hover:opacity-100 transition-opacity duration-200 flex-wrap">
|
|
{onShare && (
|
|
<Button
|
|
variant="ghost"
|
|
size="sm"
|
|
className="h-8 px-2 text-xs"
|
|
onClick={(e) => { e.stopPropagation(); onShare(); }}
|
|
>
|
|
{notebook.isPublic ? <EyeOff className="w-3.5 h-3.5 mr-1" /> : <Share2 className="w-3.5 h-3.5 mr-1" />}
|
|
{notebook.isPublic ? t('notebooks.unshare_confirm') : t('notebooks.share')}
|
|
</Button>
|
|
)}
|
|
{onDuplicate && (
|
|
<Button
|
|
variant="ghost"
|
|
size="sm"
|
|
className="h-8 px-2 text-xs"
|
|
onClick={(e) => { e.stopPropagation(); onDuplicate(); }}
|
|
>
|
|
<Copy className="w-3.5 h-3.5 mr-1" />
|
|
{t('notebooks.duplicate')}
|
|
</Button>
|
|
)}
|
|
<Button
|
|
variant="ghost"
|
|
size="sm"
|
|
className="h-8 px-2 text-xs"
|
|
onClick={(e) => { e.stopPropagation(); onArchive(); }}
|
|
>
|
|
<Archive className="w-3.5 h-3.5 mr-1" />
|
|
{notebook.isArchived ? t('notebooks.unarchive') : t('notebooks.archive')}
|
|
</Button>
|
|
<Button
|
|
variant="ghost"
|
|
size="sm"
|
|
className="h-8 px-2 text-xs text-red-500 hover:text-red-600"
|
|
onClick={(e) => { e.stopPropagation(); onDelete(); }}
|
|
>
|
|
<Trash2 className="w-3.5 h-3.5 mr-1" />
|
|
{t('notebooks.delete')}
|
|
</Button>
|
|
</div>
|
|
)}
|
|
</CardContent>
|
|
</Card>
|
|
</motion.div>
|
|
);
|
|
}
|
|
|
|
// ─── Create Notebook Dialog ──────────────────────────────────────────
|
|
|
|
function CreateNotebookDialog({
|
|
open,
|
|
onClose,
|
|
subjects,
|
|
classes,
|
|
onCreate,
|
|
}: {
|
|
open: boolean;
|
|
onClose: () => void;
|
|
subjects: Subject[];
|
|
classes: ClassGroup[];
|
|
onCreate: (data: Partial<Notebook>) => Promise<void>;
|
|
}) {
|
|
const [title, setTitle] = useState('');
|
|
const [description, setDescription] = useState('');
|
|
const [notebookType, setNotebookType] = useState('lined');
|
|
const [subjectId, setSubjectId] = useState('');
|
|
const [classGroupId, setClassGroupId] = useState('');
|
|
const [color, setColor] = useState('#10b981');
|
|
const [icon, setIcon] = useState('BookOpen');
|
|
const [isPublic, setIsPublic] = useState(false);
|
|
const [creating, setCreating] = useState(false);
|
|
|
|
const handleCreate = async () => {
|
|
if (!title.trim()) {
|
|
toast.error(t('notebooks.title_required'));
|
|
return;
|
|
}
|
|
setCreating(true);
|
|
try {
|
|
await onCreate({
|
|
title: title.trim(),
|
|
description: description.trim() || null,
|
|
notebookType,
|
|
subjectId: subjectId || null,
|
|
classGroupId: classGroupId || null,
|
|
color,
|
|
icon,
|
|
isPublic,
|
|
});
|
|
setTitle('');
|
|
setDescription('');
|
|
setNotebookType('lined');
|
|
setSubjectId('');
|
|
setClassGroupId('');
|
|
setColor('#10b981');
|
|
setIcon('BookOpen');
|
|
setIsPublic(false);
|
|
onClose();
|
|
} catch {
|
|
toast.error(t('notebooks.error_create'));
|
|
} finally {
|
|
setCreating(false);
|
|
}
|
|
};
|
|
|
|
return (
|
|
<Dialog open={open} onOpenChange={(v) => { if (!v) onClose(); }}>
|
|
<DialogContent className="max-w-lg">
|
|
<DialogHeader>
|
|
<DialogTitle className="flex items-center gap-2">
|
|
<BookOpen className="w-5 h-5 text-emerald-500" />
|
|
{t('notebooks.create_title')}
|
|
</DialogTitle>
|
|
<DialogDescription>{t('notebooks.create_desc')}</DialogDescription>
|
|
</DialogHeader>
|
|
|
|
<div className="space-y-5 py-2">
|
|
{/* Title */}
|
|
<div className="space-y-2">
|
|
<Label>{t('notebooks.field_title')}</Label>
|
|
<Input
|
|
value={title}
|
|
onChange={(e) => setTitle(e.target.value)}
|
|
placeholder={t('notebooks.field_title_placeholder')}
|
|
className="min-h-[44px]"
|
|
/>
|
|
</div>
|
|
|
|
{/* Description */}
|
|
<div className="space-y-2">
|
|
<Label>{t('notebooks.field_description')}</Label>
|
|
<Textarea
|
|
value={description}
|
|
onChange={(e) => setDescription(e.target.value)}
|
|
placeholder={t('notebooks.field_description_placeholder')}
|
|
rows={3}
|
|
/>
|
|
</div>
|
|
|
|
{/* Notebook Type - Visual Selector */}
|
|
<div className="space-y-2">
|
|
<Label>{t('notebooks.field_type')}</Label>
|
|
<div className="grid grid-cols-3 gap-2">
|
|
{NOTEBOOK_TYPES.map((nt) => {
|
|
const NtIcon = nt.icon;
|
|
const isSelected = notebookType === nt.key;
|
|
return (
|
|
<button
|
|
key={nt.key}
|
|
onClick={() => setNotebookType(nt.key)}
|
|
className={`flex flex-col items-center gap-1 p-3 rounded-xl border-2 transition-all min-h-[64px] ${
|
|
isSelected
|
|
? 'border-emerald-500 bg-emerald-50 dark:bg-emerald-900/20 text-emerald-700 dark:text-emerald-300'
|
|
: 'border-gray-200 dark:border-gray-700 bg-white dark:bg-gray-800 text-gray-600 dark:text-gray-400 hover:border-gray-300'
|
|
}`}
|
|
>
|
|
<NtIcon className="w-5 h-5" />
|
|
<span className="text-xs font-medium">{t(nt.labelKey)}</span>
|
|
</button>
|
|
);
|
|
})}
|
|
</div>
|
|
</div>
|
|
|
|
{/* Subject + Class */}
|
|
<div className="grid grid-cols-2 gap-4">
|
|
<div className="space-y-2">
|
|
<Label>{t('notebooks.field_subject')}</Label>
|
|
<Select value={subjectId} onValueChange={setSubjectId}>
|
|
<SelectTrigger className="min-h-[44px]">
|
|
<SelectValue placeholder={t('notebooks.no_subject')} />
|
|
</SelectTrigger>
|
|
<SelectContent>
|
|
<SelectItem value="none">{t('notebooks.no_subject')}</SelectItem>
|
|
{subjects.map((s) => (
|
|
<SelectItem key={s.id} value={s.id}>{s.name}</SelectItem>
|
|
))}
|
|
</SelectContent>
|
|
</Select>
|
|
</div>
|
|
<div className="space-y-2">
|
|
<Label>{t('notebooks.field_class')}</Label>
|
|
<Select value={classGroupId} onValueChange={setClassGroupId}>
|
|
<SelectTrigger className="min-h-[44px]">
|
|
<SelectValue placeholder={t('notebooks.no_class')} />
|
|
</SelectTrigger>
|
|
<SelectContent>
|
|
<SelectItem value="none">{t('notebooks.no_class')}</SelectItem>
|
|
{classes.map((c) => (
|
|
<SelectItem key={c.id} value={c.id}>{c.name}</SelectItem>
|
|
))}
|
|
</SelectContent>
|
|
</Select>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Cover Color */}
|
|
<div className="space-y-2">
|
|
<Label>{t('notebooks.field_color')}</Label>
|
|
<div className="flex items-center gap-2 flex-wrap">
|
|
{COVER_COLORS.map((cc) => (
|
|
<button
|
|
key={cc.key}
|
|
onClick={() => setColor(cc.hex)}
|
|
className={`w-8 h-8 rounded-full transition-all border-2 ${
|
|
color === cc.hex ? 'border-gray-900 dark:border-white scale-110 shadow-md' : 'border-transparent hover:scale-105'
|
|
}`}
|
|
style={{ backgroundColor: cc.hex }}
|
|
title={t(cc.labelKey)}
|
|
/>
|
|
))}
|
|
</div>
|
|
</div>
|
|
|
|
{/* Icon Selector */}
|
|
<div className="space-y-2">
|
|
<Label>{t('notebooks.field_icon')}</Label>
|
|
<div className="flex items-center gap-2 flex-wrap">
|
|
{ICON_OPTIONS.map((iconName) => {
|
|
const IconComp = ICON_MAP[iconName];
|
|
const isSelected = icon === iconName;
|
|
return (
|
|
<button
|
|
key={iconName}
|
|
onClick={() => setIcon(iconName)}
|
|
className={`w-10 h-10 flex items-center justify-center rounded-lg border-2 transition-all ${
|
|
isSelected
|
|
? 'border-emerald-500 bg-emerald-50 dark:bg-emerald-900/20'
|
|
: 'border-gray-200 dark:border-gray-700 hover:border-gray-300'
|
|
}`}
|
|
>
|
|
<IconComp className={`w-4 h-4 ${isSelected ? 'text-emerald-600 dark:text-emerald-300' : 'text-gray-500 dark:text-gray-400'}`} />
|
|
</button>
|
|
);
|
|
})}
|
|
</div>
|
|
</div>
|
|
|
|
{/* Public toggle */}
|
|
<div className="flex items-center gap-3">
|
|
<Switch checked={isPublic} onCheckedChange={setIsPublic} />
|
|
<div>
|
|
<Label className="flex items-center gap-1">
|
|
<Share2 className="w-4 h-4" />
|
|
{t('notebooks.field_public')}
|
|
</Label>
|
|
<p className="text-xs text-gray-500 dark:text-gray-400">{t('notebooks.field_public_hint')}</p>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Eco message */}
|
|
<div className="flex items-center gap-2 px-4 py-2 bg-emerald-50 dark:bg-emerald-900/20 rounded-lg text-emerald-700 dark:text-emerald-300 text-sm">
|
|
<Leaf className="w-4 h-4" />
|
|
<span>{t('notebooks.eco_message')}</span>
|
|
</div>
|
|
|
|
<DialogFooter>
|
|
<Button variant="outline" onClick={onClose} className="min-h-[44px]">
|
|
{t('action.cancel')}
|
|
</Button>
|
|
<Button onClick={handleCreate} disabled={creating || !title.trim()} className="min-h-[44px] bg-emerald-600 hover:bg-emerald-700">
|
|
{creating ? (
|
|
<div className="w-4 h-4 animate-spin rounded-full border-2 border-white border-t-transparent mr-2" />
|
|
) : (
|
|
<Plus className="w-4 h-4 mr-2" />
|
|
)}
|
|
{t('action.create')}
|
|
</Button>
|
|
</DialogFooter>
|
|
</DialogContent>
|
|
</Dialog>
|
|
);
|
|
}
|
|
|
|
// ─── Page Background Pattern Preview ─────────────────────────────────
|
|
|
|
function PageBackgroundPreview({ type }: { type: string }) {
|
|
return (
|
|
<div
|
|
className="w-full h-16 rounded-lg border border-gray-200 dark:border-gray-700"
|
|
style={{ ...getPageBackgroundCSS(type), backgroundColor: '#fff' }}
|
|
/>
|
|
);
|
|
}
|
|
|
|
// ─── Page Thumbnail Component ────────────────────────────────────────
|
|
|
|
function PageThumbnail({ page, notebookType }: { page: NotebookPage; notebookType: string }) {
|
|
const bgType = page.background || notebookType;
|
|
const previewText = page.textContent?.substring(0, 80) ?? '';
|
|
const hasTitle = !!page.title;
|
|
|
|
return (
|
|
<div
|
|
className="w-full h-24 rounded-md overflow-hidden border border-gray-200 dark:border-gray-600 relative"
|
|
style={{ ...getPageBackgroundCSS(bgType), backgroundColor: '#fff' }}
|
|
>
|
|
<div className="p-1.5 text-xs text-gray-400 dark:text-gray-500 truncate leading-tight">
|
|
{hasTitle ? (
|
|
<span className="font-semibold text-gray-600 dark:text-gray-400">{page.title}</span>
|
|
) : previewText ? (
|
|
previewText
|
|
) : (
|
|
<span className="text-gray-300 dark:text-gray-600 italic">Leere Seite</span>
|
|
)}
|
|
</div>
|
|
{/* Bookmark indicator */}
|
|
{page.isBookmark && (
|
|
<div className="absolute top-0 right-0">
|
|
<Bookmark className="w-3 h-3 text-amber-500 fill-amber-500" />
|
|
</div>
|
|
)}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
// ─── Notebook Detail View ────────────────────────────────────────────
|
|
|
|
function NotebookDetailView({
|
|
notebook,
|
|
subjectName,
|
|
onBack,
|
|
onUpdatePage,
|
|
onAddPage,
|
|
onDeletePage,
|
|
onToggleBookmark,
|
|
onTogglePublic,
|
|
onReorderPages,
|
|
}: {
|
|
notebook: Notebook;
|
|
subjectName: string | null;
|
|
onBack: () => void;
|
|
onUpdatePage: (pageId: string, data: Partial<NotebookPage>) => Promise<void>;
|
|
onAddPage: () => void;
|
|
onDeletePage: (pageId: string) => void;
|
|
onToggleBookmark: (pageId: string) => void;
|
|
onTogglePublic: () => void;
|
|
onReorderPages: (pageOrders: Array<{ id: string; pageNumber: number }>) => Promise<void>;
|
|
}) {
|
|
const [currentPageId, setCurrentPageId] = useState<string | null>(null);
|
|
const [pageContent, setPageContent] = useState('');
|
|
const [pageTitle, setPageTitle] = useState('');
|
|
const [drawingMode, setDrawingMode] = useState(false);
|
|
const [saving, setSaving] = useState(false);
|
|
const [autoSaveStatus, setAutoSaveStatus] = useState<'idle' | 'saving' | 'saved'>('idle');
|
|
|
|
// Drag-and-drop state
|
|
const [draggedPageId, setDraggedPageId] = useState<string | null>(null);
|
|
const [dragOverPageId, setDragOverPageId] = useState<string | null>(null);
|
|
|
|
const editorRef = useRef<HTMLDivElement>(null);
|
|
const autoSaveTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
|
const lastSavedContentRef = useRef<string>('');
|
|
const lastSavedTitleRef = useRef<string>('');
|
|
const isInternalChange = useRef(false);
|
|
|
|
const pages = notebook.pages ?? [];
|
|
const currentPage = pages.find(p => p.id === currentPageId) ?? pages[0] ?? null;
|
|
|
|
// Set content into editor ref without triggering auto-save
|
|
const setEditorContent = useCallback((html: string) => {
|
|
isInternalChange.current = true;
|
|
if (editorRef.current) {
|
|
editorRef.current.innerHTML = html;
|
|
}
|
|
setPageContent(html);
|
|
// Reset flag after React processes the state update
|
|
setTimeout(() => { isInternalChange.current = false; }, 50);
|
|
}, []);
|
|
|
|
useEffect(() => {
|
|
if (currentPage) {
|
|
setCurrentPageId(currentPage.id);
|
|
setEditorContent(currentPage.textContent ?? '');
|
|
setPageTitle(currentPage.title ?? '');
|
|
lastSavedContentRef.current = currentPage.textContent ?? '';
|
|
lastSavedTitleRef.current = currentPage.title ?? '';
|
|
} else if (pages.length > 0) {
|
|
setCurrentPageId(pages[0].id);
|
|
setEditorContent(pages[0].textContent ?? '');
|
|
setPageTitle(pages[0].title ?? '');
|
|
lastSavedContentRef.current = pages[0].textContent ?? '';
|
|
lastSavedTitleRef.current = pages[0].title ?? '';
|
|
}
|
|
}, [notebook.id, setEditorContent]);
|
|
|
|
useEffect(() => {
|
|
if (currentPage) {
|
|
setEditorContent(currentPage.textContent ?? '');
|
|
setPageTitle(currentPage.title ?? '');
|
|
lastSavedContentRef.current = currentPage.textContent ?? '';
|
|
lastSavedTitleRef.current = currentPage.title ?? '';
|
|
setAutoSaveStatus('idle');
|
|
}
|
|
}, [currentPageId, setEditorContent]);
|
|
|
|
// Handle content change from contentEditable div
|
|
const handleEditorInput = useCallback(() => {
|
|
if (editorRef.current) {
|
|
const html = editorRef.current.innerHTML;
|
|
setPageContent(html);
|
|
}
|
|
}, []);
|
|
|
|
// Handle format change from toolbar (for active state refresh)
|
|
const handleFormatChange = useCallback(() => {
|
|
if (editorRef.current) {
|
|
const html = editorRef.current.innerHTML;
|
|
setPageContent(html);
|
|
}
|
|
}, []);
|
|
|
|
// Auto-save with 3 second debounce
|
|
const triggerAutoSave = useCallback(() => {
|
|
if (autoSaveTimerRef.current) {
|
|
clearTimeout(autoSaveTimerRef.current);
|
|
}
|
|
autoSaveTimerRef.current = setTimeout(async () => {
|
|
if (!currentPage) return;
|
|
const contentChanged = pageContent !== lastSavedContentRef.current;
|
|
const titleChanged = pageTitle !== lastSavedTitleRef.current;
|
|
if (!contentChanged && !titleChanged) return;
|
|
|
|
setAutoSaveStatus('saving');
|
|
try {
|
|
await onUpdatePage(currentPage.id, {
|
|
textContent: pageContent,
|
|
title: pageTitle.trim() || null,
|
|
});
|
|
lastSavedContentRef.current = pageContent;
|
|
lastSavedTitleRef.current = pageTitle;
|
|
setAutoSaveStatus('saved');
|
|
setTimeout(() => setAutoSaveStatus('idle'), 2000);
|
|
} catch {
|
|
setAutoSaveStatus('idle');
|
|
}
|
|
}, 3000);
|
|
}, [currentPage, pageContent, pageTitle, onUpdatePage]);
|
|
|
|
// Trigger auto-save on content or title change
|
|
useEffect(() => {
|
|
if (isInternalChange.current) return;
|
|
triggerAutoSave();
|
|
return () => {
|
|
if (autoSaveTimerRef.current) {
|
|
clearTimeout(autoSaveTimerRef.current);
|
|
}
|
|
};
|
|
}, [pageContent, pageTitle, triggerAutoSave]);
|
|
|
|
const handleSavePage = async () => {
|
|
if (!currentPage) return;
|
|
// Sync content from editor
|
|
if (editorRef.current) {
|
|
setPageContent(editorRef.current.innerHTML);
|
|
}
|
|
// Cancel pending auto-save
|
|
if (autoSaveTimerRef.current) {
|
|
clearTimeout(autoSaveTimerRef.current);
|
|
}
|
|
setSaving(true);
|
|
try {
|
|
const contentToSave = editorRef.current?.innerHTML ?? pageContent;
|
|
await onUpdatePage(currentPage.id, {
|
|
textContent: contentToSave,
|
|
title: pageTitle.trim() || null,
|
|
});
|
|
lastSavedContentRef.current = contentToSave;
|
|
lastSavedTitleRef.current = pageTitle;
|
|
toast.success(t('notebooks.page_saved'));
|
|
setAutoSaveStatus('idle');
|
|
} catch {
|
|
toast.error(t('notebooks.error_save'));
|
|
} finally {
|
|
setSaving(false);
|
|
}
|
|
};
|
|
|
|
// Drag-and-drop handlers for page reorder
|
|
const handleDragStart = useCallback((e: React.DragEvent, pageId: string) => {
|
|
setDraggedPageId(pageId);
|
|
e.dataTransfer.effectAllowed = 'move';
|
|
// Set a transparent drag image
|
|
const ghost = document.createElement('div');
|
|
ghost.style.opacity = '0';
|
|
document.body.appendChild(ghost);
|
|
e.dataTransfer.setDragImage(ghost, 0, 0);
|
|
setTimeout(() => document.body.removeChild(ghost), 0);
|
|
}, []);
|
|
|
|
const handleDragOver = useCallback((e: React.DragEvent, pageId: string) => {
|
|
e.preventDefault();
|
|
e.dataTransfer.dropEffect = 'move';
|
|
if (draggedPageId && pageId !== draggedPageId) {
|
|
setDragOverPageId(pageId);
|
|
}
|
|
}, [draggedPageId]);
|
|
|
|
const handleDragLeave = useCallback(() => {
|
|
setDragOverPageId(null);
|
|
}, []);
|
|
|
|
const handleDrop = useCallback(async (targetPageId: string) => {
|
|
if (!draggedPageId || draggedPageId === targetPageId) {
|
|
setDraggedPageId(null);
|
|
setDragOverPageId(null);
|
|
return;
|
|
}
|
|
|
|
const draggedIdx = pages.findIndex(p => p.id === draggedPageId);
|
|
const targetIdx = pages.findIndex(p => p.id === targetPageId);
|
|
if (draggedIdx === -1 || targetIdx === -1) {
|
|
setDraggedPageId(null);
|
|
setDragOverPageId(null);
|
|
return;
|
|
}
|
|
|
|
// Reorder pages locally
|
|
const newPages = [...pages];
|
|
const [removed] = newPages.splice(draggedIdx, 1);
|
|
newPages.splice(targetIdx, 0, removed);
|
|
|
|
// Build new pageOrders array
|
|
const pageOrders = newPages.map((p, i) => ({ id: p.id, pageNumber: i + 1 }));
|
|
|
|
setDraggedPageId(null);
|
|
setDragOverPageId(null);
|
|
|
|
try {
|
|
await onReorderPages(pageOrders);
|
|
} catch {
|
|
toast.error(t('notebooks.reorder_error'));
|
|
}
|
|
}, [draggedPageId, pages, onReorderPages]);
|
|
|
|
const handleDragEnd = useCallback(() => {
|
|
setDraggedPageId(null);
|
|
setDragOverPageId(null);
|
|
}, []);
|
|
|
|
// Touch-based drag handlers for tablets
|
|
const touchDragRef = useRef<{ pageId: string; startY: number; clone: HTMLElement | null } | null>(null);
|
|
|
|
const handleTouchStart = useCallback((e: React.TouchEvent, pageId: string) => {
|
|
const touch = e.touches[0];
|
|
touchDragRef.current = { pageId, startY: touch.clientY, clone: null };
|
|
}, []);
|
|
|
|
const handleTouchMove = useCallback((e: React.TouchEvent) => {
|
|
if (!touchDragRef.current) return;
|
|
const touch = e.touches[0];
|
|
const deltaY = Math.abs(touch.clientY - touchDragRef.current.startY);
|
|
if (deltaY > 10 && !touchDragRef.current.clone) {
|
|
setDraggedPageId(touchDragRef.current.pageId);
|
|
}
|
|
}, []);
|
|
|
|
const handleTouchEnd = useCallback(() => {
|
|
if (dragOverPageId && touchDragRef.current && dragOverPageId !== touchDragRef.current.pageId) {
|
|
handleDrop(dragOverPageId);
|
|
}
|
|
touchDragRef.current = null;
|
|
setDraggedPageId(null);
|
|
setDragOverPageId(null);
|
|
}, [dragOverPageId, handleDrop]);
|
|
|
|
const IconComponent = notebook.icon ? ICON_MAP[notebook.icon] ?? BookOpen : BookOpen;
|
|
|
|
return (
|
|
<motion.div
|
|
initial={{ opacity: 0, x: 20 }}
|
|
animate={{ opacity: 1, x: 0 }}
|
|
exit={{ opacity: 0, x: -20 }}
|
|
className="flex flex-col h-full"
|
|
>
|
|
{/* Header */}
|
|
<div className="flex items-center gap-3 p-4 bg-white dark:bg-gray-900 border-b border-gray-200 dark:border-gray-700">
|
|
<Button variant="ghost" size="sm" onClick={onBack} className="min-h-[44px] min-w-[44px]">
|
|
<ChevronLeft className="w-5 h-5" />
|
|
</Button>
|
|
|
|
<div
|
|
className="w-10 h-10 rounded-lg flex items-center justify-center shrink-0"
|
|
style={{ backgroundColor: notebook.color }}
|
|
>
|
|
<IconComponent className="w-5 h-5 text-white" />
|
|
</div>
|
|
|
|
<div className="flex-1 min-w-0">
|
|
<h2 className="font-semibold text-lg truncate text-gray-900 dark:text-gray-100">
|
|
{notebook.title}
|
|
</h2>
|
|
<div className="flex items-center gap-2 text-sm text-gray-500 dark:text-gray-400">
|
|
{subjectName && <Badge variant="secondary" className="text-xs">{subjectName}</Badge>}
|
|
<Badge variant="outline" className="text-xs">
|
|
{t(NOTEBOOK_TYPES.find(nt => nt.key === notebook.notebookType)?.labelKey ?? 'notebooks.type_lined')}
|
|
</Badge>
|
|
{notebook.isPublic ? (
|
|
<Badge className="text-xs bg-emerald-100 dark:bg-emerald-900/30 text-emerald-700 dark:text-emerald-300 border-0">
|
|
<Globe className="w-3 h-3 mr-1" />
|
|
{t('notebooks.shared')}
|
|
</Badge>
|
|
) : (
|
|
<Badge variant="outline" className="text-xs">{t('notebooks.private')}</Badge>
|
|
)}
|
|
</div>
|
|
</div>
|
|
|
|
{/* Auto-save indicator */}
|
|
{autoSaveStatus !== 'idle' && (
|
|
<div className="flex items-center gap-1.5 text-sm shrink-0">
|
|
{autoSaveStatus === 'saving' && (
|
|
<div className="w-3 h-3 animate-spin rounded-full border-2 border-emerald-500 border-t-transparent" />
|
|
)}
|
|
{autoSaveStatus === 'saved' && (
|
|
<CheckCircle2 className="w-4 h-4 text-emerald-500" />
|
|
)}
|
|
<span className={`text-xs ${autoSaveStatus === 'saved' ? 'text-emerald-600 dark:text-emerald-400' : 'text-gray-500 dark:text-gray-400'}`}>
|
|
{autoSaveStatus === 'saving' ? t('notebooks.auto_saving') : t('notebooks.auto_saved')}
|
|
</span>
|
|
</div>
|
|
)}
|
|
|
|
{/* Drawing mode toggle */}
|
|
<Button
|
|
variant={drawingMode ? 'default' : 'outline'}
|
|
size="sm"
|
|
onClick={() => setDrawingMode(!drawingMode)}
|
|
className="min-h-[44px] shrink-0"
|
|
>
|
|
{drawingMode ? (
|
|
<>
|
|
<PenLine className="w-4 h-4 mr-1" />
|
|
{t('notebooks.drawing_mode_exit')}
|
|
</>
|
|
) : (
|
|
<>
|
|
<PenLine className="w-4 h-4 mr-1" />
|
|
{t('notebooks.drawing_mode_toggle')}
|
|
</>
|
|
)}
|
|
</Button>
|
|
|
|
{/* Public toggle */}
|
|
<Button
|
|
variant="ghost"
|
|
size="sm"
|
|
onClick={onTogglePublic}
|
|
className="min-h-[44px] shrink-0"
|
|
>
|
|
{notebook.isPublic ? <EyeOff className="w-4 h-4" /> : <Eye className="w-4 h-4" />}
|
|
</Button>
|
|
</div>
|
|
|
|
{/* Content: sidebar + main area */}
|
|
<div className="flex flex-1 overflow-hidden">
|
|
{/* Left sidebar: page navigation */}
|
|
<div className="w-64 border-r border-gray-200 dark:border-gray-700 bg-gray-50 dark:bg-gray-900/50 flex flex-col">
|
|
<div className="p-3">
|
|
<Button
|
|
onClick={onAddPage}
|
|
className="w-full min-h-[44px] bg-emerald-600 hover:bg-emerald-700"
|
|
size="sm"
|
|
>
|
|
<Plus className="w-4 h-4 mr-1" />
|
|
{t('notebooks.add_page')}
|
|
</Button>
|
|
</div>
|
|
|
|
<ScrollArea className="flex-1 max-h-[calc(100vh-280px)]">
|
|
<div className="p-2 space-y-2">
|
|
{pages.map((page, idx) => {
|
|
const isDragged = draggedPageId === page.id;
|
|
const isDragOver = dragOverPageId === page.id;
|
|
return (
|
|
<motion.div
|
|
key={page.id}
|
|
initial={{ opacity: 0, x: -10 }}
|
|
animate={{
|
|
opacity: isDragged ? 0.5 : 1,
|
|
x: 0,
|
|
scale: isDragged ? 0.95 : 1,
|
|
}}
|
|
transition={{ delay: idx * 0.03 }}
|
|
layout
|
|
className={`rounded-lg transition-all ${
|
|
currentPageId === page.id
|
|
? 'ring-2 ring-emerald-500'
|
|
: ''
|
|
} ${isDragOver ? 'ring-2 ring-blue-400 shadow-lg' : ''} ${
|
|
isDragged ? 'shadow-md' : ''
|
|
}`}
|
|
draggable
|
|
onDragStart={(e) => handleDragStart(e as unknown as React.DragEvent<HTMLDivElement>, page.id)}
|
|
onDragOver={(e) => handleDragOver(e as unknown as React.DragEvent<HTMLDivElement>, page.id)}
|
|
onDragLeave={handleDragLeave}
|
|
onDrop={() => handleDrop(page.id)}
|
|
onDragEnd={handleDragEnd}
|
|
onTouchStart={(e) => handleTouchStart(e, page.id)}
|
|
onTouchMove={handleTouchMove}
|
|
onTouchEnd={handleTouchEnd}
|
|
>
|
|
{/* Drag handle */}
|
|
<div className="flex items-center">
|
|
<div className="pl-1.5 py-1 cursor-grab active:cursor-grabbing touch-none">
|
|
<GripVertical className="w-4 h-4 text-gray-400 dark:text-gray-500" />
|
|
</div>
|
|
<div className="flex-1">
|
|
{/* Page thumbnail */}
|
|
<PageThumbnail page={page} notebookType={notebook.notebookType} />
|
|
</div>
|
|
</div>
|
|
|
|
{/* Page info below thumbnail */}
|
|
<button
|
|
onClick={() => { setCurrentPageId(page.id); setDrawingMode(false); }}
|
|
className={`w-full text-left p-2 rounded-b-lg text-sm transition-all min-h-[44px] flex items-center gap-2 ${
|
|
currentPageId === page.id
|
|
? 'bg-emerald-100 dark:bg-emerald-900/30 text-emerald-700 dark:text-emerald-300 font-medium'
|
|
: 'bg-white dark:bg-gray-800 text-gray-600 dark:text-gray-400 hover:bg-gray-100 dark:hover:bg-gray-700'
|
|
}`}
|
|
>
|
|
<span className="text-xs font-mono text-gray-400 dark:text-gray-500 shrink-0">
|
|
{page.pageNumber}
|
|
</span>
|
|
<span className="truncate flex-1">
|
|
{page.title ?? `${t('notebooks.page')} ${page.pageNumber}`}
|
|
</span>
|
|
{page.isBookmark && (
|
|
<Bookmark className="w-3.5 h-3.5 text-amber-500 fill-amber-500 shrink-0" />
|
|
)}
|
|
</button>
|
|
</motion.div>
|
|
);
|
|
})}
|
|
</div>
|
|
</ScrollArea>
|
|
|
|
{/* Page count footer */}
|
|
<div className="p-3 border-t border-gray-200 dark:border-gray-700">
|
|
<div className="flex items-center justify-between text-xs text-gray-500 dark:text-gray-400">
|
|
<span className="flex items-center gap-1">
|
|
<BookMarked className="w-3.5 h-3.5" />
|
|
{pages.length} {t('notebooks.pages')}
|
|
</span>
|
|
<span className="flex items-center gap-1.5 text-emerald-600 dark:text-emerald-400">
|
|
<Leaf className="w-3 h-3" />
|
|
{t('notebooks.eco_message')}
|
|
</span>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Main content area */}
|
|
<div className="flex-1 flex flex-col bg-white dark:bg-gray-900 overflow-hidden">
|
|
{currentPage ? (
|
|
<>
|
|
{/* Page title bar */}
|
|
<div className="flex items-center gap-3 px-4 py-2 border-b border-gray-100 dark:border-gray-800 bg-white dark:bg-gray-900">
|
|
<Input
|
|
value={pageTitle}
|
|
onChange={(e) => setPageTitle(e.target.value)}
|
|
placeholder={t('notebooks.page_title_placeholder')}
|
|
className="font-semibold text-lg border-0 bg-transparent shadow-none focus-visible:ring-0 h-10"
|
|
/>
|
|
|
|
<Button
|
|
variant="ghost"
|
|
size="sm"
|
|
onClick={() => onToggleBookmark(currentPage.id)}
|
|
className={`min-h-[44px] shrink-0 ${currentPage.isBookmark ? 'text-amber-500' : 'text-gray-400'}`}
|
|
>
|
|
<Star className={`w-4 h-4 ${currentPage.isBookmark ? 'fill-amber-500' : ''}`} />
|
|
</Button>
|
|
|
|
<Button
|
|
variant="ghost"
|
|
size="sm"
|
|
onClick={() => onDeletePage(currentPage.id)}
|
|
className="min-h-[44px] shrink-0 text-red-500 hover:text-red-600"
|
|
>
|
|
<Trash2 className="w-4 h-4" />
|
|
</Button>
|
|
</div>
|
|
|
|
{drawingMode ? (
|
|
/* Drawing placeholder */
|
|
<div className="flex-1 flex items-center justify-center p-4">
|
|
<motion.div
|
|
initial={{ opacity: 0, scale: 0.95 }}
|
|
animate={{ opacity: 1, scale: 1 }}
|
|
className="w-full h-full max-w-3xl rounded-xl border-2 border-gray-200 dark:border-gray-700 bg-white flex flex-col items-center justify-center gap-4 p-8"
|
|
style={{ ...getPageBackgroundCSS(currentPage.background ?? notebook.notebookType), backgroundColor: '#fff' }}
|
|
>
|
|
<PenLine className="w-12 h-12 text-gray-300 dark:text-gray-600" />
|
|
<p className="text-gray-500 dark:text-gray-400 text-lg font-medium">
|
|
{t('notebooks.drawing_placeholder')}
|
|
</p>
|
|
<Button variant="outline" onClick={() => setDrawingMode(false)} className="min-h-[44px]">
|
|
<ChevronLeft className="w-4 h-4 mr-1" />
|
|
{t('notebooks.drawing_mode_exit')}
|
|
</Button>
|
|
</motion.div>
|
|
</div>
|
|
) : (
|
|
/* Text editing area with WYSIWYG toolbar and page background */
|
|
<div className="flex-1 flex flex-col overflow-hidden">
|
|
{/* WYSIWYG toolbar */}
|
|
<WysiwygToolbar editorRef={editorRef} onFormatChange={handleFormatChange} />
|
|
|
|
{/* Page content area */}
|
|
<div className="flex-1 overflow-hidden p-4">
|
|
<div
|
|
className="w-full h-full max-w-3xl mx-auto rounded-xl overflow-hidden"
|
|
style={{ backgroundColor: '#fff' }}
|
|
>
|
|
<ScrollArea className="h-full">
|
|
<div
|
|
className="min-h-full p-6"
|
|
style={getPageBackgroundCSS(currentPage.background ?? notebook.notebookType)}
|
|
>
|
|
<div
|
|
ref={editorRef}
|
|
contentEditable
|
|
suppressContentEditableWarning
|
|
onInput={handleEditorInput}
|
|
data-placeholder={t('notebooks.page_content') + '...'}
|
|
className="w-full min-h-[500px] bg-transparent outline-none text-base text-gray-800 dark:text-gray-200 focus:ring-0 prose prose-sm max-w-none [&:empty]:before:content-[attr(data-placeholder)] [&:empty]:before:text-gray-300 [&:empty]:dark:before:text-gray-600 [&_h1]:text-2xl [&_h1]:font-bold [&_h1]:mb-2 [&_h1]:mt-4 [&_h2]:text-xl [&_h2]:font-bold [&_h2]:mb-1.5 [&_h2]:mt-3 [&_h3]:text-lg [&_h3]:font-semibold [&_h3]:mb-1 [&_h3]:mt-2 [&_ul]:list-disc [&_ul]:pl-6 [&_ul]:mb-2 [&_ol]:list-decimal [&_ol]:pl-6 [&_ol]:mb-2 [&_li]:mb-0.5"
|
|
style={{ lineHeight: notebook.notebookType === 'lined' || notebook.notebookType === 'calligraphy' ? '32px' : '1.5' }}
|
|
/>
|
|
</div>
|
|
</ScrollArea>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
{/* Save bar */}
|
|
<div className="flex items-center justify-between px-4 py-2 border-t border-gray-100 dark:border-gray-800 bg-white dark:bg-gray-900">
|
|
<div className="text-sm text-gray-500 dark:text-gray-400">
|
|
{t('notebooks.page')} {currentPage.pageNumber} / {pages.length}
|
|
</div>
|
|
<Button
|
|
onClick={handleSavePage}
|
|
disabled={saving}
|
|
size="sm"
|
|
className="min-h-[44px] bg-emerald-600 hover:bg-emerald-700"
|
|
>
|
|
{saving ? (
|
|
<div className="w-4 h-4 animate-spin rounded-full border-2 border-white border-t-transparent mr-2" />
|
|
) : (
|
|
<Edit3 className="w-4 h-4 mr-1" />
|
|
)}
|
|
{t('notebooks.save_page')}
|
|
</Button>
|
|
</div>
|
|
</>
|
|
) : (
|
|
/* Empty state - no pages */
|
|
<div className="flex-1 flex flex-col items-center justify-center gap-4 p-8">
|
|
<BookMarked className="w-16 h-16 text-gray-300 dark:text-gray-600" />
|
|
<p className="text-gray-500 dark:text-gray-400 text-lg">
|
|
{t('notebooks.no_notebooks_desc')}
|
|
</p>
|
|
<Button onClick={onAddPage} className="min-h-[44px] bg-emerald-600 hover:bg-emerald-700">
|
|
<Plus className="w-4 h-4 mr-1" />
|
|
{t('notebooks.add_page')}
|
|
</Button>
|
|
</div>
|
|
)}
|
|
</div>
|
|
</div>
|
|
</motion.div>
|
|
);
|
|
}
|
|
|
|
// ─── Main View ───────────────────────────────────────────────────────
|
|
|
|
export default function NotebooksView() {
|
|
const currentUser = useAppStore((s) => s.currentUser);
|
|
const schoolId = currentUser?.schoolId ?? '';
|
|
|
|
// Data state
|
|
const [notebooks, setNotebooks] = useState<Notebook[]>([]);
|
|
const [subjects, setSubjects] = useState<Subject[]>([]);
|
|
const [classes, setClasses] = useState<ClassGroup[]>([]);
|
|
const [loading, setLoading] = useState(true);
|
|
|
|
// UI state
|
|
const [createOpen, setCreateOpen] = useState(false);
|
|
const [showArchived, setShowArchived] = useState(false);
|
|
const [subjectFilter, setSubjectFilter] = useState<string>('all');
|
|
const [searchQuery, setSearchQuery] = useState('');
|
|
const [selectedNotebook, setSelectedNotebook] = useState<Notebook | null>(null);
|
|
const [activeTab, setActiveTab] = useState<'all' | 'shared' | 'templates'>('all');
|
|
const [sharedNotebooksFromApi, setSharedNotebooksFromApi] = useState<Notebook[]>([]);
|
|
const [shareConfirmNotebook, setShareConfirmNotebook] = useState<Notebook | null>(null);
|
|
|
|
// Load data
|
|
const loadData = useCallback(async () => {
|
|
setLoading(true);
|
|
try {
|
|
const [nbData, subData, clsData] = await Promise.all([
|
|
apiGet<Notebook[]>(`/api/notebooks?schoolId=${schoolId}`),
|
|
apiGet<Subject[]>(`/api/subjects?schoolId=${schoolId}`),
|
|
apiGet<ClassGroup[]>(`/api/classes?schoolId=${schoolId}`),
|
|
]);
|
|
setNotebooks(nbData);
|
|
setSubjects(subData);
|
|
setClasses(clsData);
|
|
} catch {
|
|
toast.error(t('notebooks.error_load'));
|
|
setNotebooks([]);
|
|
setSubjects([]);
|
|
setClasses([]);
|
|
} finally {
|
|
setLoading(false);
|
|
}
|
|
}, [schoolId]);
|
|
|
|
// Load shared notebooks from other teachers
|
|
const loadSharedNotebooks = useCallback(async () => {
|
|
if (!schoolId) return;
|
|
try {
|
|
const data = await apiGet<Notebook[]>(`/api/notebooks/shared?schoolId=${schoolId}`);
|
|
setSharedNotebooksFromApi(data);
|
|
} catch {
|
|
setSharedNotebooksFromApi([]);
|
|
}
|
|
}, [schoolId]);
|
|
|
|
useEffect(() => {
|
|
loadData();
|
|
}, [loadData]);
|
|
|
|
useEffect(() => {
|
|
loadSharedNotebooks();
|
|
}, [loadSharedNotebooks]);
|
|
|
|
// Computed data
|
|
const activeNotebooks = useMemo(
|
|
() => notebooks.filter(n => !n.isArchived),
|
|
[notebooks]
|
|
);
|
|
|
|
const archivedNotebooks = useMemo(
|
|
() => notebooks.filter(n => n.isArchived),
|
|
[notebooks]
|
|
);
|
|
|
|
const filteredNotebooks = useMemo(() => {
|
|
let list = showArchived ? archivedNotebooks : activeNotebooks;
|
|
if (subjectFilter !== 'all') {
|
|
list = list.filter(n => n.subjectId === subjectFilter);
|
|
}
|
|
if (searchQuery.trim()) {
|
|
const q = searchQuery.toLowerCase();
|
|
list = list.filter(n =>
|
|
n.title.toLowerCase().includes(q) ||
|
|
(n.description?.toLowerCase().includes(q) ?? false)
|
|
);
|
|
}
|
|
return list.sort((a, b) => a.sortOrder - b.sortOrder);
|
|
}, [activeNotebooks, archivedNotebooks, showArchived, subjectFilter, searchQuery]);
|
|
|
|
// Subject counts for filter bar
|
|
const subjectCounts = useMemo(() => {
|
|
const counts: Record<string, number> = {};
|
|
const list = showArchived ? archivedNotebooks : activeNotebooks;
|
|
list.forEach(n => {
|
|
const sid = n.subjectId ?? 'none';
|
|
counts[sid] = (counts[sid] ?? 0) + 1;
|
|
});
|
|
return counts;
|
|
}, [activeNotebooks, archivedNotebooks, showArchived]);
|
|
|
|
const subjectNameMap = useMemo(() => {
|
|
const map: Record<string, string> = {};
|
|
subjects.forEach(s => { map[s.id] = s.name; });
|
|
return map;
|
|
}, [subjects]);
|
|
|
|
const totalPaperSaved = useMemo(
|
|
() => notebooks.reduce((sum, n) => sum + (n._count?.pages ?? n.pages?.length ?? 0) * 50, 0),
|
|
[notebooks]
|
|
);
|
|
|
|
// Handlers
|
|
// BUG FIX: Call loadData() instead of just appending to local state
|
|
const handleCreate = useCallback(async (data: Partial<Notebook>) => {
|
|
await apiPost<Notebook>('/api/notebooks', {
|
|
...data,
|
|
schoolId,
|
|
ownerId: currentUser?.id ?? '',
|
|
ownerType: currentUser?.role === 'STUDENT' ? 'STUDENT' : 'TEACHER',
|
|
subjectId: data.subjectId === 'none' ? null : data.subjectId,
|
|
classGroupId: data.classGroupId === 'none' ? null : data.classGroupId,
|
|
});
|
|
await loadData();
|
|
toast.success(t('notebooks.created'));
|
|
}, [schoolId, currentUser, loadData]);
|
|
|
|
const handleArchive = useCallback(async (notebook: Notebook) => {
|
|
const updated = await apiPut<Notebook>(`/api/notebooks/${notebook.id}`, {
|
|
isArchived: !notebook.isArchived,
|
|
});
|
|
setNotebooks(prev => prev.map(n => n.id === updated.id ? updated : n));
|
|
toast.success(notebook.isArchived ? t('notebooks.unarchived_toast') : t('notebooks.archived_toast'));
|
|
}, []);
|
|
|
|
const handleDelete = useCallback(async (notebook: Notebook) => {
|
|
await apiDelete(`/api/notebooks/${notebook.id}`);
|
|
setNotebooks(prev => prev.filter(n => n.id !== notebook.id));
|
|
toast.success(t('notebooks.deleted_toast'));
|
|
}, []);
|
|
|
|
const handleOpenNotebook = useCallback(async (notebook: Notebook) => {
|
|
try {
|
|
const pages = await apiGet<NotebookPage[]>(`/api/notebooks/${notebook.id}/pages`);
|
|
const fullNotebook = { ...notebook, pages };
|
|
setSelectedNotebook(fullNotebook);
|
|
} catch {
|
|
setSelectedNotebook({ ...notebook, pages: [] });
|
|
}
|
|
}, []);
|
|
|
|
const handleUpdatePage = useCallback(async (pageId: string, data: Partial<NotebookPage>) => {
|
|
if (!selectedNotebook) return;
|
|
const updated = await apiPut<NotebookPage>(`/api/notebooks/${selectedNotebook.id}/pages/${pageId}`, data);
|
|
setSelectedNotebook(prev => {
|
|
if (!prev) return prev;
|
|
return {
|
|
...prev,
|
|
pages: prev.pages?.map(p => p.id === updated.id ? updated : p) ?? [],
|
|
};
|
|
});
|
|
}, [selectedNotebook]);
|
|
|
|
const handleAddPage = useCallback(async () => {
|
|
if (!selectedNotebook) return;
|
|
const pagesCount = selectedNotebook.pages?.length ?? 0;
|
|
const newPage = await apiPost<NotebookPage>(`/api/notebooks/${selectedNotebook.id}/pages`, {
|
|
pageNumber: pagesCount + 1,
|
|
background: selectedNotebook.notebookType,
|
|
});
|
|
setSelectedNotebook(prev => {
|
|
if (!prev) return prev;
|
|
return {
|
|
...prev,
|
|
pages: [...(prev.pages ?? []), newPage],
|
|
};
|
|
});
|
|
toast.success(t('notebooks.page_added'));
|
|
}, [selectedNotebook]);
|
|
|
|
const handleDeletePage = useCallback(async (pageId: string) => {
|
|
if (!selectedNotebook) return;
|
|
await apiDelete(`/api/notebooks/${selectedNotebook.id}/pages/${pageId}`);
|
|
setSelectedNotebook(prev => {
|
|
if (!prev) return prev;
|
|
return {
|
|
...prev,
|
|
pages: prev.pages?.filter(p => p.id !== pageId) ?? [],
|
|
};
|
|
});
|
|
toast.success(t('notebooks.page_deleted'));
|
|
}, [selectedNotebook]);
|
|
|
|
const handleToggleBookmark = useCallback(async (pageId: string) => {
|
|
if (!selectedNotebook) return;
|
|
const page = selectedNotebook.pages?.find(p => p.id === pageId);
|
|
if (!page) return;
|
|
const updated = await apiPut<NotebookPage>(`/api/notebooks/${selectedNotebook.id}/pages/${pageId}`, {
|
|
isBookmark: !page.isBookmark,
|
|
});
|
|
setSelectedNotebook(prev => {
|
|
if (!prev) return prev;
|
|
return {
|
|
...prev,
|
|
pages: prev.pages?.map(p => p.id === updated.id ? updated : p) ?? [],
|
|
};
|
|
});
|
|
}, [selectedNotebook]);
|
|
|
|
const handleTogglePublic = useCallback(async () => {
|
|
if (!selectedNotebook) return;
|
|
const updated = await apiPut<Notebook>(`/api/notebooks/${selectedNotebook.id}`, {
|
|
isPublic: !selectedNotebook.isPublic,
|
|
});
|
|
setSelectedNotebook(updated);
|
|
setNotebooks(prev => prev.map(n => n.id === updated.id ? updated : n));
|
|
}, [selectedNotebook]);
|
|
|
|
const handleReorderPages = useCallback(async (pageOrders: Array<{ id: string; pageNumber: number }>) => {
|
|
if (!selectedNotebook) return;
|
|
const updatedPages = await apiPut<NotebookPage[]>(`/api/notebooks/${selectedNotebook.id}/pages/reorder`, {
|
|
pageOrders,
|
|
});
|
|
setSelectedNotebook(prev => {
|
|
if (!prev) return prev;
|
|
return {
|
|
...prev,
|
|
pages: updatedPages,
|
|
};
|
|
});
|
|
toast.success(t('notebooks.reorder_success'));
|
|
}, [selectedNotebook]);
|
|
|
|
const handleShare = useCallback(async (notebook: Notebook) => {
|
|
setShareConfirmNotebook(notebook);
|
|
}, []);
|
|
|
|
const handleConfirmShare = useCallback(async () => {
|
|
if (!shareConfirmNotebook) return;
|
|
try {
|
|
const updated = await apiPut<Notebook>(`/api/notebooks/${shareConfirmNotebook.id}`, {
|
|
isPublic: !shareConfirmNotebook.isPublic,
|
|
});
|
|
setNotebooks(prev => prev.map(n => n.id === updated.id ? updated : n));
|
|
toast.success(updated.isPublic ? t('notebooks.shared') : t('notebooks.private'));
|
|
} catch {
|
|
toast.error(t('notebooks.error_save'));
|
|
} finally {
|
|
setShareConfirmNotebook(null);
|
|
}
|
|
}, [shareConfirmNotebook]);
|
|
|
|
const handleDuplicate = useCallback(async (notebook: Notebook) => {
|
|
try {
|
|
await apiPost<Notebook>(`/api/notebooks/${notebook.id}/duplicate`);
|
|
await loadData();
|
|
toast.success(t('notebooks.duplicated'));
|
|
} catch {
|
|
toast.error(t('notebooks.duplicate_error'));
|
|
}
|
|
}, [loadData]);
|
|
|
|
const handleCreateFromTemplate = useCallback(async (template: NotebookTemplate) => {
|
|
try {
|
|
const newNotebook = await apiPost<Notebook>('/api/notebooks', {
|
|
schoolId,
|
|
ownerId: currentUser?.id ?? '',
|
|
ownerType: 'TEACHER',
|
|
title: t(template.titleKey),
|
|
description: t(template.descKey),
|
|
notebookType: template.notebookType,
|
|
color: template.color,
|
|
icon: template.icon,
|
|
isPublic: false,
|
|
subjectId: null,
|
|
classGroupId: null,
|
|
});
|
|
// Create pages from the template
|
|
for (let i = 0; i < template.pages.length; i++) {
|
|
const page = template.pages[i];
|
|
await apiPost<NotebookPage>(`/api/notebooks/${newNotebook.id}/pages`, {
|
|
pageNumber: i + 1,
|
|
title: page.title,
|
|
textContent: page.content,
|
|
background: template.notebookType,
|
|
});
|
|
}
|
|
await loadData();
|
|
toast.success(t('notebooks.created'));
|
|
} catch {
|
|
toast.error(t('notebooks.error_create'));
|
|
}
|
|
}, [schoolId, currentUser, loadData]);
|
|
|
|
// Role detection
|
|
const isStudent = currentUser?.role === 'STUDENT';
|
|
|
|
// Separate own notebooks and shared notebooks (for students)
|
|
const ownNotebooks = useMemo(
|
|
() => notebooks.filter(n => n.ownerId === currentUser?.id),
|
|
[notebooks, currentUser?.id]
|
|
);
|
|
|
|
const sharedNotebooks = useMemo(
|
|
() => notebooks.filter(n => n.ownerId !== currentUser?.id && n.isPublic),
|
|
[notebooks, currentUser?.id]
|
|
);
|
|
|
|
const ownActiveNotebooks = useMemo(
|
|
() => ownNotebooks.filter(n => !n.isArchived),
|
|
[ownNotebooks]
|
|
);
|
|
|
|
const ownArchivedNotebooks = useMemo(
|
|
() => ownNotebooks.filter(n => n.isArchived),
|
|
[ownNotebooks]
|
|
);
|
|
|
|
// Keyboard shortcuts listener
|
|
useEffect(() => {
|
|
function onShortcut(e: Event) {
|
|
const detail = (e as CustomEvent).detail;
|
|
switch (detail) {
|
|
case 'new-notebook':
|
|
setCreateOpen(true);
|
|
break;
|
|
case 'new-page':
|
|
if (selectedNotebook) {
|
|
handleAddPage();
|
|
}
|
|
break;
|
|
case 'drawing':
|
|
if (selectedNotebook) {
|
|
// The NotebookDetailView handles drawing mode internally
|
|
toast.info(t('shortcuts.drawing'));
|
|
} else {
|
|
// Navigate to drawing view
|
|
const setCurrentView = useAppStore.getState().setCurrentView;
|
|
setCurrentView('drawing');
|
|
}
|
|
break;
|
|
case 'export-pdf':
|
|
if (selectedNotebook) {
|
|
toast.info(t('shortcuts.export_pdf'));
|
|
}
|
|
break;
|
|
case 'archive-toggle':
|
|
setShowArchived(prev => !prev);
|
|
break;
|
|
case 'close-notebook':
|
|
if (selectedNotebook) {
|
|
setSelectedNotebook(null);
|
|
}
|
|
break;
|
|
}
|
|
}
|
|
window.addEventListener('ct-shortcut', onShortcut);
|
|
return () => window.removeEventListener('ct-shortcut', onShortcut);
|
|
}, [selectedNotebook, handleAddPage]);
|
|
|
|
if (selectedNotebook) {
|
|
return (
|
|
<div className="min-h-screen bg-gray-50 dark:bg-gray-950">
|
|
<NotebookDetailView
|
|
notebook={selectedNotebook}
|
|
subjectName={selectedNotebook.subjectId ? subjectNameMap[selectedNotebook.subjectId] ?? null : null}
|
|
onBack={() => setSelectedNotebook(null)}
|
|
onUpdatePage={handleUpdatePage}
|
|
onAddPage={handleAddPage}
|
|
onDeletePage={handleDeletePage}
|
|
onToggleBookmark={handleToggleBookmark}
|
|
onTogglePublic={handleTogglePublic}
|
|
onReorderPages={handleReorderPages}
|
|
/>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
return (
|
|
<div className="min-h-screen bg-gray-50 dark:bg-gray-950 flex flex-col">
|
|
{/* Header */}
|
|
<motion.div
|
|
initial={{ opacity: 0, y: -10 }}
|
|
animate={{ opacity: 1, y: 0 }}
|
|
className="bg-white dark:bg-gray-900 border-b border-gray-200 dark:border-gray-700 px-4 sm:px-6 py-4"
|
|
>
|
|
<div className="flex items-center gap-4 flex-wrap">
|
|
<div className="flex-1 min-w-0">
|
|
<h1 className="text-2xl font-bold text-gray-900 dark:text-gray-100 flex items-center gap-2">
|
|
<BookOpen className="w-6 h-6 text-emerald-500" />
|
|
{t('notebooks.title')}
|
|
{/* Role indicator */}
|
|
{isStudent ? (
|
|
<Badge className="bg-amber-100 dark:bg-amber-900/30 text-amber-700 dark:text-amber-300 border-0 text-xs ml-1">
|
|
<GraduationCap className="w-3 h-3 mr-1" />
|
|
{t('role.student')}
|
|
</Badge>
|
|
) : (
|
|
<Badge className="bg-emerald-100 dark:bg-emerald-900/30 text-emerald-700 dark:text-emerald-300 border-0 text-xs ml-1">
|
|
<UserIcon className="w-3 h-3 mr-1" />
|
|
{t('role.teacher')}
|
|
</Badge>
|
|
)}
|
|
</h1>
|
|
<div className="flex items-center gap-2 text-sm text-emerald-600 dark:text-emerald-400 mt-1">
|
|
<Leaf className="w-4 h-4" />
|
|
<span>{t('notebooks.subtitle')}</span>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Stats */}
|
|
<div className="flex items-center gap-3">
|
|
<Card className="px-3 py-2 border-0 shadow-sm bg-emerald-50 dark:bg-emerald-900/20">
|
|
<div className="text-xs text-emerald-600 dark:text-emerald-400">{t('notebooks.total_notebooks')}</div>
|
|
<div className="text-lg font-bold text-emerald-700 dark:text-emerald-300">{activeNotebooks.length}</div>
|
|
</Card>
|
|
<Card className="px-3 py-2 border-0 shadow-sm bg-emerald-50 dark:bg-emerald-900/20">
|
|
<div className="text-xs text-emerald-600 dark:text-emerald-400">{t('notebooks.paper_saved')}</div>
|
|
<div className="text-lg font-bold text-emerald-700 dark:text-emerald-300">
|
|
{totalPaperSaved} {t('notebooks.pages_unit')}
|
|
</div>
|
|
</Card>
|
|
</div>
|
|
|
|
{/* Search */}
|
|
<div className="relative w-full sm:w-auto">
|
|
<Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-gray-400" />
|
|
<Input
|
|
value={searchQuery}
|
|
onChange={(e) => setSearchQuery(e.target.value)}
|
|
placeholder={t('notebooks.search_placeholder')}
|
|
className="pl-9 min-h-[44px] w-full sm:w-64"
|
|
/>
|
|
{searchQuery && (
|
|
<button
|
|
onClick={() => setSearchQuery('')}
|
|
className="absolute right-3 top-1/2 -translate-y-1/2"
|
|
>
|
|
<X className="w-4 h-4 text-gray-400" />
|
|
</button>
|
|
)}
|
|
</div>
|
|
|
|
{/* Create button */}
|
|
<Button
|
|
onClick={() => setCreateOpen(true)}
|
|
className="min-h-[44px] bg-emerald-600 hover:bg-emerald-700 shrink-0"
|
|
>
|
|
<Plus className="w-4 h-4 mr-2" />
|
|
{t('notebooks.create')}
|
|
</Button>
|
|
</div>
|
|
</motion.div>
|
|
|
|
{/* Tab Bar */}
|
|
<motion.div
|
|
initial={{ opacity: 0 }}
|
|
animate={{ opacity: 1 }}
|
|
transition={{ delay: 0.05 }}
|
|
className="bg-white dark:bg-gray-900 border-b border-gray-200 dark:border-gray-700 px-4 sm:px-6 py-2"
|
|
>
|
|
<div className="flex items-center gap-2">
|
|
<button
|
|
onClick={() => { setActiveTab('all'); setSubjectFilter('all'); }}
|
|
className={`px-4 py-2 rounded-full text-sm font-medium min-h-[44px] transition-all ${
|
|
activeTab === 'all'
|
|
? 'bg-emerald-600 text-white shadow-md'
|
|
: 'bg-gray-100 dark:bg-gray-800 text-gray-600 dark:text-gray-400 hover:bg-gray-200 dark:hover:bg-gray-700'
|
|
}`}
|
|
>
|
|
<BookOpen className="w-4 h-4 inline mr-1.5" />
|
|
{t('notebooks.tab_all')}
|
|
<span className="ml-1 text-xs opacity-75">{activeNotebooks.length}</span>
|
|
</button>
|
|
{!isStudent && (
|
|
<button
|
|
onClick={() => { setActiveTab('shared'); setSubjectFilter('all'); }}
|
|
className={`px-4 py-2 rounded-full text-sm font-medium min-h-[44px] transition-all ${
|
|
activeTab === 'shared'
|
|
? 'bg-emerald-600 text-white shadow-md'
|
|
: 'bg-gray-100 dark:bg-gray-800 text-gray-600 dark:text-gray-400 hover:bg-gray-200 dark:hover:bg-gray-700'
|
|
}`}
|
|
>
|
|
<Globe className="w-4 h-4 inline mr-1.5" />
|
|
{t('notebooks.tab_shared')}
|
|
<span className="ml-1 text-xs opacity-75">{sharedNotebooksFromApi.length}</span>
|
|
</button>
|
|
)}
|
|
<button
|
|
onClick={() => { setActiveTab('templates'); setSubjectFilter('all'); }}
|
|
className={`px-4 py-2 rounded-full text-sm font-medium min-h-[44px] transition-all ${
|
|
activeTab === 'templates'
|
|
? 'bg-emerald-600 text-white shadow-md'
|
|
: 'bg-gray-100 dark:bg-gray-800 text-gray-600 dark:text-gray-400 hover:bg-gray-200 dark:hover:bg-gray-700'
|
|
}`}
|
|
>
|
|
<Sparkles className="w-4 h-4 inline mr-1.5" />
|
|
{t('notebooks.tab_templates')}
|
|
</button>
|
|
</div>
|
|
</motion.div>
|
|
|
|
{/* Subject Filter Bar — only show in "all" tab */}
|
|
{activeTab === 'all' && (
|
|
<motion.div
|
|
initial={{ opacity: 0 }}
|
|
animate={{ opacity: 1 }}
|
|
transition={{ delay: 0.1 }}
|
|
className="bg-white dark:bg-gray-900 border-b border-gray-200 dark:border-gray-700 px-4 sm:px-6 py-2"
|
|
>
|
|
<div className="flex items-center gap-2 flex-wrap">
|
|
{/* All filter */}
|
|
<button
|
|
onClick={() => setSubjectFilter('all')}
|
|
className={`px-4 py-2 rounded-full text-sm font-medium min-h-[36px] transition-all ${
|
|
subjectFilter === 'all'
|
|
? 'bg-emerald-600 text-white shadow-md'
|
|
: 'bg-gray-100 dark:bg-gray-800 text-gray-600 dark:text-gray-400 hover:bg-gray-200 dark:hover:bg-gray-700'
|
|
}`}
|
|
>
|
|
{t('notebooks.filter_all')}
|
|
<span className="ml-1 text-xs opacity-75">
|
|
{(showArchived ? archivedNotebooks : activeNotebooks).length}
|
|
</span>
|
|
</button>
|
|
|
|
{/* Subject filters */}
|
|
{subjects.map((s) => {
|
|
const count = subjectCounts[s.id] ?? 0;
|
|
if (count === 0) return null;
|
|
return (
|
|
<button
|
|
key={s.id}
|
|
onClick={() => setSubjectFilter(s.id)}
|
|
className={`px-4 py-2 rounded-full text-sm font-medium min-h-[36px] transition-all ${
|
|
subjectFilter === s.id
|
|
? 'bg-emerald-600 text-white shadow-md'
|
|
: 'bg-gray-100 dark:bg-gray-800 text-gray-600 dark:text-gray-400 hover:bg-gray-200 dark:hover:bg-gray-700'
|
|
}`}
|
|
>
|
|
{s.name}
|
|
<span className="ml-1 text-xs opacity-75">{count}</span>
|
|
</button>
|
|
);
|
|
})}
|
|
|
|
{/* No subject filter */}
|
|
{subjectCounts['none'] > 0 && (
|
|
<button
|
|
onClick={() => setSubjectFilter('none')}
|
|
className={`px-4 py-2 rounded-full text-sm font-medium min-h-[36px] transition-all ${
|
|
subjectFilter === 'none'
|
|
? 'bg-emerald-600 text-white shadow-md'
|
|
: 'bg-gray-100 dark:bg-gray-800 text-gray-600 dark:text-gray-400 hover:bg-gray-200 dark:hover:bg-gray-700'
|
|
}`}
|
|
>
|
|
{t('notebooks.no_subject')}
|
|
<span className="ml-1 text-xs opacity-75">{subjectCounts['none']}</span>
|
|
</button>
|
|
)}
|
|
|
|
{/* Archive toggle */}
|
|
<div className="ml-auto flex items-center gap-2">
|
|
<Button
|
|
variant="ghost"
|
|
size="sm"
|
|
onClick={() => setShowArchived(!showArchived)}
|
|
className={`min-h-[36px] ${showArchived ? 'text-emerald-600' : 'text-gray-500'}`}
|
|
>
|
|
<Archive className="w-4 h-4 mr-1" />
|
|
{showArchived ? t('notebooks.hide_archived') : t('notebooks.show_archived')}
|
|
</Button>
|
|
</div>
|
|
</div>
|
|
</motion.div>
|
|
)}
|
|
|
|
{/* Content Area */}
|
|
<div className="flex-1 p-4 sm:p-6">
|
|
{loading ? (
|
|
<div className="flex items-center justify-center py-20">
|
|
<div className="flex items-center gap-3">
|
|
<div className="h-6 w-6 animate-spin rounded-full border-2 border-emerald-500 border-t-transparent" />
|
|
<span className="text-gray-500">{t('notebooks.loading')}</span>
|
|
</div>
|
|
</div>
|
|
) : activeTab === 'templates' ? (
|
|
/* Templates Tab */
|
|
<motion.div
|
|
initial={{ opacity: 0 }}
|
|
animate={{ opacity: 1 }}
|
|
transition={{ delay: 0.1 }}
|
|
>
|
|
<div className="mb-6">
|
|
<h2 className="text-lg font-semibold text-gray-900 dark:text-gray-100 flex items-center gap-2">
|
|
<Sparkles className="w-5 h-5 text-emerald-500" />
|
|
{t('notebooks.templates')}
|
|
</h2>
|
|
<p className="text-sm text-gray-500 dark:text-gray-400 mt-1">{t('notebooks.template_desc')}</p>
|
|
</div>
|
|
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-4 sm:gap-6">
|
|
{NOTEBOOK_TEMPLATES.map((template, idx) => {
|
|
const TemplateIcon = template.iconComponent;
|
|
return (
|
|
<motion.div
|
|
key={template.key}
|
|
initial={{ opacity: 0, y: 20 }}
|
|
animate={{ opacity: 1, y: 0 }}
|
|
transition={{ delay: idx * 0.05 }}
|
|
>
|
|
<Card className="relative overflow-hidden cursor-pointer group transition-shadow duration-300 hover:shadow-lg border-0" style={{ boxShadow: `4px 4px 12px rgba(0,0,0,0.15), 1px 1px 3px rgba(0,0,0,0.1)` }}>
|
|
<div
|
|
className="relative h-28 flex items-center justify-center overflow-hidden"
|
|
style={{ background: `linear-gradient(135deg, ${template.color}, ${template.color}cc)` }}
|
|
>
|
|
<div
|
|
className="absolute left-0 top-0 bottom-0 w-3 opacity-80"
|
|
style={{ background: `linear-gradient(90deg, ${template.color}99, ${template.color}66)` }}
|
|
/>
|
|
<TemplateIcon className="w-12 h-12 text-white/90 drop-shadow-md" />
|
|
<Badge className="absolute top-2 right-2 bg-white/90 text-gray-700 text-xs border-0 shadow-sm">
|
|
{template.pages.length} {t('notebooks.pages')}
|
|
</Badge>
|
|
</div>
|
|
<CardContent className="p-4 space-y-3">
|
|
<div className="font-semibold text-base text-gray-900 dark:text-gray-100">
|
|
{t(template.titleKey)}
|
|
</div>
|
|
<p className="text-sm text-gray-500 dark:text-gray-400">{t(template.descKey)}</p>
|
|
<Button
|
|
onClick={() => handleCreateFromTemplate(template)}
|
|
className="w-full min-h-[44px] bg-emerald-600 hover:bg-emerald-700"
|
|
>
|
|
<Plus className="w-4 h-4 mr-1" />
|
|
{t('notebooks.create_from_template')}
|
|
</Button>
|
|
</CardContent>
|
|
</Card>
|
|
</motion.div>
|
|
);
|
|
})}
|
|
</div>
|
|
</motion.div>
|
|
) : activeTab === 'shared' ? (
|
|
/* Shared Notebooks Tab */
|
|
<motion.div
|
|
initial={{ opacity: 0 }}
|
|
animate={{ opacity: 1 }}
|
|
transition={{ delay: 0.1 }}
|
|
>
|
|
<div className="mb-6">
|
|
<h2 className="text-lg font-semibold text-gray-900 dark:text-gray-100 flex items-center gap-2">
|
|
<Globe className="w-5 h-5 text-emerald-500" />
|
|
{t('notebooks.shared_notebooks')}
|
|
</h2>
|
|
<p className="text-sm text-gray-500 dark:text-gray-400 mt-1">{t('notebooks.shared_empty_desc')}</p>
|
|
</div>
|
|
{sharedNotebooksFromApi.length === 0 ? (
|
|
<motion.div
|
|
initial={{ opacity: 0, scale: 0.95 }}
|
|
animate={{ opacity: 1, scale: 1 }}
|
|
className="flex flex-col items-center justify-center py-20 gap-4"
|
|
>
|
|
<Globe className="w-16 h-16 text-gray-300 dark:text-gray-600" />
|
|
<p className="text-gray-500 dark:text-gray-400 text-lg">{t('notebooks.shared_empty')}</p>
|
|
</motion.div>
|
|
) : (
|
|
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-4 sm:gap-6">
|
|
<AnimatePresence mode="popLayout">
|
|
{sharedNotebooksFromApi.map((notebook, idx) => (
|
|
<motion.div
|
|
key={notebook.id}
|
|
initial={{ opacity: 0, y: 20 }}
|
|
animate={{ opacity: 1, y: 0 }}
|
|
exit={{ opacity: 0, scale: 0.9 }}
|
|
transition={{ delay: idx * 0.05 }}
|
|
>
|
|
<NotebookCard
|
|
notebook={notebook}
|
|
subjectName={notebook.subject?.name ?? null}
|
|
onOpen={() => handleOpenNotebook(notebook)}
|
|
onArchive={() => handleArchive(notebook)}
|
|
onDelete={() => handleDelete(notebook)}
|
|
isShared
|
|
/>
|
|
</motion.div>
|
|
))}
|
|
</AnimatePresence>
|
|
</div>
|
|
)}
|
|
</motion.div>
|
|
) : isStudent && sharedNotebooks.length > 0 ? (
|
|
/* Student view: show "My Notebooks" and "Shared with me" sections */
|
|
<div className="space-y-8">
|
|
{/* My Notebooks section */}
|
|
<div>
|
|
<div className="flex items-center gap-2 mb-4">
|
|
<BookOpen className="w-5 h-5 text-emerald-600 dark:text-emerald-400" />
|
|
<h2 className="text-lg font-semibold text-gray-900 dark:text-gray-100">
|
|
{t('notebooks.my_notebooks')}
|
|
</h2>
|
|
<Badge variant="secondary" className="text-xs">{ownActiveNotebooks.length}</Badge>
|
|
</div>
|
|
{ownActiveNotebooks.length === 0 ? (
|
|
<motion.div
|
|
initial={{ opacity: 0, scale: 0.95 }}
|
|
animate={{ opacity: 1, scale: 1 }}
|
|
className="flex flex-col items-center justify-center py-12 gap-4"
|
|
>
|
|
<BookOpen className="w-12 h-12 text-gray-300 dark:text-gray-600" />
|
|
<p className="text-gray-500 dark:text-gray-400">{t('notebooks.no_notebooks')}</p>
|
|
<Button
|
|
onClick={() => setCreateOpen(true)}
|
|
className="min-h-[44px] bg-emerald-600 hover:bg-emerald-700"
|
|
>
|
|
<Plus className="w-4 h-4 mr-2" />
|
|
{t('notebooks.no_notebooks_create')}
|
|
</Button>
|
|
</motion.div>
|
|
) : (
|
|
<motion.div
|
|
initial={{ opacity: 0 }}
|
|
animate={{ opacity: 1 }}
|
|
className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-4 sm:gap-6"
|
|
>
|
|
<AnimatePresence mode="popLayout">
|
|
{ownActiveNotebooks.map((notebook, idx) => (
|
|
<motion.div
|
|
key={notebook.id}
|
|
initial={{ opacity: 0, y: 20 }}
|
|
animate={{ opacity: 1, y: 0 }}
|
|
exit={{ opacity: 0, scale: 0.9 }}
|
|
transition={{ delay: idx * 0.05 }}
|
|
>
|
|
<NotebookCard
|
|
notebook={notebook}
|
|
subjectName={notebook.subjectId ? subjectNameMap[notebook.subjectId] ?? null : null}
|
|
onOpen={() => handleOpenNotebook(notebook)}
|
|
onArchive={() => handleArchive(notebook)}
|
|
onDelete={() => handleDelete(notebook)}
|
|
onShare={() => handleShare(notebook)}
|
|
onDuplicate={() => handleDuplicate(notebook)}
|
|
/>
|
|
</motion.div>
|
|
))}
|
|
</AnimatePresence>
|
|
</motion.div>
|
|
)}
|
|
</div>
|
|
|
|
{/* Shared with me section */}
|
|
<div>
|
|
<div className="flex items-center gap-2 mb-4">
|
|
<Share2 className="w-5 h-5 text-amber-600 dark:text-amber-400" />
|
|
<h2 className="text-lg font-semibold text-gray-900 dark:text-gray-100">
|
|
{t('notebooks.shared_with_me')}
|
|
</h2>
|
|
<Badge variant="secondary" className="text-xs">{sharedNotebooks.length}</Badge>
|
|
</div>
|
|
{sharedNotebooks.length === 0 ? (
|
|
<div className="flex flex-col items-center justify-center py-8 gap-3">
|
|
<Share2 className="w-10 h-10 text-gray-300 dark:text-gray-600" />
|
|
<p className="text-gray-400 dark:text-gray-500 text-sm">{t('notebooks.no_notebooks')}</p>
|
|
</div>
|
|
) : (
|
|
<motion.div
|
|
initial={{ opacity: 0 }}
|
|
animate={{ opacity: 1 }}
|
|
transition={{ delay: 0.1 }}
|
|
className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-4 sm:gap-6"
|
|
>
|
|
<AnimatePresence mode="popLayout">
|
|
{sharedNotebooks.map((notebook, idx) => (
|
|
<motion.div
|
|
key={notebook.id}
|
|
initial={{ opacity: 0, y: 20 }}
|
|
animate={{ opacity: 1, y: 0 }}
|
|
exit={{ opacity: 0, scale: 0.9 }}
|
|
transition={{ delay: idx * 0.05 }}
|
|
>
|
|
<NotebookCard
|
|
notebook={notebook}
|
|
subjectName={notebook.subjectId ? subjectNameMap[notebook.subjectId] ?? null : null}
|
|
onOpen={() => handleOpenNotebook(notebook)}
|
|
onArchive={() => handleArchive(notebook)}
|
|
onDelete={() => handleDelete(notebook)}
|
|
isShared
|
|
/>
|
|
</motion.div>
|
|
))}
|
|
</AnimatePresence>
|
|
</motion.div>
|
|
)}
|
|
</div>
|
|
</div>
|
|
) : filteredNotebooks.length === 0 ? (
|
|
<motion.div
|
|
initial={{ opacity: 0, scale: 0.95 }}
|
|
animate={{ opacity: 1, scale: 1 }}
|
|
className="flex flex-col items-center justify-center py-20 gap-4"
|
|
>
|
|
<BookOpen className="w-16 h-16 text-gray-300 dark:text-gray-600" />
|
|
<p className="text-gray-500 dark:text-gray-400 text-lg">
|
|
{showArchived ? t('notebooks.no_archived') : t('notebooks.no_notebooks')}
|
|
</p>
|
|
{!showArchived && (
|
|
<Button
|
|
onClick={() => setCreateOpen(true)}
|
|
className="min-h-[44px] bg-emerald-600 hover:bg-emerald-700"
|
|
>
|
|
<Plus className="w-4 h-4 mr-2" />
|
|
{t('notebooks.no_notebooks_create')}
|
|
</Button>
|
|
)}
|
|
</motion.div>
|
|
) : (
|
|
<motion.div
|
|
initial={{ opacity: 0 }}
|
|
animate={{ opacity: 1 }}
|
|
transition={{ delay: 0.15 }}
|
|
className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-4 sm:gap-6"
|
|
>
|
|
<AnimatePresence mode="popLayout">
|
|
{filteredNotebooks.map((notebook, idx) => (
|
|
<motion.div
|
|
key={notebook.id}
|
|
initial={{ opacity: 0, y: 20 }}
|
|
animate={{ opacity: 1, y: 0 }}
|
|
exit={{ opacity: 0, scale: 0.9 }}
|
|
transition={{ delay: idx * 0.05 }}
|
|
>
|
|
<NotebookCard
|
|
notebook={notebook}
|
|
subjectName={notebook.subjectId ? subjectNameMap[notebook.subjectId] ?? null : null}
|
|
onOpen={() => handleOpenNotebook(notebook)}
|
|
onArchive={() => handleArchive(notebook)}
|
|
onDelete={() => handleDelete(notebook)}
|
|
onShare={() => handleShare(notebook)}
|
|
onDuplicate={() => handleDuplicate(notebook)}
|
|
/>
|
|
</motion.div>
|
|
))}
|
|
</AnimatePresence>
|
|
</motion.div>
|
|
)}
|
|
</div>
|
|
|
|
{/* Eco footer bar */}
|
|
<div className="bg-emerald-50 dark:bg-emerald-900/20 border-t border-emerald-200 dark:border-emerald-800 px-4 sm:px-6 py-3">
|
|
<div className="flex items-center gap-3 text-emerald-700 dark:text-emerald-300 text-sm">
|
|
<Leaf className="w-5 h-5" />
|
|
<span>
|
|
{t('notebooks.eco_total', {
|
|
count: notebooks.length,
|
|
pages: totalPaperSaved,
|
|
})}
|
|
</span>
|
|
<Separator orientation="vertical" className="h-4 bg-emerald-300 dark:bg-emerald-700" />
|
|
<span className="text-xs">{t('notebooks.eco_message')}</span>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Share Confirmation Dialog */}
|
|
<AlertDialog open={!!shareConfirmNotebook} onOpenChange={(open) => { if (!open) setShareConfirmNotebook(null); }}>
|
|
<AlertDialogContent>
|
|
<AlertDialogHeader>
|
|
<AlertDialogTitle className="flex items-center gap-2">
|
|
{shareConfirmNotebook?.isPublic ? <EyeOff className="w-5 h-5" /> : <Share2 className="w-5 h-5 text-emerald-500" />}
|
|
{shareConfirmNotebook?.isPublic ? t('notebooks.unshare_confirm') : t('notebooks.share_confirm')}
|
|
</AlertDialogTitle>
|
|
<AlertDialogDescription>
|
|
{shareConfirmNotebook?.isPublic ? t('notebooks.unshare_confirm_desc') : t('notebooks.share_confirm_desc')}
|
|
</AlertDialogDescription>
|
|
</AlertDialogHeader>
|
|
<AlertDialogFooter>
|
|
<AlertDialogCancel className="min-h-[44px]">{t('action.cancel')}</AlertDialogCancel>
|
|
<AlertDialogAction onClick={handleConfirmShare} className="min-h-[44px] bg-emerald-600 hover:bg-emerald-700">
|
|
{t('action.confirm')}
|
|
</AlertDialogAction>
|
|
</AlertDialogFooter>
|
|
</AlertDialogContent>
|
|
</AlertDialog>
|
|
|
|
{/* Create Dialog */}
|
|
<CreateNotebookDialog
|
|
open={createOpen}
|
|
onClose={() => setCreateOpen(false)}
|
|
subjects={subjects}
|
|
classes={classes}
|
|
onCreate={handleCreate}
|
|
/>
|
|
</div>
|
|
);
|
|
}
|