Files
MindSift/frontend/src/features/workspace/ImageBlock.tsx
T
sandrodachandClaude Opus 5.5 e8fa14a4cb
MindSift CI & Docker Release / Build, Test in Container & Push (push) Successful in 2m18s
feat(workspace): image blocks via /bild (M2)
- /bild (only in an empty bullet) turns the bullet into an inline picker:
  file, camera on phones, drag & drop, paste, or an https link the server
  copies. Esc/✕ restores the empty bullet; nothing is synced before the
  upload succeeded.
- Photos are shrunk to 2048 px and stored as WebP (JPEG fallback); the
  re-encoding drops EXIF data such as the location.
- Image block: loaded with the login as blob URL, click opens full
  screen, optional caption kept in the block content
  (![caption](media:id)).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-29 23:14:08 +01:00

117 lines
4.1 KiB
TypeScript

import React, { useEffect, useState } from 'react';
import { createPortal } from 'react-dom';
import { ImageOff, Loader2, X } from 'lucide-react';
import { mediaObjectURL, parseMediaContent } from '../../lib/media';
interface ImageBlockProps {
content: string;
canEdit: boolean;
onCaptionChange: (caption: string) => void;
}
// Image block: the picture (click = full screen) and an optional caption below it.
export const ImageBlock: React.FC<ImageBlockProps> = ({ content, canEdit, onCaptionChange }) => {
const ref = parseMediaContent(content);
const [src, setSrc] = useState<string | null>(null);
const [failed, setFailed] = useState(false);
const [zoomed, setZoomed] = useState(false);
const [caption, setCaption] = useState(ref?.caption || '');
useEffect(() => {
setCaption(ref?.caption || '');
}, [ref?.caption]);
const mediaId = ref?.id;
useEffect(() => {
if (!mediaId) return;
let alive = true;
setFailed(false);
mediaObjectURL(mediaId)
.then((u) => alive && setSrc(u))
.catch(() => alive && setFailed(true));
return () => {
alive = false;
};
}, [mediaId]);
useEffect(() => {
if (!zoomed) return;
const onKey = (e: KeyboardEvent) => e.key === 'Escape' && setZoomed(false);
window.addEventListener('keydown', onKey);
return () => window.removeEventListener('keydown', onKey);
}, [zoomed]);
if (!ref) {
return <div className="text-xs text-status-err py-1">Bild-Verweis ist beschädigt: {content}</div>;
}
const commitCaption = () => {
if (caption.trim() !== ref.caption) onCaptionChange(caption.trim());
};
return (
<figure className="my-1 space-y-1">
{failed ? (
<div className="flex items-center gap-2 h-24 px-3 rounded-card border border-border bg-panel text-xs text-muted">
<ImageOff className="w-4 h-4" /> Bild nicht verfügbar (offline oder gelöscht)
</div>
) : src ? (
<button
type="button"
onClick={() => setZoomed(true)}
title="Vergrößern"
className="block max-w-full rounded-card overflow-hidden border border-border bg-panel cursor-zoom-in"
>
<img src={src} alt={ref.caption} className="block max-w-full max-h-[28rem] object-contain" />
</button>
) : (
<div className="flex items-center justify-center w-48 h-32 rounded-card border border-border bg-panel">
<Loader2 className="w-4 h-4 animate-spin text-muted" />
</div>
)}
{canEdit ? (
<input
type="text"
value={caption}
onChange={(e) => setCaption(e.target.value)}
onBlur={commitCaption}
onKeyDown={(e) => {
if (e.key === 'Enter') {
e.preventDefault();
(e.target as HTMLInputElement).blur();
}
}}
placeholder="Beschriftung hinzufügen…"
className="w-full bg-transparent border-0 p-0 text-xs text-muted placeholder-faint focus:outline-none focus:ring-0"
/>
) : (
ref.caption && <figcaption className="text-xs text-muted">{ref.caption}</figcaption>
)}
{zoomed &&
src &&
createPortal(
<div
role="dialog"
aria-label={ref.caption || 'Bild'}
onClick={() => setZoomed(false)}
className="fixed inset-0 z-[100] bg-black/85 flex flex-col items-center justify-center p-4 cursor-zoom-out"
>
<button
type="button"
onClick={() => setZoomed(false)}
title="Schließen (Esc)"
className="absolute top-[calc(0.75rem+env(safe-area-inset-top))] right-3 w-11 h-11 flex items-center justify-center rounded-full bg-black/50 text-white"
>
<X className="w-5 h-5" />
</button>
<img src={src} alt={ref.caption} className="max-w-full max-h-[85vh] object-contain" />
{ref.caption && <p className="mt-3 text-sm text-white/90 text-center max-w-2xl">{ref.caption}</p>}
</div>,
document.body
)}
</figure>
);
};