P42 Dashboard: erster Stand
FastAPI-Backend und React-Frontend mit Konten, Sendungsverfolgung, Projekt-Artikeln, Vorrat & Einkauf mit Rezeptabgleich, Reise-Weltkarte, Kanban-Projekten mit Priorität, Server-Monitoring und den Designs Matrix und Blackwall. Betrieb per Docker hinter nginx (siehe DEPLOY.md). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
commit
dd58b3d1f8
75 files changed
+11418
No files matched your search
@@ -0,0 +1,259 @@
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
import { isCalm, onMotionChange } from '../motion.js';
|
||||
|
||||
// Hintergrund "Blackwall" nach Einzelbildern aus dem Referenzvideo (Cyberpunk 2077):
|
||||
// - Wand-Korridor: senkrechte Lichtlinien, die perspektivisch auf einen Fluchtpunkt am Horizont zulaufen
|
||||
// (am Rand hoch, zur Mitte flach); Daten fließen sichtbar an ihnen herab
|
||||
// - Boden: Feld aus Lichtpunkten in Perspektive, das langsam auf den Betrachter zufließt
|
||||
// - Horizont: gleißendes Band mit wanderndem Hotspot, davor eine zerfaserte Skyline aus Blöcken
|
||||
// - Zeichenregen nur an den Rändern, seltener Glitch (Zeilenversatz, kein Helligkeitsblitz)
|
||||
// Farben: Pink-Magenta mit vereinzelten violett-blauen Linien. Ohne Animation: ein Standbild.
|
||||
const FPS = 24;
|
||||
const GLITCH_EVERY = [4500, 9000];
|
||||
const GLITCH_FOR = 160;
|
||||
const GLYPHS = '0123456789ABCDEF#$%&*+<>/\\|';
|
||||
|
||||
const PINK = [255, 32, 92];
|
||||
const HOT = [255, 110, 160];
|
||||
const VIOLET = [140, 110, 255];
|
||||
|
||||
const hash = (n) => {
|
||||
const x = Math.sin(n * 127.1 + 311.7) * 43758.5453;
|
||||
return x - Math.floor(x);
|
||||
};
|
||||
const rgba = ([r, g, b], a) => `rgba(${r},${g},${b},${Math.max(0, Math.min(1, a))})`;
|
||||
|
||||
export default function BlackwallField() {
|
||||
const ref = useRef(null);
|
||||
const [calm, setCalm] = useState(isCalm);
|
||||
|
||||
useEffect(() => onMotionChange(() => setCalm(isCalm())), []);
|
||||
|
||||
useEffect(() => {
|
||||
const canvas = ref.current;
|
||||
const ctx = canvas.getContext('2d');
|
||||
let w = 0;
|
||||
let h = 0;
|
||||
let dpr = 1;
|
||||
let lines = [];
|
||||
let blocks = [];
|
||||
let columns = [];
|
||||
let frame = 0;
|
||||
let last = 0;
|
||||
let nextGlitch = performance.now() + 3500;
|
||||
let glitchUntil = 0;
|
||||
|
||||
function build() {
|
||||
const cx = w / 2;
|
||||
// Wandlinien: dicht (alle ~3 px), Höhe nach Perspektive
|
||||
lines = Array.from({ length: Math.round(w / 3) }, (_, i) => {
|
||||
const x = hash(i + 1) * w;
|
||||
const persp = Math.min(1, Math.abs(x - cx) / (w / 2)); // 0 Mitte … 1 Rand
|
||||
const tint = hash(i + 7);
|
||||
return {
|
||||
x,
|
||||
width: hash(i + 2) > 0.9 ? 2 : 1,
|
||||
up: h * (0.03 + 0.46 * persp ** 1.25) * (0.55 + 0.45 * hash(i + 3)), // über dem Horizont
|
||||
down: h * (0.01 + 0.16 * persp ** 1.4) * (0.4 + 0.6 * hash(i + 4)), // unter dem Horizont
|
||||
alpha: 0.08 + hash(i + 5) ** 2 * 0.55,
|
||||
flow: 30 + hash(i + 6) * 90, // px/s Datenfluss
|
||||
phase: hash(i + 8) * 10,
|
||||
color: tint > 0.92 ? VIOLET : tint > 0.78 ? HOT : PINK,
|
||||
};
|
||||
});
|
||||
// Zerfaserte Skyline am Horizont, zur Mitte gehäuft
|
||||
blocks = Array.from({ length: Math.round(w / 12) }, (_, i) => {
|
||||
const x = w * (0.5 + (hash(i + 101) - 0.5) * (0.4 + hash(i + 107) * 0.95));
|
||||
return {
|
||||
x,
|
||||
bw: 8 + hash(i + 103) ** 2 * w * 0.08,
|
||||
bh: 6 + hash(i + 104) ** 1.5 * h * 0.12,
|
||||
lift: hash(i + 102) * 0.85, // wie weit der Block über den Horizont ragt
|
||||
alpha: 0.05 + hash(i + 105) * 0.26,
|
||||
drift: (hash(i + 106) - 0.5) * 5,
|
||||
};
|
||||
});
|
||||
// Zeichenspalten nur an den Rändern (nur auf breiten Bildschirmen)
|
||||
columns = [];
|
||||
if (w > 700) {
|
||||
const step = 18;
|
||||
for (let x = 6; x < w; x += step) {
|
||||
if (x > w * 0.14 && x < w * 0.86) continue;
|
||||
const i = columns.length;
|
||||
if (hash(i + 301) < 0.45) continue;
|
||||
columns.push({ x, speed: 40 + hash(i + 302) * 90, offset: hash(i + 303) * h * 2, len: 8 + Math.floor(hash(i + 304) * 14), seed: i });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function resize() {
|
||||
dpr = Math.min(window.devicePixelRatio || 1, 2);
|
||||
w = window.innerWidth;
|
||||
h = window.innerHeight;
|
||||
canvas.width = Math.round(w * dpr);
|
||||
canvas.height = Math.round(h * dpr);
|
||||
build();
|
||||
draw(performance.now());
|
||||
}
|
||||
|
||||
function draw(now) {
|
||||
const t = calm ? 7 : now / 1000;
|
||||
const cy = h * 0.52; // Horizont
|
||||
const cx = w * 0.5;
|
||||
const hotX = cx + Math.sin(t * 0.13) * w * 0.16; // wandernder Lichtfleck
|
||||
const breath = 0.86 + 0.1 * Math.sin(t * 0.7) + 0.04 * Math.sin(t * 1.9 + 1.3);
|
||||
|
||||
ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
|
||||
ctx.globalCompositeOperation = 'source-over';
|
||||
ctx.fillStyle = '#040002';
|
||||
ctx.fillRect(0, 0, w, h);
|
||||
ctx.globalCompositeOperation = 'lighter';
|
||||
|
||||
// 1) Dunst um den Horizont
|
||||
let g = ctx.createLinearGradient(0, cy - h * 0.4, 0, cy + h * 0.3);
|
||||
g.addColorStop(0, rgba(PINK, 0));
|
||||
g.addColorStop(0.58, rgba(PINK, 0.26 * breath));
|
||||
g.addColorStop(1, rgba(PINK, 0));
|
||||
ctx.fillStyle = g;
|
||||
ctx.fillRect(0, cy - h * 0.4, w, h * 0.7);
|
||||
|
||||
// 2) Boden: Lichtpunkte in Perspektive, fließen langsam heran
|
||||
const rows = 26;
|
||||
const flow = (t * 0.08) % 1;
|
||||
for (let r = 0; r < rows; r++) {
|
||||
const d = (r + flow) / rows; // 0 Horizont … 1 vorne
|
||||
const y = cy + (h - cy) * d ** 2.1;
|
||||
const spacing = 5 + 46 * d ** 1.6;
|
||||
const size = 0.8 + 2.4 * d;
|
||||
const a = (0.06 + 0.4 * d) * breath;
|
||||
ctx.fillStyle = rgba(PINK, a);
|
||||
const startK = -Math.ceil(cx / spacing);
|
||||
for (let k = startK; k * spacing + cx < w; k++) {
|
||||
const x = cx + k * spacing + ((r % 2) * spacing) / 2;
|
||||
const twinkle = hash(k * 13.1 + r * 7.7);
|
||||
if (twinkle < 0.18) continue;
|
||||
ctx.fillRect(x, y, size, size);
|
||||
}
|
||||
}
|
||||
|
||||
// 3) Wandlinien mit herabfließenden Daten
|
||||
for (const l of lines) {
|
||||
const top = cy - l.up;
|
||||
const len = l.up + l.down;
|
||||
const shimmer = 0.7 + 0.3 * Math.sin(t * 1.3 + l.phase);
|
||||
ctx.fillStyle = rgba(l.color, l.alpha * 0.55 * shimmer);
|
||||
ctx.fillRect(l.x, top, l.width, len);
|
||||
// heller Datenpaket-Abschnitt, der nach unten wandert
|
||||
const seg = Math.max(6, len * 0.22);
|
||||
const pos = ((t * l.flow + l.phase * 100) % (len + seg)) - seg;
|
||||
ctx.fillStyle = rgba(l.color === PINK ? HOT : l.color, l.alpha * 1.4);
|
||||
ctx.fillRect(l.x, top + Math.max(0, pos), l.width, Math.min(seg, len - Math.max(0, pos)));
|
||||
}
|
||||
|
||||
// 4) Zerfaserte Skyline (Blöcke ragen aus dem Band, tropfen nach unten)
|
||||
for (const b of blocks) {
|
||||
const x = (b.x + t * b.drift + w) % (w + b.bw) - b.bw * 0.5;
|
||||
const dist = Math.min(1, Math.abs(x + b.bw / 2 - hotX) / (w * 0.5));
|
||||
const hot = (1 - dist) ** 2;
|
||||
const a = b.alpha * (0.4 + 1 * hot) * breath;
|
||||
const y = cy - b.bh * b.lift;
|
||||
const bg = ctx.createLinearGradient(0, y, 0, y + b.bh);
|
||||
bg.addColorStop(0, rgba(PINK, a * 0.25));
|
||||
bg.addColorStop(0.6, rgba([255, 60 + 140 * hot, 120 + 100 * hot], a));
|
||||
bg.addColorStop(1, rgba(PINK, a * 0.1));
|
||||
ctx.fillStyle = bg;
|
||||
ctx.fillRect(x, y, b.bw, b.bh);
|
||||
}
|
||||
|
||||
// 5) Lichtband: Bloom, Band, gleißender Kern; Hotspot wandert
|
||||
ctx.save();
|
||||
ctx.translate(hotX, cy);
|
||||
ctx.scale(1, 0.15);
|
||||
let rg = ctx.createRadialGradient(0, 0, 0, 0, 0, w * 0.5);
|
||||
rg.addColorStop(0, rgba(HOT, 0.6 * breath));
|
||||
rg.addColorStop(0.45, rgba(PINK, 0.22 * breath));
|
||||
rg.addColorStop(1, rgba(PINK, 0));
|
||||
ctx.fillStyle = rg;
|
||||
ctx.fillRect(-w * 1.5, -h * 4, w * 3, h * 8);
|
||||
ctx.restore();
|
||||
|
||||
g = ctx.createLinearGradient(0, cy - 10, 0, cy + 10);
|
||||
g.addColorStop(0, rgba(PINK, 0));
|
||||
g.addColorStop(0.5, rgba(PINK, 0.55 * breath));
|
||||
g.addColorStop(1, rgba(PINK, 0));
|
||||
ctx.fillStyle = g;
|
||||
ctx.fillRect(0, cy - 10, w, 20);
|
||||
|
||||
ctx.save();
|
||||
ctx.translate(hotX, cy);
|
||||
ctx.scale(1, 0.006);
|
||||
rg = ctx.createRadialGradient(0, 0, 0, 0, 0, w * 0.55);
|
||||
rg.addColorStop(0, rgba([255, 245, 250], 1 * breath));
|
||||
rg.addColorStop(0.4, rgba(HOT, 0.8 * breath));
|
||||
rg.addColorStop(1, rgba(PINK, 0.15 * breath));
|
||||
ctx.fillStyle = rg;
|
||||
ctx.fillRect(-w * 1.5, -h * 100, w * 3, h * 200);
|
||||
ctx.restore();
|
||||
|
||||
// 6) Zeichenregen an den Rändern
|
||||
if (columns.length) {
|
||||
ctx.font = '13px ui-monospace, monospace';
|
||||
ctx.textBaseline = 'top';
|
||||
for (const c of columns) {
|
||||
const head = ((c.offset + t * c.speed) % (h + c.len * 16)) - c.len * 16;
|
||||
for (let k = 0; k < c.len; k++) {
|
||||
const y = head - k * 16;
|
||||
if (y < -16 || y > h) continue;
|
||||
const ch = GLYPHS[Math.floor(hash(c.seed * 31 + k * 7 + Math.floor(t * 3) * (k === 0 ? 1 : 0)) * GLYPHS.length)];
|
||||
ctx.fillStyle = k === 0 ? 'rgba(255,200,220,0.55)' : rgba(PINK, 0.32 * (1 - k / c.len));
|
||||
ctx.fillText(ch, c.x, y);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 7) Oben und unten ins Schwarz (dort stehen Titel und Fußzeile)
|
||||
ctx.globalCompositeOperation = 'source-over';
|
||||
g = ctx.createLinearGradient(0, 0, 0, h);
|
||||
g.addColorStop(0, 'rgba(4,0,2,0.95)');
|
||||
g.addColorStop(0.22, 'rgba(4,0,2,0.5)');
|
||||
g.addColorStop(0.42, 'rgba(4,0,2,0.04)');
|
||||
g.addColorStop(0.62, 'rgba(4,0,2,0.04)');
|
||||
g.addColorStop(0.85, 'rgba(4,0,2,0.5)');
|
||||
g.addColorStop(1, 'rgba(4,0,2,0.95)');
|
||||
ctx.fillStyle = g;
|
||||
ctx.fillRect(0, 0, w, h);
|
||||
|
||||
// 8) Glitch: Streifen seitlich versetzt
|
||||
if (!calm && now < glitchUntil) {
|
||||
ctx.setTransform(1, 0, 0, 1, 0, 0);
|
||||
for (let b = 0; b < 5; b++) {
|
||||
const sy = Math.floor((0.25 + hash(now * 0.0007 + b) * 0.5) * canvas.height);
|
||||
const sh = Math.floor((4 + hash(b + now * 0.0013) * 30) * dpr);
|
||||
const shift = Math.floor((hash(b * 3 + now * 0.0021) - 0.5) * 120 * dpr);
|
||||
ctx.drawImage(canvas, 0, sy, canvas.width, sh, shift, sy, canvas.width, sh);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function loop(now) {
|
||||
frame = requestAnimationFrame(loop);
|
||||
if (document.hidden || now - last < 1000 / FPS) return;
|
||||
last = now;
|
||||
if (now > nextGlitch) {
|
||||
glitchUntil = now + GLITCH_FOR;
|
||||
nextGlitch = now + GLITCH_EVERY[0] + Math.random() * (GLITCH_EVERY[1] - GLITCH_EVERY[0]);
|
||||
}
|
||||
draw(now);
|
||||
}
|
||||
|
||||
resize();
|
||||
window.addEventListener('resize', resize);
|
||||
if (!calm) frame = requestAnimationFrame(loop);
|
||||
return () => {
|
||||
cancelAnimationFrame(frame);
|
||||
window.removeEventListener('resize', resize);
|
||||
};
|
||||
}, [calm]);
|
||||
|
||||
return <canvas ref={ref} className="theme-bg blackwall-field" aria-hidden="true" />;
|
||||
}
|
||||
@@ -0,0 +1,166 @@
|
||||
import { useImperativeHandle, useRef, useState } from 'react';
|
||||
import { EditorContent, useEditor, useEditorState } from '@tiptap/react';
|
||||
import StarterKit from '@tiptap/starter-kit';
|
||||
import Image from '@tiptap/extension-image';
|
||||
import { Placeholder } from '@tiptap/extensions';
|
||||
import { imgUrl, uploadImage } from '../api.js';
|
||||
|
||||
const imageFiles = (list) => [...(list || [])].filter((f) => f.type.startsWith('image/'));
|
||||
|
||||
/**
|
||||
* Artikel-Editor. Über `ref` lässt sich der aktuelle Inhalt mit `ref.current.getHTML()` abrufen.
|
||||
* onChange: bei jeder Änderung, onUploading(bool): während Bilder hochgeladen werden.
|
||||
*/
|
||||
export default function Editor({ ref, content, onChange, onUploading, onError }) {
|
||||
const [uploading, setUploading] = useState(0);
|
||||
const fileInput = useRef(null);
|
||||
const editorRef = useRef(null);
|
||||
const callbacks = useRef({});
|
||||
callbacks.current = { onChange, onUploading, onError };
|
||||
|
||||
// Bilder hochladen und nacheinander an `pos` (oder an der Cursorposition) einfügen
|
||||
async function insertImages(files, pos) {
|
||||
const images = imageFiles(files);
|
||||
if (!images.length) return;
|
||||
setUploading(images.length);
|
||||
callbacks.current.onUploading?.(true);
|
||||
try {
|
||||
for (const file of images) {
|
||||
const src = imgUrl(await uploadImage(file));
|
||||
const chain = editorRef.current.chain().focus();
|
||||
(pos == null ? chain.setImage({ src }) : chain.insertContentAt(pos, { type: 'image', attrs: { src } })).run();
|
||||
pos = null;
|
||||
setUploading((n) => n - 1);
|
||||
}
|
||||
} catch (err) {
|
||||
callbacks.current.onError?.(err.message);
|
||||
} finally {
|
||||
setUploading(0);
|
||||
callbacks.current.onUploading?.(false);
|
||||
}
|
||||
}
|
||||
|
||||
const editor = useEditor({
|
||||
extensions: [
|
||||
StarterKit.configure({
|
||||
heading: { levels: [2, 3] },
|
||||
code: false,
|
||||
codeBlock: false,
|
||||
link: { openOnClick: false, autolink: true, defaultProtocol: 'https' },
|
||||
}),
|
||||
Image,
|
||||
Placeholder.configure({ placeholder: 'Hier den Artikel schreiben … Bilder per Button, Einfügen (Strg+V) oder Drag & Drop.' }),
|
||||
],
|
||||
content,
|
||||
editorProps: {
|
||||
attributes: { class: 'article-body editor-content', 'aria-label': 'Artikeltext' },
|
||||
handlePaste(view, event) {
|
||||
const files = imageFiles(event.clipboardData?.files);
|
||||
if (!files.length) return false;
|
||||
insertImages(files);
|
||||
return true;
|
||||
},
|
||||
handleDrop(view, event, slice, moved) {
|
||||
const files = imageFiles(event.dataTransfer?.files);
|
||||
if (moved || !files.length) return false;
|
||||
event.preventDefault();
|
||||
const pos = view.posAtCoords({ left: event.clientX, top: event.clientY })?.pos;
|
||||
insertImages(files, pos);
|
||||
return true;
|
||||
},
|
||||
},
|
||||
onUpdate: () => callbacks.current.onChange?.(),
|
||||
});
|
||||
|
||||
editorRef.current = editor;
|
||||
|
||||
useImperativeHandle(ref, () => ({ getHTML: () => editor.getHTML() }), [editor]);
|
||||
|
||||
return (
|
||||
<div className="editor">
|
||||
<Toolbar editor={editor} onImage={() => fileInput.current.click()} uploading={uploading} />
|
||||
<input
|
||||
ref={fileInput}
|
||||
type="file"
|
||||
accept="image/*"
|
||||
multiple
|
||||
hidden
|
||||
onChange={(e) => { insertImages(e.target.files); e.target.value = ''; }}
|
||||
/>
|
||||
<EditorContent editor={editor} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function Toolbar({ editor, onImage, uploading }) {
|
||||
const state = useEditorState({
|
||||
editor,
|
||||
selector: ({ editor: e }) => ({
|
||||
paragraph: e.isActive('paragraph'),
|
||||
h2: e.isActive('heading', { level: 2 }),
|
||||
h3: e.isActive('heading', { level: 3 }),
|
||||
bold: e.isActive('bold'),
|
||||
italic: e.isActive('italic'),
|
||||
underline: e.isActive('underline'),
|
||||
bulletList: e.isActive('bulletList'),
|
||||
orderedList: e.isActive('orderedList'),
|
||||
blockquote: e.isActive('blockquote'),
|
||||
link: e.isActive('link'),
|
||||
canUndo: e.can().undo(),
|
||||
canRedo: e.can().redo(),
|
||||
}),
|
||||
});
|
||||
|
||||
const run = (fn) => () => fn(editor.chain().focus()).run();
|
||||
|
||||
function toggleLink() {
|
||||
if (state.link) return editor.chain().focus().extendMarkRange('link').unsetLink().run();
|
||||
if (editor.state.selection.empty) return window.alert('Zuerst den Text markieren, der verlinkt werden soll.');
|
||||
const url = window.prompt('Link-Adresse:', 'https://');
|
||||
if (url && url !== 'https://') editor.chain().focus().extendMarkRange('link').setLink({ href: url }).run();
|
||||
}
|
||||
|
||||
const tools = [
|
||||
{ label: 'Text', title: 'Normaler Absatz', active: state.paragraph, onClick: run((c) => c.setParagraph()) },
|
||||
{ label: 'Überschrift', title: 'Überschrift', active: state.h2, onClick: run((c) => c.toggleHeading({ level: 2 })) },
|
||||
{ label: 'Zwischentitel', title: 'Zwischenüberschrift', active: state.h3, onClick: run((c) => c.toggleHeading({ level: 3 })) },
|
||||
null,
|
||||
{ label: <b>B</b>, title: 'Fett (Strg+B)', active: state.bold, onClick: run((c) => c.toggleBold()) },
|
||||
{ label: <i>I</i>, title: 'Kursiv (Strg+I)', active: state.italic, onClick: run((c) => c.toggleItalic()) },
|
||||
{ label: <u>U</u>, title: 'Unterstrichen (Strg+U)', active: state.underline, onClick: run((c) => c.toggleUnderline()) },
|
||||
null,
|
||||
{ label: 'Liste', title: 'Aufzählung', active: state.bulletList, onClick: run((c) => c.toggleBulletList()) },
|
||||
{ label: 'Nummeriert', title: 'Nummerierte Liste', active: state.orderedList, onClick: run((c) => c.toggleOrderedList()) },
|
||||
{ label: 'Zitat', title: 'Zitat', active: state.blockquote, onClick: run((c) => c.toggleBlockquote()) },
|
||||
null,
|
||||
{ label: 'Link', title: state.link ? 'Link entfernen' : 'Link einfügen', active: state.link, onClick: toggleLink },
|
||||
{ label: uploading ? `Lade ${uploading} …` : 'Bild', title: 'Bild einfügen', onClick: onImage, disabled: uploading > 0 },
|
||||
{ label: 'Linie', title: 'Trennlinie', onClick: run((c) => c.setHorizontalRule()) },
|
||||
null,
|
||||
{ label: '↶', title: 'Rückgängig (Strg+Z)', onClick: run((c) => c.undo()), disabled: !state.canUndo },
|
||||
{ label: '↷', title: 'Wiederholen (Strg+Y)', onClick: run((c) => c.redo()), disabled: !state.canRedo },
|
||||
];
|
||||
|
||||
return (
|
||||
<div className="toolbar" role="toolbar" aria-label="Formatierung">
|
||||
{tools.map((t, i) =>
|
||||
t ? (
|
||||
<button
|
||||
key={i}
|
||||
type="button"
|
||||
title={t.title}
|
||||
aria-label={t.title}
|
||||
className={t.active ? 'active' : undefined}
|
||||
disabled={t.disabled}
|
||||
onMouseDown={(e) => e.preventDefault()}
|
||||
onClick={t.onClick}
|
||||
>
|
||||
{t.label}
|
||||
</button>
|
||||
) : (
|
||||
<span key={i} className="toolbar-sep" />
|
||||
),
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,83 @@
|
||||
import { useState } from 'react';
|
||||
|
||||
/**
|
||||
* Kanban-Projekt anlegen/bearbeiten: Name, Beschreibung und verknüpfte Artikel aus "P42 Projekte".
|
||||
* initial: { name, description, links: [articleId] } oder null (Dialog geschlossen).
|
||||
*/
|
||||
export default function KanbanProjectDialog({ ref, initial, isNew, articles, onSave }) {
|
||||
// Wird pro Öffnen neu erzeugt (key beim Aufrufer), daher direkt aus "initial" vorbelegen
|
||||
const [form, setForm] = useState(() => ({
|
||||
name: initial?.name || '',
|
||||
description: initial?.description || '',
|
||||
links: initial?.links || [],
|
||||
}));
|
||||
const [search, setSearch] = useState('');
|
||||
const [error, setError] = useState(null);
|
||||
const [busy, setBusy] = useState(false);
|
||||
|
||||
const field = (name) => ({ value: form[name], onChange: (e) => setForm((f) => ({ ...f, [name]: e.target.value })) });
|
||||
const toggle = (id) => setForm((f) => ({ ...f, links: f.links.includes(id) ? f.links.filter((x) => x !== id) : [...f.links, id] }));
|
||||
const q = search.trim().toLocaleLowerCase('de-DE');
|
||||
// Verknüpfte zuerst, dann nach Suche gefiltert
|
||||
const shown = [...articles]
|
||||
.filter((a) => !q || a.title.toLocaleLowerCase('de-DE').includes(q))
|
||||
.sort((a, b) => Number(form.links.includes(b.id)) - Number(form.links.includes(a.id)));
|
||||
|
||||
async function submit(e) {
|
||||
e.preventDefault();
|
||||
setBusy(true);
|
||||
setError(null);
|
||||
const err = await onSave({
|
||||
name: form.name,
|
||||
description: form.description,
|
||||
links: form.links,
|
||||
});
|
||||
setBusy(false);
|
||||
if (err) setError(err);
|
||||
else ref.current.close();
|
||||
}
|
||||
|
||||
return (
|
||||
<dialog className="modal task-dialog" ref={ref}>
|
||||
<form className="form" onSubmit={submit}>
|
||||
<h2>{isNew ? 'Neues Kanban-Projekt' : 'Projekt bearbeiten'}</h2>
|
||||
<label>
|
||||
Name
|
||||
<input {...field('name')} maxLength={80} required autoFocus />
|
||||
</label>
|
||||
<label>
|
||||
Beschreibung <small>(optional)</small>
|
||||
<textarea {...field('description')} rows={3} maxLength={5000} placeholder="Worum geht es in diesem Board?" />
|
||||
</label>
|
||||
<fieldset className="link-picker">
|
||||
<legend>Verknüpfte Artikel aus „P42 Projekte“ <small>({form.links.length} gewählt)</small></legend>
|
||||
{articles.length === 0 ? (
|
||||
<p className="hint">Noch keine Artikel vorhanden.</p>
|
||||
) : (
|
||||
<>
|
||||
{articles.length > 6 && (
|
||||
<input type="search" value={search} onChange={(e) => setSearch(e.target.value)} placeholder="Artikel suchen …" aria-label="Artikel suchen" />
|
||||
)}
|
||||
<ul>
|
||||
{shown.map((a) => (
|
||||
<li key={a.id}>
|
||||
<label className="checkbox">
|
||||
<input type="checkbox" checked={form.links.includes(a.id)} onChange={() => toggle(a.id)} />
|
||||
{a.title}
|
||||
</label>
|
||||
</li>
|
||||
))}
|
||||
{shown.length === 0 && <li className="hint">Kein Artikel passt zur Suche.</li>}
|
||||
</ul>
|
||||
</>
|
||||
)}
|
||||
</fieldset>
|
||||
{error && <p className="error">{error}</p>}
|
||||
<div className="form-actions">
|
||||
<button className="btn ghost" type="button" onClick={() => ref.current.close()}>Abbrechen</button>
|
||||
<button className="btn" type="submit" disabled={busy}>{isNew ? 'Anlegen' : 'Speichern'}</button>
|
||||
</div>
|
||||
</form>
|
||||
</dialog>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,122 @@
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
import { Link, NavLink, Outlet, useLocation } from 'react-router-dom';
|
||||
import { useAuth } from '../auth.jsx';
|
||||
import { LINK_SERVICES, useLinks } from '../links.jsx';
|
||||
import { isCalm, motionLocked, onMotionChange, setCalm } from '../motion.js';
|
||||
import { getTheme, onThemeChange, setTheme, THEMES } from '../theme.js';
|
||||
|
||||
const PRIMARY = [
|
||||
{ to: '/', label: 'Start', end: true },
|
||||
{ to: '/projekte', label: 'Projekte' },
|
||||
{ to: '/haushalt', label: 'Vorrat' },
|
||||
{ to: '/reisen', label: 'Reisen' },
|
||||
{ to: '/kanban', label: 'Kanban' },
|
||||
{ to: '/monitoring', label: 'Monitoring' },
|
||||
];
|
||||
|
||||
export default function Layout() {
|
||||
return (
|
||||
<>
|
||||
<header className="topbar">
|
||||
<Link className="brand" to="/">P42<span>.</span>Dashboard</Link>
|
||||
<nav className="topnav" aria-label="Hauptnavigation">
|
||||
{PRIMARY.map(({ to, label, end }) => <NavLink key={to} to={to} end={end}>{label}</NavLink>)}
|
||||
</nav>
|
||||
<Menu />
|
||||
</header>
|
||||
<main className="page">
|
||||
<Outlet />
|
||||
</main>
|
||||
<footer className="footer">P42 · F.W.</footer>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function Menu() {
|
||||
const [open, setOpen] = useState(false);
|
||||
const ref = useRef(null);
|
||||
const location = useLocation();
|
||||
const { links } = useLinks();
|
||||
const { user, logout } = useAuth();
|
||||
const external = LINK_SERVICES.filter(({ key }) => links[key]);
|
||||
const [calm, setCalmState] = useState(isCalm);
|
||||
const [theme, setThemeState] = useState(getTheme);
|
||||
|
||||
useEffect(() => onMotionChange(() => setCalmState(isCalm())), []);
|
||||
useEffect(() => onThemeChange(() => setThemeState(getTheme())), []);
|
||||
|
||||
// Beim Seitenwechsel schließen
|
||||
useEffect(() => setOpen(false), [location.pathname]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
const onClick = (e) => !ref.current?.contains(e.target) && setOpen(false);
|
||||
const onKey = (e) => e.key === 'Escape' && setOpen(false);
|
||||
document.addEventListener('click', onClick);
|
||||
document.addEventListener('keydown', onKey);
|
||||
return () => {
|
||||
document.removeEventListener('click', onClick);
|
||||
document.removeEventListener('keydown', onKey);
|
||||
};
|
||||
}, [open]);
|
||||
|
||||
return (
|
||||
<div className="menu" ref={ref}>
|
||||
<button
|
||||
className="menu-btn"
|
||||
type="button"
|
||||
aria-expanded={open}
|
||||
aria-controls="main-menu"
|
||||
aria-label="Menü"
|
||||
onClick={() => setOpen((o) => !o)}
|
||||
>
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round">
|
||||
{open ? <path d="M6 6l12 12M18 6L6 18" /> : <path d="M4 7h16M4 12h16M4 17h16" />}
|
||||
</svg>
|
||||
</button>
|
||||
{open && (
|
||||
<nav className="menu-panel" id="main-menu" aria-label="Menü">
|
||||
<div className="menu-primary">
|
||||
{PRIMARY.map(({ to, label, end }) => <NavLink key={to} to={to} end={end}>{label}</NavLink>)}
|
||||
<hr />
|
||||
</div>
|
||||
{external.map(({ key, name }) => (
|
||||
<a key={key} href={links[key]} target="_blank" rel="noopener noreferrer">{name} ↗</a>
|
||||
))}
|
||||
{external.length > 0 && <hr />}
|
||||
<div className="menu-label" id="design-label">Design</div>
|
||||
<div className="theme-picker" role="radiogroup" aria-labelledby="design-label">
|
||||
{THEMES.map((t) => (
|
||||
<button
|
||||
key={t.key}
|
||||
type="button"
|
||||
role="radio"
|
||||
aria-checked={theme === t.key}
|
||||
className="menu-item theme-option"
|
||||
onClick={() => setTheme(t.key)}
|
||||
>
|
||||
<span className="swatch" aria-hidden="true">
|
||||
{t.swatch.map((c) => <i key={c} style={{ background: c }} />)}
|
||||
</span>
|
||||
<span className="theme-name">{t.name}<small>{t.hint}</small></span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
<hr />
|
||||
{!motionLocked() && (
|
||||
<button type="button" className="menu-item" aria-pressed={!calm} onClick={() => setCalm(!calm)}>
|
||||
Animation: {calm ? 'aus' : 'an'}
|
||||
</button>
|
||||
)}
|
||||
<hr />
|
||||
<div className="menu-user">
|
||||
Angemeldet als <strong>{user.displayName}</strong>
|
||||
</div>
|
||||
<NavLink to="/konto">Mein Konto</NavLink>
|
||||
{user.isAdmin && <NavLink to="/benutzer">Benutzer verwalten</NavLink>}
|
||||
<button type="button" className="menu-item" onClick={logout}>Abmelden</button>
|
||||
</nav>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
import { useEffect, useRef } from 'react';
|
||||
|
||||
// Vergrößerte Bildansicht; schließt per Klick oder Esc
|
||||
export default function Lightbox({ src, onClose }) {
|
||||
const ref = useRef(null);
|
||||
|
||||
useEffect(() => {
|
||||
const dialog = ref.current;
|
||||
if (src && !dialog.open) dialog.showModal();
|
||||
if (!src && dialog.open) dialog.close();
|
||||
}, [src]);
|
||||
|
||||
return (
|
||||
<dialog className="lightbox" ref={ref} onClick={onClose} onClose={onClose}>
|
||||
{src && <img src={src} alt="" />}
|
||||
</dialog>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { LINK_SERVICES, useLinks } from '../links.jsx';
|
||||
|
||||
// Dialog zum Anpassen der Links hinter den Schnellzugriff-Kacheln
|
||||
export default function LinksDialog({ ref }) {
|
||||
const { links, saveLinks } = useLinks();
|
||||
const [values, setValues] = useState({});
|
||||
const [error, setError] = useState(null);
|
||||
const [busy, setBusy] = useState(false);
|
||||
|
||||
// Gespeicherte Werte übernehmen – beim Laden und nach dem Schließen (verwirft Abgebrochenes)
|
||||
const reset = () => { setValues(links); setError(null); };
|
||||
useEffect(reset, [links]);
|
||||
|
||||
async function submit(e) {
|
||||
e.preventDefault();
|
||||
setBusy(true);
|
||||
setError(null);
|
||||
try {
|
||||
await saveLinks(Object.fromEntries(LINK_SERVICES.map(({ key }) => [key, values[key] || ''])));
|
||||
ref.current.close();
|
||||
} catch (err) {
|
||||
setError(err.message);
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<dialog className="modal" ref={ref} onClose={reset}>
|
||||
<form className="form" onSubmit={submit}>
|
||||
<h2>Links bearbeiten</h2>
|
||||
{LINK_SERVICES.map(({ key, name, placeholder }) => (
|
||||
<label key={key}>
|
||||
{name}
|
||||
<input
|
||||
type="text"
|
||||
inputMode="url"
|
||||
value={values[key] || ''}
|
||||
onChange={(e) => setValues((v) => ({ ...v, [key]: e.target.value }))}
|
||||
placeholder={placeholder}
|
||||
autoComplete="off"
|
||||
spellCheck={false}
|
||||
/>
|
||||
</label>
|
||||
))}
|
||||
<p className="hint">Leer lassen, wenn es den Dienst (noch) nicht gibt. „http://“ wird bei Bedarf ergänzt.</p>
|
||||
{error && <p className="error">{error}</p>}
|
||||
<div className="form-actions">
|
||||
<button className="btn ghost" type="button" onClick={() => ref.current.close()}>Abbrechen</button>
|
||||
<button className="btn" type="submit" disabled={busy}>{busy ? 'Speichere …' : 'Speichern'}</button>
|
||||
</div>
|
||||
</form>
|
||||
</dialog>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,63 @@
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
import { isCalm, onMotionChange } from '../motion.js';
|
||||
|
||||
// Dezenter "digitaler Regen" im Hintergrund – Farbe kommt aus dem Stylesheet (--accent-rgb, --bright).
|
||||
// Pausiert in inaktiven Tabs, entfällt bei "Bewegung reduzieren" und lässt sich im Menü abschalten.
|
||||
const CHARS = 'アイウエオカキクケコサシスセソタチツテトナニヌネノハヒフヘホマミムメモヤユヨラリルレロワン0123456789P42';
|
||||
const FONT_SIZE = 16;
|
||||
const FPS = 18;
|
||||
|
||||
export default function MatrixRain() {
|
||||
const ref = useRef(null);
|
||||
const [calm, setCalmState] = useState(isCalm);
|
||||
|
||||
useEffect(() => onMotionChange(() => setCalmState(isCalm())), []);
|
||||
|
||||
useEffect(() => {
|
||||
if (calm) return undefined;
|
||||
const canvas = ref.current;
|
||||
const ctx = canvas.getContext('2d');
|
||||
const css = getComputedStyle(document.documentElement);
|
||||
const color = `rgb(${css.getPropertyValue('--accent-rgb').trim()})`;
|
||||
const highlight = css.getPropertyValue('--bright').trim();
|
||||
let drops = [];
|
||||
let frame = 0;
|
||||
let last = 0;
|
||||
|
||||
function resize() {
|
||||
canvas.width = window.innerWidth;
|
||||
canvas.height = window.innerHeight;
|
||||
const columns = Math.ceil(canvas.width / FONT_SIZE);
|
||||
drops = Array.from({ length: columns }, () => Math.floor((Math.random() * canvas.height) / FONT_SIZE));
|
||||
ctx.fillStyle = '#000';
|
||||
ctx.fillRect(0, 0, canvas.width, canvas.height);
|
||||
}
|
||||
|
||||
function draw(time) {
|
||||
frame = requestAnimationFrame(draw);
|
||||
if (document.hidden || time - last < 1000 / FPS) return;
|
||||
last = time;
|
||||
ctx.fillStyle = 'rgba(0, 0, 0, 0.08)';
|
||||
ctx.fillRect(0, 0, canvas.width, canvas.height);
|
||||
ctx.font = `${FONT_SIZE}px monospace`;
|
||||
drops.forEach((y, i) => {
|
||||
const char = CHARS[Math.floor(Math.random() * CHARS.length)];
|
||||
// Vorderste Zeichen heller
|
||||
ctx.fillStyle = Math.random() > 0.96 ? highlight : color;
|
||||
ctx.fillText(char, i * FONT_SIZE, y * FONT_SIZE);
|
||||
drops[i] = y * FONT_SIZE > canvas.height && Math.random() > 0.975 ? 0 : y + 1;
|
||||
});
|
||||
}
|
||||
|
||||
resize();
|
||||
window.addEventListener('resize', resize);
|
||||
frame = requestAnimationFrame(draw);
|
||||
return () => {
|
||||
cancelAnimationFrame(frame);
|
||||
window.removeEventListener('resize', resize);
|
||||
ctx.clearRect(0, 0, canvas.width, canvas.height);
|
||||
};
|
||||
}, [calm]);
|
||||
|
||||
return <canvas ref={ref} className="matrix-rain" aria-hidden="true" />;
|
||||
}
|
||||
@@ -0,0 +1,93 @@
|
||||
import { useId, useMemo, useState } from 'react';
|
||||
|
||||
const MAX_SUGGESTIONS = 8;
|
||||
const fold = (s) => s.toLocaleLowerCase('de-DE');
|
||||
|
||||
/**
|
||||
* Eingabefeld mit Vorschlägen aus dem Produktkatalog, damit Namen einheitlich bleiben.
|
||||
* products: bekannte Namen (häufig genutzte zuerst), hint(name): Zusatzinfo je Vorschlag.
|
||||
*/
|
||||
export default function ProductInput({ value, onChange, products, hint, inputRef, ...props }) {
|
||||
const [open, setOpen] = useState(false);
|
||||
const [active, setActive] = useState(-1);
|
||||
const listId = useId();
|
||||
|
||||
const suggestions = useMemo(() => {
|
||||
const q = fold(value.trim());
|
||||
if (!q) return products.slice(0, MAX_SUGGESTIONS);
|
||||
// Treffer am Wortanfang zuerst, dann irgendwo im Namen
|
||||
const starts = [];
|
||||
const contains = [];
|
||||
for (const name of products) {
|
||||
const n = fold(name);
|
||||
if (n === q) continue;
|
||||
if (n.startsWith(q) || n.includes(` ${q}`)) starts.push(name);
|
||||
else if (n.includes(q)) contains.push(name);
|
||||
}
|
||||
return [...starts, ...contains].slice(0, MAX_SUGGESTIONS);
|
||||
}, [value, products]);
|
||||
|
||||
const show = open && suggestions.length > 0;
|
||||
|
||||
function pick(name) {
|
||||
onChange(name);
|
||||
setOpen(false);
|
||||
setActive(-1);
|
||||
}
|
||||
|
||||
function onKeyDown(e) {
|
||||
if (e.key === 'ArrowDown') {
|
||||
e.preventDefault();
|
||||
setOpen(true);
|
||||
setActive((i) => Math.min(i + 1, suggestions.length - 1));
|
||||
} else if (e.key === 'ArrowUp') {
|
||||
e.preventDefault();
|
||||
setActive((i) => Math.max(i - 1, -1));
|
||||
} else if (e.key === 'Enter' && show && active >= 0) {
|
||||
e.preventDefault();
|
||||
pick(suggestions[active]);
|
||||
} else if (e.key === 'Escape' && show) {
|
||||
e.preventDefault();
|
||||
setOpen(false);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="combo">
|
||||
<input
|
||||
{...props}
|
||||
ref={inputRef}
|
||||
value={value}
|
||||
onChange={(e) => { onChange(e.target.value); setOpen(true); setActive(-1); }}
|
||||
onFocus={() => setOpen(true)}
|
||||
onBlur={() => setOpen(false)}
|
||||
onKeyDown={onKeyDown}
|
||||
role="combobox"
|
||||
aria-autocomplete="list"
|
||||
aria-expanded={show}
|
||||
aria-controls={listId}
|
||||
aria-activedescendant={show && active >= 0 ? `${listId}-${active}` : undefined}
|
||||
autoComplete="off"
|
||||
spellCheck={false}
|
||||
/>
|
||||
{show && (
|
||||
<ul className="combo-list" id={listId} role="listbox">
|
||||
{suggestions.map((name, i) => (
|
||||
<li
|
||||
key={name}
|
||||
id={`${listId}-${i}`}
|
||||
role="option"
|
||||
aria-selected={i === active}
|
||||
className={i === active ? 'active' : undefined}
|
||||
// mousedown statt click: sonst schließt onBlur die Liste vorher
|
||||
onMouseDown={(e) => { e.preventDefault(); pick(name); }}
|
||||
>
|
||||
<span>{name}</span>
|
||||
{hint?.(name) && <small>{hint(name)}</small>}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,278 @@
|
||||
import { useState } from 'react';
|
||||
import { api } from '../api.js';
|
||||
import { ceil2, fmtItems, fmtQty, parseQty } from '../units.js';
|
||||
import ProductInput from './ProductInput.jsx';
|
||||
import UnitSelect from './UnitSelect.jsx';
|
||||
|
||||
const PEOPLE_KEY = 'p42.rezeptPersonen';
|
||||
const UNIT_LABELS = { el: 'EL', tl: 'TL', pck: 'Pck.', msp: 'Msp.', stk: 'Stk.', liter: 'l', gr: 'g' };
|
||||
|
||||
const unitLabel = (u) => UNIT_LABELS[u] || (u.length > 2 ? u[0].toUpperCase() + u.slice(1) : u);
|
||||
// Rezeptmengen, die sich in kg bzw. l umrechnen lassen
|
||||
const CONVERT = {
|
||||
g: ['kg', 0.001], gr: ['kg', 0.001], gramm: ['kg', 0.001], mg: ['kg', 0.000001], kg: ['kg', 1],
|
||||
ml: ['l', 0.001], cl: ['l', 0.01], dl: ['l', 0.1], l: ['l', 1], liter: ['l', 1],
|
||||
};
|
||||
const fmt = (n) => (n >= 10 ? Math.round(n) : Math.round(n * 10) / 10).toLocaleString('de-DE');
|
||||
|
||||
function loadPeople() {
|
||||
try { return Number(localStorage.getItem(PEOPLE_KEY)) || null; } catch { return null; }
|
||||
}
|
||||
|
||||
/**
|
||||
* Rezept abgleichen: Zutaten laden, auf Personenzahl umrechnen, mit dem Vorrat vergleichen
|
||||
* und Fehlendes auf die Einkaufsliste setzen. find(list, name) sucht in den aktuellen Listen.
|
||||
*/
|
||||
export default function RecipeCheck({ products, find, onTakeOver, busy }) {
|
||||
const [mode, setMode] = useState('url'); // url | text
|
||||
const [url, setUrl] = useState('');
|
||||
const [text, setText] = useState('');
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState(null);
|
||||
const [recipe, setRecipe] = useState(null);
|
||||
|
||||
async function load(e) {
|
||||
e.preventDefault();
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
try {
|
||||
const data = await api('POST', '/api/haushalt/rezept', mode === 'url' ? { url } : { text });
|
||||
// Ohne Portionsangabe gilt die Liste für die gewählte Personenzahl, Mengen bleiben wie eingegeben
|
||||
const people = loadPeople() || data.servings || 4;
|
||||
setRecipe({
|
||||
...data,
|
||||
base: String(data.servings || people),
|
||||
people: String(people),
|
||||
rows: data.ingredients.map((i) => ({ ...i, matched: i.product, take: null, qty: null })),
|
||||
done: null,
|
||||
});
|
||||
} catch (err) {
|
||||
setError(err.message);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
if (!recipe) {
|
||||
return (
|
||||
<section className="card recipe" aria-labelledby="recipe-title">
|
||||
<header className="household-head">
|
||||
<h2 id="recipe-title">Rezept abgleichen</h2>
|
||||
<button className="link-btn" type="button" onClick={() => { setMode(mode === 'url' ? 'text' : 'url'); setError(null); }}>
|
||||
{mode === 'url' ? 'Zutaten selbst einfügen' : 'Rezept-Link verwenden'}
|
||||
</button>
|
||||
</header>
|
||||
<form className="recipe-load" onSubmit={load}>
|
||||
{mode === 'url' ? (
|
||||
<input
|
||||
type="url"
|
||||
inputMode="url"
|
||||
value={url}
|
||||
onChange={(e) => setUrl(e.target.value)}
|
||||
placeholder="Rezept-Link einfügen, z. B. von chefkoch.de"
|
||||
aria-label="Rezept-Link"
|
||||
required
|
||||
/>
|
||||
) : (
|
||||
<textarea
|
||||
value={text}
|
||||
onChange={(e) => setText(e.target.value)}
|
||||
rows={5}
|
||||
placeholder={'Eine Zutat pro Zeile, z. B.\n300 g Mehl\n2 Eier'}
|
||||
aria-label="Zutatenliste"
|
||||
required
|
||||
/>
|
||||
)}
|
||||
<button className="btn" type="submit" disabled={loading}>{loading ? 'Lade …' : 'Zutaten prüfen'}</button>
|
||||
</form>
|
||||
{error && <p className="error" role="alert">{error}</p>}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
const base = Number(recipe.base) || 0;
|
||||
const people = Number(recipe.people) || 0;
|
||||
const factor = base > 0 && people > 0 ? people / base : 1;
|
||||
|
||||
// Status je Zutat aus aktuellem Vorrat und gewählter Personenzahl
|
||||
const rows = recipe.rows.map((row) => {
|
||||
const scaled = row.amount != null ? row.amount * factor : null;
|
||||
const stock = find('vorrat', row.product);
|
||||
const listed = find('einkauf', row.product);
|
||||
const convert = row.unit && CONVERT[row.unit];
|
||||
// Vergleichbar sind Gramm/ml mit kg/l im Vorrat und Stückzahlen mit "Anzahl"
|
||||
const measure = scaled == null ? null
|
||||
: convert ? { unit: convert[0], need: scaled * convert[1] }
|
||||
: !row.unit ? { unit: 'stk', need: Math.ceil(scaled - 1e-9) }
|
||||
: null;
|
||||
const same = measure && stock.find((s) => s.unit === measure.unit);
|
||||
let status;
|
||||
let missing;
|
||||
if (same) {
|
||||
missing = Math.max(0, measure.need - same.quantity);
|
||||
status = missing <= 1e-9 ? 'ok' : 'part';
|
||||
} else if (stock.length) {
|
||||
// Vorhanden, aber in anderer Einheit (z. B. 1 Packung Mehl): nur "vorhanden" prüfbar
|
||||
missing = 0;
|
||||
status = 'ok';
|
||||
} else {
|
||||
missing = measure ? measure.need : 1;
|
||||
status = 'missing';
|
||||
}
|
||||
const buyUnit = measure?.unit || 'stk';
|
||||
// Vorhandenes trotzdem ankreuzen: dann die volle benötigte Menge vorschlagen
|
||||
const amount = missing > 1e-9 ? missing : measure ? measure.need : 1;
|
||||
const autoQty = buyUnit === 'stk' ? Math.max(1, Math.ceil(amount)) : ceil2(amount);
|
||||
const autoTake = status !== 'ok' && !listed.length && !row.optional && !row.staple;
|
||||
return {
|
||||
...row,
|
||||
scaled,
|
||||
stock,
|
||||
listed,
|
||||
status,
|
||||
missing,
|
||||
unitBuy: row.unitBuy ?? buyUnit,
|
||||
take: row.take ?? autoTake,
|
||||
qty: row.qty ?? String(autoQty).replace('.', ','),
|
||||
};
|
||||
});
|
||||
const chosen = rows.filter((r) => r.take && r.product.trim());
|
||||
|
||||
const update = (index, patch) =>
|
||||
setRecipe((r) => ({ ...r, done: null, invalid: null, rows: r.rows.map((row, i) => (i === index ? { ...row, ...patch } : row)) }));
|
||||
|
||||
function setPeople(value) {
|
||||
setRecipe((r) => ({ ...r, people: value, done: null, rows: r.rows.map((row) => ({ ...row, qty: null })) }));
|
||||
try { if (Number(value) > 0) localStorage.setItem(PEOPLE_KEY, value); } catch { /* ignorieren */ }
|
||||
}
|
||||
|
||||
async function takeOver() {
|
||||
const items = chosen.map((r) => ({ name: r.product.trim(), quantity: parseQty(r.qty, r.unitBuy), unit: r.unitBuy }));
|
||||
const invalid = chosen.find((r, i) => items[i].quantity == null);
|
||||
if (invalid) {
|
||||
setRecipe((r) => ({ ...r, invalid: invalid.raw }));
|
||||
return;
|
||||
}
|
||||
// Von Hand geänderte Zuordnungen merken, damit sie beim nächsten Rezept passen
|
||||
const aliases = rows
|
||||
.filter((r) => r.product.trim() && r.product.trim() !== r.matched)
|
||||
.map((r) => ({ ingredient: r.name, product: r.product.trim() }));
|
||||
if (await onTakeOver({ items, aliases })) {
|
||||
setRecipe((r) => ({
|
||||
...r,
|
||||
done: items.length,
|
||||
invalid: null,
|
||||
rows: r.rows.map((row) => ({ ...row, matched: row.product.trim() || row.matched, take: null, qty: null, unitBuy: null })),
|
||||
}));
|
||||
}
|
||||
}
|
||||
|
||||
const STATUS = {
|
||||
ok: (r) => [`Vorhanden${r.stock.length ? ` (${fmtItems(r.stock)})` : ''}`, 'ok'],
|
||||
part: (r) => [`Fehlen ${fmtQty(r.unitBuy === 'stk' ? Math.ceil(r.missing) : ceil2(r.missing), r.unitBuy)}`, 'warn'],
|
||||
missing: () => ['Fehlt', 'bad'],
|
||||
};
|
||||
|
||||
return (
|
||||
<section className="card recipe" aria-labelledby="recipe-title">
|
||||
<header className="household-head">
|
||||
<h2 id="recipe-title">
|
||||
{recipe.source
|
||||
? <a href={recipe.source} target="_blank" rel="noopener noreferrer">{recipe.title} ↗</a>
|
||||
: recipe.title}
|
||||
</h2>
|
||||
<button className="link-btn" type="button" onClick={() => setRecipe(null)}>✕ Abgleich schließen</button>
|
||||
</header>
|
||||
|
||||
<div className="recipe-people">
|
||||
<label>
|
||||
Rezept ist für
|
||||
<input type="number" inputMode="numeric" min={1} max={100} value={recipe.base}
|
||||
onChange={(e) => setRecipe((r) => ({ ...r, base: e.target.value, done: null, rows: r.rows.map((row) => ({ ...row, qty: null })) }))} />
|
||||
Portionen
|
||||
</label>
|
||||
<label className="recipe-people-main">
|
||||
Ich koche für
|
||||
<input type="number" inputMode="numeric" min={1} max={100} value={recipe.people} onChange={(e) => setPeople(e.target.value)} />
|
||||
Personen
|
||||
</label>
|
||||
{!recipe.servings && <span className="hint">Das Rezept nennt keine Portionen, bitte prüfen.</span>}
|
||||
</div>
|
||||
|
||||
<ul className="recipe-rows">
|
||||
{rows.map((row, i) => {
|
||||
// Fehlt, steht aber schon auf der Einkaufsliste: nicht rot warnen
|
||||
const [label, tone] = row.status !== 'ok' && row.listed.length
|
||||
? [`Auf Liste (${fmtItems(row.listed)})`, '']
|
||||
: STATUS[row.status](row);
|
||||
return (
|
||||
<li key={row.raw} className={`recipe-row${row.take ? ' chosen' : ''}`}>
|
||||
<input
|
||||
type="checkbox"
|
||||
className="household-check recipe-take"
|
||||
checked={row.take}
|
||||
onChange={(e) => update(i, { take: e.target.checked })}
|
||||
aria-label={`${row.product} auf die Einkaufsliste`}
|
||||
/>
|
||||
<div className="recipe-ingredient">
|
||||
<strong>
|
||||
{row.scaled != null && `${fmt(row.scaled)} `}
|
||||
{row.unit && `${unitLabel(row.unit)} `}
|
||||
{row.name}
|
||||
</strong>
|
||||
<small>
|
||||
{row.raw}
|
||||
{row.optional && ' · optional'}
|
||||
{row.listed.length > 0 && ` · steht auf Einkaufsliste (${fmtItems(row.listed)})`}
|
||||
</small>
|
||||
</div>
|
||||
<div className="recipe-product">
|
||||
<ProductInput
|
||||
value={row.product}
|
||||
onChange={(v) => update(i, { product: v, take: null, qty: null, unitBuy: null })}
|
||||
products={products}
|
||||
aria-label={`Produkt für ${row.name}`}
|
||||
maxLength={60}
|
||||
/>
|
||||
</div>
|
||||
<span className={`badge ${tone} recipe-status`}>{label}</span>
|
||||
<div className="recipe-buy qty-unit">
|
||||
<input
|
||||
className="recipe-qty"
|
||||
type="text"
|
||||
inputMode="decimal"
|
||||
value={row.qty}
|
||||
disabled={!row.take}
|
||||
onChange={(e) => update(i, { qty: e.target.value })}
|
||||
aria-invalid={(row.take && parseQty(row.qty, row.unitBuy) == null) || undefined}
|
||||
aria-label={`Menge ${row.product} zum Einkaufen`}
|
||||
title="Menge für die Einkaufsliste"
|
||||
/>
|
||||
<UnitSelect
|
||||
value={row.unitBuy}
|
||||
disabled={!row.take}
|
||||
onChange={(unitBuy) => update(i, { unitBuy })}
|
||||
label={`Einheit ${row.product} zum Einkaufen`}
|
||||
compact
|
||||
/>
|
||||
</div>
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
|
||||
<footer className="recipe-foot">
|
||||
<p className={recipe.invalid ? 'error' : 'hint'} aria-live="polite">
|
||||
{recipe.invalid
|
||||
? `Bitte die Menge bei „${recipe.invalid}“ prüfen (Anzahl: ganze Zahl, kg/l: z. B. 0,5).`
|
||||
: recipe.done != null
|
||||
? `✓ ${recipe.done} ${recipe.done === 1 ? 'Zutat' : 'Zutaten'} auf die Einkaufsliste gesetzt.`
|
||||
: 'Gramm und ml werden mit kg/l im Vorrat verglichen. EL, Packungen usw. nur auf „vorhanden“.'}
|
||||
</p>
|
||||
<button className="btn" type="button" disabled={busy || chosen.length === 0} onClick={takeOver}>
|
||||
Fehlendes auf Einkaufsliste ({chosen.length})
|
||||
</button>
|
||||
</footer>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
import { STATUS } from '../api.js';
|
||||
|
||||
export default function StatusBadge({ status }) {
|
||||
const [label, tone] = STATUS[status] || [status, ''];
|
||||
return <span className={`badge ${tone}`}>{label}</span>;
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { getTheme, onThemeChange } from '../theme.js';
|
||||
import BlackwallField from './BlackwallField.jsx';
|
||||
import MatrixRain from './MatrixRain.jsx';
|
||||
|
||||
// Dekorativer Hintergrund des aktiven Designs (alles aria-hidden, ohne Mausereignisse)
|
||||
const BACKGROUNDS = {
|
||||
matrix: MatrixRain,
|
||||
blackwall: BlackwallField,
|
||||
};
|
||||
|
||||
export default function ThemeBackground() {
|
||||
const [theme, setTheme] = useState(getTheme);
|
||||
useEffect(() => onThemeChange(() => setTheme(getTheme())), []);
|
||||
const Background = BACKGROUNDS[theme];
|
||||
// key: beim Wechsel neu aufbauen, damit Farben aus den CSS-Variablen frisch gelesen werden
|
||||
return <Background key={theme} />;
|
||||
}
|
||||
@@ -0,0 +1,228 @@
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
import { useSearchParams } from 'react-router-dom';
|
||||
import { api } from '../api.js';
|
||||
|
||||
const STATUS = {
|
||||
notfound: ['Nicht gefunden', 'warn'],
|
||||
inforeceived: ['Daten übermittelt', ''],
|
||||
intransit: ['Unterwegs', ''],
|
||||
outfordelivery: ['In Zustellung', ''],
|
||||
pickup: ['Abholbereit', 'warn'],
|
||||
delivered: ['Zugestellt', 'ok'],
|
||||
failed: ['Zustellung fehlgeschlagen', 'bad'],
|
||||
exception: ['Problem', 'bad'],
|
||||
expired: ['Abgelaufen', 'warn'],
|
||||
pending: ['Wird abgefragt …', ''],
|
||||
unknown: ['Unbekannt', ''],
|
||||
};
|
||||
|
||||
const VISIBLE_EVENTS = 6;
|
||||
const RECENT_KEY = 'p42.recentTracking';
|
||||
const normalize = (n) => n.replace(/\s+/g, '').toUpperCase();
|
||||
|
||||
function fmtDate(iso, withTime = true) {
|
||||
if (!iso) return '';
|
||||
const d = new Date(iso);
|
||||
if (Number.isNaN(d.getTime())) return iso;
|
||||
return d.toLocaleString('de-DE', {
|
||||
weekday: 'short', day: '2-digit', month: '2-digit', year: 'numeric',
|
||||
...(withTime && /T\d/.test(iso) ? { hour: '2-digit', minute: '2-digit' } : {}),
|
||||
});
|
||||
}
|
||||
|
||||
function fmtEta(eta) {
|
||||
if (!eta) return null;
|
||||
if (eta.from && eta.to && eta.from.slice(0, 10) !== eta.to.slice(0, 10)) {
|
||||
return `${fmtDate(eta.from, false)} – ${fmtDate(eta.to, false)}`;
|
||||
}
|
||||
return fmtDate(eta.from || eta.to);
|
||||
}
|
||||
|
||||
function loadRecent() {
|
||||
try { return JSON.parse(localStorage.getItem(RECENT_KEY)) || []; } catch { return []; }
|
||||
}
|
||||
|
||||
export default function Tracking() {
|
||||
const [params] = useSearchParams();
|
||||
const [input, setInput] = useState(params.get('n') || '');
|
||||
const [result, setResult] = useState(null);
|
||||
const [error, setError] = useState(null);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [recent, setRecent] = useState(loadRecent);
|
||||
const requestId = useRef(0);
|
||||
const retryTimer = useRef(null);
|
||||
|
||||
function remember(number) {
|
||||
const list = [number, ...loadRecent().filter((n) => n !== number)].slice(0, 5);
|
||||
try { localStorage.setItem(RECENT_KEY, JSON.stringify(list)); } catch { /* ignorieren */ }
|
||||
setRecent(list);
|
||||
}
|
||||
|
||||
async function track(number, attempt = 0) {
|
||||
const req = ++requestId.current;
|
||||
clearTimeout(retryTimer.current);
|
||||
setLoading(true);
|
||||
if (attempt === 0) {
|
||||
setResult(null);
|
||||
setError(null);
|
||||
}
|
||||
try {
|
||||
const data = await api('GET', `/api/track?number=${encodeURIComponent(number)}`);
|
||||
if (req !== requestId.current) return;
|
||||
setResult(data);
|
||||
remember(data.number);
|
||||
// Frisch registrierte Nummern brauchen bei 17TRACK ein paar Sekunden
|
||||
if (data.status === 'pending' && attempt < 4) {
|
||||
retryTimer.current = setTimeout(() => track(number, attempt + 1), 4000);
|
||||
}
|
||||
} catch (err) {
|
||||
if (req === requestId.current) setError(err.message);
|
||||
} finally {
|
||||
if (req === requestId.current) setLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
if (params.get('n')) track(normalize(params.get('n')));
|
||||
return () => clearTimeout(retryTimer.current);
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, []);
|
||||
|
||||
function submit(e) {
|
||||
e.preventDefault();
|
||||
const number = normalize(input);
|
||||
if (number) track(number);
|
||||
}
|
||||
|
||||
return (
|
||||
<section className="hero">
|
||||
<h1>Sendungsverfolgung</h1>
|
||||
<p className="lead">Sendungsnummer eingeben – der Paketdienst wird automatisch erkannt.</p>
|
||||
|
||||
<form className="track-form" onSubmit={submit} autoComplete="off">
|
||||
<div className="prompt-input">
|
||||
<input
|
||||
value={input}
|
||||
onChange={(e) => setInput(e.target.value)}
|
||||
placeholder="Sendungsnummer eingeben …"
|
||||
aria-label="Sendungsnummer"
|
||||
spellCheck={false}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
<button className="btn" type="submit" disabled={loading}>Verfolgen</button>
|
||||
</form>
|
||||
|
||||
{recent.length > 0 && (
|
||||
<div className="recent">
|
||||
{recent.map((n) => (
|
||||
<button key={n} type="button" className="chip" onClick={() => { setInput(n); track(n); }}>{n}</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="result" aria-live="polite">
|
||||
{loading && !result && <div className="card"><p className="hint">Suche Sendung …</p></div>}
|
||||
{error && <div className="card"><p className="error">{error}</p></div>}
|
||||
{result && <TrackingResult key={result.number} data={result} />}
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
function CarrierLinks({ data }) {
|
||||
return (
|
||||
<div className="links">
|
||||
{(data.candidates || []).map((c) => (
|
||||
<a key={c.id} href={c.link} target="_blank" rel="noopener noreferrer">{c.name} ↗</a>
|
||||
))}
|
||||
<a href={data.universalLink} target="_blank" rel="noopener noreferrer">17TRACK ↗</a>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function TrackingResult({ data }) {
|
||||
const [showAll, setShowAll] = useState(false);
|
||||
|
||||
// Kein Live-Ergebnis -> Erkennung + Links
|
||||
if (!data.source) {
|
||||
const names = (data.candidates || []).map((c) => c.name);
|
||||
return (
|
||||
<div className="card">
|
||||
<div className="number">{data.number}</div>
|
||||
<p>
|
||||
{names.length
|
||||
? <>Wahrscheinlich: <strong>{names.join(' / ')}</strong></>
|
||||
: 'Paketdienst konnte nicht anhand des Formats erkannt werden.'}
|
||||
</p>
|
||||
<p className="hint">
|
||||
{data.configured
|
||||
? `Live-Daten konnten nicht abgerufen werden.${data.errors?.length ? ` (${data.errors.join('; ')})` : ''}`
|
||||
: <>Für Live-Daten (Status, Ankunft, Verlauf) einen API-Key in <code>config.json</code> eintragen – siehe README.</>}
|
||||
</p>
|
||||
<CarrierLinks data={data} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const [label, tone] = STATUS[data.status] || STATUS.unknown;
|
||||
const events = data.events || [];
|
||||
const visible = showAll ? events : events.slice(0, VISIBLE_EVENTS);
|
||||
const facts = [
|
||||
data.deliveredAt
|
||||
? ['Zugestellt am', fmtDate(data.deliveredAt)]
|
||||
: ['Voraussichtliche Ankunft', fmtEta(data.eta) || 'keine Angabe'],
|
||||
events[0]?.location && ['Letzter Ort', events[0].location],
|
||||
data.origin && ['Von', data.origin],
|
||||
data.destination && ['Nach', data.destination],
|
||||
].filter(Boolean);
|
||||
|
||||
return (
|
||||
<div className="card tracking-result">
|
||||
<div className="tracking-summary">
|
||||
<div className="result-head">
|
||||
<div>
|
||||
<div className="carrier">{data.carrier || 'Unbekannter Paketdienst'}</div>
|
||||
<div className="number">{data.number}</div>
|
||||
</div>
|
||||
<span className={`badge ${tone}`}>{label}</span>
|
||||
</div>
|
||||
{data.statusDetail && data.status !== 'pending' && <p className="hint">{data.statusDetail}</p>}
|
||||
<dl className="facts">
|
||||
{facts.map(([k, v]) => (
|
||||
<div className="fact" key={k}><dt>{k}</dt><dd>{v}</dd></div>
|
||||
))}
|
||||
</dl>
|
||||
<CarrierLinks data={data} />
|
||||
<p className="hint">Quelle: {data.source}</p>
|
||||
</div>
|
||||
|
||||
<div className="tracking-events">
|
||||
{events.length ? (
|
||||
<>
|
||||
<ol className="timeline">
|
||||
{visible.map((e, i) => (
|
||||
<li key={i}>
|
||||
<div className="when">{fmtDate(e.time)}</div>
|
||||
<div className="what">{e.description}</div>
|
||||
{e.location && <div className="where">{e.location}</div>}
|
||||
</li>
|
||||
))}
|
||||
</ol>
|
||||
{events.length > VISIBLE_EVENTS && !showAll && (
|
||||
<button type="button" className="chip more" onClick={() => setShowAll(true)}>
|
||||
Alle {events.length} Schritte anzeigen
|
||||
</button>
|
||||
)}
|
||||
</>
|
||||
) : (
|
||||
<p className="hint">
|
||||
{data.status === 'pending'
|
||||
? 'Die Sendung wurde gerade registriert, Daten folgen gleich …'
|
||||
: 'Noch keine Sendungsereignisse vorhanden.'}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
import { UNITS } from '../units.js';
|
||||
|
||||
// Auswahl Anzahl / kg / Liter; compact für enge Listenzeilen ("Anz.")
|
||||
export default function UnitSelect({ value, onChange, label = 'Einheit', compact = false, ...props }) {
|
||||
return (
|
||||
<select className="unit-select" value={value} onChange={(e) => onChange(e.target.value)} aria-label={label} {...props}>
|
||||
{UNITS.map((u) => <option key={u.key} value={u.key}>{compact ? u.compact : u.label}</option>)}
|
||||
</select>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,157 @@
|
||||
import { useEffect, useImperativeHandle, useMemo, useRef, useState } from 'react';
|
||||
import { geoArea, geoGraticule10, geoNaturalEarth1, geoPath } from 'd3-geo';
|
||||
import { select } from 'd3-selection';
|
||||
import 'd3-transition';
|
||||
import { zoom as d3zoom, zoomIdentity } from 'd3-zoom';
|
||||
import { isCalm } from '../motion.js';
|
||||
|
||||
const MAX_ZOOM = 60;
|
||||
const CITY_ZOOM = 9;
|
||||
const PAD = 8;
|
||||
|
||||
const duration = () => (isCalm() ? 0 : 750);
|
||||
|
||||
// Bei Ländern mit Überseegebieten (Frankreich, USA …) auf das größte Teilstück zoomen
|
||||
function mainPart(feature) {
|
||||
const g = feature.geometry;
|
||||
if (g?.type !== 'MultiPolygon') return feature;
|
||||
let best = null;
|
||||
let bestArea = -1;
|
||||
for (const coordinates of g.coordinates) {
|
||||
const part = { type: 'Polygon', coordinates };
|
||||
const area = geoArea(part);
|
||||
if (area > bestArea) { best = part; bestArea = area; }
|
||||
}
|
||||
return best;
|
||||
}
|
||||
|
||||
/**
|
||||
* Weltkarte mit Ländern und Stadt-Punkten. Zoomen/Verschieben per Maus, Touch und Buttons.
|
||||
* countries: [{ key, name, feature }], visited: Set der Länder-Keys, selected: { type, key }.
|
||||
* Über `ref`: focusCountry(key, offset), focusCity(city, offset), zoomBy(f), reset().
|
||||
* offset: Ziel nach rechts oben schieben, weil unten links der Info-Kasten liegt.
|
||||
*/
|
||||
export default function WorldMap({ ref, countries, visited, cities, selected, onSelect }) {
|
||||
const box = useRef(null);
|
||||
const svgRef = useRef(null);
|
||||
const layer = useRef(null);
|
||||
const zoomRef = useRef(null);
|
||||
const [size, setSize] = useState({ w: 960, h: 500 });
|
||||
|
||||
// Kartengröße folgt dem Container; die Welt wird in die Breite eingepasst
|
||||
useEffect(() => {
|
||||
const ro = new ResizeObserver(([entry]) => {
|
||||
const { width, height } = entry.contentRect;
|
||||
if (width > 0 && height > 0) setSize({ w: Math.round(width), h: Math.round(height) });
|
||||
});
|
||||
ro.observe(box.current);
|
||||
return () => ro.disconnect();
|
||||
}, []);
|
||||
|
||||
const { path, projection } = useMemo(() => {
|
||||
const p = geoNaturalEarth1().fitExtent([[PAD, PAD], [size.w - PAD, size.h - PAD]], { type: 'Sphere' });
|
||||
return { projection: p, path: geoPath(p) };
|
||||
}, [size]);
|
||||
|
||||
const shapes = useMemo(
|
||||
() => countries.map((c) => ({ ...c, d: path(c.feature) })).filter((c) => c.d),
|
||||
[countries, path],
|
||||
);
|
||||
const sphere = useMemo(() => path({ type: 'Sphere' }), [path]);
|
||||
const graticule = useMemo(() => path(geoGraticule10()), [path]);
|
||||
|
||||
useEffect(() => {
|
||||
const svg = select(svgRef.current);
|
||||
const z = d3zoom()
|
||||
.scaleExtent([1, MAX_ZOOM])
|
||||
.translateExtent([[0, 0], [size.w, size.h]])
|
||||
.extent([[0, 0], [size.w, size.h]])
|
||||
.on('zoom', (event) => {
|
||||
layer.current.setAttribute('transform', event.transform);
|
||||
// Punkte und Linien behalten beim Zoomen ihre Größe
|
||||
svgRef.current.style.setProperty('--inv', String(1 / event.transform.k));
|
||||
});
|
||||
svg.call(z).on('dblclick.zoom', null);
|
||||
zoomRef.current = z;
|
||||
return () => svg.on('.zoom', null);
|
||||
}, [size]);
|
||||
|
||||
function zoomTo(k, [x, y], offset) {
|
||||
k = Math.max(1, Math.min(MAX_ZOOM, k));
|
||||
const cx = offset ? size.w * 0.64 : size.w / 2;
|
||||
const cy = offset ? size.h * 0.42 : size.h / 2;
|
||||
const t = zoomIdentity.translate(cx - k * x, cy - k * y).scale(k);
|
||||
select(svgRef.current).transition().duration(duration()).call(zoomRef.current.transform, t);
|
||||
}
|
||||
|
||||
useImperativeHandle(ref, () => ({
|
||||
focusCountry(key, offset = false) {
|
||||
const c = countries.find((x) => x.key === key);
|
||||
if (!c) return;
|
||||
const [[x0, y0], [x1, y1]] = path.bounds(mainPart(c.feature));
|
||||
// Mit Info-Kasten daneben nur gut die Hälfte der Fläche nutzen
|
||||
const fill = offset ? 0.5 : 0.8;
|
||||
const k = fill / Math.max((x1 - x0) / size.w, (y1 - y0) / size.h);
|
||||
zoomTo(Math.min(k, 24), [(x0 + x1) / 2, (y0 + y1) / 2], offset);
|
||||
},
|
||||
focusCity(city, offset = false) {
|
||||
const p = projection([city.lon, city.lat]);
|
||||
if (p) zoomTo(CITY_ZOOM, p, offset);
|
||||
},
|
||||
zoomBy(factor) {
|
||||
select(svgRef.current).transition().duration(duration() / 2).call(zoomRef.current.scaleBy, factor);
|
||||
},
|
||||
reset() {
|
||||
select(svgRef.current).transition().duration(duration()).call(zoomRef.current.transform, zoomIdentity);
|
||||
},
|
||||
}));
|
||||
|
||||
return (
|
||||
<div className="worldmap" ref={box}>
|
||||
<svg
|
||||
ref={svgRef}
|
||||
viewBox={`0 0 ${size.w} ${size.h}`}
|
||||
role="img"
|
||||
aria-label={`Weltkarte: ${visited.size} Länder und ${cities.length} Städte besucht`}
|
||||
onClick={() => onSelect(null)}
|
||||
>
|
||||
<g ref={layer}>
|
||||
<path className="map-sphere" d={sphere} />
|
||||
<path className="map-graticule" d={graticule} />
|
||||
{shapes.map((c) => (
|
||||
<path
|
||||
key={c.key}
|
||||
d={c.d}
|
||||
className={[
|
||||
'map-country',
|
||||
visited.has(c.key) && 'visited',
|
||||
selected?.type === 'country' && selected.key === c.key && 'selected',
|
||||
].filter(Boolean).join(' ')}
|
||||
onClick={(e) => { e.stopPropagation(); onSelect({ type: 'country', key: c.key }); }}
|
||||
>
|
||||
<title>{c.name}</title>
|
||||
</path>
|
||||
))}
|
||||
{cities.map((city) => {
|
||||
const p = projection([city.lon, city.lat]);
|
||||
if (!p) return null;
|
||||
const isSelected = selected?.type === 'city' && selected.key === city.id;
|
||||
return (
|
||||
<g key={city.id} transform={`translate(${p[0]},${p[1]})`}>
|
||||
<g
|
||||
className={`map-city${isSelected ? ' selected' : ''}`}
|
||||
onClick={(e) => { e.stopPropagation(); onSelect({ type: 'city', key: city.id }); }}
|
||||
>
|
||||
<title>{city.name}</title>
|
||||
<circle className="map-city-hit" r="11" />
|
||||
{isSelected && <circle className="map-city-ring" r="9" />}
|
||||
<circle className="map-city-dot" r="4" />
|
||||
</g>
|
||||
</g>
|
||||
);
|
||||
})}
|
||||
</g>
|
||||
</svg>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in new issue
Block a user