Kanban-Archiv mit automatischem Löschen, Reise-Karte als Bild herunterladen
Kanban: - Erledigte Aufgaben wandern 2 Tage nach "Erledigt" ins Archiv (Button "Archiv (n)" im Board, dort "Zurückholen") und werden nach 14 Tagen automatisch gelöscht (stündlich im Hintergrund und beim Öffnen) - Neue Spalte done_at; schon erledigte Aufgaben zählen ab dem Update Reisen: - "Karte herunterladen": eigene Städte und besuchte Länder als PNG (4000/8000 px, nur besuchte Hauptstädte beschriftet) oder SVG (kleine Punkte, alle Städte beschriftet, beliebig zoombar); Ausschnitt "Meine Orte" oder ganze Welt; Beschriftungen weichen einander aus - Hauptstadt-Kennzeichen (GeoNames PPLC) wird beim Hinzufügen gespeichert und für ältere Städte im Hintergrund bei Open-Meteo nachgetragen Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
f0e17b06b5
commit
dc4ce18593
9 files changed
+489
-12
No files matched your search
@@ -0,0 +1,175 @@
|
||||
// Reise-Karte als Bild exportieren: eigene Städte und besuchte Länder in hoher Auflösung.
|
||||
// Gezeichnet wird ein eigenes SVG (Farben aus dem aktiven Design); daraus wird bei Bedarf ein PNG.
|
||||
// SVG: kleine Punkte und Schrift, alle Städte beschriftet (man kann beliebig hineinzoomen).
|
||||
// PNG: größere Punkte, beschriftet nur besuchte Hauptstädte (sonst wird es im festen Raster zu voll).
|
||||
import { geoCircle, geoGraticule10, geoNaturalEarth1, geoPath } from 'd3-geo';
|
||||
|
||||
const FONT = 'Arial, Helvetica, sans-serif'; // Systemschrift: Webfonts lädt ein SVG-Bild nicht nach
|
||||
const MAX_PIXELS = 64e6; // Browser-Grenze für Canvas sicher unterschreiten
|
||||
|
||||
const esc = (s) => String(s).replace(/[&<>"]/g, (c) => ({ '&': '&', '<': '<', '>': '>', '"': '"' }[c]));
|
||||
const n = (v) => Math.round(v * 10) / 10;
|
||||
|
||||
function themeColors() {
|
||||
const style = getComputedStyle(document.documentElement);
|
||||
const get = (name, fallback) => style.getPropertyValue(name).trim() || fallback;
|
||||
const rgb = get('--accent-rgb', '255 70 70').split(/[\s,]+/).map(Number);
|
||||
const accent = (a) => `rgba(${rgb[0]},${rgb[1]},${rgb[2]},${a})`;
|
||||
return {
|
||||
ocean: get('--map-ocean', '#050102'),
|
||||
land: get('--map-land', '#1a0506'),
|
||||
bg: get('--bg', '#080203'),
|
||||
bright: get('--bright', '#ffffff'),
|
||||
muted: get('--muted', '#999999'),
|
||||
visited: accent(0.55),
|
||||
visitedLine: accent(0.9),
|
||||
line: accent(0.35),
|
||||
grid: accent(0.1),
|
||||
dot: accent(1),
|
||||
};
|
||||
}
|
||||
|
||||
// Beschriftungen nacheinander setzen: erst direkt neben den Punkt, sonst schrittweise weiter weg (mit Linie),
|
||||
// bis sie weder andere Beschriftungen noch Punkte überdecken. Nur Punkte mit label werden beschriftet,
|
||||
// alle Punkte bleiben aber Hindernisse.
|
||||
function placeLabels(points, s, width, height) {
|
||||
const ctx = document.createElement('canvas').getContext('2d');
|
||||
const fs = 13 * s;
|
||||
ctx.font = `600 ${fs}px ${FONT}`;
|
||||
const r = 4.5 * s;
|
||||
const h = fs * 1.25;
|
||||
const gap = r + 3 * s;
|
||||
const dirs = [[1, 0], [-1, 0], [1, -1], [1, 1], [-1, -1], [-1, 1], [0, -1], [0, 1]];
|
||||
const steps = [1, 2.2, 3.8, 6, 9, 13, 18];
|
||||
const placed = [];
|
||||
const dots = points.map((p) => ({ x0: p.x - r, y0: p.y - r, x1: p.x + r, y1: p.y + r }));
|
||||
const hits = (a, b) => a.x0 < b.x1 && b.x0 < a.x1 && a.y0 < b.y1 && b.y0 < a.y1;
|
||||
|
||||
// Größere Städte bekommen die besten Plätze
|
||||
const order = points.filter((p) => p.label).sort((a, b) => (b.population || 0) - (a.population || 0));
|
||||
return order.map((p) => {
|
||||
const w = ctx.measureText(p.name).width + 2 * s;
|
||||
let best = null;
|
||||
search: for (const step of steps) {
|
||||
for (const [dx, dy] of dirs) {
|
||||
const len = Math.hypot(dx, dy);
|
||||
const ax = p.x + (dx / len) * gap * step;
|
||||
const ay = p.y + (dy / len) * gap * step;
|
||||
const x0 = dx > 0 ? ax : dx < 0 ? ax - w : ax - w / 2;
|
||||
const y0 = dy > 0 ? ay : dy < 0 ? ay - h : ay - h / 2;
|
||||
const box = { x0, y0, x1: x0 + w, y1: y0 + h };
|
||||
if (box.x0 < 0 || box.y0 < 0 || box.x1 > width || box.y1 > height) continue;
|
||||
if (placed.some((b) => hits(box, b)) || dots.some((d) => hits(box, d))) continue;
|
||||
best = { box, step };
|
||||
break search;
|
||||
}
|
||||
}
|
||||
// Kein freier Platz: rechts daneben, notfalls überlappend
|
||||
if (!best) best = { box: { x0: p.x + gap, y0: p.y - h / 2, x1: p.x + gap + w, y1: p.y + h / 2 }, step: 1 };
|
||||
placed.push(best.box);
|
||||
return { ...p, ...best };
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* area: 'orte' (auf die eigenen Städte zoomen) oder 'welt'. width: Breite in Pixeln.
|
||||
* marks: Größe von Punkten, Linien und Schrift (1 = gut lesbar im Ganzen, kleiner = erst beim Zoomen).
|
||||
* label: 'alle' oder 'hauptstaedte' (welche Städte beschriftet werden). Liefert { svg, width, height }.
|
||||
*/
|
||||
export function buildMapSvg({ shapes, visited, cities, area, width, caption, marks = 1, label = 'alle' }) {
|
||||
const page = width / 2400; // Maßstab für Ränder und Fußzeile
|
||||
const s = page * marks; // Maßstab für Punkte, Linien und Schrift
|
||||
const pad = Math.round(50 * page);
|
||||
const footer = Math.round(46 * page);
|
||||
const focus = area === 'orte' && cities.length
|
||||
? { type: 'GeometryCollection', geometries: cities.map((c) => geoCircle().center([c.lon, c.lat]).radius(1.2)()) }
|
||||
: { type: 'Sphere' };
|
||||
|
||||
// Höhe aus dem Seitenverhältnis des Ausschnitts, in vernünftigen Grenzen
|
||||
const probe = geoNaturalEarth1().fitWidth(width - 2 * pad, focus);
|
||||
const [[, py0], [, py1]] = geoPath(probe).bounds(focus);
|
||||
let height = Math.round(Math.min(Math.max(py1 - py0, width * 0.3), width * 1.3) + 2 * pad + footer);
|
||||
const projection = geoNaturalEarth1().fitExtent([[pad, pad], [width - pad, height - pad - footer]], focus);
|
||||
const path = geoPath(projection);
|
||||
const c = themeColors();
|
||||
|
||||
const points = cities
|
||||
.map((city) => {
|
||||
const p = projection([city.lon, city.lat]);
|
||||
return p && {
|
||||
name: city.name, population: city.population, x: p[0], y: p[1],
|
||||
label: label === 'alle' || city.capital === true,
|
||||
};
|
||||
})
|
||||
.filter((p) => p && p.x >= 0 && p.x <= width && p.y >= 0 && p.y <= height - footer);
|
||||
const labels = placeLabels(points, s, width, height - footer);
|
||||
|
||||
const lw = n(0.6 * s);
|
||||
const countries = shapes.map((shape) => {
|
||||
const d = path(shape.feature);
|
||||
if (!d) return '';
|
||||
const on = visited.has(shape.key);
|
||||
return `<path d="${d}" fill="${on ? c.visited : c.land}" stroke="${on ? c.visitedLine : c.line}" stroke-width="${lw}"/>`;
|
||||
}).join('');
|
||||
|
||||
const r = n(4.5 * s);
|
||||
const fs = n(13 * s);
|
||||
const leaders = labels.filter((l) => l.step > 1).map((l) => {
|
||||
// Linie vom Punkt zur nächsten Kante der Beschriftung
|
||||
const tx = Math.max(l.box.x0, Math.min(l.x, l.box.x1));
|
||||
const ty = Math.max(l.box.y0, Math.min(l.y, l.box.y1));
|
||||
return `<line x1="${n(l.x)}" y1="${n(l.y)}" x2="${n(tx)}" y2="${n(ty)}" stroke="${c.bright}" stroke-opacity="0.7" stroke-width="${n(1 * s)}"/>`;
|
||||
}).join('');
|
||||
const dots = points.map((p) =>
|
||||
`<circle cx="${n(p.x)}" cy="${n(p.y)}" r="${r}" fill="${c.bright}" stroke="${c.dot}" stroke-width="${n(2 * s)}"/>`).join('');
|
||||
const texts = labels.map((l) =>
|
||||
`<text x="${n(l.box.x0 + s)}" y="${n(l.box.y0 + fs * 0.98)}">${esc(l.name)}</text>`).join('');
|
||||
|
||||
const svg = `<svg xmlns="http://www.w3.org/2000/svg" width="${width}" height="${height}" viewBox="0 0 ${width} ${height}">
|
||||
<rect width="100%" height="100%" fill="${c.bg}"/>
|
||||
<path d="${path({ type: 'Sphere' })}" fill="${c.ocean}"/>
|
||||
<path d="${path(geoGraticule10())}" fill="none" stroke="${c.grid}" stroke-width="${lw}"/>
|
||||
<g stroke-linejoin="round">${countries}</g>
|
||||
<g>${leaders}</g>
|
||||
<g>${dots}</g>
|
||||
<g font-family="${FONT}" font-size="${fs}" font-weight="600" fill="${c.bright}" stroke="${c.bg}" stroke-width="${n(3.2 * s)}" stroke-linejoin="round" paint-order="stroke">${texts}</g>
|
||||
<text x="${pad}" y="${height - Math.round(18 * page)}" font-family="${FONT}" font-size="${n(15 * page)}" fill="${c.muted}">${esc(caption)}</text>
|
||||
</svg>`;
|
||||
return { svg, width, height };
|
||||
}
|
||||
|
||||
/** Wie buildMapSvg, aber fürs PNG so verkleinert, dass der Browser das Bild noch zeichnen kann. */
|
||||
export function buildMapImage(options) {
|
||||
const out = buildMapSvg(options);
|
||||
if (out.width * out.height <= MAX_PIXELS) return out;
|
||||
return buildMapSvg({ ...options, width: Math.floor(Math.sqrt(MAX_PIXELS * (out.width / out.height))) });
|
||||
}
|
||||
|
||||
export async function svgToPng(svg, width, height) {
|
||||
const url = URL.createObjectURL(new Blob([svg], { type: 'image/svg+xml' }));
|
||||
try {
|
||||
const img = new Image();
|
||||
img.src = url;
|
||||
await img.decode();
|
||||
const canvas = document.createElement('canvas');
|
||||
canvas.width = width;
|
||||
canvas.height = height;
|
||||
canvas.getContext('2d').drawImage(img, 0, 0, width, height);
|
||||
return await new Promise((resolve, reject) => {
|
||||
canvas.toBlob((blob) => (blob ? resolve(blob) : reject(new Error('Bild zu groß für diesen Browser.'))), 'image/png');
|
||||
});
|
||||
} finally {
|
||||
URL.revokeObjectURL(url);
|
||||
}
|
||||
}
|
||||
|
||||
export function download(blob, filename) {
|
||||
const url = URL.createObjectURL(blob);
|
||||
const a = document.createElement('a');
|
||||
a.href = url;
|
||||
a.download = filename;
|
||||
document.body.append(a);
|
||||
a.click();
|
||||
a.remove();
|
||||
setTimeout(() => URL.revokeObjectURL(url), 1000);
|
||||
}
|
||||
@@ -49,6 +49,7 @@ export default function KanbanBoard() {
|
||||
const [filter, setFilter] = useState(loadFilter);
|
||||
const [editing, setEditing] = useState(null); // null | { task } | { status } (neu)
|
||||
const [drag, setDrag] = useState(null); // { id, status, index } während Drag & Drop
|
||||
const [showArchive, setShowArchive] = useState(false);
|
||||
const dialog = useRef(null);
|
||||
const projectDialog = useRef(null);
|
||||
const [projectForm, setProjectForm] = useState(null);
|
||||
@@ -128,7 +129,7 @@ export default function KanbanBoard() {
|
||||
}
|
||||
|
||||
async function deleteProject() {
|
||||
const n = data.tasks.length;
|
||||
const n = data.tasks.length + data.archived.length;
|
||||
if (!window.confirm(`Kanban-Projekt „${project.name}“${n ? ` mit ${n} ${n === 1 ? 'Aufgabe' : 'Aufgaben'}` : ''} endgültig löschen?`)) return;
|
||||
try {
|
||||
await api('DELETE', `/api/kanban/projekte/${project.id}`);
|
||||
@@ -141,11 +142,13 @@ export default function KanbanBoard() {
|
||||
if (!data) {
|
||||
return <><BackLink />{error ? <p className="error">{error}</p> : <p className="hint">Lade …</p>}</>;
|
||||
}
|
||||
const visible = (data?.tasks || []).filter((t) =>
|
||||
const matches = (t) => (
|
||||
filter === 'alle' ? true
|
||||
: filter === 'meine' ? t.assignee === user.id
|
||||
: filter === 'niemand' ? t.assignee == null
|
||||
: t.assignee === Number(filter));
|
||||
const visible = (data?.tasks || []).filter(matches);
|
||||
const archived = (data?.archived || []).filter(matches);
|
||||
const byColumn = Object.fromEntries(COLUMNS.map((c) => [c.key, visible.filter((t) => t.status === c.key).sort(byPriority)]));
|
||||
|
||||
// ---------- Drag & Drop (Desktop) ----------
|
||||
@@ -214,6 +217,15 @@ export default function KanbanBoard() {
|
||||
{users.filter((u) => u.id !== user.id).map((u) => <option key={u.id} value={u.id}>{u.name}</option>)}
|
||||
</select>
|
||||
</label>
|
||||
<button
|
||||
className="btn ghost"
|
||||
type="button"
|
||||
aria-expanded={showArchive}
|
||||
aria-controls="kanban-archive"
|
||||
onClick={() => setShowArchive((v) => !v)}
|
||||
>
|
||||
Archiv ({data.archived.length})
|
||||
</button>
|
||||
<button className="btn" type="button" onClick={() => open({ status: 'offen' })}>+ Neue Aufgabe</button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -267,6 +279,35 @@ export default function KanbanBoard() {
|
||||
})}
|
||||
</div>
|
||||
|
||||
{showArchive && (
|
||||
<section className="card kanban-archive" id="kanban-archive" aria-labelledby="archive-title">
|
||||
<header className="household-head">
|
||||
<h2 id="archive-title">Archiv</h2>
|
||||
<span className="hint">Erledigtes kommt nach 2 Tagen hierher und wird nach 14 Tagen automatisch gelöscht.</span>
|
||||
</header>
|
||||
{archived.length === 0 ? (
|
||||
<p className="hint">
|
||||
{data.archived.length ? 'Keine archivierten Aufgaben für diesen Bearbeiter-Filter.' : 'Noch nichts archiviert.'}
|
||||
</p>
|
||||
) : (
|
||||
<ul className="archive-list">
|
||||
{archived.map((task) => (
|
||||
<li key={task.id} className="archive-item">
|
||||
<button type="button" className="archive-title" onClick={() => open({ task })}>{task.title}</button>
|
||||
<Assignee id={task.assignee} users={users} />
|
||||
<small className="archive-dates">
|
||||
erledigt {fmtDate(task.doneAt)} · wird am {fmtDate(task.deleteAt)} gelöscht
|
||||
</small>
|
||||
<button className="link-btn" type="button" onClick={() => run('POST', `/api/kanban/aufgaben/${task.id}/zurueckholen`)}>
|
||||
Zurückholen
|
||||
</button>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</section>
|
||||
)}
|
||||
|
||||
<TaskDialog
|
||||
key={`aufgabe-${editing?.opened ?? 'zu'}`}
|
||||
ref={dialog}
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { feature } from 'topojson-client';
|
||||
import { api } from '../api.js';
|
||||
import { useAuth } from '../auth.jsx';
|
||||
import { buildMapImage, download, svgToPng } from '../components/mapExport.js';
|
||||
import WorldMap from '../components/WorldMap.jsx';
|
||||
import LAENDER from '../data/laender.json';
|
||||
|
||||
@@ -52,6 +54,7 @@ export default function Reisen() {
|
||||
const [showOthers, setShowOthers] = useState(() => loadFlag(OTHERS_KEY));
|
||||
const [hiddenPeople, setHiddenPeople] = useState(() => loadSet(HIDDEN_KEY));
|
||||
const map = useRef(null);
|
||||
const exportDialog = useRef(null);
|
||||
|
||||
useEffect(() => {
|
||||
api('GET', '/api/reisen').then(setData).catch((e) => setError(e.message));
|
||||
@@ -161,7 +164,13 @@ export default function Reisen() {
|
||||
{' '}· Klick auf ein Land oder einen Punkt zeigt Infos.
|
||||
</p>
|
||||
</div>
|
||||
{shapes && visited.size > 0 && (
|
||||
<button className="link-btn" type="button" onClick={() => exportDialog.current.showModal()}>⤓ Karte herunterladen</button>
|
||||
)}
|
||||
</div>
|
||||
{shapes && data && (
|
||||
<ExportDialog ref={exportDialog} shapes={shapes} visited={visited} cities={cities} />
|
||||
)}
|
||||
|
||||
<CitySearch cities={cities} onAdd={addCity} busy={busy} />
|
||||
{error && <p className="error travel-error" role="alert">{error}</p>}
|
||||
@@ -256,6 +265,112 @@ export default function Reisen() {
|
||||
);
|
||||
}
|
||||
|
||||
// ---------- Karte als Bild herunterladen (nur eigene Orte) ----------
|
||||
|
||||
const SIZES = [
|
||||
{ width: 4000, title: 'Mittel (4000 px breit)' },
|
||||
{ width: 8000, title: 'Groß (8000 px breit) – zum Hineinzoomen' },
|
||||
];
|
||||
|
||||
function ExportDialog({ ref, shapes, visited, cities }) {
|
||||
const { user } = useAuth();
|
||||
const [area, setArea] = useState(cities.length ? 'orte' : 'welt');
|
||||
const [format, setFormat] = useState('png');
|
||||
const [width, setWidth] = useState(8000);
|
||||
const [state, setState] = useState(null); // null | 'busy' | Fehlermeldung
|
||||
|
||||
async function save(e) {
|
||||
e.preventDefault();
|
||||
setState('busy');
|
||||
// Kurz Luft lassen, damit "Erstelle Bild …" erscheint, bevor der Browser rechnet
|
||||
await new Promise((resolve) => { setTimeout(resolve, 30); });
|
||||
try {
|
||||
const date = new Date();
|
||||
const caption = `Reisen · ${user.displayName} · ${visited.size} ${visited.size === 1 ? 'Land' : 'Länder'} · `
|
||||
+ `${cities.length} ${cities.length === 1 ? 'Stadt' : 'Städte'} · Stand ${date.toLocaleDateString('de-DE')}`
|
||||
+ ' · Karte: Natural Earth';
|
||||
const name = `reisekarte-${user.username.toLowerCase().replace(/[^a-z0-9]+/g, '-')}-${date.toISOString().slice(0, 10)}`;
|
||||
// SVG: klein und vollständig beschriftet (zoombar); PNG: nur Hauptstädte beschriftet
|
||||
const options = format === 'svg'
|
||||
? { shapes, visited, cities, area, caption, width: 2400, marks: 0.4, label: 'alle' }
|
||||
: { shapes, visited, cities, area, caption, width, marks: 1, label: 'hauptstaedte' };
|
||||
if (format === 'svg') {
|
||||
const { svg } = buildMapImage(options);
|
||||
download(new Blob([svg], { type: 'image/svg+xml' }), `${name}.svg`);
|
||||
} else {
|
||||
const out = buildMapImage(options);
|
||||
download(await svgToPng(out.svg, out.width, out.height), `${name}.png`);
|
||||
}
|
||||
setState(null);
|
||||
ref.current.close();
|
||||
} catch (err) {
|
||||
setState(err.message || 'Das Bild konnte nicht erstellt werden.');
|
||||
}
|
||||
}
|
||||
|
||||
const busy = state === 'busy';
|
||||
const capitals = cities.filter((c) => c.capital === true).length;
|
||||
const unknown = cities.filter((c) => c.capital == null).length;
|
||||
return (
|
||||
<dialog className="modal" ref={ref} onClose={() => setState(null)}>
|
||||
<form className="form" onSubmit={save}>
|
||||
<h2>Karte herunterladen</h2>
|
||||
<p className="hint">Deine Städte und besuchten Länder als Bild – Städte anderer Konten sind nicht dabei.</p>
|
||||
<fieldset className="link-picker">
|
||||
<legend>Ausschnitt</legend>
|
||||
<ul>
|
||||
<li>
|
||||
<label className="checkbox">
|
||||
<input type="radio" name="area" checked={area === 'orte'} disabled={!cities.length} onChange={() => setArea('orte')} />
|
||||
Meine Orte (auf die Städte gezoomt)
|
||||
</label>
|
||||
</li>
|
||||
<li>
|
||||
<label className="checkbox">
|
||||
<input type="radio" name="area" checked={area === 'welt'} onChange={() => setArea('welt')} />
|
||||
Ganze Welt
|
||||
</label>
|
||||
</li>
|
||||
</ul>
|
||||
</fieldset>
|
||||
<fieldset className="link-picker">
|
||||
<legend>Format</legend>
|
||||
<ul>
|
||||
<li>
|
||||
<label className="checkbox">
|
||||
<input type="radio" name="format" checked={format === 'png'} onChange={() => setFormat('png')} />
|
||||
PNG-Bild – beschriftet sind nur besuchte Hauptstädte ({capitals})
|
||||
</label>
|
||||
</li>
|
||||
<li>
|
||||
<label className="checkbox">
|
||||
<input type="radio" name="format" checked={format === 'svg'} onChange={() => setFormat('svg')} />
|
||||
SVG – alle Städte beschriftet, beliebig scharf zoombar (z. B. zum Drucken)
|
||||
</label>
|
||||
</li>
|
||||
</ul>
|
||||
</fieldset>
|
||||
{format === 'png' && (
|
||||
<label>
|
||||
Größe
|
||||
<select value={width} onChange={(e) => setWidth(Number(e.target.value))}>
|
||||
{SIZES.map((s) => <option key={s.width} value={s.width}>{s.title}</option>)}
|
||||
</select>
|
||||
</label>
|
||||
)}
|
||||
{format === 'png' && unknown > 0 && (
|
||||
<p className="hint">Bei {unknown} {unknown === 1 ? 'Stadt' : 'Städten'} wird gerade noch nachgeschlagen, ob es eine Hauptstadt ist – kurz warten und die Seite neu laden.</p>
|
||||
)}
|
||||
{state && !busy && <p className="error">{state}</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 ? 'Erstelle Bild …' : 'Herunterladen'}</button>
|
||||
</div>
|
||||
</form>
|
||||
</dialog>
|
||||
);
|
||||
}
|
||||
|
||||
// ---------- Besuchte Orte: Kontinent → Land (ausklappbar) → Städte ----------
|
||||
|
||||
const CONTINENTS = ['Europa', 'Asien', 'Afrika', 'Nordamerika', 'Südamerika', 'Ozeanien', 'Antarktis'];
|
||||
|
||||
@@ -1745,5 +1745,13 @@ html.vt-items .household-item { view-transition-name: var(--vt); }
|
||||
}
|
||||
html.calm .tile:hover, html.calm .project-card:hover, html.calm .kanban-project-card:hover, html.calm .task-card:hover { transform: none; }
|
||||
|
||||
/* Kanban-Archiv */
|
||||
.kanban-archive { margin-top: clamp(16px, 2vw, 28px); display: flex; flex-direction: column; gap: 12px; }
|
||||
.archive-list { list-style: none; margin: 0; padding: 0; }
|
||||
.archive-item { display: flex; flex-wrap: wrap; align-items: center; gap: 4px 14px; padding: 10px 0; border-top: 1px dashed var(--border); }
|
||||
.archive-title { flex: 1 1 220px; min-width: 0; text-align: left; font: inherit; font-weight: 600; color: var(--bright); background: none; border: 0; padding: 0; cursor: pointer; text-decoration: line-through; text-decoration-color: var(--muted); }
|
||||
.archive-title:hover { color: var(--accent); }
|
||||
.archive-dates { color: var(--muted); font-size: 0.78rem; }
|
||||
|
||||
/* Auswahlknöpfe (z. B. Sichtbarkeit einer Einkaufsliste) nicht wie Eingabefelder gestalten */
|
||||
.form label.checkbox input[type='radio'] { width: 18px; height: 18px; min-height: 0; padding: 0; margin: 0; border: 0; background: none; box-shadow: none; accent-color: var(--accent); flex: none; }
|
||||
Reference in new issue
Block a user