diff --git a/frontend/public/sw.js b/frontend/public/sw.js index f31782d..965692c 100644 --- a/frontend/public/sw.js +++ b/frontend/public/sw.js @@ -1,24 +1,56 @@ -// Service Worker – erkennt neue Builds und lädt automatisch neu -// BUILD_TIME wird beim Deploy vom Backend via /api/build-time geliefert +// Service Worker – Network-first für HTML, kein Cache für Assets +// Stellt sicher dass nach einem Deploy immer der neue Stand geladen wird -const CACHE_NAME = 'dickendock-v1'; +const CACHE_NAME = 'dickendock-v2'; self.addEventListener('install', () => { - self.skipWaiting(); // sofort aktivieren + self.skipWaiting(); // sofort aktivieren, nicht auf Tab-Schließen warten }); self.addEventListener('activate', e => { - // Alte Caches löschen + // Alle alten Caches löschen e.waitUntil( caches.keys() .then(keys => Promise.all(keys.map(k => caches.delete(k)))) - .then(() => self.clients.claim()) + .then(() => self.clients.claim()) // alle offenen Tabs übernehmen ); }); -// Alle Requests direkt ans Netzwerk – kein Caching self.addEventListener('fetch', e => { - e.respondWith(fetch(e.request)); + const url = new URL(e.request.url); + + // API-Requests immer direkt ans Netzwerk, nie cachen + if (url.pathname.startsWith('/api/')) { + e.respondWith(fetch(e.request)); + return; + } + + // index.html und Navigation: Network-first, kein Cache + // Damit nach einem Deploy immer die neue Version geladen wird + if (e.request.mode === 'navigate' || + url.pathname === '/' || + url.pathname === '/index.html') { + e.respondWith( + fetch(e.request, { cache: 'no-store' }) + .catch(() => caches.match('/index.html')) // Fallback wenn offline + ); + return; + } + + // JS/CSS/Assets: Network-first mit Cache-Fallback + // Vite baut mit Content-Hash im Dateinamen → neue Version = neue URL + e.respondWith( + fetch(e.request) + .then(response => { + // Erfolgreiche Antwort kurz cachen als Offline-Fallback + if (response.ok) { + const clone = response.clone(); + caches.open(CACHE_NAME).then(cache => cache.put(e.request, clone)); + } + return response; + }) + .catch(() => caches.match(e.request)) + ); }); // Auf Update-Nachricht vom Frontend reagieren diff --git a/frontend/src/main.jsx b/frontend/src/main.jsx index e8e9e9d..84e380f 100644 --- a/frontend/src/main.jsx +++ b/frontend/src/main.jsx @@ -2,7 +2,7 @@ import React from 'react' import ReactDOM from 'react-dom/client' import App from './App.jsx' -// ── Auto-Reload bei neuem Build – nur beim Seitenwechsel ───────────────────── +// ── Auto-Reload bei neuem Build ─────────────────────────────────────────────── const STORED_KEY = 'dd_build_time'; let newBuildAvailable = false; @@ -15,7 +15,7 @@ async function checkBuildTime() { localStorage.setItem(STORED_KEY, buildTime); } else if (stored !== buildTime) { localStorage.setItem(STORED_KEY, buildTime); - newBuildAvailable = true; // merken, aber noch nicht reloaden + newBuildAvailable = true; } } catch {} } @@ -32,17 +32,25 @@ export function reloadIfNewBuild() { // ── Service Worker ──────────────────────────────────────────────────────────── if ('serviceWorker' in navigator) { navigator.serviceWorker.register('/sw.js').then(reg => { - if (reg.waiting) reg.waiting.postMessage('SKIP_WAITING'); + // Falls schon ein wartender SW da ist → sofort aktivieren + if (reg.waiting) { + reg.waiting.postMessage('SKIP_WAITING'); + } + reg.addEventListener('updatefound', () => { const nw = reg.installing; nw?.addEventListener('statechange', () => { - if (nw.state === 'installed' && navigator.serviceWorker.controller) { + if (nw.state === 'installed') { + // Neuer SW installiert → sofort aktivieren nw.postMessage('SKIP_WAITING'); } }); }); + + // Wenn SW-Controller gewechselt hat → sofort neu laden + // (nicht erst beim nächsten Seitenwechsel warten) navigator.serviceWorker.addEventListener('controllerchange', () => { - newBuildAvailable = true; // SW-Update → beim nächsten Wechsel laden + window.location.reload(); }); }); } diff --git a/frontend/src/tools/devtools.jsx b/frontend/src/tools/devtools.jsx index d99c32c..1b62d05 100644 --- a/frontend/src/tools/devtools.jsx +++ b/frontend/src/tools/devtools.jsx @@ -1896,12 +1896,319 @@ function RegexBuilder({ toast, mobile }) { } +// ──────────────────────────────────────────────────────────── JSON Tool ── + +// ── JSON Viewer: rekursiver Baum mit auf/zuklappen ── +function JsonNode({ data, depth, keyName }) { + const [open, setOpen] = useState(depth < 2); + const indent = depth * 16; + const isObj = data !== null && typeof data === 'object' && !Array.isArray(data); + const isArr = Array.isArray(data); + const isPrim = !isObj && !isArr; + + const typeColor = { + string: '#a8f0eb', + number: '#ffe66d', + boolean: '#ff6b9d', + null: 'rgba(255,255,255,0.3)', + }; + + function valColor(v) { + if (v === null) return typeColor.null; + return typeColor[typeof v] || '#fff'; + } + + function valLabel(v) { + if (v === null) return 'null'; + if (typeof v === 'string') return `"${v}"`; + return String(v); + } + + function typeTag(v) { + if (v === null) return 'null'; + if (Array.isArray(v)) return `Array[${v.length}]`; + if (typeof v === 'object') return `Object{${Object.keys(v).length}}`; + return typeof v; + } + + const keyStyle = {color:'#7eb8ff', fontFamily:"'Courier New',monospace", fontSize:12}; + const lineBase = {display:'flex', alignItems:'baseline', gap:4, padding:'1px 0', + marginLeft:indent, lineHeight:1.7}; + + if (isPrim) { + return ( +
+ {keyName !== undefined && "{keyName}"} + {keyName !== undefined && :} + + {valLabel(data)} + + {typeof data} +
+ ); + } + + const entries = isArr ? data.map((v,i)=>[i,v]) : Object.entries(data); + const bracket = isArr ? ['[',']'] : ['{','}']; + + return ( +
+
setOpen(o=>!o)}> + + {open ? '▾' : '▸'} + + {keyName !== undefined && "{keyName}"} + {keyName !== undefined && :} + + {bracket[0]} + + {!open && ( + + {isArr ? `${entries.length} Einträge` : `${entries.length} Felder`} + + )} + {!open && {bracket[1]}} + + {typeTag(data)} + +
+ {open && ( + <> + {entries.map(([k,v]) => ( + + ))} +
+ {bracket[1]} +
+ + )} +
+ ); +} + +// ── JSON Builder: Baumstruktur erstellen ── +let _nodeId = 1; +function newNode(type='string', key='', value='') { + return { id: _nodeId++, type, key, value, children:[] }; +} + +function BuilderNode({ node, depth, onChange, onDelete, onAddSibling }) { + const indent = depth * 20; + const canHaveChildren = node.type==='object' || node.type==='array'; + + function addChild(type) { + const child = newNode(type); + onChange({...node, children:[...node.children, child]}); + } + + function updateChild(updated) { + onChange({...node, children: node.children.map(c => c.id===updated.id ? updated : c)}); + } + + function deleteChild(id) { + onChange({...node, children: node.children.filter(c => c.id!==id)}); + } + + const inp = {...S.inp, fontSize:12, padding:'4px 7px', boxSizing:'border-box'}; + const typeColors = {object:'#4ecdc4', array:'#ffe66d', string:'#a8f0eb', number:'#ff6b9d', boolean:'#c3b1e1', null:'rgba(255,255,255,0.3)'}; + + return ( +
+
+ {/* Key (only for object children) */} + {depth > 0 && node.type !== '__root' && ( + onChange({...node, key:e.target.value})}/> + )} + {depth > 0 && :} + {/* Type */} + + {/* Value for primitives */} + {node.type==='string' && onChange({...node, value:e.target.value})}/>} + {node.type==='number' && onChange({...node, value:e.target.value})}/>} + {node.type==='boolean' && ( + + )} + {/* Add child button */} + {canHaveChildren && ( + + )} + {/* Delete */} + {depth > 0 && ( + + )} +
+ {/* Children */} + {canHaveChildren && node.children.map(child=>( + deleteChild(child.id)}/> + ))} +
+ ); +} + +function nodeToJson(node) { + if (node.type==='object') { + const obj = {}; + node.children.forEach(c => { obj[c.key || `field${c.id}`] = nodeToJson(c); }); + return obj; + } + if (node.type==='array') { + return node.children.map(c => nodeToJson(c)); + } + if (node.type==='number') return isNaN(Number(node.value)) ? 0 : Number(node.value); + if (node.type==='boolean') return node.value !== 'false'; + if (node.type==='null') return null; + return node.value; +} + +function JsonTool({ toast, mobile }) { + const [tab, setTab] = useState('view'); + // View tab + const [jsonInput, setJsonInput] = useState(''); + const [parsed, setParsed] = useState(null); + const [parseErr, setParseErr] = useState(''); + + function parseJson(val) { + setJsonInput(val); + if (!val.trim()) { setParsed(null); setParseErr(''); return; } + try { + setParsed(JSON.parse(val)); + setParseErr(''); + } catch(e) { + setParsed(null); + setParseErr(e.message); + } + } + + function formatJson() { + try { + const p = JSON.parse(jsonInput); + setJsonInput(JSON.stringify(p, null, 2)); + setParsed(p); + setParseErr(''); + } catch {} + } + + // Build tab + const [root, setRoot] = useState(() => ({...newNode('object'), key:'root'})); + const generatedJson = JSON.stringify(nodeToJson(root), null, 2); + const [copiedJson, setCopiedJson] = useState(false); + + async function copyJson(text) { + try { await navigator.clipboard.writeText(text); setCopiedJson(true); setTimeout(()=>setCopiedJson(false),1800); } + catch { toast('Kopieren fehlgeschlagen'); } + } + + const inp = {...S.inp, fontSize:13, padding:'7px 9px', boxSizing:'border-box'}; + const tabs = [{id:'view',label:'👁 JSON ansehen'},{id:'build',label:'🔨 JSON bauen'}]; + + return ( +
+
+ {tabs.map(t=>( + + ))} +
+ + {tab==='view' && ( +
+
+
JSON einfügen
+