diff --git a/CHANGELOG.md b/CHANGELOG.md index 64d0a77..c9a261f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,19 @@ Das Format folgt [Keep a Changelog](https://keepachangelog.com/de/1.1.0/), die V ## [Unreleased] +## [0.41.0] - 2026-11-01 + +### Added +- Hinweis bei neuer Version: Wurde der Server aktualisiert, erscheint in der offenen App ein Fenster „Neue Version verfügbar“ mit „Jetzt aktualisieren“ (lädt die Seite neu) und „Später“. Geprüft wird beim Zurückkehren in den Tab bzw. die App, alle 15 Minuten und nach einem Neustart des Servers. So bleibt auch die installierte App am Handy nicht auf einem alten Stand. +- Nach dem Neuladen der Seite bleibt der zuletzt offene Bereich (Triage, Workspace, Aufgaben, Listen, Einstellungen) offen, statt immer zur Triage zu springen. + +### Changed +- Spracheingabe mit eigenem Whisper-Server: Quick-Dump-Leiste, Erfassen-Fenster am Handy und Halte-Knopf schicken die Aufnahme schon während des Sprechens in Stücken zur Erkennung (wie das Diktat). Der Text erscheint dadurch beim Sprechen, nach dem Stoppen fehlt nur noch das letzte Stück. +- Quick Dump: Das gewählte Ziel (z. B. „Direkt als Aufgabe“ in eine bestimmte Liste) bleibt nach dem Abschicken eingestellt. +- Quick Dump: Die gewählte Liste ist nicht mehr voll in der Akzentfarbe hinterlegt, sondern umrandet, damit die Emojis darauf gut zu sehen sind. +- Emoji-Auswähler am PC: kompakter wie ein Kontextmenü (kleinere Emojis und Schrift, ohne Titelleiste, schließt per Klick daneben oder Escape), mit eigener, abgehobener Fläche. Seine Farben folgen jetzt in allen Themes dem Theme (im dunklen Theme war er bisher grau). „Icon entfernen“ steht bei Notizen als Zeile unten. +- Workspace: Der Hinweis „n markiert · in einen Ordner oder auf den Papierkorb ziehen“ beim Markieren mehrerer Notizen entfällt. Aufheben geht weiter mit Escape oder einem Klick daneben. + ## [0.40.1] - 2026-11-01 ### Fixed @@ -673,7 +686,8 @@ Das Format folgt [Keep a Changelog](https://keepachangelog.com/de/1.1.0/), die V - Geteilte Listen (Einkaufs-, Pack- und Checklisten) mit `#Kategorie`-Gruppierung, Mengenangaben und Echtzeit-Abhaken. - Benutzer-, Gruppen- und Admin-Verwaltung (Ersteinrichtungs-Assistent, Einladungscodes, Profilbilder, persönliche API-Keys & Webhook-Ingest `/api/v1/webhooks/ingest`, JSON-Datenexport) sowie CSS-Theme-Engine mit 4 Themes (`Dark`, `Light`, `High Contrast`, `Soft`) und PWA-Unterstützung. -[Unreleased]: https://git.dsit.me/sandrodach/MindSift/compare/v0.40.1...dev +[Unreleased]: https://git.dsit.me/sandrodach/MindSift/compare/v0.41.0...dev +[0.41.0]: https://git.dsit.me/sandrodach/MindSift/compare/v0.40.1...v0.41.0 [0.40.1]: https://git.dsit.me/sandrodach/MindSift/compare/v0.40.0...v0.40.1 [0.40.0]: https://git.dsit.me/sandrodach/MindSift/compare/v0.39.0...v0.40.0 [0.39.0]: https://git.dsit.me/sandrodach/MindSift/compare/v0.38.1...v0.39.0 diff --git a/README.md b/README.md index 7c9ac85..9042e5a 100644 --- a/README.md +++ b/README.md @@ -6,7 +6,7 @@ [![Self-Hosted](https://img.shields.io/badge/100%25-Self--Hosted-2563eb?style=flat-square)](#) [![Docker](https://img.shields.io/badge/Deployment-Single%20Container-2496ed?style=flat-square&logo=docker&logoColor=white)](#-schnellstart) [![SQLite](https://img.shields.io/badge/Daten-SQLite-003b57?style=flat-square&logo=sqlite&logoColor=white)](#-deine-daten-gehören-dir) -[![Version](https://img.shields.io/badge/Version-v0.40.1-16a34a?style=flat-square)](CHANGELOG.md) +[![Version](https://img.shields.io/badge/Version-v0.41.0-16a34a?style=flat-square)](CHANGELOG.md) **MindSift** ist eine selbst gehostete Schaltstelle für spontane Gedanken, Aufgaben mit Kanban-Board, hierarchische Outliner-Notizen, die mehrere Personen gleichzeitig bearbeiten können, und gemeinsame Listen. Alles läuft als ein einzelner Container mit eingebetteter SQLite-Datenbank, auf deinem eigenen Server. diff --git a/frontend/package-lock.json b/frontend/package-lock.json index 2cfd793..d5c237d 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -1,12 +1,12 @@ { "name": "mindsift-frontend", - "version": "0.40.1", + "version": "0.41.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "mindsift-frontend", - "version": "0.40.1", + "version": "0.41.0", "license": "Unlicense", "dependencies": { "@fontsource-variable/inter": "5.3.0", diff --git a/frontend/package.json b/frontend/package.json index 87eaf90..2f3a4d4 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -1,7 +1,7 @@ { "name": "mindsift-frontend", "private": true, - "version": "0.40.1", + "version": "0.41.0", "license": "Unlicense", "type": "module", "scripts": { diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 03a7a85..dccc598 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -73,18 +73,14 @@ import { CommandPalette } from './components/CommandPalette'; import { AppFooter } from './components/AppFooter'; import { ThemeQuickPicker } from './components/ThemeQuickPicker'; import { AboutModal } from './components/AboutModal'; +import { UpdateAvailableModal } from './components/UpdateAvailableModal'; +import { useUpdateCheck } from './lib/useUpdateCheck'; import { consumePendingShares, formatSharedThought } from './lib/shareTarget'; import { APP_VERSION } from './version'; +import { readViewSession, writeViewSession } from './lib/viewSession'; -type ActiveTab = - | 'triage' - | 'tasks' - | 'workspace' - | 'lists' - | 'groups' - | 'user_settings' - | 'admin_settings' - | 'help'; +const APP_TABS = ['triage', 'tasks', 'workspace', 'lists', 'groups', 'user_settings', 'admin_settings', 'help'] as const; +type ActiveTab = (typeof APP_TABS)[number]; interface ErrorBoundaryState { hasError: boolean; @@ -170,7 +166,15 @@ export const App: React.FC = () => { }); const [deck, setDeck] = useState([]); const [lists, setLists] = useState([]); - const [activeTab, setActiveTab] = useState('triage'); + // The open tab survives a reload of this browser tab (lib/viewSession); a new tab starts at the triage + const [activeTab, setActiveTab] = useState(() => { + const tab = readViewSession<{ tab: ActiveTab }>('app').tab; + return tab && APP_TABS.includes(tab) ? tab : 'triage'; + }); + useEffect(() => writeViewSession('app', { tab: activeTab }), [activeTab]); + useEffect(() => { + if (user && user.role !== 'admin' && activeTab === 'admin_settings') setActiveTab('triage'); + }, [user, activeTab]); const [previousTab, setPreviousTab] = useState('triage'); const [selectedDocId, setSelectedDocId] = useState(null); const [selectedBlockId, setSelectedBlockId] = useState(null); @@ -184,6 +188,7 @@ export const App: React.FC = () => { const [showProfileMenu, setShowProfileMenu] = useState(false); const serverOnline = useServerOnline(); const [showAbout, setShowAbout] = useState(false); + const { update: availableUpdate, dismiss: dismissUpdate } = useUpdateCheck(); const dropdownRef = useRef(null); // Mobile Navigation & Quick-Action State @@ -197,6 +202,7 @@ export const App: React.FC = () => { const mobileBaseVoiceRef = useRef(''); // Voice input of the mobile capture sheet: own Whisper server or the browser (lib/voice.ts) const mobileVoice = useVoiceInput({ + chunked: true, onText: (text) => { if (!text) return; const base = mobileBaseVoiceRef.current; @@ -1877,6 +1883,7 @@ export const App: React.FC = () => { /> {showAbout && setShowAbout(false)} />} + {availableUpdate && } ); }; diff --git a/frontend/src/components/EmojiIconButton.tsx b/frontend/src/components/EmojiIconButton.tsx index 9174f26..f3053e9 100644 --- a/frontend/src/components/EmojiIconButton.tsx +++ b/frontend/src/components/EmojiIconButton.tsx @@ -4,7 +4,7 @@ import { EmojiPickerModal } from '../features/workspace/EmojiPickerModal'; interface EmojiIconButtonProps { value: string; onChange: (emoji: string) => void; - // Default icon; removing the icon in the picker goes back to it + // Shown while no icon is chosen fallback: string; } @@ -29,8 +29,9 @@ export const EmojiIconButton: React.FC = ({ value, onChang { - onChange(emoji || fallback); + onChange(emoji); setAnchor(null); }} onClose={() => setAnchor(null)} diff --git a/frontend/src/components/UpdateAvailableModal.tsx b/frontend/src/components/UpdateAvailableModal.tsx new file mode 100644 index 0000000..cbf6fe3 --- /dev/null +++ b/frontend/src/components/UpdateAvailableModal.tsx @@ -0,0 +1,51 @@ +import React from 'react'; +import { RefreshCw } from 'lucide-react'; +import { APP_VERSION } from '../version'; +import type { AvailableUpdate } from '../lib/useUpdateCheck'; + +// Shown when the server got a new MindSift (lib/useUpdateCheck): reloading fetches the new app (the +// service worker loads pages from the network first). +export const UpdateAvailableModal: React.FC<{ update: AvailableUpdate; onLater: () => void }> = ({ update, onLater }) => ( +
+
e.stopPropagation()} + className="w-full max-w-sm panel-surface shadow-panel p-5 space-y-4" + > +
+
+ +
+
+

