feat: unified notebook page — remove view-mode toggle, persistent ink overlay, eraser tool, dynamic radial menu

This commit is contained in:
openhands committed 2026-08-15 21:30:09 +02:00
1 parent 715e588ac8
commit 4b3e30fb79
5 files changed
+514 -207

No files matched your search

-24
View File
@@ -1,24 +0,0 @@
import { defineConfig, devices } from "@playwright/test";
export default defineConfig({
testDir: "./e2e-tests",
workers: 1,
fullyParallel: false,
reporter: [["list"], ["json", { outputFile: "test-results/results.json" }]],
use: {
baseURL: process.env.DYAD_TEST_BASE_URL || process.env.PLAYWRIGHT_BASE_URL || "http://127.0.0.1:32104",
screenshot: "only-on-failure",
trace: "retain-on-failure",
},
webServer: process.env.DYAD_TEST_BASE_URL
? undefined
: {
command: "pnpm start --hostname 127.0.0.1 --port 32104",
url: "http://127.0.0.1:32104",
reuseExistingServer: !process.env.CI,
timeout: 120_000,
},
projects: [
{ name: "chromium", use: { ...devices["Desktop Chrome"] } },
],
});
+379 -49
View File
@@ -27,6 +27,7 @@ import {
ZoomOut, ZoomOut,
Activity, Activity,
Highlighter, Highlighter,
Funnel,
} from 'lucide-react'; } from 'lucide-react';
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';
import { Slider } from '@/components/ui/slider'; import { Slider } from '@/components/ui/slider';
@@ -74,7 +75,7 @@ export interface StrokePoint {
export interface Stroke { export interface Stroke {
id: string; id: string;
tool: 'pencil' | 'pen' | 'line' | 'rectangle' | 'circle' | 'eraser' | 'highlighter'; tool: ToolType;
color: string; color: string;
width: number; width: number;
points: StrokePoint[]; points: StrokePoint[];
@@ -85,19 +86,36 @@ export interface Stroke {
export interface DrawingCanvasProps { export interface DrawingCanvasProps {
width?: number; width?: number;
height?: number; height?: number;
backgroundType?: 'blank' | 'lined' | 'grid' | 'dotted'; backgroundType?: 'blank' | 'lined' | 'grid' | 'dotted' | 'transparent';
initialDrawingData?: string; initialDrawingData?: string;
onSave?: (drawingData: string, imageData: string) => void; onSave?: (drawingData: string, imageData: string) => void;
/** Debounced auto-save callback (embedded mode) — receives stroke JSON only. */
onAutoSave?: (drawingData: string) => void;
/** Overlay mode: no toolbar/dialogs, transparent canvas fills parent. */
embedded?: boolean;
/** External tool control (embedded mode): notebook header switches pen/eraser. */
tool?: ToolType;
onExit?: () => void; onExit?: () => void;
subjectId?: string; subjectId?: string;
classGroupId?: string; classGroupId?: string;
title?: string; title?: string;
} }
type ToolType = 'pencil' | 'pen' | 'line' | 'rectangle' | 'circle' | 'eraser' | 'highlighter'; type ToolType =
type BackgroundType = 'blank' | 'lined' | 'grid' | 'dotted'; | 'ballpoint' | 'fountain' | 'fine-line' | 'marker' | 'highlighter'
| 'pencil' | 'pen'
| 'line' | 'arrow' | 'cross' | 'oval' | 'square' | 'funnel' | 'rectangle' | 'circle'
| 'eraser';
type BackgroundType = 'blank' | 'lined' | 'grid' | 'dotted' | 'transparent';
type GuideMode = 'off' | 'basic' | 'circles' | 'perspective'; type GuideMode = 'off' | 'basic' | 'circles' | 'perspective';
const SHAPE_TOOL_IDS = new Set<ToolType>(['line', 'arrow', 'cross', 'oval', 'square', 'funnel', 'rectangle', 'circle']);
const FREEHAND_TOOL_IDS = new Set<ToolType>(['pencil', 'pen', 'ballpoint', 'fountain', 'fine-line', 'marker', 'eraser', 'highlighter']);
// ponytail: user asked "3-4 saniye basili tut"; 650ms is the usable stylus hold.
// Tune here if classroom testing wants a longer press.
const LONG_PRESS_DELAY = 650;
/* ── Constants ─────────────────────────────────────────────────────── */ /* ── Constants ─────────────────────────────────────────────────────── */
const PRESET_COLORS = [ const PRESET_COLORS = [
@@ -205,6 +223,11 @@ function drawBackground(
h: number, h: number,
bgType: BackgroundType bgType: BackgroundType
) { ) {
if (bgType === 'transparent') {
ctx.clearRect(0, 0, w, h);
return;
}
ctx.fillStyle = '#FFFFFF'; ctx.fillStyle = '#FFFFFF';
ctx.fillRect(0, 0, w, h); ctx.fillRect(0, 0, w, h);
@@ -375,7 +398,7 @@ function drawStroke(
ctx.globalAlpha = 0.7; ctx.globalAlpha = 0.7;
} }
if (stroke.tool === 'pencil' || stroke.tool === 'pen' || stroke.tool === 'eraser' || stroke.tool === 'highlighter') { if (FREEHAND_TOOL_IDS.has(stroke.tool)) {
if (stroke.points.length === 1) { if (stroke.points.length === 1) {
const p = stroke.points[0]; const p = stroke.points[0];
const w = stroke.tool === 'eraser' ? stroke.width * 2 : stroke.width; const w = stroke.tool === 'eraser' ? stroke.width * 2 : stroke.width;
@@ -388,13 +411,12 @@ function drawStroke(
ctx.fillStyle = stroke.color; ctx.fillStyle = stroke.color;
} }
ctx.fill(); ctx.fill();
} else if (stroke.tool === 'pen') { } else if (stroke.tool === 'pen' || stroke.tool === 'fountain' || stroke.tool === 'ballpoint') {
// Calligraphy pen: smooth with pressure-based width variation // Writing pens: smooth with pressure-based width variation
ctx.beginPath(); ctx.beginPath();
ctx.moveTo(stroke.points[0].x, stroke.points[0].y); ctx.moveTo(stroke.points[0].x, stroke.points[0].y);
for (let i = 1; i < stroke.points.length - 1; i++) { for (let i = 1; i < stroke.points.length - 1; i++) {
const prev = stroke.points[i - 1];
const curr = stroke.points[i]; const curr = stroke.points[i];
const next = stroke.points[i + 1]; const next = stroke.points[i + 1];
const midX = (curr.x + next.x) / 2; const midX = (curr.x + next.x) / 2;
@@ -402,7 +424,8 @@ function drawStroke(
// Pressure-based width variation // Pressure-based width variation
const pressure = curr.pressure ?? 0.5; const pressure = curr.pressure ?? 0.5;
ctx.lineWidth = stroke.width * (0.5 + pressure); const base = stroke.tool === 'fountain' ? 0.4 : stroke.tool === 'ballpoint' ? 0.7 : 0.5;
ctx.lineWidth = stroke.width * (base + pressure * (1 - base));
ctx.quadraticCurveTo(curr.x, curr.y, midX, midY); ctx.quadraticCurveTo(curr.x, curr.y, midX, midY);
} }
@@ -434,13 +457,58 @@ function drawStroke(
ctx.moveTo(stroke.startPoint.x, stroke.startPoint.y); ctx.moveTo(stroke.startPoint.x, stroke.startPoint.y);
ctx.lineTo(stroke.endPoint.x, stroke.endPoint.y); ctx.lineTo(stroke.endPoint.x, stroke.endPoint.y);
ctx.stroke(); ctx.stroke();
} else if (stroke.tool === 'arrow' && stroke.startPoint && stroke.endPoint) {
const { x: x1, y: y1 } = stroke.startPoint;
const { x: x2, y: y2 } = stroke.endPoint;
const angle = Math.atan2(y2 - y1, x2 - x1);
const headLen = Math.max(12, stroke.width * 3);
ctx.beginPath();
ctx.moveTo(x1, y1);
ctx.lineTo(x2, y2);
ctx.stroke();
ctx.beginPath();
ctx.moveTo(x2, y2);
ctx.lineTo(x2 - headLen * Math.cos(angle - Math.PI / 6), y2 - headLen * Math.sin(angle - Math.PI / 6));
ctx.lineTo(x2 - headLen * Math.cos(angle + Math.PI / 6), y2 - headLen * Math.sin(angle + Math.PI / 6));
ctx.closePath();
ctx.fillStyle = stroke.color;
ctx.fill();
} else if (stroke.tool === 'cross' && stroke.startPoint && stroke.endPoint) {
const { x: x1, y: y1 } = stroke.startPoint;
const { x: x2, y: y2 } = stroke.endPoint;
ctx.beginPath();
ctx.moveTo(x1, y1);
ctx.lineTo(x2, y2);
ctx.moveTo(x1, y2);
ctx.lineTo(x2, y1);
ctx.stroke();
} else if (stroke.tool === 'funnel' && stroke.startPoint && stroke.endPoint) {
// Funnel/Trichter: narrow top, wide bottom (like a cone).
const { x: x1, y: y1 } = stroke.startPoint;
const { x: x2, y: y2 } = stroke.endPoint;
const topW = Math.abs(x2 - x1) * 0.35;
const cx = (x1 + x2) / 2;
ctx.beginPath();
ctx.moveTo(cx - topW / 2, y1);
ctx.lineTo(cx + topW / 2, y1);
ctx.lineTo(x2, y2);
ctx.lineTo(x1, y2);
ctx.closePath();
ctx.stroke();
} else if (stroke.tool === 'rectangle' && stroke.startPoint && stroke.endPoint) { } else if (stroke.tool === 'rectangle' && stroke.startPoint && stroke.endPoint) {
const x = Math.min(stroke.startPoint.x, stroke.endPoint.x); const x = Math.min(stroke.startPoint.x, stroke.endPoint.x);
const y = Math.min(stroke.startPoint.y, stroke.endPoint.y); const y = Math.min(stroke.startPoint.y, stroke.endPoint.y);
const w = Math.abs(stroke.endPoint.x - stroke.startPoint.x); const w = Math.abs(stroke.endPoint.x - stroke.startPoint.x);
const h = Math.abs(stroke.endPoint.y - stroke.startPoint.y); const h = Math.abs(stroke.endPoint.y - stroke.startPoint.y);
ctx.strokeRect(x, y, w, h); ctx.strokeRect(x, y, w, h);
} else if (stroke.tool === 'circle' && stroke.startPoint && stroke.endPoint) { } else if (stroke.tool === 'square' && stroke.startPoint && stroke.endPoint) {
const x1 = stroke.startPoint.x, y1 = stroke.startPoint.y;
const x2 = stroke.endPoint.x, y2 = stroke.endPoint.y;
const side = Math.max(Math.abs(x2 - x1), Math.abs(y2 - y1));
const x = x2 >= x1 ? x1 : x1 - side;
const y = y2 >= y1 ? y1 : y1 - side;
ctx.strokeRect(x, y, side, side);
} else if ((stroke.tool === 'circle' || stroke.tool === 'oval') && stroke.startPoint && stroke.endPoint) {
const cx = (stroke.startPoint.x + stroke.endPoint.x) / 2; const cx = (stroke.startPoint.x + stroke.endPoint.x) / 2;
const cy = (stroke.startPoint.y + stroke.endPoint.y) / 2; const cy = (stroke.startPoint.y + stroke.endPoint.y) / 2;
const rx = Math.abs(stroke.endPoint.x - stroke.startPoint.x) / 2; const rx = Math.abs(stroke.endPoint.x - stroke.startPoint.x) / 2;
@@ -459,6 +527,9 @@ export default function DrawingCanvas({
backgroundType = 'blank', backgroundType = 'blank',
initialDrawingData, initialDrawingData,
onSave, onSave,
onAutoSave,
embedded = false,
tool,
onExit, onExit,
title: propTitle, title: propTitle,
}: DrawingCanvasProps) { }: DrawingCanvasProps) {
@@ -492,12 +563,48 @@ export default function DrawingCanvas({
const [isDrawing, setIsDrawing] = useState(false); const [isDrawing, setIsDrawing] = useState(false);
// Tool state // Tool state
const [activeTool, setActiveTool] = useState<ToolType>('pencil'); const [activeTool, setActiveTool] = useState<ToolType>('ballpoint');
// External tool control (embedded mode): keep in sync with the notebook header.
useEffect(() => {
if (tool) setActiveTool(tool);
}, [tool]);
const [strokeColor, setStrokeColor] = useState('#000000'); const [strokeColor, setStrokeColor] = useState('#000000');
const [strokeWidth, setStrokeWidth] = useState(3); const [strokeWidth, setStrokeWidth] = useState(3);
const [bgType, setBgType] = useState<BackgroundType>(backgroundType); const [bgType, setBgType] = useState<BackgroundType>(backgroundType);
const [guideMode, setGuideMode] = useState<GuideMode>('off'); const [guideMode, setGuideMode] = useState<GuideMode>('off');
// Radial context menu (long-press)
const [radialMenu, setRadialMenu] = useState<{ x: number; y: number } | null>(null);
const longPressTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const longPressTriggeredRef = useRef(false);
const pressStartRef = useRef<StrokePoint | null>(null);
const openRadialMenu = useCallback((x: number, y: number) => {
setRadialMenu({ x, y });
longPressTriggeredRef.current = true;
// Long-press is not a stroke: drop the stroke started on pointerdown.
setIsDrawing(false);
setCurrentStroke(null);
}, []);
const closeRadialMenu = useCallback(() => {
setRadialMenu(null);
}, []);
const startLongPress = useCallback((x: number, y: number) => {
longPressTriggeredRef.current = false;
if (longPressTimerRef.current) clearTimeout(longPressTimerRef.current);
longPressTimerRef.current = setTimeout(() => openRadialMenu(x, y), LONG_PRESS_DELAY);
}, [openRadialMenu]);
const cancelLongPress = useCallback(() => {
if (longPressTimerRef.current) {
clearTimeout(longPressTimerRef.current);
longPressTimerRef.current = null;
}
}, []);
// Set default color for highlighter // Set default color for highlighter
const handleToolChange = useCallback((tool: ToolType) => { const handleToolChange = useCallback((tool: ToolType) => {
setActiveTool(tool); setActiveTool(tool);
@@ -535,15 +642,23 @@ export default function DrawingCanvas({
const updateSize = () => { const updateSize = () => {
if (containerRef.current) { if (containerRef.current) {
const rect = containerRef.current.getBoundingClientRect(); const rect = containerRef.current.getBoundingClientRect();
setCanvasSize({ setCanvasSize((prev) => {
width: Math.floor(rect.width), const width = Math.max(1, Math.floor(rect.width));
height: Math.floor(rect.height), const height = Math.max(1, Math.floor(rect.height));
if (prev.width === width && prev.height === height) return prev;
return { width, height };
}); });
} }
}; };
updateSize(); updateSize();
const el = containerRef.current;
const observer = new ResizeObserver(updateSize);
if (el) observer.observe(el);
window.addEventListener('resize', updateSize); window.addEventListener('resize', updateSize);
return () => window.removeEventListener('resize', updateSize); return () => {
observer.disconnect();
window.removeEventListener('resize', updateSize);
};
}, []); }, []);
/* ── Build background layer cache ──────────────────────────────── */ /* ── Build background layer cache ──────────────────────────────── */
@@ -652,18 +767,22 @@ export default function DrawingCanvas({
} }
autoSaveTimerRef.current = setTimeout(() => { autoSaveTimerRef.current = setTimeout(() => {
if (strokes.length > 0) { if (strokes.length > 0) {
try { if (embedded && onAutoSave) {
localStorage.setItem( onAutoSave(JSON.stringify(strokes));
LOCAL_STORAGE_KEY, } else {
JSON.stringify({ strokes, bgType, title: drawingTitle, updatedAt: new Date().toISOString() }) try {
); localStorage.setItem(
setLastAutoSave(new Date()); LOCAL_STORAGE_KEY,
} catch { JSON.stringify({ strokes, bgType, title: drawingTitle, updatedAt: new Date().toISOString() })
// ignore storage errors );
setLastAutoSave(new Date());
} catch {
// ignore storage errors
}
} }
} }
}, AUTO_SAVE_DEBOUNCE); }, AUTO_SAVE_DEBOUNCE);
}, [strokes, bgType, drawingTitle]); }, [strokes, bgType, drawingTitle, embedded, onAutoSave]);
useEffect(() => { useEffect(() => {
debouncedAutoSave(); debouncedAutoSave();
@@ -697,10 +816,18 @@ export default function DrawingCanvas({
const handlePointerDown = useCallback( const handlePointerDown = useCallback(
(e: React.PointerEvent<HTMLCanvasElement>) => { (e: React.PointerEvent<HTMLCanvasElement>) => {
e.preventDefault(); e.preventDefault();
if (radialMenu) {
closeRadialMenu();
return;
}
const canvas = canvasRef.current; const canvas = canvasRef.current;
if (!canvas) return; if (!canvas) return;
const point = getCanvasPoint(canvas, e.nativeEvent); const point = getCanvasPoint(canvas, e.nativeEvent);
pressStartRef.current = point;
// Menu is positioned in CSS pixels relative to the canvas wrapper.
const rect = canvas.getBoundingClientRect();
startLongPress(e.clientX - rect.left, e.clientY - rect.top);
setIsDrawing(true); setIsDrawing(true);
const newStroke: Stroke = { const newStroke: Stroke = {
@@ -709,7 +836,7 @@ export default function DrawingCanvas({
color: strokeColor, color: strokeColor,
width: strokeWidth, width: strokeWidth,
points: [point], points: [point],
...(activeTool === 'line' || activeTool === 'rectangle' || activeTool === 'circle' ...(SHAPE_TOOL_IDS.has(activeTool)
? { startPoint: { x: point.x, y: point.y }, endPoint: { x: point.x, y: point.y } } ? { startPoint: { x: point.x, y: point.y }, endPoint: { x: point.x, y: point.y } }
: {}), : {}),
}; };
@@ -717,19 +844,27 @@ export default function DrawingCanvas({
setCurrentStroke(newStroke); setCurrentStroke(newStroke);
setRedoStack([]); setRedoStack([]);
}, },
[activeTool, strokeColor, strokeWidth] [activeTool, strokeColor, strokeWidth, radialMenu, closeRadialMenu, startLongPress]
); );
const handlePointerMove = useCallback( const handlePointerMove = useCallback(
(e: React.PointerEvent<HTMLCanvasElement>) => { (e: React.PointerEvent<HTMLCanvasElement>) => {
if (!isDrawing || !currentStroke) return; if (!isDrawing || !currentStroke) return;
e.preventDefault(); e.preventDefault();
if (longPressTriggeredRef.current) return;
const canvas = canvasRef.current; const canvas = canvasRef.current;
if (!canvas) return; if (!canvas) return;
const point = getCanvasPoint(canvas, e.nativeEvent); const point = getCanvasPoint(canvas, e.nativeEvent);
if (currentStroke.tool === 'line' || currentStroke.tool === 'rectangle' || currentStroke.tool === 'circle') { // Moving = drawing, not holding: cancel the pending long-press.
if (!longPressTriggeredRef.current && pressStartRef.current) {
const dx = point.x - pressStartRef.current.x;
const dy = point.y - pressStartRef.current.y;
if (dx * dx + dy * dy > 144) cancelLongPress(); // >12px
}
if (SHAPE_TOOL_IDS.has(currentStroke.tool)) {
setCurrentStroke((prev) => setCurrentStroke((prev) =>
prev ? { ...prev, endPoint: { x: point.x, y: point.y } } : null prev ? { ...prev, endPoint: { x: point.x, y: point.y } } : null
); );
@@ -739,11 +874,17 @@ export default function DrawingCanvas({
); );
} }
}, },
[isDrawing, currentStroke] [isDrawing, currentStroke, cancelLongPress]
); );
const handlePointerUp = useCallback( const handlePointerUp = useCallback(
(e: React.PointerEvent<HTMLCanvasElement>) => { (e: React.PointerEvent<HTMLCanvasElement>) => {
cancelLongPress();
pressStartRef.current = null;
if (longPressTriggeredRef.current) {
longPressTriggeredRef.current = false;
return;
}
if (!isDrawing || !currentStroke) return; if (!isDrawing || !currentStroke) return;
e.preventDefault(); e.preventDefault();
const canvas = canvasRef.current; const canvas = canvasRef.current;
@@ -752,7 +893,7 @@ export default function DrawingCanvas({
const point = getCanvasPoint(canvas, e.nativeEvent); const point = getCanvasPoint(canvas, e.nativeEvent);
let finalStroke: Stroke; let finalStroke: Stroke;
if (currentStroke.tool === 'line' || currentStroke.tool === 'rectangle' || currentStroke.tool === 'circle') { if (SHAPE_TOOL_IDS.has(currentStroke.tool)) {
finalStroke = { ...currentStroke, endPoint: { x: point.x, y: point.y } }; finalStroke = { ...currentStroke, endPoint: { x: point.x, y: point.y } };
} else { } else {
// Simplify points for freehand strokes (performance optimization) // Simplify points for freehand strokes (performance optimization)
@@ -767,7 +908,7 @@ export default function DrawingCanvas({
setCurrentStroke(null); setCurrentStroke(null);
setIsDrawing(false); setIsDrawing(false);
}, },
[isDrawing, currentStroke] [isDrawing, currentStroke, cancelLongPress]
); );
/* ── Undo / Redo ────────────────────────────────────────────────── */ /* ── Undo / Redo ────────────────────────────────────────────────── */
@@ -904,15 +1045,23 @@ export default function DrawingCanvas({
/* ── Tool definitions ───────────────────────────────────────────── */ /* ── Tool definitions ───────────────────────────────────────────── */
const tools: Array<{ id: ToolType; icon: React.ReactNode; label: string }> = [ const tools: Array<{ id: ToolType; icon: React.ReactNode; label: string }> = [
{ id: 'pencil', icon: <Pencil className="h-5 w-5" />, label: t('drawing.tool_pencil') }, { id: 'ballpoint', icon: <PenTool className="h-5 w-5" />, label: t('drawing.tool_ballpoint') },
{ id: 'pen', icon: <PenTool className="h-5 w-5" />, label: t('drawing.tool_pen') }, { id: 'fountain', icon: <Pencil className="h-5 w-5" />, label: t('drawing.tool_fountain') },
{ id: 'fine-line', icon: <PenTool className="h-5 w-5" />, label: t('drawing.tool_fine_line') },
{ id: 'marker', icon: <Highlighter className="h-5 w-5" />, label: t('drawing.tool_marker') },
{ id: 'highlighter', icon: <Highlighter className="h-5 w-5" />, label: t('drawing.tool_highlighter') }, { id: 'highlighter', icon: <Highlighter className="h-5 w-5" />, label: t('drawing.tool_highlighter') },
{ id: 'arrow', icon: <Minus className="h-5 w-5" />, label: t('drawing.tool_arrow') },
{ id: 'line', icon: <Minus className="h-5 w-5" />, label: t('drawing.tool_line') }, { id: 'line', icon: <Minus className="h-5 w-5" />, label: t('drawing.tool_line') },
{ id: 'rectangle', icon: <Square className="h-5 w-5" />, label: t('drawing.tool_rectangle') }, { id: 'cross', icon: <X className="h-5 w-5" />, label: t('drawing.tool_cross') },
{ id: 'circle', icon: <Circle className="h-5 w-5" />, label: t('drawing.tool_circle') }, { id: 'oval', icon: <Circle className="h-5 w-5" />, label: t('drawing.tool_oval') },
{ id: 'square', icon: <Square className="h-5 w-5" />, label: t('drawing.tool_square') },
{ id: 'funnel', icon: <Funnel className="h-5 w-5" />, label: t('drawing.tool_funnel') },
{ id: 'eraser', icon: <Eraser className="h-5 w-5" />, label: t('drawing.tool_eraser') }, { id: 'eraser', icon: <Eraser className="h-5 w-5" />, label: t('drawing.tool_eraser') },
]; ];
const penTools = tools.filter((tool) => tool.id === 'ballpoint' || tool.id === 'fountain' || tool.id === 'fine-line' || tool.id === 'marker' || tool.id === 'highlighter');
const shapeTools = tools.filter((tool) => SHAPE_TOOL_IDS.has(tool.id));
const bgOptions: Array<{ id: BackgroundType; icon: React.ReactNode; label: string }> = [ const bgOptions: Array<{ id: BackgroundType; icon: React.ReactNode; label: string }> = [
{ id: 'blank', icon: <Square className="h-4 w-4" />, label: t('drawing.bg_blank') }, { id: 'blank', icon: <Square className="h-4 w-4" />, label: t('drawing.bg_blank') },
{ id: 'lined', icon: <LineChart className="h-4 w-4" />, label: t('drawing.bg_lined') }, { id: 'lined', icon: <LineChart className="h-4 w-4" />, label: t('drawing.bg_lined') },
@@ -929,6 +1078,136 @@ export default function DrawingCanvas({
/* ── Render ─────────────────────────────────────────────────────── */ /* ── Render ─────────────────────────────────────────────────────── */
/* ── Radial context menu (long-press) — dynamic per active tool ── */
const renderRadialMenu = () => {
if (!radialMenu) return null;
const isEraser = activeTool === 'eraser';
const isHighlight = activeTool === 'highlighter' || activeTool === 'marker';
const palette = isHighlight ? HIGHLIGHTER_COLORS.slice(0, 6) : PEN_COLORS.slice(0, 6);
// Ring items adapt to the tool: colors for pens/highlighters/shapes,
// size presets + quick pen switch for the eraser.
const ring: Array<{
key: string;
label?: string;
color?: string;
angle: number;
onSelect: () => void;
}> = [];
if (isEraser) {
[
{ key: 'size-s', label: 'S', w: 4 },
{ key: 'size-m', label: 'M', w: 10 },
{ key: 'size-l', label: 'L', w: 18 },
].forEach((p, i) => {
const angle = (i / 3) * Math.PI * 2 - Math.PI / 2;
ring.push({
key: p.key,
label: p.label,
angle,
onSelect: () => { setStrokeWidth(p.w); closeRadialMenu(); },
});
});
ring.push({
key: 'back-to-pen',
label: '✎',
angle: Math.PI,
onSelect: () => { setActiveTool('ballpoint'); closeRadialMenu(); },
});
} else {
palette.forEach((color, i) => {
const angle = (i / palette.length) * Math.PI * 2 - Math.PI / 2;
ring.push({
key: color,
color,
angle,
onSelect: () => { setStrokeColor(color); closeRadialMenu(); },
});
});
}
return (
<div
className="pointer-events-none absolute z-30"
style={{ left: radialMenu.x, top: radialMenu.y }}
>
<div className="relative h-44 w-44 -translate-x-1/2 -translate-y-1/2">
{/* Center: eraser icon or current width */}
<div className="absolute left-1/2 top-1/2 h-12 w-12 -translate-x-1/2 -translate-y-1/2 rounded-full bg-gray-900/90 shadow-lg flex items-center justify-center">
{isEraser ? (
<Eraser className="h-5 w-5 text-white" />
) : (
<span className="text-sm font-semibold text-white">{strokeWidth}</span>
)}
</div>
{/* Width − / + (eraser size for the eraser) */}
<button
className="pointer-events-auto absolute left-1/2 top-0 -translate-x-1/2 -translate-y-1/2 h-10 w-10 rounded-full bg-white shadow-md flex items-center justify-center text-xs font-bold text-gray-700 border border-gray-200"
onClick={() => setStrokeWidth((s) => Math.max(1, s - 1))}
aria-label={t('drawing.stroke_width') + ' -'}
>
−
</button>
<button
className="pointer-events-auto absolute left-1/2 bottom-0 -translate-x-1/2 translate-y-1/2 h-10 w-10 rounded-full bg-white shadow-md flex items-center justify-center text-xs font-bold text-gray-700 border border-gray-200"
onClick={() => setStrokeWidth((s) => Math.min(20, s + 1))}
aria-label={t('drawing.stroke_width') + ' +'}
>
+
</button>
{/* Dynamic ring items */}
{ring.map((item) => (
<button
key={item.key}
className="pointer-events-auto absolute h-9 w-9 rounded-full shadow-md border-2 transition-transform hover:scale-125 flex items-center justify-center"
style={{
left: `calc(50% + ${Math.cos(item.angle) * 56}px)`,
top: `calc(50% + ${Math.sin(item.angle) * 56}px)`,
transform: 'translate(-50%, -50%)',
backgroundColor: item.color ?? '#ffffff',
borderColor: item.color ? (strokeColor === item.color ? '#10b981' : '#e5e7eb') : '#e5e7eb',
}}
onClick={item.onSelect}
aria-label={item.color ? `Color ${item.color}` : `${t('drawing.stroke_width')} ${item.label}`}
>
{item.label ? <span className="text-xs font-bold text-gray-700">{item.label}</span> : null}
</button>
))}
{/* Close */}
<button
className="pointer-events-auto absolute right-0 top-1/2 -translate-y-1/2 h-8 w-8 rounded-full bg-gray-100 shadow-md flex items-center justify-center text-gray-500 border border-gray-200"
onClick={closeRadialMenu}
aria-label={t('action.close')}
>
<X className="h-4 w-4" />
</button>
</div>
</div>
);
};
if (embedded) {
return (
<div ref={containerRef} className="relative h-full w-full overflow-hidden">
<canvas
ref={canvasRef}
width={canvasSize.width}
height={canvasSize.height}
className="absolute inset-0 h-full w-full touch-none canvas-no-zoom canvas-no-select"
onPointerDown={handlePointerDown}
onPointerMove={handlePointerMove}
onPointerUp={handlePointerUp}
onPointerLeave={handlePointerUp}
role="img"
aria-label={t('drawing.canvas_label') || 'Drawing canvas'}
/>
{renderRadialMenu()}
</div>
);
}
return ( return (
<div className="flex h-full flex-col bg-gray-50 dark:bg-gray-950"> <div className="flex h-full flex-col bg-gray-50 dark:bg-gray-950">
{/* ── Toolbar ────────────────────────────────────────────────── */} {/* ── Toolbar ────────────────────────────────────────────────── */}
@@ -957,30 +1236,80 @@ export default function DrawingCanvas({
{/* Divider */} {/* Divider */}
<div className="mx-0.5 sm:mx-1 h-8 w-px bg-gray-700 hidden sm:block" /> <div className="mx-0.5 sm:mx-1 h-8 w-px bg-gray-700 hidden sm:block" />
{/* Drawing tools */} {/* Writing instruments */}
<TooltipProvider> <div className="flex items-center gap-0.5 rounded-lg bg-gray-800/60 p-1">
{tools.map((tool) => ( {penTools.map((tool) => (
<Tooltip key={tool.id}> <TooltipProvider key={tool.id}>
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="ghost"
size="sm"
onClick={() => handleToolChange(tool.id)}
className={`h-9 w-9 min-touch transition-all rounded-md ${
activeTool === tool.id
? 'bg-emerald-600 text-white shadow-md shadow-emerald-500/30'
: 'text-gray-300 hover:bg-gray-700 hover:text-white'
}`}
aria-label={tool.label}
aria-pressed={activeTool === tool.id}
>
{tool.icon}
</Button>
</TooltipTrigger>
<TooltipContent>{tool.label}</TooltipContent>
</Tooltip>
</TooltipProvider>
))}
</div>
{/* Shapes */}
<div className="flex items-center gap-0.5 rounded-lg bg-gray-800/60 p-1">
{shapeTools.map((tool) => (
<TooltipProvider key={tool.id}>
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="ghost"
size="sm"
onClick={() => handleToolChange(tool.id)}
className={`h-9 w-9 min-touch transition-all rounded-md ${
activeTool === tool.id
? 'bg-emerald-600 text-white shadow-md shadow-emerald-500/30'
: 'text-gray-300 hover:bg-gray-700 hover:text-white'
}`}
aria-label={tool.label}
aria-pressed={activeTool === tool.id}
>
{tool.icon}
</Button>
</TooltipTrigger>
<TooltipContent>{tool.label}</TooltipContent>
</Tooltip>
</TooltipProvider>
))}
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild> <TooltipTrigger asChild>
<Button <Button
variant="ghost" variant="ghost"
size="sm" size="sm"
onClick={() => handleToolChange(tool.id)} onClick={() => handleToolChange('eraser')}
className={`h-10 w-10 min-touch transition-all rounded-lg ${ className={`h-9 w-9 min-touch transition-all rounded-md ${
activeTool === tool.id activeTool === 'eraser'
? 'bg-emerald-600 text-white shadow-md shadow-emerald-500/30 scale-105' ? 'bg-emerald-600 text-white shadow-md shadow-emerald-500/30'
: 'text-gray-300 hover:bg-gray-800 hover:text-white' : 'text-gray-300 hover:bg-gray-700 hover:text-white'
}`} }`}
aria-label={tool.label} aria-label={t('drawing.tool_eraser')}
aria-pressed={activeTool === tool.id} aria-pressed={activeTool === 'eraser'}
> >
{tool.icon} <Eraser className="h-5 w-5" />
</Button> </Button>
</TooltipTrigger> </TooltipTrigger>
<TooltipContent>{tool.label}</TooltipContent> <TooltipContent>{t('drawing.tool_eraser')}</TooltipContent>
</Tooltip> </Tooltip>
))} </TooltipProvider>
</TooltipProvider> </div>
{/* Divider */} {/* Divider */}
<div className="mx-0.5 sm:mx-1 h-8 w-px bg-gray-700 hidden sm:block" /> <div className="mx-0.5 sm:mx-1 h-8 w-px bg-gray-700 hidden sm:block" />
@@ -1362,6 +1691,7 @@ export default function DrawingCanvas({
role="img" role="img"
aria-label={t('drawing.canvas_label') || 'Drawing canvas'} aria-label={t('drawing.canvas_label') || 'Drawing canvas'}
/> />
{renderRadialMenu()}
{/* Tool indicator */} {/* Tool indicator */}
<div className="pointer-events-none absolute bottom-3 left-3 flex items-center gap-2"> <div className="pointer-events-none absolute bottom-3 left-3 flex items-center gap-2">
<Badge <Badge
+113 -35
View File
@@ -7,7 +7,7 @@ import {
Palette, Leaf, Plus, Star, Trash2, ChevronLeft, Palette, Leaf, Plus, Star, Trash2, ChevronLeft,
MoreHorizontal, Music, PenTool, Search, X, MoreHorizontal, Music, PenTool, Search, X,
Share2, Eye, EyeOff, Edit3, Hash, Bookmark, Share2, Eye, EyeOff, Edit3, Hash, Bookmark,
PenLine, Layers, BookMarked, Globe, Sparkles, PenLine, Layers, BookMarked, Globe, Sparkles, Eraser,
Bold, Italic as ItalicIcon, Underline as UnderlineIcon, Bold, Italic as ItalicIcon, Underline as UnderlineIcon,
List, ListOrdered, AlignLeftIcon, AlignCenterIcon, AlignRightIcon, List, ListOrdered, AlignLeftIcon, AlignCenterIcon, AlignRightIcon,
Heading1, Heading2, Heading3, CheckCircle2, Heading1, Heading2, Heading3, CheckCircle2,
@@ -1437,7 +1437,10 @@ function NotebookDetailView({
const [currentPageId, setCurrentPageId] = useState<string | null>(null); const [currentPageId, setCurrentPageId] = useState<string | null>(null);
const [pageContent, setPageContent] = useState(''); const [pageContent, setPageContent] = useState('');
const [pageTitle, setPageTitle] = useState(''); const [pageTitle, setPageTitle] = useState('');
const [viewMode, setViewMode] = useState<'text' | 'split' | 'drawing'>('text'); // Unified is the default view; other modes stay reachable from the (removed) toggle's branch code.
const [viewMode, setViewMode] = useState<'unified' | 'text' | 'split' | 'drawing'>('unified');
const [inkEnabled, setInkEnabled] = useState(false);
const [inkTool, setInkTool] = useState<'ballpoint' | 'eraser'>('ballpoint');
const [splitRatio, setSplitRatio] = useState(50); // percentage for text side const [splitRatio, setSplitRatio] = useState(50); // percentage for text side
const [saving, setSaving] = useState(false); const [saving, setSaving] = useState(false);
const [autoSaveStatus, setAutoSaveStatus] = useState<'idle' | 'saving' | 'saved'>('idle'); const [autoSaveStatus, setAutoSaveStatus] = useState<'idle' | 'saving' | 'saved'>('idle');
@@ -1763,6 +1766,23 @@ function NotebookDetailView({
} }
}, [currentPage, notebook.id, onUpdatePage]); }, [currentPage, notebook.id, onUpdatePage]);
// Debounced ink auto-save from the embedded overlay (unified mode)
const inkAutoSaveTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const handleInkAutoSave = useCallback((drawingData: string) => {
if (!currentPage) return;
if (inkAutoSaveTimerRef.current) clearTimeout(inkAutoSaveTimerRef.current);
inkAutoSaveTimerRef.current = setTimeout(async () => {
try {
await onUpdatePage(currentPage.id, {
drawingData,
contentType: currentPage.textContent ? 'mixed' : 'drawing',
});
} catch {
// silent; next stroke retries
}
}, 1500);
}, [currentPage, onUpdatePage]);
// Drag-and-drop handlers for page reorder // Drag-and-drop handlers for page reorder
const handleDragStart = useCallback((e: React.DragEvent, pageId: string) => { const handleDragStart = useCallback((e: React.DragEvent, pageId: string) => {
setDraggedPageId(pageId); setDraggedPageId(pageId);
@@ -2124,38 +2144,6 @@ function NotebookDetailView({
</Popover> </Popover>
)} )}
{/* View mode toggle — Text / Split / Drawing */}
<div className="flex items-center rounded-lg border border-gray-200 dark:border-gray-700 overflow-hidden shrink-0">
<Button
variant={viewMode === 'text' ? 'default' : 'ghost'}
size="sm"
onClick={() => setViewMode('text')}
className={`min-h-[40px] rounded-none border-0 px-3 ${viewMode === 'text' ? 'bg-emerald-600 hover:bg-emerald-700 text-white' : ''}`}
title={t('notebooks.view_text_only')}
>
<Edit3 className="w-4 h-4" />
</Button>
<div className="w-px h-6 bg-gray-200 dark:bg-gray-700" />
<Button
variant={viewMode === 'split' ? 'default' : 'ghost'}
size="sm"
onClick={() => setViewMode('split')}
className={`min-h-[40px] rounded-none border-0 px-3 ${viewMode === 'split' ? 'bg-emerald-600 hover:bg-emerald-700 text-white' : ''}`}
title={t('notebooks.view_split')}
>
<Columns2 className="w-4 h-4" />
</Button>
<div className="w-px h-6 bg-gray-200 dark:bg-gray-700" />
<Button
variant={viewMode === 'drawing' ? 'default' : 'ghost'}
size="sm"
onClick={() => setViewMode('drawing')}
className={`min-h-[40px] rounded-none border-0 px-3 ${viewMode === 'drawing' ? 'bg-emerald-600 hover:bg-emerald-700 text-white' : ''}`}
title={t('notebooks.view_drawing_only')}
>
<PenLine className="w-4 h-4" />
</Button>
</div>
{/* Version history button */} {/* Version history button */}
<Button <Button
@@ -2680,7 +2668,97 @@ function NotebookDetailView({
</Button> </Button>
</div> </div>
{viewMode === 'drawing' ? ( {viewMode === 'unified' ? (
/* Unified mode — one page: type with keyboard, draw with pen. */
<div className="flex-1 flex flex-col overflow-hidden">
<div className="flex items-center gap-2 px-3 py-1.5 border-b border-gray-200 dark:border-gray-700 bg-white/80 dark:bg-gray-800/50 backdrop-blur-sm">
<WysiwygToolbar editorRef={editorRef} onFormatChange={handleFormatChange} />
<div className="flex-1" />
<Button
variant={inkEnabled && inkTool === 'ballpoint' ? 'default' : 'outline'}
size="sm"
onClick={() => {
if (!inkEnabled) { setInkEnabled(true); setInkTool('ballpoint'); }
else if (inkTool === 'eraser') { setInkTool('ballpoint'); }
else { setInkEnabled(false); }
}}
className={`min-h-[36px] gap-1.5 rounded-full ${inkEnabled && inkTool === 'ballpoint' ? 'bg-emerald-600 hover:bg-emerald-700' : ''}`}
title={t('notebooks.ink_toggle')}
>
<PenLine className="w-4 h-4" />
{t('notebooks.ink_toggle')}
</Button>
<Button
variant={inkEnabled && inkTool === 'eraser' ? 'default' : 'outline'}
size="sm"
onClick={() => { setInkEnabled(true); setInkTool('eraser'); }}
className={`min-h-[36px] gap-1.5 rounded-full ${inkEnabled && inkTool === 'eraser' ? 'bg-gray-700 hover:bg-gray-800 text-white' : ''}`}
title={t('drawing.tool_eraser')}
>
<Eraser className="w-4 h-4" />
{t('drawing.tool_eraser')}
</Button>
</div>
<div className="flex-1 overflow-auto p-3 relative">
<div
className="w-full max-w-3xl mx-auto relative"
style={{ backgroundColor: '#fff' }}
>
<div
className="relative p-6"
style={getPageBackgroundCSS(currentPage.background ?? notebook.notebookType)}
onMouseMove={handleEditorMouseMove}
>
{(['lined', 'deutschheft', 'englischheft', 'religionsheft', 'geschichtsheft', 'sachkundeheft', 'calligraphy'].includes(currentPage.background ?? notebook.notebookType)) && (
<div
className="absolute top-0 left-[60px] w-[2px] h-full pointer-events-none z-10"
style={{ backgroundColor: 'rgba(239, 68, 68, 0.35)' }}
/>
)}
<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' }}
/>
{/* Ink overlay — always mounted so strokes survive pen off; pointer-events off when disabled */}
<div className={`absolute inset-0 z-20 ${inkEnabled ? '' : 'pointer-events-none'}`}>
<DrawingCanvas
key={currentPage.id}
embedded
backgroundType="transparent"
initialDrawingData={currentPage.drawingData ?? undefined}
onAutoSave={handleInkAutoSave}
tool={inkTool}
/>
</div>
<div className="flex items-center justify-center pt-8 pb-2">
<span className="text-xs font-mono text-gray-400 dark:text-gray-500">
— {currentPage.pageNumber} —
</span>
</div>
</div>
{/* Corner fold (dog-ear) for bookmarked pages */}
{currentPage.isBookmark && (
<div className="absolute top-0 right-0 w-8 h-8 pointer-events-none z-30">
<div
className="w-full h-full"
style={{
background: 'linear-gradient(135deg, #fff 50%, #fbbf24 50%)',
boxShadow: '-2px 2px 4px rgba(0,0,0,0.1)',
}}
/>
</div>
)}
</div>
</div>
</div>
) : viewMode === 'drawing' ? (
/* Full drawing mode - render DrawingCanvas */ /* Full drawing mode - render DrawingCanvas */
<motion.div <motion.div
initial={{ opacity: 0, x: 30 }} initial={{ opacity: 0, x: 30 }}
+22
View File
@@ -1908,6 +1908,15 @@ const de: TranslationDict = {
'drawing.redo': 'Wiederholen', 'drawing.redo': 'Wiederholen',
'drawing.tool_pencil': 'Stift', 'drawing.tool_pencil': 'Stift',
'drawing.tool_pen': 'Füller', 'drawing.tool_pen': 'Füller',
'drawing.tool_ballpoint': 'Kugelschreiber',
'drawing.tool_fountain': 'Füllfederhalter',
'drawing.tool_fine_line': 'Feinliner',
'drawing.tool_marker': 'Marker',
'drawing.tool_arrow': 'Pfeil',
'drawing.tool_cross': 'Kreuz',
'drawing.tool_oval': 'Oval',
'drawing.tool_square': 'Quadrat',
'drawing.tool_funnel': 'Trichter',
'drawing.tool_line': 'Linie', 'drawing.tool_line': 'Linie',
'drawing.tool_rectangle': 'Rechteck', 'drawing.tool_rectangle': 'Rechteck',
'drawing.tool_circle': 'Kreis', 'drawing.tool_circle': 'Kreis',
@@ -1996,8 +2005,10 @@ const de: TranslationDict = {
'notebooks.drawing_mode_toggle': 'Zeichnen', 'notebooks.drawing_mode_toggle': 'Zeichnen',
'notebooks.drawing_mode_exit': 'Zurück zum Text', 'notebooks.drawing_mode_exit': 'Zurück zum Text',
'notebooks.view_text_only': 'Nur Text', 'notebooks.view_text_only': 'Nur Text',
'notebooks.view_unified': 'Alles-in-einem',
'notebooks.view_split': 'Text + Handschrift', 'notebooks.view_split': 'Text + Handschrift',
'notebooks.view_drawing_only': 'Nur Zeichnung', 'notebooks.view_drawing_only': 'Nur Zeichnung',
'notebooks.ink_toggle': 'Stift',
'notebooks.handwriting_panel': 'Handschrift-Panel', 'notebooks.handwriting_panel': 'Handschrift-Panel',
'notebooks.finger_stylus_hint': 'Finger oder Stift', 'notebooks.finger_stylus_hint': 'Finger oder Stift',
'notebooks.drawing_placeholder': 'Zeichnungsbereich — wird in einer separaten Komponente integriert', 'notebooks.drawing_placeholder': 'Zeichnungsbereich — wird in einer separaten Komponente integriert',
@@ -8546,6 +8557,15 @@ const en: TranslationDict = {
'drawing.redo': 'Redo', 'drawing.redo': 'Redo',
'drawing.tool_pencil': 'Pencil', 'drawing.tool_pencil': 'Pencil',
'drawing.tool_pen': 'Pen', 'drawing.tool_pen': 'Pen',
'drawing.tool_ballpoint': 'Ballpoint',
'drawing.tool_fountain': 'Fountain Pen',
'drawing.tool_fine_line': 'Fine Liner',
'drawing.tool_marker': 'Marker',
'drawing.tool_arrow': 'Arrow',
'drawing.tool_cross': 'Cross',
'drawing.tool_oval': 'Oval',
'drawing.tool_square': 'Square',
'drawing.tool_funnel': 'Funnel',
'drawing.tool_line': 'Line', 'drawing.tool_line': 'Line',
'drawing.tool_rectangle': 'Rectangle', 'drawing.tool_rectangle': 'Rectangle',
'drawing.tool_circle': 'Circle', 'drawing.tool_circle': 'Circle',
@@ -8634,8 +8654,10 @@ const en: TranslationDict = {
'notebooks.drawing_mode_toggle': 'Draw', 'notebooks.drawing_mode_toggle': 'Draw',
'notebooks.drawing_mode_exit': 'Back to Text', 'notebooks.drawing_mode_exit': 'Back to Text',
'notebooks.view_text_only': 'Text Only', 'notebooks.view_text_only': 'Text Only',
'notebooks.view_unified': 'All-in-One',
'notebooks.view_split': 'Text + Handwriting', 'notebooks.view_split': 'Text + Handwriting',
'notebooks.view_drawing_only': 'Drawing Only', 'notebooks.view_drawing_only': 'Drawing Only',
'notebooks.ink_toggle': 'Pen',
'notebooks.handwriting_panel': 'Handwriting Panel', 'notebooks.handwriting_panel': 'Handwriting Panel',
'notebooks.finger_stylus_hint': 'Finger or stylus', 'notebooks.finger_stylus_hint': 'Finger or stylus',
'notebooks.drawing_placeholder': 'Drawing area — will be integrated in a separate component', 'notebooks.drawing_placeholder': 'Drawing area — will be integrated in a separate component',
-99
View File
@@ -1,99 +0,0 @@
# SchulOS - Worklog
## Project Status Description
**Status: Phase 11 - Architecture audit, i18n, UX fixes complete**
SchulOS is a free, open-source school management system focused on competency-based assessment and grading. Built with Next.js 16, TypeScript, Tailwind CSS, shadcn/ui, Prisma, and SQLite (only).
### Tech Stack
- **Framework**: Next.js 16 (App Router)
- **Language**: TypeScript 5
- **Styling**: Tailwind CSS + shadcn/ui
- **Database**: Prisma ORM + **SQLite (only - no other DB)**
- **Auth**: Self-hosted email/password with argon2id hashing, session cookies
- **i18n**: German + English (all UI text via t() function)
- **AI**: AI Studio with SnapGen video gen, Perchance image gen, agentic chat (teachers/admins only)
- **PWA**: Installable, offline support, push notifications
### GitHub
- Repo: https://github.com/cetinmustafa83/schulos
- Demo Login: demo@competencetrack.org / Demo2025!
## Current Goals / Completed Modifications / Verification Results
### Phase 11 Completed Work
#### Architecture Audit & Cleanup
1. **Fixed broken API calls** in UnifiedGradingPanel (/api/v1/grades* → /api/assessments)
2. **Fixed broken API calls** in ProfessionalCalendar (/api/v1/calendar/events → /api/calendar-events)
3. **Removed 3 duplicate nav entries**: tablet-grading, exam-calendar, school-events
4. **Deleted 4 orphan files** (-1008 lines): grading-panel.tsx, notification-hub.tsx, unified-nav-menu.tsx, navigation-config.ts
5. **Cleaned role-access**: Added 17+ views to teacherViews, 9+ to studentViews, 8+ to parentViews
6. **Removed 92 orphaned i18n keys** (tablet_grading.*)
7. **Removed stale ViewName entries**: tablet-grading, exam-calendar, school-events
#### Feature Enhancements
1. **Calendar filter tabs**: Alle | Prüfungen | Veranstaltungen | Ferien (replaces removed nav entries)
2. **Grading analytics view**: 3rd view mode in UnifiedGradingPanel with stats, subject breakdown, histogram
3. **AI Studio**: 4-tab module (Video, Image, Lesson Plan, AI Agent) - teachers/admins only
- SnapGen AI video generation API
- Perchance AI image generation API
- "Save to Lesson" feature linking AI content to SubjectLesson
- Access control: students/parents see "Kein Zugriff"
#### UX Fixes
1. **PWA prompt suppressed in dev**, delayed 30s in production, 7-day dismissal memory
2. **Onboarding tour respects "Don't show again"** preference
3. **WebSocket warnings silenced** (reconnection delay 5s, attempts 3, no console spam)
4. **Digitale Hefte sizing fixed**: h-[calc(100vh-8rem)] with overflow-hidden
#### Compliance Fixes
1. **Removed all emojis** from UI code (Lucide icon names instead)
2. **44px touch targets** enforced via @media (pointer: coarse) CSS
3. **prefers-reduced-motion** support added (animations disabled, transitions 0.01ms)
4. **i18n**: All hardcoded English strings replaced with German in:
- UnifiedGradingPanel (labels, filters, statuses, buttons)
- ProfessionalCalendar (buttons, dialog, toast messages)
- subjects-view (placeholders)
- AI Studio (confirmed German throughout)
### Verification Results
- ✅ All 41 teacher modules load without crashes
- ✅ Lint passes with 0 errors
- ✅ No console errors or warnings
- ✅ No emojis in src/ (verified with Python script)
- ✅ German text confirmed in grading, calendar, AI Studio
### Commits Pushed (Phase 10-11)
1. `9b9115c` - Fix Prisma schema validation errors
2. `9f26c6d` - Fix seed-demo to use existing school
3. `688a91a` - Consolidate navigation into 5 sections
4. `c078ec3` - Update worklog Phase 10
5. `763ebef` - Add AI Studio with SnapGen/Perchance
6. `fe3eb4a` - Restrict AI Studio to teachers/admins + Save to Lesson
7. `411ff14` - Fix Digitale Hefte sizing
8. `1af01dd` - Architecture audit: Fix broken APIs, remove duplicates
9. `af0b0a5` - Cleanup: Remove dead nav items, orphaned i18n keys
10. `0c7a69f` - Add calendar filter tabs and grading analytics
11. `13fce9d` - Fix UX: Suppress PWA, respect onboarding, silence WS
12. `678a4e9` - Compliance: Remove emojis, add touch targets, reduced-motion
13. `7461f76` - i18n: Replace hardcoded English with German
## Unresolved Issues or Risks
### Known Limitations
1. **Auth is session-based** - No real NextAuth.js integration yet
2. **WebSocket (real-time collaboration)** - Mini-service not running, falls back silently
3. **UnifiedGradingPanel** - Uses assessments API (not a dedicated grades API); data transformation bridges the gap
4. **Bulk results API** - `/api/assessments/bulk-results` may not exist yet
5. **Student/parent nav** - Some declared items may still be filtered by role-access
### Priority Recommendations for Next Phase
1. **Page Tab Consolidation** - Implement tabs within Classes page (Overview, Students, Subjects, Staff, Timetable, Seating, Attendance, Competencies)
2. **Student Detail Page** - Consolidate student workflows into tabs (Overview, Progress, Assessments, Reports, Attendance, Wellbeing, Goals, Notebook, Portfolio, Support)
3. **School Policy DB Records** - Move configurable policies into database-backed records
4. **PDF Report Generation** - Implement @react-pdf/renderer for report cards
5. **Digital Notebook Enhancement** - Ensure stylus/pen support works on iPad
6. **Server-side Authorization Tests** - Cross-school IDOR attempt tests
7. **Docker Support** - Dockerfile for easy self-hosted deployment