Compare commits
19 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 997252c4cb | |||
| 890d5dc2b7 | |||
| 759113b9ce | |||
| f981904e7f | |||
| 1fe196b839 | |||
| 1b4cf63e07 | |||
| 18197a9f95 | |||
| 383245ec90 | |||
| 5e59ee22f6 | |||
| be6f5b9c6f | |||
| 2243c646ac | |||
| 3df7cc01ea | |||
| f95858c703 | |||
| d29e2c1174 | |||
| 11a9c8b173 | |||
| d623a59c87 | |||
| 2a2e334f51 | |||
| 9228cc635c | |||
| 7056bfdd1c |
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@chat-app/desktop",
|
||||
"version": "0.19.0",
|
||||
"version": "0.19.1",
|
||||
"private": true,
|
||||
"description": "Electron desktop client (Windows / macOS / Linux)",
|
||||
"type": "module",
|
||||
|
||||
@@ -0,0 +1,468 @@
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
import { XIcon } from './icons';
|
||||
|
||||
export type AnnotatorTool = 'pen' | 'arrow' | 'rect' | 'circle' | 'text' | 'highlighter';
|
||||
export type AnnotatorColor = '#ef4444' | '#3b82f6' | '#22c55e' | '#facc15' | '#a855f7' | '#ffffff';
|
||||
export type AnnotatorWidth = 2 | 4 | 8;
|
||||
|
||||
export interface AnnotatorOp {
|
||||
tool: AnnotatorTool;
|
||||
color: AnnotatorColor;
|
||||
width: AnnotatorWidth;
|
||||
points?: Array<{ x: number; y: number }>;
|
||||
from?: { x: number; y: number };
|
||||
to?: { x: number; y: number };
|
||||
text?: string;
|
||||
at?: { x: number; y: number };
|
||||
}
|
||||
|
||||
interface Props {
|
||||
file: File;
|
||||
onCancel: () => void;
|
||||
onSave: (next: File) => void;
|
||||
}
|
||||
|
||||
export function ImageAnnotator({ file, onCancel, onSave }: Props) {
|
||||
const { t } = useTranslation();
|
||||
const canvasRef = useRef<HTMLCanvasElement | null>(null);
|
||||
const imageRef = useRef<HTMLImageElement | null>(null);
|
||||
const [imageLoaded, setImageLoaded] = useState(false);
|
||||
const [ops, setOps] = useState<AnnotatorOp[]>([]);
|
||||
const [redoStack, setRedoStack] = useState<AnnotatorOp[]>([]);
|
||||
// tool/color/width are read by Task 2 (drawing engine) and Task 3 (toolbar).
|
||||
// Defaults shown here are intentional so T2's pointer handlers work
|
||||
// immediately with sensible behavior before T3's UI is wired.
|
||||
const [tool, setTool] = useState<AnnotatorTool>('pen');
|
||||
const [color, setColor] = useState<AnnotatorColor>('#ef4444');
|
||||
const [width, setWidth] = useState<AnnotatorWidth>(4);
|
||||
|
||||
const draftRef = useRef<AnnotatorOp | null>(null);
|
||||
const [draftTick, setDraftTick] = useState(0);
|
||||
|
||||
useEffect(() => {
|
||||
const url = URL.createObjectURL(file);
|
||||
const img = new Image();
|
||||
img.onload = () => {
|
||||
imageRef.current = img;
|
||||
setImageLoaded(true);
|
||||
};
|
||||
img.onerror = () => {
|
||||
console.error('ImageAnnotator: failed to decode source image');
|
||||
onCancel();
|
||||
};
|
||||
img.src = url;
|
||||
return () => URL.revokeObjectURL(url);
|
||||
}, [file, onCancel]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!imageLoaded) return;
|
||||
const cv = canvasRef.current;
|
||||
const img = imageRef.current;
|
||||
if (!cv || !img) return;
|
||||
cv.width = img.naturalWidth;
|
||||
cv.height = img.naturalHeight;
|
||||
const ctx = cv.getContext('2d');
|
||||
if (!ctx) return;
|
||||
ctx.drawImage(img, 0, 0);
|
||||
for (const op of ops) {
|
||||
renderOp(ctx, op);
|
||||
}
|
||||
if (draftRef.current) {
|
||||
renderOp(ctx, draftRef.current);
|
||||
}
|
||||
}, [imageLoaded, ops, draftTick]);
|
||||
|
||||
useEffect(() => {
|
||||
const onKey = (e: KeyboardEvent) => {
|
||||
if (e.key === 'Escape') onCancel();
|
||||
if ((e.ctrlKey || e.metaKey) && e.key === 'z' && !e.shiftKey) {
|
||||
e.preventDefault();
|
||||
handleUndo();
|
||||
} else if ((e.ctrlKey || e.metaKey) && (e.key === 'y' || (e.key === 'z' && e.shiftKey))) {
|
||||
e.preventDefault();
|
||||
handleRedo();
|
||||
}
|
||||
};
|
||||
window.addEventListener('keydown', onKey);
|
||||
return () => window.removeEventListener('keydown', onKey);
|
||||
});
|
||||
|
||||
const handleUndo = () => {
|
||||
setOps((cur) => {
|
||||
if (cur.length === 0) return cur;
|
||||
const next = cur.slice(0, -1);
|
||||
setRedoStack((r) => [...r, cur[cur.length - 1]!]);
|
||||
return next;
|
||||
});
|
||||
};
|
||||
|
||||
const handleRedo = () => {
|
||||
setRedoStack((r) => {
|
||||
if (r.length === 0) return r;
|
||||
const top = r[r.length - 1]!;
|
||||
setOps((cur) => [...cur, top]);
|
||||
return r.slice(0, -1);
|
||||
});
|
||||
};
|
||||
|
||||
const handleReset = () => {
|
||||
setOps([]);
|
||||
setRedoStack([]);
|
||||
};
|
||||
|
||||
const handleSave = () => {
|
||||
const cv = canvasRef.current;
|
||||
if (!cv) return;
|
||||
cv.toBlob((blob) => {
|
||||
if (!blob) {
|
||||
console.error('ImageAnnotator: toBlob returned null');
|
||||
return;
|
||||
}
|
||||
const baseName = file.name.replace(/\.[^.]+$/, '');
|
||||
const next = new File([blob], baseName + '-annotated.png', {
|
||||
type: 'image/png',
|
||||
lastModified: Date.now(),
|
||||
});
|
||||
onSave(next);
|
||||
}, 'image/png');
|
||||
};
|
||||
|
||||
function canvasPoint(e: React.PointerEvent<HTMLCanvasElement>): { x: number; y: number } {
|
||||
const cv = canvasRef.current!;
|
||||
const rect = cv.getBoundingClientRect();
|
||||
const scaleX = cv.width / rect.width;
|
||||
const scaleY = cv.height / rect.height;
|
||||
return {
|
||||
x: (e.clientX - rect.left) * scaleX,
|
||||
y: (e.clientY - rect.top) * scaleY,
|
||||
};
|
||||
}
|
||||
|
||||
const handlePointerDown = (e: React.PointerEvent<HTMLCanvasElement>) => {
|
||||
if (!imageLoaded) return;
|
||||
const cv = canvasRef.current;
|
||||
if (!cv) return;
|
||||
cv.setPointerCapture(e.pointerId);
|
||||
const p = canvasPoint(e);
|
||||
|
||||
if (tool === 'text') {
|
||||
const value = window.prompt(
|
||||
t('app:annotator.text_prompt', { defaultValue: 'Text eingeben:' }),
|
||||
'',
|
||||
);
|
||||
if (value !== null && value.trim().length > 0) {
|
||||
setOps((cur) => [...cur, { tool: 'text', color, width, text: value, at: p }]);
|
||||
setRedoStack([]);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (tool === 'pen' || tool === 'highlighter') {
|
||||
draftRef.current = { tool, color, width, points: [p] };
|
||||
} else {
|
||||
draftRef.current = { tool, color, width, from: p, to: p };
|
||||
}
|
||||
setDraftTick((n) => n + 1);
|
||||
};
|
||||
|
||||
const handlePointerMove = (e: React.PointerEvent<HTMLCanvasElement>) => {
|
||||
if (!draftRef.current) return;
|
||||
const p = canvasPoint(e);
|
||||
const cur = draftRef.current;
|
||||
if (cur.tool === 'pen' || cur.tool === 'highlighter') {
|
||||
cur.points = [...(cur.points ?? []), p];
|
||||
} else {
|
||||
cur.to = p;
|
||||
}
|
||||
setDraftTick((n) => n + 1);
|
||||
};
|
||||
|
||||
const handlePointerUp = (e: React.PointerEvent<HTMLCanvasElement>) => {
|
||||
const cv = canvasRef.current;
|
||||
if (cv && cv.hasPointerCapture(e.pointerId)) cv.releasePointerCapture(e.pointerId);
|
||||
const cur = draftRef.current;
|
||||
draftRef.current = null;
|
||||
if (!cur) return;
|
||||
const hasContent =
|
||||
(cur.tool === 'pen' || cur.tool === 'highlighter')
|
||||
? (cur.points?.length ?? 0) >= 2
|
||||
: !!(cur.from && cur.to && (cur.from.x !== cur.to.x || cur.from.y !== cur.to.y));
|
||||
if (hasContent) {
|
||||
setOps((p) => [...p, cur]);
|
||||
setRedoStack([]);
|
||||
}
|
||||
setDraftTick((n) => n + 1);
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-label={t('app:annotator.title', { defaultValue: 'Bild bearbeiten' })}
|
||||
className="fixed inset-0 z-[80] flex flex-col bg-ink-950/95"
|
||||
>
|
||||
<header className="flex shrink-0 items-center justify-between border-b border-line/40 bg-surface-2 px-4 py-2">
|
||||
<h2 className="font-display text-sm font-semibold text-fg">
|
||||
{t('app:annotator.title', { defaultValue: 'Bild bearbeiten' })}
|
||||
</h2>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onCancel}
|
||||
aria-label={t('app:annotator.cancel', { defaultValue: 'Abbrechen' })}
|
||||
className="flex h-7 w-7 cursor-pointer items-center justify-center rounded-md text-fg-muted hover:bg-surface-3 hover:text-fg"
|
||||
>
|
||||
<XIcon className="h-3.5 w-3.5" />
|
||||
</button>
|
||||
</header>
|
||||
|
||||
<div className="flex min-h-0 flex-1 items-center justify-center overflow-hidden p-6">
|
||||
{imageLoaded ? (
|
||||
<canvas
|
||||
ref={canvasRef}
|
||||
onPointerDown={handlePointerDown}
|
||||
onPointerMove={handlePointerMove}
|
||||
onPointerUp={handlePointerUp}
|
||||
onPointerLeave={handlePointerUp}
|
||||
className="max-h-full max-w-full cursor-crosshair touch-none rounded-lg border border-line/40 bg-black shadow-2xl"
|
||||
/>
|
||||
) : (
|
||||
<p className="text-sm text-fg-muted">
|
||||
{t('app:annotator.loading', { defaultValue: 'Bild wird geladen…' })}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<footer className="flex shrink-0 flex-wrap items-center gap-4 border-t border-line/40 bg-surface-2 px-4 py-3">
|
||||
<div className="flex items-center gap-1">
|
||||
{(['pen', 'highlighter', 'arrow', 'rect', 'circle', 'text'] as AnnotatorTool[]).map((id) => {
|
||||
const label = t('app:annotator.tool.' + id, {
|
||||
defaultValue:
|
||||
id === 'pen' ? 'Stift'
|
||||
: id === 'highlighter' ? 'Marker'
|
||||
: id === 'arrow' ? 'Pfeil'
|
||||
: id === 'rect' ? 'Rechteck'
|
||||
: id === 'circle' ? 'Kreis'
|
||||
: 'Text',
|
||||
});
|
||||
const active = tool === id;
|
||||
return (
|
||||
<button
|
||||
key={id}
|
||||
type="button"
|
||||
onClick={() => setTool(id)}
|
||||
aria-pressed={active}
|
||||
title={label}
|
||||
className={
|
||||
'flex h-8 w-8 cursor-pointer items-center justify-center rounded-md text-xs font-semibold transition ' +
|
||||
(active
|
||||
? 'bg-accent/20 text-accent ring-2 ring-accent/40'
|
||||
: 'bg-surface-3 text-fg-muted hover:bg-surface hover:text-fg')
|
||||
}
|
||||
>
|
||||
{toolGlyph(id)}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
<div className="h-6 w-px bg-line/40" aria-hidden />
|
||||
|
||||
<div className="flex items-center gap-1">
|
||||
{(['#ef4444', '#3b82f6', '#22c55e', '#facc15', '#a855f7', '#ffffff'] as AnnotatorColor[]).map((c) => {
|
||||
const active = color === c;
|
||||
return (
|
||||
<button
|
||||
key={c}
|
||||
type="button"
|
||||
onClick={() => setColor(c)}
|
||||
aria-pressed={active}
|
||||
aria-label={c}
|
||||
className={
|
||||
'h-6 w-6 cursor-pointer rounded-full border-2 transition ' +
|
||||
(active ? 'border-fg scale-110' : 'border-line/40 hover:scale-105')
|
||||
}
|
||||
style={{ backgroundColor: c }}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
<div className="h-6 w-px bg-line/40" aria-hidden />
|
||||
|
||||
<div className="flex items-center gap-1">
|
||||
{([2, 4, 8] as AnnotatorWidth[]).map((w) => {
|
||||
const active = width === w;
|
||||
return (
|
||||
<button
|
||||
key={w}
|
||||
type="button"
|
||||
onClick={() => setWidth(w)}
|
||||
aria-pressed={active}
|
||||
title={w + 'px'}
|
||||
className={
|
||||
'flex h-8 w-8 cursor-pointer items-center justify-center rounded-md transition ' +
|
||||
(active
|
||||
? 'bg-accent/20 ring-2 ring-accent/40'
|
||||
: 'bg-surface-3 hover:bg-surface')
|
||||
}
|
||||
>
|
||||
<div
|
||||
className="rounded-full bg-fg"
|
||||
style={{ width: w + 2 + 'px', height: w + 2 + 'px' }}
|
||||
/>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
<div className="h-6 w-px bg-line/40" aria-hidden />
|
||||
|
||||
<div className="flex items-center gap-1">
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleUndo}
|
||||
disabled={ops.length === 0}
|
||||
title={t('app:annotator.undo', { defaultValue: 'Rückgängig (Ctrl+Z)' })}
|
||||
className="flex h-8 w-8 cursor-pointer items-center justify-center rounded-md bg-surface-3 text-fg-muted transition hover:bg-surface hover:text-fg disabled:cursor-not-allowed disabled:opacity-40"
|
||||
>
|
||||
↶
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleRedo}
|
||||
disabled={redoStack.length === 0}
|
||||
title={t('app:annotator.redo', { defaultValue: 'Wiederholen (Ctrl+Y)' })}
|
||||
className="flex h-8 w-8 cursor-pointer items-center justify-center rounded-md bg-surface-3 text-fg-muted transition hover:bg-surface hover:text-fg disabled:cursor-not-allowed disabled:opacity-40"
|
||||
>
|
||||
↷
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="ml-auto flex items-center gap-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleReset}
|
||||
disabled={ops.length === 0}
|
||||
className="cursor-pointer rounded-md border border-line bg-surface-3 px-3 py-1.5 text-xs font-medium text-fg-muted transition hover:bg-surface disabled:cursor-not-allowed disabled:opacity-50"
|
||||
>
|
||||
{t('app:annotator.reset', { defaultValue: 'Zurücksetzen' })}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleSave}
|
||||
disabled={!imageLoaded}
|
||||
className="cursor-pointer rounded-md bg-accent px-4 py-1.5 text-xs font-semibold text-accent-fg transition hover:bg-accent/90 disabled:cursor-not-allowed disabled:opacity-50"
|
||||
>
|
||||
{t('app:annotator.save', { defaultValue: 'Speichern' })}
|
||||
</button>
|
||||
</div>
|
||||
</footer>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function toolGlyph(t: AnnotatorTool): string {
|
||||
switch (t) {
|
||||
case 'pen': return '✎';
|
||||
case 'highlighter': return '🖍';
|
||||
case 'arrow': return '↗';
|
||||
case 'rect': return '▭';
|
||||
case 'circle': return '◯';
|
||||
case 'text': return 'T';
|
||||
}
|
||||
}
|
||||
|
||||
function renderOp(ctx: CanvasRenderingContext2D, op: AnnotatorOp): void {
|
||||
ctx.save();
|
||||
ctx.lineCap = 'round';
|
||||
ctx.lineJoin = 'round';
|
||||
ctx.strokeStyle = op.color;
|
||||
ctx.fillStyle = op.color;
|
||||
ctx.lineWidth = op.width;
|
||||
|
||||
switch (op.tool) {
|
||||
case 'pen': {
|
||||
const pts = op.points;
|
||||
if (!pts || pts.length < 1) break;
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(pts[0]!.x, pts[0]!.y);
|
||||
for (let i = 1; i < pts.length; i++) {
|
||||
ctx.lineTo(pts[i]!.x, pts[i]!.y);
|
||||
}
|
||||
ctx.stroke();
|
||||
break;
|
||||
}
|
||||
case 'highlighter': {
|
||||
const pts = op.points;
|
||||
if (!pts || pts.length < 1) break;
|
||||
ctx.globalAlpha = 0.35;
|
||||
ctx.lineWidth = op.width * 4;
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(pts[0]!.x, pts[0]!.y);
|
||||
for (let i = 1; i < pts.length; i++) {
|
||||
ctx.lineTo(pts[i]!.x, pts[i]!.y);
|
||||
}
|
||||
ctx.stroke();
|
||||
break;
|
||||
}
|
||||
case 'rect': {
|
||||
const { from, to } = op;
|
||||
if (!from || !to) break;
|
||||
ctx.strokeRect(
|
||||
Math.min(from.x, to.x),
|
||||
Math.min(from.y, to.y),
|
||||
Math.abs(to.x - from.x),
|
||||
Math.abs(to.y - from.y),
|
||||
);
|
||||
break;
|
||||
}
|
||||
case 'circle': {
|
||||
const { from, to } = op;
|
||||
if (!from || !to) break;
|
||||
const cx = (from.x + to.x) / 2;
|
||||
const cy = (from.y + to.y) / 2;
|
||||
const rx = Math.abs(to.x - from.x) / 2;
|
||||
const ry = Math.abs(to.y - from.y) / 2;
|
||||
ctx.beginPath();
|
||||
ctx.ellipse(cx, cy, rx, ry, 0, 0, Math.PI * 2);
|
||||
ctx.stroke();
|
||||
break;
|
||||
}
|
||||
case 'arrow': {
|
||||
const { from, to } = op;
|
||||
if (!from || !to) break;
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(from.x, from.y);
|
||||
ctx.lineTo(to.x, to.y);
|
||||
ctx.stroke();
|
||||
const angle = Math.atan2(to.y - from.y, to.x - from.x);
|
||||
const head = Math.max(12, op.width * 3);
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(to.x, to.y);
|
||||
ctx.lineTo(
|
||||
to.x - head * Math.cos(angle - Math.PI / 6),
|
||||
to.y - head * Math.sin(angle - Math.PI / 6),
|
||||
);
|
||||
ctx.moveTo(to.x, to.y);
|
||||
ctx.lineTo(
|
||||
to.x - head * Math.cos(angle + Math.PI / 6),
|
||||
to.y - head * Math.sin(angle + Math.PI / 6),
|
||||
);
|
||||
ctx.stroke();
|
||||
break;
|
||||
}
|
||||
case 'text': {
|
||||
const { at, text } = op;
|
||||
if (!at || !text) break;
|
||||
const fontSize = Math.max(14, op.width * 6);
|
||||
ctx.font = '600 ' + fontSize + 'px Inter, system-ui, sans-serif';
|
||||
ctx.textBaseline = 'top';
|
||||
ctx.fillText(text, at.x, at.y);
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
ctx.restore();
|
||||
}
|
||||
@@ -442,6 +442,32 @@ export function MessageBubble({
|
||||
defaultValue: 'Nachricht nicht lesbar',
|
||||
})}
|
||||
</span>
|
||||
) : parsed.kind === 'whiteboard' ? (
|
||||
<div className="flex items-center gap-3 rounded-2xl border border-accent/30 bg-accent/5 px-4 py-3">
|
||||
<div className="flex h-10 w-10 shrink-0 items-center justify-center rounded-lg bg-accent/20 text-accent">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={2}
|
||||
strokeLinecap="round" strokeLinejoin="round" className="h-5 w-5">
|
||||
<rect x="3" y="4" width="18" height="13" rx="2" />
|
||||
<path d="M8 21h8M12 17v4" />
|
||||
</svg>
|
||||
</div>
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="text-sm font-semibold text-fg">Whiteboard</div>
|
||||
<div className="text-xs text-fg-muted">Gemeinsames Zeichnen</div>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
window.dispatchEvent(
|
||||
new CustomEvent('chatapp:open-whiteboard', { detail: { id: parsed.whiteboardId } }),
|
||||
);
|
||||
}}
|
||||
disabled={!parsed.whiteboardId}
|
||||
className="cursor-pointer rounded-md bg-accent px-3 py-1.5 text-xs font-semibold text-accent-fg transition hover:bg-accent/90 disabled:cursor-not-allowed disabled:opacity-50"
|
||||
>
|
||||
Öffnen
|
||||
</button>
|
||||
</div>
|
||||
) : parsed.kind === 'poll' ? (
|
||||
<PollCard
|
||||
question={parsed.question}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
import { deleteSound as deleteRemoteSound } from '@chat-app/shared/chat';
|
||||
import { getPttSettings } from '../lib/pttSettings';
|
||||
import { codeToShortcut } from '../lib/globalShortcut';
|
||||
import { invalidate as invalidateSoundCache, preload } from '../lib/soundboardPlayback';
|
||||
@@ -15,6 +16,8 @@ import {
|
||||
subscribeSoundboardChanges,
|
||||
updateSound,
|
||||
} from '../lib/soundboardStorage';
|
||||
import { supabase } from '../lib/supabase';
|
||||
import { useSoundboardSync, type SyncBadge } from '../hooks/useSoundboardSync';
|
||||
import { Modal } from './Modal';
|
||||
import {
|
||||
AlertIcon,
|
||||
@@ -46,6 +49,7 @@ export function SoundboardManagerDialog({ open, onClose }: Props) {
|
||||
const [busyId, setBusyId] = useState<string | null>(null);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [previewingId, setPreviewingId] = useState<string | null>(null);
|
||||
const { badges } = useSoundboardSync();
|
||||
|
||||
const refresh = useCallback(async () => {
|
||||
setLoading(true);
|
||||
@@ -167,6 +171,11 @@ export function SoundboardManagerDialog({ open, onClose }: Props) {
|
||||
}
|
||||
setBusyId(id);
|
||||
try {
|
||||
try {
|
||||
await deleteRemoteSound(supabase, id);
|
||||
} catch (err) {
|
||||
console.warn('remote sound delete failed (local delete proceeds)', err);
|
||||
}
|
||||
await deleteSound(id);
|
||||
invalidateSoundCache(id);
|
||||
if (previewingId === id) stopPreview();
|
||||
@@ -301,6 +310,7 @@ export function SoundboardManagerDialog({ open, onClose }: Props) {
|
||||
entriesTotal={entries}
|
||||
busyId={busyId}
|
||||
previewingId={previewingId}
|
||||
badges={badges}
|
||||
onPatch={handlePatch}
|
||||
onDelete={handleDelete}
|
||||
onPreview={handlePreview}
|
||||
@@ -323,6 +333,7 @@ interface GroupProps {
|
||||
entriesTotal: SoundboardEntry[];
|
||||
busyId: string | null;
|
||||
previewingId: string | null;
|
||||
badges: Map<string, SyncBadge>;
|
||||
onPatch: (id: string, patch: Parameters<typeof updateSound>[1]) => Promise<void>;
|
||||
onDelete: (id: string) => Promise<void>;
|
||||
onPreview: (entry: SoundboardEntry) => Promise<void>;
|
||||
@@ -335,6 +346,7 @@ function SoundboardCategoryGroup({
|
||||
entriesTotal,
|
||||
busyId,
|
||||
previewingId,
|
||||
badges,
|
||||
onPatch,
|
||||
onDelete,
|
||||
onPreview,
|
||||
@@ -370,6 +382,7 @@ function SoundboardCategoryGroup({
|
||||
isLast={idx === entries.length - 1}
|
||||
busy={busyId === entry.id}
|
||||
previewing={previewingId === entry.id}
|
||||
badge={badges.get(entry.id)}
|
||||
onPatch={onPatch}
|
||||
onDelete={onDelete}
|
||||
onPreview={onPreview}
|
||||
@@ -392,6 +405,7 @@ interface RowProps {
|
||||
isLast: boolean;
|
||||
busy: boolean;
|
||||
previewing: boolean;
|
||||
badge: SyncBadge | undefined;
|
||||
onPatch: (id: string, patch: Parameters<typeof updateSound>[1]) => Promise<void>;
|
||||
onDelete: (id: string) => Promise<void>;
|
||||
onPreview: (entry: SoundboardEntry) => Promise<void>;
|
||||
@@ -406,6 +420,7 @@ function SoundboardRow({
|
||||
isLast,
|
||||
busy,
|
||||
previewing,
|
||||
badge,
|
||||
onPatch,
|
||||
onDelete,
|
||||
onPreview,
|
||||
@@ -503,6 +518,13 @@ function SoundboardRow({
|
||||
)}
|
||||
<p className="text-[10px] text-fg-muted">
|
||||
{(entry.size / BYTES_PER_MB).toFixed(2)} MB · {entry.mime}
|
||||
<span
|
||||
title={badgeTitle(badge)}
|
||||
aria-label={badgeTitle(badge)}
|
||||
className="ml-2 inline-flex items-center text-[10px] font-medium text-fg-muted"
|
||||
>
|
||||
{badgeGlyph(badge)}
|
||||
</span>
|
||||
</p>
|
||||
</div>
|
||||
|
||||
@@ -623,3 +645,25 @@ function SoundboardRow({
|
||||
</li>
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function badgeGlyph(b: SyncBadge | undefined): string {
|
||||
switch (b) {
|
||||
case 'uploading': return '↑';
|
||||
case 'downloading': return '↓';
|
||||
case 'error': return '⚠';
|
||||
case 'synced':
|
||||
default: return '☁';
|
||||
}
|
||||
}
|
||||
|
||||
function badgeTitle(b: SyncBadge | undefined): string {
|
||||
switch (b) {
|
||||
case 'uploading': return 'Hochladen…';
|
||||
case 'downloading': return 'Wird heruntergeladen…';
|
||||
case 'error': return 'Synchronisationsfehler';
|
||||
case 'synced':
|
||||
default: return 'Synchronisiert';
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,142 @@
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
|
||||
import type { WhiteboardStroke } from '@chat-app/shared/chat';
|
||||
|
||||
export type WhiteboardTool = 'pen' | 'eraser';
|
||||
export type WhiteboardColor = '#000000' | '#ef4444' | '#3b82f6' | '#22c55e' | '#facc15' | '#a855f7';
|
||||
export type WhiteboardWidth = 2 | 4 | 8;
|
||||
|
||||
export interface WhiteboardStrokePayload {
|
||||
tool: WhiteboardTool;
|
||||
color: WhiteboardColor;
|
||||
width: WhiteboardWidth;
|
||||
// [x, y, t-ms-since-stroke-start]
|
||||
points: Array<[number, number, number]>;
|
||||
}
|
||||
|
||||
interface Props {
|
||||
strokes: WhiteboardStroke[];
|
||||
tool: WhiteboardTool;
|
||||
color: WhiteboardColor;
|
||||
width: WhiteboardWidth;
|
||||
onStroke: (payload: WhiteboardStrokePayload) => void;
|
||||
logicalWidth?: number;
|
||||
logicalHeight?: number;
|
||||
}
|
||||
|
||||
const DEFAULT_LOGICAL_W = 1280;
|
||||
const DEFAULT_LOGICAL_H = 720;
|
||||
|
||||
export function WhiteboardCanvas({
|
||||
strokes,
|
||||
tool,
|
||||
color,
|
||||
width,
|
||||
onStroke,
|
||||
logicalWidth = DEFAULT_LOGICAL_W,
|
||||
logicalHeight = DEFAULT_LOGICAL_H,
|
||||
}: Props) {
|
||||
const canvasRef = useRef<HTMLCanvasElement | null>(null);
|
||||
const draftRef = useRef<WhiteboardStrokePayload | null>(null);
|
||||
const strokeStartRef = useRef<number>(0);
|
||||
const [, forceTick] = useState(0);
|
||||
|
||||
useEffect(() => {
|
||||
const cv = canvasRef.current;
|
||||
if (!cv) return;
|
||||
cv.width = logicalWidth;
|
||||
cv.height = logicalHeight;
|
||||
const ctx = cv.getContext('2d');
|
||||
if (!ctx) return;
|
||||
ctx.fillStyle = '#ffffff';
|
||||
ctx.fillRect(0, 0, cv.width, cv.height);
|
||||
for (const s of strokes) {
|
||||
const payload = s.strokeJson as Partial<WhiteboardStrokePayload> | null;
|
||||
if (payload) renderStroke(ctx, payload);
|
||||
}
|
||||
if (draftRef.current) renderStroke(ctx, draftRef.current);
|
||||
});
|
||||
|
||||
function canvasPoint(e: React.PointerEvent<HTMLCanvasElement>): [number, number] {
|
||||
const cv = canvasRef.current!;
|
||||
const rect = cv.getBoundingClientRect();
|
||||
const scaleX = cv.width / rect.width;
|
||||
const scaleY = cv.height / rect.height;
|
||||
return [(e.clientX - rect.left) * scaleX, (e.clientY - rect.top) * scaleY];
|
||||
}
|
||||
|
||||
const handlePointerDown = (e: React.PointerEvent<HTMLCanvasElement>) => {
|
||||
const cv = canvasRef.current;
|
||||
if (!cv) return;
|
||||
cv.setPointerCapture(e.pointerId);
|
||||
const [x, y] = canvasPoint(e);
|
||||
strokeStartRef.current = Date.now();
|
||||
draftRef.current = {
|
||||
tool,
|
||||
color,
|
||||
width,
|
||||
points: [[x, y, 0]],
|
||||
};
|
||||
forceTick((n) => n + 1);
|
||||
};
|
||||
|
||||
const handlePointerMove = (e: React.PointerEvent<HTMLCanvasElement>) => {
|
||||
if (!draftRef.current) return;
|
||||
const [x, y] = canvasPoint(e);
|
||||
draftRef.current.points.push([x, y, Date.now() - strokeStartRef.current]);
|
||||
forceTick((n) => n + 1);
|
||||
};
|
||||
|
||||
const handlePointerUp = (e: React.PointerEvent<HTMLCanvasElement>) => {
|
||||
const cv = canvasRef.current;
|
||||
if (cv && cv.hasPointerCapture(e.pointerId)) cv.releasePointerCapture(e.pointerId);
|
||||
const draft = draftRef.current;
|
||||
draftRef.current = null;
|
||||
if (!draft) return;
|
||||
if (draft.points.length < 2) {
|
||||
forceTick((n) => n + 1);
|
||||
return;
|
||||
}
|
||||
onStroke(draft);
|
||||
forceTick((n) => n + 1);
|
||||
};
|
||||
|
||||
return (
|
||||
<canvas
|
||||
ref={canvasRef}
|
||||
onPointerDown={handlePointerDown}
|
||||
onPointerMove={handlePointerMove}
|
||||
onPointerUp={handlePointerUp}
|
||||
onPointerLeave={handlePointerUp}
|
||||
className="block max-h-full max-w-full cursor-crosshair touch-none rounded-lg border border-line/40 bg-white shadow-2xl"
|
||||
style={{ aspectRatio: logicalWidth + ' / ' + logicalHeight, width: '100%' }}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function renderStroke(
|
||||
ctx: CanvasRenderingContext2D,
|
||||
s: Partial<WhiteboardStrokePayload>,
|
||||
): void {
|
||||
const points = Array.isArray(s.points) ? s.points : null;
|
||||
if (!points || points.length < 1) return;
|
||||
|
||||
ctx.save();
|
||||
ctx.lineCap = 'round';
|
||||
ctx.lineJoin = 'round';
|
||||
ctx.lineWidth = typeof s.width === 'number' ? s.width : 4;
|
||||
if (s.tool === 'eraser') {
|
||||
ctx.strokeStyle = '#ffffff';
|
||||
ctx.lineWidth = Math.max(8, (typeof s.width === 'number' ? s.width : 4) * 4);
|
||||
} else {
|
||||
ctx.strokeStyle = typeof s.color === 'string' ? s.color : '#000000';
|
||||
}
|
||||
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(points[0]![0], points[0]![1]);
|
||||
for (let i = 1; i < points.length; i++) {
|
||||
ctx.lineTo(points[i]![0], points[i]![1]);
|
||||
}
|
||||
ctx.stroke();
|
||||
ctx.restore();
|
||||
}
|
||||
@@ -0,0 +1,197 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
import { useWhiteboardStrokes } from '../hooks/useWhiteboardStrokes';
|
||||
import { XIcon } from './icons';
|
||||
import {
|
||||
WhiteboardCanvas,
|
||||
type WhiteboardColor,
|
||||
type WhiteboardTool,
|
||||
type WhiteboardWidth,
|
||||
} from './WhiteboardCanvas';
|
||||
|
||||
interface Props {
|
||||
whiteboardId: string;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
const COLORS: WhiteboardColor[] = ['#000000', '#ef4444', '#3b82f6', '#22c55e', '#facc15', '#a855f7'];
|
||||
const WIDTHS: WhiteboardWidth[] = [2, 4, 8];
|
||||
|
||||
export function WhiteboardModal({ whiteboardId, onClose }: Props) {
|
||||
const { t } = useTranslation();
|
||||
const { strokes, loading, error, insertStroke, clearAll } = useWhiteboardStrokes(whiteboardId);
|
||||
const [tool, setTool] = useState<WhiteboardTool>('pen');
|
||||
const [color, setColor] = useState<WhiteboardColor>('#000000');
|
||||
const [width, setWidth] = useState<WhiteboardWidth>(4);
|
||||
const [confirmClear, setConfirmClear] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
const onKey = (e: KeyboardEvent) => {
|
||||
if (e.key === 'Escape') onClose();
|
||||
};
|
||||
window.addEventListener('keydown', onKey);
|
||||
return () => window.removeEventListener('keydown', onKey);
|
||||
}, [onClose]);
|
||||
|
||||
const handleConfirmClear = async () => {
|
||||
setConfirmClear(false);
|
||||
try {
|
||||
await clearAll();
|
||||
} catch (err) {
|
||||
console.error('clearAll failed', err);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-label={t('app:whiteboard.title', { defaultValue: 'Whiteboard' })}
|
||||
className="fixed inset-0 z-[80] flex flex-col bg-ink-950/95"
|
||||
>
|
||||
<header className="flex shrink-0 items-center justify-between border-b border-line/40 bg-surface-2 px-4 py-2">
|
||||
<h2 className="font-display text-sm font-semibold text-fg">
|
||||
{t('app:whiteboard.title', { defaultValue: 'Whiteboard' })}
|
||||
</h2>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClose}
|
||||
aria-label={t('app:whiteboard.close', { defaultValue: 'Schließen' })}
|
||||
className="flex h-7 w-7 cursor-pointer items-center justify-center rounded-md text-fg-muted hover:bg-surface-3 hover:text-fg"
|
||||
>
|
||||
<XIcon className="h-3.5 w-3.5" />
|
||||
</button>
|
||||
</header>
|
||||
|
||||
<div className="flex min-h-0 flex-1 items-center justify-center overflow-hidden p-6">
|
||||
{loading ? (
|
||||
<p className="text-sm text-fg-muted">
|
||||
{t('app:whiteboard.loading', { defaultValue: 'Lädt…' })}
|
||||
</p>
|
||||
) : error ? (
|
||||
<p className="text-sm text-rose-400">
|
||||
{t('app:whiteboard.error', { defaultValue: 'Whiteboard konnte nicht geladen werden.' })}
|
||||
</p>
|
||||
) : (
|
||||
<WhiteboardCanvas
|
||||
strokes={strokes}
|
||||
tool={tool}
|
||||
color={color}
|
||||
width={width}
|
||||
onStroke={(payload) => void insertStroke(payload)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<footer className="flex shrink-0 flex-wrap items-center gap-4 border-t border-line/40 bg-surface-2 px-4 py-3">
|
||||
<div className="flex items-center gap-1">
|
||||
{(['pen', 'eraser'] as WhiteboardTool[]).map((id) => {
|
||||
const label = id === 'pen' ? 'Stift' : 'Radierer';
|
||||
const active = tool === id;
|
||||
return (
|
||||
<button
|
||||
key={id}
|
||||
type="button"
|
||||
onClick={() => setTool(id)}
|
||||
aria-pressed={active}
|
||||
title={label}
|
||||
className={
|
||||
'flex h-8 w-8 cursor-pointer items-center justify-center rounded-md text-xs font-semibold transition ' +
|
||||
(active
|
||||
? 'bg-accent/20 text-accent ring-2 ring-accent/40'
|
||||
: 'bg-surface-3 text-fg-muted hover:bg-surface hover:text-fg')
|
||||
}
|
||||
>
|
||||
{id === 'pen' ? '✎' : '⌫'}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
<div className="h-6 w-px bg-line/40" aria-hidden />
|
||||
|
||||
<div className="flex items-center gap-1">
|
||||
{COLORS.map((c) => {
|
||||
const active = color === c;
|
||||
return (
|
||||
<button
|
||||
key={c}
|
||||
type="button"
|
||||
onClick={() => setColor(c)}
|
||||
aria-pressed={active}
|
||||
aria-label={c}
|
||||
className={
|
||||
'h-6 w-6 cursor-pointer rounded-full border-2 transition ' +
|
||||
(active ? 'border-fg scale-110' : 'border-line/40 hover:scale-105')
|
||||
}
|
||||
style={{ backgroundColor: c }}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
<div className="h-6 w-px bg-line/40" aria-hidden />
|
||||
|
||||
<div className="flex items-center gap-1">
|
||||
{WIDTHS.map((w) => {
|
||||
const active = width === w;
|
||||
return (
|
||||
<button
|
||||
key={w}
|
||||
type="button"
|
||||
onClick={() => setWidth(w)}
|
||||
aria-pressed={active}
|
||||
title={w + 'px'}
|
||||
className={
|
||||
'flex h-8 w-8 cursor-pointer items-center justify-center rounded-md transition ' +
|
||||
(active
|
||||
? 'bg-accent/20 ring-2 ring-accent/40'
|
||||
: 'bg-surface-3 hover:bg-surface')
|
||||
}
|
||||
>
|
||||
<div
|
||||
className="rounded-full bg-fg"
|
||||
style={{ width: w + 2 + 'px', height: w + 2 + 'px' }}
|
||||
/>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
<div className="ml-auto flex items-center gap-2">
|
||||
{confirmClear ? (
|
||||
<>
|
||||
<span className="text-xs text-fg-muted">
|
||||
{t('app:whiteboard.confirm_clear', { defaultValue: 'Alles löschen?' })}
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setConfirmClear(false)}
|
||||
className="cursor-pointer rounded-md border border-line bg-surface-3 px-3 py-1.5 text-xs font-medium text-fg-muted transition hover:bg-surface"
|
||||
>
|
||||
{t('app:whiteboard.cancel', { defaultValue: 'Abbrechen' })}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => void handleConfirmClear()}
|
||||
className="cursor-pointer rounded-md bg-rose-500 px-3 py-1.5 text-xs font-semibold text-white transition hover:bg-rose-500/90"
|
||||
>
|
||||
{t('app:whiteboard.confirm', { defaultValue: 'Ja, löschen' })}
|
||||
</button>
|
||||
</>
|
||||
) : (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setConfirmClear(true)}
|
||||
disabled={strokes.length === 0}
|
||||
className="cursor-pointer rounded-md border border-rose-500/40 bg-rose-500/10 px-3 py-1.5 text-xs font-semibold text-rose-600 transition hover:bg-rose-500/20 disabled:cursor-not-allowed disabled:opacity-50 dark:text-rose-300"
|
||||
>
|
||||
{t('app:whiteboard.clear_all', { defaultValue: 'Alles löschen' })}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</footer>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,276 @@
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
|
||||
import { getCryptoBackend } from '@chat-app/shared/crypto';
|
||||
import {
|
||||
decryptSoundEnvelope,
|
||||
downloadSoundCiphertext,
|
||||
encryptSoundBlob,
|
||||
listOwnSounds,
|
||||
type RemoteSound,
|
||||
upsertSound,
|
||||
uploadSoundCiphertext,
|
||||
} from '@chat-app/shared/chat';
|
||||
|
||||
import { useAuth } from '../context/AuthContext';
|
||||
import {
|
||||
deleteRawStoredSound,
|
||||
getRawStoredSound,
|
||||
listSounds,
|
||||
putRawStoredSound,
|
||||
type SoundboardEntry,
|
||||
subscribeSoundboardChanges,
|
||||
} from '../lib/soundboardStorage';
|
||||
import { supabase } from '../lib/supabase';
|
||||
import { cachedUserKey } from '../lib/userIdentity';
|
||||
|
||||
export type SyncBadge = 'synced' | 'uploading' | 'downloading' | 'error';
|
||||
|
||||
const PUSH_DEBOUNCE_MS = 500;
|
||||
const DELETE_GRACE_MS = 5000;
|
||||
|
||||
function storagePathFor(userId: string, soundId: string): string {
|
||||
return userId + '/' + soundId + '.bin';
|
||||
}
|
||||
|
||||
export function useSoundboardSync(): {
|
||||
badges: Map<string, SyncBadge>;
|
||||
initialPullDone: boolean;
|
||||
} {
|
||||
const { session } = useAuth();
|
||||
const userId = session?.user.id ?? null;
|
||||
const [badges, setBadges] = useState<Map<string, SyncBadge>>(new Map());
|
||||
const [initialPullDone, setInitialPullDone] = useState(false);
|
||||
const debounceRef = useRef<number | null>(null);
|
||||
|
||||
function setBadge(id: string, badge: SyncBadge): void {
|
||||
setBadges((cur) => {
|
||||
const next = new Map(cur);
|
||||
next.set(id, badge);
|
||||
return next;
|
||||
});
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
if (!userId) {
|
||||
setInitialPullDone(false);
|
||||
setBadges(new Map());
|
||||
return;
|
||||
}
|
||||
let cancelled = false;
|
||||
let teardown: (() => void) | null = null;
|
||||
|
||||
const init = async () => {
|
||||
const priv = await cachedUserKey(userId);
|
||||
if (!priv) return;
|
||||
const pub = getCryptoBackend().scalarMultBase(priv);
|
||||
|
||||
try {
|
||||
await runDiff(userId, priv, pub);
|
||||
} catch (err) {
|
||||
console.error('soundboard initial diff failed', err);
|
||||
}
|
||||
if (cancelled) return;
|
||||
setInitialPullDone(true);
|
||||
|
||||
const unsubLocal = subscribeSoundboardChanges(() => {
|
||||
if (debounceRef.current !== null) window.clearTimeout(debounceRef.current);
|
||||
debounceRef.current = window.setTimeout(() => {
|
||||
debounceRef.current = null;
|
||||
void runDiff(userId, priv, pub).catch((err) => {
|
||||
console.error('soundboard push diff failed', err);
|
||||
});
|
||||
}, PUSH_DEBOUNCE_MS);
|
||||
});
|
||||
|
||||
const channel = supabase
|
||||
.channel('soundboards:' + userId)
|
||||
.on(
|
||||
'postgres_changes',
|
||||
{
|
||||
event: '*',
|
||||
schema: 'public',
|
||||
table: 'user_soundboards',
|
||||
filter: 'user_id=eq.' + userId,
|
||||
},
|
||||
() => {
|
||||
void runDiff(userId, priv, pub).catch((err) => {
|
||||
console.error('soundboard realtime pull failed', err);
|
||||
});
|
||||
},
|
||||
)
|
||||
.subscribe();
|
||||
|
||||
teardown = () => {
|
||||
unsubLocal();
|
||||
void supabase.removeChannel(channel);
|
||||
if (debounceRef.current !== null) {
|
||||
window.clearTimeout(debounceRef.current);
|
||||
debounceRef.current = null;
|
||||
}
|
||||
};
|
||||
};
|
||||
|
||||
void init();
|
||||
return () => {
|
||||
cancelled = true;
|
||||
teardown?.();
|
||||
};
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [userId]);
|
||||
|
||||
async function runDiff(uid: string, priv: Uint8Array, pub: Uint8Array): Promise<void> {
|
||||
const [localList, remoteList] = await Promise.all([
|
||||
listSounds(),
|
||||
listOwnSounds(supabase),
|
||||
]);
|
||||
const remoteById = new Map<string, RemoteSound>();
|
||||
for (const r of remoteList) remoteById.set(r.id, r);
|
||||
const localById = new Map<string, SoundboardEntry>();
|
||||
for (const l of localList) localById.set(l.id, l);
|
||||
|
||||
// Push pass
|
||||
for (const local of localList) {
|
||||
const remote = remoteById.get(local.id);
|
||||
const localIso = new Date(local.updatedAt).toISOString();
|
||||
if (!remote) {
|
||||
await uploadAndUpsert(local, uid, priv, pub, localIso);
|
||||
} else {
|
||||
const remoteMs = Date.parse(remote.updatedAt);
|
||||
if (local.updatedAt > remoteMs) {
|
||||
await upsertMetadataOnly(local, remote, localIso);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Pull pass
|
||||
for (const remote of remoteList) {
|
||||
const local = localById.get(remote.id);
|
||||
const remoteMs = Date.parse(remote.updatedAt);
|
||||
if (!local) {
|
||||
await pullAndStore(remote, priv, pub);
|
||||
} else if (remoteMs > local.updatedAt) {
|
||||
const stored = await getRawStoredSound(remote.id);
|
||||
if (stored) {
|
||||
await putRawStoredSound({
|
||||
...stored,
|
||||
name: remote.name,
|
||||
mime: remote.mime,
|
||||
size: remote.size,
|
||||
category: remote.category,
|
||||
hotkey: remote.hotkey,
|
||||
gain: remote.gain,
|
||||
order: remote.sortOrder,
|
||||
updatedAt: remoteMs,
|
||||
});
|
||||
setBadge(remote.id, 'synced');
|
||||
}
|
||||
} else {
|
||||
setBadge(remote.id, 'synced');
|
||||
}
|
||||
}
|
||||
|
||||
// Remote-absence → local delete (with grace window for fresh adds).
|
||||
const now = Date.now();
|
||||
for (const local of localList) {
|
||||
if (!remoteById.has(local.id) && now - local.updatedAt > DELETE_GRACE_MS) {
|
||||
await deleteRawStoredSound(local.id);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function uploadAndUpsert(
|
||||
local: SoundboardEntry,
|
||||
uid: string,
|
||||
priv: Uint8Array,
|
||||
pub: Uint8Array,
|
||||
localIso: string,
|
||||
): Promise<void> {
|
||||
setBadge(local.id, 'uploading');
|
||||
try {
|
||||
const stored = await getRawStoredSound(local.id);
|
||||
if (!stored) return;
|
||||
const ciphertext = await encryptSoundBlob(stored.blob, pub, priv);
|
||||
const path = storagePathFor(uid, local.id);
|
||||
await uploadSoundCiphertext(supabase, path, ciphertext);
|
||||
await upsertSound(supabase, {
|
||||
id: local.id,
|
||||
name: local.name,
|
||||
mime: local.mime,
|
||||
size: local.size,
|
||||
category: local.category,
|
||||
hotkey: local.hotkey,
|
||||
gain: local.gain,
|
||||
sortOrder: local.order,
|
||||
storagePath: path,
|
||||
updatedAtIso: localIso,
|
||||
});
|
||||
setBadge(local.id, 'synced');
|
||||
} catch (err) {
|
||||
console.error('soundboard upload failed', err);
|
||||
setBadge(local.id, 'error');
|
||||
}
|
||||
}
|
||||
|
||||
async function upsertMetadataOnly(
|
||||
local: SoundboardEntry,
|
||||
remote: RemoteSound,
|
||||
localIso: string,
|
||||
): Promise<void> {
|
||||
setBadge(local.id, 'uploading');
|
||||
try {
|
||||
await upsertSound(supabase, {
|
||||
id: local.id,
|
||||
name: local.name,
|
||||
mime: local.mime,
|
||||
size: local.size,
|
||||
category: local.category,
|
||||
hotkey: local.hotkey,
|
||||
gain: local.gain,
|
||||
sortOrder: local.order,
|
||||
storagePath: remote.storagePath,
|
||||
updatedAtIso: localIso,
|
||||
});
|
||||
setBadge(local.id, 'synced');
|
||||
} catch (err) {
|
||||
console.error('soundboard metadata upload failed', err);
|
||||
setBadge(local.id, 'error');
|
||||
}
|
||||
}
|
||||
|
||||
async function pullAndStore(
|
||||
remote: RemoteSound,
|
||||
priv: Uint8Array,
|
||||
pub: Uint8Array,
|
||||
): Promise<void> {
|
||||
setBadge(remote.id, 'downloading');
|
||||
try {
|
||||
const envelope = await downloadSoundCiphertext(supabase, remote.storagePath);
|
||||
const plain = await decryptSoundEnvelope(envelope, pub, priv);
|
||||
// Copy into a fresh ArrayBuffer so Blob accepts it across TS lib variants
|
||||
// (mirrors the pattern in @chat-app/shared/chat/attachments.ts).
|
||||
const copy = new Uint8Array(plain.byteLength);
|
||||
copy.set(plain);
|
||||
const blob = new Blob([copy.buffer], { type: remote.mime });
|
||||
const ms = Date.parse(remote.updatedAt);
|
||||
await putRawStoredSound({
|
||||
id: remote.id,
|
||||
name: remote.name,
|
||||
mime: remote.mime,
|
||||
size: remote.size,
|
||||
category: remote.category,
|
||||
hotkey: remote.hotkey,
|
||||
gain: remote.gain,
|
||||
order: remote.sortOrder,
|
||||
createdAt: Date.parse(remote.createdAt),
|
||||
updatedAt: ms,
|
||||
blob,
|
||||
});
|
||||
setBadge(remote.id, 'synced');
|
||||
} catch (err) {
|
||||
console.error('soundboard pull failed', err);
|
||||
setBadge(remote.id, 'error');
|
||||
}
|
||||
}
|
||||
|
||||
return { badges, initialPullDone };
|
||||
}
|
||||
@@ -0,0 +1,130 @@
|
||||
import { useCallback, useEffect, useState } from 'react';
|
||||
|
||||
import {
|
||||
clearWhiteboardStrokes,
|
||||
insertWhiteboardStroke,
|
||||
listWhiteboardStrokes,
|
||||
type WhiteboardStroke,
|
||||
} from '@chat-app/shared/chat';
|
||||
|
||||
import { supabase } from '../lib/supabase';
|
||||
|
||||
interface State {
|
||||
strokes: WhiteboardStroke[];
|
||||
loading: boolean;
|
||||
error: string | null;
|
||||
}
|
||||
|
||||
export function useWhiteboardStrokes(whiteboardId: string | null): {
|
||||
strokes: WhiteboardStroke[];
|
||||
loading: boolean;
|
||||
error: string | null;
|
||||
insertStroke: (strokeJson: unknown) => Promise<void>;
|
||||
clearAll: () => Promise<void>;
|
||||
} {
|
||||
const [state, setState] = useState<State>({ strokes: [], loading: true, error: null });
|
||||
|
||||
useEffect(() => {
|
||||
if (!whiteboardId) {
|
||||
setState({ strokes: [], loading: false, error: null });
|
||||
return;
|
||||
}
|
||||
let cancelled = false;
|
||||
void (async () => {
|
||||
try {
|
||||
setState((s) => ({ ...s, loading: true, error: null }));
|
||||
const list = await listWhiteboardStrokes(supabase, whiteboardId);
|
||||
if (!cancelled) setState({ strokes: list, loading: false, error: null });
|
||||
} catch (err) {
|
||||
if (!cancelled) {
|
||||
setState({
|
||||
strokes: [],
|
||||
loading: false,
|
||||
error: err instanceof Error ? err.message : 'failed to load strokes',
|
||||
});
|
||||
}
|
||||
}
|
||||
})();
|
||||
const channel = supabase
|
||||
.channel('whiteboard:' + whiteboardId)
|
||||
.on(
|
||||
'postgres_changes',
|
||||
{
|
||||
event: 'INSERT',
|
||||
schema: 'public',
|
||||
table: 'whiteboard_strokes',
|
||||
filter: 'whiteboard_id=eq.' + whiteboardId,
|
||||
},
|
||||
(payload) => {
|
||||
const row = payload.new as {
|
||||
id?: string;
|
||||
whiteboard_id?: string;
|
||||
author_user_id?: string;
|
||||
stroke_json?: unknown;
|
||||
created_at?: string;
|
||||
} | null;
|
||||
if (!row?.id || !row.whiteboard_id || !row.author_user_id || !row.created_at) return;
|
||||
const next: WhiteboardStroke = {
|
||||
id: row.id,
|
||||
whiteboardId: row.whiteboard_id,
|
||||
authorUserId: row.author_user_id,
|
||||
strokeJson: row.stroke_json,
|
||||
createdAt: row.created_at,
|
||||
};
|
||||
setState((s) => {
|
||||
if (s.strokes.some((x) => x.id === next.id)) return s;
|
||||
return { ...s, strokes: [...s.strokes, next] };
|
||||
});
|
||||
},
|
||||
)
|
||||
.on(
|
||||
'postgres_changes',
|
||||
{
|
||||
event: 'DELETE',
|
||||
schema: 'public',
|
||||
table: 'whiteboard_strokes',
|
||||
filter: 'whiteboard_id=eq.' + whiteboardId,
|
||||
},
|
||||
() => {
|
||||
// Bulk delete via "Clear all" — drop everything; future inserts
|
||||
// come back via the INSERT branch above.
|
||||
setState((s) => ({ ...s, strokes: [] }));
|
||||
},
|
||||
)
|
||||
.subscribe();
|
||||
return () => {
|
||||
cancelled = true;
|
||||
void supabase.removeChannel(channel);
|
||||
};
|
||||
}, [whiteboardId]);
|
||||
|
||||
const insertStroke = useCallback(
|
||||
async (strokeJson: unknown) => {
|
||||
if (!whiteboardId) return;
|
||||
try {
|
||||
await insertWhiteboardStroke(supabase, { whiteboardId, strokeJson });
|
||||
// No optimistic append — realtime echoes the row back in <150ms.
|
||||
} catch (err) {
|
||||
console.error('insertWhiteboardStroke failed', err);
|
||||
setState((s) => ({
|
||||
...s,
|
||||
error: err instanceof Error ? err.message : 'stroke insert failed',
|
||||
}));
|
||||
}
|
||||
},
|
||||
[whiteboardId],
|
||||
);
|
||||
|
||||
const clearAll = useCallback(async () => {
|
||||
if (!whiteboardId) return;
|
||||
await clearWhiteboardStrokes(supabase, whiteboardId);
|
||||
}, [whiteboardId]);
|
||||
|
||||
return {
|
||||
strokes: state.strokes,
|
||||
loading: state.loading,
|
||||
error: state.error,
|
||||
insertStroke,
|
||||
clearAll,
|
||||
};
|
||||
}
|
||||
@@ -4,6 +4,7 @@ import {
|
||||
type AttachmentHandle,
|
||||
type DecryptedMessage,
|
||||
type PollOption,
|
||||
type WhiteboardPayload,
|
||||
} from '@chat-app/shared/chat';
|
||||
|
||||
export type AttachmentBucket = 'media' | 'audio' | 'files';
|
||||
@@ -97,6 +98,15 @@ export function createPollPayload(question: string, optionTexts: string[]): stri
|
||||
});
|
||||
}
|
||||
|
||||
export function createWhiteboardPayload(whiteboardId: string): string {
|
||||
const payload: WhiteboardPayload = {
|
||||
v: 1,
|
||||
type: 'whiteboard',
|
||||
whiteboard_id: whiteboardId,
|
||||
};
|
||||
return serializeMessagePayload(payload);
|
||||
}
|
||||
|
||||
export function summarizePollVotes(
|
||||
options: PollOption[],
|
||||
reactions: ReactionSummaryInput[],
|
||||
|
||||
@@ -369,3 +369,52 @@ export async function isHotkeyTaken(
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
// --- Sync-engine bypass helpers ------------------------------------------
|
||||
// Used by useSoundboardSync to write/delete IndexedDB rows without firing
|
||||
// notifyChange — pulls and remote-driven deletes are not "edits". If they
|
||||
// triggered notifyChange the engine would loop:
|
||||
// push debounce → upsert → realtime → pull → notifyChange → push debounce → ...
|
||||
|
||||
export async function putRawStoredSound(stored: {
|
||||
id: string;
|
||||
name: string;
|
||||
mime: string;
|
||||
size: number;
|
||||
category: string | null;
|
||||
hotkey: string | null;
|
||||
gain: number;
|
||||
order: number;
|
||||
createdAt: number;
|
||||
updatedAt: number;
|
||||
blob: Blob;
|
||||
}): Promise<void> {
|
||||
await tx(SOUNDS_STORE, 'readwrite', (s) => s.put(stored));
|
||||
}
|
||||
|
||||
export async function deleteRawStoredSound(id: string): Promise<void> {
|
||||
await tx(SOUNDS_STORE, 'readwrite', (s) => s.delete(id));
|
||||
}
|
||||
|
||||
export async function getRawStoredSound(id: string): Promise<{
|
||||
id: string;
|
||||
name: string;
|
||||
mime: string;
|
||||
size: number;
|
||||
category: string | null;
|
||||
hotkey: string | null;
|
||||
gain: number;
|
||||
order: number;
|
||||
createdAt: number;
|
||||
updatedAt: number;
|
||||
blob: Blob;
|
||||
} | null> {
|
||||
const db = await openDb();
|
||||
return new Promise((resolve, reject) => {
|
||||
const t = db.transaction(SOUNDS_STORE, 'readonly');
|
||||
const s = t.objectStore(SOUNDS_STORE);
|
||||
const req = s.get(id);
|
||||
req.onsuccess = () => resolve((req.result as any) ?? null);
|
||||
req.onerror = () => reject(req.error);
|
||||
});
|
||||
}
|
||||
|
||||
@@ -16,6 +16,7 @@ import {
|
||||
ChevronDownIcon,
|
||||
ChevronUpIcon,
|
||||
EyeOffIcon,
|
||||
PencilIcon,
|
||||
PlusIcon,
|
||||
PollIcon,
|
||||
ReplyIcon,
|
||||
@@ -25,6 +26,7 @@ import {
|
||||
SpinnerIcon,
|
||||
XIcon,
|
||||
} from '../components/icons';
|
||||
import { ImageAnnotator } from '../components/ImageAnnotator';
|
||||
import { InCallPanel } from '../components/InCallPanel';
|
||||
import { IncomingCallPanel } from '../components/IncomingCallPanel';
|
||||
import { CallPreviewPanel } from '../components/CallPreviewPanel';
|
||||
@@ -40,7 +42,13 @@ import type { DecryptedMessage } from '@chat-app/shared/chat';
|
||||
import { useAuth } from '../context/AuthContext';
|
||||
import { useCall } from '../context/CallContext';
|
||||
import { useConversationsContext } from '../context/ConversationsContext';
|
||||
import { collectConversationAttachments, createPollPayload } from '../lib/conversationFeatures';
|
||||
import {
|
||||
collectConversationAttachments,
|
||||
createPollPayload,
|
||||
createWhiteboardPayload,
|
||||
} from '../lib/conversationFeatures';
|
||||
import { WhiteboardModal } from '../components/WhiteboardModal';
|
||||
import { createWhiteboard } from '@chat-app/shared/chat';
|
||||
import { compressImages } from '../lib/imageCompress';
|
||||
import { ensureInstallId } from '../lib/installId';
|
||||
import { searchCachedMessages } from '../lib/messageCache';
|
||||
@@ -154,10 +162,13 @@ export function ConversationPage() {
|
||||
const [sendError, setSendError] = useState<string | null>(null);
|
||||
const [stickToBottom, setStickToBottom] = useState(true);
|
||||
const [attachments, setAttachments] = useState<File[]>([]);
|
||||
const [annotatingIndex, setAnnotatingIndex] = useState<number | null>(null);
|
||||
const [infoPanelOpen, setInfoPanelOpen] = useState(false);
|
||||
const [mediaDrawerOpen, setMediaDrawerOpen] = useState(false);
|
||||
const [pollDialogOpen, setPollDialogOpen] = useState(false);
|
||||
const [pollSending, setPollSending] = useState(false);
|
||||
const [openWhiteboardId, setOpenWhiteboardId] = useState<string | null>(null);
|
||||
const [creatingWhiteboard, setCreatingWhiteboard] = useState(false);
|
||||
const [pollError, setPollError] = useState<string | null>(null);
|
||||
const [replyTo, setReplyTo] = useState<DecryptedMessage | null>(null);
|
||||
const [forwardTarget, setForwardTarget] = useState<DecryptedMessage | null>(null);
|
||||
@@ -452,6 +463,15 @@ export function ConversationPage() {
|
||||
};
|
||||
}, [id, setActiveConversation]);
|
||||
|
||||
useEffect(() => {
|
||||
const onOpen = (e: Event) => {
|
||||
const detail = (e as CustomEvent<{ id?: string }>).detail;
|
||||
if (detail?.id) setOpenWhiteboardId(detail.id);
|
||||
};
|
||||
window.addEventListener('chatapp:open-whiteboard', onOpen);
|
||||
return () => window.removeEventListener('chatapp:open-whiteboard', onOpen);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (id && messages.length > 0) markRead(id);
|
||||
}, [id, messages.length, markRead]);
|
||||
@@ -585,6 +605,23 @@ export function ConversationPage() {
|
||||
[send, replyTo?.id, notifyStopTyping],
|
||||
);
|
||||
|
||||
const handleCreateWhiteboard = useCallback(async () => {
|
||||
if (!id || creatingWhiteboard) return;
|
||||
setCreatingWhiteboard(true);
|
||||
try {
|
||||
const board = await createWhiteboard(supabase, id);
|
||||
const payload = createWhiteboardPayload(board.id);
|
||||
await send(payload, [], replyTo?.id ?? null);
|
||||
setReplyTo(null);
|
||||
setStickToBottom(true);
|
||||
setOpenWhiteboardId(board.id);
|
||||
} catch (err: unknown) {
|
||||
setSendError(err instanceof Error ? err.message : 'Whiteboard konnte nicht angelegt werden');
|
||||
} finally {
|
||||
setCreatingWhiteboard(false);
|
||||
}
|
||||
}, [id, creatingWhiteboard, send, replyTo?.id]);
|
||||
|
||||
async function ingestFiles(files: File[]) {
|
||||
const compressed = await compressImages(files);
|
||||
const next: File[] = [];
|
||||
@@ -917,6 +954,9 @@ export function ConversationPage() {
|
||||
key={idx}
|
||||
file={file}
|
||||
onRemove={() => setAttachments((prev) => prev.filter((_, i) => i !== idx))}
|
||||
{...(file.type.startsWith('image/')
|
||||
? { onEdit: () => setAnnotatingIndex(idx) }
|
||||
: {})}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
@@ -975,6 +1015,16 @@ export function ConversationPage() {
|
||||
>
|
||||
<PollIcon className="h-4 w-4" />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => void handleCreateWhiteboard()}
|
||||
disabled={creatingWhiteboard}
|
||||
title={t('app:composer.whiteboard', { defaultValue: 'Whiteboard' })}
|
||||
aria-label={t('app:composer.whiteboard', { defaultValue: 'Whiteboard' })}
|
||||
className="inline-flex h-10 w-10 shrink-0 cursor-pointer items-center justify-center rounded-lg text-fg-muted transition hover:bg-surface-3 hover:text-fg focus:outline-none focus-visible:ring-2 focus-visible:ring-accent/50 disabled:cursor-not-allowed disabled:opacity-50 dark:hover:bg-[#313338]"
|
||||
>
|
||||
<WhiteboardIcon className="h-4 w-4" />
|
||||
</button>
|
||||
<div className="relative">
|
||||
<button
|
||||
type="button"
|
||||
@@ -1163,6 +1213,24 @@ export function ConversationPage() {
|
||||
}}
|
||||
onUnpin={(messageId) => void handleTogglePin(messageId)}
|
||||
/>
|
||||
|
||||
{annotatingIndex !== null && attachments[annotatingIndex] && (
|
||||
<ImageAnnotator
|
||||
file={attachments[annotatingIndex]!}
|
||||
onCancel={() => setAnnotatingIndex(null)}
|
||||
onSave={(next) => {
|
||||
setAttachments((prev) => prev.map((f, i) => (i === annotatingIndex ? next : f)));
|
||||
setAnnotatingIndex(null);
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
|
||||
{openWhiteboardId && (
|
||||
<WhiteboardModal
|
||||
whiteboardId={openWhiteboardId}
|
||||
onClose={() => setOpenWhiteboardId(null)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1390,7 +1458,15 @@ function Banner({ children }: { children: React.ReactNode }) {
|
||||
);
|
||||
}
|
||||
|
||||
function AttachmentPreview({ file, onRemove }: { file: File; onRemove: () => void }) {
|
||||
function AttachmentPreview({
|
||||
file,
|
||||
onRemove,
|
||||
onEdit,
|
||||
}: {
|
||||
file: File;
|
||||
onRemove: () => void;
|
||||
onEdit?: () => void;
|
||||
}) {
|
||||
const isImage = file.type.startsWith('image/');
|
||||
const [url, setUrl] = useState<string | null>(null);
|
||||
useEffect(() => {
|
||||
@@ -1400,7 +1476,7 @@ function AttachmentPreview({ file, onRemove }: { file: File; onRemove: () => voi
|
||||
return () => URL.revokeObjectURL(u);
|
||||
}, [file, isImage]);
|
||||
return (
|
||||
<div className="relative overflow-hidden rounded-lg border border-line bg-surface-2 dark:bg-[#2b2d31]">
|
||||
<div className="group relative overflow-hidden rounded-lg border border-line bg-surface-2 dark:bg-[#2b2d31]">
|
||||
{isImage && url ? (
|
||||
<img src={url} alt={file.name} className="block h-20 w-20 object-cover" />
|
||||
) : (
|
||||
@@ -1412,6 +1488,17 @@ function AttachmentPreview({ file, onRemove }: { file: File; onRemove: () => voi
|
||||
<span className="text-fg-muted">{(file.size / 1024).toFixed(0)} KB</span>
|
||||
</div>
|
||||
)}
|
||||
{isImage && onEdit && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onEdit}
|
||||
aria-label="Bearbeiten"
|
||||
title="Bearbeiten"
|
||||
className="absolute bottom-1 left-1 flex h-5 w-5 cursor-pointer items-center justify-center rounded-full bg-black/70 text-white opacity-0 transition group-hover:opacity-100 hover:bg-accent/80"
|
||||
>
|
||||
<PencilIcon className="h-3 w-3" />
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
type="button"
|
||||
onClick={onRemove}
|
||||
@@ -1423,3 +1510,13 @@ function AttachmentPreview({ file, onRemove }: { file: File; onRemove: () => voi
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function WhiteboardIcon(props: React.SVGProps<SVGSVGElement>) {
|
||||
return (
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={2}
|
||||
strokeLinecap="round" strokeLinejoin="round" {...props}>
|
||||
<rect x="3" y="4" width="18" height="13" rx="2" />
|
||||
<path d="M8 21h8M12 17v4" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,849 @@
|
||||
# Phase 4A — Image Annotation vor Send
|
||||
|
||||
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||||
|
||||
**Goal:** Let the user mark up an image attachment (pen, arrow, rectangle, circle, text, highlighter) BEFORE it's sent. The annotated copy replaces the original `File` in the composer's attachments[] state; the recipient sees the flattened PNG with the annotation baked in (no separate metadata, no decoding work on receive).
|
||||
|
||||
**Architecture:**
|
||||
- New self-contained component `ImageAnnotator.tsx` (~400 LOC, no external deps): a full-screen modal with a single `<canvas>` rendering the original image plus an in-memory "ops stack" of drawing commands. Every tool stroke is one op; undo pops from the stack into a redo buffer; redo moves it back. The canvas re-renders the whole stack on every change — simple, debuggable, fast at typical image sizes.
|
||||
- New `AttachmentPreview` prop `onEdit?: () => void`. When the preview is an image and `onEdit` is wired, a `✏` button overlays the thumb. Clicking it opens `ImageAnnotator`; on Save the modal calls `onSave(newFile)` and `ConversationPage` swaps the entry in `attachments[]`.
|
||||
- Save flow: `canvas.toBlob({ type: 'image/png' })` → wrap in `new File([blob], original.name.replace(/\.\w+$/, '') + '-annotated.png', { type: 'image/png', lastModified: Date.now() })` → return through `onSave`.
|
||||
|
||||
**Tech Stack:** React 18 + TypeScript + HTML5 Canvas. Reuses Tailwind classes from the existing modal/toolbar code in the app. No new dependencies.
|
||||
|
||||
**Non-goals:**
|
||||
- No image-only crop / rotate / filter (just annotation).
|
||||
- No persistence of in-progress annotations between modal opens.
|
||||
- No collaboration / sharing of the op-stack (recipient sees flattened PNG only).
|
||||
- No SVG / vector output; PNG raster only.
|
||||
|
||||
---
|
||||
|
||||
## Pre-flight
|
||||
|
||||
- [ ] **Verify clean working tree on `main`**
|
||||
|
||||
Run: `cd "D:\Programmieren\ChatApp-Electron\chat-app" && git status`
|
||||
Expected: clean (ignored `.env.local` is fine).
|
||||
|
||||
- [ ] **Confirm tooling is green**
|
||||
|
||||
Run: `pnpm --filter @chat-app/desktop typecheck`
|
||||
Expected: PASS. If it's red, STOP and report — don't start on a broken baseline.
|
||||
|
||||
---
|
||||
|
||||
## Task 1: ImageAnnotator component skeleton + canvas mount + op-stack types
|
||||
|
||||
**Why:** Get the modal rendering with the image visible inside the canvas before adding any drawing logic. Establishes the file's structure (state, refs, types) that the next tasks fill in.
|
||||
|
||||
**Files:**
|
||||
- Create: `apps/desktop/src/components/ImageAnnotator.tsx`
|
||||
|
||||
- [ ] **Step 1: Write the file**
|
||||
|
||||
Create `apps/desktop/src/components/ImageAnnotator.tsx`:
|
||||
|
||||
```tsx
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
import { XIcon } from './icons';
|
||||
|
||||
export type AnnotatorTool = 'pen' | 'arrow' | 'rect' | 'circle' | 'text' | 'highlighter';
|
||||
export type AnnotatorColor = '#ef4444' | '#3b82f6' | '#22c55e' | '#facc15' | '#a855f7' | '#ffffff';
|
||||
export type AnnotatorWidth = 2 | 4 | 8;
|
||||
|
||||
export interface AnnotatorOp {
|
||||
tool: AnnotatorTool;
|
||||
color: AnnotatorColor;
|
||||
width: AnnotatorWidth;
|
||||
points?: Array<{ x: number; y: number }>;
|
||||
from?: { x: number; y: number };
|
||||
to?: { x: number; y: number };
|
||||
text?: string;
|
||||
at?: { x: number; y: number };
|
||||
}
|
||||
|
||||
interface Props {
|
||||
file: File;
|
||||
onCancel: () => void;
|
||||
onSave: (next: File) => void;
|
||||
}
|
||||
|
||||
export function ImageAnnotator({ file, onCancel, onSave }: Props) {
|
||||
const { t } = useTranslation();
|
||||
const canvasRef = useRef<HTMLCanvasElement | null>(null);
|
||||
const imageRef = useRef<HTMLImageElement | null>(null);
|
||||
const [imageLoaded, setImageLoaded] = useState(false);
|
||||
const [ops, setOps] = useState<AnnotatorOp[]>([]);
|
||||
const [redoStack, setRedoStack] = useState<AnnotatorOp[]>([]);
|
||||
const [tool, setTool] = useState<AnnotatorTool>('pen');
|
||||
const [color, setColor] = useState<AnnotatorColor>('#ef4444');
|
||||
const [width, setWidth] = useState<AnnotatorWidth>(4);
|
||||
|
||||
useEffect(() => {
|
||||
const url = URL.createObjectURL(file);
|
||||
const img = new Image();
|
||||
img.onload = () => {
|
||||
imageRef.current = img;
|
||||
setImageLoaded(true);
|
||||
};
|
||||
img.onerror = () => {
|
||||
console.error('ImageAnnotator: failed to decode source image');
|
||||
onCancel();
|
||||
};
|
||||
img.src = url;
|
||||
return () => URL.revokeObjectURL(url);
|
||||
}, [file, onCancel]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!imageLoaded) return;
|
||||
const cv = canvasRef.current;
|
||||
const img = imageRef.current;
|
||||
if (!cv || !img) return;
|
||||
cv.width = img.naturalWidth;
|
||||
cv.height = img.naturalHeight;
|
||||
const ctx = cv.getContext('2d');
|
||||
if (!ctx) return;
|
||||
ctx.drawImage(img, 0, 0);
|
||||
for (const op of ops) {
|
||||
renderOp(ctx, op);
|
||||
}
|
||||
}, [imageLoaded, ops]);
|
||||
|
||||
useEffect(() => {
|
||||
const onKey = (e: KeyboardEvent) => {
|
||||
if (e.key === 'Escape') onCancel();
|
||||
if ((e.ctrlKey || e.metaKey) && e.key === 'z' && !e.shiftKey) {
|
||||
e.preventDefault();
|
||||
handleUndo();
|
||||
} else if ((e.ctrlKey || e.metaKey) && (e.key === 'y' || (e.key === 'z' && e.shiftKey))) {
|
||||
e.preventDefault();
|
||||
handleRedo();
|
||||
}
|
||||
};
|
||||
window.addEventListener('keydown', onKey);
|
||||
return () => window.removeEventListener('keydown', onKey);
|
||||
});
|
||||
|
||||
const handleUndo = () => {
|
||||
setOps((cur) => {
|
||||
if (cur.length === 0) return cur;
|
||||
const next = cur.slice(0, -1);
|
||||
setRedoStack((r) => [...r, cur[cur.length - 1]!]);
|
||||
return next;
|
||||
});
|
||||
};
|
||||
|
||||
const handleRedo = () => {
|
||||
setRedoStack((r) => {
|
||||
if (r.length === 0) return r;
|
||||
const top = r[r.length - 1]!;
|
||||
setOps((cur) => [...cur, top]);
|
||||
return r.slice(0, -1);
|
||||
});
|
||||
};
|
||||
|
||||
const handleReset = () => {
|
||||
setOps([]);
|
||||
setRedoStack([]);
|
||||
};
|
||||
|
||||
const handleSave = () => {
|
||||
const cv = canvasRef.current;
|
||||
if (!cv) return;
|
||||
cv.toBlob((blob) => {
|
||||
if (!blob) {
|
||||
console.error('ImageAnnotator: toBlob returned null');
|
||||
return;
|
||||
}
|
||||
const baseName = file.name.replace(/\.[^.]+$/, '');
|
||||
const next = new File([blob], baseName + '-annotated.png', {
|
||||
type: 'image/png',
|
||||
lastModified: Date.now(),
|
||||
});
|
||||
onSave(next);
|
||||
}, 'image/png');
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-label={t('app:annotator.title', { defaultValue: 'Bild bearbeiten' })}
|
||||
className="fixed inset-0 z-[80] flex flex-col bg-ink-950/95"
|
||||
>
|
||||
<header className="flex shrink-0 items-center justify-between border-b border-line/40 bg-surface-2 px-4 py-2">
|
||||
<h2 className="font-display text-sm font-semibold text-fg">
|
||||
{t('app:annotator.title', { defaultValue: 'Bild bearbeiten' })}
|
||||
</h2>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onCancel}
|
||||
aria-label={t('app:annotator.cancel', { defaultValue: 'Abbrechen' })}
|
||||
className="flex h-7 w-7 cursor-pointer items-center justify-center rounded-md text-fg-muted hover:bg-surface-3 hover:text-fg"
|
||||
>
|
||||
<XIcon className="h-3.5 w-3.5" />
|
||||
</button>
|
||||
</header>
|
||||
|
||||
<div className="flex min-h-0 flex-1 items-center justify-center overflow-hidden p-6">
|
||||
{imageLoaded ? (
|
||||
<canvas
|
||||
ref={canvasRef}
|
||||
className="max-h-full max-w-full cursor-crosshair rounded-lg border border-line/40 bg-black shadow-2xl"
|
||||
/>
|
||||
) : (
|
||||
<p className="text-sm text-fg-muted">
|
||||
{t('app:annotator.loading', { defaultValue: 'Bild wird geladen…' })}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<footer className="flex shrink-0 items-center justify-between gap-3 border-t border-line/40 bg-surface-2 px-4 py-3">
|
||||
<div className="text-xs text-fg-muted">
|
||||
{ops.length === 0
|
||||
? t('app:annotator.no_changes', { defaultValue: 'Keine Änderungen' })
|
||||
: ops.length + ' ' + t('app:annotator.changes', { defaultValue: 'Änderungen' })}
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleReset}
|
||||
disabled={ops.length === 0}
|
||||
className="cursor-pointer rounded-md border border-line bg-surface-3 px-3 py-1.5 text-xs font-medium text-fg-muted transition hover:bg-surface disabled:cursor-not-allowed disabled:opacity-50"
|
||||
>
|
||||
{t('app:annotator.reset', { defaultValue: 'Zurücksetzen' })}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleSave}
|
||||
disabled={!imageLoaded}
|
||||
className="cursor-pointer rounded-md bg-accent px-4 py-1.5 text-xs font-semibold text-accent-fg transition hover:bg-accent/90 disabled:cursor-not-allowed disabled:opacity-50"
|
||||
>
|
||||
{t('app:annotator.save', { defaultValue: 'Speichern' })}
|
||||
</button>
|
||||
</div>
|
||||
</footer>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// renderOp is filled in by Task 2. Stub for now so the canvas-replay loop
|
||||
// in the main useEffect compiles cleanly.
|
||||
function renderOp(_ctx: CanvasRenderingContext2D, _op: AnnotatorOp): void {
|
||||
// implemented in Task 2
|
||||
}
|
||||
```
|
||||
|
||||
If `text-accent-fg` doesn't exist in this project's Tailwind, use the same class the existing accent buttons use — grep `Grep -n "bg-accent " apps/desktop/src/components/RemoteRevokedScreen.tsx` (P3.T7 wrote this very recently) to find the conventional pair.
|
||||
|
||||
- [ ] **Step 2: Typecheck**
|
||||
|
||||
Run: `pnpm --filter @chat-app/desktop typecheck`
|
||||
Expected: PASS.
|
||||
|
||||
- [ ] **Step 3: Commit**
|
||||
|
||||
```bash
|
||||
cd "D:\Programmieren\ChatApp-Electron\chat-app"
|
||||
git add apps/desktop/src/components/ImageAnnotator.tsx
|
||||
git commit -m "feat(P4A.T1): ImageAnnotator modal skeleton with canvas mount + op-stack types"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 2: Drawing implementation — render all 6 tools + capture pointer events
|
||||
|
||||
**Why:** This is the drawing engine. After this task the user can free-hand-draw + shapes on the canvas with the defaults (pen / red / width 4). Tool/color/width pickers come in Task 3.
|
||||
|
||||
**Files:**
|
||||
- Modify: `apps/desktop/src/components/ImageAnnotator.tsx` (replace the `renderOp` stub + add pointer handlers + draft state)
|
||||
|
||||
- [ ] **Step 1: Replace the `renderOp` stub with the real renderer**
|
||||
|
||||
At the bottom of the file, replace the stub with:
|
||||
|
||||
```ts
|
||||
function renderOp(ctx: CanvasRenderingContext2D, op: AnnotatorOp): void {
|
||||
ctx.save();
|
||||
ctx.lineCap = 'round';
|
||||
ctx.lineJoin = 'round';
|
||||
ctx.strokeStyle = op.color;
|
||||
ctx.fillStyle = op.color;
|
||||
ctx.lineWidth = op.width;
|
||||
|
||||
switch (op.tool) {
|
||||
case 'pen': {
|
||||
const pts = op.points;
|
||||
if (!pts || pts.length < 1) break;
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(pts[0]!.x, pts[0]!.y);
|
||||
for (let i = 1; i < pts.length; i++) {
|
||||
ctx.lineTo(pts[i]!.x, pts[i]!.y);
|
||||
}
|
||||
ctx.stroke();
|
||||
break;
|
||||
}
|
||||
case 'highlighter': {
|
||||
const pts = op.points;
|
||||
if (!pts || pts.length < 1) break;
|
||||
ctx.globalAlpha = 0.35;
|
||||
ctx.lineWidth = op.width * 4;
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(pts[0]!.x, pts[0]!.y);
|
||||
for (let i = 1; i < pts.length; i++) {
|
||||
ctx.lineTo(pts[i]!.x, pts[i]!.y);
|
||||
}
|
||||
ctx.stroke();
|
||||
break;
|
||||
}
|
||||
case 'rect': {
|
||||
const { from, to } = op;
|
||||
if (!from || !to) break;
|
||||
ctx.strokeRect(
|
||||
Math.min(from.x, to.x),
|
||||
Math.min(from.y, to.y),
|
||||
Math.abs(to.x - from.x),
|
||||
Math.abs(to.y - from.y),
|
||||
);
|
||||
break;
|
||||
}
|
||||
case 'circle': {
|
||||
const { from, to } = op;
|
||||
if (!from || !to) break;
|
||||
const cx = (from.x + to.x) / 2;
|
||||
const cy = (from.y + to.y) / 2;
|
||||
const rx = Math.abs(to.x - from.x) / 2;
|
||||
const ry = Math.abs(to.y - from.y) / 2;
|
||||
ctx.beginPath();
|
||||
ctx.ellipse(cx, cy, rx, ry, 0, 0, Math.PI * 2);
|
||||
ctx.stroke();
|
||||
break;
|
||||
}
|
||||
case 'arrow': {
|
||||
const { from, to } = op;
|
||||
if (!from || !to) break;
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(from.x, from.y);
|
||||
ctx.lineTo(to.x, to.y);
|
||||
ctx.stroke();
|
||||
const angle = Math.atan2(to.y - from.y, to.x - from.x);
|
||||
const head = Math.max(12, op.width * 3);
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(to.x, to.y);
|
||||
ctx.lineTo(
|
||||
to.x - head * Math.cos(angle - Math.PI / 6),
|
||||
to.y - head * Math.sin(angle - Math.PI / 6),
|
||||
);
|
||||
ctx.moveTo(to.x, to.y);
|
||||
ctx.lineTo(
|
||||
to.x - head * Math.cos(angle + Math.PI / 6),
|
||||
to.y - head * Math.sin(angle + Math.PI / 6),
|
||||
);
|
||||
ctx.stroke();
|
||||
break;
|
||||
}
|
||||
case 'text': {
|
||||
const { at, text } = op;
|
||||
if (!at || !text) break;
|
||||
const fontSize = Math.max(14, op.width * 6);
|
||||
ctx.font = '600 ' + fontSize + 'px Inter, system-ui, sans-serif';
|
||||
ctx.textBaseline = 'top';
|
||||
ctx.fillText(text, at.x, at.y);
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
ctx.restore();
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Add pointer-event capture + draft state**
|
||||
|
||||
Inside the component, near the other refs, add:
|
||||
|
||||
```ts
|
||||
const draftRef = useRef<AnnotatorOp | null>(null);
|
||||
const [draftTick, setDraftTick] = useState(0);
|
||||
```
|
||||
|
||||
Replace the existing main render `useEffect` body so it ALSO draws the in-progress draft (live preview during pointer-down):
|
||||
|
||||
```ts
|
||||
useEffect(() => {
|
||||
if (!imageLoaded) return;
|
||||
const cv = canvasRef.current;
|
||||
const img = imageRef.current;
|
||||
if (!cv || !img) return;
|
||||
cv.width = img.naturalWidth;
|
||||
cv.height = img.naturalHeight;
|
||||
const ctx = cv.getContext('2d');
|
||||
if (!ctx) return;
|
||||
ctx.drawImage(img, 0, 0);
|
||||
for (const op of ops) {
|
||||
renderOp(ctx, op);
|
||||
}
|
||||
if (draftRef.current) {
|
||||
renderOp(ctx, draftRef.current);
|
||||
}
|
||||
}, [imageLoaded, ops, draftTick]);
|
||||
```
|
||||
|
||||
Add this helper right above the `return` (to convert client coords to internal canvas coords — important because the canvas is scaled to fit):
|
||||
|
||||
```ts
|
||||
function canvasPoint(e: React.PointerEvent<HTMLCanvasElement>): { x: number; y: number } {
|
||||
const cv = canvasRef.current!;
|
||||
const rect = cv.getBoundingClientRect();
|
||||
const scaleX = cv.width / rect.width;
|
||||
const scaleY = cv.height / rect.height;
|
||||
return {
|
||||
x: (e.clientX - rect.left) * scaleX,
|
||||
y: (e.clientY - rect.top) * scaleY,
|
||||
};
|
||||
}
|
||||
```
|
||||
|
||||
Add these handlers (also above the `return`):
|
||||
|
||||
```ts
|
||||
const handlePointerDown = (e: React.PointerEvent<HTMLCanvasElement>) => {
|
||||
if (!imageLoaded) return;
|
||||
const cv = canvasRef.current;
|
||||
if (!cv) return;
|
||||
cv.setPointerCapture(e.pointerId);
|
||||
const p = canvasPoint(e);
|
||||
|
||||
if (tool === 'text') {
|
||||
const value = window.prompt(
|
||||
t('app:annotator.text_prompt', { defaultValue: 'Text eingeben:' }),
|
||||
'',
|
||||
);
|
||||
if (value !== null && value.trim().length > 0) {
|
||||
setOps((cur) => [...cur, { tool: 'text', color, width, text: value, at: p }]);
|
||||
setRedoStack([]);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (tool === 'pen' || tool === 'highlighter') {
|
||||
draftRef.current = { tool, color, width, points: [p] };
|
||||
} else {
|
||||
draftRef.current = { tool, color, width, from: p, to: p };
|
||||
}
|
||||
setDraftTick((n) => n + 1);
|
||||
};
|
||||
|
||||
const handlePointerMove = (e: React.PointerEvent<HTMLCanvasElement>) => {
|
||||
if (!draftRef.current) return;
|
||||
const p = canvasPoint(e);
|
||||
const cur = draftRef.current;
|
||||
if (cur.tool === 'pen' || cur.tool === 'highlighter') {
|
||||
cur.points = [...(cur.points ?? []), p];
|
||||
} else {
|
||||
cur.to = p;
|
||||
}
|
||||
setDraftTick((n) => n + 1);
|
||||
};
|
||||
|
||||
const handlePointerUp = (e: React.PointerEvent<HTMLCanvasElement>) => {
|
||||
const cv = canvasRef.current;
|
||||
if (cv && cv.hasPointerCapture(e.pointerId)) cv.releasePointerCapture(e.pointerId);
|
||||
const cur = draftRef.current;
|
||||
draftRef.current = null;
|
||||
if (!cur) return;
|
||||
const hasContent =
|
||||
(cur.tool === 'pen' || cur.tool === 'highlighter')
|
||||
? (cur.points?.length ?? 0) >= 2
|
||||
: !!(cur.from && cur.to && (cur.from.x !== cur.to.x || cur.from.y !== cur.to.y));
|
||||
if (hasContent) {
|
||||
setOps((p) => [...p, cur]);
|
||||
setRedoStack([]);
|
||||
}
|
||||
setDraftTick((n) => n + 1);
|
||||
};
|
||||
```
|
||||
|
||||
Wire the handlers onto the `<canvas>` element — replace the existing `<canvas ref={canvasRef} className="..." />` JSX with:
|
||||
|
||||
```tsx
|
||||
<canvas
|
||||
ref={canvasRef}
|
||||
onPointerDown={handlePointerDown}
|
||||
onPointerMove={handlePointerMove}
|
||||
onPointerUp={handlePointerUp}
|
||||
onPointerLeave={handlePointerUp}
|
||||
className="max-h-full max-w-full cursor-crosshair touch-none rounded-lg border border-line/40 bg-black shadow-2xl"
|
||||
/>
|
||||
```
|
||||
|
||||
- [ ] **Step 3: Typecheck**
|
||||
|
||||
Run: `pnpm --filter @chat-app/desktop typecheck`
|
||||
Expected: PASS.
|
||||
|
||||
- [ ] **Step 4: Commit**
|
||||
|
||||
```bash
|
||||
git add apps/desktop/src/components/ImageAnnotator.tsx
|
||||
git commit -m "feat(P4A.T2): annotator drawing engine — pen/arrow/rect/circle/text/highlighter"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 3: Toolbar — tool picker, color swatches, width swatches, undo/redo
|
||||
|
||||
**Why:** The user can already DRAW (default pen + red + width 4) — but can't change tool/color/width or visibly undo. This task adds the controls.
|
||||
|
||||
**Files:**
|
||||
- Modify: `apps/desktop/src/components/ImageAnnotator.tsx` (replace the footer)
|
||||
|
||||
- [ ] **Step 1: Add the tool/color/width palette to the footer**
|
||||
|
||||
Replace the existing `<footer>` element with:
|
||||
|
||||
```tsx
|
||||
<footer className="flex shrink-0 flex-wrap items-center gap-4 border-t border-line/40 bg-surface-2 px-4 py-3">
|
||||
<div className="flex items-center gap-1">
|
||||
{(['pen', 'highlighter', 'arrow', 'rect', 'circle', 'text'] as AnnotatorTool[]).map((id) => {
|
||||
const label = t('app:annotator.tool.' + id, {
|
||||
defaultValue:
|
||||
id === 'pen' ? 'Stift'
|
||||
: id === 'highlighter' ? 'Marker'
|
||||
: id === 'arrow' ? 'Pfeil'
|
||||
: id === 'rect' ? 'Rechteck'
|
||||
: id === 'circle' ? 'Kreis'
|
||||
: 'Text',
|
||||
});
|
||||
const active = tool === id;
|
||||
return (
|
||||
<button
|
||||
key={id}
|
||||
type="button"
|
||||
onClick={() => setTool(id)}
|
||||
aria-pressed={active}
|
||||
title={label}
|
||||
className={
|
||||
'flex h-8 w-8 cursor-pointer items-center justify-center rounded-md text-xs font-semibold transition ' +
|
||||
(active
|
||||
? 'bg-accent/20 text-accent ring-2 ring-accent/40'
|
||||
: 'bg-surface-3 text-fg-muted hover:bg-surface hover:text-fg')
|
||||
}
|
||||
>
|
||||
{toolGlyph(id)}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
<div className="h-6 w-px bg-line/40" aria-hidden />
|
||||
|
||||
<div className="flex items-center gap-1">
|
||||
{(['#ef4444', '#3b82f6', '#22c55e', '#facc15', '#a855f7', '#ffffff'] as AnnotatorColor[]).map((c) => {
|
||||
const active = color === c;
|
||||
return (
|
||||
<button
|
||||
key={c}
|
||||
type="button"
|
||||
onClick={() => setColor(c)}
|
||||
aria-pressed={active}
|
||||
aria-label={c}
|
||||
className={
|
||||
'h-6 w-6 cursor-pointer rounded-full border-2 transition ' +
|
||||
(active ? 'border-fg scale-110' : 'border-line/40 hover:scale-105')
|
||||
}
|
||||
style={{ backgroundColor: c }}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
<div className="h-6 w-px bg-line/40" aria-hidden />
|
||||
|
||||
<div className="flex items-center gap-1">
|
||||
{([2, 4, 8] as AnnotatorWidth[]).map((w) => {
|
||||
const active = width === w;
|
||||
return (
|
||||
<button
|
||||
key={w}
|
||||
type="button"
|
||||
onClick={() => setWidth(w)}
|
||||
aria-pressed={active}
|
||||
title={w + 'px'}
|
||||
className={
|
||||
'flex h-8 w-8 cursor-pointer items-center justify-center rounded-md transition ' +
|
||||
(active
|
||||
? 'bg-accent/20 ring-2 ring-accent/40'
|
||||
: 'bg-surface-3 hover:bg-surface')
|
||||
}
|
||||
>
|
||||
<div
|
||||
className="rounded-full bg-fg"
|
||||
style={{ width: w + 2 + 'px', height: w + 2 + 'px' }}
|
||||
/>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
<div className="h-6 w-px bg-line/40" aria-hidden />
|
||||
|
||||
<div className="flex items-center gap-1">
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleUndo}
|
||||
disabled={ops.length === 0}
|
||||
title={t('app:annotator.undo', { defaultValue: 'Rückgängig (Ctrl+Z)' })}
|
||||
className="flex h-8 w-8 cursor-pointer items-center justify-center rounded-md bg-surface-3 text-fg-muted transition hover:bg-surface hover:text-fg disabled:cursor-not-allowed disabled:opacity-40"
|
||||
>
|
||||
↶
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleRedo}
|
||||
disabled={redoStack.length === 0}
|
||||
title={t('app:annotator.redo', { defaultValue: 'Wiederholen (Ctrl+Y)' })}
|
||||
className="flex h-8 w-8 cursor-pointer items-center justify-center rounded-md bg-surface-3 text-fg-muted transition hover:bg-surface hover:text-fg disabled:cursor-not-allowed disabled:opacity-40"
|
||||
>
|
||||
↷
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="ml-auto flex items-center gap-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleReset}
|
||||
disabled={ops.length === 0}
|
||||
className="cursor-pointer rounded-md border border-line bg-surface-3 px-3 py-1.5 text-xs font-medium text-fg-muted transition hover:bg-surface disabled:cursor-not-allowed disabled:opacity-50"
|
||||
>
|
||||
{t('app:annotator.reset', { defaultValue: 'Zurücksetzen' })}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleSave}
|
||||
disabled={!imageLoaded}
|
||||
className="cursor-pointer rounded-md bg-accent px-4 py-1.5 text-xs font-semibold text-accent-fg transition hover:bg-accent/90 disabled:cursor-not-allowed disabled:opacity-50"
|
||||
>
|
||||
{t('app:annotator.save', { defaultValue: 'Speichern' })}
|
||||
</button>
|
||||
</div>
|
||||
</footer>
|
||||
```
|
||||
|
||||
And add this helper at the bottom of the file (after `renderOp`):
|
||||
|
||||
```ts
|
||||
function toolGlyph(t: AnnotatorTool): string {
|
||||
switch (t) {
|
||||
case 'pen': return '✎';
|
||||
case 'highlighter': return '🖍';
|
||||
case 'arrow': return '↗';
|
||||
case 'rect': return '▭';
|
||||
case 'circle': return '◯';
|
||||
case 'text': return 'T';
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Typecheck**
|
||||
|
||||
Run: `pnpm --filter @chat-app/desktop typecheck`
|
||||
Expected: PASS.
|
||||
|
||||
- [ ] **Step 3: Commit**
|
||||
|
||||
```bash
|
||||
git add apps/desktop/src/components/ImageAnnotator.tsx
|
||||
git commit -m "feat(P4A.T3): annotator toolbar — tool/color/width/undo/redo controls"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 4: Wire annotator into the composer — "✏" overlay on image previews + Save round-trip
|
||||
|
||||
**Why:** The annotator is fully functional but unreachable from the UI. This task adds the entry point.
|
||||
|
||||
**Files:**
|
||||
- Modify: `apps/desktop/src/pages/ConversationPage.tsx` (the `AttachmentPreview` function at line ~1393 + the call site at line ~915 + add ImageAnnotator import/state)
|
||||
|
||||
- [ ] **Step 1: Extend `AttachmentPreview` with an `onEdit` prop**
|
||||
|
||||
Replace the existing `AttachmentPreview` function (line ~1393) with:
|
||||
|
||||
```tsx
|
||||
function AttachmentPreview({
|
||||
file,
|
||||
onRemove,
|
||||
onEdit,
|
||||
}: {
|
||||
file: File;
|
||||
onRemove: () => void;
|
||||
onEdit?: () => void;
|
||||
}) {
|
||||
const isImage = file.type.startsWith('image/');
|
||||
const [url, setUrl] = useState<string | null>(null);
|
||||
useEffect(() => {
|
||||
if (!isImage) return;
|
||||
const u = URL.createObjectURL(file);
|
||||
setUrl(u);
|
||||
return () => URL.revokeObjectURL(u);
|
||||
}, [file, isImage]);
|
||||
return (
|
||||
<div className="group relative overflow-hidden rounded-lg border border-line bg-surface-2 dark:bg-[#2b2d31]">
|
||||
{isImage && url ? (
|
||||
<img src={url} alt={file.name} className="block h-20 w-20 object-cover" />
|
||||
) : (
|
||||
<div className="flex h-20 w-32 flex-col justify-center gap-0.5 px-2 text-[10px]">
|
||||
<span className="truncate font-semibold text-fg" title={file.name}>
|
||||
{file.name || 'Datei'}
|
||||
</span>
|
||||
<span className="text-fg-muted">{file.type || 'unbekannt'}</span>
|
||||
<span className="text-fg-muted">{(file.size / 1024).toFixed(0)} KB</span>
|
||||
</div>
|
||||
)}
|
||||
{isImage && onEdit && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onEdit}
|
||||
aria-label="Bearbeiten"
|
||||
title="Bearbeiten"
|
||||
className="absolute bottom-1 left-1 flex h-5 w-5 cursor-pointer items-center justify-center rounded-full bg-black/70 text-white opacity-0 transition group-hover:opacity-100 hover:bg-accent/80"
|
||||
>
|
||||
<PencilIcon className="h-3 w-3" />
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
type="button"
|
||||
onClick={onRemove}
|
||||
aria-label="Entfernen"
|
||||
className="absolute right-1 top-1 flex h-5 w-5 cursor-pointer items-center justify-center rounded-full bg-black/70 text-white transition hover:bg-rose-500/80"
|
||||
>
|
||||
<XIcon className="h-3 w-3" />
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
If `PencilIcon` isn't already exported from `./components/icons` (grep first: `Grep -n "PencilIcon\|EditIcon" apps/desktop/src/components/icons*`), inline this small SVG below `AttachmentPreview`:
|
||||
|
||||
```tsx
|
||||
function PencilIcon(props: React.SVGProps<SVGSVGElement>) {
|
||||
return (
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={2}
|
||||
strokeLinecap="round" strokeLinejoin="round" {...props}>
|
||||
<path d="M12 20h9" />
|
||||
<path d="M16.5 3.5a2.121 2.121 0 0 1 3 3L7 19l-4 1 1-4 12.5-12.5z" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Add ImageAnnotator state + import to ConversationPage**
|
||||
|
||||
In the import block at the top of the file (alongside other `../components/...` imports), add:
|
||||
|
||||
```ts
|
||||
import { ImageAnnotator } from '../components/ImageAnnotator';
|
||||
```
|
||||
|
||||
Near the top of `ConversationPage` where other `useState`s live (e.g. near `attachments`), add:
|
||||
|
||||
```ts
|
||||
const [annotatingIndex, setAnnotatingIndex] = useState<number | null>(null);
|
||||
```
|
||||
|
||||
- [ ] **Step 3: Pass `onEdit` to AttachmentPreview**
|
||||
|
||||
In the `attachments.map(...)` at line ~915, change the JSX to:
|
||||
|
||||
```tsx
|
||||
{attachments.map((file, idx) => (
|
||||
<AttachmentPreview
|
||||
key={idx}
|
||||
file={file}
|
||||
onRemove={() => setAttachments((prev) => prev.filter((_, i) => i !== idx))}
|
||||
onEdit={
|
||||
file.type.startsWith('image/')
|
||||
? () => setAnnotatingIndex(idx)
|
||||
: undefined
|
||||
}
|
||||
/>
|
||||
))}
|
||||
```
|
||||
|
||||
- [ ] **Step 4: Render the annotator at the page root when `annotatingIndex !== null`**
|
||||
|
||||
Place this near the end of the JSX return — just before the closing `</div>` of the page root:
|
||||
|
||||
```tsx
|
||||
{annotatingIndex !== null && attachments[annotatingIndex] && (
|
||||
<ImageAnnotator
|
||||
file={attachments[annotatingIndex]!}
|
||||
onCancel={() => setAnnotatingIndex(null)}
|
||||
onSave={(next) => {
|
||||
setAttachments((prev) => prev.map((f, i) => (i === annotatingIndex ? next : f)));
|
||||
setAnnotatingIndex(null);
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
```
|
||||
|
||||
- [ ] **Step 5: Typecheck**
|
||||
|
||||
Run: `pnpm --filter @chat-app/desktop typecheck`
|
||||
Expected: PASS.
|
||||
|
||||
- [ ] **Step 6: Commit**
|
||||
|
||||
```bash
|
||||
git add apps/desktop/src/pages/ConversationPage.tsx
|
||||
git commit -m "feat(P4A.T4): wire ImageAnnotator into composer attachment preview"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Final gate
|
||||
|
||||
- [ ] **Step 1: Typecheck both packages**
|
||||
|
||||
Run: `pnpm --filter @chat-app/shared typecheck && pnpm --filter @chat-app/desktop typecheck`
|
||||
Expected: both PASS.
|
||||
|
||||
- [ ] **Step 2: Run shared tests (sanity — these changes don't touch shared)**
|
||||
|
||||
Run: `pnpm --filter @chat-app/shared test -- --run`
|
||||
Expected: PASS — same count as Phase 3 baseline (33 tests).
|
||||
|
||||
- [ ] **Step 3: Verify no uncommitted changes**
|
||||
|
||||
Run: `git status`
|
||||
Expected: clean working tree on `main`.
|
||||
|
||||
- [ ] **Step 4: Report**
|
||||
|
||||
Report: "Phase 4A (Image Annotation) code-complete on `main`. Restart dev, attach an image in any chat, hover the preview → ✏ button appears → click → annotator opens. Draw, choose tools/colors/widths, undo/redo, Save → preview updates with the annotated PNG, ready to send. No migration. Released? defer to user."
|
||||
|
||||
---
|
||||
|
||||
## Self-review (resolved inline)
|
||||
|
||||
1. **Spec coverage** (against `docs/superpowers/specs/2026-05-16-fifteen-features-design.md` lines 97-101):
|
||||
- "Attachment-picker for images shows a new ✏ Bearbeiten button before send" → T4 adds the overlay button (image-only via `file.type.startsWith('image/')`).
|
||||
- "Opens `<ImageAnnotator>` modal: canvas overlay on the image" → T1 mounts canvas at image's natural size.
|
||||
- "Tools: pen, arrow, rectangle, circle, text, highlighter" → T2 implements all 6 in `renderOp`.
|
||||
- "6 colors, 3 stroke widths" → T3 swatches use the exact AnnotatorColor/AnnotatorWidth tuples from T1.
|
||||
- "Undo/Redo stack, Reset, Save" → T1 callbacks + T3 toolbar buttons; Ctrl+Z / Ctrl+Y bindings in T1 keydown effect.
|
||||
- "On Save: canvas.toBlob({type: 'image/png'}) flattens" → T1's `handleSave` + T4's `setAttachments(... map ... idx === annotatingIndex ? next : f)`.
|
||||
|
||||
2. **Placeholders:** none. Every step has concrete code.
|
||||
|
||||
3. **Type consistency:**
|
||||
- `AnnotatorTool`/`AnnotatorColor`/`AnnotatorWidth` defined in T1, consumed everywhere downstream.
|
||||
- `AnnotatorOp` fields match `renderOp` switch arms in T2.
|
||||
- `ImageAnnotator` props shape (`file`/`onCancel`/`onSave`) used identically in T4's call site.
|
||||
- `AttachmentPreview` extended with optional `onEdit?: () => void` in T4; existing callers passing only `file`/`onRemove` continue to compile because it's optional.
|
||||
|
||||
4. **One ambiguity surfaced + resolved:** the spec doesn't say whether to overwrite the original File or keep a side-by-side copy. Plan replaces the slot with a `<basename>-annotated.png` File so the recipient sees the marked-up version with a clear filename hint.
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -477,6 +477,111 @@ export type Database = {
|
||||
},
|
||||
]
|
||||
}
|
||||
conversation_whiteboards: {
|
||||
Row: {
|
||||
id: string
|
||||
conversation_id: string
|
||||
owner_user_id: string
|
||||
created_at: string
|
||||
}
|
||||
Insert: {
|
||||
id?: string
|
||||
conversation_id: string
|
||||
owner_user_id: string
|
||||
created_at?: string
|
||||
}
|
||||
Update: {
|
||||
id?: string
|
||||
conversation_id?: string
|
||||
owner_user_id?: string
|
||||
created_at?: string
|
||||
}
|
||||
Relationships: [
|
||||
{
|
||||
foreignKeyName: "conversation_whiteboards_conversation_id_fkey"
|
||||
columns: ["conversation_id"]
|
||||
isOneToOne: false
|
||||
referencedRelation: "conversations"
|
||||
referencedColumns: ["id"]
|
||||
},
|
||||
]
|
||||
}
|
||||
whiteboard_strokes: {
|
||||
Row: {
|
||||
id: string
|
||||
whiteboard_id: string
|
||||
author_user_id: string
|
||||
stroke_json: Json
|
||||
created_at: string
|
||||
}
|
||||
Insert: {
|
||||
id?: string
|
||||
whiteboard_id: string
|
||||
author_user_id: string
|
||||
stroke_json: Json
|
||||
created_at?: string
|
||||
}
|
||||
Update: {
|
||||
id?: string
|
||||
whiteboard_id?: string
|
||||
author_user_id?: string
|
||||
stroke_json?: Json
|
||||
created_at?: string
|
||||
}
|
||||
Relationships: [
|
||||
{
|
||||
foreignKeyName: "whiteboard_strokes_whiteboard_id_fkey"
|
||||
columns: ["whiteboard_id"]
|
||||
isOneToOne: false
|
||||
referencedRelation: "conversation_whiteboards"
|
||||
referencedColumns: ["id"]
|
||||
},
|
||||
]
|
||||
}
|
||||
user_soundboards: {
|
||||
Row: {
|
||||
id: string
|
||||
user_id: string
|
||||
name: string
|
||||
mime: string
|
||||
size: number
|
||||
category: string | null
|
||||
hotkey: string | null
|
||||
gain: number
|
||||
sort_order: number
|
||||
storage_path: string
|
||||
created_at: string
|
||||
updated_at: string
|
||||
}
|
||||
Insert: {
|
||||
id?: string
|
||||
user_id: string
|
||||
name: string
|
||||
mime: string
|
||||
size: number
|
||||
category?: string | null
|
||||
hotkey?: string | null
|
||||
gain?: number
|
||||
sort_order?: number
|
||||
storage_path: string
|
||||
created_at?: string
|
||||
updated_at?: string
|
||||
}
|
||||
Update: {
|
||||
id?: string
|
||||
user_id?: string
|
||||
name?: string
|
||||
mime?: string
|
||||
size?: number
|
||||
category?: string | null
|
||||
hotkey?: string | null
|
||||
gain?: number
|
||||
sort_order?: number
|
||||
storage_path?: string
|
||||
updated_at?: string
|
||||
}
|
||||
Relationships: []
|
||||
}
|
||||
}
|
||||
Views: {
|
||||
[_ in never]: never
|
||||
|
||||
@@ -76,7 +76,17 @@ export interface PollPayload {
|
||||
options: PollOption[];
|
||||
}
|
||||
|
||||
export type MessagePayload = TextMessagePayload | CallEventPayload | PollPayload;
|
||||
export interface WhiteboardPayload {
|
||||
v: 1;
|
||||
type: 'whiteboard';
|
||||
whiteboard_id: string;
|
||||
}
|
||||
|
||||
export type MessagePayload =
|
||||
| TextMessagePayload
|
||||
| CallEventPayload
|
||||
| PollPayload
|
||||
| WhiteboardPayload;
|
||||
|
||||
export type ParsedMessagePayload =
|
||||
| {
|
||||
@@ -95,6 +105,10 @@ export type ParsedMessagePayload =
|
||||
kind: 'poll';
|
||||
question: string;
|
||||
options: PollOption[];
|
||||
}
|
||||
| {
|
||||
kind: 'whiteboard';
|
||||
whiteboardId: string;
|
||||
};
|
||||
|
||||
export function serializeMessagePayload(payload: MessagePayload): string {
|
||||
@@ -151,6 +165,13 @@ export function parseMessagePayload(raw: string | null): ParsedMessagePayload {
|
||||
options,
|
||||
};
|
||||
}
|
||||
if (obj.type === 'whiteboard') {
|
||||
const p = obj as Partial<WhiteboardPayload>;
|
||||
const id = typeof p.whiteboard_id === 'string' && p.whiteboard_id.length > 0
|
||||
? p.whiteboard_id
|
||||
: '';
|
||||
return { kind: 'whiteboard', whiteboardId: id };
|
||||
}
|
||||
const t = obj as TextMessagePayload;
|
||||
return {
|
||||
kind: 'text',
|
||||
|
||||
@@ -10,6 +10,8 @@ export * from './userKeyMigration';
|
||||
export * from './pinnedMessages';
|
||||
export * from './mentions';
|
||||
export * from './viewOnceAttachments';
|
||||
export * from './whiteboards';
|
||||
export * from './soundboards';
|
||||
|
||||
// ----- RPC wrappers ---------------------------------------------------------
|
||||
|
||||
|
||||
@@ -0,0 +1,134 @@
|
||||
import { beforeAll, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import { getCryptoBackend, setCryptoBackend } from '../crypto/backend';
|
||||
import { makeWasmTestBackend } from '../crypto/testBackend';
|
||||
import {
|
||||
decryptSoundEnvelope,
|
||||
encryptSoundBlob,
|
||||
listOwnSounds,
|
||||
upsertSound,
|
||||
} from './soundboards';
|
||||
|
||||
beforeAll(async () => {
|
||||
setCryptoBackend(await makeWasmTestBackend());
|
||||
});
|
||||
|
||||
function makeClient(opts: {
|
||||
user?: { id: string } | null;
|
||||
selectData?: unknown[];
|
||||
upsertReturn?: { data: unknown; error: unknown };
|
||||
}): any {
|
||||
const order = vi.fn().mockResolvedValue({ data: opts.selectData ?? [], error: null });
|
||||
const eq = vi.fn().mockReturnValue({ order });
|
||||
const selectChain = vi.fn().mockReturnValue({ eq });
|
||||
const single = vi.fn().mockResolvedValue(opts.upsertReturn ?? { data: {}, error: null });
|
||||
const upsertSelect = vi.fn().mockReturnValue({ single });
|
||||
const upsertChain = vi.fn().mockReturnValue({ select: upsertSelect });
|
||||
const from = vi.fn().mockReturnValue({
|
||||
select: selectChain,
|
||||
upsert: upsertChain,
|
||||
});
|
||||
return {
|
||||
auth: { getUser: vi.fn().mockResolvedValue({ data: { user: opts.user ?? { id: 'u-1' } } }) },
|
||||
from,
|
||||
};
|
||||
}
|
||||
|
||||
describe('encryptSoundBlob ↔ decryptSoundEnvelope', () => {
|
||||
it('round-trips bytes via sealed-to-self crypto_box', async () => {
|
||||
const backend = getCryptoBackend();
|
||||
const seed = backend.randomBytes(32);
|
||||
const pub = backend.scalarMultBase(seed);
|
||||
const blob = new Blob([new Uint8Array([1, 2, 3, 4, 5, 6, 7, 8])]);
|
||||
|
||||
const envelope = await encryptSoundBlob(blob, pub, seed);
|
||||
expect(envelope.length).toBeGreaterThan(24);
|
||||
|
||||
const plain = await decryptSoundEnvelope(envelope, pub, seed);
|
||||
expect(Array.from(plain)).toEqual([1, 2, 3, 4, 5, 6, 7, 8]);
|
||||
});
|
||||
|
||||
it('rejects an envelope that is too short', async () => {
|
||||
const backend = getCryptoBackend();
|
||||
const seed = backend.randomBytes(32);
|
||||
const pub = backend.scalarMultBase(seed);
|
||||
await expect(
|
||||
decryptSoundEnvelope(new Uint8Array(20), pub, seed),
|
||||
).rejects.toThrow(/too_short/);
|
||||
});
|
||||
});
|
||||
|
||||
describe('listOwnSounds', () => {
|
||||
it('maps DB rows to camelCase', async () => {
|
||||
const client = makeClient({
|
||||
selectData: [
|
||||
{
|
||||
id: 's-1',
|
||||
user_id: 'u-1',
|
||||
name: 'horn',
|
||||
mime: 'audio/mpeg',
|
||||
size: 1234,
|
||||
category: 'fx',
|
||||
hotkey: 'F1',
|
||||
gain: 0.8,
|
||||
sort_order: 0,
|
||||
storage_path: 'u-1/s-1.bin',
|
||||
created_at: '2026-05-16T00:00:00Z',
|
||||
updated_at: '2026-05-16T00:00:00Z',
|
||||
},
|
||||
],
|
||||
});
|
||||
const out = await listOwnSounds(client);
|
||||
expect(out[0]).toEqual({
|
||||
id: 's-1',
|
||||
userId: 'u-1',
|
||||
name: 'horn',
|
||||
mime: 'audio/mpeg',
|
||||
size: 1234,
|
||||
category: 'fx',
|
||||
hotkey: 'F1',
|
||||
gain: 0.8,
|
||||
sortOrder: 0,
|
||||
storagePath: 'u-1/s-1.bin',
|
||||
createdAt: '2026-05-16T00:00:00Z',
|
||||
updatedAt: '2026-05-16T00:00:00Z',
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('upsertSound', () => {
|
||||
it('returns mapped RemoteSound after upsert', async () => {
|
||||
const upsertReturn = {
|
||||
data: {
|
||||
id: 's-1',
|
||||
user_id: 'u-1',
|
||||
name: 'horn',
|
||||
mime: 'audio/mpeg',
|
||||
size: 1234,
|
||||
category: null,
|
||||
hotkey: null,
|
||||
gain: 1,
|
||||
sort_order: 0,
|
||||
storage_path: 'u-1/s-1.bin',
|
||||
created_at: '2026-05-16T00:00:00Z',
|
||||
updated_at: '2026-05-16T00:00:00Z',
|
||||
},
|
||||
error: null,
|
||||
};
|
||||
const client = makeClient({ upsertReturn });
|
||||
const out = await upsertSound(client, {
|
||||
id: 's-1',
|
||||
name: 'horn',
|
||||
mime: 'audio/mpeg',
|
||||
size: 1234,
|
||||
category: null,
|
||||
hotkey: null,
|
||||
gain: 1,
|
||||
sortOrder: 0,
|
||||
storagePath: 'u-1/s-1.bin',
|
||||
updatedAtIso: '2026-05-16T00:00:00Z',
|
||||
});
|
||||
expect(out.id).toBe('s-1');
|
||||
expect(out.userId).toBe('u-1');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,189 @@
|
||||
import { decryptFrom, encryptFor } from '../crypto/box';
|
||||
import type { AppSupabaseClient } from '../supabase/client';
|
||||
|
||||
export const SOUNDBOARDS_BUCKET = 'soundboards';
|
||||
|
||||
export interface RemoteSound {
|
||||
id: string;
|
||||
userId: string;
|
||||
name: string;
|
||||
mime: string;
|
||||
size: number;
|
||||
category: string | null;
|
||||
hotkey: string | null;
|
||||
gain: number;
|
||||
sortOrder: number;
|
||||
storagePath: string;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
export interface UpsertSoundInput {
|
||||
id: string;
|
||||
name: string;
|
||||
mime: string;
|
||||
size: number;
|
||||
category: string | null;
|
||||
hotkey: string | null;
|
||||
gain: number;
|
||||
sortOrder: number;
|
||||
storagePath: string;
|
||||
updatedAtIso: string;
|
||||
}
|
||||
|
||||
export async function listOwnSounds(client: AppSupabaseClient): Promise<RemoteSound[]> {
|
||||
const { data: session } = await client.auth.getUser();
|
||||
if (!session.user) throw new Error('not authenticated');
|
||||
const { data, error } = await client
|
||||
.from('user_soundboards')
|
||||
.select(
|
||||
'id, user_id, name, mime, size, category, hotkey, gain, sort_order, storage_path, created_at, updated_at',
|
||||
)
|
||||
.eq('user_id', session.user.id)
|
||||
.order('updated_at', { ascending: false });
|
||||
if (error) throw error;
|
||||
return data.map(mapRow);
|
||||
}
|
||||
|
||||
export async function upsertSound(
|
||||
client: AppSupabaseClient,
|
||||
input: UpsertSoundInput,
|
||||
): Promise<RemoteSound> {
|
||||
const { data: session } = await client.auth.getUser();
|
||||
if (!session.user) throw new Error('not authenticated');
|
||||
const { data, error } = await client
|
||||
.from('user_soundboards')
|
||||
.upsert(
|
||||
{
|
||||
id: input.id,
|
||||
user_id: session.user.id,
|
||||
name: input.name,
|
||||
mime: input.mime,
|
||||
size: input.size,
|
||||
category: input.category,
|
||||
hotkey: input.hotkey,
|
||||
gain: input.gain,
|
||||
sort_order: input.sortOrder,
|
||||
storage_path: input.storagePath,
|
||||
updated_at: input.updatedAtIso,
|
||||
},
|
||||
{ onConflict: 'id' },
|
||||
)
|
||||
.select(
|
||||
'id, user_id, name, mime, size, category, hotkey, gain, sort_order, storage_path, created_at, updated_at',
|
||||
)
|
||||
.single();
|
||||
if (error) throw error;
|
||||
return mapRow(data);
|
||||
}
|
||||
|
||||
export async function deleteSound(
|
||||
client: AppSupabaseClient,
|
||||
soundId: string,
|
||||
): Promise<void> {
|
||||
const { data: session } = await client.auth.getUser();
|
||||
if (!session.user) throw new Error('not authenticated');
|
||||
const { data: row } = await client
|
||||
.from('user_soundboards')
|
||||
.select('storage_path')
|
||||
.eq('id', soundId)
|
||||
.eq('user_id', session.user.id)
|
||||
.maybeSingle();
|
||||
if (row?.storage_path) {
|
||||
const { error: storageErr } = await client.storage
|
||||
.from(SOUNDBOARDS_BUCKET)
|
||||
.remove([row.storage_path]);
|
||||
if (storageErr && !/not found/i.test(storageErr.message)) throw storageErr;
|
||||
}
|
||||
const { error } = await client
|
||||
.from('user_soundboards')
|
||||
.delete()
|
||||
.eq('id', soundId)
|
||||
.eq('user_id', session.user.id);
|
||||
if (error) throw error;
|
||||
}
|
||||
|
||||
// Sealed-to-self envelope: nonce || ciphertext. encryptFor's sender and
|
||||
// recipient are both the current user, equivalent to crypto_box_seal but
|
||||
// reuses the existing helper (no new backend method).
|
||||
export async function encryptSoundBlob(
|
||||
blob: Blob,
|
||||
myPublicKey: Uint8Array,
|
||||
myPrivateKey: Uint8Array,
|
||||
): Promise<Uint8Array> {
|
||||
const bytes = new Uint8Array(await blob.arrayBuffer());
|
||||
const { ciphertext, nonce } = await encryptFor(bytes, myPublicKey, myPrivateKey);
|
||||
const out = new Uint8Array(nonce.length + ciphertext.length);
|
||||
out.set(nonce, 0);
|
||||
out.set(ciphertext, nonce.length);
|
||||
return out;
|
||||
}
|
||||
|
||||
export async function decryptSoundEnvelope(
|
||||
envelope: Uint8Array,
|
||||
myPublicKey: Uint8Array,
|
||||
myPrivateKey: Uint8Array,
|
||||
): Promise<Uint8Array> {
|
||||
const NONCE_LEN = 24;
|
||||
if (envelope.length < NONCE_LEN + 16) {
|
||||
throw new Error('sound_envelope_too_short');
|
||||
}
|
||||
const nonce = envelope.slice(0, NONCE_LEN);
|
||||
const ciphertext = envelope.slice(NONCE_LEN);
|
||||
return decryptFrom(ciphertext, nonce, myPublicKey, myPrivateKey);
|
||||
}
|
||||
|
||||
export async function uploadSoundCiphertext(
|
||||
client: AppSupabaseClient,
|
||||
storagePath: string,
|
||||
ciphertext: Uint8Array,
|
||||
): Promise<void> {
|
||||
const { error } = await client.storage
|
||||
.from(SOUNDBOARDS_BUCKET)
|
||||
.upload(storagePath, ciphertext, {
|
||||
contentType: 'application/octet-stream',
|
||||
upsert: true,
|
||||
});
|
||||
if (error) throw error;
|
||||
}
|
||||
|
||||
export async function downloadSoundCiphertext(
|
||||
client: AppSupabaseClient,
|
||||
storagePath: string,
|
||||
): Promise<Uint8Array> {
|
||||
const { data, error } = await client.storage
|
||||
.from(SOUNDBOARDS_BUCKET)
|
||||
.download(storagePath);
|
||||
if (error) throw error;
|
||||
return new Uint8Array(await data.arrayBuffer());
|
||||
}
|
||||
|
||||
function mapRow(row: {
|
||||
id: string;
|
||||
user_id: string;
|
||||
name: string;
|
||||
mime: string;
|
||||
size: number;
|
||||
category: string | null;
|
||||
hotkey: string | null;
|
||||
gain: number;
|
||||
sort_order: number;
|
||||
storage_path: string;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
}): RemoteSound {
|
||||
return {
|
||||
id: row.id,
|
||||
userId: row.user_id,
|
||||
name: row.name,
|
||||
mime: row.mime,
|
||||
size: row.size,
|
||||
category: row.category,
|
||||
hotkey: row.hotkey,
|
||||
gain: row.gain,
|
||||
sortOrder: row.sort_order,
|
||||
storagePath: row.storage_path,
|
||||
createdAt: row.created_at,
|
||||
updatedAt: row.updated_at,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,121 @@
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import {
|
||||
clearWhiteboardStrokes,
|
||||
createWhiteboard,
|
||||
insertWhiteboardStroke,
|
||||
listWhiteboardStrokes,
|
||||
} from './whiteboards';
|
||||
|
||||
function makeClient(opts: {
|
||||
user?: { id: string } | null;
|
||||
insertReturn?: { data: unknown; error: unknown };
|
||||
selectReturn?: { data: unknown[]; error: unknown };
|
||||
deleteReturn?: { error: unknown };
|
||||
}): any {
|
||||
const single = vi.fn().mockResolvedValue(opts.insertReturn ?? { data: {}, error: null });
|
||||
const order = vi.fn().mockResolvedValue(opts.selectReturn ?? { data: [], error: null });
|
||||
const eqDelete = vi.fn().mockResolvedValue(opts.deleteReturn ?? { error: null });
|
||||
const insertSelect = vi.fn().mockReturnValue({ single });
|
||||
const insertChain = vi.fn().mockReturnValue({ select: insertSelect });
|
||||
const selectChain = vi.fn().mockReturnValue({
|
||||
eq: vi.fn().mockReturnValue({ order }),
|
||||
});
|
||||
const deleteChain = vi.fn().mockReturnValue({ eq: eqDelete });
|
||||
const from = vi.fn().mockReturnValue({
|
||||
insert: insertChain,
|
||||
select: selectChain,
|
||||
delete: deleteChain,
|
||||
});
|
||||
return {
|
||||
auth: { getUser: vi.fn().mockResolvedValue({ data: { user: opts.user ?? { id: 'u-1' } } }) },
|
||||
from,
|
||||
};
|
||||
}
|
||||
|
||||
describe('createWhiteboard', () => {
|
||||
it('inserts with owner = current user', async () => {
|
||||
const client = makeClient({
|
||||
insertReturn: {
|
||||
data: {
|
||||
id: 'w-1',
|
||||
conversation_id: 'c-1',
|
||||
owner_user_id: 'u-1',
|
||||
created_at: '2026-05-16T00:00:00Z',
|
||||
},
|
||||
error: null,
|
||||
},
|
||||
});
|
||||
const out = await createWhiteboard(client, 'c-1');
|
||||
expect(out).toEqual({
|
||||
id: 'w-1',
|
||||
conversationId: 'c-1',
|
||||
ownerUserId: 'u-1',
|
||||
createdAt: '2026-05-16T00:00:00Z',
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('listWhiteboardStrokes', () => {
|
||||
it('maps DB rows to camelCase + ascending order', async () => {
|
||||
const client = makeClient({
|
||||
selectReturn: {
|
||||
data: [
|
||||
{
|
||||
id: 's-1',
|
||||
whiteboard_id: 'w-1',
|
||||
author_user_id: 'u-1',
|
||||
stroke_json: { tool: 'pen' },
|
||||
created_at: '2026-05-16T00:00:00Z',
|
||||
},
|
||||
],
|
||||
error: null,
|
||||
},
|
||||
});
|
||||
const out = await listWhiteboardStrokes(client, 'w-1');
|
||||
expect(out).toEqual([
|
||||
{
|
||||
id: 's-1',
|
||||
whiteboardId: 'w-1',
|
||||
authorUserId: 'u-1',
|
||||
strokeJson: { tool: 'pen' },
|
||||
createdAt: '2026-05-16T00:00:00Z',
|
||||
},
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('insertWhiteboardStroke', () => {
|
||||
it('writes author + stroke_json', async () => {
|
||||
const client = makeClient({
|
||||
insertReturn: {
|
||||
data: {
|
||||
id: 's-2',
|
||||
whiteboard_id: 'w-1',
|
||||
author_user_id: 'u-1',
|
||||
stroke_json: { tool: 'pen', points: [[0, 0, 0]] },
|
||||
created_at: '2026-05-16T00:00:01Z',
|
||||
},
|
||||
error: null,
|
||||
},
|
||||
});
|
||||
const out = await insertWhiteboardStroke(client, {
|
||||
whiteboardId: 'w-1',
|
||||
strokeJson: { tool: 'pen', points: [[0, 0, 0]] },
|
||||
});
|
||||
expect(out.id).toBe('s-2');
|
||||
expect((out.strokeJson as { tool: string }).tool).toBe('pen');
|
||||
});
|
||||
});
|
||||
|
||||
describe('clearWhiteboardStrokes', () => {
|
||||
it('does not throw on a successful delete', async () => {
|
||||
const client = makeClient({ deleteReturn: { error: null } });
|
||||
await expect(clearWhiteboardStrokes(client, 'w-1')).resolves.toBeUndefined();
|
||||
});
|
||||
|
||||
it('throws when delete returns an error', async () => {
|
||||
const client = makeClient({ deleteReturn: { error: { message: 'rls denied' } as any } });
|
||||
await expect(clearWhiteboardStrokes(client, 'w-1')).rejects.toBeTruthy();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,96 @@
|
||||
import type { Json } from '@chat-app/db-types';
|
||||
|
||||
import type { AppSupabaseClient } from '../supabase/client';
|
||||
|
||||
export interface Whiteboard {
|
||||
id: string;
|
||||
conversationId: string;
|
||||
ownerUserId: string;
|
||||
createdAt: string;
|
||||
}
|
||||
|
||||
export interface WhiteboardStroke {
|
||||
id: string;
|
||||
whiteboardId: string;
|
||||
authorUserId: string;
|
||||
strokeJson: unknown;
|
||||
createdAt: string;
|
||||
}
|
||||
|
||||
export async function createWhiteboard(
|
||||
client: AppSupabaseClient,
|
||||
conversationId: string,
|
||||
): Promise<Whiteboard> {
|
||||
const { data: session } = await client.auth.getUser();
|
||||
if (!session.user) throw new Error('not authenticated');
|
||||
const { data, error } = await client
|
||||
.from('conversation_whiteboards')
|
||||
.insert({
|
||||
conversation_id: conversationId,
|
||||
owner_user_id: session.user.id,
|
||||
})
|
||||
.select('id, conversation_id, owner_user_id, created_at')
|
||||
.single();
|
||||
if (error) throw error;
|
||||
return {
|
||||
id: data.id,
|
||||
conversationId: data.conversation_id,
|
||||
ownerUserId: data.owner_user_id,
|
||||
createdAt: data.created_at,
|
||||
};
|
||||
}
|
||||
|
||||
export async function listWhiteboardStrokes(
|
||||
client: AppSupabaseClient,
|
||||
whiteboardId: string,
|
||||
): Promise<WhiteboardStroke[]> {
|
||||
const { data, error } = await client
|
||||
.from('whiteboard_strokes')
|
||||
.select('id, whiteboard_id, author_user_id, stroke_json, created_at')
|
||||
.eq('whiteboard_id', whiteboardId)
|
||||
.order('created_at', { ascending: true });
|
||||
if (error) throw error;
|
||||
return data.map((row) => ({
|
||||
id: row.id,
|
||||
whiteboardId: row.whiteboard_id,
|
||||
authorUserId: row.author_user_id,
|
||||
strokeJson: row.stroke_json,
|
||||
createdAt: row.created_at,
|
||||
}));
|
||||
}
|
||||
|
||||
export async function insertWhiteboardStroke(
|
||||
client: AppSupabaseClient,
|
||||
params: { whiteboardId: string; strokeJson: unknown },
|
||||
): Promise<WhiteboardStroke> {
|
||||
const { data: session } = await client.auth.getUser();
|
||||
if (!session.user) throw new Error('not authenticated');
|
||||
const { data, error } = await client
|
||||
.from('whiteboard_strokes')
|
||||
.insert({
|
||||
whiteboard_id: params.whiteboardId,
|
||||
author_user_id: session.user.id,
|
||||
stroke_json: params.strokeJson as unknown as Json,
|
||||
})
|
||||
.select('id, whiteboard_id, author_user_id, stroke_json, created_at')
|
||||
.single();
|
||||
if (error) throw error;
|
||||
return {
|
||||
id: data.id,
|
||||
whiteboardId: data.whiteboard_id,
|
||||
authorUserId: data.author_user_id,
|
||||
strokeJson: data.stroke_json,
|
||||
createdAt: data.created_at,
|
||||
};
|
||||
}
|
||||
|
||||
export async function clearWhiteboardStrokes(
|
||||
client: AppSupabaseClient,
|
||||
whiteboardId: string,
|
||||
): Promise<void> {
|
||||
const { error } = await client
|
||||
.from('whiteboard_strokes')
|
||||
.delete()
|
||||
.eq('whiteboard_id', whiteboardId);
|
||||
if (error) throw error;
|
||||
}
|
||||
@@ -0,0 +1,122 @@
|
||||
-- Phase 4B: per-conversation whiteboards.
|
||||
--
|
||||
-- conversation_whiteboards: one row per whiteboard. Created by a member; the
|
||||
-- bubble that appears in the chat message timeline is a normal `messages`
|
||||
-- row whose plaintext payload is `{v:1, type:'whiteboard', whiteboard_id:<id>}`.
|
||||
--
|
||||
-- whiteboard_strokes: append-only stream of drawing operations. Each row is
|
||||
-- one user gesture (pen-down → pen-up); stroke_json carries the tool/color/
|
||||
-- width/points. Realtime subscribers replay rows in created_at order.
|
||||
|
||||
create table if not exists public.conversation_whiteboards (
|
||||
id uuid primary key default gen_random_uuid(),
|
||||
conversation_id uuid not null references public.conversations(id) on delete cascade,
|
||||
owner_user_id uuid not null references auth.users(id) on delete cascade,
|
||||
created_at timestamptz not null default now()
|
||||
);
|
||||
|
||||
create index if not exists conversation_whiteboards_conv_idx
|
||||
on public.conversation_whiteboards(conversation_id, created_at desc);
|
||||
|
||||
create table if not exists public.whiteboard_strokes (
|
||||
id uuid primary key default gen_random_uuid(),
|
||||
whiteboard_id uuid not null references public.conversation_whiteboards(id) on delete cascade,
|
||||
author_user_id uuid not null references auth.users(id) on delete cascade,
|
||||
stroke_json jsonb not null,
|
||||
created_at timestamptz not null default now()
|
||||
);
|
||||
|
||||
create index if not exists whiteboard_strokes_board_idx
|
||||
on public.whiteboard_strokes(whiteboard_id, created_at asc);
|
||||
|
||||
alter table public.conversation_whiteboards enable row level security;
|
||||
alter table public.whiteboard_strokes enable row level security;
|
||||
|
||||
drop policy if exists conversation_whiteboards_select on public.conversation_whiteboards;
|
||||
drop policy if exists conversation_whiteboards_insert on public.conversation_whiteboards;
|
||||
drop policy if exists conversation_whiteboards_delete on public.conversation_whiteboards;
|
||||
drop policy if exists whiteboard_strokes_select on public.whiteboard_strokes;
|
||||
drop policy if exists whiteboard_strokes_insert on public.whiteboard_strokes;
|
||||
drop policy if exists whiteboard_strokes_delete on public.whiteboard_strokes;
|
||||
|
||||
create policy conversation_whiteboards_select
|
||||
on public.conversation_whiteboards
|
||||
for select
|
||||
using (public.is_conversation_member(conversation_id));
|
||||
|
||||
create policy conversation_whiteboards_insert
|
||||
on public.conversation_whiteboards
|
||||
for insert
|
||||
with check (
|
||||
public.is_conversation_member(conversation_id)
|
||||
and owner_user_id = auth.uid()
|
||||
);
|
||||
|
||||
create policy conversation_whiteboards_delete
|
||||
on public.conversation_whiteboards
|
||||
for delete
|
||||
using (owner_user_id = auth.uid());
|
||||
|
||||
create policy whiteboard_strokes_select
|
||||
on public.whiteboard_strokes
|
||||
for select
|
||||
using (
|
||||
exists (
|
||||
select 1
|
||||
from public.conversation_whiteboards w
|
||||
where w.id = whiteboard_strokes.whiteboard_id
|
||||
and public.is_conversation_member(w.conversation_id)
|
||||
)
|
||||
);
|
||||
|
||||
create policy whiteboard_strokes_insert
|
||||
on public.whiteboard_strokes
|
||||
for insert
|
||||
with check (
|
||||
author_user_id = auth.uid()
|
||||
and exists (
|
||||
select 1
|
||||
from public.conversation_whiteboards w
|
||||
where w.id = whiteboard_id
|
||||
and public.is_conversation_member(w.conversation_id)
|
||||
)
|
||||
);
|
||||
|
||||
create policy whiteboard_strokes_delete
|
||||
on public.whiteboard_strokes
|
||||
for delete
|
||||
using (
|
||||
exists (
|
||||
select 1
|
||||
from public.conversation_whiteboards w
|
||||
where w.id = whiteboard_strokes.whiteboard_id
|
||||
and public.is_conversation_member(w.conversation_id)
|
||||
)
|
||||
);
|
||||
|
||||
alter table public.conversation_whiteboards replica identity full;
|
||||
alter table public.whiteboard_strokes replica identity full;
|
||||
|
||||
do $$
|
||||
begin
|
||||
if not exists (
|
||||
select 1
|
||||
from pg_publication_tables
|
||||
where pubname = 'supabase_realtime'
|
||||
and schemaname = 'public'
|
||||
and tablename = 'conversation_whiteboards'
|
||||
) then
|
||||
execute 'alter publication supabase_realtime add table public.conversation_whiteboards';
|
||||
end if;
|
||||
|
||||
if not exists (
|
||||
select 1
|
||||
from pg_publication_tables
|
||||
where pubname = 'supabase_realtime'
|
||||
and schemaname = 'public'
|
||||
and tablename = 'whiteboard_strokes'
|
||||
) then
|
||||
execute 'alter publication supabase_realtime add table public.whiteboard_strokes';
|
||||
end if;
|
||||
end
|
||||
$$;
|
||||
@@ -0,0 +1,126 @@
|
||||
-- Phase 4C: cloud-synced soundboard.
|
||||
--
|
||||
-- user_soundboards: one row per cloud-known sound. The audio payload itself
|
||||
-- lives in the `soundboards` storage bucket at `<user_id>/<sound_id>.bin`,
|
||||
-- E2E-encrypted with the user's own X25519 key (sealed-to-self). The first
|
||||
-- 24 bytes of the stored object are the XSalsa20 nonce; the rest is the
|
||||
-- Poly1305-authenticated ciphertext.
|
||||
--
|
||||
-- Metadata (name, gain, hotkey, category, sort_order) is stored plaintext —
|
||||
-- not considered sensitive by the spec — so other devices of the same user
|
||||
-- can read it without holding the private key.
|
||||
|
||||
create table if not exists public.user_soundboards (
|
||||
id uuid primary key default gen_random_uuid(),
|
||||
user_id uuid not null references auth.users(id) on delete cascade,
|
||||
name text not null,
|
||||
mime text not null,
|
||||
size bigint not null,
|
||||
category text null,
|
||||
hotkey text null,
|
||||
gain real not null default 1.0,
|
||||
sort_order integer not null default 0,
|
||||
storage_path text not null,
|
||||
created_at timestamptz not null default now(),
|
||||
updated_at timestamptz not null default now(),
|
||||
constraint user_soundboards_name_len check (length(name) between 1 and 200),
|
||||
constraint user_soundboards_size_pos check (size > 0)
|
||||
);
|
||||
|
||||
create index if not exists user_soundboards_user_idx
|
||||
on public.user_soundboards(user_id, updated_at desc);
|
||||
|
||||
alter table public.user_soundboards enable row level security;
|
||||
|
||||
drop policy if exists user_soundboards_select on public.user_soundboards;
|
||||
drop policy if exists user_soundboards_insert on public.user_soundboards;
|
||||
drop policy if exists user_soundboards_update on public.user_soundboards;
|
||||
drop policy if exists user_soundboards_delete on public.user_soundboards;
|
||||
|
||||
create policy user_soundboards_select
|
||||
on public.user_soundboards
|
||||
for select
|
||||
using (user_id = auth.uid());
|
||||
|
||||
create policy user_soundboards_insert
|
||||
on public.user_soundboards
|
||||
for insert
|
||||
with check (user_id = auth.uid());
|
||||
|
||||
create policy user_soundboards_update
|
||||
on public.user_soundboards
|
||||
for update
|
||||
using (user_id = auth.uid())
|
||||
with check (user_id = auth.uid());
|
||||
|
||||
create policy user_soundboards_delete
|
||||
on public.user_soundboards
|
||||
for delete
|
||||
using (user_id = auth.uid());
|
||||
|
||||
alter table public.user_soundboards replica identity full;
|
||||
|
||||
do $$
|
||||
begin
|
||||
if not exists (
|
||||
select 1
|
||||
from pg_publication_tables
|
||||
where pubname = 'supabase_realtime'
|
||||
and schemaname = 'public'
|
||||
and tablename = 'user_soundboards'
|
||||
) then
|
||||
execute 'alter publication supabase_realtime add table public.user_soundboards';
|
||||
end if;
|
||||
end
|
||||
$$;
|
||||
|
||||
-- Storage bucket: private, owner-only.
|
||||
do $$
|
||||
begin
|
||||
if not exists (select 1 from storage.buckets where id = 'soundboards') then
|
||||
insert into storage.buckets (id, name, public)
|
||||
values ('soundboards', 'soundboards', false);
|
||||
end if;
|
||||
end
|
||||
$$;
|
||||
|
||||
drop policy if exists soundboards_select on storage.objects;
|
||||
drop policy if exists soundboards_insert on storage.objects;
|
||||
drop policy if exists soundboards_update on storage.objects;
|
||||
drop policy if exists soundboards_delete on storage.objects;
|
||||
|
||||
create policy soundboards_select
|
||||
on storage.objects
|
||||
for select
|
||||
using (
|
||||
bucket_id = 'soundboards'
|
||||
and auth.uid()::text = (storage.foldername(name))[1]
|
||||
);
|
||||
|
||||
create policy soundboards_insert
|
||||
on storage.objects
|
||||
for insert
|
||||
with check (
|
||||
bucket_id = 'soundboards'
|
||||
and auth.uid()::text = (storage.foldername(name))[1]
|
||||
);
|
||||
|
||||
create policy soundboards_update
|
||||
on storage.objects
|
||||
for update
|
||||
using (
|
||||
bucket_id = 'soundboards'
|
||||
and auth.uid()::text = (storage.foldername(name))[1]
|
||||
)
|
||||
with check (
|
||||
bucket_id = 'soundboards'
|
||||
and auth.uid()::text = (storage.foldername(name))[1]
|
||||
);
|
||||
|
||||
create policy soundboards_delete
|
||||
on storage.objects
|
||||
for delete
|
||||
using (
|
||||
bucket_id = 'soundboards'
|
||||
and auth.uid()::text = (storage.foldername(name))[1]
|
||||
);
|
||||
Reference in New Issue
Block a user