Code-Schnipsel: neue Funktion unter Werkzeuge

This commit is contained in:
2026-05-29 13:16:39 +02:00
parent d755dcf68a
commit 449a0aa2f7
6 changed files with 712 additions and 2 deletions

View File

@@ -406,4 +406,40 @@ if (!db.prepare("SELECT name FROM sqlite_master WHERE type='table' AND name='cal
`);
}
// ── Code-Schnipsel ────────────────────────────────────────────────────────────
if (!db.prepare("SELECT name FROM sqlite_master WHERE type='table' AND name='snippets'").get()) {
db.exec(`
CREATE TABLE snippets (
id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
title TEXT NOT NULL,
code TEXT NOT NULL DEFAULT '',
language TEXT NOT NULL DEFAULT 'text',
description TEXT NOT NULL DEFAULT '',
created_at DATETIME DEFAULT NULL,
updated_at DATETIME DEFAULT NULL
);
CREATE TABLE snippet_tags (
snippet_id INTEGER NOT NULL REFERENCES snippets(id) ON DELETE CASCADE,
tag TEXT NOT NULL,
PRIMARY KEY (snippet_id, tag)
);
CREATE TABLE snippet_history (
id INTEGER PRIMARY KEY AUTOINCREMENT,
snippet_id INTEGER NOT NULL REFERENCES snippets(id) ON DELETE CASCADE,
code TEXT NOT NULL,
language TEXT NOT NULL DEFAULT 'text',
saved_at DATETIME DEFAULT NULL
);
CREATE TABLE snippet_shares (
id INTEGER PRIMARY KEY AUTOINCREMENT,
snippet_id INTEGER NOT NULL REFERENCES snippets(id) ON DELETE CASCADE,
shared_by INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
shared_with INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
created_at DATETIME DEFAULT NULL,
UNIQUE(snippet_id, shared_with)
)
`);
}
module.exports = db;

View File

@@ -66,7 +66,8 @@ fs.readdirSync(toolsDir, { withFileTypes: true })
}
});
// ── Frontend ──────────────────────────────────────────────────────────────────
app.use('/api/tools/snippets', require('./tools/snippets/routes'));
const PUBLIC = path.join(__dirname, '../public');
app.get('/manifest.json', (_req, res) => {

View File

@@ -0,0 +1,126 @@
const express = require('express');
const db = require('../../db');
const { authenticate } = require('../../middleware/auth');
const router = express.Router();
const uid = req => req.user.id;
const getTags = id => db.prepare('SELECT tag FROM snippet_tags WHERE snippet_id=?').all(id).map(r=>r.tag);
const enrich = (s) => ({ ...s, tags: getTags(s.id) });
// ── Alle Snippets (eigene + geteilt) ────────────────────────────────────────
router.get('/', authenticate, (req, res) => {
const me = uid(req);
const own = db.prepare(`
SELECT s.*, u.username as owner
FROM snippets s JOIN users u ON u.id=s.user_id
WHERE s.user_id=? ORDER BY s.updated_at DESC, s.created_at DESC
`).all(me).map(enrich);
const shared = db.prepare(`
SELECT s.*, u.username as owner, 1 as is_shared
FROM snippets s
JOIN snippet_shares sh ON sh.snippet_id=s.id
JOIN users u ON u.id=s.user_id
WHERE sh.shared_with=?
ORDER BY s.updated_at DESC
`).all(me).map(enrich);
res.json({ own, shared });
});
// ── Einzelnes Snippet ────────────────────────────────────────────────────────
router.get('/:id', authenticate, (req, res) => {
const s = db.prepare('SELECT s.*, u.username as owner FROM snippets s JOIN users u ON u.id=s.user_id WHERE s.id=?').get(req.params.id);
if (!s) return res.status(404).json({ error: 'Nicht gefunden' });
const isOwner = s.user_id === uid(req);
const isShared = db.prepare('SELECT id FROM snippet_shares WHERE snippet_id=? AND shared_with=?').get(s.id, uid(req));
if (!isOwner && !isShared) return res.status(403).json({ error: 'Kein Zugriff' });
res.json(enrich(s));
});
// ── History ──────────────────────────────────────────────────────────────────
router.get('/:id/history', authenticate, (req, res) => {
const s = db.prepare('SELECT * FROM snippets WHERE id=?').get(req.params.id);
if (!s) return res.status(404).json({ error: 'Nicht gefunden' });
const isOwner = s.user_id === uid(req);
const isShared = db.prepare('SELECT id FROM snippet_shares WHERE snippet_id=? AND shared_with=?').get(s.id, uid(req));
if (!isOwner && !isShared) return res.status(403).json({ error: 'Kein Zugriff' });
res.json(db.prepare('SELECT * FROM snippet_history WHERE snippet_id=? ORDER BY saved_at DESC').all(s.id));
});
// ── Erstellen ────────────────────────────────────────────────────────────────
router.post('/', authenticate, (req, res) => {
const { title, code='', language='text', description='', tags=[] } = req.body;
if (!title?.trim()) return res.status(400).json({ error: 'Titel erforderlich' });
const r = db.prepare(`
INSERT INTO snippets (user_id, title, code, language, description, created_at, updated_at)
VALUES (?,?,?,?,?,datetime('now','localtime'),datetime('now','localtime'))
`).run(uid(req), title.trim(), code, language, description);
const id = r.lastInsertRowid;
const insertTag = db.prepare('INSERT OR IGNORE INTO snippet_tags (snippet_id, tag) VALUES (?,?)');
db.transaction(()=>{ tags.forEach(t => insertTag.run(id, t)); })();
res.json(enrich(db.prepare('SELECT s.*, u.username as owner FROM snippets s JOIN users u ON u.id=s.user_id WHERE s.id=?').get(id)));
});
// ── Aktualisieren ────────────────────────────────────────────────────────────
router.put('/:id', authenticate, (req, res) => {
const s = db.prepare('SELECT * FROM snippets WHERE id=? AND user_id=?').get(req.params.id, uid(req));
if (!s) return res.status(404).json({ error: 'Nicht gefunden' });
const { title, code, language, description, tags } = req.body;
// History-Eintrag wenn Code geändert
if (code !== undefined && code !== s.code) {
db.prepare(`INSERT INTO snippet_history (snippet_id, code, language, saved_at) VALUES (?,?,?,datetime('now','localtime'))`)
.run(s.id, s.code, s.language);
}
db.prepare(`UPDATE snippets SET title=?,code=?,language=?,description=?,updated_at=datetime('now','localtime') WHERE id=?`)
.run(title??s.title, code??s.code, language??s.language, description??s.description, s.id);
if (Array.isArray(tags)) {
db.prepare('DELETE FROM snippet_tags WHERE snippet_id=?').run(s.id);
const ins = db.prepare('INSERT OR IGNORE INTO snippet_tags (snippet_id, tag) VALUES (?,?)');
db.transaction(()=>{ tags.forEach(t => ins.run(s.id, t)); })();
}
res.json(enrich(db.prepare('SELECT s.*, u.username as owner FROM snippets s JOIN users u ON u.id=s.user_id WHERE s.id=?').get(s.id)));
});
// ── Löschen ──────────────────────────────────────────────────────────────────
router.delete('/:id', authenticate, (req, res) => {
const r = db.prepare('DELETE FROM snippets WHERE id=? AND user_id=?').run(req.params.id, uid(req));
if (!r.changes) return res.status(404).json({ error: 'Nicht gefunden' });
res.json({ ok: true });
});
// ── Shares ───────────────────────────────────────────────────────────────────
router.get('/:id/shares', authenticate, (req, res) => {
const s = db.prepare('SELECT * FROM snippets WHERE id=? AND user_id=?').get(req.params.id, uid(req));
if (!s) return res.status(404).json({ error: 'Nicht gefunden' });
res.json(db.prepare(`
SELECT u.id, u.username, sh.created_at as shared_at
FROM snippet_shares sh JOIN users u ON u.id=sh.shared_with WHERE sh.snippet_id=?
`).all(s.id));
});
router.post('/:id/share', authenticate, (req, res) => {
const s = db.prepare('SELECT * FROM snippets WHERE id=? AND user_id=?').get(req.params.id, uid(req));
if (!s) return res.status(404).json({ error: 'Nicht gefunden' });
const target = db.prepare('SELECT * FROM users WHERE username=?').get(req.body.username);
if (!target) return res.status(404).json({ error: 'Benutzer nicht gefunden' });
if (target.id === uid(req)) return res.status(400).json({ error: 'Kann nicht mit dir selbst teilen' });
db.prepare(`INSERT OR IGNORE INTO snippet_shares (snippet_id,shared_by,shared_with,created_at) VALUES (?,?,?,datetime('now','localtime'))`)
.run(s.id, uid(req), target.id);
res.json({ ok: true });
});
router.delete('/:id/share/:userId', authenticate, (req, res) => {
const s = db.prepare('SELECT * FROM snippets WHERE id=? AND user_id=?').get(req.params.id, uid(req));
if (!s) return res.status(404).json({ error: 'Nicht gefunden' });
db.prepare('DELETE FROM snippet_shares WHERE snippet_id=? AND shared_with=?').run(s.id, req.params.userId);
res.json({ ok: true });
});
module.exports = router;

View File

@@ -141,3 +141,7 @@ export const LinkIcon = ({size=20,color='currentColor',sw}) => base(<>
<path d="M10 13a5 5 0 0 0 7.54.54l3-3a5 5 0 0 0-7.07-7.07l-1.72 1.71" stroke={color} strokeWidth={sw||1.5} fill="none" strokeLinecap="round" strokeLinejoin="round"/>
<path d="M14 11a5 5 0 0 0-7.54-.54l-3 3a5 5 0 0 0 7.07 7.07l1.71-1.71" stroke={color} strokeWidth={sw||1.5} fill="none" strokeLinecap="round" strokeLinejoin="round"/>
</>, size, color, sw);
export const CodeIcon = ({size=20,color='currentColor',sw}) => base(<>
<polyline points="16 18 22 12 16 6" stroke={color} strokeWidth={sw||1.5} fill="none" strokeLinecap="round" strokeLinejoin="round"/>
<polyline points="8 6 2 12 8 18" stroke={color} strokeWidth={sw||1.5} fill="none" strokeLinecap="round" strokeLinejoin="round"/>
</>, size, color, sw);

View File

@@ -3,7 +3,8 @@ import Bestellungen from './tools/bestellungen.jsx';
import Dateien from './tools/dateien.jsx';
import Nachrichten from './tools/nachrichten.jsx';
import Linkliste from './tools/linkliste.jsx';
import { CalculatorIcon, ArchiveIcon, DatabaseIcon, AppsIcon, MessageIcon, LinkIcon } from './icons.jsx';
import CodeSchnipsel from './tools/codeschnipsel.jsx';
import { CalculatorIcon, ArchiveIcon, DatabaseIcon, AppsIcon, MessageIcon, LinkIcon, CodeIcon } from './icons.jsx';
export const TOOLS = [
{
@@ -54,6 +55,14 @@ export const TOOLS = [
group: 'Werkzeuge',
component: Linkliste,
},
{
id: 'codeschnipsel',
Icon: CodeIcon,
label: 'Code-Schnipsel',
navLabel: 'Code',
group: 'Werkzeuge',
component: CodeSchnipsel,
},
// Neues Tool: { id:'...', Icon:..., label:'...', navLabel:'...', group:'...', component:... },
];

View File

@@ -0,0 +1,534 @@
import { useState, useEffect, useRef, useCallback } from 'react';
import { api, S } from '../lib.js';
// ── Spracherkennung ───────────────────────────────────────────────────────────
const LANG_PATTERNS = [
{ lang:'javascript', label:'JavaScript', patterns:[/\b(const|let|var|function|=>|require|import|export|console\.log)\b/] },
{ lang:'typescript', label:'TypeScript', patterns:[/\b(interface|type\s+\w+\s*=|:\s*(string|number|boolean|void)|<T>)\b/] },
{ lang:'python', label:'Python', patterns:[/\b(def |import |from .* import|elif |print\(|self\.|__init__)\b/] },
{ lang:'jsx', label:'JSX/React', patterns:[/<[A-Z][a-zA-Z]+|useState|useEffect|return\s*\([\s\S]*<|className=/] },
{ lang:'html', label:'HTML', patterns:[/<html|<body|<div|<span|<head|<!DOCTYPE/i] },
{ lang:'css', label:'CSS', patterns:[/\{[\s\S]*:\s*[^;]+;\s*\}|@media|@keyframes|\.[\w-]+\s*\{/] },
{ lang:'sql', label:'SQL', patterns:[/\b(SELECT|INSERT|UPDATE|DELETE|FROM|WHERE|JOIN|CREATE TABLE)\b/i] },
{ lang:'bash', label:'Bash/Shell', patterns:[/^#!/m, /\b(echo|grep|sed|awk|chmod|sudo|apt|curl|wget)\b/] },
{ lang:'json', label:'JSON', patterns:[/^\s*[\[{][\s\S]*[\]}]\s*$/, /"\w+":\s*[\w"[\]{]/] },
{ lang:'rust', label:'Rust', patterns:[/\b(fn |let mut|impl |pub fn|use std|println!)\b/] },
{ lang:'go', label:'Go', patterns:[/\b(func |package |import |fmt\.|var |:=)\b/] },
{ lang:'java', label:'Java', patterns:[/\b(public class|private |protected |void |System\.out)\b/] },
{ lang:'php', label:'PHP', patterns:[/<\?php|\$\w+\s*=|echo |function \w+|\$this->/] },
{ lang:'csharp', label:'C#', patterns:[/\b(using System|namespace |class \w+|public void|Console\.Write)\b/] },
{ lang:'cpp', label:'C/C++', patterns:[/#include|std::|cout <<|int main\(|nullptr/] },
{ lang:'yaml', label:'YAML', patterns:[/^[\w-]+:\s*$/m, /^\s+-\s+\w/m] },
{ lang:'dockerfile', label:'Dockerfile', patterns:[/^FROM |^RUN |^CMD |^EXPOSE |^COPY /m] },
];
const LANG_COLORS = {
javascript:'#f7df1e', typescript:'#3178c6', python:'#3572a5', jsx:'#61dafb',
html:'#e34c26', css:'#563d7c', sql:'#e38c00', bash:'#4EAA25',
json:'#4ecdc4', rust:'#dea584', go:'#00add8', java:'#b07219',
php:'#4f5d95', csharp:'#178600', cpp:'#f34b7d', yaml:'#cb171e',
dockerfile:'#2496ed', text:'rgba(255,255,255,0.3)',
};
function detectLanguage(code) {
if (!code?.trim()) return 'text';
for (const { lang, patterns } of LANG_PATTERNS) {
if (patterns.some(p => p.test(code))) return lang;
}
return 'text';
}
function getLangLabel(lang) {
return LANG_PATTERNS.find(l=>l.lang===lang)?.label || lang.toUpperCase();
}
function fmtDt(s) {
if (!s) return '';
const d = new Date(s.replace(' ','T'));
return isNaN(d) ? '' : d.toLocaleString('de-DE',{day:'2-digit',month:'2-digit',year:'numeric',hour:'2-digit',minute:'2-digit'});
}
// ── Share Modal ───────────────────────────────────────────────────────────────
function SnippetShareModal({ snippet, onClose, toast }) {
const [username, setUsername] = useState('');
const [shares, setShares] = useState([]);
const load = () => api(`/tools/snippets/${snippet.id}/shares`).then(setShares).catch(()=>{});
useEffect(()=>{ load(); },[]);
const share = async () => {
if (!username.trim()) return;
try { await api(`/tools/snippets/${snippet.id}/share`,{body:{username:username.trim()}}); toast('Geteilt ✓'); setUsername(''); load(); }
catch(e){ toast(e.message,'error'); }
};
const unshare = async uid => {
try { await api(`/tools/snippets/${snippet.id}/share/${uid}`,{method:'DELETE'}); setShares(p=>p.filter(s=>s.id!==uid)); }
catch(e){ toast(e.message,'error'); }
};
const isMob = window.innerWidth < 768;
return (
<div style={{position:'fixed',inset:0,background:'rgba(0,0,0,0.8)',zIndex:7000,
display:'flex',alignItems:isMob?'flex-end':'center',justifyContent:'center',padding:isMob?0:24}}
onClick={e=>e.target===e.currentTarget&&onClose()}>
<div style={{background:'#1a1a1e',borderRadius:isMob?'16px 16px 0 0':14,
width:'100%',maxWidth:400,padding:'20px 20px 28px',border:'1px solid rgba(255,255,255,0.12)'}}>
{isMob&&<div style={{width:36,height:4,background:'rgba(255,255,255,0.15)',borderRadius:2,margin:'0 auto 14px'}}/>}
<div style={{display:'flex',justifyContent:'space-between',alignItems:'center',marginBottom:12}}>
<div style={{color:'#fff',fontFamily:"'Space Mono',monospace",fontSize:13,fontWeight:700,
overflow:'hidden',textOverflow:'ellipsis',whiteSpace:'nowrap',flex:1}}>🤝 {snippet.title}</div>
<button onClick={onClose} style={{background:'transparent',border:'none',color:'rgba(255,255,255,0.4)',cursor:'pointer',fontSize:18,marginLeft:8}}></button>
</div>
<p style={{color:'rgba(255,255,255,0.35)',fontFamily:'monospace',fontSize:10,marginBottom:12,lineHeight:1.6}}>
Empfänger können lesen, aber nicht bearbeiten.
</p>
<div style={{display:'flex',gap:8,marginBottom:14}}>
<input value={username} onChange={e=>setUsername(e.target.value)} onKeyDown={e=>e.key==='Enter'&&share()}
placeholder="Benutzername" autoCapitalize="none" style={{...S.inp,flex:1}}/>
<button onClick={share} style={S.btn('#4ecdc4')}>Teilen</button>
</div>
{shares.length>0 ? shares.map(s=>(
<div key={s.id} style={{display:'flex',justifyContent:'space-between',alignItems:'center',
padding:'7px 0',borderBottom:'1px solid rgba(255,255,255,0.05)'}}>
<span style={{color:'rgba(255,255,255,0.7)',fontFamily:'monospace',fontSize:13}}>{s.username}</span>
<button onClick={()=>unshare(s.id)} style={S.btn('#ff6b9d',true)}></button>
</div>
)) : <div style={{color:'rgba(255,255,255,0.3)',fontFamily:'monospace',fontSize:11}}>Noch nicht geteilt.</div>}
</div>
</div>
);
}
// ── History Modal ─────────────────────────────────────────────────────────────
function HistoryModal({ snippet, onClose, onRestore, toast }) {
const [history, setHistory] = useState([]);
const [selected, setSelected] = useState(null);
useEffect(()=>{
api(`/tools/snippets/${snippet.id}/history`).then(setHistory).catch(()=>{});
},[]);
const isMob = window.innerWidth < 768;
return (
<div style={{position:'fixed',inset:0,background:'rgba(0,0,0,0.85)',zIndex:7000,
display:'flex',alignItems:isMob?'flex-end':'center',justifyContent:'center',padding:isMob?0:24}}
onClick={e=>e.target===e.currentTarget&&onClose()}>
<div style={{background:'#1a1a1e',borderRadius:isMob?'16px 16px 0 0':14,
width:'100%',maxWidth:640,border:'1px solid rgba(255,255,255,0.12)',
display:'flex',flexDirection:'column',maxHeight:isMob?'88vh':'80vh',overflow:'hidden'}}>
<div style={{padding:'16px 20px',borderBottom:'1px solid rgba(255,255,255,0.08)',
display:'flex',justifyContent:'space-between',alignItems:'center',flexShrink:0}}>
<div style={{color:'#fff',fontFamily:"'Space Mono',monospace",fontSize:14,fontWeight:700}}>
📜 Versionshistorie
</div>
<button onClick={onClose} style={{background:'transparent',border:'none',color:'rgba(255,255,255,0.4)',cursor:'pointer',fontSize:18}}></button>
</div>
{history.length===0 ? (
<div style={{padding:32,textAlign:'center',color:'rgba(255,255,255,0.3)',fontFamily:'monospace',fontSize:11}}>
Keine älteren Versionen vorhanden.
</div>
) : (
<div style={{display:'flex',flex:1,overflow:'hidden'}}>
{/* Liste */}
<div style={{width:160,borderRight:'1px solid rgba(255,255,255,0.07)',overflowY:'auto',flexShrink:0}}>
{history.map((h,i)=>(
<button key={h.id} onClick={()=>setSelected(h)} style={{
width:'100%',padding:'10px 12px',background: selected?.id===h.id?'rgba(78,205,196,0.1)':'transparent',
borderLeft:`2px solid ${selected?.id===h.id?'#4ecdc4':'transparent'}`,
border:'none',borderRight:'none',borderTop:'none',borderBottom:'1px solid rgba(255,255,255,0.05)',
cursor:'pointer',textAlign:'left',
}}>
<div style={{color:'rgba(255,255,255,0.6)',fontFamily:'monospace',fontSize:9}}>
Version {history.length-i}
</div>
<div style={{color:'rgba(255,255,255,0.35)',fontFamily:'monospace',fontSize:8,marginTop:2}}>
{fmtDt(h.saved_at)}
</div>
</button>
))}
</div>
{/* Preview */}
<div style={{flex:1,overflow:'auto',padding:14,display:'flex',flexDirection:'column',gap:10}}>
{selected ? (
<>
<div style={{display:'flex',alignItems:'center',justifyContent:'space-between'}}>
<span style={{color:'rgba(255,255,255,0.4)',fontFamily:'monospace',fontSize:10}}>
{getLangLabel(selected.language)} · {fmtDt(selected.saved_at)}
</span>
{!snippet.is_shared && (
<button onClick={()=>onRestore(selected)} style={{...S.btn('#ffe66d',true),fontSize:10}}>
Wiederherstellen
</button>
)}
</div>
<pre style={{margin:0,background:'rgba(0,0,0,0.4)',border:'1px solid rgba(255,255,255,0.08)',
borderRadius:8,padding:12,color:'#d0d0d0',fontFamily:"'Courier New',monospace",
fontSize:11,lineHeight:1.6,overflowX:'auto',whiteSpace:'pre-wrap',wordBreak:'break-all'}}>
{selected.code}
</pre>
</>
) : (
<div style={{color:'rgba(255,255,255,0.25)',fontFamily:'monospace',fontSize:11,margin:'auto'}}>
Version auswählen
</div>
)}
</div>
</div>
)}
</div>
</div>
);
}
// ── Snippet Card ──────────────────────────────────────────────────────────────
function SnippetCard({ snippet, onEdit, onDelete, onShare, onHistory, toast }) {
const [expanded, setExpanded] = useState(false);
const [copied, setCopied] = useState(false);
const langColor = LANG_COLORS[snippet.language] || LANG_COLORS.text;
const copy = async () => {
try { await navigator.clipboard.writeText(snippet.code); setCopied(true); setTimeout(()=>setCopied(false),1500); }
catch { toast('Kopieren fehlgeschlagen','error'); }
};
return (
<div style={{...S.card,marginBottom:10,padding:0,overflow:'hidden'}}>
{/* Header */}
<div onClick={()=>setExpanded(v=>!v)} style={{padding:'12px 14px',cursor:'pointer',
display:'flex',alignItems:'center',gap:10}}>
<div style={{width:8,height:8,borderRadius:'50%',background:langColor,flexShrink:0,
boxShadow:`0 0 6px ${langColor}80`}}/>
<div style={{flex:1,minWidth:0}}>
<div style={{display:'flex',alignItems:'center',gap:6,flexWrap:'wrap'}}>
<span style={{color:'#fff',fontFamily:'monospace',fontSize:13,fontWeight:700,
overflow:'hidden',textOverflow:'ellipsis',whiteSpace:'nowrap'}}>{snippet.title}</span>
{snippet.is_shared && (
<span style={{fontSize:9,fontFamily:'monospace',color:'rgba(78,205,196,0.6)',
background:'rgba(78,205,196,0.08)',border:'1px solid rgba(78,205,196,0.2)',
borderRadius:4,padding:'1px 5px',flexShrink:0}}>von {snippet.owner}</span>
)}
</div>
<div style={{display:'flex',alignItems:'center',gap:6,marginTop:2,flexWrap:'wrap'}}>
<span style={{fontSize:9,fontFamily:'monospace',color:langColor,opacity:0.8}}>
{getLangLabel(snippet.language)}
</span>
{snippet.tags?.map(t=>(
<span key={t} style={{fontSize:8,fontFamily:'monospace',
color:'rgba(255,255,255,0.4)',background:'rgba(255,255,255,0.05)',
border:'1px solid rgba(255,255,255,0.08)',borderRadius:4,padding:'1px 5px'}}>
{t}
</span>
))}
</div>
</div>
<span style={{color:'rgba(255,255,255,0.3)',fontSize:10,
transform:expanded?'rotate(90deg)':'rotate(0)',transition:'transform 0.2s',flexShrink:0}}></span>
</div>
{/* Expanded */}
{expanded && (
<div style={{borderTop:'1px solid rgba(255,255,255,0.06)'}}>
{snippet.description && (
<div style={{padding:'8px 14px',color:'rgba(255,255,255,0.45)',fontFamily:'monospace',fontSize:11,
borderBottom:'1px solid rgba(255,255,255,0.04)'}}>
{snippet.description}
</div>
)}
{/* Code */}
<div style={{position:'relative'}}>
<pre style={{margin:0,padding:'12px 14px',paddingRight:48,
color:'#d0d0d0',fontFamily:"'Courier New',Courier,monospace",
fontSize:12,lineHeight:1.65,overflowX:'auto',
background:'rgba(0,0,0,0.35)',whiteSpace:'pre',maxHeight:320,overflowY:'auto'}}>
{snippet.code}
</pre>
<button onClick={copy} title="Kopieren" style={{
position:'absolute',top:8,right:8,
background:'rgba(255,255,255,0.07)',border:'1px solid rgba(255,255,255,0.12)',
borderRadius:6,color: copied?'#4ecdc4':'rgba(255,255,255,0.5)',
cursor:'pointer',fontSize:13,padding:'4px 8px',transition:'color 0.2s',
}}>{copied?'✓':'⎘'}</button>
</div>
{/* Meta + Aktionen */}
<div style={{padding:'10px 14px',display:'flex',alignItems:'center',
justifyContent:'space-between',gap:8,flexWrap:'wrap',
borderTop:'1px solid rgba(255,255,255,0.05)'}}>
<div style={{color:'rgba(255,255,255,0.2)',fontFamily:'monospace',fontSize:9,lineHeight:1.7}}>
<div>erstellt: {fmtDt(snippet.created_at)}</div>
{snippet.updated_at !== snippet.created_at && (
<div>bearbeitet: {fmtDt(snippet.updated_at)}</div>
)}
</div>
<div style={{display:'flex',gap:5,flexWrap:'wrap'}}>
<button onClick={()=>onHistory(snippet)}
style={{...S.btn('rgba(255,255,255,0.3)',true),fontSize:10,padding:'4px 8px'}}>📜</button>
{!snippet.is_shared && (
<>
<button onClick={()=>onShare(snippet)}
style={{...S.btn('#ffe66d',true),fontSize:10,padding:'4px 8px'}}>🤝</button>
<button onClick={()=>onEdit(snippet)}
style={{...S.btn('#4ecdc4',true),fontSize:10,padding:'4px 8px'}}></button>
<button onClick={()=>onDelete(snippet.id)}
style={{...S.btn('#ff6b9d',true),fontSize:10,padding:'4px 8px'}}></button>
</>
)}
</div>
</div>
</div>
)}
</div>
);
}
// ── Snippet Form ──────────────────────────────────────────────────────────────
function SnippetForm({ initial, onSave, onCancel, toast }) {
const ALL_LANGS = ['text',...LANG_PATTERNS.map(l=>l.lang)];
const [title, setTitle] = useState(initial?.title || '');
const [code, setCode] = useState(initial?.code || '');
const [lang, setLang] = useState(initial?.language || 'text');
const [desc, setDesc] = useState(initial?.description || '');
const [tags, setTags] = useState(initial?.tags?.join(', ') || '');
const [busy, setBusy] = useState(false);
const [autoLang,setAutoLang]= useState(true);
// Auto-detect language as user types code
useEffect(()=>{
if (!autoLang) return;
const detected = detectLanguage(code);
setLang(detected);
},[code, autoLang]);
const parseTags = () => {
const langTag = lang !== 'text' ? [lang] : [];
const manual = tags.split(/[,\s]+/).map(t=>t.trim().toLowerCase()).filter(Boolean);
return [...new Set([...langTag, ...manual])];
};
const save = async () => {
if (!title.trim()) { toast('Titel erforderlich','error'); return; }
setBusy(true);
try {
await onSave({ title:title.trim(), code, language:lang, description:desc.trim(), tags:parseTags() });
} catch(e) { toast(e.message,'error'); }
setBusy(false);
};
return (
<div style={{...S.card,marginBottom:14}}>
<div style={{...S.head,marginBottom:10}}>{initial?.id ? 'BEARBEITEN' : 'NEUER SCHNIPSEL'}</div>
<input value={title} onChange={e=>setTitle(e.target.value)} placeholder="Titel *"
style={{...S.inp,marginBottom:8}}/>
<input value={desc} onChange={e=>setDesc(e.target.value)} placeholder="Beschreibung (optional)"
style={{...S.inp,marginBottom:8,fontSize:12}}/>
{/* Code */}
<textarea value={code} onChange={e=>setCode(e.target.value)} placeholder="Code einfügen…"
rows={8} style={{...S.inp,resize:'vertical',fontFamily:"'Courier New',monospace",
fontSize:12,lineHeight:1.6,marginBottom:8,width:'100%',boxSizing:'border-box'}}/>
{/* Sprache */}
<div style={{display:'flex',gap:8,marginBottom:8,alignItems:'center'}}>
<select value={lang} onChange={e=>{setLang(e.target.value);setAutoLang(false);}}
style={{...S.inp,flex:1,fontSize:12}}>
{ALL_LANGS.map(l=>(
<option key={l} value={l} style={{background:'#1a1a1e'}}>
{getLangLabel(l)}
</option>
))}
</select>
<label style={{display:'flex',alignItems:'center',gap:5,cursor:'pointer',flexShrink:0}}>
<input type="checkbox" checked={autoLang} onChange={e=>setAutoLang(e.target.checked)}
style={{accentColor:'#4ecdc4'}}/>
<span style={{color:'rgba(255,255,255,0.45)',fontFamily:'monospace',fontSize:10}}>Auto</span>
</label>
{lang!=='text'&&<div style={{width:10,height:10,borderRadius:'50%',
background:LANG_COLORS[lang]||'#fff',flexShrink:0}}/>}
</div>
{/* Tags */}
<div style={{marginBottom:12}}>
<label style={{...S.head,display:'block',marginBottom:4,fontSize:9}}>WEITERE TAGS (kommagetrennt)</label>
<input value={tags} onChange={e=>setTags(e.target.value)} placeholder="z.B. api, auth, utils"
autoCapitalize="none" style={{...S.inp,fontSize:12}}/>
{parseTags().length>0&&(
<div style={{display:'flex',gap:4,flexWrap:'wrap',marginTop:6}}>
{parseTags().map(t=>(
<span key={t} style={{fontSize:9,fontFamily:'monospace',
color:t===lang?LANG_COLORS[lang]:'rgba(255,255,255,0.5)',
background:'rgba(255,255,255,0.06)',borderRadius:4,padding:'2px 7px',
border:`1px solid ${t===lang?(LANG_COLORS[lang]+'40'):'rgba(255,255,255,0.1)'}`}}>
{t}
</span>
))}
</div>
)}
</div>
<div style={{display:'flex',gap:8}}>
<button onClick={save} disabled={busy}
style={{...S.btn('#4ecdc4'),flex:1,textAlign:'center',padding:'10px 0',opacity:busy?0.5:1}}>
{busy?'…':initial?.id?'✓ Aktualisieren':'↓ Speichern'}
</button>
<button onClick={onCancel} style={{...S.btn('#ff6b9d',true),padding:'0 16px'}}>Abbrechen</button>
</div>
</div>
);
}
// ── Main Component ────────────────────────────────────────────────────────────
export default function CodeSchnipsel({ toast, mobile }) {
const [own, setOwn] = useState([]);
const [shared, setShared] = useState([]);
const [tab, setTab] = useState('own');
const [search, setSearch] = useState('');
const [tagFilter, setTagFilter] = useState('');
const [showForm, setShowForm] = useState(false);
const [editItem, setEditItem] = useState(null);
const [shareItem, setShareItem] = useState(null);
const [histItem, setHistItem] = useState(null);
const load = useCallback(()=>{
api('/tools/snippets').then(d=>{ setOwn(d.own||[]); setShared(d.shared||[]); }).catch(()=>{});
},[]);
useEffect(()=>{ load(); },[load]);
const allTags = [...new Set([...own,...shared].flatMap(s=>s.tags||[]))].sort();
const filterItems = items => {
let res = items;
if (tagFilter) res = res.filter(s=>s.tags?.includes(tagFilter));
if (search.trim()) {
const q = search.toLowerCase();
res = res.filter(s =>
s.title.toLowerCase().includes(q) ||
s.code.toLowerCase().includes(q) ||
s.description?.toLowerCase().includes(q) ||
s.tags?.some(t=>t.includes(q)) ||
s.language.includes(q)
);
}
return res;
};
const items = filterItems(tab==='own' ? own : shared);
const handleSave = async form => {
if (editItem?.id) {
const r = await api(`/tools/snippets/${editItem.id}`,{method:'PUT',body:form});
setOwn(p=>p.map(s=>s.id===r.id?r:s)); setEditItem(null);
} else {
const r = await api('/tools/snippets',{body:form});
setOwn(p=>[r,...p]); setShowForm(false);
}
toast('Gespeichert ✓');
};
const handleDelete = async id => {
if (!window.confirm('Schnipsel wirklich löschen?')) return;
await api(`/tools/snippets/${id}`,{method:'DELETE'});
setOwn(p=>p.filter(s=>s.id!==id)); toast('Gelöscht');
};
const handleRestore = async (histEntry) => {
if (!histItem) return;
const r = await api(`/tools/snippets/${histItem.id}`,{method:'PUT',body:{
code: histEntry.code, language: histEntry.language
}});
setOwn(p=>p.map(s=>s.id===r.id?r:s));
setHistItem(null); toast('Version wiederhergestellt ✓');
};
return (
<div style={{padding:mobile?'14px 14px 90px':'36px 44px',maxWidth:860}}>
{shareItem && <SnippetShareModal snippet={shareItem} onClose={()=>{setShareItem(null);load();}} toast={toast}/>}
{histItem && <HistoryModal snippet={histItem} onClose={()=>setHistItem(null)} onRestore={handleRestore} toast={toast}/>}
{/* Header */}
<div style={{display:'flex',alignItems:'center',justifyContent:'space-between',marginBottom:14}}>
<h1 style={{color:'#fff',fontFamily:"'Space Mono',monospace",fontSize:mobile?17:22,margin:0}}>Code-Schnipsel</h1>
<div style={{display:'flex',gap:8}}>
<button onClick={load} title="Neu laden"
style={{background:'transparent',border:'1px solid rgba(255,255,255,0.1)',borderRadius:8,
color:'rgba(255,255,255,0.4)',cursor:'pointer',padding:'6px 10px',fontSize:14,fontFamily:'monospace'}}></button>
{!showForm && !editItem && tab==='own' && (
<button onClick={()=>setShowForm(true)}
style={{...S.btn('#4ecdc4'),padding:'7px 16px',fontSize:12}}>+ Neu</button>
)}
</div>
</div>
{/* Form */}
{(showForm || editItem) && (
<SnippetForm
initial={editItem}
onSave={handleSave}
onCancel={()=>{ setShowForm(false); setEditItem(null); }}
toast={toast}
/>
)}
{/* Tabs */}
<div style={{display:'flex',gap:6,marginBottom:12,flexWrap:'wrap'}}>
{[['own','Meine',own.length],['shared','Geteilt mit mir',shared.length]].map(([k,l,cnt])=>(
<button key={k} onClick={()=>{setTab(k);setSearch('');setTagFilter('');}} style={{
padding:'6px 14px',borderRadius:20,fontFamily:'monospace',fontSize:11,cursor:'pointer',
background:tab===k?'#4ecdc4':'rgba(255,255,255,0.05)',
color:tab===k?'#0d0d0f':'rgba(255,255,255,0.55)',
border:tab===k?'none':'1px solid rgba(255,255,255,0.1)',
fontWeight:tab===k?700:400,
}}>
{l}{cnt>0&&<span style={{marginLeft:4,opacity:0.7}}>({cnt})</span>}
</button>
))}
</div>
{/* Suche */}
<div style={{position:'relative',marginBottom:10}}>
<span style={{position:'absolute',left:12,top:'50%',transform:'translateY(-50%)',
color:'rgba(255,255,255,0.4)',pointerEvents:'none'}}></span>
<input value={search} onChange={e=>setSearch(e.target.value)} placeholder="Suchen (Titel, Code, Tags…)"
style={{...S.inp,paddingLeft:34}}/>
{search && <button onClick={()=>setSearch('')} style={{position:'absolute',right:10,top:'50%',
transform:'translateY(-50%)',background:'transparent',border:'none',
color:'rgba(255,255,255,0.4)',cursor:'pointer',fontSize:16}}></button>}
</div>
{/* Tag-Filter */}
{allTags.length>0 && (
<div style={{display:'flex',gap:5,flexWrap:'wrap',marginBottom:14}}>
<button onClick={()=>setTagFilter('')} style={{
fontSize:9,fontFamily:'monospace',padding:'3px 9px',borderRadius:20,cursor:'pointer',
background:!tagFilter?'rgba(78,205,196,0.15)':'rgba(255,255,255,0.04)',
border:`1px solid ${!tagFilter?'rgba(78,205,196,0.3)':'rgba(255,255,255,0.08)'}`,
color:!tagFilter?'#4ecdc4':'rgba(255,255,255,0.4)',
}}>Alle</button>
{allTags.map(t=>(
<button key={t} onClick={()=>setTagFilter(t===tagFilter?'':t)} style={{
fontSize:9,fontFamily:'monospace',padding:'3px 9px',borderRadius:20,cursor:'pointer',
background:tagFilter===t?(LANG_COLORS[t]?LANG_COLORS[t]+'20':'rgba(78,205,196,0.15)'):'rgba(255,255,255,0.04)',
border:`1px solid ${tagFilter===t?(LANG_COLORS[t]||'#4ecdc4')+'40':'rgba(255,255,255,0.08)'}`,
color:tagFilter===t?(LANG_COLORS[t]||'#4ecdc4'):'rgba(255,255,255,0.45)',
}}>{t}</button>
))}
</div>
)}
{/* Liste */}
{items.length===0 ? (
<div style={{...S.card,textAlign:'center',padding:40}}>
<div style={{fontSize:28,marginBottom:10}}>{'</>'}</div>
<div style={{color:'rgba(255,255,255,0.35)',fontFamily:'monospace',fontSize:12}}>
{search||tagFilter ? 'Keine Treffer.' : tab==='own' ? 'Noch keine Schnipsel füge deinen ersten hinzu.' : 'Noch nichts geteilt.'}
</div>
</div>
) : items.map(s=>(
<SnippetCard
key={s.id} snippet={s} toast={toast}
onEdit={setEditItem}
onDelete={handleDelete}
onShare={setShareItem}
onHistory={setHistItem}
/>
))}
</div>
);
}