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>
166 lines
5.6 KiB
TypeScript
166 lines
5.6 KiB
TypeScript
import React, { useEffect, useRef, useState } from 'react';
|
|
import { Camera, ImagePlus, Link as LinkIcon, Loader2, X } from 'lucide-react';
|
|
import { copyExternalImage, prepareImage, UploadedMedia, uploadMedia } from '../../lib/media';
|
|
|
|
interface ImagePickerProps {
|
|
docId: string;
|
|
onDone: (media: UploadedMedia) => void;
|
|
onCancel: () => void;
|
|
}
|
|
|
|
// Inline box an empty bullet turns into after /bild: pick a file (or the camera on phones), drop or
|
|
// paste an image, or give a link the server copies. Nothing is synced until the upload succeeded.
|
|
export const ImagePicker: React.FC<ImagePickerProps> = ({ docId, onDone, onCancel }) => {
|
|
const [busy, setBusy] = useState(false);
|
|
const [error, setError] = useState<string | null>(null);
|
|
const [dragOver, setDragOver] = useState(false);
|
|
const [url, setUrl] = useState('');
|
|
const fileRef = useRef<HTMLInputElement>(null);
|
|
const cameraRef = useRef<HTMLInputElement>(null);
|
|
const boxRef = useRef<HTMLDivElement>(null);
|
|
|
|
useEffect(() => {
|
|
boxRef.current?.focus();
|
|
}, []);
|
|
|
|
const uploadFile = async (file: File | Blob | null | undefined) => {
|
|
if (!file || busy) return;
|
|
if (file.type && !file.type.startsWith('image/')) {
|
|
setError('Bitte eine Bilddatei wählen');
|
|
return;
|
|
}
|
|
setBusy(true);
|
|
setError(null);
|
|
try {
|
|
const { blob, name } = await prepareImage(file);
|
|
onDone(await uploadMedia(docId, blob, name));
|
|
} catch (err: any) {
|
|
setError(err?.message || 'Hochladen fehlgeschlagen');
|
|
setBusy(false);
|
|
}
|
|
};
|
|
|
|
const copyLink = async (e?: React.FormEvent) => {
|
|
e?.preventDefault();
|
|
const target = url.trim();
|
|
if (!target || busy) return;
|
|
setBusy(true);
|
|
setError(null);
|
|
try {
|
|
onDone(await copyExternalImage(docId, target));
|
|
} catch (err: any) {
|
|
setError(err?.message || 'Bild konnte nicht übernommen werden');
|
|
setBusy(false);
|
|
}
|
|
};
|
|
|
|
return (
|
|
<div
|
|
ref={boxRef}
|
|
tabIndex={-1}
|
|
onKeyDown={(e) => {
|
|
if (e.key === 'Escape') {
|
|
e.preventDefault();
|
|
onCancel();
|
|
}
|
|
}}
|
|
onPaste={(e) => {
|
|
const item = Array.from(e.clipboardData.items).find((i) => i.type.startsWith('image/'));
|
|
if (item) {
|
|
e.preventDefault();
|
|
void uploadFile(item.getAsFile());
|
|
}
|
|
}}
|
|
onDragOver={(e) => {
|
|
e.preventDefault();
|
|
setDragOver(true);
|
|
}}
|
|
onDragLeave={() => setDragOver(false)}
|
|
onDrop={(e) => {
|
|
e.preventDefault();
|
|
setDragOver(false);
|
|
void uploadFile(e.dataTransfer.files?.[0]);
|
|
}}
|
|
className={
|
|
dragOver
|
|
? 'my-1 p-3 rounded-card border-2 border-dashed border-accent bg-accent-dim space-y-3 focus:outline-none'
|
|
: 'my-1 p-3 rounded-card border-2 border-dashed border-border bg-panel space-y-3 focus:outline-none'
|
|
}
|
|
>
|
|
<div className="flex items-center justify-between gap-2">
|
|
<span className="text-xs font-semibold text-main flex items-center gap-1.5">
|
|
<ImagePlus className="w-4 h-4 text-accent" /> Bild einfügen
|
|
</span>
|
|
<button
|
|
type="button"
|
|
onClick={onCancel}
|
|
title="Abbrechen (Esc)"
|
|
className="w-11 h-11 sm:w-7 sm:h-7 flex items-center justify-center rounded-btn text-muted hover:text-main hover:bg-panel-elevated"
|
|
>
|
|
<X className="w-4 h-4" />
|
|
</button>
|
|
</div>
|
|
|
|
{busy ? (
|
|
<div className="flex items-center gap-2 text-xs text-muted py-3">
|
|
<Loader2 className="w-4 h-4 animate-spin text-accent" /> Wird hochgeladen…
|
|
</div>
|
|
) : (
|
|
<>
|
|
<div className="flex flex-col sm:flex-row gap-2">
|
|
<button
|
|
type="button"
|
|
onClick={() => fileRef.current?.click()}
|
|
className="btn-primary h-11 sm:h-8 px-3 text-xs flex items-center justify-center gap-1.5"
|
|
>
|
|
<ImagePlus className="w-4 h-4" /> Datei wählen
|
|
</button>
|
|
<button
|
|
type="button"
|
|
onClick={() => cameraRef.current?.click()}
|
|
className="btn-secondary h-11 sm:h-8 px-3 text-xs flex items-center justify-center gap-1.5 sm:hidden"
|
|
>
|
|
<Camera className="w-4 h-4" /> Foto aufnehmen
|
|
</button>
|
|
<span className="hidden sm:flex items-center text-[11px] text-faint">
|
|
oder hierher ziehen bzw. mit Strg+V einfügen
|
|
</span>
|
|
</div>
|
|
<form onSubmit={copyLink} className="flex gap-2">
|
|
<div className="relative flex-1 min-w-0">
|
|
<LinkIcon className="w-3.5 h-3.5 text-faint absolute left-2.5 top-1/2 -translate-y-1/2" />
|
|
<input
|
|
type="url"
|
|
inputMode="url"
|
|
value={url}
|
|
onChange={(e) => setUrl(e.target.value)}
|
|
placeholder="https://… Bild-Link"
|
|
className="w-full h-11 sm:h-8 pl-8 pr-2 input-field text-xs"
|
|
/>
|
|
</div>
|
|
<button
|
|
type="submit"
|
|
disabled={!url.trim()}
|
|
className="btn-secondary h-11 sm:h-8 px-3 text-xs shrink-0 disabled:opacity-40"
|
|
>
|
|
Übernehmen
|
|
</button>
|
|
</form>
|
|
</>
|
|
)}
|
|
|
|
{error && <p className="text-xs text-status-err">{error}</p>}
|
|
|
|
<input ref={fileRef} type="file" accept="image/*" hidden onChange={(e) => void uploadFile(e.target.files?.[0])} />
|
|
<input
|
|
ref={cameraRef}
|
|
type="file"
|
|
accept="image/*"
|
|
capture="environment"
|
|
hidden
|
|
onChange={(e) => void uploadFile(e.target.files?.[0])}
|
|
/>
|
|
</div>
|
|
);
|
|
};
|