feat(pwa): offer immediate mobile Web-App installation sheet and header install button
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
c02c7d20de
commit
b3e3fa27c4
4 files changed
+252
-24
No files matched your search
+15
-4
@@ -4,12 +4,23 @@
|
|||||||
<meta charset="UTF-8" />
|
<meta charset="UTF-8" />
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no" />
|
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no" />
|
||||||
<meta name="theme-color" content="#0b0f19" />
|
<meta name="theme-color" content="#0b0f19" />
|
||||||
|
<meta name="mobile-web-app-capable" content="yes" />
|
||||||
<meta name="apple-mobile-web-app-capable" content="yes" />
|
<meta name="apple-mobile-web-app-capable" content="yes" />
|
||||||
|
<meta name="apple-mobile-web-app-title" content="MindSift" />
|
||||||
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent" />
|
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent" />
|
||||||
<meta name="description" content="MindSift — Der Kopf frei in drei Sekunden. Instant Capture, Tinder-Style Triage, Workspace & Echtzeit-Listen." />
|
<meta name="description" content="MindSift — Der Kopf frei in drei Sekunden. Instant Capture, Tinder-Style Triage, Workspace & Echtzeit-Listen." />
|
||||||
<link rel="manifest" href="/manifest.json" />
|
<link rel="manifest" href="/manifest.json" />
|
||||||
<link rel="icon" type="image/svg+xml" href="data:image/svg+xml,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 100 100'><rect width='100' height='100' rx='26' fill='%23131314'/><circle cx='50' cy='50' r='32' fill='none' stroke='%238ab4f8' stroke-width='7'/><path d='M36 52 L46 62 L66 38' fill='none' stroke='%23c58af9' stroke-width='8' stroke-linecap='round' stroke-linejoin='round'/></svg>" />
|
<link rel="icon" type="image/svg+xml" href="/icon.svg" />
|
||||||
|
<link rel="apple-touch-icon" href="/icon.svg" />
|
||||||
<title>MindSift — Der Kopf frei in drei Sekunden</title>
|
<title>MindSift — Der Kopf frei in drei Sekunden</title>
|
||||||
|
<script>
|
||||||
|
window.__deferredPwaPrompt = null;
|
||||||
|
window.addEventListener('beforeinstallprompt', function (e) {
|
||||||
|
e.preventDefault();
|
||||||
|
window.__deferredPwaPrompt = e;
|
||||||
|
window.dispatchEvent(new CustomEvent('mindsift:pwa-ready'));
|
||||||
|
});
|
||||||
|
</script>
|
||||||
</head>
|
</head>
|
||||||
<body class="bg-[#0b0f19] text-[#e3e3e3] antialiased selection:bg-[#8ab4f8]/30 selection:text-[#8ab4f8]">
|
<body class="bg-[#0b0f19] text-[#e3e3e3] antialiased selection:bg-[#8ab4f8]/30 selection:text-[#8ab4f8]">
|
||||||
<div id="root"></div>
|
<div id="root"></div>
|
||||||
@@ -19,9 +30,9 @@
|
|||||||
caches.keys().then((keys) => keys.forEach((k) => caches.delete(k))).catch(() => {});
|
caches.keys().then((keys) => keys.forEach((k) => caches.delete(k))).catch(() => {});
|
||||||
}
|
}
|
||||||
if ('serviceWorker' in navigator) {
|
if ('serviceWorker' in navigator) {
|
||||||
navigator.serviceWorker.getRegistrations().then((regs) => {
|
window.addEventListener('load', () => {
|
||||||
regs.forEach((r) => r.unregister());
|
navigator.serviceWorker.register('/sw.js').catch(() => {});
|
||||||
}).catch(() => {});
|
});
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
</body>
|
</body>
|
||||||
|
|||||||
@@ -0,0 +1,5 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512" width="512" height="512">
|
||||||
|
<rect width="512" height="512" rx="128" fill="#0b0f19"/>
|
||||||
|
<circle cx="256" cy="256" r="164" fill="none" stroke="#8ab4f8" stroke-width="36"/>
|
||||||
|
<path d="M184 266 L236 318 L338 194" fill="none" stroke="#c58af9" stroke-width="40" stroke-linecap="round" stroke-linejoin="round"/>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 374 B |
@@ -1,18 +1,26 @@
|
|||||||
{
|
{
|
||||||
|
"id": "/",
|
||||||
"name": "MindSift — Der Kopf frei in drei Sekunden",
|
"name": "MindSift — Der Kopf frei in drei Sekunden",
|
||||||
"short_name": "MindSift",
|
"short_name": "MindSift",
|
||||||
"description": "Instant Brain-Dump, Tinder-Style Triage, Workspace & Echtzeit-Familienlisten.",
|
"description": "Instant Brain-Dump, Tinder-Style Triage, Workspace & Echtzeit-Familienlisten.",
|
||||||
"start_url": "/?mode=capture",
|
"start_url": "/",
|
||||||
|
"scope": "/",
|
||||||
"display": "standalone",
|
"display": "standalone",
|
||||||
"background_color": "#0b0f19",
|
"background_color": "#0b0f19",
|
||||||
"theme_color": "#0b0f19",
|
"theme_color": "#0b0f19",
|
||||||
"orientation": "portrait-primary",
|
"orientation": "portrait-primary",
|
||||||
"icons": [
|
"icons": [
|
||||||
{
|
{
|
||||||
"src": "data:image/svg+xml,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 100 100'><rect width='100' height='100' rx='26' fill='%23131314'/><circle cx='50' cy='50' r='32' fill='none' stroke='%238ab4f8' stroke-width='7'/><path d='M36 52 L46 62 L66 38' fill='none' stroke='%23c58af9' stroke-width='8' stroke-linecap='round' stroke-linejoin='round'/></svg>",
|
"src": "/icon.svg",
|
||||||
|
"sizes": "any",
|
||||||
|
"type": "image/svg+xml",
|
||||||
|
"purpose": "any"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"src": "/icon.svg",
|
||||||
"sizes": "192x192 512x512",
|
"sizes": "192x192 512x512",
|
||||||
"type": "image/svg+xml",
|
"type": "image/svg+xml",
|
||||||
"purpose": "any maskable"
|
"purpose": "maskable"
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"shortcuts": [
|
"shortcuts": [
|
||||||
|
|||||||
+221
-17
@@ -14,6 +14,9 @@ import {
|
|||||||
HelpCircle,
|
HelpCircle,
|
||||||
Plus,
|
Plus,
|
||||||
X,
|
X,
|
||||||
|
Download,
|
||||||
|
Smartphone,
|
||||||
|
Share,
|
||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
import { AuthStatus, DashboardStats, SharedList, Thought, User } from './lib/types';
|
import { AuthStatus, DashboardStats, SharedList, Thought, User } from './lib/types';
|
||||||
import {
|
import {
|
||||||
@@ -124,6 +127,86 @@ export const App: React.FC = () => {
|
|||||||
const [showMobileCapture, setShowMobileCapture] = useState(false);
|
const [showMobileCapture, setShowMobileCapture] = useState(false);
|
||||||
const [showMobileProfile, setShowMobileProfile] = useState(false);
|
const [showMobileProfile, setShowMobileProfile] = useState(false);
|
||||||
|
|
||||||
|
// Mobile Web-App (PWA) Installation State
|
||||||
|
const [isStandaloneApp, setIsStandaloneApp] = useState<boolean>(() => {
|
||||||
|
if (typeof window === 'undefined') return false;
|
||||||
|
return (
|
||||||
|
window.matchMedia('(display-mode: standalone)').matches ||
|
||||||
|
(window.navigator as any).standalone === true
|
||||||
|
);
|
||||||
|
});
|
||||||
|
const [deferredPwaPrompt, setDeferredPwaPrompt] = useState<any>(() =>
|
||||||
|
typeof window !== 'undefined' ? (window as any).__deferredPwaPrompt || null : null
|
||||||
|
);
|
||||||
|
const [showPwaInstallModal, setShowPwaInstallModal] = useState(false);
|
||||||
|
const [showManualPwaGuide, setShowManualPwaGuide] = useState(false);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const syncPwaPrompt = () => {
|
||||||
|
if ((window as any).__deferredPwaPrompt) {
|
||||||
|
setDeferredPwaPrompt((window as any).__deferredPwaPrompt);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
const onBeforeInstallPrompt = (e: Event) => {
|
||||||
|
e.preventDefault();
|
||||||
|
(window as any).__deferredPwaPrompt = e;
|
||||||
|
setDeferredPwaPrompt(e);
|
||||||
|
};
|
||||||
|
const onAppInstalled = () => {
|
||||||
|
(window as any).__deferredPwaPrompt = null;
|
||||||
|
setDeferredPwaPrompt(null);
|
||||||
|
setIsStandaloneApp(true);
|
||||||
|
setShowPwaInstallModal(false);
|
||||||
|
setShowManualPwaGuide(false);
|
||||||
|
};
|
||||||
|
|
||||||
|
window.addEventListener('beforeinstallprompt', onBeforeInstallPrompt);
|
||||||
|
window.addEventListener('mindsift:pwa-ready', syncPwaPrompt);
|
||||||
|
window.addEventListener('appinstalled', onAppInstalled);
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
window.removeEventListener('beforeinstallprompt', onBeforeInstallPrompt);
|
||||||
|
window.removeEventListener('mindsift:pwa-ready', syncPwaPrompt);
|
||||||
|
window.removeEventListener('appinstalled', onAppInstalled);
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// Immediately offer Web-App installation on mobile if not yet running as installed standalone app
|
||||||
|
useEffect(() => {
|
||||||
|
if (!user || isStandaloneApp) return;
|
||||||
|
if (window.innerWidth < 640 && !sessionStorage.getItem('mindsift_pwa_dismissed')) {
|
||||||
|
setShowPwaInstallModal(true);
|
||||||
|
}
|
||||||
|
}, [user?.id, isStandaloneApp]);
|
||||||
|
|
||||||
|
const handleInstallWebApp = async () => {
|
||||||
|
triggerHaptic(15);
|
||||||
|
const promptEvent = deferredPwaPrompt || (window as any).__deferredPwaPrompt;
|
||||||
|
if (promptEvent && typeof promptEvent.prompt === 'function') {
|
||||||
|
try {
|
||||||
|
await promptEvent.prompt();
|
||||||
|
const choice = await promptEvent.userChoice;
|
||||||
|
if (choice && choice.outcome === 'accepted') {
|
||||||
|
(window as any).__deferredPwaPrompt = null;
|
||||||
|
setDeferredPwaPrompt(null);
|
||||||
|
setShowPwaInstallModal(false);
|
||||||
|
setShowManualPwaGuide(false);
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
setShowManualPwaGuide(true);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
setShowManualPwaGuide(true);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDismissPwaModal = () => {
|
||||||
|
triggerHaptic(10);
|
||||||
|
sessionStorage.setItem('mindsift_pwa_dismissed', '1');
|
||||||
|
setShowPwaInstallModal(false);
|
||||||
|
setShowManualPwaGuide(false);
|
||||||
|
};
|
||||||
|
|
||||||
const handleOpenHelp = () => {
|
const handleOpenHelp = () => {
|
||||||
triggerHaptic(10);
|
triggerHaptic(10);
|
||||||
setPreviousTab((prev) => (activeTab === 'help' ? prev : activeTab));
|
setPreviousTab((prev) => (activeTab === 'help' ? prev : activeTab));
|
||||||
@@ -351,23 +434,41 @@ export const App: React.FC = () => {
|
|||||||
</span>
|
</span>
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
<button
|
<div className="flex items-center gap-1.5">
|
||||||
type="button"
|
{!isStandaloneApp && (
|
||||||
onClick={() => {
|
<button
|
||||||
triggerHaptic(10);
|
type="button"
|
||||||
setShowMobileProfile(true);
|
onClick={() => {
|
||||||
}}
|
triggerHaptic(10);
|
||||||
className={`w-11 h-11 -mr-1.5 rounded-full flex items-center justify-center transition-transform active:scale-95 cursor-pointer ${
|
setShowManualPwaGuide(false);
|
||||||
activeTab === 'user_settings' || activeTab === 'admin_settings' || activeTab === 'help'
|
setShowPwaInstallModal(true);
|
||||||
? 'ring-2 ring-accent'
|
}}
|
||||||
: ''
|
className="h-9 px-2.5 rounded-xl bg-accent/15 border border-accent/40 text-accent text-xs font-semibold flex items-center gap-1.5 active:scale-95 transition-transform cursor-pointer"
|
||||||
}`}
|
title="Als Web-App auf dem Smartphone installieren"
|
||||||
title="Profil & Einstellungen"
|
>
|
||||||
>
|
<Download className="w-3.5 h-3.5" />
|
||||||
<div className="w-8 h-8 rounded-full bg-accent text-on-accent text-xs font-bold shadow-sm flex items-center justify-center">
|
<span>App</span>
|
||||||
{user.displayName ? user.displayName.slice(0, 1).toUpperCase() : 'U'}
|
</button>
|
||||||
</div>
|
)}
|
||||||
</button>
|
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => {
|
||||||
|
triggerHaptic(10);
|
||||||
|
setShowMobileProfile(true);
|
||||||
|
}}
|
||||||
|
className={`w-11 h-11 -mr-1.5 rounded-full flex items-center justify-center transition-transform active:scale-95 cursor-pointer ${
|
||||||
|
activeTab === 'user_settings' || activeTab === 'admin_settings' || activeTab === 'help'
|
||||||
|
? 'ring-2 ring-accent'
|
||||||
|
: ''
|
||||||
|
}`}
|
||||||
|
title="Profil & Einstellungen"
|
||||||
|
>
|
||||||
|
<div className="w-8 h-8 rounded-full bg-accent text-on-accent text-xs font-bold shadow-sm flex items-center justify-center">
|
||||||
|
{user.displayName ? user.displayName.slice(0, 1).toUpperCase() : 'U'}
|
||||||
|
</div>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Desktop Header: 3-column Layout (Logo, InstantCaptureBar, Profile Pill) */}
|
{/* Desktop Header: 3-column Layout (Logo, InstantCaptureBar, Profile Pill) */}
|
||||||
@@ -876,6 +977,22 @@ export const App: React.FC = () => {
|
|||||||
|
|
||||||
{/* Menu Items */}
|
{/* Menu Items */}
|
||||||
<div className="pt-3 space-y-1">
|
<div className="pt-3 space-y-1">
|
||||||
|
{!isStandaloneApp && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => {
|
||||||
|
triggerHaptic(10);
|
||||||
|
setShowMobileProfile(false);
|
||||||
|
setShowManualPwaGuide(false);
|
||||||
|
setShowPwaInstallModal(true);
|
||||||
|
}}
|
||||||
|
className="w-full px-3 py-2.5 rounded-btn text-left flex items-center gap-3 bg-accent/10 border border-accent/30 text-accent font-semibold transition-colors text-xs cursor-pointer mb-1"
|
||||||
|
>
|
||||||
|
<Smartphone className="w-4 h-4 text-accent" />
|
||||||
|
<span>Web-App auf Smartphone installieren</span>
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
@@ -940,6 +1057,93 @@ export const App: React.FC = () => {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{/* IMMEDIATE MOBILE WEB-APP INSTALLATION OFFER SHEET */}
|
||||||
|
{showPwaInstallModal && !isStandaloneApp && (
|
||||||
|
<div
|
||||||
|
className="sm:hidden fixed inset-0 z-50 flex items-end justify-center bg-black/65 backdrop-blur-xs animate-in fade-in duration-150"
|
||||||
|
onClick={handleDismissPwaModal}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
className="w-full bg-panel border-t border-border rounded-t-2xl shadow-panel p-5 pb-[calc(1.75rem+env(safe-area-inset-bottom))] space-y-4 animate-in slide-in-from-bottom duration-200"
|
||||||
|
onClick={(e) => e.stopPropagation()}
|
||||||
|
>
|
||||||
|
<div className="flex items-start justify-between gap-3">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<div className="w-12 h-12 rounded-2xl bg-accent text-on-accent shadow-md flex items-center justify-center flex-shrink-0">
|
||||||
|
<Brain className="w-6 h-6" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<h3 className="text-base font-bold text-main leading-tight">
|
||||||
|
{authStatus.instanceName || 'MindSift'} als App installieren
|
||||||
|
</h3>
|
||||||
|
<p className="text-xs text-muted mt-0.5">
|
||||||
|
Vollbild ohne Browser-Leiste, schnellerer Start & Direktzugriff vom Home-Bildschirm.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={handleDismissPwaModal}
|
||||||
|
className="p-1.5 text-faint hover:text-main rounded-btn cursor-pointer"
|
||||||
|
>
|
||||||
|
<X className="w-5 h-5" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{!showManualPwaGuide ? (
|
||||||
|
<div className="space-y-2.5 pt-1">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={handleInstallWebApp}
|
||||||
|
className="btn-primary w-full h-12 text-sm font-semibold rounded-xl flex items-center justify-center gap-2 shadow-md active:scale-98 transition-transform cursor-pointer"
|
||||||
|
>
|
||||||
|
<Download className="w-4 h-4" />
|
||||||
|
<span>Jetzt als Web-App installieren</span>
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={handleDismissPwaModal}
|
||||||
|
className="btn-action w-full h-10 text-xs rounded-xl justify-center cursor-pointer"
|
||||||
|
>
|
||||||
|
Später im Browser weiter nutzen
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="space-y-3 pt-1">
|
||||||
|
<div className="p-3.5 rounded-xl bg-canvas border border-border space-y-2.5 text-xs text-main">
|
||||||
|
<p className="font-semibold text-accent flex items-center gap-1.5">
|
||||||
|
<Smartphone className="w-4 h-4" />
|
||||||
|
<span>In 2 Sekunden zum Home-Bildschirm hinzufügen:</span>
|
||||||
|
</p>
|
||||||
|
<ol className="space-y-2 text-muted list-decimal list-inside">
|
||||||
|
<li>
|
||||||
|
Tippe im Browser auf{' '}
|
||||||
|
<strong className="text-main inline-flex items-center gap-1">
|
||||||
|
<Share className="w-3.5 h-3.5 text-accent inline" /> Teilen
|
||||||
|
</strong>{' '}
|
||||||
|
(iPhone/Safari) oder das{' '}
|
||||||
|
<strong className="text-main">Browser-Menü (⋮)</strong>.
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
Wähle{' '}
|
||||||
|
<strong className="text-main">„Zum Home-Bildschirm“</strong> bzw.{' '}
|
||||||
|
<strong className="text-main">„App installieren“</strong>.
|
||||||
|
</li>
|
||||||
|
</ol>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={handleDismissPwaModal}
|
||||||
|
className="btn-primary w-full h-11 text-xs font-semibold rounded-xl justify-center cursor-pointer"
|
||||||
|
>
|
||||||
|
Verstanden
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
Reference in new issue
Block a user