diff --git a/web/index.html b/web/index.html index ca2ec1e..19f2456 100644 --- a/web/index.html +++ b/web/index.html @@ -2,14 +2,31 @@ - - + + + + + + + + + + + + + + + + + + + Dumpster diff --git a/web/public/apple-touch-icon.png b/web/public/apple-touch-icon.png new file mode 100644 index 0000000..0e04f8a Binary files /dev/null and b/web/public/apple-touch-icon.png differ diff --git a/web/public/favicon-16.png b/web/public/favicon-16.png new file mode 100644 index 0000000..290d4da Binary files /dev/null and b/web/public/favicon-16.png differ diff --git a/web/public/favicon-32.png b/web/public/favicon-32.png new file mode 100644 index 0000000..c671e2b Binary files /dev/null and b/web/public/favicon-32.png differ diff --git a/web/public/icons/icon-128.png b/web/public/icons/icon-128.png new file mode 100644 index 0000000..caba87e Binary files /dev/null and b/web/public/icons/icon-128.png differ diff --git a/web/public/icons/icon-144.png b/web/public/icons/icon-144.png new file mode 100644 index 0000000..3023c56 Binary files /dev/null and b/web/public/icons/icon-144.png differ diff --git a/web/public/icons/icon-152.png b/web/public/icons/icon-152.png new file mode 100644 index 0000000..1e0cd38 Binary files /dev/null and b/web/public/icons/icon-152.png differ diff --git a/web/public/icons/icon-192-maskable.png b/web/public/icons/icon-192-maskable.png new file mode 100644 index 0000000..c9e490a Binary files /dev/null and b/web/public/icons/icon-192-maskable.png differ diff --git a/web/public/icons/icon-192.png b/web/public/icons/icon-192.png index 1fa3d9d..81db14a 100644 Binary files a/web/public/icons/icon-192.png and b/web/public/icons/icon-192.png differ diff --git a/web/public/icons/icon-384.png b/web/public/icons/icon-384.png new file mode 100644 index 0000000..ca1712c Binary files /dev/null and b/web/public/icons/icon-384.png differ diff --git a/web/public/icons/icon-48.png b/web/public/icons/icon-48.png new file mode 100644 index 0000000..1ba3ac9 Binary files /dev/null and b/web/public/icons/icon-48.png differ diff --git a/web/public/icons/icon-512-maskable.png b/web/public/icons/icon-512-maskable.png new file mode 100644 index 0000000..09fc07e Binary files /dev/null and b/web/public/icons/icon-512-maskable.png differ diff --git a/web/public/icons/icon-512.png b/web/public/icons/icon-512.png index 3e6dfff..0a27665 100644 Binary files a/web/public/icons/icon-512.png and b/web/public/icons/icon-512.png differ diff --git a/web/public/icons/icon-72.png b/web/public/icons/icon-72.png new file mode 100644 index 0000000..d28d488 Binary files /dev/null and b/web/public/icons/icon-72.png differ diff --git a/web/public/icons/icon-96.png b/web/public/icons/icon-96.png new file mode 100644 index 0000000..73e42de Binary files /dev/null and b/web/public/icons/icon-96.png differ diff --git a/web/public/manifest.json b/web/public/manifest.json index 2d8e857..359fd57 100644 --- a/web/public/manifest.json +++ b/web/public/manifest.json @@ -1,22 +1,89 @@ { - "name": "Dumpster", + "name": "Dumpster Chat", "short_name": "Dumpster", "description": "A chaotic, self-hosted Discord-like platform", "start_url": "/", + "scope": "/", "display": "standalone", + "display_override": ["window-controls-overlay", "standalone"], + "orientation": "any", "background_color": "#282828", "theme_color": "#282828", - "orientation": "any", + "categories": ["social", "communication"], + "lang": "en", + "dir": "ltr", "icons": [ + { + "src": "/icons/icon-48.png", + "sizes": "48x48", + "type": "image/png" + }, + { + "src": "/icons/icon-72.png", + "sizes": "72x72", + "type": "image/png" + }, + { + "src": "/icons/icon-96.png", + "sizes": "96x96", + "type": "image/png" + }, + { + "src": "/icons/icon-128.png", + "sizes": "128x128", + "type": "image/png" + }, + { + "src": "/icons/icon-144.png", + "sizes": "144x144", + "type": "image/png" + }, + { + "src": "/icons/icon-152.png", + "sizes": "152x152", + "type": "image/png" + }, { "src": "/icons/icon-192.png", "sizes": "192x192", + "type": "image/png", + "purpose": "any" + }, + { + "src": "/icons/icon-192-maskable.png", + "sizes": "192x192", + "type": "image/png", + "purpose": "maskable" + }, + { + "src": "/icons/icon-384.png", + "sizes": "384x384", "type": "image/png" }, { "src": "/icons/icon-512.png", "sizes": "512x512", - "type": "image/png" + "type": "image/png", + "purpose": "any" + }, + { + "src": "/icons/icon-512-maskable.png", + "sizes": "512x512", + "type": "image/png", + "purpose": "maskable" + } + ], + "shortcuts": [ + { + "name": "Create Account", + "short_name": "Register", + "url": "/?register=true", + "icons": [ + { + "src": "/icons/icon-96.png", + "sizes": "96x96" + } + ] } ] } diff --git a/web/public/offline.html b/web/public/offline.html new file mode 100644 index 0000000..41f9539 --- /dev/null +++ b/web/public/offline.html @@ -0,0 +1,103 @@ + + + + + + + Dumpster - Offline + + + +
+
📡
+

