Fix PWA-Reload: __BUILD_TIME__ und version.txt aus selber Quelle; Fix QuickLinkIcon custom icons

This commit is contained in:
2026-06-04 09:36:30 +02:00
parent 8946152012
commit 414fba39c7
4 changed files with 52 additions and 52 deletions

View File

@@ -4,6 +4,10 @@ WORKDIR /build
COPY frontend/package.json ./ COPY frontend/package.json ./
RUN npm install RUN npm install
COPY frontend/ ./ COPY frontend/ ./
# version.txt VOR dem Build generieren Unix-Timestamp (Sekunden)
# Dieser Wert wird von vite.config.js als __BUILD_TIME__ ins Bundle gebacken
# UND vom Backend als /api/build-time ausgeliefert → exakt derselbe Wert
RUN date -u +"%s" > ./version.txt
RUN npm run build RUN npm run build
# ── Stage 2: Express liefert API + React in einem Container ────────────────── # ── Stage 2: Express liefert API + React in einem Container ──────────────────
@@ -13,9 +17,8 @@ WORKDIR /app
COPY backend/package.json ./ COPY backend/package.json ./
RUN npm install --production RUN npm install --production
COPY backend/ ./ COPY backend/ ./
# version.txt bei jedem Build mit aktuellem Zeitstempel überschreiben # version.txt aus dem Build-Stage kopieren (selber Wert wie im JS-Bundle)
# → /api/build-time liefert neuen Wert → Frontend erkennt neuen Build → Auto-Reload COPY --from=builder /build/version.txt ./version.txt
RUN date -u +"build-%Y%m%d-%H%M%S" > ./version.txt
COPY --from=builder /build/dist ./public COPY --from=builder /build/dist ./public
EXPOSE 4000 EXPOSE 4000
CMD ["node", "src/index.js"] CMD ["node", "src/index.js"]

View File

