feat(mobile): align triage quick dump modal design with task creation modal
MindSift CI & Docker Release / Build, Test in Container & Push (push) Successful in 1m2s
MindSift CI & Docker Release / Build, Test in Container & Push (push) Successful in 1m2s
This commit is contained in:
1 parent
0e06f6761d
commit
10d5c6d563
1 file changed
+209
-24
+209
-24
@@ -18,8 +18,13 @@ import {
|
|||||||
Smartphone,
|
Smartphone,
|
||||||
Share,
|
Share,
|
||||||
Users,
|
Users,
|
||||||
|
Calendar,
|
||||||
|
AlertTriangle,
|
||||||
|
Flame,
|
||||||
|
Mic,
|
||||||
|
MicOff,
|
||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
import { AuthStatus, DashboardStats, SharedList, Thought, User } from './lib/types';
|
import { AuthStatus, DashboardStats, PriorityLevel, SharedList, Thought, User } from './lib/types';
|
||||||
import {
|
import {
|
||||||
api,
|
api,
|
||||||
clearToken,
|
clearToken,
|
||||||
@@ -132,12 +137,93 @@ export const App: React.FC = () => {
|
|||||||
// Mobile Navigation & Quick-Action State
|
// Mobile Navigation & Quick-Action State
|
||||||
const [showMobileCapture, setShowMobileCapture] = useState(false);
|
const [showMobileCapture, setShowMobileCapture] = useState(false);
|
||||||
const [showMobileProfile, setShowMobileProfile] = useState(false);
|
const [showMobileProfile, setShowMobileProfile] = useState(false);
|
||||||
|
const [mobileTriageContent, setMobileTriageContent] = useState('');
|
||||||
|
const [mobileTriagePriority, setMobileTriagePriority] = useState<PriorityLevel>('normal');
|
||||||
|
const [mobileTriageDueDate, setMobileTriageDueDate] = useState('');
|
||||||
|
const [mobileVoiceActive, setMobileVoiceActive] = useState(false);
|
||||||
|
const [isVoiceCaptured, setIsVoiceCaptured] = useState(false);
|
||||||
|
const mobileRecognitionRef = useRef<any>(null);
|
||||||
|
|
||||||
|
const toggleMobileVoice = () => {
|
||||||
|
const SpeechRecognition =
|
||||||
|
(window as any).SpeechRecognition || (window as any).webkitSpeechRecognition;
|
||||||
|
if (!SpeechRecognition) {
|
||||||
|
alert('Spracheingabe wird von diesem Browser leider nicht unterstützt.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (mobileVoiceActive && mobileRecognitionRef.current) {
|
||||||
|
mobileRecognitionRef.current.stop();
|
||||||
|
setMobileVoiceActive(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const recognition = new SpeechRecognition();
|
||||||
|
recognition.lang = 'de-DE';
|
||||||
|
recognition.continuous = false;
|
||||||
|
recognition.interimResults = true;
|
||||||
|
recognition.onstart = () => {
|
||||||
|
setMobileVoiceActive(true);
|
||||||
|
triggerHaptic(20);
|
||||||
|
};
|
||||||
|
recognition.onresult = (event: any) => {
|
||||||
|
let transcript = '';
|
||||||
|
for (let i = 0; i < event.results.length; i++) {
|
||||||
|
transcript += event.results[i][0].transcript;
|
||||||
|
}
|
||||||
|
setMobileTriageContent((prev) => {
|
||||||
|
const base = prev.replace(/\s*\[…\]$/, '').trim();
|
||||||
|
return base ? `${base} ${transcript}` : transcript;
|
||||||
|
});
|
||||||
|
setIsVoiceCaptured(true);
|
||||||
|
};
|
||||||
|
recognition.onerror = () => setMobileVoiceActive(false);
|
||||||
|
recognition.onend = () => setMobileVoiceActive(false);
|
||||||
|
mobileRecognitionRef.current = recognition;
|
||||||
|
recognition.start();
|
||||||
|
} catch {
|
||||||
|
setMobileVoiceActive(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleMobileCreateTriage = async (e: React.FormEvent) => {
|
||||||
|
e.preventDefault();
|
||||||
|
const trimmed = mobileTriageContent.trim();
|
||||||
|
if (!trimmed) return;
|
||||||
|
try {
|
||||||
|
const thought = await api.captureThought(
|
||||||
|
trimmed,
|
||||||
|
mobileTriagePriority,
|
||||||
|
isVoiceCaptured ? 'voice' : 'manual',
|
||||||
|
mobileTriageDueDate,
|
||||||
|
{ target: 'inbox' }
|
||||||
|
);
|
||||||
|
handleCaptured(thought, 'inbox');
|
||||||
|
setMobileTriageContent('');
|
||||||
|
setMobileTriagePriority('normal');
|
||||||
|
setMobileTriageDueDate('');
|
||||||
|
setIsVoiceCaptured(false);
|
||||||
|
setShowMobileCapture(false);
|
||||||
|
triggerHaptic(20);
|
||||||
|
} catch (err: any) {
|
||||||
|
alert(`Fehler beim Speichern: ${err.message || 'Unbekannter Fehler'}`);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const openMobileTriageCapture = () => {
|
||||||
|
setMobileTriageContent('');
|
||||||
|
setMobileTriagePriority('normal');
|
||||||
|
setMobileTriageDueDate('');
|
||||||
|
setIsVoiceCaptured(false);
|
||||||
|
setMobileVoiceActive(false);
|
||||||
|
setShowMobileCapture(true);
|
||||||
|
};
|
||||||
|
|
||||||
const { isMenuOpen, setIsMenuOpen, closeMenu, setGroups, primaryAction, setPrimaryAction } = useMobileActions();
|
const { isMenuOpen, setIsMenuOpen, closeMenu, setGroups, primaryAction, setPrimaryAction } = useMobileActions();
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
closeMenu();
|
closeMenu();
|
||||||
if (activeTab === 'triage') {
|
if (activeTab === 'triage') {
|
||||||
setPrimaryAction(() => setShowMobileCapture(true));
|
setPrimaryAction(() => openMobileTriageCapture);
|
||||||
setGroups([
|
setGroups([
|
||||||
{
|
{
|
||||||
id: 'triage-actions',
|
id: 'triage-actions',
|
||||||
@@ -148,7 +234,7 @@ export const App: React.FC = () => {
|
|||||||
label: 'Neuen Gedanken erfassen',
|
label: 'Neuen Gedanken erfassen',
|
||||||
icon: <Plus className="w-4 h-4" />,
|
icon: <Plus className="w-4 h-4" />,
|
||||||
variant: 'accent',
|
variant: 'accent',
|
||||||
onClick: () => setShowMobileCapture(true),
|
onClick: () => openMobileTriageCapture(),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 'refresh-deck',
|
id: 'refresh-deck',
|
||||||
@@ -471,7 +557,7 @@ export const App: React.FC = () => {
|
|||||||
const handleTriggerAddButton = () => {
|
const handleTriggerAddButton = () => {
|
||||||
triggerHaptic(20);
|
triggerHaptic(20);
|
||||||
if (window.innerWidth < 640) {
|
if (window.innerWidth < 640) {
|
||||||
setShowMobileCapture(true);
|
openMobileTriageCapture();
|
||||||
} else {
|
} else {
|
||||||
window.scrollTo({ top: 0, behavior: 'smooth' });
|
window.scrollTo({ top: 0, behavior: 'smooth' });
|
||||||
setCaptureFocusCount((c) => c + 1);
|
setCaptureFocusCount((c) => c + 1);
|
||||||
@@ -970,7 +1056,7 @@ export const App: React.FC = () => {
|
|||||||
if (primaryAction) {
|
if (primaryAction) {
|
||||||
primaryAction();
|
primaryAction();
|
||||||
} else {
|
} else {
|
||||||
setShowMobileCapture(true);
|
openMobileTriageCapture();
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
className="w-14 h-14 -mt-6 rounded-full bg-accent text-on-accent shadow-lg flex items-center justify-center active:scale-90 transition-transform border-4 border-panel cursor-pointer"
|
className="w-14 h-14 -mt-6 rounded-full bg-accent text-on-accent shadow-lg flex items-center justify-center active:scale-90 transition-transform border-4 border-panel cursor-pointer"
|
||||||
@@ -1020,7 +1106,7 @@ export const App: React.FC = () => {
|
|||||||
</button>
|
</button>
|
||||||
</nav>
|
</nav>
|
||||||
|
|
||||||
{/* MOBILE QUICK-ADD BOTTOM SHEET / MODAL */}
|
{/* MOBILE QUICK-ADD BOTTOM SHEET / MODAL (DESIGN-ALIGNED WITH TASKS VIEW) */}
|
||||||
{showMobileCapture && (
|
{showMobileCapture && (
|
||||||
<div
|
<div
|
||||||
className="sm:hidden fixed inset-0 !m-0 z-50 flex items-end justify-center bg-black/60 backdrop-blur-xs animate-in fade-in duration-150"
|
className="sm:hidden fixed inset-0 !m-0 z-50 flex items-end justify-center bg-black/60 backdrop-blur-xs animate-in fade-in duration-150"
|
||||||
@@ -1033,37 +1119,136 @@ export const App: React.FC = () => {
|
|||||||
{/* Header */}
|
{/* Header */}
|
||||||
<div className="flex items-center justify-between pb-3 border-b border-border mb-3">
|
<div className="flex items-center justify-between pb-3 border-b border-border mb-3">
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<div className="w-6 h-6 rounded-btn bg-accent/15 text-accent flex items-center justify-center">
|
<div className="w-7 h-7 rounded-btn bg-accent/15 text-accent flex items-center justify-center">
|
||||||
{activeTab === 'triage' ? <Layers className="w-4 h-4" /> : <Plus className="w-4 h-4" />}
|
<Layers className="w-4 h-4" />
|
||||||
</div>
|
</div>
|
||||||
<h3 className="text-sm font-semibold text-main">
|
<h3 className="text-sm font-semibold text-main">Quick Dump erfassen</h3>
|
||||||
{activeTab === 'triage' ? 'Quick Dump erfassen' : 'Schnell erfassen'}
|
|
||||||
</h3>
|
|
||||||
</div>
|
</div>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => setShowMobileCapture(false)}
|
onClick={() => setShowMobileCapture(false)}
|
||||||
className="p-1 text-faint hover:text-main rounded-btn transition-colors cursor-pointer"
|
className="p-1.5 text-faint hover:text-main rounded-btn transition-colors cursor-pointer"
|
||||||
>
|
>
|
||||||
<X className="w-5 h-5" />
|
<X className="w-5 h-5" />
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* InstantCaptureBar */}
|
<form onSubmit={handleMobileCreateTriage} className="space-y-3">
|
||||||
<div className="pt-1">
|
{/* Text Input with Voice Dictation */}
|
||||||
<InstantCaptureBar
|
<div>
|
||||||
inlineOptions={true}
|
<label className="text-[11px] font-semibold text-muted block mb-1">
|
||||||
defaultShowOptions={true}
|
Gedanke / Quick Dump
|
||||||
hideTargetPicker={activeTab === 'triage'}
|
</label>
|
||||||
fixedTarget={activeTab === 'triage' ? 'inbox' : undefined}
|
<div className="relative">
|
||||||
onCaptured={(thought, target) => {
|
<input
|
||||||
handleCaptured(thought, target);
|
type="text"
|
||||||
setShowMobileCapture(false);
|
autoFocus
|
||||||
|
value={mobileTriageContent}
|
||||||
|
onChange={(e) => setMobileTriageContent(e.target.value)}
|
||||||
|
placeholder="Was geht dir durch den Kopf?…"
|
||||||
|
className="w-full input-field px-3 py-2 pr-10 text-sm"
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={toggleMobileVoice}
|
||||||
|
className={`absolute right-1.5 top-1/2 -translate-y-1/2 w-7 h-7 rounded-full flex items-center justify-center transition-colors cursor-pointer ${
|
||||||
|
mobileVoiceActive
|
||||||
|
? 'bg-accent text-on-accent animate-pulse'
|
||||||
|
: 'text-muted hover:text-main hover:bg-panel-elevated'
|
||||||
|
}`}
|
||||||
|
title="Spracheingabe"
|
||||||
|
>
|
||||||
|
{mobileVoiceActive ? <MicOff className="w-3.5 h-3.5" /> : <Mic className="w-3.5 h-3.5" />}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Priority - 4 Buttons (Identical to TasksView) */}
|
||||||
|
<div className="space-y-1.5 pt-1">
|
||||||
|
<label className="text-[11px] font-semibold text-muted block">Priorität</label>
|
||||||
|
<div className="grid grid-cols-4 gap-1.5">
|
||||||
|
{(['low', 'normal', 'high', 'urgent'] as PriorityLevel[]).map((p) => {
|
||||||
|
const config: Record<
|
||||||
|
PriorityLevel,
|
||||||
|
{ label: string; icon?: React.ReactNode; activeClass: string }
|
||||||
|
> = {
|
||||||
|
low: {
|
||||||
|
label: 'Niedrig',
|
||||||
|
activeClass: 'bg-canvas border-strong text-main font-semibold shadow-xs',
|
||||||
|
},
|
||||||
|
normal: {
|
||||||
|
label: 'Normal',
|
||||||
|
activeClass: 'bg-accent/15 border-accent text-accent font-semibold shadow-xs',
|
||||||
|
},
|
||||||
|
high: {
|
||||||
|
label: 'Wichtig',
|
||||||
|
icon: <AlertTriangle className="w-3 h-3 text-status-warn" />,
|
||||||
|
activeClass:
|
||||||
|
'bg-status-warn/15 border-status-warn text-status-warn font-semibold shadow-xs',
|
||||||
|
},
|
||||||
|
urgent: {
|
||||||
|
label: 'Dringend',
|
||||||
|
icon: <Flame className="w-3 h-3 text-status-err" />,
|
||||||
|
activeClass:
|
||||||
|
'bg-status-err/15 border-status-err text-status-err font-semibold shadow-xs',
|
||||||
|
},
|
||||||
|
};
|
||||||
|
const active = mobileTriagePriority === p;
|
||||||
|
const item = config[p];
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={p}
|
||||||
|
type="button"
|
||||||
|
onClick={() => {
|
||||||
|
triggerHaptic(10);
|
||||||
|
setMobileTriagePriority(p);
|
||||||
}}
|
}}
|
||||||
lists={lists}
|
className={`h-9 px-1 text-xs rounded-btn font-medium transition-colors border flex items-center justify-center gap-1 cursor-pointer ${
|
||||||
autoFocusTrigger={Date.now()}
|
active
|
||||||
|
? item.activeClass
|
||||||
|
: 'bg-canvas border-border text-muted hover:text-main'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{item.icon}
|
||||||
|
<span className="truncate">{item.label}</span>
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Due Date (Optional) */}
|
||||||
|
<div className="space-y-1 pt-1">
|
||||||
|
<label className="text-[11px] font-semibold text-muted flex items-center gap-1">
|
||||||
|
<Calendar className="w-3.5 h-3.5 text-accent" />
|
||||||
|
<span>Fällig am (optional)</span>
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="date"
|
||||||
|
value={mobileTriageDueDate}
|
||||||
|
onChange={(e) => setMobileTriageDueDate(e.target.value)}
|
||||||
|
className="w-full input-field px-3 py-2 text-xs"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Bottom Buttons */}
|
||||||
|
<div className="pt-2 flex justify-end gap-2">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setShowMobileCapture(false)}
|
||||||
|
className="btn-ghost h-10 px-4 text-xs cursor-pointer"
|
||||||
|
>
|
||||||
|
Abbrechen
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
disabled={!mobileTriageContent.trim()}
|
||||||
|
className="btn-primary h-10 px-5 text-xs font-semibold disabled:opacity-40 cursor-pointer"
|
||||||
|
>
|
||||||
|
Gedanke erfassen
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|||||||
Reference in new issue
Block a user