feat: update notice after a server update, keep the open tab on reload, chunked Whisper in Quick Dump, compact emoji picker
MindSift CI & Docker Release / Build, Test in Container & Push (push) Successful in 2m31s

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
sandrodachandClaude Opus 5.5 committed 2026-11-01 15:09:21 +01:00
1 parent 20b907078f
commit 55112b940c
17 files changed
+250 -61

No files matched your search

+2 -2
View File
@@ -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",
+1 -1
View File
@@ -1,7 +1,7 @@
{
"name": "mindsift-frontend",
"private": true,
"version": "0.40.1",
"version": "0.41.0",
"license": "Unlicense",
"type": "module",
"scripts": {
+17 -10
View File
@@ -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<Thought[]>([]);
const [lists, setLists] = useState<SharedList[]>([]);
const [activeTab, setActiveTab] = useState<ActiveTab>('triage');
// The open tab survives a reload of this browser tab (lib/viewSession); a new tab starts at the triage
const [activeTab, setActiveTab] = useState<ActiveTab>(() => {
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<ActiveTab>('triage');
const [selectedDocId, setSelectedDocId] = useState<string | null>(null);
const [selectedBlockId, setSelectedBlockId] = useState<string | null>(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<HTMLDivElement | null>(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 && <AboutModal onClose={() => setShowAbout(false)} />}
{availableUpdate && <UpdateAvailableModal update={availableUpdate} onLater={dismissUpdate} />}
</div>
);
};
+3 -2
View File
@@ -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<EmojiIconButtonProps> = ({ value, onChang
<EmojiPickerModal
currentIcon={value}
anchor={anchor}
allowRemove={false}
onSelectEmoji={(emoji) => {
onChange(emoji || fallback);
onChange(emoji);
setAnchor(null);
}}
onClose={() => setAnchor(null)}
@@ -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 }) => (
<div className="fixed inset-0 !m-0 z-[70] modal-overlay flex items-center justify-center p-4" onClick={onLater}>
<div
role="dialog"
aria-modal="true"
aria-labelledby="update-title"
onClick={(e) => e.stopPropagation()}
className="w-full max-w-sm panel-surface shadow-panel p-5 space-y-4"
>
<div className="flex items-start gap-3">
<div className="w-9 h-9 bg-accent text-on-accent rounded-btn flex items-center justify-center shrink-0">
<RefreshCw className="w-4 h-4" />
</div>
<div className="min-w-0 space-y-1">
<h3 id="update-title" className="text-sm font-semibold text-main">
Neue Version verfügbar
</h3>
<p className="text-xs text-muted leading-relaxed">
Der Server wurde aktualisiert
{update.version && update.version !== APP_VERSION ? (
<>
{' '}auf <span className="font-mono text-main">{update.version}</span>
</>
) : null}
. Lade die Seite neu, um sie zu nutzen; ungespeicherte Eingaben in offenen Feldern gehen dabei verloren.
</p>
</div>
</div>
<div className="flex gap-2 sm:justify-end">
<button type="button" onClick={onLater} className="btn-action flex-1 sm:flex-none h-11 sm:h-8 px-3 text-sm sm:text-xs">
Später
</button>
<button
type="button"
autoFocus
onClick={() => window.location.reload()}
className="btn-primary flex-1 sm:flex-none h-11 sm:h-8 px-4 text-sm sm:text-xs font-semibold"
>
Jetzt aktualisieren
</button>
</div>
</div>
</div>
);
@@ -80,9 +80,10 @@ export const HoldToRecordFab: React.FC<HoldToRecordFabProps> = ({ 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) {
@@ -70,6 +70,7 @@ export const InstantCaptureBar: React.FC<InstantCaptureBarProps> = ({
// 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<InstantCaptureBarProps> = ({
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<InstantCaptureBarProps> = ({
}}
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<InstantCaptureBarProps> = ({
}}
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'
}`}
>
@@ -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<EmojiPickerModalProps> = ({
@@ -36,11 +38,12 @@ export const EmojiPickerModal: React.FC<EmojiPickerModalProps> = ({
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<boolean>(() =>
typeof document !== 'undefined'
? document.documentElement.classList.contains('dark')
@@ -98,8 +101,8 @@ export const EmojiPickerModal: React.FC<EmojiPickerModalProps> = ({
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<EmojiPickerModalProps> = ({
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<EmojiPickerModalProps> = ({
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<EmojiPickerModalProps> = ({
{/* Mobile handle indicator */}
<div className="w-10 h-1 bg-border rounded-full mx-auto sm:hidden shrink-0" />
{/* Top Header: Title, Actions & Close */}
<div className="flex items-center justify-between gap-2 shrink-0 border-b border-border pb-2">
{/* Top Header: Title, Actions & Close (phone only; on desktop a click outside closes it) */}
<div className="flex sm:hidden items-center justify-between gap-2 shrink-0 border-b border-border pb-2">
<div className="flex items-center gap-1.5 text-xs font-semibold text-main">
<span>Icon auswählen</span>
{currentIcon && (
@@ -155,7 +151,7 @@ export const EmojiPickerModal: React.FC<EmojiPickerModalProps> = ({
</div>
<div className="flex items-center gap-1">
{currentIcon && (
{currentIcon && allowRemove && (
<button
type="button"
onClick={() => onSelectEmoji('')}
@@ -208,6 +204,18 @@ export const EmojiPickerModal: React.FC<EmojiPickerModalProps> = ({
/>
</Suspense>
</div>
{/* Desktop: removing the icon as a quiet last row, like an entry of a context menu */}
{currentIcon && allowRemove && (
<button
type="button"
onClick={() => onSelectEmoji('')}
className="hidden sm:flex items-center gap-1.5 w-full h-7 px-2 mt-1 rounded-btn text-xs text-muted hover:text-status-err hover:bg-panel-hover transition-colors cursor-pointer"
>
<Trash2 className="w-3.5 h-3.5" />
Icon entfernen
</button>
)}
</div>
</>
);
@@ -1891,20 +1891,6 @@ export const WorkspaceView: React.FC<WorkspaceViewProps> = ({
if (e.target === e.currentTarget && markedDocIds.size > 0) setMarkedDocIds(new Set());
}}
>
{markedDocIds.size > 0 && (
<div className="sticky top-0 z-10 flex items-center gap-2 px-2 py-1.5 rounded-btn bg-accent/10 border border-accent/30 text-[11px] text-main">
<span className="flex-1">
{markedDocIds.size} markiert · in einen Ordner oder auf den Papierkorb ziehen
</span>
<button
type="button"
onClick={() => setMarkedDocIds(new Set())}
className="text-accent hover:underline shrink-0"
>
Aufheben
</button>
</div>
)}
{/* 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
+45
View File
@@ -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;
+56
View File
@@ -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<AvailableUpdate | null>(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<AvailableUpdate>;
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 };
}
+9 -6
View File
@@ -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<EffectiveVoiceMode | null>(null);
const [state, setState] = useState<VoiceState>('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;
+5
View File
@@ -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__ : '';
+12 -1
View File
@@ -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,