feat(workspace): image blocks via /bild (M2)
MindSift CI & Docker Release / Build, Test in Container & Push (push) Successful in 2m18s
MindSift CI & Docker Release / Build, Test in Container & Push (push) Successful in 2m18s
- /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 (). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
177fa49363
commit
e8fa14a4cb
8 files changed
+476
-4
No files matched your search
@@ -0,0 +1,116 @@
|
||||
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>
|
||||
);
|
||||
};
|
||||
Reference in new issue
Block a user