Freunde, privates Teilen, Artikelrechte, Reiserouten

- Freunde (freunde.py, Tabelle friendships): suchen, anfragen, annehmen,
  ablehnen, entfernen; neue Seite "Freunde" im Menü
- Vorratslisten und Kanban-Projekte immer privat: sichtbar für Ersteller und
  Mitglieder; teilen nur mit Freunden (Admins: mit allen); Kanban-Mitglieder
  (Tabelle kanban_project_members), Bearbeiter nur aus dem Projekt,
  ausgetretene Mitglieder verlieren ihre Zuteilung
- Artikel: lesen alle; bearbeiten Autor, Mitarbeitende verknüpfter
  Kanban-Projekte und Admins; verknüpfen nur bearbeitbare Artikel
  (sonst ließen sich Rechte erschleichen); Board-Liste auf der Artikelseite
  zeigt nur sichtbare Boards
- Administratoren sehen und bearbeiten alles
- Reisen: Städte nur von Freunden (Admins: alle); "Reiserouten zeigen"
  zeichnet die Strecken der Kilometer-Rechnung als Bögen
- Migration Schritt 6: bisher gemeinsame Listen und alle Projekte bekommen
  die vorhandenen Konten als Mitglieder, ohne Ersteller → erster Admin;
  erste Liste einer neuen Installation gehört dem ersten Konto
- Tests: test_freunde.py, test_zugriff.py, bestehende auf Privat-Modell
  umgestellt

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
guyg1474andClaude Opus 5.5 committed 2026-10-15 21:49:45 +02:00
1 parent 1d7497bead
commit 0fb3521c2f
34 files changed
+833 -245

No files matched your search

