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
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:
1 parent
20b907078f
commit
55112b940c
17 files changed
+250
-61
No files matched your search
Generated
+2
-2
@@ -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,7 +1,7 @@
|
||||
{
|
||||
"name": "mindsift-frontend",
|
||||
"private": true,
|
||||
"version": "0.40.1",
|
||||
"version": "0.41.0",
|
||||
"license": "Unlicense",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
|
||||
+17
-10
@@ -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>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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
@@ -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,
|
||||
|
||||
Reference in new issue
Block a user