feat: e-mail, password reset and shareable links for images and collections
Mail goes out through the account's own mailbox (SMTP) rather than a sending service: the domain lives there anyway, so SPF and DKIM line up by themselves and no third party gets to see the messages. Credentials are AES-256-GCM encrypted in settings like every other secret, never in the repo. nodemailer has zero dependencies and no native postinstall -- checked before adding it, given the history. Password reset: single-use token, only its hash is stored, one hour, and the request endpoint answers identically whether or not the address exists so it cannot be used to enumerate accounts. Rate limited per address. Share links (/s/<slug>) let someone without an account see the pictures and download them at full resolution. These are photos of children, so the defaults lean safe: every link expires by itself (30 days), can be revoked, optionally carries a passphrase, and the page and files are noindex. Access is bound to the link, not the image -- swapping in another item id on a valid link returns 404. Private-session results can never be shared at all. Tracking records when and with what, never who: IP addresses are only stored salted-and-hashed, so repeat visitors can be counted but nobody identified. Sharing goes out by mail (several recipients at once, no attachment -- a link can be withdrawn later, an attachment cannot) or via wa.me, which opens WhatsApp with the text prepared. The WhatsApp Business API would need a Meta account, approved templates and per-conversation fees; this works today for free and the API can slot in behind it later. Verified end to end against a local SMTP server: reset issues and consumes its token exactly once, the old password stops working, an unknown address gives the same answer, share mail reaches several recipients. Link behaviour checked for expiry (410), revocation (410), wrong passphrase (401) and download lock (403). Outbound SMTP is blocked in the sandbox, so the real mailbox still needs one test from the live server. Also fixes a regression this introduced: the seventh nav item pushed the logout button off screen at 820px. The bar now shrinks and scrolls, logout never does. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01BNhLunz586g6DeYMa7Qsd1
This commit is contained in:
27 files changed
+1946
-13
No files matched your search
@@ -11,6 +11,7 @@ export default function AdminApp() {
|
||||
const [orKey, setOrKey] = useState(''); const [pdPw, setPdPw] = useState(''); const [nasPw, setNasPw] = useState('');
|
||||
const [storage, setStorage] = useState<any>(null);
|
||||
const [rename, setRename] = useState<any>(null); // Probelauf/Ergebnis der Umbenennung
|
||||
const [mailAn, setMailAn] = useState(''); // Adresse für die Testnachricht
|
||||
const [targets, setTargets] = useState<any[]>([]);
|
||||
const [nt, setNt] = useState<any>({ protocol: 'sftp' });
|
||||
const notify = (t: string) => { setToast(t); setTimeout(() => setToast(null), 2600); };
|
||||
@@ -130,6 +131,14 @@ export default function AdminApp() {
|
||||
notify(r.deleted != null ? `${r.deleted} alte Positionen entfernt.` : r.purged != null ? `${r.purged} Quelldateien gelöscht.` : r.made != null ? `${r.made} Vorschaubilder erzeugt.` : (r.error || 'OK'));
|
||||
load();
|
||||
};
|
||||
const testeMail = async (mitVersand: boolean) => {
|
||||
if (mitVersand && !mailAn.trim()) { notify('Bitte eine Adresse für die Testnachricht eintragen.'); return; }
|
||||
notify(mitVersand ? 'Verschicke Testnachricht …' : 'Prüfe Verbindung …');
|
||||
const r = await fetch('/api/admin/test-mail', { method: 'POST', headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(mitVersand ? { to: mailAn.trim() } : {}) }).then((x) => x.json());
|
||||
notify(r.message || (r.ok ? 'OK' : 'Fehler'));
|
||||
};
|
||||
|
||||
// Eindeutige Dateinamen — erst zeigen, was passieren würde, dann anwenden.
|
||||
const renamePreview = async () => {
|
||||
notify('Prüfe Dateinamen …');
|
||||
@@ -305,6 +314,54 @@ export default function AdminApp() {
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="karte">
|
||||
<div className="kopfzeile"><span className="mono-label">E-Mail</span>
|
||||
<div className="reihe">
|
||||
<button className="mini" onClick={() => testeMail(false)}>Verbindung testen</button>
|
||||
<button className="mini" onClick={() => testeMail(true)}>Testnachricht</button>
|
||||
</div></div>
|
||||
<div className="steuer">
|
||||
<div className="fein">Klarbild verschickt über dein eigenes Postfach — dadurch stimmen SPF und DKIM
|
||||
von selbst und es kommt kein weiterer Anbieter ins Spiel. Gebraucht wird es für
|
||||
<b> Passwort zurücksetzen</b>, <b>Hinweise zu fertigen Aufträgen</b> und <b>Teilen-Links per Mail</b>.
|
||||
Das Passwort wird verschlüsselt gespeichert und nie wieder angezeigt.</div>
|
||||
<div className="zwei">
|
||||
<div className="feld"><label>Postausgangsserver</label>
|
||||
<input className="input" placeholder="z. B. w020d97a.kasserver.com" value={s.smtp_host ?? ''} onChange={(e) => field('smtp_host', e.target.value)} /></div>
|
||||
<div className="feld"><label>Port</label>
|
||||
<input className="input" type="number" placeholder="465" value={s.smtp_port ?? 465} onChange={(e) => field('smtp_port', e.target.value)} />
|
||||
<div className="fein">465 = SSL · 587 = STARTTLS</div></div>
|
||||
</div>
|
||||
<label className="schalt"><input type="checkbox" checked={s.smtp_secure !== false} onChange={(e) => field('smtp_secure', e.target.checked)} />
|
||||
<span><b>Verschlüsselt verbinden (SSL)</b><em>Für Port 465 an, für 587 aus.</em></span></label>
|
||||
<div className="zwei">
|
||||
<div className="feld"><label>Benutzername</label>
|
||||
<input className="input" placeholder="mail@deine-domain.de" value={s.smtp_user ?? ''} onChange={(e) => field('smtp_user', e.target.value)} /></div>
|
||||
<div className="feld"><label>Passwort {s.smtp_password_set ? <span className="fein">· gesetzt</span> : null}</label>
|
||||
<input className="input" type="password" placeholder={s.smtp_password_set ? 'unverändert lassen' : 'Postfach-Passwort'}
|
||||
onChange={(e) => field('smtp_password', e.target.value)} /></div>
|
||||
</div>
|
||||
<div className="feld"><label>Absender</label>
|
||||
<input className="input" placeholder="Klarbild <mail@deine-domain.de>" value={s.smtp_from ?? ''} onChange={(e) => field('smtp_from', e.target.value)} />
|
||||
<div className="fein">Leer = Benutzername. Muss zum Postfach gehören, sonst lehnen Empfänger die Mail ab.</div></div>
|
||||
<div className="feld"><label>Testnachricht an</label>
|
||||
<input className="input" type="email" placeholder="du@beispiel.de" value={mailAn} onChange={(e) => setMailAn(e.target.value)} />
|
||||
<div className="fein">„Verbindung testen" prüft nur die Anmeldung. Erst die Testnachricht beweist,
|
||||
dass die Mail wirklich ankommt — schau auch im Spam-Ordner nach.</div></div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="karte">
|
||||
<div className="kopfzeile"><span className="mono-label">Teilen-Links</span></div>
|
||||
<div className="steuer">
|
||||
<div className="feld"><label>Standard-Gültigkeit (Tage)</label>
|
||||
<input className="input" type="number" min={0} max={3650} value={s.share_default_days ?? 30}
|
||||
onChange={(e) => field('share_default_days', e.target.value)} />
|
||||
<div className="fein">Voreinstellung für neue Links; beim Erzeugen jederzeit änderbar. <b>0 = unbegrenzt.</b>
|
||||
Auf den Bildern sind Kinder — ein Link, der von selbst abläuft, ist die sichere Voreinstellung.</div></div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="karte">
|
||||
<div className="kopfzeile"><span className="mono-label">Dateinamen</span>
|
||||
<span className="fein">JJJJ-MM-TT_HHMMSS_motiv_format_kürzel</span></div>
|
||||
|
||||
@@ -0,0 +1,148 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
|
||||
/**
|
||||
* Eigenes Konto: E-Mail hinterlegen und Benachrichtigungen schalten.
|
||||
*
|
||||
* Die Adresse ist zugleich der Weg, ein vergessenes Passwort zurückzusetzen —
|
||||
* das steht auch so dabei, damit niemand sie für reine Werbepost hält.
|
||||
*/
|
||||
interface Me {
|
||||
username: string | null; display_name: string | null;
|
||||
email: string | null; notify_jobs: boolean; mail_ready: boolean; role: string;
|
||||
}
|
||||
|
||||
export default function KontoApp() {
|
||||
const [me, setMe] = useState<Me | null>(null);
|
||||
const [email, setEmail] = useState('');
|
||||
const [msg, setMsg] = useState<{ t: string; ok: boolean } | null>(null);
|
||||
const [laeuft, setLaeuft] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
fetch('/api/me').then((r) => r.json()).then((m) => { setMe(m); setEmail(m.email || ''); }).catch(() => {});
|
||||
}, []);
|
||||
|
||||
const sagen = (t: string, ok: boolean) => { setMsg({ t, ok }); setTimeout(() => setMsg(null), 5000); };
|
||||
|
||||
const patch = async (body: unknown, erfolg: string) => {
|
||||
setLaeuft(true);
|
||||
const r = await fetch('/api/me', {
|
||||
method: 'PATCH', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body),
|
||||
}).then((x) => x.json()).catch(() => ({ error: 'Netzwerkfehler.' }));
|
||||
setLaeuft(false);
|
||||
if (r.error) { sagen(r.error, false); return false; }
|
||||
sagen(erfolg, true);
|
||||
const m = await fetch('/api/me').then((x) => x.json()).catch(() => null);
|
||||
if (m) setMe(m);
|
||||
return true;
|
||||
};
|
||||
|
||||
if (!me) return <p className="ko-fein">Einen Moment …</p>;
|
||||
|
||||
return (
|
||||
<div className="ko">
|
||||
<section className="ko-karte">
|
||||
<div className="ko-kopf"><span className="mono-label">Anmeldung</span></div>
|
||||
<div className="ko-inhalt">
|
||||
<div className="ko-zeile">
|
||||
<span className="ko-label">Benutzername</span>
|
||||
<span className="ko-wert">{me.username}</span>
|
||||
</div>
|
||||
{me.display_name && (
|
||||
<div className="ko-zeile">
|
||||
<span className="ko-label">Angezeigt als</span>
|
||||
<span className="ko-wert">{me.display_name}</span>
|
||||
</div>
|
||||
)}
|
||||
<div className="ko-zeile">
|
||||
<span className="ko-label">Passwort</span>
|
||||
<span className="ko-wert"><a href="/passwort">Neues Passwort setzen</a></span>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="ko-karte">
|
||||
<div className="ko-kopf"><span className="mono-label">E-Mail-Adresse</span></div>
|
||||
<div className="ko-inhalt">
|
||||
<p className="ko-fein">
|
||||
Damit kannst du dir ein <b>neues Passwort schicken lassen</b>, wenn du deines vergessen hast.
|
||||
Ohne hinterlegte Adresse geht das nicht — dann muss ein Admin ran.
|
||||
</p>
|
||||
<div className="ko-eingabe">
|
||||
<input type="email" value={email} onChange={(e) => setEmail(e.target.value)}
|
||||
placeholder="du@beispiel.de" aria-label="E-Mail-Adresse" autoComplete="email" />
|
||||
<button className="ko-btn stark" disabled={laeuft || email === (me.email || '')}
|
||||
onClick={() => patch({ email }, email ? 'Adresse gespeichert.' : 'Adresse entfernt.')}>
|
||||
Speichern
|
||||
</button>
|
||||
</div>
|
||||
{!me.mail_ready && (
|
||||
<p className="ko-warn">
|
||||
Auf diesem Server ist noch kein E-Mail-Versand eingerichtet — die Adresse wird zwar
|
||||
gespeichert, es kommt aber noch nichts an. Ein Admin richtet das unter
|
||||
<b> Admin → E-Mail</b> ein.
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="ko-karte">
|
||||
<div className="ko-kopf"><span className="mono-label">Benachrichtigungen</span></div>
|
||||
<div className="ko-inhalt">
|
||||
<label className="ko-schalt">
|
||||
<input type="checkbox" checked={me.notify_jobs} disabled={!me.email}
|
||||
onChange={(e) => patch({ notify_jobs: e.target.checked },
|
||||
e.target.checked ? 'Hinweise eingeschaltet.' : 'Hinweise ausgeschaltet.')} />
|
||||
<span>
|
||||
<b>Sag mir Bescheid, wenn ein Auftrag fertig ist</b>
|
||||
<em>Eine kurze Mail je Auftrag — kein Newsletter, keine Werbung.
|
||||
{!me.email && ' Dafür brauchst du zuerst eine E-Mail-Adresse (oben).'}</em>
|
||||
</span>
|
||||
</label>
|
||||
<p className="ko-fein">
|
||||
Aufträge aus einer <b>privaten Sitzung</b> lösen bewusst nie eine Mail aus — die sollen
|
||||
ja gerade keine Spur hinterlassen.
|
||||
</p>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{msg && <p className={`ko-msg ${msg.ok ? 'ok' : 'err'}`} role="status">{msg.t}</p>}
|
||||
|
||||
<style>{`
|
||||
.ko{display:grid;gap:14px;max-width:620px;}
|
||||
.ko-karte{background:var(--card);border:1px solid var(--line);border-radius:var(--radius);}
|
||||
.ko-kopf{padding:13px 16px;border-bottom:1px solid var(--line);}
|
||||
.ko-inhalt{padding:16px;display:grid;gap:12px;}
|
||||
.ko-zeile{display:flex;gap:12px;justify-content:space-between;align-items:baseline;
|
||||
padding-bottom:9px;border-bottom:1px solid var(--line);font-size:.92rem;}
|
||||
.ko-zeile:last-child{border-bottom:none;padding-bottom:0;}
|
||||
.ko-label{color:var(--soft);font-size:.85rem;}
|
||||
.ko-wert{font-weight:500;overflow-wrap:anywhere;text-align:right;}
|
||||
.ko-wert a{color:var(--accent);}
|
||||
.ko-fein{margin:0;font-size:.85rem;color:var(--ink-soft);line-height:1.6;}
|
||||
.ko-eingabe{display:flex;gap:8px;}
|
||||
.ko-eingabe input{flex:1;min-width:0;padding:11px 12px;border:1px solid var(--line);
|
||||
border-radius:var(--radius-sm);background:var(--paper);color:var(--ink);font-size:16px;font-family:inherit;}
|
||||
.ko-btn{border:1px solid var(--line);border-radius:var(--radius-sm);background:var(--card);
|
||||
color:var(--ink);padding:11px 16px;font-size:.9rem;cursor:pointer;font-family:inherit;white-space:nowrap;}
|
||||
.ko-btn.stark{background:var(--accent);color:#fff;border-color:var(--accent);font-weight:600;}
|
||||
.ko-btn:disabled{background:var(--paper);color:var(--soft);border-color:var(--line);cursor:not-allowed;}
|
||||
.ko-schalt{display:flex;gap:11px;align-items:flex-start;cursor:pointer;font-size:.92rem;}
|
||||
.ko-schalt input{width:19px;height:19px;margin-top:2px;flex:none;accent-color:var(--accent);}
|
||||
.ko-schalt input:disabled{cursor:not-allowed;}
|
||||
.ko-schalt b{display:block;}
|
||||
.ko-schalt em{display:block;font-style:normal;color:var(--soft);font-size:.82rem;margin-top:3px;line-height:1.5;}
|
||||
.ko-warn{margin:0;padding:10px 12px;border:1px solid var(--warn);border-radius:var(--radius-sm);
|
||||
background:oklch(72% .15 75 / .08);font-size:.82rem;line-height:1.55;}
|
||||
.ko-msg{margin:0;font-size:.9rem;}
|
||||
.ko-msg.ok{color:var(--ok);}
|
||||
.ko-msg.err{color:var(--err);}
|
||||
@media(max-width:560px){
|
||||
.ko-eingabe{flex-direction:column;}
|
||||
.ko-btn{width:100%;padding:13px;}
|
||||
.ko-zeile{flex-direction:column;gap:3px;}
|
||||
.ko-wert{text-align:left;}
|
||||
}
|
||||
`}</style>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,4 +1,5 @@
|
||||
import React, { useState, useEffect, useCallback } from 'react';
|
||||
import ShareDialog, { type ShareZiel } from './ShareDialog';
|
||||
|
||||
interface Item { id: string; filename: string; output_px: string; has_alpha: boolean;
|
||||
folder_id: string | null; by_name: string; tasks: string[]; delivery_status: string; nas_status: string;
|
||||
@@ -24,6 +25,7 @@ export default function LibraryApp() {
|
||||
const [promptOf, setPromptOf] = useState<Item | null>(null);
|
||||
const [saveView, setSaveView] = useState<Item | null>(null);
|
||||
const [tagMenu, setTagMenu] = useState<string | null>(null);
|
||||
const [teilen, setTeilen] = useState<ShareZiel | null>(null);
|
||||
const [tagFilter, setTagFilter] = useState<string | null>(null);
|
||||
const [toast, setToast] = useState<string | null>(null);
|
||||
const notify = (t: string) => { setToast(t); setTimeout(() => setToast(null), 2400); };
|
||||
@@ -129,6 +131,7 @@ export default function LibraryApp() {
|
||||
{pick.size > 0 && <>
|
||||
<span className="fein">{pick.size} gewählt</span>
|
||||
<button className="mini stark" onClick={deliverSelected}>Nach Picdrop</button>
|
||||
<button className="mini" onClick={() => setTeilen({ itemIds: [...pick] })}>Teilen</button>
|
||||
<button className="mini" onClick={zipSelected}>ZIP laden</button>
|
||||
<select className="mini-select" value="" onChange={(e) => { if (e.target.value) moveSelected(e.target.value === '__none' ? null : e.target.value); }}>
|
||||
<option value="" disabled>In Ordner …</option>
|
||||
@@ -224,6 +227,8 @@ export default function LibraryApp() {
|
||||
<button className="mini stark2" onClick={() => setSaveView(v)} title="Groß öffnen, dann sichern (iPhone: In Fotos sichern)">Laden</button>
|
||||
<button className="mini" onClick={() => reuse(v.id)} title="Ergebnis im Studio weiterbearbeiten">Weiter</button>
|
||||
<button className="mini" onClick={() => alternative(v.id)} title="Weitere Fassung erzeugen">+ Alt.</button>
|
||||
<button className="mini" onClick={() => setTeilen({ itemIds: [v.id] })}
|
||||
title="Link erzeugen — auch ohne Klarbild-Konto zu öffnen">Teilen</button>
|
||||
<select className="mini-select" value={v.folder_id || ''} onChange={(e) => setFolder(v.id, e.target.value || null)}>
|
||||
<option value="">Kein Ordner</option>
|
||||
{folders.map((o) => <option key={o.id} value={o.id}>{o.name}</option>)}
|
||||
@@ -236,6 +241,10 @@ export default function LibraryApp() {
|
||||
</div>
|
||||
)}
|
||||
|
||||
{teilen && (
|
||||
<ShareDialog ziel={teilen} onClose={() => setTeilen(null)} />
|
||||
)}
|
||||
|
||||
{compare && (
|
||||
<div className="lupe" onClick={() => setCompare(null)}>
|
||||
<div className="vergleich" onClick={(e) => e.stopPropagation()}>
|
||||
|
||||
@@ -0,0 +1,278 @@
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
|
||||
/**
|
||||
* Teilen-Dialog: aus einer Bildauswahl einen Link machen und ihn weitergeben.
|
||||
*
|
||||
* Bewusst zweistufig — erst Einstellungen, dann Weitergabe. Der Link entsteht
|
||||
* beim Klick auf „Link erzeugen"; bis dahin ist nichts öffentlich. Danach zeigt
|
||||
* derselbe Dialog die Wege: kopieren, WhatsApp, E-Mail.
|
||||
*/
|
||||
export interface ShareZiel { itemIds: string[]; titel?: string }
|
||||
|
||||
const TAGE = [
|
||||
{ v: 7, t: '7 Tage' },
|
||||
{ v: 30, t: '30 Tage' },
|
||||
{ v: 90, t: '3 Monate' },
|
||||
{ v: 365, t: '1 Jahr' },
|
||||
{ v: 0, t: 'unbegrenzt' },
|
||||
];
|
||||
|
||||
export default function ShareDialog(
|
||||
{ ziel, onClose, onFertig }: { ziel: ShareZiel; onClose: () => void; onFertig?: () => void },
|
||||
) {
|
||||
const mehrere = ziel.itemIds.length > 1;
|
||||
const [titel, setTitel] = useState(ziel.titel || '');
|
||||
const [notiz, setNotiz] = useState('');
|
||||
const [tage, setTage] = useState(30);
|
||||
const [download, setDownload] = useState(true);
|
||||
const [wort, setWort] = useState('');
|
||||
const [link, setLink] = useState<{ slug: string; id: string } | null>(null);
|
||||
const [laeuft, setLaeuft] = useState(false);
|
||||
const [msg, setMsg] = useState<string | null>(null);
|
||||
const [mailAn, setMailAn] = useState('');
|
||||
const [mailText, setMailText] = useState('');
|
||||
const [mailBereit, setMailBereit] = useState(false);
|
||||
const [kopiert, setKopiert] = useState(false);
|
||||
const box = useRef<HTMLDivElement>(null);
|
||||
|
||||
const url = link ? `${location.origin}/s/${link.slug}` : '';
|
||||
|
||||
useEffect(() => {
|
||||
fetch('/api/me').then((r) => r.json()).then((m) => setMailBereit(!!m.mail_ready)).catch(() => {});
|
||||
}, []);
|
||||
|
||||
// Fokus einfangen und Hintergrund festhalten, solange der Dialog offen ist.
|
||||
useEffect(() => {
|
||||
const vorher = document.body.style.overflow;
|
||||
document.body.style.overflow = 'hidden';
|
||||
box.current?.querySelector<HTMLElement>('input,select,button')?.focus();
|
||||
const key = (e: KeyboardEvent) => {
|
||||
if (e.key === 'Escape') onClose();
|
||||
if (e.key !== 'Tab') return;
|
||||
const f = box.current?.querySelectorAll<HTMLElement>('a[href],button:not([disabled]),input,select,textarea');
|
||||
if (!f?.length) return;
|
||||
const [erst, letzt] = [f[0], f[f.length - 1]];
|
||||
if (e.shiftKey && document.activeElement === erst) { e.preventDefault(); letzt.focus(); }
|
||||
else if (!e.shiftKey && document.activeElement === letzt) { e.preventDefault(); erst.focus(); }
|
||||
};
|
||||
document.addEventListener('keydown', key);
|
||||
return () => { document.removeEventListener('keydown', key); document.body.style.overflow = vorher; };
|
||||
}, [onClose]);
|
||||
|
||||
const erzeugen = async () => {
|
||||
setLaeuft(true); setMsg(null);
|
||||
const r = await fetch('/api/shares', {
|
||||
method: 'POST', headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
itemIds: ziel.itemIds, title: titel || null, note: notiz || null,
|
||||
days: tage, allowDownload: download, password: wort || null,
|
||||
}),
|
||||
}).then((x) => x.json()).catch(() => ({ error: 'Netzwerkfehler.' }));
|
||||
setLaeuft(false);
|
||||
if (r.error) { setMsg(r.error); return; }
|
||||
setLink({ slug: r.share.slug, id: r.share.id });
|
||||
onFertig?.();
|
||||
};
|
||||
|
||||
const kopieren = async () => {
|
||||
try { await navigator.clipboard.writeText(url); }
|
||||
catch {
|
||||
// Ohne Zwischenablage-Recht: Feld markieren, damit man von Hand kopieren kann.
|
||||
box.current?.querySelector<HTMLInputElement>('.linkfeld')?.select();
|
||||
}
|
||||
setKopiert(true); setTimeout(() => setKopiert(false), 2000);
|
||||
};
|
||||
|
||||
const waText = `${titel || 'Bilder'}${wort ? '' : ''}\n${url}`;
|
||||
const waHref = `https://wa.me/?text=${encodeURIComponent(waText)}`;
|
||||
|
||||
const mailen = async () => {
|
||||
if (!mailAn.trim()) { setMsg('Bitte eine Empfängeradresse eintragen.'); return; }
|
||||
setLaeuft(true); setMsg(null);
|
||||
const r = await fetch('/api/shares/send', {
|
||||
method: 'POST', headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ shareId: link!.id, to: mailAn, message: mailText }),
|
||||
}).then((x) => x.json()).catch(() => ({ error: 'Netzwerkfehler.' }));
|
||||
setLaeuft(false);
|
||||
if (r.error) { setMsg(r.error); return; }
|
||||
setMsg(r.fehler?.length
|
||||
? `${r.ok} verschickt, bei ${r.fehler.length} hat es nicht geklappt.`
|
||||
: `Verschickt an ${r.ok === 1 ? 'die Adresse' : `${r.ok} Adressen`}.`);
|
||||
setMailAn('');
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="tl-hinter" onMouseDown={(e) => e.target === e.currentTarget && onClose()}>
|
||||
<div className="tl-box" ref={box} role="dialog" aria-modal="true" aria-label="Teilen">
|
||||
<div className="tl-kopf">
|
||||
<h2>{mehrere ? `${ziel.itemIds.length} Bilder teilen` : 'Bild teilen'}</h2>
|
||||
<button className="tl-x" onClick={onClose} aria-label="Schließen">✕</button>
|
||||
</div>
|
||||
|
||||
{!link ? (
|
||||
<div className="tl-inhalt">
|
||||
<p className="tl-lead">
|
||||
Es entsteht ein Link, den auch jemand ohne Klarbild-Konto öffnen kann.
|
||||
Er läuft von selbst ab und lässt sich jederzeit wieder abschalten.
|
||||
</p>
|
||||
|
||||
<label className="tl-feld">Überschrift
|
||||
<input value={titel} onChange={(e) => setTitel(e.target.value)}
|
||||
placeholder={mehrere ? 'z. B. Kita-Fotos August' : 'z. B. Phoenix im Garten'} maxLength={120} />
|
||||
</label>
|
||||
|
||||
<label className="tl-feld"><span className="tl-kopfzeile">Nachricht auf der Seite <em>optional</em></span>
|
||||
<textarea value={notiz} onChange={(e) => setNotiz(e.target.value)} rows={2}
|
||||
placeholder="Steht über den Bildern" maxLength={500} />
|
||||
</label>
|
||||
|
||||
<label className="tl-feld">Gültig
|
||||
<select value={tage} onChange={(e) => setTage(Number(e.target.value))}>
|
||||
{TAGE.map((t) => <option key={t.v} value={t.v}>{t.t}</option>)}
|
||||
</select>
|
||||
</label>
|
||||
|
||||
<label className="tl-check">
|
||||
<input type="checkbox" checked={download} onChange={(e) => setDownload(e.target.checked)} />
|
||||
<span><b>Herunterladen erlauben</b><em>Ohne Haken kann man die Bilder nur ansehen.</em></span>
|
||||
</label>
|
||||
|
||||
<label className="tl-feld"><span className="tl-kopfzeile">Zugangswort <em>optional</em></span>
|
||||
<input type="text" value={wort} onChange={(e) => setWort(e.target.value)}
|
||||
placeholder="Leer = jeder mit dem Link kommt rein" maxLength={100} />
|
||||
<span className="tl-fein">Schick das Wort auf einem anderen Weg als den Link.</span>
|
||||
</label>
|
||||
|
||||
{msg && <p className="tl-msg err">{msg}</p>}
|
||||
<div className="tl-fuss">
|
||||
<button className="tl-zweit" onClick={onClose}>Abbrechen</button>
|
||||
<button className="tl-haupt" onClick={erzeugen} disabled={laeuft}>
|
||||
{laeuft ? 'Einen Moment …' : 'Link erzeugen'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="tl-inhalt">
|
||||
<p className="tl-lead">Der Link ist da. So kommt er zum Empfänger:</p>
|
||||
|
||||
<div className="tl-linkzeile">
|
||||
<input className="linkfeld" value={url} readOnly onFocus={(e) => e.currentTarget.select()} aria-label="Teilen-Link" />
|
||||
<button className="tl-haupt" onClick={kopieren}>{kopiert ? 'Kopiert ✓' : 'Kopieren'}</button>
|
||||
</div>
|
||||
|
||||
<div className="tl-wege">
|
||||
<a className="tl-weg wa" href={waHref} target="_blank" rel="noopener">
|
||||
<span className="tl-weg-t">WhatsApp</span>
|
||||
<span className="tl-weg-f">Öffnet WhatsApp mit fertigem Text</span>
|
||||
</a>
|
||||
<a className="tl-weg" href={`mailto:?subject=${encodeURIComponent(titel || 'Bilder')}&body=${encodeURIComponent(waText)}`}>
|
||||
<span className="tl-weg-t">Eigenes Mailprogramm</span>
|
||||
<span className="tl-weg-f">Öffnet deine Mail-App</span>
|
||||
</a>
|
||||
</div>
|
||||
|
||||
{wort && (
|
||||
<p className="tl-warn">
|
||||
Der Link ist mit <b>{wort}</b> geschützt. Schick das Wort getrennt vom Link —
|
||||
sonst nützt der Schutz nichts.
|
||||
</p>
|
||||
)}
|
||||
|
||||
<div className="tl-trenn"><span>oder direkt von hier verschicken</span></div>
|
||||
|
||||
{mailBereit ? (
|
||||
<>
|
||||
<label className="tl-feld">An
|
||||
<input value={mailAn} onChange={(e) => setMailAn(e.target.value)} type="email"
|
||||
placeholder="oma@beispiel.de, opa@beispiel.de" />
|
||||
<span className="tl-fein">Mehrere Adressen mit Komma trennen.</span>
|
||||
</label>
|
||||
<label className="tl-feld"><span className="tl-kopfzeile">Dazu schreiben <em>optional</em></span>
|
||||
<textarea value={mailText} onChange={(e) => setMailText(e.target.value)} rows={2}
|
||||
placeholder="Ein paar Zeilen an den Empfänger" maxLength={1000} />
|
||||
</label>
|
||||
<button className="tl-haupt breit" onClick={mailen} disabled={laeuft}>
|
||||
{laeuft ? 'Wird verschickt …' : 'Per E-Mail schicken'}
|
||||
</button>
|
||||
</>
|
||||
) : (
|
||||
<p className="tl-fein">
|
||||
Direkter E-Mail-Versand ist noch nicht eingerichtet — ein Admin kann das
|
||||
unter <b>Admin → E-Mail</b> nachholen. Kopieren und WhatsApp gehen trotzdem.
|
||||
</p>
|
||||
)}
|
||||
|
||||
{msg && <p className={`tl-msg ${/nicht|Fehler|klappt/.test(msg) ? 'err' : 'ok'}`}>{msg}</p>}
|
||||
|
||||
<div className="tl-fuss">
|
||||
<a className="tl-zweit" href="/teilen">Alle Links verwalten</a>
|
||||
<button className="tl-zweit" onClick={onClose}>Fertig</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<style>{`
|
||||
.tl-hinter{position:fixed;inset:0;z-index:80;background:rgba(22,21,15,.55);
|
||||
display:flex;align-items:center;justify-content:center;padding:16px;backdrop-filter:blur(2px);}
|
||||
.tl-box{background:var(--card);border:1px solid var(--line);border-radius:var(--radius);
|
||||
width:min(520px,100%);max-height:min(88vh,760px);display:flex;flex-direction:column;box-shadow:0 12px 40px rgba(22,21,15,.25);}
|
||||
.tl-kopf{display:flex;align-items:center;justify-content:space-between;gap:10px;
|
||||
padding:16px 18px;border-bottom:1px solid var(--line);}
|
||||
.tl-kopf h2{margin:0;font-size:1.05rem;letter-spacing:-.01em;}
|
||||
.tl-x{border:none;background:none;font-size:17px;cursor:pointer;color:var(--soft);
|
||||
width:34px;height:34px;border-radius:50%;line-height:1;}
|
||||
.tl-x:hover{background:var(--paper);color:var(--ink);}
|
||||
.tl-inhalt{padding:18px;overflow:auto;display:grid;gap:14px;}
|
||||
.tl-lead{margin:0;font-size:.9rem;color:var(--ink-soft);line-height:1.6;}
|
||||
.tl-feld{display:grid;gap:6px;font-size:.86rem;color:var(--ink-soft);}
|
||||
.tl-feld input,.tl-feld select,.tl-feld textarea{padding:10px 11px;border:1px solid var(--line);
|
||||
border-radius:var(--radius-sm);background:var(--paper);color:var(--ink);font-size:16px;
|
||||
font-family:inherit;width:100%;box-sizing:border-box;}
|
||||
.tl-feld textarea{resize:vertical;line-height:1.5;}
|
||||
.tl-kopfzeile{display:flex;gap:6px;align-items:baseline;}
|
||||
.tl-kopfzeile em{font-style:normal;color:var(--soft);font-size:.78rem;}
|
||||
.tl-fein{font-size:.78rem;color:var(--soft);line-height:1.5;}
|
||||
.tl-check{display:flex;gap:10px;align-items:flex-start;font-size:.88rem;cursor:pointer;}
|
||||
.tl-check input{width:18px;height:18px;margin-top:2px;flex:none;accent-color:var(--accent);}
|
||||
.tl-check b{display:block;font-weight:600;color:var(--ink);}
|
||||
.tl-check em{display:block;font-style:normal;color:var(--soft);font-size:.8rem;margin-top:2px;}
|
||||
.tl-linkzeile{display:flex;gap:8px;}
|
||||
.tl-linkzeile input{flex:1;min-width:0;padding:10px 11px;border:1px solid var(--line);
|
||||
border-radius:var(--radius-sm);background:var(--paper);font-family:var(--font-mono);
|
||||
font-size:12.5px;color:var(--ink);}
|
||||
.tl-haupt{border:none;border-radius:var(--radius-sm);background:var(--accent);color:#fff;
|
||||
padding:11px 16px;font-weight:600;font-size:.92rem;cursor:pointer;font-family:inherit;white-space:nowrap;}
|
||||
.tl-haupt.breit{width:100%;}
|
||||
.tl-haupt:disabled{background:var(--paper);color:var(--soft);border:1px solid var(--line);cursor:not-allowed;}
|
||||
.tl-zweit{border:1px solid var(--line);border-radius:var(--radius-sm);background:var(--card);
|
||||
color:var(--ink);padding:11px 16px;font-size:.92rem;cursor:pointer;font-family:inherit;
|
||||
text-decoration:none;display:inline-block;text-align:center;}
|
||||
.tl-wege{display:grid;grid-template-columns:1fr 1fr;gap:8px;}
|
||||
.tl-weg{display:grid;gap:2px;padding:11px 13px;border:1px solid var(--line);
|
||||
border-radius:var(--radius-sm);background:var(--paper);text-decoration:none;color:var(--ink);}
|
||||
.tl-weg:hover{border-color:var(--accent);}
|
||||
.tl-weg.wa{border-color:#25D366;}
|
||||
.tl-weg-t{font-weight:600;font-size:.88rem;}
|
||||
.tl-weg-f{font-size:.75rem;color:var(--soft);line-height:1.4;}
|
||||
.tl-trenn{display:flex;align-items:center;gap:10px;color:var(--soft);
|
||||
font-family:var(--font-mono);font-size:9.5px;letter-spacing:.14em;text-transform:uppercase;}
|
||||
.tl-trenn::before,.tl-trenn::after{content:'';flex:1;height:1px;background:var(--line);}
|
||||
.tl-warn{margin:0;padding:10px 12px;border:1px solid var(--warn);border-radius:var(--radius-sm);
|
||||
background:oklch(72% .15 75 / .08);font-size:.82rem;line-height:1.55;}
|
||||
.tl-msg{margin:0;font-size:.86rem;line-height:1.5;}
|
||||
.tl-msg.err{color:var(--err);}
|
||||
.tl-msg.ok{color:var(--ok);}
|
||||
.tl-fuss{display:flex;gap:8px;justify-content:flex-end;padding-top:4px;}
|
||||
@media(max-width:560px){
|
||||
.tl-hinter{padding:0;align-items:flex-end;}
|
||||
.tl-box{width:100%;max-height:92vh;border-radius:var(--radius) var(--radius) 0 0;}
|
||||
.tl-wege{grid-template-columns:1fr;}
|
||||
.tl-fuss{flex-direction:column-reverse;}
|
||||
.tl-fuss>*{width:100%;}
|
||||
.tl-haupt,.tl-zweit{padding:13px 16px;}
|
||||
}
|
||||
`}</style>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,227 @@
|
||||
import { useCallback, useEffect, useState } from 'react';
|
||||
|
||||
/**
|
||||
* Verwaltung der Teilen-Links: sehen, wie oft ein Link aufgerufen wurde,
|
||||
* ihn ändern, abschalten oder löschen.
|
||||
*
|
||||
* Der Zähler ist bewusst schlicht: Aufrufe, Downloads und die Zahl der
|
||||
* unterschiedlichen Besucher. Wer geschaut hat, steht nirgends — die
|
||||
* IP-Adressen liegen nur gehasht vor und lassen sich nicht zurückrechnen.
|
||||
*/
|
||||
interface Share {
|
||||
id: string; slug: string; kind: string; title: string | null; note: string | null;
|
||||
expires_at: string | null; revoked_at: string | null; allow_download: boolean;
|
||||
password_hash: string | null; views: number; downloads: number;
|
||||
last_seen_at: string | null; created_at: string; anzahl: number; besucher: number;
|
||||
}
|
||||
|
||||
const TAGE = [
|
||||
{ v: 7, t: '7 Tage ab jetzt' }, { v: 30, t: '30 Tage ab jetzt' },
|
||||
{ v: 90, t: '3 Monate ab jetzt' }, { v: 365, t: '1 Jahr ab jetzt' }, { v: 0, t: 'unbegrenzt' },
|
||||
];
|
||||
|
||||
const datum = (s: string | null) => s ? new Date(s).toLocaleDateString('de-DE', { day: '2-digit', month: '2-digit', year: 'numeric' }) : '';
|
||||
const zeit = (s: string | null) => s ? new Date(s).toLocaleString('de-DE', { day: '2-digit', month: '2-digit', hour: '2-digit', minute: '2-digit' }) : '';
|
||||
|
||||
type Zustand = 'aktiv' | 'abgelaufen' | 'aus';
|
||||
const zustand = (s: Share): Zustand =>
|
||||
s.revoked_at ? 'aus'
|
||||
: s.expires_at && new Date(s.expires_at).getTime() < Date.now() ? 'abgelaufen'
|
||||
: 'aktiv';
|
||||
|
||||
export default function SharesApp() {
|
||||
const [shares, setShares] = useState<Share[]>([]);
|
||||
const [laden, setLaden] = useState(true);
|
||||
const [offen, setOffen] = useState<string | null>(null);
|
||||
const [zugriffe, setZugriffe] = useState<Record<string, any[]>>({});
|
||||
const [msg, setMsg] = useState<string | null>(null);
|
||||
const [kopiert, setKopiert] = useState<string | null>(null);
|
||||
|
||||
const laden_ = useCallback(async () => {
|
||||
const r = await fetch('/api/shares').then((x) => x.json()).catch(() => ({ shares: [] }));
|
||||
setShares(r.shares || []); setLaden(false);
|
||||
}, []);
|
||||
useEffect(() => { laden_(); }, [laden_]);
|
||||
|
||||
const sagen = (t: string) => { setMsg(t); setTimeout(() => setMsg(null), 4000); };
|
||||
|
||||
const patch = async (id: string, body: unknown) => {
|
||||
const r = await fetch(`/api/shares/${id}`, {
|
||||
method: 'PATCH', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body),
|
||||
}).then((x) => x.json()).catch(() => ({ error: 'Netzwerkfehler.' }));
|
||||
if (r.error) sagen(r.error); else await laden_();
|
||||
};
|
||||
|
||||
const loeschen = async (s: Share) => {
|
||||
if (!confirm(`Den Link „${s.title || s.slug}" endgültig löschen?\n\nEr lässt sich danach nicht mehr öffnen und die Zugriffszahlen sind weg. `
|
||||
+ `Nur abschalten reicht meistens.`)) return;
|
||||
const r = await fetch(`/api/shares/${s.id}`, { method: 'DELETE' }).then((x) => x.json()).catch(() => ({}));
|
||||
if (r.ok) { sagen('Link gelöscht.'); await laden_(); } else sagen(r.error || 'Hat nicht geklappt.');
|
||||
};
|
||||
|
||||
const kopieren = async (s: Share) => {
|
||||
const url = `${location.origin}/s/${s.slug}`;
|
||||
try { await navigator.clipboard.writeText(url); } catch { prompt('Link kopieren:', url); }
|
||||
setKopiert(s.id); setTimeout(() => setKopiert(null), 2000);
|
||||
};
|
||||
|
||||
const details = async (s: Share) => {
|
||||
if (offen === s.id) { setOffen(null); return; }
|
||||
setOffen(s.id);
|
||||
if (!zugriffe[s.id]) {
|
||||
const r = await fetch(`/api/shares/${s.id}`).then((x) => x.json()).catch(() => ({ zugriffe: [] }));
|
||||
setZugriffe((z) => ({ ...z, [s.id]: r.zugriffe || [] }));
|
||||
}
|
||||
};
|
||||
|
||||
if (laden) return <p className="tw-leer">Einen Moment …</p>;
|
||||
|
||||
return (
|
||||
<div className="tw">
|
||||
{msg && <div className="tw-toast" role="status">{msg}</div>}
|
||||
|
||||
{!shares.length ? (
|
||||
<div className="tw-leer-box">
|
||||
<span className="regmark" />
|
||||
<h2>Noch nichts geteilt</h2>
|
||||
<p>In der <a href="/bibliothek">Bibliothek</a> auf <b>Teilen</b> tippen — dann entsteht hier
|
||||
ein Eintrag, den du jederzeit wieder abschalten kannst.</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="tw-liste">
|
||||
{shares.map((s) => {
|
||||
const z = zustand(s);
|
||||
return (
|
||||
<article key={s.id} className={`tw-karte ${z}`}>
|
||||
<div className="tw-kopf">
|
||||
<div className="tw-titel">
|
||||
<h3>{s.title || (s.anzahl > 1 ? `${s.anzahl} Bilder` : 'Ein Bild')}</h3>
|
||||
<span className={`tw-status ${z}`}>
|
||||
{z === 'aktiv' ? 'aktiv' : z === 'aus' ? 'abgeschaltet' : 'abgelaufen'}
|
||||
</span>
|
||||
{s.password_hash && <span className="tw-schutz" title="Mit Zugangswort geschützt">geschützt</span>}
|
||||
</div>
|
||||
<div className="tw-zahlen">
|
||||
<span><b>{s.views}</b> Aufrufe</span>
|
||||
<span><b>{s.downloads}</b> Downloads</span>
|
||||
<span><b>{s.besucher}</b> {s.besucher === 1 ? 'Besucher' : 'Besucher'}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="tw-meta">
|
||||
{s.anzahl} {s.anzahl === 1 ? 'Bild' : 'Bilder'} ·
|
||||
{' '}{s.expires_at ? `gültig bis ${datum(s.expires_at)}` : 'ohne Ablauf'} ·
|
||||
{' '}angelegt {datum(s.created_at)}
|
||||
{s.last_seen_at && <> · zuletzt gesehen {zeit(s.last_seen_at)}</>}
|
||||
{!s.allow_download && <> · nur ansehen</>}
|
||||
</div>
|
||||
|
||||
<div className="tw-zeile">
|
||||
<input className="tw-link" readOnly value={`${location.origin}/s/${s.slug}`}
|
||||
onFocus={(e) => e.currentTarget.select()} aria-label="Teilen-Link" />
|
||||
<button className="tw-btn" onClick={() => kopieren(s)}>{kopiert === s.id ? 'Kopiert ✓' : 'Kopieren'}</button>
|
||||
<a className="tw-btn" href={`/s/${s.slug}`} target="_blank" rel="noopener">Ansehen</a>
|
||||
</div>
|
||||
|
||||
<div className="tw-aktionen">
|
||||
{z === 'aus'
|
||||
? <button className="tw-btn stark" onClick={() => patch(s.id, { action: 'unrevoke' })}>Wieder freigeben</button>
|
||||
: <button className="tw-btn" onClick={() => patch(s.id, { action: 'revoke' })}>Abschalten</button>}
|
||||
<select className="tw-btn" value="" onChange={(e) => e.target.value !== '' && patch(s.id, { days: Number(e.target.value) })}
|
||||
aria-label="Gültigkeit ändern">
|
||||
<option value="" disabled>Gültigkeit ändern …</option>
|
||||
{TAGE.map((t) => <option key={t.v} value={t.v}>{t.t}</option>)}
|
||||
</select>
|
||||
<button className="tw-btn" onClick={() => patch(s.id, { allowDownload: !s.allow_download })}>
|
||||
{s.allow_download ? 'Download sperren' : 'Download erlauben'}
|
||||
</button>
|
||||
<button className="tw-btn" onClick={() => {
|
||||
const w = prompt(s.password_hash
|
||||
? 'Neues Zugangswort (leer lassen = Schutz entfernen):'
|
||||
: 'Zugangswort setzen:');
|
||||
if (w !== null) patch(s.id, { password: w });
|
||||
}}>{s.password_hash ? 'Wort ändern' : 'Wort setzen'}</button>
|
||||
<button className="tw-btn" onClick={() => details(s)}>{offen === s.id ? 'Zugriffe zu' : 'Zugriffe'}</button>
|
||||
<button className="tw-btn gefahr" onClick={() => loeschen(s)}>Löschen</button>
|
||||
</div>
|
||||
|
||||
{offen === s.id && (
|
||||
<div className="tw-zugriffe">
|
||||
{!zugriffe[s.id]?.length ? <p className="tw-fein">Noch keine Zugriffe.</p> : (
|
||||
<ul>
|
||||
{zugriffe[s.id].slice(0, 30).map((v, i) => (
|
||||
<li key={i}>
|
||||
<span className="tw-art">{v.kind === 'download' ? 'Download' : 'Aufruf'}</span>
|
||||
<span className="tw-wann">{zeit(v.at)}</span>
|
||||
<span className="tw-ua">{(v.user_agent || '').slice(0, 60)}</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
<p className="tw-fein">Es wird festgehalten, <b>wann</b> und <b>womit</b> geöffnet wurde —
|
||||
nicht <b>wer</b>. Die IP-Adresse liegt nur gehasht vor und lässt sich nicht zurückrechnen.</p>
|
||||
</div>
|
||||
)}
|
||||
</article>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<style>{`
|
||||
.tw{display:grid;gap:14px;}
|
||||
.tw-leer{color:var(--soft);}
|
||||
.tw-leer-box{background:var(--card);border:1px solid var(--line);border-radius:var(--radius);
|
||||
padding:44px 24px;text-align:center;}
|
||||
.tw-leer-box h2{margin:14px 0 8px;font-size:1.15rem;}
|
||||
.tw-leer-box p{margin:0;color:var(--ink-soft);line-height:1.6;}
|
||||
.tw-toast{position:sticky;top:70px;z-index:5;background:var(--ink);color:var(--card);
|
||||
padding:10px 14px;border-radius:var(--radius-sm);font-size:.9rem;}
|
||||
.tw-liste{display:grid;gap:12px;}
|
||||
.tw-karte{background:var(--card);border:1px solid var(--line);border-radius:var(--radius);
|
||||
padding:16px;display:grid;gap:11px;}
|
||||
.tw-karte.aus,.tw-karte.abgelaufen{background:var(--paper);}
|
||||
.tw-kopf{display:flex;flex-wrap:wrap;gap:10px;align-items:flex-start;justify-content:space-between;}
|
||||
.tw-titel{display:flex;flex-wrap:wrap;gap:8px;align-items:center;min-width:0;}
|
||||
.tw-titel h3{margin:0;font-size:1rem;letter-spacing:-.01em;overflow-wrap:anywhere;}
|
||||
.tw-status{font-family:var(--font-mono);font-size:9px;letter-spacing:.12em;text-transform:uppercase;
|
||||
padding:3px 8px;border-radius:20px;}
|
||||
.tw-status.aktiv{background:oklch(60% .15 150 / .14);color:oklch(42% .13 150);}
|
||||
.tw-status.aus{background:oklch(55% .19 25 / .12);color:var(--err);}
|
||||
.tw-status.abgelaufen{background:var(--paper);color:var(--soft);border:1px solid var(--line);}
|
||||
.tw-schutz{font-family:var(--font-mono);font-size:9px;letter-spacing:.12em;text-transform:uppercase;
|
||||
padding:3px 8px;border-radius:20px;background:oklch(72% .15 75 / .16);color:oklch(48% .13 75);}
|
||||
.tw-zahlen{display:flex;gap:14px;font-size:.78rem;color:var(--soft);white-space:nowrap;}
|
||||
.tw-zahlen b{color:var(--ink);font-size:.95rem;}
|
||||
.tw-meta{font-size:.8rem;color:var(--soft);line-height:1.5;}
|
||||
.tw-zeile{display:flex;gap:7px;}
|
||||
.tw-link{flex:1;min-width:0;padding:9px 10px;border:1px solid var(--line);border-radius:var(--radius-sm);
|
||||
background:var(--paper);font-family:var(--font-mono);font-size:12px;color:var(--ink);}
|
||||
.tw-aktionen{display:flex;flex-wrap:wrap;gap:6px;}
|
||||
.tw-btn{background:var(--card);border:1px solid var(--line);border-radius:var(--radius-sm);
|
||||
padding:8px 11px;font-size:.82rem;cursor:pointer;font-family:inherit;color:var(--ink);
|
||||
text-decoration:none;display:inline-block;white-space:nowrap;}
|
||||
.tw-btn:hover{border-color:var(--accent);}
|
||||
.tw-btn.stark{background:var(--accent);color:#fff;border-color:var(--accent);font-weight:600;}
|
||||
.tw-btn.gefahr{color:var(--err);}
|
||||
.tw-zugriffe{border-top:1px solid var(--line);padding-top:11px;}
|
||||
.tw-zugriffe ul{list-style:none;margin:0 0 10px;padding:0;display:grid;gap:5px;max-height:260px;overflow:auto;}
|
||||
.tw-zugriffe li{display:grid;grid-template-columns:82px 118px minmax(0,1fr);gap:8px;
|
||||
font-size:.76rem;align-items:baseline;}
|
||||
.tw-art{font-weight:600;}
|
||||
.tw-wann{font-family:var(--font-mono);font-size:10.5px;color:var(--soft);}
|
||||
.tw-ua{color:var(--soft);overflow:hidden;text-overflow:ellipsis;white-space:nowrap;}
|
||||
.tw-fein{font-size:.76rem;color:var(--soft);line-height:1.5;margin:0;}
|
||||
@media(max-width:640px){
|
||||
.tw-kopf{flex-direction:column;gap:8px;}
|
||||
.tw-zahlen{gap:12px;}
|
||||
.tw-zeile{flex-wrap:wrap;}
|
||||
.tw-link{flex-basis:100%;}
|
||||
.tw-btn{flex:1;text-align:center;padding:11px 10px;}
|
||||
.tw-zugriffe li{grid-template-columns:74px 1fr;}
|
||||
.tw-ua{display:none;}
|
||||
}
|
||||
`}</style>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in new issue
Block a user