YOU ARE OFFLINE

+

+ Dumpster can't reach the server. Check your connection and try again. + Messages will sync when you're back online. +

+
OFFLINE
+
+ +
+ + + diff --git a/web/public/sw.js b/web/public/sw.js index 57e6998..dc9e656 100644 --- a/web/public/sw.js +++ b/web/public/sw.js @@ -1,61 +1,79 @@ -// ponytail: bump CACHE_NAME on each deploy, or stale HTML breaks assets. -// Network-first for navigation (index.html) keeps hashed asset refs fresh. -const CACHE_NAME = 'dumpster-v3'; +// Dumpster PWA Service Worker +// Bump CACHE_VERSION on each deploy to force re-cache of hashed assets. +const CACHE_VERSION = 'dumpster-v4'; +const OFFLINE_URL = '/offline.html'; -self.addEventListener('install', () => { - self.skipWaiting(); +// Assets to pre-cache on install (shell files) +const PRECACHE_URLS = [ + '/', + '/offline.html', + '/favicon.png', + '/icons/icon-192.png', + '/icons/icon-512.png', +]; + +// Install: pre-cache shell and skip waiting +self.addEventListener('install', (event) => { + event.waitUntil( + caches.open(CACHE_VERSION).then((cache) => cache.addAll(PRECACHE_URLS)).then(() => self.skipWaiting()) + ); }); +// Activate: clean old caches and claim clients self.addEventListener('activate', (event) => { event.waitUntil( - caches.keys().then((names) => - Promise.all(names.filter((n) => n !== CACHE_NAME).map((n) => caches.delete(n))) - ) + caches + .keys() + .then((names) => Promise.all(names.filter((n) => n !== CACHE_VERSION).map((n) => caches.delete(n)))) + .then(() => self.clients.claim()) ); - self.clients.claim(); }); +// Fetch handler self.addEventListener('fetch', (event) => { if (event.request.method !== 'GET') return; const url = new URL(event.request.url); - // Skip API + WS + // Skip API, WebSocket, and cross-origin requests if (url.pathname.startsWith('/api/') || url.pathname.startsWith('/ws')) return; - - // Skip cross-origin requests (e.g. Giphy, LiveKit) if (url.origin !== self.location.origin) return; - // Network-first for navigation requests (index.html) — always fetch fresh HTML - // so hashed asset references stay current after deploys. + // Navigation requests: network-first, offline fallback if (event.request.mode === 'navigate') { event.respondWith( fetch(event.request) .then((response) => { const clone = response.clone(); - caches.open(CACHE_NAME).then((c) => c.put(event.request, clone)); + caches.open(CACHE_VERSION).then((c) => c.put(event.request, clone)); return response; }) - .catch(() => caches.match('/index.html')) + .catch(() => + caches.match(event.request).then((cached) => cached || caches.match(OFFLINE_URL)) + ) ); return; } - // Cache-first for static assets (JS, CSS, images, fonts) + // Static assets (JS, CSS, images, fonts): stale-while-revalidate + // Serve cached immediately, fetch fresh in background event.respondWith( caches.match(event.request).then((cached) => { - const fetchPromise = fetch(event.request).then((response) => { - if (response.ok) { - const clone = response.clone(); - caches.open(CACHE_NAME).then((c) => c.put(event.request, clone)); - } - return response; - }).catch(() => cached); + const fetchPromise = fetch(event.request) + .then((response) => { + if (response.ok) { + const clone = response.clone(); + caches.open(CACHE_VERSION).then((c) => c.put(event.request, clone)); + } + return response; + }) + .catch(() => cached); return cached || fetchPromise; }) ); }); +// Push notifications self.addEventListener('push', (event) => { if (!event.data) return; const data = event.data.json(); @@ -65,11 +83,14 @@ self.addEventListener('push', (event) => { icon: '/icons/icon-192.png', badge: '/icons/icon-192.png', vibrate: [100, 50, 100], + tag: data.tag || 'dumpster-notification', + renotify: true, data: { url: data.url || '/' }, }) ); }); +// Notification click: focus or open window self.addEventListener('notificationclick', (event) => { event.notification.close(); event.waitUntil( diff --git a/web/src/App.tsx b/web/src/App.tsx index f011c75..2f994c6 100644 --- a/web/src/App.tsx +++ b/web/src/App.tsx @@ -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 ( + + } /> } /> diff --git a/web/src/components/ConnectionStatus.tsx b/web/src/components/ConnectionStatus.tsx new file mode 100644 index 0000000..292bd21 --- /dev/null +++ b/web/src/components/ConnectionStatus.tsx @@ -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 ( +
+ {online ? "● CONNECTION RESTORED" : "○ YOU ARE OFFLINE — messages will send when reconnected"} +
+ ); +} diff --git a/web/src/components/InstallBanner.tsx b/web/src/components/InstallBanner.tsx new file mode 100644 index 0000000..68f3209 --- /dev/null +++ b/web/src/components/InstallBanner.tsx @@ -0,0 +1,98 @@ +import { useState, useEffect, useCallback } from "react"; + +interface BeforeInstallPromptEvent extends Event { + prompt: () => Promise; + userChoice: Promise<{ outcome: "accepted" | "dismissed" }>; +} + +const DISMISSED_KEY = "dumpster-pwa-install-dismissed"; + +export function InstallBanner() { + const [deferredPrompt, setDeferredPrompt] = useState(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 ( +
+
+ +
+
Install Dumpster
+ {isIOS ? ( +
+ Tap Share then{" "} + Add to Home Screen +
+ ) : ( +
+ Add to home screen for the full app experience +
+ )} +
+
+ {!isIOS && deferredPrompt && ( + + )} + +
+
+
+ ); +} diff --git a/web/src/components/LoginForm.tsx b/web/src/components/LoginForm.tsx index f53c2f7..8863e37 100644 --- a/web/src/components/LoginForm.tsx +++ b/web/src/components/LoginForm.tsx @@ -175,14 +175,6 @@ export function LoginForm() { {isRegister ? "[BACK TO LOGIN]" : "[CREATE ACCOUNT]"} - {!isRegister && ( -

- Use Link component:{" "} - - [HOME] - -

- )} );