feat: PWA overhaul

- Remove debug artifact from login page ('Use Link component: HOME')
- Full icon set: 48/72/96/128/144/152/192/384/512 + maskable variants
- Proper manifest: display_override, shortcuts, categories, scope
- Better service worker: offline fallback page, pre-caching, stale-while-revalidate
- Custom install banner (Android beforeinstallprompt + iOS instructions)
- Connection status indicator (online/offline toast)
- Updated index.html with proper favicon sizes and meta tags
This commit is contained in:
2026-07-02 13:51:42 -04:00
parent f6f63ecd2b
commit 0b645fe765
22 changed files with 379 additions and 37 deletions
+4
View File
@@ -12,6 +12,8 @@ import { DMChat } from './components/DMChat.tsx';
import { ForgotPasswordPage } from './components/ForgotPasswordPage.tsx';
import { ResetPasswordPage } from './components/ResetPasswordPage.tsx';
import { useAuthStore } from './stores/auth.ts';
import { InstallBanner } from './components/InstallBanner.tsx';
import { ConnectionStatus } from './components/ConnectionStatus.tsx';
function ProtectedRoute({ children }: { children: React.ReactNode }) {
const isAuthenticated = useAuthStore((state) => state.isAuthenticated);
@@ -32,6 +34,8 @@ function App() {
return (
<BrowserRouter>
<ConnectionStatus />
<InstallBanner />
<Routes>
<Route path="/login" element={<LoginForm />} />
<Route path="/forgot-password" element={<ForgotPasswordPage />} />
+40
View File
@@ -0,0 +1,40 @@
import { useState, useEffect } from "react";
export function ConnectionStatus() {
const [online, setOnline] = useState(navigator.onLine);
const [visible, setVisible] = useState(!navigator.onLine);
useEffect(() => {
const handleOnline = () => {
setOnline(true);
// Show "back online" briefly then hide
setVisible(true);
setTimeout(() => setVisible(false), 3000);
};
const handleOffline = () => {
setOnline(false);
setVisible(true);
};
window.addEventListener("online", handleOnline);
window.addEventListener("offline", handleOffline);
return () => {
window.removeEventListener("online", handleOnline);
window.removeEventListener("offline", handleOffline);
};
}, []);
if (!visible) return null;
return (
<div
className={`fixed top-0 left-0 right-0 z-[100] text-center py-1 text-xs font-mono transition-all duration-300 ${
online
? "bg-gb-green/20 text-gb-green border-b border-gb-green/30"
: "bg-gb-red/20 text-gb-red border-b border-gb-red/30"
}`}
>
{online ? "● CONNECTION RESTORED" : "○ YOU ARE OFFLINE — messages will send when reconnected"}
</div>
);
}
+98
View File
@@ -0,0 +1,98 @@
import { useState, useEffect, useCallback } from "react";
interface BeforeInstallPromptEvent extends Event {
prompt: () => Promise<void>;
userChoice: Promise<{ outcome: "accepted" | "dismissed" }>;
}
const DISMISSED_KEY = "dumpster-pwa-install-dismissed";
export function InstallBanner() {
const [deferredPrompt, setDeferredPrompt] = useState<BeforeInstallPromptEvent | null>(null);
const [visible, setVisible] = useState(false);
const [isIOS, setIsIOS] = useState(false);
useEffect(() => {
// Check if already dismissed
if (localStorage.getItem(DISMISSED_KEY)) return;
// Detect iOS (no beforeinstallprompt)
const ua = window.navigator.userAgent;
const isIOSDevice = /iPad|iPhone|iPod/.test(ua) || (ua.includes("Mac") && "ontouchend" in window);
const isStandalone = window.matchMedia("(display-mode: standalone)").matches;
if (isStandalone) return; // Already installed
if (isIOSDevice && !isStandalone) {
setIsIOS(true);
// Show iOS instructions after a short delay
const timer = setTimeout(() => setVisible(true), 3000);
return () => clearTimeout(timer);
}
const handler = (e: Event) => {
e.preventDefault();
setDeferredPrompt(e as BeforeInstallPromptEvent);
// Show banner after a short delay so it doesn't clash with page load
setTimeout(() => setVisible(true), 2000);
};
window.addEventListener("beforeinstallprompt", handler);
return () => window.removeEventListener("beforeinstallprompt", handler);
}, []);
const handleInstall = useCallback(async () => {
if (!deferredPrompt) return;
await deferredPrompt.prompt();
const { outcome } = await deferredPrompt.userChoice;
if (outcome === "accepted") {
setVisible(false);
}
setDeferredPrompt(null);
}, [deferredPrompt]);
const handleDismiss = useCallback(() => {
setVisible(false);
localStorage.setItem(DISMISSED_KEY, "1");
}, []);
if (!visible) return null;
return (
<div className="fixed bottom-4 left-4 right-4 z-50 flex justify-center pointer-events-none">
<div className="pointer-events-auto bg-gb-bg-s border border-gb-bg-t shadow-2xl max-w-md w-full flex items-center gap-3 p-3">
<img src="/icons/icon-72.png" alt="" className="w-10 h-10 shrink-0" />
<div className="flex-1 min-w-0">
<div className="text-sm text-gb-fg font-mono font-bold">Install Dumpster</div>
{isIOS ? (
<div className="text-xs text-gb-fg-f font-mono mt-0.5">
Tap <span className="text-gb-blue">Share</span> then{" "}
<span className="text-gb-blue">Add to Home Screen</span>
</div>
) : (
<div className="text-xs text-gb-fg-f font-mono mt-0.5">
Add to home screen for the full app experience
</div>
)}
</div>
<div className="flex items-center gap-2 shrink-0">
{!isIOS && deferredPrompt && (
<button
onClick={handleInstall}
className="text-xs font-mono px-3 py-1.5 bg-gb-orange text-gb-bg hover:bg-gb-yellow transition-colors"
>
INSTALL
</button>
)}
<button
onClick={handleDismiss}
className="text-xs font-mono text-gb-fg-f hover:text-gb-red transition-colors px-2 py-1.5"
title="Dismiss"
>
</button>
</div>
</div>
</div>
);
}
-8
View File
@@ -175,14 +175,6 @@ export function LoginForm() {
{isRegister ? "[BACK TO LOGIN]" : "[CREATE ACCOUNT]"}
</button>
</div>
{!isRegister && (
<p className="text-gb-fg-f text-xs mt-4 text-center">
Use Link component:{" "}
<Link to="/" className="text-gb-blue hover:text-gb-aqua">
[HOME]
</Link>
</p>
)}
</div>
</div>
);