diff --git a/backend/src/db.js b/backend/src/db.js index 7c536af..032ebf2 100644 --- a/backend/src/db.js +++ b/backend/src/db.js @@ -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; diff --git a/backend/src/index.js b/backend/src/index.js index 38ff8de..56c58ad 100644 --- a/backend/src/index.js +++ b/backend/src/index.js @@ -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) => { diff --git a/backend/src/tools/snippets/routes.js b/backend/src/tools/snippets/routes.js new file mode 100644 index 0000000..f36b9ac --- /dev/null +++ b/backend/src/tools/snippets/routes.js @@ -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; diff --git a/frontend/src/icons.jsx b/frontend/src/icons.jsx index 04216af..4943e1e 100644 --- a/frontend/src/icons.jsx +++ b/frontend/src/icons.jsx @@ -141,3 +141,7 @@ export const LinkIcon = ({size=20,color='currentColor',sw}) => base(<> , size, color, sw); +export const CodeIcon = ({size=20,color='currentColor',sw}) => base(<> + + +, size, color, sw); diff --git a/frontend/src/toolRegistry.js b/frontend/src/toolRegistry.js index 0c09386..52a3b5a 100644 --- a/frontend/src/toolRegistry.js +++ b/frontend/src/toolRegistry.js @@ -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:... }, ]; diff --git a/frontend/src/tools/codeschnipsel.jsx b/frontend/src/tools/codeschnipsel.jsx new file mode 100644 index 0000000..10643d4 --- /dev/null +++ b/frontend/src/tools/codeschnipsel.jsx @@ -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)|)\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:[//] }, + { 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 ( +
e.target===e.currentTarget&&onClose()}> +
+ {isMob&&
} +
+
🤝 {snippet.title}
+ +
+

+ Empfänger können lesen, aber nicht bearbeiten. +

+
+ setUsername(e.target.value)} onKeyDown={e=>e.key==='Enter'&&share()} + placeholder="Benutzername" autoCapitalize="none" style={{...S.inp,flex:1}}/> + +
+ {shares.length>0 ? shares.map(s=>( +
+ {s.username} + +
+ )) :
Noch nicht geteilt.
} +
+
+ ); +} + +// ── 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 ( +
e.target===e.currentTarget&&onClose()}> +
+
+
+ 📜 Versionshistorie +
+ +
+ {history.length===0 ? ( +
+ Keine älteren Versionen vorhanden. +
+ ) : ( +
+ {/* Liste */} +
+ {history.map((h,i)=>( + + ))} +
+ {/* Preview */} +
+ {selected ? ( + <> +
+ + {getLangLabel(selected.language)} · {fmtDt(selected.saved_at)} + + {!snippet.is_shared && ( + + )} +
+
+                    {selected.code}
+                  
+ + ) : ( +
+ Version auswählen +
+ )} +
+
+ )} +
+
+ ); +} + +// ── 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 ( +
+ {/* Header */} +
setExpanded(v=>!v)} style={{padding:'12px 14px',cursor:'pointer', + display:'flex',alignItems:'center',gap:10}}> +
+
+
+ {snippet.title} + {snippet.is_shared && ( + von {snippet.owner} + )} +
+
+ + {getLangLabel(snippet.language)} + + {snippet.tags?.map(t=>( + + {t} + + ))} +
+
+ +
+ + {/* Expanded */} + {expanded && ( +
+ {snippet.description && ( +
+ {snippet.description} +
+ )} + + {/* Code */} +
+
+              {snippet.code}
+            
+ +
+ + {/* Meta + Aktionen */} +
+
+
erstellt: {fmtDt(snippet.created_at)}
+ {snippet.updated_at !== snippet.created_at && ( +
bearbeitet: {fmtDt(snippet.updated_at)}
+ )} +
+
+ + {!snippet.is_shared && ( + <> + + + + + )} +
+
+
+ )} +
+ ); +} + +// ── 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 ( +
+
{initial?.id ? 'BEARBEITEN' : 'NEUER SCHNIPSEL'}
+ + setTitle(e.target.value)} placeholder="Titel *" + style={{...S.inp,marginBottom:8}}/> + setDesc(e.target.value)} placeholder="Beschreibung (optional)" + style={{...S.inp,marginBottom:8,fontSize:12}}/> + + {/* Code */} +