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

+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>
)}
</>
);
}