Files
dickendock/frontend/src/tools/codeschnipsel.jsx

702 lines
35 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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>
);
}
// ── Simple Diff ───────────────────────────────────────────────────────────────
function DiffView({ oldCode, newCode }) {
if (!oldCode) return (
<pre style={{margin:0,padding:'12px 14px',color:'#d0d0d0',fontFamily:"'Courier New',monospace",
fontSize:11,lineHeight:1.6,whiteSpace:'pre-wrap',wordBreak:'break-all',
background:'rgba(0,0,0,0.35)'}}>
{newCode}
</pre>
);
const oldLines = oldCode.split('\n');
const newLines = newCode.split('\n');
const maxLen = Math.max(oldLines.length, newLines.length);
const rows = [];
for (let i = 0; i < maxLen; i++) {
const o = oldLines[i];
const n = newLines[i];
if (o === n) {
rows.push({ type:'same', text: n ?? '' });
} else {
if (o !== undefined) rows.push({ type:'del', text: o });
if (n !== undefined) rows.push({ type:'add', text: n });
}
}
return (
<div style={{background:'rgba(0,0,0,0.4)',border:'1px solid rgba(255,255,255,0.08)',
borderRadius:8,overflow:'auto',maxHeight:320,fontFamily:"'Courier New',monospace",fontSize:11,lineHeight:1.6}}>
{rows.map((r,i) => (
<div key={i} style={{
padding:'0 12px',whiteSpace:'pre-wrap',wordBreak:'break-all',
background: r.type==='add'?'rgba(78,205,196,0.12)':r.type==='del'?'rgba(255,107,157,0.12)':'transparent',
borderLeft:`2px solid ${r.type==='add'?'#4ecdc4':r.type==='del'?'#ff6b9d':'transparent'}`,
}}>
<span style={{color: r.type==='add'?'#4ecdc4':r.type==='del'?'rgba(255,107,157,0.7)':'rgba(255,255,255,0.2)',
marginRight:6,userSelect:'none',fontSize:10}}>
{r.type==='add'?'+':r.type==='del'?'':' '}
</span>
<span style={{color: r.type==='same'?'#d0d0d0':'#fff'}}>{r.text}</span>
</div>
))}
</div>
);
}
function HistoryModal({ snippet, onClose, onRestore, toast }) {
const [history, setHistory] = useState([]);
const [selectedIdx, setSelectedIdx] = useState(0); // 0 = Aktuell (current)
const [showDiff, setShowDiff] = useState(true);
const isMob = window.innerWidth < 768;
const load = () => api(`/tools/snippets/${snippet.id}/history`).then(setHistory).catch(()=>{});
useEffect(()=>{ load(); },[]);
// Entries: [current, ...history] where index 0 = current
const entries = [
{ id:'current', code: snippet.code, language: snippet.language,
saved_at: snippet.updated_at, label:'Aktuell', isCurrent: true },
...history.map((h,i) => ({ ...h, label:`v${history.length - i}`, isCurrent: false })),
];
// Check if current code matches a history entry (= was restored)
const restoredFromIdx = history.findIndex(h => h.code === snippet.code);
const restoredFromLabel = restoredFromIdx >= 0 ? `v${history.length - restoredFromIdx}` : null;
const selected = entries[selectedIdx];
const prevEntry = entries[selectedIdx + 1];
const oldCode = prevEntry?.code ?? null;
const delHistory = async (hid) => {
if (!window.confirm('Diese Version wirklich löschen?')) return;
try {
await api(`/tools/snippets/${snippet.id}/history/${hid}`, { method:'DELETE' });
await load();
setSelectedIdx(0);
toast('Version gelöscht');
} catch(e) { toast(e.message,'error'); }
};
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:700,border:'1px solid rgba(255,255,255,0.12)',
display:'flex',flexDirection:'column',maxHeight:isMob?'88vh':'82vh',overflow:'hidden'}}>
{/* Header */}
<div style={{padding:'14px 20px',borderBottom:'1px solid rgba(255,255,255,0.08)',
display:'flex',justifyContent:'space-between',alignItems:'center',flexShrink:0,gap:10}}>
<div style={{color:'#fff',fontFamily:"'Space Mono',monospace",fontSize:13,fontWeight:700}}>
📜 Versionshistorie · {snippet.title}
</div>
<div style={{display:'flex',gap:6,alignItems:'center'}}>
<button onClick={()=>setShowDiff(v=>!v)} style={{
background:showDiff?'rgba(78,205,196,0.12)':'rgba(255,255,255,0.05)',
border:`1px solid ${showDiff?'rgba(78,205,196,0.3)':'rgba(255,255,255,0.1)'}`,
borderRadius:6,color:showDiff?'#4ecdc4':'rgba(255,255,255,0.4)',
cursor:'pointer',fontSize:9,fontFamily:'monospace',padding:'3px 8px'}}>
{showDiff?'± Diff':'📄 Code'}
</button>
<button onClick={onClose} style={{background:'transparent',border:'none',
color:'rgba(255,255,255,0.4)',cursor:'pointer',fontSize:18}}></button>
</div>
</div>
{entries.length <= 1 ? (
<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'}}>
{/* Versions-Liste */}
<div style={{width:155,borderRight:'1px solid rgba(255,255,255,0.07)',overflowY:'auto',flexShrink:0}}>
{entries.map((e,i) => (
<div key={e.id} style={{
display:'flex',alignItems:'center',
background: i===selectedIdx?'rgba(78,205,196,0.08)':'transparent',
borderLeft:`2px solid ${i===selectedIdx?'#4ecdc4':'transparent'}`,
borderBottom:'1px solid rgba(255,255,255,0.04)',
}}>
<button onClick={()=>setSelectedIdx(i)} style={{
flex:1,padding:'10px 10px',background:'transparent',
border:'none',cursor:'pointer',textAlign:'left',
}}>
<div style={{color: e.isCurrent?'#4ecdc4':'rgba(255,255,255,0.65)',
fontFamily:'monospace',fontSize:10,fontWeight: e.isCurrent?700:400}}>
{e.label}
{e.isCurrent&&<span style={{color:'rgba(78,205,196,0.5)',fontSize:8,marginLeft:4}}></span>}
{e.isCurrent && restoredFromLabel && (
<div style={{color:'rgba(255,230,109,0.7)',fontSize:8,fontWeight:400,marginTop:2}}>
aus {restoredFromLabel}
</div>
)}
</div>
<div style={{color:'rgba(255,255,255,0.25)',fontFamily:'monospace',fontSize:8,marginTop:2}}>
{fmtDt(e.saved_at)}
</div>
</button>
{/* Löschen nur für Altversionen, nicht Aktuell */}
{!e.isCurrent && !snippet.is_shared && (
<button onClick={()=>delHistory(e.id)}
title="Version löschen"
style={{background:'transparent',border:'none',cursor:'pointer',
padding:'0 8px',color:'rgba(255,107,157,0.35)',fontSize:12,
flexShrink:0,lineHeight:1}}></button>
)}
</div>
))}
</div>
{/* Detail */}
<div style={{flex:1,overflow:'auto',padding:14,display:'flex',flexDirection:'column',gap:10}}>
{selected && (
<>
<div style={{display:'flex',alignItems:'center',justifyContent:'space-between',flexWrap:'wrap',gap:6}}>
<div style={{display:'flex',gap:6,alignItems:'center',flexWrap:'wrap'}}>
<span style={{color:'rgba(255,255,255,0.35)',fontFamily:'monospace',fontSize:10}}>
{getLangLabel(selected.language)} · {fmtDt(selected.saved_at)}
</span>
{showDiff && oldCode===null && (
<span style={{color:'rgba(255,255,255,0.2)',fontFamily:'monospace',fontSize:9}}>älteste Version kein Diff verfügbar</span>
)}
{showDiff && oldCode!==null && prevEntry && (
<span style={{color:'rgba(255,255,255,0.2)',fontFamily:'monospace',fontSize:9}}>
Änderungen gegenüber {prevEntry.label}
</span>
)}
</div>
{/* Wiederherstellen nur für Altversionen die vom aktuellen abweichen */}
{!selected.isCurrent && !snippet.is_shared && selected.code !== snippet.code && (
<button onClick={()=>onRestore(selected)} style={{
background:'rgba(255,230,109,0.1)',border:'1px solid rgba(255,230,109,0.25)',
borderRadius:6,color:'#ffe66d',cursor:'pointer',fontSize:10,padding:'4px 10px'}}>
Wiederherstellen
</button>
)}
</div>
{showDiff && oldCode!==null
? <DiffView oldCode={oldCode} newCode={selected.code}/>
: <pre style={{margin:0,padding:'12px 14px',color:'#d0d0d0',
fontFamily:"'Courier New',monospace",fontSize:11,lineHeight:1.6,
background:'rgba(0,0,0,0.35)',border:'1px solid rgba(255,255,255,0.08)',
borderRadius:8,overflowX:'auto',whiteSpace:'pre-wrap',wordBreak:'break-all',
maxHeight:380,overflowY:'auto'}}>
{selected.code}
</pre>
}
</>
)}
</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>
{snippet.description && (
<div style={{color:'rgba(255,255,255,0.38)',fontFamily:'monospace',fontSize:10,
marginTop:2,overflow:'hidden',textOverflow:'ellipsis',whiteSpace:'nowrap'}}>
{snippet.description}
</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={{background:'rgba(255,255,255,0.07)',border:'1px solid rgba(255,255,255,0.12)',
borderRadius:6,color:'rgba(255,255,255,0.5)',cursor:'pointer',
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, existingTags=[] }) {
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?.filter(t=>t!==initial?.language).join(', ') || '');
const [busy, setBusy] = useState(false);
const [autoLang,setAutoLang]= useState(!initial?.id);
useEffect(()=>{
if (!autoLang) return;
const detected = detectLanguage(code);
setLang(detected);
},[code, autoLang]);
// Nur Komma als Trennzeichen
const parseTags = () => {
const langTag = lang !== 'text' ? [lang] : [];
const manual = tags.split(',').map(t=>t.trim().toLowerCase()).filter(Boolean);
return [...new Set([...langTag, ...manual])];
};
const addSuggestedTag = tag => {
const cur = tags.split(',').map(t=>t.trim()).filter(Boolean);
if (cur.includes(tag)) return;
setTags(cur.length ? cur.join(', ') + ', ' + tag : tag);
};
// Tags die noch nicht eingetragen sind als Vorschläge
const currentParsed = parseTags();
const suggestions = existingTags.filter(t => t !== lang && !currentParsed.includes(t));
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}}/>
{/* Vorhandene Tags als Vorschläge */}
{suggestions.length>0 && (
<div style={{marginTop:6}}>
<div style={{color:'rgba(255,255,255,0.25)',fontFamily:'monospace',fontSize:8,marginBottom:4}}>VORHANDENE TAGS HINZUFÜGEN:</div>
<div style={{display:'flex',gap:4,flexWrap:'wrap'}}>
{suggestions.map(t=>(
<button key={t} onClick={()=>addSuggestedTag(t)} style={{
fontSize:9,fontFamily:'monospace',padding:'2px 8px',borderRadius:12,cursor:'pointer',
background:'rgba(255,255,255,0.04)',border:'1px solid rgba(255,255,255,0.1)',
color:'rgba(255,255,255,0.4)',
}}>+ {t}</button>
))}
</div>
</div>
)}
{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 [sharedByMe,setSharedByMe]= useState([]);
const [tab, setTab] = useState('own');
const [search, setSearch] = useState('');
const [tagFilters,setTagFilters]= useState([]); // multi-select array
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||[]); setSharedByMe(d.sharedByMe||[]); }).catch(()=>{});
},[]);
useEffect(()=>{ load(); },[load]);
// Only tags that are currently in use
const allItems = [...own, ...shared];
const allTags = [...new Set(allItems.flatMap(s=>s.tags||[]))].sort();
const toggleTag = t => setTagFilters(p => p.includes(t) ? p.filter(x=>x!==t) : [...p, t]);
const filterItems = items => {
let res = items;
if (tagFilters.length) res = res.filter(s => tagFilters.every(t=>s.tags?.includes(t)));
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 curList = tab==='own' ? own : tab==='shared' ? shared : sharedByMe;
const items = filterItems(curList);
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 und alle seine Versionen 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}
existingTags={allTags}
/>
)}
{/* Tabs */}
<div style={{display:'flex',gap:6,marginBottom:12,flexWrap:'wrap'}}>
{[['own','Meine',own.length],['shared','Geteilt mit mir',shared.length],['sharedByMe','Geteilt von mir',sharedByMe.length]].map(([k,l,cnt])=>(
<button key={k} onClick={()=>{setTab(k);setSearch('');setTagFilters([]);}} 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 (Mehrfachauswahl) */}
{allTags.length>0 && (
<div style={{display:'flex',gap:5,flexWrap:'wrap',marginBottom:14,alignItems:'center'}}>
<button onClick={()=>setTagFilters([])} style={{
fontSize:9,fontFamily:'monospace',padding:'3px 9px',borderRadius:20,cursor:'pointer',
background:!tagFilters.length?'rgba(78,205,196,0.15)':'rgba(255,255,255,0.04)',
border:`1px solid ${!tagFilters.length?'rgba(78,205,196,0.3)':'rgba(255,255,255,0.08)'}`,
color:!tagFilters.length?'#4ecdc4':'rgba(255,255,255,0.4)',
}}>Alle</button>
{allTags.map(t=>{
const active = tagFilters.includes(t);
const c = LANG_COLORS[t] || '#4ecdc4';
return (
<button key={t} onClick={()=>toggleTag(t)} style={{
fontSize:9,fontFamily:'monospace',padding:'3px 9px',borderRadius:20,cursor:'pointer',
background:active?`${c}20`:'rgba(255,255,255,0.04)',
border:`1px solid ${active?`${c}50`:'rgba(255,255,255,0.08)'}`,
color:active?c:'rgba(255,255,255,0.45)',
}}>{active?'✓ ':''}{t}</button>
);
})}
{tagFilters.length>0 && (
<span style={{color:'rgba(255,255,255,0.25)',fontFamily:'monospace',fontSize:9,marginLeft:2}}>
({tagFilters.length} aktiv)
</span>
)}
</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||tagFilters.length ? '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>
);
}