@@ -455,17 +455,22 @@ function QuickLinksCarousel({ links }) {
function QuickLinkIcon({ l }) { function QuickLinkIcon({ l }) {
const [imgOk, setImgOk] = useState(true); const [imgOk, setImgOk] = useState(true);
// Favicon-URL bauen: gespeicherten Wert nehmen oder aus URL ableiten // Favicon nur wenn kein eigenes Icon gesetzt (Standard-🔗) oder bereits eine URL gespeichert ist
const useDefaultFavicon = !l.icon || l.icon === '🔗';
const faviconUrl = (() => { const faviconUrl = (() => {
if (l.icon && l.icon.startsWith('http')) return l.icon; if (l.icon && l.icon.startsWith('http')) return l.icon;
if (useDefaultFavicon) {
try { try {
const host = new URL(l.url).hostname; const host = new URL(l.url).hostname;
return `https://www.google.com/s2/favicons?sz=64&domain=${host}`; return `https://www.google.com/s2/favicons?sz=64&domain=${host}`;
} catch { return null; } } catch { return null; }
}
return null;
})(); })();
const showEmoji = !faviconUrl || !imgOk; const showImg = !!faviconUrl && imgOk;
const fallbackEmoji = l.icon && !l.icon.startsWith('http') ? l.icon : '🔗'; const showEmoji = !showImg;
const emoji = (l.icon && !l.icon.startsWith('http')) ? l.icon : '🔗';
return ( return (
<a href={l.url} target="_blank" rel="noopener noreferrer" <a href={l.url} target="_blank" rel="noopener noreferrer"
@@ -473,10 +478,10 @@ function QuickLinkIcon({ l }) {
padding:'4px 2px', background:'rgba(255,255,255,0.04)', padding:'4px 2px', background:'rgba(255,255,255,0.04)',
border:'1px solid rgba(255,255,255,0.08)', borderRadius:10, border:'1px solid rgba(255,255,255,0.08)', borderRadius:10,
textDecoration:'none', height:48, width:'100%', boxSizing:'border-box', cursor:'pointer' }}> textDecoration:'none', height:48, width:'100%', boxSizing:'border-box', cursor:'pointer' }}>
{!showEmoji {showImg
? <img src={faviconUrl} alt="" style={{width:18,height:18,objectFit:'contain'}} ? <img src={faviconUrl} alt="" style={{width:18,height:18,objectFit:'contain'}}
onError={() => setImgOk(false)}/> onError={() => setImgOk(false)}/>
: <span style={{ fontSize:16 }}>{fallbackEmoji}</span> : <span style={{ fontSize:16 }}>{emoji}</span>
} }
<span style={{ color:'rgba(255,255,255,0.65)', fontSize:8, fontFamily:'monospace', <span style={{ color:'rgba(255,255,255,0.65)', fontSize:8, fontFamily:'monospace',
maxWidth:52, overflow:'hidden', textOverflow:'ellipsis', whiteSpace:'nowrap' }}>{l.title}</span> maxWidth:52, overflow:'hidden', textOverflow:'ellipsis', whiteSpace:'nowrap' }}>{l.title}</span>

View File

@@ -3,17 +3,13 @@ import ReactDOM from 'react-dom/client'
import App from './App.jsx' import App from './App.jsx'
// ── Auto-Reload bei neuem Build ─────────────────────────────────────────────── // ── Auto-Reload bei neuem Build ───────────────────────────────────────────────
// Regeln: // __BUILD_TIME__ wird von Vite zur Build-Zeit aus version.txt gebacken.
// - Neuer Build wird erkannt → nur Flag setzen (window.__newBuildAvailable) // /api/build-time liefert denselben Wert vom Server.
// - Reload passiert NUR: // Wenn sie abweichen → neues Build wurde deployed → Reload.
// a) beim Öffnen der App (App kommt aus Hintergrund in den Vordergrund) // Kein localStorage, keine Race-Condition.
// b) beim Klick auf "Dashboard" (in App.jsx geprüft) const MY_BUILD_TIME = __BUILD_TIME__;
// - NIEMALS während einer Eingabe oder bei ungespeicherten Feldern
window.__newBuildAvailable = false; window.__newBuildAvailable = false;
const STORED_KEY = 'dd_build_time';
// Läuft gerade eine Eingabe? (Fokus auf Eingabefeld)
function isUserTyping() { function isUserTyping() {
const el = document.activeElement; const el = document.activeElement;
if (!el) return false; if (!el) return false;
@@ -21,7 +17,6 @@ function isUserTyping() {
return tag === 'INPUT' || tag === 'TEXTAREA' || tag === 'SELECT' || el.isContentEditable; return tag === 'INPUT' || tag === 'TEXTAREA' || tag === 'SELECT' || el.isContentEditable;
} }
// Stehen irgendwo ungespeicherte Eingaben in Textfeldern?
function hasUnsavedInput() { function hasUnsavedInput() {
const fields = document.querySelectorAll('input, textarea'); const fields = document.querySelectorAll('input, textarea');
for (const f of fields) { for (const f of fields) {
@@ -40,21 +35,20 @@ async function getBuildTime() {
} catch { return null; } } catch { return null; }
} }
// Prüft die Build-Zeit und setzt nur das Flag (lädt nie von selbst) // Prüft ob Server-Build != Bundle-Build → Flag setzen
async function checkBuild() { // reloadNow: sofort laden wenn kein Input aktiv (für App-Fokus)
const bt = await getBuildTime(); async function checkBuild({ reloadNow = false } = {}) {
if (!bt) return; const serverBt = await getBuildTime();
const stored = localStorage.getItem(STORED_KEY); if (!serverBt) return;
if (!stored) { if (serverBt !== MY_BUILD_TIME) {
localStorage.setItem(STORED_KEY, bt);
} else if (stored !== bt) {
localStorage.setItem(STORED_KEY, bt);
window.__newBuildAvailable = true; window.__newBuildAvailable = true;
if (reloadNow && !isUserTyping() && !hasUnsavedInput()) {
window.location.reload();
}
} }
} }
// Sicherer Reload: nur wenn neuer Build da UND keine Eingabe aktiv/ungespeichert // Sicherer Reload bei Seitenwechsel (aufgerufen aus App.jsx setActive)
// Wird von App.jsx beim Dashboard-Klick aufgerufen. Gibt true zurück wenn neu geladen wird.
window.__safeReloadIfNewBuild = function () { window.__safeReloadIfNewBuild = function () {
if (!window.__newBuildAvailable) return false; if (!window.__newBuildAvailable) return false;
if (isUserTyping() || hasUnsavedInput()) return false; if (isUserTyping() || hasUnsavedInput()) return false;
@@ -62,32 +56,26 @@ window.__safeReloadIfNewBuild = function () {
return true; return true;
}; };
// Beim Start einmal prüfen (App-Öffnen Fall 1: Kaltstart) // Beim Start prüfen (kein sofortiger Reload Seite lädt gerade erst)
checkBuild(); checkBuild();
// Im Hintergrund alle 60s nur das Flag aktualisieren // Im Hintergrund alle 60s
setInterval(() => checkBuild(), 60_000); setInterval(() => checkBuild(), 60_000);
// App-Öffnen Fall 2: App kommt aus dem Hintergrund zurück in den Vordergrund // App kommt in den Vordergrund → prüfen und bei Bedarf sofort laden
document.addEventListener('visibilitychange', async () => { // (PWA vom Homescreen öffnen / aus dem Hintergrund holen)
document.addEventListener('visibilitychange', () => {
if (document.visibilityState === 'visible') { if (document.visibilityState === 'visible') {
await checkBuild(); checkBuild({ reloadNow: true });
// Nur laden wenn der User nicht gerade tippt und nichts ungespeichert ist
if (!isUserTyping() && !hasUnsavedInput()) {
window.__safeReloadIfNewBuild();
}
} }
}); });
// ── Service Worker ──────────────────────────────────────────────────────────── // ── Service Worker ────────────────────────────────────────────────────────────
if ('serviceWorker' in navigator) { if ('serviceWorker' in navigator) {
navigator.serviceWorker.addEventListener('message', (e) => { navigator.serviceWorker.addEventListener('message', (e) => {
if (e.data?.type === 'SW_ACTIVATED') { if (e.data?.type === 'SW_ACTIVATED') window.__newBuildAvailable = true;
window.__newBuildAvailable = true; // nur merken
}
}); });
navigator.serviceWorker.register('/sw.js').then(reg => { navigator.serviceWorker.register('/sw.js').then(reg => {
// Browser regelmäßig nach neuem SW suchen lassen
setInterval(() => reg.update().catch(()=>{}), 60_000); setInterval(() => reg.update().catch(()=>{}), 60_000);
if (reg.waiting) reg.waiting.postMessage('SKIP_WAITING'); if (reg.waiting) reg.waiting.postMessage('SKIP_WAITING');
reg.addEventListener('updatefound', () => { reg.addEventListener('updatefound', () => {

View File

@@ -1,12 +1,16 @@
import { defineConfig } from 'vite' import { defineConfig } from 'vite'
import react from '@vitejs/plugin-react' import react from '@vitejs/plugin-react'
import { readFileSync } from 'fs'
const now = new Date(); // version.txt wird im Dockerfile VOR dem Build generiert (date +%s)
const buildTime = now.toLocaleString('de-DE', { // Damit haben __BUILD_TIME__ im Bundle und /api/build-time exakt denselben Wert
timeZone: 'Europe/Berlin', let buildTime;
day: '2-digit', month: '2-digit', year: 'numeric', try {
hour: '2-digit', minute: '2-digit' buildTime = readFileSync('./version.txt', 'utf8').trim();
}); } catch {
// Fallback für lokale Entwicklung ohne Dockerfile
buildTime = String(Date.now());
}
export default defineConfig({ export default defineConfig({
plugins: [react()], plugins: [react()],