- account/cards.ts: Kartendienst (neu/bestehend speichern mit Aufräumen halber Uploads, Umbenennen, Löschen, Laden) über ein schmales CardsApi-Interface, Appwrite-Anschluss mit 22.4.1-Objektparametern, fetchFile über client.prepareRequest (Projekt-Header + Cookie, kein getFileView) - account/with-login.ts: bei 401 Nutzer neu prüfen, einmal anmelden, wiederholen - editor/save.ts: Speichern-Ablauf (ein Lauf zur Zeit, 401 -> Login -> einmal wiederholen, deutsche Fehlermeldung) - Editor: Speichern mit Schnappschuss und 480-px-Vorschau, Karte öffnen über ?karte=<id>, Bildwechsel während des Speicherns gesperrt - pages/cards-page.ts: /karten mit Kacheln, Öffnen, Herunterladen, Umbenennen, Löschen (eigener Dialog), Abmelde- und Leer-Ansicht - Tests für Dienst, withLogin, Speichern-Ablauf, Karten-Seite und Editor-Anbindung Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
259 lines
8.7 KiB
TypeScript
259 lines
8.7 KiB
TypeScript
// Mittlere Spalte: Ablagefläche bzw. Kartenvorschau (Verschieben, Zoom) und die Knopfleiste.
|
||
import { MAX_ZOOM, MIN_ZOOM } from '../card/config';
|
||
import { CARD_H, CARD_W } from '../card/geometry';
|
||
import { el, icon } from './dom';
|
||
import { ACCEPTED_TYPES } from './upload';
|
||
|
||
export interface PreviewHandlers {
|
||
onFile(file: File): void;
|
||
/** Verschiebung in Karten-px (1536 x 969), nicht in CSS-px. */
|
||
onPan(dx: number, dy: number): void;
|
||
onZoom(zoom: number): void;
|
||
onDownload(): void;
|
||
onSave(): void;
|
||
}
|
||
|
||
export interface PreviewView {
|
||
hasImage: boolean;
|
||
zoom: number;
|
||
saving: boolean;
|
||
/** true für kurze Zeit nach erfolgreichem Speichern (Knopf zeigt „Gespeichert“). */
|
||
saved: boolean;
|
||
exporting: boolean;
|
||
}
|
||
|
||
export interface Preview {
|
||
el: HTMLElement;
|
||
canvas: HTMLCanvasElement;
|
||
sync(view: PreviewView): void;
|
||
setDragging(active: boolean): void;
|
||
setError(message: string | null): void;
|
||
setWarning(message: string | null): void;
|
||
/** Neutraler Hinweis, z. B. „Karte wird geladen …“. */
|
||
setNotice(message: string | null): void;
|
||
}
|
||
|
||
const NO_IMAGE_HINT = 'Erst ein Bild wählen';
|
||
const SAVE_LABEL = 'Speichern';
|
||
const SAVING_LABEL = 'Speichert …';
|
||
const SAVED_LABEL = 'Gespeichert';
|
||
const KEY_STEP = 24;
|
||
const KEY_STEP_BIG = 96;
|
||
const KEY_ZOOM_STEP = 0.1;
|
||
|
||
/** Nimmt Dateien per Ziehen und Ablegen an `target` an. Auch Ablagen neben der Fläche sollen das Bild laden, statt die Seite zu verlassen. */
|
||
export function attachFileDrop(
|
||
target: HTMLElement,
|
||
onFile: (file: File) => void,
|
||
onActive: (active: boolean) => void,
|
||
): void {
|
||
let depth = 0;
|
||
const hasFiles = (e: DragEvent) => Array.from(e.dataTransfer?.types ?? []).includes('Files');
|
||
|
||
target.addEventListener('dragenter', (e) => {
|
||
if (!hasFiles(e)) return;
|
||
e.preventDefault();
|
||
depth += 1;
|
||
onActive(true);
|
||
});
|
||
target.addEventListener('dragover', (e) => {
|
||
if (!hasFiles(e)) return;
|
||
e.preventDefault();
|
||
if (e.dataTransfer) e.dataTransfer.dropEffect = 'copy';
|
||
});
|
||
target.addEventListener('dragleave', (e) => {
|
||
if (!hasFiles(e)) return;
|
||
depth = Math.max(0, depth - 1);
|
||
if (depth === 0) onActive(false);
|
||
});
|
||
target.addEventListener('drop', (e) => {
|
||
if (!hasFiles(e)) return;
|
||
e.preventDefault();
|
||
depth = 0;
|
||
onActive(false);
|
||
const file = e.dataTransfer?.files[0];
|
||
if (file) onFile(file);
|
||
});
|
||
}
|
||
|
||
export function createPreview(handlers: PreviewHandlers): Preview {
|
||
let zoom = MIN_ZOOM;
|
||
|
||
const input = el('input', {
|
||
type: 'file',
|
||
accept: ACCEPTED_TYPES.join(','),
|
||
hidden: true,
|
||
'aria-label': 'Bilddatei wählen',
|
||
});
|
||
const openPicker = () => input.click();
|
||
input.addEventListener('change', () => {
|
||
const file = input.files?.[0];
|
||
// Zurücksetzen, damit dieselbe Datei später noch einmal gewählt werden kann.
|
||
input.value = '';
|
||
if (file) handlers.onFile(file);
|
||
});
|
||
|
||
// Ablagefläche (ohne Bild)
|
||
const chooseButton = el('button', { type: 'button', class: 'btn btn-primary btn-large' }, 'Bild auswählen');
|
||
const dropzone = el(
|
||
'div',
|
||
{ class: 'dropzone' },
|
||
icon('imagePlus', 'dropzone-icon'),
|
||
el('p', { class: 'dropzone-title' }, 'Ziehe deine Idee hierher'),
|
||
chooseButton,
|
||
el('p', { class: 'dropzone-hint' }, 'PNG / JPG / WebP · Max. 5 MiB'),
|
||
);
|
||
dropzone.addEventListener('click', openPicker);
|
||
|
||
// Kartenvorschau (mit Bild)
|
||
const canvas = el('canvas', {
|
||
class: 'card-canvas',
|
||
width: CARD_W,
|
||
height: CARD_H,
|
||
tabindex: 0,
|
||
role: 'application',
|
||
'aria-label': 'Kartenvorschau. Mit der Maus ziehen oder die Pfeiltasten benutzen, um das Bild zu verschieben.',
|
||
});
|
||
const cardWrap = el('div', { class: 'card-wrap', hidden: true }, canvas);
|
||
|
||
let dragId: number | null = null;
|
||
let lastX = 0;
|
||
let lastY = 0;
|
||
canvas.addEventListener('pointerdown', (e) => {
|
||
if (dragId !== null || (e.pointerType === 'mouse' && e.button !== 0)) return;
|
||
e.preventDefault();
|
||
dragId = e.pointerId;
|
||
lastX = e.clientX;
|
||
lastY = e.clientY;
|
||
canvas.setPointerCapture(e.pointerId);
|
||
canvas.classList.add('is-panning');
|
||
});
|
||
canvas.addEventListener('pointermove', (e) => {
|
||
if (e.pointerId !== dragId || canvas.clientWidth === 0) return;
|
||
const k = CARD_W / canvas.clientWidth;
|
||
const dx = (e.clientX - lastX) * k;
|
||
const dy = (e.clientY - lastY) * k;
|
||
lastX = e.clientX;
|
||
lastY = e.clientY;
|
||
if (dx !== 0 || dy !== 0) handlers.onPan(dx, dy);
|
||
});
|
||
const endPan = (e: PointerEvent) => {
|
||
if (e.pointerId !== dragId) return;
|
||
dragId = null;
|
||
canvas.classList.remove('is-panning');
|
||
if (canvas.hasPointerCapture(e.pointerId)) canvas.releasePointerCapture(e.pointerId);
|
||
};
|
||
canvas.addEventListener('pointerup', endPan);
|
||
canvas.addEventListener('pointercancel', endPan);
|
||
|
||
canvas.addEventListener('keydown', (e) => {
|
||
const step = e.shiftKey ? KEY_STEP_BIG : KEY_STEP;
|
||
const moves: Record<string, [number, number]> = {
|
||
ArrowLeft: [-step, 0],
|
||
ArrowRight: [step, 0],
|
||
ArrowUp: [0, -step],
|
||
ArrowDown: [0, step],
|
||
};
|
||
const move = moves[e.key];
|
||
if (move) {
|
||
e.preventDefault();
|
||
handlers.onPan(move[0], move[1]);
|
||
} else if (e.key === '+' || e.key === '=') {
|
||
e.preventDefault();
|
||
handlers.onZoom(zoom + KEY_ZOOM_STEP);
|
||
} else if (e.key === '-') {
|
||
e.preventDefault();
|
||
handlers.onZoom(zoom - KEY_ZOOM_STEP);
|
||
}
|
||
});
|
||
|
||
const stage = el('div', { class: 'stage' }, dropzone, cardWrap);
|
||
|
||
// Zoom
|
||
const zoomInput = el('input', {
|
||
type: 'range',
|
||
class: 'zoom-input',
|
||
id: 'editor-zoom',
|
||
min: MIN_ZOOM,
|
||
max: MAX_ZOOM,
|
||
step: 0.01,
|
||
value: MIN_ZOOM,
|
||
});
|
||
const zoomValue = el('output', { class: 'zoom-value', for: 'editor-zoom' }, '100 %');
|
||
zoomInput.addEventListener('input', () => handlers.onZoom(Number(zoomInput.value)));
|
||
const zoomRow = el(
|
||
'div',
|
||
{ class: 'zoom-row', hidden: true },
|
||
el('label', { class: 'zoom-label', for: 'editor-zoom' }, 'Zoom'),
|
||
zoomInput,
|
||
zoomValue,
|
||
);
|
||
|
||
// Meldungen
|
||
const errorBox = el('p', { class: 'msg msg-error', role: 'alert' });
|
||
const warningBox = el('p', { class: 'msg msg-warning', role: 'status' });
|
||
const noticeBox = el('p', { class: 'msg msg-notice', role: 'status' });
|
||
|
||
// Knopfleiste
|
||
const replaceButton = el('button', { type: 'button', class: 'btn btn-ghost' }, 'Bild ersetzen');
|
||
replaceButton.addEventListener('click', openPicker);
|
||
const downloadButton = el('button', { type: 'button', class: 'btn btn-primary' }, 'Herunterladen');
|
||
downloadButton.addEventListener('click', () => handlers.onDownload());
|
||
const saveButton = el('button', { type: 'button', class: 'btn btn-primary btn-save' }, SAVE_LABEL);
|
||
saveButton.addEventListener('click', () => handlers.onSave());
|
||
const actions = el(
|
||
'div',
|
||
{ class: 'actions' },
|
||
el('span', { class: 'size-label' }, `${CARD_W} × ${CARD_H} PNG`),
|
||
el('div', { class: 'action-buttons' }, replaceButton, downloadButton, saveButton),
|
||
);
|
||
|
||
const note = el('p', { class: 'note' }, icon('lock', 'note-icon'), 'Dein Bild bleibt auf deinem Gerät, bis du speicherst.');
|
||
|
||
const inner = el('div', { class: 'preview-inner' }, stage, zoomRow, errorBox, warningBox, noticeBox, actions, note, input);
|
||
const section = el('section', { class: 'editor-center', 'aria-label': 'Kartenvorschau' }, inner);
|
||
|
||
function setDisabled(button: HTMLButtonElement, disabled: boolean, hint: string | null): void {
|
||
button.disabled = disabled;
|
||
if (disabled && hint) button.setAttribute('title', hint);
|
||
else button.removeAttribute('title');
|
||
}
|
||
|
||
function setMessage(box: HTMLElement, message: string | null): void {
|
||
box.textContent = message ?? '';
|
||
}
|
||
|
||
return {
|
||
el: section,
|
||
canvas,
|
||
sync(view) {
|
||
zoom = view.zoom;
|
||
dropzone.hidden = view.hasImage;
|
||
cardWrap.hidden = !view.hasImage;
|
||
zoomRow.hidden = !view.hasImage;
|
||
zoomInput.value = String(view.zoom);
|
||
zoomValue.textContent = `${Math.round(view.zoom * 100)} %`;
|
||
setDisabled(downloadButton, !view.hasImage || view.exporting, view.hasImage ? null : NO_IMAGE_HINT);
|
||
setDisabled(saveButton, !view.hasImage || view.saving, view.hasImage ? null : NO_IMAGE_HINT);
|
||
saveButton.textContent = view.saving ? SAVING_LABEL : view.saved ? SAVED_LABEL : SAVE_LABEL;
|
||
// Ein Bildwechsel mitten im Speichern würde die gespeicherte Karte durcheinanderbringen.
|
||
replaceButton.disabled = view.saving;
|
||
input.disabled = view.saving;
|
||
downloadButton.setAttribute('aria-busy', String(view.exporting));
|
||
saveButton.setAttribute('aria-busy', String(view.saving));
|
||
},
|
||
setDragging(active) {
|
||
stage.classList.toggle('is-dragging', active);
|
||
},
|
||
setError(message) {
|
||
setMessage(errorBox, message);
|
||
},
|
||
setWarning(message) {
|
||
setMessage(warningBox, message);
|
||
},
|
||
setNotice(message) {
|
||
setMessage(noticeBox, message);
|
||
},
|
||
};
|
||
}
|