+1
View File
@@ -113,6 +113,7 @@ function Menu() {
Angemeldet als <strong>{user.displayName}</strong>
</div>
<NavLink to="/konto">Mein Konto</NavLink>
<NavLink to="/freunde">Freunde</NavLink>
{user.isAdmin && <NavLink to="/benutzer">Benutzer verwalten</NavLink>}
<button type="button" className="menu-item" onClick={logout}>Abmelden</button>
</nav>
+23 -39
View File
@@ -1,27 +1,28 @@
import { useState } from 'react';
import { personName } from '../../api.js';
import { Link } from 'react-router-dom';
import { useAuth } from '../../auth.jsx';
// Liste anlegen oder bearbeiten: Name und wer sie sehen kann; löschen nur beim Bearbeiten
export default function ListDialog({ ref, list, users, onSave, onDelete, onClosed }) {
// shareable: Freunde (Admins: alle); users: alle Namen, damit auch schon geteilte Nicht-Freunde angezeigt werden
export default function ListDialog({ ref, list, users, shareable = [], onSave, onDelete, onClosed }) {
const { user } = useAuth();
const ownerId = list ? list.createdBy?.id : user?.id;
const [form, setForm] = useState(() => ({
name: list?.name || '',
everyone: list ? list.everyone : true,
members: list?.members || [],
}));
const [error, setError] = useState(null);
const [busy, setBusy] = useState(false);
// Der Ersteller sieht seine Liste immer, deshalb steht er nicht zur Auswahl
const choosable = users.filter((u) => u.id !== ownerId);
const choosable = users.filter((u) => u.id !== ownerId && (shareable.some((f) => f.id === u.id) || (list?.members || []).includes(u.id)));
const toggle = (id) => setForm((f) => ({ ...f, members: f.members.includes(id) ? f.members.filter((x) => x !== id) : [...f.members, id] }));
async function submit(e) {
e.preventDefault();
setBusy(true);
setError(null);
const err = await onSave({ name: form.name, everyone: form.everyone, members: form.everyone ? [] : form.members });
const err = await onSave({ name: form.name, members: form.members });
setBusy(false);
if (err) setError(err);
else ref.current.close();
@@ -44,41 +45,24 @@ export default function ListDialog({ ref, list, users, onSave, onDelete, onClose
<input value={form.name} onChange={(e) => setForm((f) => ({ ...f, name: e.target.value }))} maxLength={40} required autoFocus placeholder="z. B. Zuhause, Urlaub Italien, WG" />
</label>
<fieldset className="link-picker">
<legend>Wer kann die Liste sehen?</legend>
<ul>
<li>
<label className="checkbox">
<input type="radio" name="visibility" checked={form.everyone} onChange={() => setForm((f) => ({ ...f, everyone: true }))} />
Alle Konten
</label>
</li>
<li>
<label className="checkbox">
<input type="radio" name="visibility" checked={!form.everyone} onChange={() => setForm((f) => ({ ...f, everyone: false }))} />
Nur ausgewählte Konten
</label>
</li>
</ul>
{!form.everyone && (
<>
<p className="hint">
{list && list.createdBy?.id !== user?.id ? `${personName(list.createdBy)} (angelegt)` : 'Du'} siehst die Liste immer. Zusätzlich:
</p>
{choosable.length === 0 ? (
<p className="hint">Es gibt keine weiteren Konten.</p>
) : (
<ul>
{choosable.map((u) => (
<li key={u.id}>
<label className="checkbox">
<input type="checkbox" checked={form.members.includes(u.id)} onChange={() => toggle(u.id)} />
{u.name}
</label>
</li>
))}
</ul>
)}
</>
<legend>Teilen mit</legend>
<p className="hint">
{list && list.createdBy?.id !== user?.id ? `${personName(list.createdBy)} (angelegt)` : 'Du'} siehst die Liste immer.
Teilen kannst du mit deinen Freunden.
</p>
{choosable.length === 0 ? (
<p className="hint">Noch keine Freunde – unter <Link to="/freunde">Freunde</Link> hinzufügen.</p>
) : (
<ul>
{choosable.map((u) => (
<li key={u.id}>
<label className="checkbox">
<input type="checkbox" checked={form.members.includes(u.id)} onChange={() => toggle(u.id)} />
{u.name}
</label>
</li>
))}
</ul>
)}
</fieldset>
{error && <p className="error">{error}</p>}
@@ -1,7 +1,6 @@
// Listen (je Vorrat + Einkauf) wählen; zeigt, wer die gewählte Liste sieht
function visibleFor(list, users) {
if (list.everyone) return 'alle Konten';
const ids = new Set([list.createdBy?.id, ...list.members]);
const names = users.filter((u) => ids.has(u.id)).map((u) => u.name);
return names.length ? names.join(', ') : 'niemanden';
@@ -21,7 +20,7 @@ export default function ListSwitcher({ lists, current, users, onChoose, onNew, o
onClick={() => onChoose(l.id)}
title={`Sichtbar für ${visibleFor(l, users)}`}
>
{!l.everyone && <span aria-label="nicht für alle sichtbar">🔒 </span>}
{l.members.length === 0 && <span aria-label="nur für dich">🔒 </span>}
{l.name}
</button>
</li>
@@ -1,15 +1,19 @@
import { useState } from 'react';
import { Link } from 'react-router-dom';
/**
* Kanban-Projekt anlegen/bearbeiten: Name, Beschreibung und verknüpfte Artikel aus "P42 Projekte".
* initial: { name, description, links: [articleId] } oder null (Dialog geschlossen).
* Kanban-Projekt anlegen/bearbeiten: Name, Beschreibung, Mitglieder (nur Freunde) und verknüpfte Artikel.
* initial: { name, description, links, members } oder null (Dialog geschlossen).
* canManage: Mitglieder ändern dürfen nur Ersteller und Admins. Verknüpfen lassen sich nur Artikel,
* die man selbst bearbeiten darf (articles[].editable) – schon verknüpfte bleiben sichtbar.
*/
export default function KanbanProjectDialog({ ref, initial, isNew, articles, onSave }) {
export default function KanbanProjectDialog({ ref, initial, isNew, articles, users = [], shareable = [], ownerId, canManage = true, 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 || [],
members: initial?.members || [],
}));
const [search, setSearch] = useState('');
const [error, setError] = useState(null);
@@ -17,9 +21,13 @@ export default function KanbanProjectDialog({ ref, initial, isNew, articles, onS
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 toggleMember = (id) => setForm((f) => ({ ...f, members: f.members.includes(id) ? f.members.filter((x) => x !== id) : [...f.members, id] }));
// Wählbar: Freunde und schon eingetragene Mitglieder (auch wenn keine Freunde mehr)
const choosable = users.filter((u) => u.id !== ownerId && (shareable.some((f) => f.id === u.id) || (initial?.members || []).includes(u.id)));
const q = search.trim().toLocaleLowerCase('de-DE');
// Verknüpfte zuerst, dann nach Suche gefiltert
const shown = [...articles]
.filter((a) => a.editable || (initial?.links || []).includes(a.id))
.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)));
@@ -31,6 +39,7 @@ export default function KanbanProjectDialog({ ref, initial, isNew, articles, onS
name: form.name,
description: form.description,
links: form.links,
...(canManage ? { members: form.members } : {}),
});
setBusy(false);
if (err) setError(err);
@@ -49,10 +58,29 @@ export default function KanbanProjectDialog({ ref, initial, isNew, articles, onS
Beschreibung <small>(optional)</small>
<textarea {...field('description')} rows={3} maxLength={5000} placeholder="Worum geht es in diesem Board?" />
</label>
<fieldset className="link-picker">
<legend>Mitarbeit mit <small>({form.members.length} gewählt)</small></legend>
{!canManage ? (
<p className="hint">Mitglieder verwalten kann, wer das Projekt angelegt hat.</p>
) : choosable.length === 0 ? (
<p className="hint">Noch keine Freunde – unter <Link to="/freunde">Freunde</Link> hinzufügen. Ohne Mitglieder ist das Projekt nur für dich.</p>
) : (
<ul>
{choosable.map((u) => (
<li key={u.id}>
<label className="checkbox">
<input type="checkbox" checked={form.members.includes(u.id)} onChange={() => toggleMember(u.id)} />
{u.name}
</label>
</li>
))}
</ul>
)}
</fieldset>
<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>
<p className="hint">Keine Artikel, die du bearbeiten darfst.</p>
) : (
<>
{articles.length > 6 && (
+13 -1
View File
@@ -32,7 +32,8 @@ function mainPart(feature) {
* Ü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, otherCities = [], home, selected, onSelect }) {
// routes: Teilstrecken ({ a: [lon, lat], b: [lon, lat], kind }) – als Bogen der kürzesten Strecke auf der Erdkugel
export default function WorldMap({ ref, countries, visited, cities, otherCities = [], home, routes = [], selected, onSelect }) {
const box = useRef(null);
const svgRef = useRef(null);
const layer = useRef(null);
@@ -133,6 +134,17 @@ export default function WorldMap({ ref, countries, visited, cities, otherCities
<title>{c.name}</title>
</path>
))}
{routes.length > 0 && (
<g className="map-routes" aria-hidden="true">
{routes.map((r, i) => (
<path
key={i}
className={`map-route ${r.kind === 'Umzug' ? 'move' : r.kind.startsWith('Ausflug') ? 'trip' : ''}`}
d={path({ type: 'LineString', coordinates: [r.a, r.b] })}
/>
))}
</g>
)}
{home && (() => {
const p = projection([home.lon, home.lat]);
return p && (
+1 -1
View File
@@ -50,7 +50,7 @@ export function travelRoute(visits, cities, homes = []) {
const legs = [];
const leg = (from, to, kind) => {
if (!from || !to || samePlace(from, to)) return;
legs.push({ from: from.name, to: to.name, km: distanceKm(from, to), kind });
legs.push({ from: from.name, to: to.name, km: distanceKm(from, to), kind, a: [from.lon, from.lat], b: [to.lon, to.lat] });
};
let home = homes[0] || null; // aktueller Wohnort
+2
View File
@@ -29,6 +29,7 @@ import Monitoring from './pages/Monitoring.jsx';
import NotFound from './pages/NotFound.jsx';
import Projects from './pages/Projects.jsx';
import Users from './pages/Users.jsx';
import Freunde from './pages/Freunde.jsx';
import './theme.js';
import './styles/index.css';
@@ -54,6 +55,7 @@ const router = createBrowserRouter([
{ path: 'monitoring', element: <Monitoring /> },
{ path: 'konto', element: <Account /> },
{ path: 'benutzer', element: <Users /> },
{ path: 'freunde', element: <Freunde /> },
{ path: '*', element: <NotFound /> },
],
},
+101
View File
@@ -0,0 +1,101 @@
import { useEffect, useRef, useState } from 'react';
import { api } from '../api.js';
import useLiveData from '../useLiveData.js';
// Freunde: Grundlage fürs Teilen von Listen und Kanban-Projekten und für fremde Punkte auf der Reise-Karte
export default function Freunde() {
const { data, error, busy, run } = useLiveData('/api/freunde', { refresh: 30 });
const [q, setQ] = useState('');
const [results, setResults] = useState([]);
const timer = useRef(null);
useEffect(() => {
clearTimeout(timer.current);
if (q.trim().length < 2) { setResults([]); return undefined; }
timer.current = setTimeout(() => {
api('GET', `/api/freunde/suche?q=${encodeURIComponent(q.trim())}`).then(setResults).catch(() => setResults([]));
}, 250);
return () => clearTimeout(timer.current);
}, [q, data]);
const act = (method, person) => run(method, `/api/freunde/${person.id}`);
const label = { freund: '✓ Freund', angefragt: 'Angefragt', eingeladen: 'Hat dich angefragt' };
return (
<>
<div className="page-head">
<div>
<h1>Freunde</h1>
<p className="lead">Mit Freunden teilst du Vorratslisten und Kanban-Projekte und siehst ihre Reise-Punkte. Eine Freundschaft gilt erst, wenn beide zugestimmt haben.</p>
</div>
</div>
{error && <p className="error" role="alert">{error}</p>}
<section className="card friends-search" aria-labelledby="freunde-suche">
<header className="household-head"><h2 id="freunde-suche">Freund hinzufügen</h2></header>
<label className="sr-only" htmlFor="friend-q">Name oder Benutzername</label>
<input id="friend-q" className="friends-input" value={q} onChange={(e) => setQ(e.target.value)} placeholder="Name oder Benutzername …" autoComplete="off" />
{results.length > 0 && (
<ul className="friends-list">
{results.map((p) => (
<li key={p.id}>
<span><strong>{p.name}</strong> <small>@{p.username}</small></span>
{p.status === 'freund' || p.status === 'angefragt'
? <span className="hint">{label[p.status]}</span>
: <button className="btn small" type="button" disabled={busy} onClick={() => act('POST', p)}>{p.status === 'eingeladen' ? 'Annehmen' : 'Anfragen'}</button>}
</li>
))}
</ul>
)}
{q.trim().length >= 2 && results.length === 0 && <p className="hint">Kein Konto gefunden.</p>}
</section>
{data?.incoming.length > 0 && (
<section className="friends-section" aria-labelledby="freunde-anfragen">
<h2 className="section-title" id="freunde-anfragen">Anfragen an dich</h2>
<ul className="friends-list">
{data.incoming.map((p) => (
<li key={p.id}>
<span><strong>{p.name}</strong> <small>@{p.username}</small></span>
<span className="friends-actions">
<button className="btn small" type="button" disabled={busy} onClick={() => act('POST', p)}>Annehmen</button>
<button className="link-btn danger" type="button" disabled={busy} onClick={() => act('DELETE', p)}>Ablehnen</button>
</span>
</li>
))}
</ul>
</section>
)}
<section className="friends-section" aria-labelledby="freunde-liste">
<h2 className="section-title" id="freunde-liste">Deine Freunde</h2>
{data && !data.friends.length && <p className="hint">Noch keine Freunde. Oben suchen und anfragen.</p>}
<ul className="friends-list">
{data?.friends.map((p) => (
<li key={p.id}>
<span><strong>{p.name}</strong> <small>@{p.username}</small></span>
<button className="link-btn danger" type="button" disabled={busy}
onClick={() => window.confirm(`Freundschaft mit ${p.name} beenden? Geteilte Listen und Projekte bleiben geteilt, bis du sie dort entfernst.`) && act('DELETE', p)}>
Entfernen
</button>
</li>
))}
</ul>
</section>
{data?.outgoing.length > 0 && (
<section className="friends-section" aria-labelledby="freunde-offen">
<h2 className="section-title" id="freunde-offen">Deine offenen Anfragen</h2>
<ul className="friends-list">
{data.outgoing.map((p) => (
<li key={p.id}>
<span><strong>{p.name}</strong> <small>@{p.username}</small></span>
<button className="link-btn" type="button" disabled={busy} onClick={() => act('DELETE', p)}>Zurückziehen</button>
</li>
))}
</ul>
</section>
)}
</>
);
}
+1
View File
@@ -246,6 +246,7 @@ export default function Haushalt() {
ref={listDialogRef}
list={listDialog.list}
users={data?.users || []}
shareable={data?.shareable || []}
onSave={saveList}
onDelete={deleteList}
onClosed={() => setListDialog(null)}
+3
View File
@@ -129,6 +129,9 @@ export default function Kanban() {
initial={projectForm}
isNew
articles={data?.articles || []}
users={data?.users || []}
shareable={data?.shareable || []}
ownerId={user.id}
onSave={createProject}
/>
</>
+11 -4
View File
@@ -51,7 +51,10 @@ export default function KanbanBoard() {
const project = data?.project;
function editProject() {
setProjectForm({ name: project.name, description: project.description, links: project.links.map((l) => l.id), opened: Date.now() });
setProjectForm({
name: project.name, description: project.description, links: project.links.map((l) => l.id),
members: project.members, opened: Date.now(),
});
}
useEffect(() => {
if (projectForm) projectDialog.current?.showModal();
@@ -66,7 +69,7 @@ export default function KanbanBoard() {
}
}
const mayDelete = user.isAdmin || project?.createdBy?.id === user.id;
const mayDelete = project?.canManage;
async function setCompleted(completed) {
await saveProject({ completed }).then((err) => err && setError(err));
@@ -166,7 +169,7 @@ export default function KanbanBoard() {
<option value="alle">Alle</option>
<option value="meine">Mir zugeteilt</option>
<option value="niemand">Niemandem zugeteilt</option>
{users.filter((u) => u.id !== user.id).map((u) => <option key={u.id} value={u.id}>{u.name}</option>)}
{data.people.filter((u) => u.id !== user.id).map((u) => <option key={u.id} value={u.id}>{u.name}</option>)}
</select>
</label>
<button
@@ -265,7 +268,7 @@ export default function KanbanBoard() {
key={`aufgabe-${editing?.opened ?? 'zu'}`}
ref={dialog}
target={editing}
users={users}
users={data.people}
tasks={allTasks}
me={user}
onSave={(taskId, body) => run(taskId ? 'PATCH' : 'POST', taskId ? `/api/kanban/aufgaben/${taskId}` : `/api/kanban/projekte/${project.id}/aufgaben`, body)}
@@ -276,6 +279,10 @@ export default function KanbanBoard() {
ref={projectDialog}
initial={projectForm}
articles={data.articles}
users={data.users}
shareable={data.shareable}
ownerId={project.createdBy?.id}
canManage={project.canManage}
onSave={saveProject}
/>
</>
+8 -7
View File
@@ -1,7 +1,6 @@
import { useEffect, useRef, useState } from 'react';
import { Link, useBlocker, useNavigate, useParams, useSearchParams } from 'react-router-dom';
import { api, fmtDate, imgUrl, personName, STATUS, uploadImage } from '../api.js';
import { useAuth } from '../auth.jsx';
import Editor from '../components/projekte/Editor.jsx';
import Lightbox from '../components/projekte/Lightbox.jsx';
import StatusBadge from '../components/projekte/StatusBadge.jsx';
@@ -10,7 +9,6 @@ export default function Project() {
const { id } = useParams();
const [params, setParams] = useSearchParams();
const navigate = useNavigate();
const { user } = useAuth();
const [project, setProject] = useState(null);
const [loadError, setLoadError] = useState(null);
@@ -30,7 +28,7 @@ export default function Project() {
api('GET', `/api/projects/${encodeURIComponent(id)}`)
.then((p) => {
setProject(p);
if (params.has('bearbeiten')) startEditing(p);
if (params.has('bearbeiten') && p.canEdit) startEditing(p);
})
.catch((e) => setLoadError(e.message === 'Nicht gefunden.' ? 'Projekt nicht gefunden.' : e.message));
// eslint-disable-next-line react-hooks/exhaustive-deps
@@ -134,8 +132,8 @@ export default function Project() {
if (!project) return <><BackLink /><p className="hint">Lade Projekt …</p></>;
const cover = editing ? draft.cover : project.cover;
// Löschen dürfen nur der Autor und Administratoren
const canDelete = user.isAdmin || project.createdBy?.id === user.id;
// Lesen dürfen alle; bearbeiten Autor, Mitarbeitende verknüpfter Kanban-Projekte und Admins; löschen Autor und Admins
const canDelete = project.canDelete;
return (
<div className={`article-page${editing ? ' editing' : ''}`}>
@@ -228,14 +226,17 @@ export default function Project() {
<dt>Kanban-Boards</dt>
<dd className="aside-boards">
{project.boards?.map((b) => <Link key={b.id} to={`/kanban/${b.id}`}>▦ {b.name}</Link>)}
<Link className="sub" to={`/kanban?artikel=${project.id}`}>+ Board zu diesem Artikel</Link>
{project.canEdit && <Link className="sub" to={`/kanban?artikel=${project.id}`}>+ Board zu diesem Artikel</Link>}
</dd>
</div>
</dl>
{!editing && (
{!editing && project.canEdit && (
<button className="btn full" type="button" onClick={() => startEditing()}>Bearbeiten</button>
)}
{!project.canEdit && (
<p className="hint">Bearbeiten können der Autor und wer an einem verknüpften Kanban-Projekt mitarbeitet.</p>
)}
{editing && (
<div className="edit-actions">
+26 -4
View File
@@ -1,4 +1,5 @@
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { Link } from 'react-router-dom';
import { feature } from 'topojson-client';
import CitySearch from '../components/reisen/CitySearch.jsx';
import ExportDialog from '../components/reisen/ExportDialog.jsx';
@@ -7,7 +8,7 @@ import InfoCard from '../components/reisen/InfoCard.jsx';
import { BY_NUM, countryName } from '../components/reisen/laender.js';
import Timeline from '../components/reisen/Timeline.jsx';
import TravelStats from '../components/reisen/TravelStats.jsx';
import { homeAt } from '../components/reisen/route.js';
import { homeAt, travelRoute } from '../components/reisen/route.js';
import VisitDialog from '../components/reisen/VisitDialog.jsx';
import VisitedList from '../components/reisen/VisitedList.jsx';
import WorldMap from '../components/reisen/WorldMap.jsx';
@@ -24,6 +25,7 @@ const SPECIAL = {
};
const TAB_KEY = 'p42.reisenAnsicht';
const ROUTES_KEY = 'p42.reisenRouten';
const OTHERS_KEY = 'p42.reisenAndere';
const HIDDEN_KEY = 'p42.reisenAndereAus';
const OTHER_COLORS = 6;
@@ -42,6 +44,7 @@ export default function Reisen() {
const [visitDialog, setVisitDialog] = useState(null); // { city, visit, opened }
const [tab, setTab] = useState(() => { try { return localStorage.getItem(TAB_KEY) || 'orte'; } catch { return 'orte'; } });
const [zoom, setZoom] = useState(null);
const [showRoutes, setShowRoutes] = useState(() => loadFlag(ROUTES_KEY));
useEffect(() => {
fetch('/maps/countries-50m.json')
@@ -88,6 +91,17 @@ export default function Reisen() {
);
const allCities = useMemo(() => [...own, ...otherCities], [own, otherCities]);
// Reiserouten aus derselben Rechnung wie die Kilometer-Statistik
const routes = useMemo(
() => (showRoutes && data ? travelRoute(data.visits || [], own, data.homes || []).legs : []),
[showRoutes, data, own],
);
function toggleRoutes() {
setShowRoutes(!showRoutes);
saveFlag(ROUTES_KEY, !showRoutes);
}
function toggleOthers() {
const next = !showOthers;
setShowOthers(next);
@@ -167,11 +181,18 @@ export default function Reisen() {
{data && (
<div className="travel-others" role="group" aria-label="Städte anderer Konten">
<button type="button" className={`chip${showOthers ? ' active' : ''}`} aria-pressed={showOthers} onClick={toggleOthers}>
{showOthers ? '✓ ' : ''}Städte anderer zeigen
<button type="button" className={`chip${showRoutes ? ' active' : ''}`} aria-pressed={showRoutes} onClick={toggleRoutes}
title="Strecken aus der Kilometer-Statistik: Reisen, Ausflüge (gepunktet), Umzüge (gestrichelt)">
{showRoutes ? '✓ ' : ''}Reiserouten zeigen
</button>
<button type="button" className={`chip${showOthers ? ' active' : ''}`} aria-pressed={showOthers} onClick={toggleOthers}>
{showOthers ? '✓ ' : ''}Städte von Freunden zeigen
</button>
{showRoutes && data && !(data.visits || []).length && (
<span className="hint travel-legend">Für Routen Besuche mit Datum eintragen.</span>
)}
{showOthers && others.length === 0 && (
<span className="hint travel-legend">Noch kein anderes Konto hat Städte eingetragen.</span>
<span className="hint travel-legend">Noch keine Freunde mit Städten – unter <Link to="/freunde">Freunde</Link> hinzufügen.</span>
)}
{showOthers && others.length > 0 && (
<>
@@ -207,6 +228,7 @@ export default function Reisen() {
cities={own}
otherCities={otherCities}
home={homeAt(data?.homes || [], new Date().toISOString().slice(0, 10))}
routes={routes}
selected={selected}
onSelect={(sel) => select(sel)}
/>
+9
View File
@@ -0,0 +1,9 @@
/* ---------- Freunde ---------- */
.friends-search { display: grid; gap: 12px; max-width: 760px; }
.friends-input { font: inherit; color: var(--bright); background: var(--input-bg); border: 1px solid var(--line-strong); border-radius: var(--radius-s); min-height: 46px; padding: 0 12px; }
.friends-input:focus { outline: none; border-color: var(--accent); box-shadow: var(--focus-ring); }
.friends-section { margin-top: 28px; max-width: 760px; }
.friends-list { list-style: none; margin: 10px 0 0; padding: 0; display: grid; gap: 2px; }
.friends-list li { display: flex; align-items: center; justify-content: space-between; gap: 12px; padding: 8px 0; border-top: 1px solid var(--border); }
.friends-list small { color: var(--muted); }
.friends-actions { display: inline-flex; align-items: center; gap: 14px; }
+1
View File
@@ -10,6 +10,7 @@
@import './reisen.css';
@import './kanban.css';
@import './konto.css';
@import './freunde.css';
@import './responsive.css';
@import './grundschicht.css';
@import './designs/gemeinsam.css';
+5
View File
@@ -257,3 +257,8 @@
.stat-home-since { display: flex; align-items: center; gap: 8px; color: var(--muted); font-size: 0.85rem; }
.stat-home-since input { font: inherit; color: var(--bright); background: var(--input-bg); border: 1px solid var(--border); border-radius: var(--radius-s); min-height: 50px; padding: 0 10px; }
.visit-lived { margin: 0; }
/* Reiserouten */
.map-route { fill: none; stroke: var(--bright); stroke-width: 1.6; vector-effect: non-scaling-stroke; opacity: 0.75; stroke-linecap: round; }
.map-route.move { stroke-dasharray: 6 4; }
.map-route.trip { stroke-dasharray: 1 4; }