702 lines
35 KiB
JavaScript
702 lines
35 KiB
JavaScript
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>
|
||
);
|
||
}
|