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>
117 lines
4.1 KiB
TypeScript
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>
|
|
);
|
|
};
|