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

+15 -1
View File
@@ -6,6 +6,19 @@ Das Format folgt [Keep a Changelog](https://keepachangelog.com/de/1.1.0/), die V
## [Unreleased] ## [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 ## [0.40.1] - 2026-11-01
### Fixed ### 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. - 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. - 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.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.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 [0.39.0]: https://git.dsit.me/sandrodach/MindSift/compare/v0.38.1...v0.39.0
+1 -1
View File
@@ -6,7 +6,7 @@
[![Self-Hosted](https://img.shields.io/badge/100%25-Self--Hosted-2563eb?style=flat-square)](#) [![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) [![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) [![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. **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.
+2 -2
View File
@@ -1,12 +1,12 @@
{ {
"name": "mindsift-frontend", "name": "mindsift-frontend",
"version": "0.40.1", "version": "0.41.0",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "mindsift-frontend", "name": "mindsift-frontend",
"version": "0.40.1", "version": "0.41.0",
"license": "Unlicense", "license": "Unlicense",
"dependencies": { "dependencies": {
"@fontsource-variable/inter": "5.3.0", "@fontsource-variable/inter": "5.3.0",
+1 -1
View File
@@ -1,7 +1,7 @@
{ {
"name": "mindsift-frontend", "name": "mindsift-frontend",
"private": true, "private": true,
"version": "0.40.1", "version": "0.41.0",
"license": "Unlicense", "license": "Unlicense",
"type": "module", "type": "module",
"scripts": { "scripts": {
+17 -10
View File
@@ -73,18 +73,14 @@ import { CommandPalette } from './components/CommandPalette';
import { AppFooter } from './components/AppFooter'; import { AppFooter } from './components/AppFooter';
import { ThemeQuickPicker } from './components/ThemeQuickPicker'; import { ThemeQuickPicker } from './components/ThemeQuickPicker';
import { AboutModal } from './components/AboutModal'; import { AboutModal } from './components/AboutModal';
import { UpdateAvailableModal } from './components/UpdateAvailableModal';
import { useUpdateCheck } from './lib/useUpdateCheck';
import { consumePendingShares, formatSharedThought } from './lib/shareTarget'; import { consumePendingShares, formatSharedThought } from './lib/shareTarget';
import { APP_VERSION } from './version'; import { APP_VERSION } from './version';
import { readViewSession, writeViewSession } from './lib/viewSession';
type ActiveTab = const APP_TABS = ['triage', 'tasks', 'workspace', 'lists', 'groups', 'user_settings', 'admin_settings', 'help'] as const;
| 'triage' type ActiveTab = (typeof APP_TABS)[number];
| 'tasks'
| 'workspace'
| 'lists'
| 'groups'
| 'user_settings'
| 'admin_settings'
| 'help';
interface ErrorBoundaryState { interface ErrorBoundaryState {
hasError: boolean; hasError: boolean;
@@ -170,7 +166,15 @@ export const App: React.FC = () => {
}); });
const [deck, setDeck] = useState<Thought[]>([]); const [deck, setDeck] = useState<Thought[]>([]);
const [lists, setLists] = useState<SharedList[]>([]); 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 [previousTab, setPreviousTab] = useState<ActiveTab>('triage');
const [selectedDocId, setSelectedDocId] = useState<string | null>(null); const [selectedDocId, setSelectedDocId] = useState<string | null>(null);
const [selectedBlockId, setSelectedBlockId] = 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 [showProfileMenu, setShowProfileMenu] = useState(false);
const serverOnline = useServerOnline(); const serverOnline = useServerOnline();
const [showAbout, setShowAbout] = useState(false); const [showAbout, setShowAbout] = useState(false);
const { update: availableUpdate, dismiss: dismissUpdate } = useUpdateCheck();
const dropdownRef = useRef<HTMLDivElement | null>(null); const dropdownRef = useRef<HTMLDivElement | null>(null);
// Mobile Navigation & Quick-Action State // Mobile Navigation & Quick-Action State
@@ -197,6 +202,7 @@ export const App: React.FC = () => {
const mobileBaseVoiceRef = useRef(''); const mobileBaseVoiceRef = useRef('');
// Voice input of the mobile capture sheet: own Whisper server or the browser (lib/voice.ts) // Voice input of the mobile capture sheet: own Whisper server or the browser (lib/voice.ts)
const mobileVoice = useVoiceInput({ const mobileVoice = useVoiceInput({
chunked: true,
onText: (text) => { onText: (text) => {
if (!text) return; if (!text) return;
const base = mobileBaseVoiceRef.current; const base = mobileBaseVoiceRef.current;
@@ -1877,6 +1883,7 @@ export const App: React.FC = () => {
/> />
{showAbout && <AboutModal onClose={() => setShowAbout(false)} />} {showAbout && <AboutModal onClose={() => setShowAbout(false)} />}
{availableUpdate && <UpdateAvailableModal update={availableUpdate} onLater={dismissUpdate} />}
</div> </div>
); );
}; };
+3 -2
View File
@@ -4,7 +4,7 @@ import { EmojiPickerModal } from '../features/workspace/EmojiPickerModal';
interface EmojiIconButtonProps { interface EmojiIconButtonProps {
value: string; value: string;
onChange: (emoji: string) => void; onChange: (emoji: string) => void;
// Default icon; removing the icon in the picker goes back to it // Shown while no icon is chosen
fallback: string; fallback: string;
} }
@@ -29,8 +29,9 @@ export const EmojiIconButton: React.FC<EmojiIconButtonProps> = ({ value, onChang
<EmojiPickerModal <EmojiPickerModal
currentIcon={value} currentIcon={value}
anchor={anchor} anchor={anchor}
allowRemove={false}
onSelectEmoji={(emoji) => { onSelectEmoji={(emoji) => {
onChange(emoji || fallback); onChange(emoji);
setAnchor(null); setAnchor(null);
}} }}
onClose={() => 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({ const voice = useVoiceInput({
continuous: true, continuous: true,
chunked: true,
onText: (text, final) => { onText: (text, final) => {
transcriptRef.current = text; transcriptRef.current = text;
if (!final) { 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) // Voice input: own Whisper server or the browser's recognition (lib/voice.ts)
const voice = useVoiceInput({ const voice = useVoiceInput({
chunked: true,
onText: (text, final) => { onText: (text, final) => {
const base = baseVoiceTextRef.current; const base = baseVoiceTextRef.current;
if (text) setContent(base ? `${base} ${text}` : text); if (text) setContent(base ? `${base} ${text}` : text);
@@ -206,7 +207,7 @@ export const InstantCaptureBar: React.FC<InstantCaptureBarProps> = ({
setIsMultiline(false); setIsMultiline(false);
setPriority('normal'); setPriority('normal');
setDueDate(''); setDueDate('');
setTarget(fixedTarget || 'inbox'); // Target and list stay for the next dump; only priority and due date belong to one entry
setShowOptions(defaultShowOptions || hideTargetPicker); setShowOptions(defaultShowOptions || hideTargetPicker);
setIsSubmitting(true); setIsSubmitting(true);
triggerHaptic(15); 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 ${ className={`min-h-[30px] px-2.5 border rounded-btn text-xs flex items-center gap-1.5 transition-colors cursor-pointer ${
isSelected 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' : '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 ${ className={`min-h-[30px] px-2.5 border rounded-btn text-xs flex items-center gap-1.5 transition-colors cursor-pointer ${
isSelected 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' : '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 // Inside a dialog (which clips and transforms its content): the picker is put on the page instead and
// opens below this button on desktop // opens below this button on desktop
anchor?: DOMRect | null; 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> = ({ export const EmojiPickerModal: React.FC<EmojiPickerModalProps> = ({
@@ -36,11 +38,12 @@ export const EmojiPickerModal: React.FC<EmojiPickerModalProps> = ({
onSelectEmoji, onSelectEmoji,
onClose, onClose,
anchor, anchor,
allowRemove = true,
}) => { }) => {
const { inset, visibleHeight } = useKeyboardInset(); const { inset, visibleHeight } = useKeyboardInset();
const isPhone = typeof window !== 'undefined' && window.innerWidth < 640; const isPhone = typeof window !== 'undefined' && window.innerWidth < 640;
// On phones the picker shrinks with the keyboard so search field and results stay visible // 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>(() => const [isDark, setIsDark] = useState<boolean>(() =>
typeof document !== 'undefined' typeof document !== 'undefined'
? document.documentElement.classList.contains('dark') ? document.documentElement.classList.contains('dark')
@@ -98,8 +101,8 @@ export const EmojiPickerModal: React.FC<EmojiPickerModalProps> = ({
const anchorStyle = anchor const anchorStyle = anchor
? ({ ? ({
'--pick-left': `${Math.max(12, Math.min(anchor.left, window.innerWidth - 372))}px`, '--pick-left': `${Math.max(12, Math.min(anchor.left, window.innerWidth - 312))}px`,
'--pick-top': `${Math.min(anchor.bottom + 4, window.innerHeight - 532)}px`, '--pick-top': `${Math.min(anchor.bottom + 4, window.innerHeight - 360)}px`,
} as React.CSSProperties) } as React.CSSProperties)
: undefined; : undefined;
@@ -117,7 +120,7 @@ export const EmojiPickerModal: React.FC<EmojiPickerModalProps> = ({
ref={containerRef} ref={containerRef}
className={` 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 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 anchor
? 'z-[61] sm:fixed sm:left-[var(--pick-left)] sm:top-[max(12px,var(--pick-top))]' ? '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' : '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={ style={
{ {
...anchorStyle, ...anchorStyle,
// Inject MindSift CSS variables into emoji-picker-react for seamless theming // Theme colours for the picker (index.css); desktop: its own raised surface, so it stands apart
'--epr-bg-color': 'var(--bg-panel)', // from the dialog it opens from
'--epr-category-label-bg-color': 'var(--bg-panel)', '--pick-surface': isPhone ? 'var(--bg-panel)' : 'var(--bg-panel-elevated)',
'--epr-picker-border-color': 'var(--border-color)', '--pick-hover': isPhone ? 'var(--bg-panel-elevated)' : 'var(--bg-panel-hover)',
'--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)',
'--kb-inset': `${inset}px`, '--kb-inset': `${inset}px`,
'--vv-max': `${Math.round(visibleHeight * 0.9)}px`, '--vv-max': `${Math.round(visibleHeight * 0.9)}px`,
} as React.CSSProperties } as React.CSSProperties
@@ -145,8 +141,8 @@ export const EmojiPickerModal: React.FC<EmojiPickerModalProps> = ({
{/* Mobile handle indicator */} {/* Mobile handle indicator */}
<div className="w-10 h-1 bg-border rounded-full mx-auto sm:hidden shrink-0" /> <div className="w-10 h-1 bg-border rounded-full mx-auto sm:hidden shrink-0" />
{/* Top Header: Title, Actions & Close */} {/* Top Header: Title, Actions & Close (phone only; on desktop a click outside closes it) */}
<div className="flex items-center justify-between gap-2 shrink-0 border-b border-border pb-2"> <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"> <div className="flex items-center gap-1.5 text-xs font-semibold text-main">
<span>Icon auswählen</span> <span>Icon auswählen</span>
{currentIcon && ( {currentIcon && (
@@ -155,7 +151,7 @@ export const EmojiPickerModal: React.FC<EmojiPickerModalProps> = ({
</div> </div>
<div className="flex items-center gap-1"> <div className="flex items-center gap-1">
{currentIcon && ( {currentIcon && allowRemove && (
<button <button
type="button" type="button"
onClick={() => onSelectEmoji('')} onClick={() => onSelectEmoji('')}
@@ -208,6 +204,18 @@ export const EmojiPickerModal: React.FC<EmojiPickerModalProps> = ({
/> />
</Suspense> </Suspense>
</div> </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> </div>
</> </>
); );
@@ -1891,20 +1891,6 @@ export const WorkspaceView: React.FC<WorkspaceViewProps> = ({
if (e.target === e.currentTarget && markedDocIds.size > 0) setMarkedDocIds(new Set()); 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). {/* 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: 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 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 { .mindsift-emoji-picker .EmojiPickerReact {
border: none !important; border: none !important;
font-family: inherit !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) { @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 */ /* Read-only sections (e.g. server settings in the demo): everything inside looks switched off */
fieldset:disabled :is(button, input, select, textarea) { fieldset:disabled :is(button, input, select, textarea) {
opacity: 0.5; 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; 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 dictation mode (/diktat): auto-restart browser speech recognition on pause, and chunk server recordings every ~8s or ~0.8s of silence. */
live?: boolean; 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; 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 * 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. * "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 [mode, setMode] = useState<EffectiveVoiceMode | null>(null);
const [state, setState] = useState<VoiceState>('idle'); const [state, setState] = useState<VoiceState>('idle');
const [pendingChunks, setPendingChunks] = useState(0); const [pendingChunks, setPendingChunks] = useState(0);
@@ -149,14 +152,14 @@ export function useVoiceInput({ onText, onError, continuous = false, live = fals
} }
const rec = recorderRef.current; const rec = recorderRef.current;
if (!rec) { if (!rec) {
if (live && state === 'listening') { if (chunkServer && state === 'listening') {
setStateSafe('idle'); setStateSafe('idle');
onTextRef.current(transcriptRef.current, true); onTextRef.current(transcriptRef.current, true);
} }
return; return;
} }
recorderRef.current = null; recorderRef.current = null;
if (live) { if (chunkServer) {
const idx = chunkIndexRef.current++; const idx = chunkIndexRef.current++;
setStateSafe('transcribing'); setStateSafe('transcribing');
processSegmentPromise(idx, rec.stop()); processSegmentPromise(idx, rec.stop());
@@ -179,7 +182,7 @@ export function useVoiceInput({ onText, onError, continuous = false, live = fals
} }
// fail only touches refs and state setters // fail only touches refs and state setters
// eslint-disable-next-line react-hooks/exhaustive-deps // eslint-disable-next-line react-hooks/exhaustive-deps
}, [live, state]); }, [chunkServer, state]);
const startServer = async () => { const startServer = async () => {
const rec = new VoiceRecorder(); const rec = new VoiceRecorder();
@@ -201,7 +204,7 @@ export function useVoiceInput({ onText, onError, continuous = false, live = fals
return; return;
} }
timerRef.current = window.setTimeout(() => void stop(), maxSecondsRef.current * 1000); timerRef.current = window.setTimeout(() => void stop(), maxSecondsRef.current * 1000);
if (live) { if (chunkServer) {
let segmentMs = 0; let segmentMs = 0;
let silentMs = 0; let silentMs = 0;
let hadSpeech = false; let hadSpeech = false;
@@ -325,7 +328,7 @@ export function useVoiceInput({ onText, onError, continuous = false, live = fals
else if (current === 'browser') startBrowser(); else if (current === 'browser') startBrowser();
else fail(current === 'off' ? 'Spracheingabe ist ausgeschaltet' : 'Spracheingabe wird von diesem Browser nicht unterstützt'); else fail(current === 'off' ? 'Spracheingabe ist ausgeschaltet' : 'Spracheingabe wird von diesem Browser nicht unterstützt');
// eslint-disable-next-line react-hooks/exhaustive-deps // eslint-disable-next-line react-hooks/exhaustive-deps
}, [mode, continuous, live]); }, [mode, continuous, live, chunkServer]);
const cancel = useCallback(() => { const cancel = useCallback(() => {
cancelledRef.current = true; 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. */ /** Version shown in the header; set at build time in vite.config.ts. */
export const APP_VERSION = typeof __APP_VERSION__ !== 'undefined' ? __APP_VERSION__ : 'dev'; 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). // 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 pkg = JSON.parse(readFileSync(new URL('./package.json', import.meta.url), 'utf8'));
const appVersion = process.env.APP_VERSION || `v${pkg.version}-dev`; 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({ 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: { define: {
__APP_VERSION__: JSON.stringify(appVersion), __APP_VERSION__: JSON.stringify(appVersion),
__APP_BUILD__: JSON.stringify(appBuild),
}, },
server: { server: {
port: 5173, port: 5173,
+1 -1
View File
@@ -366,7 +366,7 @@ func (s *Server) Router() http.Handler {
req.URL.Path = "/" req.URL.Path = "/"
path = "index.html" 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("Cache-Control", "no-store, no-cache, must-revalidate")
w.Header().Set("Pragma", "no-cache") w.Header().Set("Pragma", "no-cache")
w.Header().Set("Expires", "0") w.Header().Set("Expires", "0")