Code-Schnipsel: neue Funktion unter Werkzeuge
This commit is contained in:
@@ -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;
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
126
backend/src/tools/snippets/routes.js
Normal file
126
backend/src/tools/snippets/routes.js
Normal 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;
|
||||
@@ -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);
|
||||
|
||||
@@ -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:... },
|
||||
];
|
||||
|
||||
|
||||
534
frontend/src/tools/codeschnipsel.jsx
Normal file
534
frontend/src/tools/codeschnipsel.jsx
Normal 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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user