Compare commits

..

3 commits

Author SHA1 Message Date
0a0769115b fix(hub): drop hub PWA install to unblock standalone PWAs (closes #41)
Pre-#41 the hub shipped a Workbox SW with manifest scope `/`, which
claimed the entire app.ariege.io origin and made Chrome treat the
path-mounted standalones at /libra/, /market/, etc. as sub-areas of the
already-installed hub PWA — suppressing the install affordance for each
standalone.

Drop the VitePWA plugin from the hub entirely. The hub is a tile-grid
launcher; users install the standalones they actually use. Add a
decommission helper that runs on every hub boot and unregisters any
legacy hub SW, so users who installed the old hub PWA get cleaned up
automatically. Standalone SWs at deeper scopes are left alone.
2026-05-06 07:48:37 +02:00
b46d23b5bb style(scrollbar): match shadcn ScrollArea aesthetic on native scrollbars
The page-level scroll on every standalone falls back to the browser's default
scrollbar because nothing wraps the app shell in <ScrollArea>. Add global
@layer base styles so native scrollbars (page scroll, textareas, etc.) pick
up the same border-color thumb + transparent track + rounded-full styling
as the shadcn ScrollArea component.
2026-05-06 07:29:10 +02:00
a2c4cfd955 fix(layout): swap 100vh/h-screen for dvh so bottom navs survive browser chrome
In non-PWA browser tabs (especially iOS Safari), 100vh is the largest possible
viewport — it doesn't shrink when the bottom URL bar slides in, so fixed-bottom
navs get occluded. 100dvh tracks the dynamic viewport, so layouts reflow and
nav stays clickable. Safe-area-inset-bottom padding on the navs themselves is
already in place.
2026-05-06 07:29:10 +02:00
24 changed files with 103 additions and 217 deletions

View file

@ -1,126 +0,0 @@
// Custom service worker for push notifications
// This will be merged with Workbox generated SW
import { precacheAndRoute, cleanupOutdatedCaches } from 'workbox-precaching'
import { clientsClaim, skipWaiting } from 'workbox-core'
// Precache and route static assets
precacheAndRoute(self.__WB_MANIFEST)
cleanupOutdatedCaches()
// Take control of all pages immediately
skipWaiting()
clientsClaim()
// Push notification event handler
self.addEventListener('push', (event) => {
console.log('Push event received:', event)
let notificationData = {
title: 'New Announcement',
body: 'You have a new admin announcement',
icon: '/pwa-192x192.png',
badge: '/pwa-192x192.png',
data: {
url: '/',
timestamp: Date.now()
},
tag: 'admin-announcement',
requireInteraction: true,
actions: [
{
action: 'view',
title: 'View',
icon: '/pwa-192x192.png'
},
{
action: 'dismiss',
title: 'Dismiss'
}
]
}
// Parse push data if available
if (event.data) {
try {
const pushData = event.data.json()
notificationData = {
...notificationData,
...pushData
}
} catch (error) {
console.warn('Failed to parse push data:', error)
// Use default notification data
}
}
event.waitUntil(
self.registration.showNotification(notificationData.title, notificationData)
)
})
// Notification click handler
self.addEventListener('notificationclick', (event) => {
console.log('Notification clicked:', event)
event.notification.close()
const action = event.action
const notificationData = event.notification.data || {}
if (action === 'dismiss') {
return // Just close the notification
}
// Default action or 'view' action - open the app
const urlToOpen = notificationData.url || '/'
event.waitUntil(
clients.matchAll({ type: 'window', includeUncontrolled: true })
.then((clientList) => {
// Try to find an existing window with the app
for (const client of clientList) {
if (client.url.includes(self.location.origin) && 'focus' in client) {
client.focus()
// Navigate to the notification URL if different
if (client.url !== urlToOpen) {
client.navigate(urlToOpen)
}
return
}
}
// If no existing window, open a new one
if (clients.openWindow) {
return clients.openWindow(urlToOpen)
}
})
)
})
// Background sync for offline notification queue (future enhancement)
self.addEventListener('sync', (event) => {
if (event.tag === 'notification-queue') {
event.waitUntil(
// Process any queued notifications when back online
console.log('Background sync: notification-queue')
)
}
})
// Message handler for communication with main app
self.addEventListener('message', (event) => {
console.log('Service worker received message:', event.data)
if (event.data && event.data.type === 'SHOW_NOTIFICATION') {
const { title, body, data } = event.data.payload
self.registration.showNotification(title, {
body,
icon: '/pwa-192x192.png',
badge: '/pwa-192x192.png',
data,
tag: 'manual-notification',
requireInteraction: false
})
}
})

View file

@ -26,8 +26,8 @@ async function handleLoginSuccess() {
</script> </script>
<template> <template>
<div class="min-h-screen bg-background font-sans antialiased"> <div class="min-h-dvh bg-background font-sans antialiased">
<div class="relative flex min-h-screen flex-col" <div class="relative flex min-h-dvh flex-col"
style="padding-top: env(safe-area-inset-top); padding-bottom: env(safe-area-inset-bottom)"> style="padding-top: env(safe-area-inset-top); padding-bottom: env(safe-area-inset-bottom)">
<div v-if="!isLoginPage && !isAuthenticated" class="fixed top-0 right-0 z-50 p-3" style="padding-top: env(safe-area-inset-top)"> <div v-if="!isLoginPage && !isAuthenticated" class="fixed top-0 right-0 z-50 p-3" style="padding-top: env(safe-area-inset-top)">

View file

@ -49,8 +49,8 @@ async function handleLoginSuccess() {
</script> </script>
<template> <template>
<div class="min-h-screen bg-background font-sans antialiased"> <div class="min-h-dvh bg-background font-sans antialiased">
<div class="relative flex min-h-screen flex-col" <div class="relative flex min-h-dvh flex-col"
style="padding-top: env(safe-area-inset-top); padding-bottom: env(safe-area-inset-bottom)"> style="padding-top: env(safe-area-inset-top); padding-bottom: env(safe-area-inset-bottom)">
<!-- Top bar with login --> <!-- Top bar with login -->

View file

@ -46,8 +46,8 @@ async function handleLoginSuccess() {
</script> </script>
<template> <template>
<div class="min-h-screen bg-background font-sans antialiased"> <div class="min-h-dvh bg-background font-sans antialiased">
<div class="relative flex min-h-screen flex-col" <div class="relative flex min-h-dvh flex-col"
style="padding-top: env(safe-area-inset-top); padding-bottom: env(safe-area-inset-bottom)"> style="padding-top: env(safe-area-inset-top); padding-bottom: env(safe-area-inset-bottom)">
<!-- Main content (with bottom padding for nav bar) --> <!-- Main content (with bottom padding for nav bar) -->

View file

@ -177,4 +177,31 @@
body { body {
@apply bg-background text-foreground; @apply bg-background text-foreground;
} }
/* Match shadcn ScrollArea look on native scrollbars (full-page scroll,
textareas, anything not wrapped in <ScrollArea>). */
* {
scrollbar-width: thin;
scrollbar-color: var(--color-border) transparent;
}
*::-webkit-scrollbar {
width: 10px;
height: 10px;
}
*::-webkit-scrollbar-track {
background: transparent;
}
*::-webkit-scrollbar-thumb {
background-color: var(--color-border);
border-radius: 9999px;
border: 2px solid transparent;
background-clip: content-box;
}
*::-webkit-scrollbar-thumb:hover {
background-color: var(--color-muted-foreground);
background-clip: content-box;
}
*::-webkit-scrollbar-corner {
background: transparent;
}
} }

View file

@ -26,8 +26,8 @@ async function handleLoginSuccess() {
</script> </script>
<template> <template>
<div class="min-h-screen bg-background font-sans antialiased"> <div class="min-h-dvh bg-background font-sans antialiased">
<div class="relative flex min-h-screen flex-col" <div class="relative flex min-h-dvh flex-col"
style="padding-top: env(safe-area-inset-top); padding-bottom: env(safe-area-inset-bottom)"> style="padding-top: env(safe-area-inset-top); padding-bottom: env(safe-area-inset-bottom)">
<div v-if="!isLoginPage && !isAuthenticated" class="fixed top-0 right-0 z-50 p-3" style="padding-top: env(safe-area-inset-top)"> <div v-if="!isLoginPage && !isAuthenticated" class="fixed top-0 right-0 z-50 p-3" style="padding-top: env(safe-area-inset-top)">

View file

@ -12,7 +12,7 @@ const openSidebar = () => {
</script> </script>
<template> <template>
<div class="min-h-screen bg-background"> <div class="min-h-dvh bg-background">
<!-- Mobile Drawer --> <!-- Mobile Drawer -->
<MobileDrawer v-model:open="sidebarOpen" /> <MobileDrawer v-model:open="sidebarOpen" />

View file

@ -59,8 +59,8 @@ async function handleLoginSuccess() {
</script> </script>
<template> <template>
<div class="min-h-screen bg-background font-sans antialiased"> <div class="min-h-dvh bg-background font-sans antialiased">
<div class="relative flex min-h-screen flex-col" <div class="relative flex min-h-dvh flex-col"
style="padding-top: env(safe-area-inset-top); padding-bottom: env(safe-area-inset-bottom)"> style="padding-top: env(safe-area-inset-top); padding-bottom: env(safe-area-inset-bottom)">
<div v-if="!isLoginPage && !isAuthenticated" class="fixed top-0 right-0 z-50 p-3" style="padding-top: env(safe-area-inset-top)"> <div v-if="!isLoginPage && !isAuthenticated" class="fixed top-0 right-0 z-50 p-3" style="padding-top: env(safe-area-inset-top)">

View file

@ -0,0 +1,38 @@
/**
* Unregister the legacy hub service worker (closes #41).
*
* Pre-#41 the hub shipped a Workbox SW at `${origin}/sw.js` with scope `/`,
* which claimed the whole origin and blocked Chrome from offering installs
* for the path-mounted standalones at /libra/, /market/, etc. The hub is no
* longer a PWA; this helper proactively cleans up that stale registration
* for users who installed the hub before this change shipped.
*
* Only touches SWs whose scope is the origin root. Standalone SWs at
* /libra/, /market/, etc. live at deeper scopes and are left alone they
* are still legitimate PWAs.
*
* Idempotent: once the legacy registration is gone, future calls are
* cheap no-ops. Safe to leave in place permanently.
*/
export async function decommissionHubServiceWorker(): Promise<void> {
if (!('serviceWorker' in navigator)) return
try {
const regs = await navigator.serviceWorker.getRegistrations()
const originRoot = `${location.origin}/`
const legacy = regs.filter(r => r.scope === originRoot)
if (legacy.length === 0) return
console.warn(
`[decommission-hub-sw] Unregistering ${legacy.length} legacy hub service worker(s).`
)
await Promise.all(legacy.map(r => r.unregister()))
if ('caches' in window) {
const keys = await caches.keys()
await Promise.all(keys.map(k => caches.delete(k)))
}
} catch (err) {
console.warn('[decommission-hub-sw] failed to unregister:', err)
}
}

View file

@ -1,24 +1,15 @@
// New modular application entry point // New modular application entry point
import { startApp } from './app' import { startApp } from './app'
import { registerSW } from 'virtual:pwa-register'
import { cleanupStaleDevServiceWorkers } from '@/lib/dev-sw-cleanup' import { cleanupStaleDevServiceWorkers } from '@/lib/dev-sw-cleanup'
import { decommissionHubServiceWorker } from '@/lib/decommission-hub-sw'
import 'vue-sonner/style.css' import 'vue-sonner/style.css'
// Clean up any leftover dev-mode service workers from a previous session // Clean up any leftover dev-mode service workers from a previous session
cleanupStaleDevServiceWorkers() cleanupStaleDevServiceWorkers()
// Simple periodic service worker updates // Hub is no longer a PWA (#41) — unregister any legacy hub SW left behind
const intervalMS = 60 * 60 * 1000 // 1 hour // on users who installed the old hub PWA before this change shipped.
registerSW({ void decommissionHubServiceWorker()
onRegistered(r) {
r && setInterval(() => {
r.update()
}, intervalMS)
},
onOfflineReady() {
console.log('App ready to work offline')
}
})
// Start the modular application // Start the modular application
startApp() startApp()

View file

@ -69,8 +69,8 @@ async function handleLoginSuccess() {
</script> </script>
<template> <template>
<div class="min-h-screen bg-background font-sans antialiased"> <div class="min-h-dvh bg-background font-sans antialiased">
<div class="relative flex min-h-screen flex-col" <div class="relative flex min-h-dvh flex-col"
style="padding-top: env(safe-area-inset-top); padding-bottom: env(safe-area-inset-bottom)"> style="padding-top: env(safe-area-inset-top); padding-bottom: env(safe-area-inset-bottom)">
<main class="flex-1" :class="{ 'pb-16': !isLoginPage }"> <main class="flex-1" :class="{ 'pb-16': !isLoginPage }">

View file

@ -26,7 +26,7 @@ onMounted(() => {
</script> </script>
<template> <template>
<div class="flex flex-col h-[calc(100vh-3.5rem)]"> <div class="flex flex-col h-[calc(100dvh-3.5rem)]">
<!-- Loading overlay --> <!-- Loading overlay -->
<div v-if="isLoading && geoActivities.length === 0" class="flex-1 flex items-center justify-center"> <div v-if="isLoading && geoActivities.length === 0" class="flex-1 flex items-center justify-center">
<div class="animate-spin rounded-full h-8 w-8 border-b-2 border-primary" /> <div class="animate-spin rounded-full h-8 w-8 border-b-2 border-primary" />

View file

@ -1,6 +1,6 @@
<template> <template>
<!-- Loading State --> <!-- Loading State -->
<div v-if="isLoading" class="flex flex-col items-center justify-center min-h-screen"> <div v-if="isLoading" class="flex flex-col items-center justify-center min-h-dvh">
<div class="flex flex-col items-center space-y-4"> <div class="flex flex-col items-center space-y-4">
<div class="animate-spin rounded-full h-12 w-12 border-b-2 border-primary"></div> <div class="animate-spin rounded-full h-12 w-12 border-b-2 border-primary"></div>
<div class="text-center space-y-2"> <div class="text-center space-y-2">
@ -11,7 +11,7 @@
</div> </div>
<!-- Error State --> <!-- Error State -->
<div v-else-if="error" class="flex flex-col items-center justify-center min-h-screen"> <div v-else-if="error" class="flex flex-col items-center justify-center min-h-dvh">
<div class="text-center space-y-4"> <div class="text-center space-y-4">
<h2 class="text-xl font-semibold text-red-600">Failed to load chat</h2> <h2 class="text-xl font-semibold text-red-600">Failed to load chat</h2>
<p class="text-muted-foreground">{{ error }}</p> <p class="text-muted-foreground">{{ error }}</p>
@ -22,7 +22,7 @@
</div> </div>
<!-- Chat Content - Only render when module is ready --> <!-- Chat Content - Only render when module is ready -->
<div v-else class="h-[calc(100vh-3.5rem)] lg:h-[calc(100vh-4rem)] xl:h-[calc(100vh-5rem)] w-full"> <div v-else class="h-[calc(100dvh-3.5rem)] lg:h-[calc(100dvh-4rem)] xl:h-[calc(100dvh-5rem)] w-full">
<ChatComponent /> <ChatComponent />
</div> </div>
</template> </template>

View file

@ -275,7 +275,7 @@ watch(comments, (newComments) => {
</script> </script>
<template> <template>
<div class="min-h-screen bg-background"> <div class="min-h-dvh bg-background">
<!-- Header --> <!-- Header -->
<header class="sticky top-0 z-30 bg-background/95 backdrop-blur border-b"> <header class="sticky top-0 z-30 bg-background/95 backdrop-blur border-b">
<div class="max-w-4xl mx-auto px-4 py-3"> <div class="max-w-4xl mx-auto px-4 py-3">

View file

@ -11,7 +11,7 @@ function onSubmissionClick(submission: SubmissionWithMeta) {
</script> </script>
<template> <template>
<div class="flex flex-col h-screen bg-background"> <div class="flex flex-col h-dvh bg-background">
<div class="sticky top-0 z-30 bg-background/95 backdrop-blur supports-[backdrop-filter]:bg-background/60 border-b"> <div class="sticky top-0 z-30 bg-background/95 backdrop-blur supports-[backdrop-filter]:bg-background/60 border-b">
<div class="max-w-4xl mx-auto flex items-center justify-between px-4 py-2 sm:px-6"> <div class="max-w-4xl mx-auto flex items-center justify-between px-4 py-2 sm:px-6">
<h1 class="text-lg font-semibold">Forum</h1> <h1 class="text-lg font-semibold">Forum</h1>

View file

@ -275,7 +275,7 @@ watch(comments, (newComments) => {
</script> </script>
<template> <template>
<div class="min-h-screen bg-background"> <div class="min-h-dvh bg-background">
<!-- Header --> <!-- Header -->
<header class="sticky top-0 z-30 bg-background/95 backdrop-blur border-b"> <header class="sticky top-0 z-30 bg-background/95 backdrop-blur border-b">
<div class="max-w-4xl mx-auto px-4 py-3"> <div class="max-w-4xl mx-auto px-4 py-3">

View file

@ -1,5 +1,5 @@
<template> <template>
<div class="flex flex-col h-screen bg-background"> <div class="flex flex-col h-dvh bg-background">
<!-- Header --> <!-- Header -->
<div class="sticky top-0 z-40 bg-background/95 backdrop-blur supports-[backdrop-filter]:bg-background/60 border-b"> <div class="sticky top-0 z-40 bg-background/95 backdrop-blur supports-[backdrop-filter]:bg-background/60 border-b">
<div class="flex items-center justify-between px-4 py-2 sm:px-6"> <div class="flex items-center justify-between px-4 py-2 sm:px-6">

View file

@ -1,5 +1,5 @@
<template> <template>
<div class="flex flex-col h-screen bg-background"> <div class="flex flex-col h-dvh bg-background">
<PWAInstallPrompt auto-show /> <PWAInstallPrompt auto-show />
<!-- Header --> <!-- Header -->

View file

@ -96,7 +96,7 @@ function notImplemented() {
</script> </script>
<template> <template>
<div class="relative h-screen flex flex-col text-foreground overflow-hidden bg-background" <div class="relative h-dvh flex flex-col text-foreground overflow-hidden bg-background"
style=" style="
background-image: background-image:
linear-gradient(to bottom, linear-gradient(to bottom,

View file

@ -1,5 +1,5 @@
<template> <template>
<div class="min-h-screen flex items-center justify-center p-4 bg-gradient-to-br from-background via-background to-muted/20"> <div class="min-h-dvh flex items-center justify-center p-4 bg-gradient-to-br from-background via-background to-muted/20">
<div class="w-full max-w-md space-y-8"> <div class="w-full max-w-md space-y-8">
<!-- Logo and Title --> <!-- Logo and Title -->
<div class="text-center space-y-6"> <div class="text-center space-y-6">

View file

@ -1,6 +1,6 @@
<template> <template>
<div <div
class="min-h-screen flex items-start sm:items-center justify-center px-4 sm:px-6 lg:px-8 xl:px-12 2xl:px-16 py-8 sm:py-12"> class="min-h-dvh flex items-start sm:items-center justify-center px-4 sm:px-6 lg:px-8 xl:px-12 2xl:px-16 py-8 sm:py-12">
<div class="flex flex-col items-center justify-center space-y-3 sm:space-y-6 max-w-4xl mx-auto w-full mt-8 sm:mt-0"> <div class="flex flex-col items-center justify-center space-y-3 sm:space-y-6 max-w-4xl mx-auto w-full mt-8 sm:mt-0">
<!-- Welcome Section --> <!-- Welcome Section -->
<div class="text-center space-y-2 sm:space-y-4"> <div class="text-center space-y-2 sm:space-y-4">

View file

@ -26,8 +26,8 @@ async function handleLoginSuccess() {
</script> </script>
<template> <template>
<div class="min-h-screen bg-background font-sans antialiased"> <div class="min-h-dvh bg-background font-sans antialiased">
<div class="relative flex min-h-screen flex-col" <div class="relative flex min-h-dvh flex-col"
style="padding-top: env(safe-area-inset-top); padding-bottom: env(safe-area-inset-bottom)"> style="padding-top: env(safe-area-inset-top); padding-bottom: env(safe-area-inset-bottom)">
<div v-if="!isLoginPage && !isAuthenticated" class="fixed top-0 right-0 z-50 p-3" style="padding-top: env(safe-area-inset-top)"> <div v-if="!isLoginPage && !isAuthenticated" class="fixed top-0 right-0 z-50 p-3" style="padding-top: env(safe-area-inset-top)">

View file

@ -26,8 +26,8 @@ async function handleLoginSuccess() {
</script> </script>
<template> <template>
<div class="min-h-screen bg-background font-sans antialiased"> <div class="min-h-dvh bg-background font-sans antialiased">
<div class="relative flex min-h-screen flex-col" <div class="relative flex min-h-dvh flex-col"
style="padding-top: env(safe-area-inset-top); padding-bottom: env(safe-area-inset-bottom)"> style="padding-top: env(safe-area-inset-top); padding-bottom: env(safe-area-inset-bottom)">
<!-- Top bar with login --> <!-- Top bar with login -->

View file

@ -2,12 +2,16 @@ import { fileURLToPath, URL } from 'node:url'
import vue from '@vitejs/plugin-vue' import vue from '@vitejs/plugin-vue'
import tailwindcss from '@tailwindcss/vite' import tailwindcss from '@tailwindcss/vite'
import { defineConfig } from 'vite' import { defineConfig } from 'vite'
import { VitePWA } from 'vite-plugin-pwa'
import Inspect from 'vite-plugin-inspect' import Inspect from 'vite-plugin-inspect'
import { ViteImageOptimizer } from 'vite-plugin-image-optimizer' import { ViteImageOptimizer } from 'vite-plugin-image-optimizer'
import { visualizer } from 'rollup-plugin-visualizer' import { visualizer } from 'rollup-plugin-visualizer'
// https://vite.dev/config/ // https://vite.dev/config/
//
// The hub is intentionally NOT a PWA (closes #41). Its scope `/` claimed
// the entire origin and blocked Chrome from offering installs for the
// path-mounted standalones at /libra/, /market/, etc. The hub is a
// launcher page; users install the standalones they actually use.
export default defineConfig(({ mode }) => ({ export default defineConfig(({ mode }) => ({
// Per-app dep cache so concurrent dev servers don't race on .vite/deps // Per-app dep cache so concurrent dev servers don't race on .vite/deps
cacheDir: 'node_modules/.vite-hub', cacheDir: 'node_modules/.vite-hub',
@ -18,54 +22,6 @@ export default defineConfig(({ mode }) => ({
plugins: [ plugins: [
vue(), vue(),
tailwindcss(), tailwindcss(),
VitePWA({
registerType: 'autoUpdate',
devOptions: {
// SW disabled in dev — was caching stale bundles across restarts.
// Run `npm run preview` to test PWA behaviour against a real build.
enabled: false
},
// strategies: 'injectManifest',
srcDir: 'public',
filename: 'sw.js',
workbox: {
globPatterns: [
'**/*.{js,css,html,ico,png,svg}'
],
// Don't intercept standalone app paths — they have their own service workers
navigateFallbackDenylist: [/^\/sortir\//, /^\/libra\//, /^\/wallet\//, /^\/chat\//, /^\/market\//, /^\/cart\//, /^\/checkout\//, /^\/tasks\//, /^\/forum\//, /^\/submit\//, /^\/submission\//],
},
includeAssets: [
'favicon.ico',
'apple-touch-icon.png',
'mask-icon.svg',
// optional: include the icon PNGs explicitly if you also reference them directly
'icon-192.png',
'icon-512.png',
'icon-maskable-192.png',
'icon-maskable-512.png',
],
manifest: {
name: 'AIO - Community Hub',
short_name: 'AIO',
description: 'Nostr-based community platform with Lightning Network integration for events, market and announcements',
theme_color: '#1f2937',
background_color: '#ffffff',
display: 'standalone',
orientation: 'portrait-primary',
start_url: '/',
scope: '/',
id: 'aio-community-hub',
categories: ['social', 'utilities'],
lang: 'en',
"icons": [
{ "src": "/icon-192.png", "sizes": "192x192", "type": "image/png", "purpose": "any" },
{ "src": "/icon-512.png", "sizes": "512x512", "type": "image/png", "purpose": "any" },
{ "src": "/icon-maskable-192.png", "sizes": "192x192", "type": "image/png", "purpose": "maskable" },
{ "src": "/icon-maskable-512.png", "sizes": "512x512", "type": "image/png", "purpose": "maskable" }
],
}
}),
Inspect(), Inspect(),
ViteImageOptimizer({ ViteImageOptimizer({
jpg: { jpg: {