+ Neue Version verfügbar +

+

+ Der Server wurde aktualisiert + {update.version && update.version !== APP_VERSION ? ( + <> + {' '}auf {update.version} + + ) : null} + . Lade die Seite neu, um sie zu nutzen; ungespeicherte Eingaben in offenen Feldern gehen dabei verloren. +

+
+
+
+ + +
+
+
+); diff --git a/frontend/src/features/capture/HoldToRecordFab.tsx b/frontend/src/features/capture/HoldToRecordFab.tsx index c26eb44..f9aca3f 100644 --- a/frontend/src/features/capture/HoldToRecordFab.tsx +++ b/frontend/src/features/capture/HoldToRecordFab.tsx @@ -80,9 +80,10 @@ export const HoldToRecordFab: React.FC = ({ onTap, onTrans } }; - // Own Whisper server (text after release) or the browser's recognition (text while speaking) + // Own Whisper server (text in chunks while speaking) or the browser's recognition (text while speaking) const voice = useVoiceInput({ continuous: true, + chunked: true, onText: (text, final) => { transcriptRef.current = text; if (!final) { diff --git a/frontend/src/features/capture/InstantCaptureBar.tsx b/frontend/src/features/capture/InstantCaptureBar.tsx index a133917..9a46533 100644 --- a/frontend/src/features/capture/InstantCaptureBar.tsx +++ b/frontend/src/features/capture/InstantCaptureBar.tsx @@ -70,6 +70,7 @@ export const InstantCaptureBar: React.FC = ({ // Voice input: own Whisper server or the browser's recognition (lib/voice.ts) const voice = useVoiceInput({ + chunked: true, onText: (text, final) => { const base = baseVoiceTextRef.current; if (text) setContent(base ? `${base} ${text}` : text); @@ -206,7 +207,7 @@ export const InstantCaptureBar: React.FC = ({ setIsMultiline(false); setPriority('normal'); setDueDate(''); - setTarget(fixedTarget || 'inbox'); + // Target and list stay for the next dump; only priority and due date belong to one entry setShowOptions(defaultShowOptions || hideTargetPicker); setIsSubmitting(true); triggerHaptic(15); @@ -544,7 +545,7 @@ export const InstantCaptureBar: React.FC = ({ }} className={`min-h-[30px] px-2.5 border rounded-btn text-xs flex items-center gap-1.5 transition-colors cursor-pointer ${ isSelected - ? 'bg-accent text-on-accent border-accent font-medium' + ? 'bg-accent/10 text-main border-accent font-medium' : 'bg-canvas border-border text-muted hover:text-main hover:bg-panel-hover' }`} > @@ -578,7 +579,7 @@ export const InstantCaptureBar: React.FC = ({ }} className={`min-h-[30px] px-2.5 border rounded-btn text-xs flex items-center gap-1.5 transition-colors cursor-pointer ${ isSelected - ? 'bg-accent text-on-accent border-accent font-medium' + ? 'bg-accent/10 text-main border-accent font-medium' : 'bg-canvas border-border text-muted hover:text-main hover:bg-panel-hover' }`} > diff --git a/frontend/src/features/workspace/EmojiPickerModal.tsx b/frontend/src/features/workspace/EmojiPickerModal.tsx index 664ab4c..0a5ab61 100644 --- a/frontend/src/features/workspace/EmojiPickerModal.tsx +++ b/frontend/src/features/workspace/EmojiPickerModal.tsx @@ -29,6 +29,8 @@ interface EmojiPickerModalProps { // Inside a dialog (which clips and transforms its content): the picker is put on the page instead and // opens below this button on desktop anchor?: DOMRect | null; + // Offer "Icon entfernen" (notes; a create dialog only has its default icon to go back to) + allowRemove?: boolean; } export const EmojiPickerModal: React.FC = ({ @@ -36,11 +38,12 @@ export const EmojiPickerModal: React.FC = ({ onSelectEmoji, onClose, anchor, + allowRemove = true, }) => { const { inset, visibleHeight } = useKeyboardInset(); const isPhone = typeof window !== 'undefined' && window.innerWidth < 640; // On phones the picker shrinks with the keyboard so search field and results stay visible - const pickerHeight = isPhone ? Math.max(200, Math.min(350, visibleHeight - 110)) : 350; + const pickerHeight = isPhone ? Math.max(200, Math.min(350, visibleHeight - 110)) : 300; const [isDark, setIsDark] = useState(() => typeof document !== 'undefined' ? document.documentElement.classList.contains('dark') @@ -98,8 +101,8 @@ export const EmojiPickerModal: React.FC = ({ const anchorStyle = anchor ? ({ - '--pick-left': `${Math.max(12, Math.min(anchor.left, window.innerWidth - 372))}px`, - '--pick-top': `${Math.min(anchor.bottom + 4, window.innerHeight - 532)}px`, + '--pick-left': `${Math.max(12, Math.min(anchor.left, window.innerWidth - 312))}px`, + '--pick-top': `${Math.min(anchor.bottom + 4, window.innerHeight - 360)}px`, } as React.CSSProperties) : undefined; @@ -117,7 +120,7 @@ export const EmojiPickerModal: React.FC = ({ ref={containerRef} className={` fixed inset-x-0 bottom-[var(--kb-inset)] rounded-t-2xl max-h-[var(--vv-max)] bg-panel border-t border-border shadow-2xl flex flex-col p-3 space-y-3 - sm:inset-auto sm:w-[360px] sm:max-h-[520px] sm:rounded-card sm:border sm:border-border sm:shadow-xl ${ + sm:inset-auto sm:w-[300px] sm:max-h-[360px] sm:p-1 sm:space-y-0 sm:bg-panel-elevated sm:rounded-card sm:border sm:border-border sm:shadow-xl ${ anchor ? 'z-[61] sm:fixed sm:left-[var(--pick-left)] sm:top-[max(12px,var(--pick-top))]' : 'z-50 sm:absolute sm:left-0 sm:top-full sm:mt-1 sm:z-40' @@ -126,17 +129,10 @@ export const EmojiPickerModal: React.FC = ({ style={ { ...anchorStyle, - // Inject MindSift CSS variables into emoji-picker-react for seamless theming - '--epr-bg-color': 'var(--bg-panel)', - '--epr-category-label-bg-color': 'var(--bg-panel)', - '--epr-picker-border-color': 'var(--border-color)', - '--epr-text-color': 'var(--text-main)', - '--epr-search-input-bg-color': 'var(--bg-app)', - '--epr-search-input-bg-color-active': 'var(--bg-panel-elevated)', - '--epr-hover-bg-color': 'var(--bg-panel-elevated)', - '--epr-focus-bg-color': 'var(--bg-panel-elevated)', - '--epr-highlight-color': 'var(--accent-primary)', - '--epr-category-icon-active-color': 'var(--accent-primary)', + // Theme colours for the picker (index.css); desktop: its own raised surface, so it stands apart + // from the dialog it opens from + '--pick-surface': isPhone ? 'var(--bg-panel)' : 'var(--bg-panel-elevated)', + '--pick-hover': isPhone ? 'var(--bg-panel-elevated)' : 'var(--bg-panel-hover)', '--kb-inset': `${inset}px`, '--vv-max': `${Math.round(visibleHeight * 0.9)}px`, } as React.CSSProperties @@ -145,8 +141,8 @@ export const EmojiPickerModal: React.FC = ({ {/* Mobile handle indicator */}
- {/* Top Header: Title, Actions & Close */} -
+ {/* Top Header: Title, Actions & Close (phone only; on desktop a click outside closes it) */} +
Icon auswählen {currentIcon && ( @@ -155,7 +151,7 @@ export const EmojiPickerModal: React.FC = ({
- {currentIcon && ( + {currentIcon && allowRemove && (
+ + {/* Desktop: removing the icon as a quiet last row, like an entry of a context menu */} + {currentIcon && allowRemove && ( + + )}
); diff --git a/frontend/src/features/workspace/WorkspaceView.tsx b/frontend/src/features/workspace/WorkspaceView.tsx index 1f6b1fa..c9f62a4 100644 --- a/frontend/src/features/workspace/WorkspaceView.tsx +++ b/frontend/src/features/workspace/WorkspaceView.tsx @@ -1891,20 +1891,6 @@ export const WorkspaceView: React.FC = ({ if (e.target === e.currentTarget && markedDocIds.size > 0) setMarkedDocIds(new Set()); }} > - {markedDocIds.size > 0 && ( -
- - {markedDocIds.size} markiert · in einen Ordner oder auf den Papierkorb ziehen - - -
- )} {/* Journal on top; open, it becomes a panel with the calendar (click the heading to close). Panel and calendar animate open (grid rows 0fr → 1fr) instead of jumping. Only from lg on: below, the journal is a view of its own and the button stays a plain entry like the folders diff --git a/frontend/src/index.css b/frontend/src/index.css index bc3a91f..a376849 100644 --- a/frontend/src/index.css +++ b/frontend/src/index.css @@ -538,6 +538,29 @@ body { .mindsift-emoji-picker .EmojiPickerReact { border: none !important; font-family: inherit !important; + /* Theme colours. The library sets its own (dark: #222 …) on this element, so they are overridden here, + for the light and the dark variant; --pick-surface / --pick-hover come from EmojiPickerModal. */ + --epr-bg-color: var(--pick-surface); + --epr-dark-bg-color: var(--pick-surface); + --epr-category-label-bg-color: var(--pick-surface); + --epr-dark-category-label-bg-color: var(--pick-surface); + --epr-picker-border-color: var(--border-color); + --epr-dark-picker-border-color: var(--border-color); + --epr-text-color: var(--text-main); + --epr-dark-text-color: var(--text-main); + --epr-search-input-text-color: var(--text-main); + --epr-search-input-bg-color: var(--bg-input); + --epr-dark-search-input-bg-color: var(--bg-input); + --epr-search-input-bg-color-active: var(--bg-input); + --epr-dark-search-input-bg-color-active: var(--bg-input); + --epr-hover-bg-color: var(--pick-hover); + --epr-dark-hover-bg-color: var(--pick-hover); + --epr-focus-bg-color: var(--pick-hover); + --epr-dark-focus-bg-color: var(--pick-hover); + --epr-highlight-color: var(--accent-primary); + --epr-dark-highlight-color: var(--accent-primary); + --epr-category-icon-active-color: var(--accent-primary); + --epr-dark-category-icon-active-color: var(--accent-primary); } @media (max-width: 639px) { @@ -561,6 +584,28 @@ body { } } +@media (min-width: 640px) { + /* Desktop: compact like a context menu, in step with the app's small type (the sprite of the category + icons scales with its size variable) */ + .mindsift-emoji-picker .EmojiPickerReact { + --epr-emoji-size: 22px; + --epr-emoji-padding: 4px; + --epr-horizontal-padding: 8px; + --epr-header-padding: 8px 8px 6px; + --epr-search-input-height: 30px; + --epr-search-input-border-radius: var(--radius-btn, 6px); + --epr-category-navigation-button-size: 22px; + --epr-category-label-height: 24px; + --epr-category-label-padding: 0 8px; + } + .mindsift-emoji-picker .EmojiPickerReact input[type='text'] { + font-size: 12px !important; + } + .mindsift-emoji-picker .EmojiPickerReact .epr-emoji-category-label { + font-size: 11px !important; + } +} + /* Read-only sections (e.g. server settings in the demo): everything inside looks switched off */ fieldset:disabled :is(button, input, select, textarea) { opacity: 0.5; diff --git a/frontend/src/lib/useUpdateCheck.ts b/frontend/src/lib/useUpdateCheck.ts new file mode 100644 index 0000000..64980aa --- /dev/null +++ b/frontend/src/lib/useUpdateCheck.ts @@ -0,0 +1,56 @@ +import { useEffect, useRef, useState } from 'react'; +import { APP_BUILD } from '../version'; + +const CHECK_EVERY_MS = 15 * 60 * 1000; + +export interface AvailableUpdate { + version: string; + build: string; +} + +// Notices when the server runs a newer build than this page (version.json, written at build time and +// never cached): when the tab or the installed app comes back to the foreground, every 15 minutes and +// when the realtime connection is back after a server restart. "Später" hides it until the next build. +export function useUpdateCheck() { + const [update, setUpdate] = useState(null); + const dismissedRef = useRef(''); + + useEffect(() => { + if (!APP_BUILD) return; // dev server: no version.json + let busy = false; + const check = async () => { + if (busy || document.visibilityState === 'hidden') return; + busy = true; + try { + const res = await fetch(`/version.json?t=${Date.now()}`, { cache: 'no-store' }); + const live = (await res.json()) as Partial; + if (live.build && live.build !== APP_BUILD && live.build !== dismissedRef.current) { + setUpdate({ version: live.version || '', build: live.build }); + } + } catch { + // offline, or an older server without version.json (answers with the page): try again later + } finally { + busy = false; + } + }; + const onVisible = () => document.visibilityState === 'visible' && void check(); + const onWsStatus = (e: Event) => (e as CustomEvent<{ connected?: boolean }>).detail?.connected && void check(); + const timer = window.setInterval(() => void check(), CHECK_EVERY_MS); + document.addEventListener('visibilitychange', onVisible); + window.addEventListener('focus', onVisible); + window.addEventListener('mindsift:ws:status', onWsStatus); + void check(); + return () => { + window.clearInterval(timer); + document.removeEventListener('visibilitychange', onVisible); + window.removeEventListener('focus', onVisible); + window.removeEventListener('mindsift:ws:status', onWsStatus); + }; + }, []); + + const dismiss = () => { + if (update) dismissedRef.current = update.build; + setUpdate(null); + }; + return { update, dismiss }; +} diff --git a/frontend/src/lib/useVoiceInput.ts b/frontend/src/lib/useVoiceInput.ts index f44fe75..54ce8d4 100644 --- a/frontend/src/lib/useVoiceInput.ts +++ b/frontend/src/lib/useVoiceInput.ts @@ -12,6 +12,8 @@ export interface VoiceInputOptions { continuous?: boolean; /** Live dictation mode (/diktat): auto-restart browser speech recognition on pause, and chunk server recordings every ~8s or ~0.8s of silence. */ live?: boolean; + /** Server mode only: send the recording in chunks like live mode, so the text arrives while speaking (Quick Dump). */ + chunked?: boolean; } const LIVE_CHUNK_MS = 8000; @@ -25,7 +27,8 @@ const MIN_RESTART_INTERVAL_MS = 300; * is made depends on the server setting, see lib/voice.ts. mode is null until known, "off" or * "unsupported" means: do not show a microphone. */ -export function useVoiceInput({ onText, onError, continuous = false, live = false }: VoiceInputOptions) { +export function useVoiceInput({ onText, onError, continuous = false, live = false, chunked = false }: VoiceInputOptions) { + const chunkServer = live || chunked; const [mode, setMode] = useState(null); const [state, setState] = useState('idle'); const [pendingChunks, setPendingChunks] = useState(0); @@ -149,14 +152,14 @@ export function useVoiceInput({ onText, onError, continuous = false, live = fals } const rec = recorderRef.current; if (!rec) { - if (live && state === 'listening') { + if (chunkServer && state === 'listening') { setStateSafe('idle'); onTextRef.current(transcriptRef.current, true); } return; } recorderRef.current = null; - if (live) { + if (chunkServer) { const idx = chunkIndexRef.current++; setStateSafe('transcribing'); processSegmentPromise(idx, rec.stop()); @@ -179,7 +182,7 @@ export function useVoiceInput({ onText, onError, continuous = false, live = fals } // fail only touches refs and state setters // eslint-disable-next-line react-hooks/exhaustive-deps - }, [live, state]); + }, [chunkServer, state]); const startServer = async () => { const rec = new VoiceRecorder(); @@ -201,7 +204,7 @@ export function useVoiceInput({ onText, onError, continuous = false, live = fals return; } timerRef.current = window.setTimeout(() => void stop(), maxSecondsRef.current * 1000); - if (live) { + if (chunkServer) { let segmentMs = 0; let silentMs = 0; let hadSpeech = false; @@ -325,7 +328,7 @@ export function useVoiceInput({ onText, onError, continuous = false, live = fals else if (current === 'browser') startBrowser(); else fail(current === 'off' ? 'Spracheingabe ist ausgeschaltet' : 'Spracheingabe wird von diesem Browser nicht unterstützt'); // eslint-disable-next-line react-hooks/exhaustive-deps - }, [mode, continuous, live]); + }, [mode, continuous, live, chunkServer]); const cancel = useCallback(() => { cancelledRef.current = true; diff --git a/frontend/src/version.ts b/frontend/src/version.ts index 510a313..e97f24b 100644 --- a/frontend/src/version.ts +++ b/frontend/src/version.ts @@ -2,3 +2,8 @@ declare const __APP_VERSION__: string; /** Version shown in the header; set at build time in vite.config.ts. */ export const APP_VERSION = typeof __APP_VERSION__ !== 'undefined' ? __APP_VERSION__ : 'dev'; + +declare const __APP_BUILD__: string; + +/** ID of this build; version.json on the server names the build that is live now. */ +export const APP_BUILD = typeof __APP_BUILD__ !== 'undefined' ? __APP_BUILD__ : ''; diff --git a/frontend/vite.config.ts b/frontend/vite.config.ts index ae41eef..1e8cc97 100644 --- a/frontend/vite.config.ts +++ b/frontend/vite.config.ts @@ -6,11 +6,22 @@ import { readFileSync } from 'node:fs'; // other builds show the package version with a "-dev" suffix (Semantic Versioning, see CHANGELOG.md). const pkg = JSON.parse(readFileSync(new URL('./package.json', import.meta.url), 'utf8')); const appVersion = process.env.APP_VERSION || `v${pkg.version}-dev`; +// Every build gets its own ID; open pages compare it with version.json to offer a reload (lib/useUpdateCheck) +const appBuild = `${Date.now()}`; export default defineConfig({ - plugins: [react()], + plugins: [ + react(), + { + name: 'mindsift-version-json', + generateBundle() { + this.emitFile({ type: 'asset', fileName: 'version.json', source: JSON.stringify({ version: appVersion, build: appBuild }) }); + }, + }, + ], define: { __APP_VERSION__: JSON.stringify(appVersion), + __APP_BUILD__: JSON.stringify(appBuild), }, server: { port: 5173, diff --git a/internal/api/server.go b/internal/api/server.go index e16eb16..4397fd3 100644 --- a/internal/api/server.go +++ b/internal/api/server.go @@ -366,7 +366,7 @@ func (s *Server) Router() http.Handler { req.URL.Path = "/" path = "index.html" } - if path == "index.html" || path == "sw.js" || path == "manifest.json" { + if path == "index.html" || path == "sw.js" || path == "manifest.json" || path == "version.json" { w.Header().Set("Cache-Control", "no-store, no-cache, must-revalidate") w.Header().Set("Pragma", "no-cache") w.Header().Set("Expires", "0")