feat: KoePi mehrere benannte oeffentliche Links statt nur einem, Migration des bestehenden Sina-Links, Name in Logs
This commit is contained in:
@@ -677,6 +677,34 @@ db.exec(`
|
|||||||
const cols = db.pragma('table_info(public_access_log)').map(c => c.name);
|
const cols = db.pragma('table_info(public_access_log)').map(c => c.name);
|
||||||
if (!cols.includes('device'))
|
if (!cols.includes('device'))
|
||||||
db.exec("ALTER TABLE public_access_log ADD COLUMN device TEXT NOT NULL DEFAULT ''");
|
db.exec("ALTER TABLE public_access_log ADD COLUMN device TEXT NOT NULL DEFAULT ''");
|
||||||
|
if (!cols.includes('link_name'))
|
||||||
|
db.exec("ALTER TABLE public_access_log ADD COLUMN link_name TEXT NOT NULL DEFAULT ''");
|
||||||
|
}
|
||||||
|
|
||||||
|
// KöPi: benannte, öffentliche Teilen-Links (mehrere gleichzeitig möglich,
|
||||||
|
// jeweils mit eigenem Namen wie "Sina") statt nur einem einzigen globalen Link
|
||||||
|
db.exec(`
|
||||||
|
CREATE TABLE IF NOT EXISTS koepi_share_links (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
token TEXT NOT NULL UNIQUE,
|
||||||
|
name TEXT NOT NULL DEFAULT '',
|
||||||
|
created_at DATETIME
|
||||||
|
)
|
||||||
|
`);
|
||||||
|
// Migration: den bisherigen einzelnen Link (war für Sina gedacht) einmalig in
|
||||||
|
// die neue Tabelle übernehmen, statt ihn ungültig zu machen
|
||||||
|
{
|
||||||
|
const oldToken = db.prepare("SELECT value FROM admin_settings WHERE key='koepi_share_token'").get()?.value;
|
||||||
|
if (oldToken) {
|
||||||
|
const already = db.prepare('SELECT 1 FROM koepi_share_links WHERE token=?').get(oldToken);
|
||||||
|
if (!already) {
|
||||||
|
db.prepare(`
|
||||||
|
INSERT INTO koepi_share_links (token, name, created_at)
|
||||||
|
VALUES (?, 'Sina', datetime('now','localtime'))
|
||||||
|
`).run(oldToken);
|
||||||
|
}
|
||||||
|
db.prepare("DELETE FROM admin_settings WHERE key='koepi_share_token'").run();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
module.exports = db;
|
module.exports = db;
|
||||||
|
|||||||
@@ -57,7 +57,7 @@ function parseDevice(ua) {
|
|||||||
// Klicks/Tab-Wechseln hintereinander, spart nebenbei auch unnötige
|
// Klicks/Tab-Wechseln hintereinander, spart nebenbei auch unnötige
|
||||||
// Geolocation-Abfragen).
|
// Geolocation-Abfragen).
|
||||||
const DEDUP_WINDOW_MINUTES = 5;
|
const DEDUP_WINDOW_MINUTES = 5;
|
||||||
function logPublicAccess({ linkType, path, ip, userAgent }) {
|
function logPublicAccess({ linkType, path, ip, userAgent, linkName }) {
|
||||||
try {
|
try {
|
||||||
const recent = db.prepare(`
|
const recent = db.prepare(`
|
||||||
SELECT 1 FROM public_access_log
|
SELECT 1 FROM public_access_log
|
||||||
@@ -73,9 +73,9 @@ function logPublicAccess({ linkType, path, ip, userAgent }) {
|
|||||||
lookupLocation(ip).then(location => {
|
lookupLocation(ip).then(location => {
|
||||||
try {
|
try {
|
||||||
db.prepare(`
|
db.prepare(`
|
||||||
INSERT INTO public_access_log (link_type, path, ip, location, device, created_at)
|
INSERT INTO public_access_log (link_type, path, ip, location, device, link_name, created_at)
|
||||||
VALUES (?, ?, ?, ?, ?, datetime('now','localtime'))
|
VALUES (?, ?, ?, ?, ?, ?, datetime('now','localtime'))
|
||||||
`).run(linkType || '', path || '', ip || '', location || '', device || '');
|
`).run(linkType || '', path || '', ip || '', location || '', device || '', linkName || '');
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.error('Public-Access-Log Fehler:', e.message);
|
console.error('Public-Access-Log Fehler:', e.message);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -549,7 +549,7 @@ router.get('/logs', authenticate, requireAdmin, (req, res) => {
|
|||||||
}));
|
}));
|
||||||
|
|
||||||
const accessRows = db.prepare(`
|
const accessRows = db.prepare(`
|
||||||
SELECT id, link_type, path, ip, location, device, created_at
|
SELECT id, link_type, path, ip, location, device, link_name, created_at
|
||||||
FROM public_access_log
|
FROM public_access_log
|
||||||
ORDER BY id DESC
|
ORDER BY id DESC
|
||||||
LIMIT ?
|
LIMIT ?
|
||||||
@@ -562,6 +562,7 @@ router.get('/logs', authenticate, requireAdmin, (req, res) => {
|
|||||||
ip: r.ip,
|
ip: r.ip,
|
||||||
location: r.location,
|
location: r.location,
|
||||||
device: r.device,
|
device: r.device,
|
||||||
|
linkName: r.link_name,
|
||||||
}));
|
}));
|
||||||
|
|
||||||
const combined = [...pushRows, ...accessRows]
|
const combined = [...pushRows, ...accessRows]
|
||||||
|
|||||||
@@ -829,16 +829,17 @@ router.post('/run-daily-check', authenticate, async (req, res) => {
|
|||||||
} catch(e) { res.status(500).json({ error: e.message }); }
|
} catch(e) { res.status(500).json({ error: e.message }); }
|
||||||
});
|
});
|
||||||
|
|
||||||
// ── Öffentlicher Teilen-Link (kein Login nötig) ─────────────────────────────
|
// ── Öffentliche Teilen-Links (kein Login nötig) ─────────────────────────────
|
||||||
// Sicherheitsprinzip: die öffentlichen Routen unten liefern AUSSCHLIESSLICH
|
// Sicherheitsprinzip: die öffentlichen Routen unten liefern AUSSCHLIESSLICH
|
||||||
// bereits gecachte Daten aus (kein forceRefresh, kein Puppeteer-Trigger, kein
|
// bereits gecachte Daten aus (kein forceRefresh, kein Puppeteer-Trigger, kein
|
||||||
// Zugriff auf irgendeine Admin-Funktion) und sind zusätzlich pro IP
|
// Zugriff auf irgendeine Admin-Funktion) und sind zusätzlich pro IP
|
||||||
// ratenbegrenzt — ein gefundener/erratener Link kann also weder einen echten
|
// ratenbegrenzt — ein gefundener/erratener Link kann also weder einen echten
|
||||||
// marktguru-Abruf erzwingen noch den Server sonst irgendwie belasten.
|
// marktguru-Abruf erzwingen noch den Server sonst irgendwie belasten.
|
||||||
const KOEPI_SHARE_TOKEN_KEY = 'koepi_share_token';
|
//
|
||||||
|
// Mehrere benannte Links gleichzeitig möglich (z.B. einer pro Person), damit
|
||||||
function getShareToken() {
|
// in den Logs nachvollziehbar ist, WER welchen Link benutzt hat.
|
||||||
return db.prepare('SELECT value FROM admin_settings WHERE key=?').get(KOEPI_SHARE_TOKEN_KEY)?.value || null;
|
function getShareLinkByToken(token) {
|
||||||
|
return db.prepare('SELECT id, name FROM koepi_share_links WHERE token=?').get(token) || null;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Einfache In-Memory-Ratenbegrenzung pro IP (30 Anfragen/Minute) — bewusst
|
// Einfache In-Memory-Ratenbegrenzung pro IP (30 Anfragen/Minute) — bewusst
|
||||||
@@ -863,26 +864,40 @@ setInterval(() => {
|
|||||||
}
|
}
|
||||||
}, 5 * 60 * 1000);
|
}, 5 * 60 * 1000);
|
||||||
|
|
||||||
// GET /share-link – aktuellen Link anzeigen (Admin)
|
// GET /share-links – alle benannten Links auflisten (Admin)
|
||||||
router.get('/share-link', authenticate, (req, res) => {
|
router.get('/share-links', authenticate, (req, res) => {
|
||||||
if (req.user?.role !== 'admin') return res.status(403).json({ error: 'Kein Zugriff' });
|
if (req.user?.role !== 'admin') return res.status(403).json({ error: 'Kein Zugriff' });
|
||||||
const token = getShareToken();
|
const links = db.prepare('SELECT id, token, name, created_at FROM koepi_share_links ORDER BY created_at DESC').all()
|
||||||
res.json({ token, url: token ? `${req.protocol}://${req.get('host')}/kp/${token}` : null });
|
.map(l => ({ ...l, url: `${req.protocol}://${req.get('host')}/kp/${l.token}` }));
|
||||||
|
res.json({ links });
|
||||||
});
|
});
|
||||||
|
|
||||||
// POST /share-link – neuen Link erzeugen (macht einen evtl. vorhandenen alten
|
// POST /share-links – neuen benannten Link erstellen (Admin)
|
||||||
// automatisch ungültig, da nur ein Token gleichzeitig gespeichert wird)
|
router.post('/share-links', authenticate, (req, res) => {
|
||||||
router.post('/share-link', authenticate, (req, res) => {
|
|
||||||
if (req.user?.role !== 'admin') return res.status(403).json({ error: 'Kein Zugriff' });
|
if (req.user?.role !== 'admin') return res.status(403).json({ error: 'Kein Zugriff' });
|
||||||
|
const name = (req.body?.name || '').trim();
|
||||||
|
if (!name) return res.status(400).json({ error: 'Name fehlt' });
|
||||||
const token = crypto.randomBytes(20).toString('hex');
|
const token = crypto.randomBytes(20).toString('hex');
|
||||||
db.prepare('INSERT OR REPLACE INTO admin_settings (key, value) VALUES (?, ?)').run(KOEPI_SHARE_TOKEN_KEY, token);
|
db.prepare(`INSERT INTO koepi_share_links (token, name, created_at) VALUES (?, ?, datetime('now','localtime'))`).run(token, name);
|
||||||
res.json({ token, url: `${req.protocol}://${req.get('host')}/kp/${token}` });
|
const link = db.prepare('SELECT id, token, name, created_at FROM koepi_share_links WHERE token=?').get(token);
|
||||||
|
res.json({ ...link, url: `${req.protocol}://${req.get('host')}/kp/${token}` });
|
||||||
});
|
});
|
||||||
|
|
||||||
// POST /share-link/disable – Link deaktivieren
|
// POST /share-links/:id/reset – Token dieses Links neu erzeugen (Name bleibt,
|
||||||
router.post('/share-link/disable', authenticate, (req, res) => {
|
// alter Link wird ungültig)
|
||||||
|
router.post('/share-links/:id/reset', authenticate, (req, res) => {
|
||||||
if (req.user?.role !== 'admin') return res.status(403).json({ error: 'Kein Zugriff' });
|
if (req.user?.role !== 'admin') return res.status(403).json({ error: 'Kein Zugriff' });
|
||||||
db.prepare('DELETE FROM admin_settings WHERE key=?').run(KOEPI_SHARE_TOKEN_KEY);
|
const existing = db.prepare('SELECT * FROM koepi_share_links WHERE id=?').get(req.params.id);
|
||||||
|
if (!existing) return res.status(404).json({ error: 'Nicht gefunden' });
|
||||||
|
const token = crypto.randomBytes(20).toString('hex');
|
||||||
|
db.prepare('UPDATE koepi_share_links SET token=? WHERE id=?').run(token, req.params.id);
|
||||||
|
res.json({ id: existing.id, token, name: existing.name, url: `${req.protocol}://${req.get('host')}/kp/${token}` });
|
||||||
|
});
|
||||||
|
|
||||||
|
// DELETE /share-links/:id – Link löschen (Admin)
|
||||||
|
router.delete('/share-links/:id', authenticate, (req, res) => {
|
||||||
|
if (req.user?.role !== 'admin') return res.status(403).json({ error: 'Kein Zugriff' });
|
||||||
|
db.prepare('DELETE FROM koepi_share_links WHERE id=?').run(req.params.id);
|
||||||
res.json({ ok: true });
|
res.json({ ok: true });
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -891,8 +906,8 @@ router.post('/share-link/disable', authenticate, (req, res) => {
|
|||||||
// damit die installierte App direkt wieder auf der öffentlichen Seite landet
|
// damit die installierte App direkt wieder auf der öffentlichen Seite landet
|
||||||
// statt auf dem normalen Login der Haupt-App.
|
// statt auf dem normalen Login der Haupt-App.
|
||||||
router.get('/public/:token/manifest.json', (req, res) => {
|
router.get('/public/:token/manifest.json', (req, res) => {
|
||||||
const active = getShareToken();
|
const link = getShareLinkByToken(req.params.token);
|
||||||
if (!active || req.params.token !== active) return res.status(404).json({ error: 'Nicht gefunden' });
|
if (!link) return res.status(404).json({ error: 'Nicht gefunden' });
|
||||||
res.setHeader('Content-Type', 'application/manifest+json');
|
res.setHeader('Content-Type', 'application/manifest+json');
|
||||||
res.json({
|
res.json({
|
||||||
name: 'Dickens Prospekte',
|
name: 'Dickens Prospekte',
|
||||||
@@ -914,8 +929,8 @@ router.get('/public/:token/manifest.json', (req, res) => {
|
|||||||
|
|
||||||
// GET /public/:token/offers – öffentlich, nur Cache, kein Login
|
// GET /public/:token/offers – öffentlich, nur Cache, kein Login
|
||||||
router.get('/public/:token/offers', publicRateLimit, async (req, res) => {
|
router.get('/public/:token/offers', publicRateLimit, async (req, res) => {
|
||||||
const active = getShareToken();
|
const link = getShareLinkByToken(req.params.token);
|
||||||
if (!active || req.params.token !== active) return res.status(404).json({ error: 'Nicht gefunden' });
|
if (!link) return res.status(404).json({ error: 'Nicht gefunden' });
|
||||||
try {
|
try {
|
||||||
const result = await scrapeMarktguru(false); // niemals forceRefresh über die öffentliche Route
|
const result = await scrapeMarktguru(false); // niemals forceRefresh über die öffentliche Route
|
||||||
res.json(result);
|
res.json(result);
|
||||||
@@ -924,9 +939,9 @@ router.get('/public/:token/offers', publicRateLimit, async (req, res) => {
|
|||||||
|
|
||||||
// GET /public/:token/prospekte – öffentlich, nur Cache, kein Login
|
// GET /public/:token/prospekte – öffentlich, nur Cache, kein Login
|
||||||
router.get('/public/:token/prospekte', publicRateLimit, async (req, res) => {
|
router.get('/public/:token/prospekte', publicRateLimit, async (req, res) => {
|
||||||
const active = getShareToken();
|
const link = getShareLinkByToken(req.params.token);
|
||||||
if (!active || req.params.token !== active) return res.status(404).json({ error: 'Nicht gefunden' });
|
if (!link) return res.status(404).json({ error: 'Nicht gefunden' });
|
||||||
logPublicAccess({ linkType: 'koepi_share', path: `/kp/${req.params.token}`, ip: req.ip, userAgent: req.headers['user-agent'] });
|
logPublicAccess({ linkType: 'koepi_share', path: `/kp/${req.params.token}`, ip: req.ip, userAgent: req.headers['user-agent'], linkName: link.name });
|
||||||
try {
|
try {
|
||||||
const result = await scrapeProspekte();
|
const result = await scrapeProspekte();
|
||||||
res.json(result);
|
res.json(result);
|
||||||
|
|||||||
@@ -3312,6 +3312,7 @@ function PushLogs({ toast }) {
|
|||||||
<div style={{ display:'flex', justifyContent:'space-between', alignItems:'baseline', gap:8, marginBottom:3, flexWrap:'wrap' }}>
|
<div style={{ display:'flex', justifyContent:'space-between', alignItems:'baseline', gap:8, marginBottom:3, flexWrap:'wrap' }}>
|
||||||
<span style={{ color:'#4ecdc4', fontFamily:"'Space Mono',monospace", fontSize:11, fontWeight:700 }}>
|
<span style={{ color:'#4ecdc4', fontFamily:"'Space Mono',monospace", fontSize:11, fontWeight:700 }}>
|
||||||
{PUBLIC_LINK_LABELS[l.linkType] || '🔗 Öffentlicher Link besucht'}
|
{PUBLIC_LINK_LABELS[l.linkType] || '🔗 Öffentlicher Link besucht'}
|
||||||
|
{l.linkName && <span style={{ color:'#ffe66d' }}> — {l.linkName}</span>}
|
||||||
</span>
|
</span>
|
||||||
<span style={{ display:'flex', alignItems:'center', gap:8 }}>
|
<span style={{ display:'flex', alignItems:'center', gap:8 }}>
|
||||||
<span style={{ color:'rgba(255,255,255,0.35)', fontFamily:'monospace', fontSize:10 }}>
|
<span style={{ color:'rgba(255,255,255,0.35)', fontFamily:'monospace', fontSize:10 }}>
|
||||||
|
|||||||
@@ -262,35 +262,58 @@ export default function Koepi({ toast }) {
|
|||||||
finally { setResolvingStores(false); }
|
finally { setResolvingStores(false); }
|
||||||
};
|
};
|
||||||
|
|
||||||
const [shareInfo, setShareInfo] = useState(null); // { token, url } | null
|
const [shareLinks, setShareLinks] = useState([]);
|
||||||
const [shareOpen, setShareOpen] = useState(false);
|
const [shareOpen, setShareOpen] = useState(false);
|
||||||
const [shareBusy, setShareBusy] = useState(false);
|
const [shareBusy, setShareBusy] = useState(false);
|
||||||
useEffect(() => {
|
const [newLinkName, setNewLinkName] = useState('');
|
||||||
if (!isAdmin) return;
|
|
||||||
api('/tools/koepi/share-link').then(setShareInfo).catch(()=>{});
|
const loadShareLinks = () => {
|
||||||
}, [isAdmin]);
|
api('/tools/koepi/share-links').then(r => setShareLinks(r.links || [])).catch(()=>{});
|
||||||
|
};
|
||||||
|
useEffect(() => { if (isAdmin) loadShareLinks(); }, [isAdmin]);
|
||||||
|
|
||||||
const createShareLink = async () => {
|
const createShareLink = async () => {
|
||||||
|
const name = newLinkName.trim();
|
||||||
|
if (!name) { toast?.('Bitte einen Namen eingeben','error'); return; }
|
||||||
setShareBusy(true);
|
setShareBusy(true);
|
||||||
try {
|
try {
|
||||||
const r = await api('/tools/koepi/share-link', { method:'POST', body:{} });
|
await api('/tools/koepi/share-links', { method:'POST', body:{ name } });
|
||||||
setShareInfo(r);
|
setNewLinkName('');
|
||||||
toast('🔗 Neuer Link erzeugt — alter Link ist damit ungültig');
|
toast(`🔗 Link für "${name}" erstellt`);
|
||||||
|
loadShareLinks();
|
||||||
} catch(e) { toast?.(e.message||'Fehler','error'); }
|
} catch(e) { toast?.(e.message||'Fehler','error'); }
|
||||||
finally { setShareBusy(false); }
|
finally { setShareBusy(false); }
|
||||||
};
|
};
|
||||||
const disableShareLink = async () => {
|
const resetShareLink = async (id, name) => {
|
||||||
|
if (!window.confirm(`Link für "${name}" wirklich zurücksetzen? Der alte Link funktioniert danach nicht mehr.`)) return;
|
||||||
setShareBusy(true);
|
setShareBusy(true);
|
||||||
try {
|
try {
|
||||||
await api('/tools/koepi/share-link/disable', { method:'POST', body:{} });
|
await api(`/tools/koepi/share-links/${id}/reset`, { method:'POST', body:{} });
|
||||||
setShareInfo({ token:null, url:null });
|
toast(`🔄 Link für "${name}" zurückgesetzt`);
|
||||||
toast('🔗 Link deaktiviert');
|
loadShareLinks();
|
||||||
} catch(e) { toast?.(e.message||'Fehler','error'); }
|
} catch(e) { toast?.(e.message||'Fehler','error'); }
|
||||||
finally { setShareBusy(false); }
|
finally { setShareBusy(false); }
|
||||||
};
|
};
|
||||||
const copyShareLink = async () => {
|
const deleteShareLink = async (id, name) => {
|
||||||
if (!shareInfo?.url) return;
|
if (!window.confirm(`Link für "${name}" wirklich löschen?`)) return;
|
||||||
try { await navigator.clipboard.writeText(shareInfo.url); toast('Link kopiert'); }
|
setShareBusy(true);
|
||||||
catch { toast?.('Kopieren nicht möglich — bitte manuell markieren','error'); }
|
try {
|
||||||
|
await api(`/tools/koepi/share-links/${id}`, { method:'DELETE' });
|
||||||
|
toast(`🗑 Link für "${name}" gelöscht`);
|
||||||
|
loadShareLinks();
|
||||||
|
} catch(e) { toast?.(e.message||'Fehler','error'); }
|
||||||
|
finally { setShareBusy(false); }
|
||||||
|
};
|
||||||
|
const copyLink = async (url) => {
|
||||||
|
try { await navigator.clipboard.writeText(url); toast('Link kopiert'); return; } catch {}
|
||||||
|
try {
|
||||||
|
const ta = document.createElement('textarea');
|
||||||
|
ta.value = url; ta.style.position = 'fixed'; ta.style.left = '-9999px';
|
||||||
|
document.body.appendChild(ta); ta.focus(); ta.select();
|
||||||
|
const ok = document.execCommand('copy');
|
||||||
|
document.body.removeChild(ta);
|
||||||
|
toast?.(ok ? 'Link kopiert' : 'Kopieren nicht möglich', ok ? 'success' : 'error');
|
||||||
|
} catch { toast?.('Kopieren nicht möglich','error'); }
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -310,37 +333,52 @@ export default function Koepi({ toast }) {
|
|||||||
{resolvingStores ? '⏳ läuft…' : '🏪 Filial-Liste neu auflösen'}
|
{resolvingStores ? '⏳ läuft…' : '🏪 Filial-Liste neu auflösen'}
|
||||||
</button>
|
</button>
|
||||||
<button onClick={clearCache} style={{ ...S.btn('#666666',true), fontSize:10, flexShrink:0, whiteSpace:'nowrap' }}>🗑 Cache leeren</button>
|
<button onClick={clearCache} style={{ ...S.btn('#666666',true), fontSize:10, flexShrink:0, whiteSpace:'nowrap' }}>🗑 Cache leeren</button>
|
||||||
<button onClick={()=>setShareOpen(v=>!v)} style={{ ...S.btn(shareInfo?.token?'#ffe66d':'#888888',true), fontSize:10, flexShrink:0, whiteSpace:'nowrap' }}>
|
<button onClick={()=>setShareOpen(v=>!v)} style={{ ...S.btn(shareLinks.length?'#ffe66d':'#888888',true), fontSize:10, flexShrink:0, whiteSpace:'nowrap' }}>
|
||||||
🔗 Teilen{shareInfo?.token ? ' (aktiv)' : ''}
|
🔗 Teilen{shareLinks.length ? ` (${shareLinks.length})` : ''}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{isAdmin && shareOpen && (
|
{isAdmin && shareOpen && (
|
||||||
<div style={{ ...S.card, marginTop:10 }}>
|
<div style={{ ...S.card, marginTop:10 }}>
|
||||||
<div style={{ ...S.sub, marginBottom:10 }}>
|
<div style={{ ...S.sub, marginBottom:10 }}>
|
||||||
Öffentlicher, schreibgeschützter Link ohne Login — zeigt zuerst die Prospekte, man kann dort
|
Öffentliche, schreibgeschützte Links ohne Login — zeigen zuerst die Prospekte, man kann dort
|
||||||
auch zu den Angeboten wechseln. Keine Admin-Funktionen sichtbar/erreichbar über diesen Link.
|
auch zu den Angeboten wechseln. Keine Admin-Funktionen sichtbar/erreichbar über diese Links.
|
||||||
|
Du kannst mehrere Links gleichzeitig anlegen (z.B. einen pro Person) — in den Logs siehst du
|
||||||
|
dann, wer welchen Link benutzt hat.
|
||||||
</div>
|
</div>
|
||||||
{shareInfo?.token ? (
|
|
||||||
<>
|
{shareLinks.length > 0 && (
|
||||||
<div style={{ display:'flex', gap:8, marginBottom:10, flexWrap:'wrap' }}>
|
<div style={{ display:'flex', flexDirection:'column', gap:8, marginBottom:14 }}>
|
||||||
<input readOnly value={shareInfo.url} onClick={e=>e.target.select()} style={{ ...S.inp, flex:1, minWidth:200, fontSize:11 }}/>
|
{shareLinks.map(l => (
|
||||||
<button onClick={copyShareLink} style={{ ...S.btn('#4ecdc4') }}>📋 Kopieren</button>
|
<div key={l.id} style={{ border:'1px solid rgba(255,255,255,0.08)', borderRadius:8, padding:'8px 10px' }}>
|
||||||
</div>
|
<div style={{ color:'#ffe66d', fontFamily:"'Space Mono',monospace", fontSize:12, fontWeight:700, marginBottom:4 }}>
|
||||||
<div style={{ display:'flex', gap:8, flexWrap:'wrap' }}>
|
{l.name}
|
||||||
<button onClick={createShareLink} disabled={shareBusy} style={{ ...S.btn('#f59e0b'), opacity:shareBusy?0.5:1 }}>
|
</div>
|
||||||
{shareBusy?'…':'🔄 Neuen Link erzeugen (alter wird ungültig)'}
|
<div style={{ display:'flex', gap:8, marginBottom:6, flexWrap:'wrap' }}>
|
||||||
</button>
|
<input readOnly value={l.url} onClick={e=>e.target.select()} style={{ ...S.inp, flex:1, minWidth:180, fontSize:10 }}/>
|
||||||
<button onClick={disableShareLink} disabled={shareBusy} style={{ ...S.btn('#f87171'), opacity:shareBusy?0.5:1 }}>
|
<button onClick={()=>copyLink(l.url)} style={{ ...S.btn('#4ecdc4'), fontSize:11 }}>📋</button>
|
||||||
{shareBusy?'…':'🚫 Link deaktivieren'}
|
</div>
|
||||||
</button>
|
<div style={{ display:'flex', gap:8, flexWrap:'wrap' }}>
|
||||||
</div>
|
<button onClick={()=>resetShareLink(l.id, l.name)} disabled={shareBusy} style={{ ...S.btn('#f59e0b'), fontSize:10, opacity:shareBusy?0.5:1 }}>
|
||||||
</>
|
🔄 Zurücksetzen
|
||||||
) : (
|
</button>
|
||||||
<button onClick={createShareLink} disabled={shareBusy} style={{ ...S.btn('#4ecdc4'), opacity:shareBusy?0.5:1 }}>
|
<button onClick={()=>deleteShareLink(l.id, l.name)} disabled={shareBusy} style={{ ...S.btn('#f87171'), fontSize:10, opacity:shareBusy?0.5:1 }}>
|
||||||
{shareBusy?'…':'🔗 Link erzeugen'}
|
🗑 Löschen
|
||||||
</button>
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
<div style={{ display:'flex', gap:8, flexWrap:'wrap' }}>
|
||||||
|
<input value={newLinkName} onChange={e=>setNewLinkName(e.target.value)}
|
||||||
|
onKeyDown={e=>e.key==='Enter' && createShareLink()}
|
||||||
|
placeholder="Name (z.B. Sina)" style={{ ...S.inp, flex:1, minWidth:140 }}/>
|
||||||
|
<button onClick={createShareLink} disabled={shareBusy || !newLinkName.trim()} style={{ ...S.btn('#4ecdc4'), opacity:(shareBusy || !newLinkName.trim())?0.5:1 }}>
|
||||||
|
{shareBusy?'…':'➕ Neuen Link erstellen'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
Reference in New Issue
Block a user