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:
1 parent
1d7497bead
commit
0fb3521c2f
34 files changed
+833
-245
No files matched your search
@@ -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>
|
||||
|
||||
@@ -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 && (
|
||||
|
||||
@@ -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 && (
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 /> },
|
||||
],
|
||||
},
|
||||
|
||||
@@ -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>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -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)}
|
||||
|
||||
@@ -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}
|
||||
/>
|
||||
</>
|
||||
|
||||
@@ -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}
|
||||
/>
|
||||
</>
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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)}
|
||||
/>
|
||||
|
||||
@@ -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; }
|
||||
@@ -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';
|
||||
|
||||
@@ -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; }
|
||||
Reference in new issue
Block a user