Add standalone Sortir activities app (sortir.ariege.io)

Second Vite entry point for deploying the activities module as an
independent PWA at sortir.ariege.io. Includes its own App.vue with
bottom navigation bar (p'a semana style: Feed, Calendar, Map, Favorites,
Settings), stripped-down app config (base + activities only), French
PWA manifest, and SPA fallback plugin for dev server. New routes for
calendar, map, and favorites views (placeholder). Settings page with
theme toggle, language switcher (FR/EN), and auth.

Build: npm run build:activities -> dist-activities/
Dev:   npm run dev:activities -> localhost:5173

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Padreug 2026-04-20 07:40:26 +02:00
commit 00eddc9189
12 changed files with 578 additions and 0 deletions

19
activities.html Normal file
View file

@ -0,0 +1,19 @@
<!doctype html>
<html lang="fr">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no, viewport-fit=cover" />
<meta name="apple-mobile-web-app-capable" content="yes">
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent">
<link rel="icon" href="/favicon.ico" />
<link rel="apple-touch-icon" href="/apple-touch-icon.png" sizes="180x180">
<link rel="mask-icon" href="/mask-icon.svg" color="#FFFFFF">
<title>Sortir — Activités</title>
<meta name="apple-mobile-web-app-title" content="Sortir">
<meta name="description" content="Découvrez les activités et événements près de chez vous">
</head>
<body>
<div id="app"></div>
<script type="module" src="/src/activities-app/main.ts"></script>
</body>
</html>

View file

@ -9,6 +9,9 @@
"build": "vue-tsc -b && vite build", "build": "vue-tsc -b && vite build",
"preview": "vite preview --host", "preview": "vite preview --host",
"analyze": "vite build --mode analyze", "analyze": "vite build --mode analyze",
"dev:activities": "vite --host --config vite.activities.config.ts",
"build:activities": "vue-tsc -b && vite build --config vite.activities.config.ts",
"preview:activities": "vite preview --host --config vite.activities.config.ts",
"electron:dev": "concurrently \"vite --host\" \"electron-forge start\"", "electron:dev": "concurrently \"vite --host\" \"electron-forge start\"",
"electron:build": "vue-tsc -b && vite build && electron-builder", "electron:build": "vue-tsc -b && vite build && electron-builder",
"electron:package": "electron-builder", "electron:package": "electron-builder",

View file

@ -0,0 +1,82 @@
<script setup lang="ts">
import { computed, ref } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { Toaster } from '@/components/ui/sonner'
import LoginDialog from '@/components/auth/LoginDialog.vue'
import { useTheme } from '@/components/theme-provider'
import { toast } from 'vue-sonner'
import {
CalendarDays, Map, Heart, Settings, Search,
} from 'lucide-vue-next'
const route = useRoute()
const router = useRouter()
useTheme()
const showLoginDialog = ref(false)
// Bottom navigation tabs (p'a semana style)
const bottomTabs = [
{ name: 'Feed', icon: Search, path: '/activities' },
{ name: 'Calendar', icon: CalendarDays, path: '/activities/calendar' },
{ name: 'Map', icon: Map, path: '/activities/map' },
{ name: 'Favorites', icon: Heart, path: '/activities/favorites' },
{ name: 'Settings', icon: Settings, path: '/settings' },
]
const isLoginPage = computed(() => route.path === '/login')
function isActiveTab(path: string): boolean {
if (path === '/activities') {
return route.path === '/activities' || route.path.startsWith('/activities/') &&
!route.path.startsWith('/activities/calendar') &&
!route.path.startsWith('/activities/map') &&
!route.path.startsWith('/activities/favorites')
}
return route.path.startsWith(path)
}
async function handleLoginSuccess() {
showLoginDialog.value = false
toast.success('Welcome!')
}
</script>
<template>
<div class="min-h-screen bg-background font-sans antialiased">
<div class="relative flex min-h-screen flex-col"
style="padding-top: env(safe-area-inset-top); padding-bottom: env(safe-area-inset-bottom)">
<!-- Main content (with bottom padding for nav bar) -->
<main class="flex-1" :class="{ 'pb-16': !isLoginPage }">
<router-view />
</main>
<!-- Bottom navigation bar (p'a semana style) -->
<nav
v-if="!isLoginPage"
class="fixed bottom-0 left-0 right-0 z-50 border-t bg-background/95 backdrop-blur supports-[backdrop-filter]:bg-background/60"
style="padding-bottom: env(safe-area-inset-bottom)"
>
<div class="flex items-center justify-around h-14 max-w-lg mx-auto">
<button
v-for="tab in bottomTabs"
:key="tab.path"
class="flex flex-col items-center justify-center gap-0.5 flex-1 h-full transition-colors"
:class="isActiveTab(tab.path)
? 'text-primary'
: 'text-muted-foreground hover:text-foreground'"
@click="router.push(tab.path)"
>
<component :is="tab.icon" class="w-5 h-5" />
<span class="text-[10px] font-medium">{{ tab.name }}</span>
</button>
</div>
</nav>
</div>
<Toaster />
<LoginDialog v-model:is-open="showLoginDialog" @success="handleLoginSuccess" />
</div>
</template>

View file

@ -0,0 +1,55 @@
import type { AppConfig } from '@/core/types'
/**
* Standalone activities app configuration.
* Only enables base + activities modules.
*/
export const appConfig: AppConfig = {
modules: {
base: {
name: 'base',
enabled: true,
lazy: false,
config: {
nostr: {
relays: JSON.parse(import.meta.env.VITE_NOSTR_RELAYS || '["wss://relay.damus.io", "wss://nos.lol"]')
},
auth: {
sessionTimeout: 24 * 60 * 60 * 1000,
},
pwa: {
autoPrompt: true
},
imageUpload: {
baseUrl: import.meta.env.VITE_PICTRS_BASE_URL || 'https://img.mydomain.com',
maxSizeMB: 10,
acceptedTypes: ['image/jpeg', 'image/png', 'image/webp', 'image/avif', 'image/gif']
}
}
},
activities: {
name: 'activities',
enabled: true,
lazy: false,
config: {
apiConfig: {
baseUrl: import.meta.env.VITE_LNBITS_BASE_URL || 'http://localhost:5000',
apiKey: import.meta.env.VITE_API_KEY || ''
},
defaultMapCenter: { lat: 42.9667, lng: 1.6000 }, // Ariège, France
maxTicketsPerUser: 10,
enableMap: true,
enablePrivateEvents: false
}
},
},
features: {
pwa: true,
pushNotifications: true,
electronApp: false,
developmentMode: import.meta.env.DEV
}
}
export default appConfig

132
src/activities-app/app.ts Normal file
View file

@ -0,0 +1,132 @@
import { createApp } from 'vue'
import { createRouter, createWebHistory } from 'vue-router'
import { createPinia } from 'pinia'
import { pluginManager } from '@/core/plugin-manager'
import { eventBus } from '@/core/event-bus'
import { container } from '@/core/di-container'
import appConfig from './app.config'
import baseModule from '@/modules/base'
import activitiesModule from '@/modules/activities'
import App from './App.vue'
import '@/assets/index.css'
import { i18n } from '@/i18n'
/**
* Initialize the standalone activities app
*/
export async function createAppInstance() {
console.log('🚀 Starting Sortir — Activities App...')
const app = createApp(App)
// Collect routes from enabled modules only
const moduleRoutes = [
...baseModule.routes || [],
...activitiesModule.routes || [],
].filter(Boolean)
const router = createRouter({
history: createWebHistory(),
routes: [
// Activities page is the home page in standalone mode
{
path: '/',
redirect: '/activities'
},
{
path: '/login',
name: 'login',
component: () => import('@/pages/Login.vue'),
meta: { requiresAuth: false }
},
...moduleRoutes,
// App-specific routes
{
path: '/settings',
name: 'settings',
component: () => import('./views/SettingsPage.vue'),
meta: { requiresAuth: false }
},
]
})
const pinia = createPinia()
app.use(router)
app.use(pinia)
app.use(i18n)
// Initialize plugin manager
pluginManager.init(app, router)
// Register modules
const moduleRegistrations = []
if (appConfig.modules.base.enabled) {
moduleRegistrations.push(
pluginManager.register(baseModule, appConfig.modules.base)
)
}
if (appConfig.modules.activities?.enabled) {
moduleRegistrations.push(
pluginManager.register(activitiesModule, appConfig.modules.activities)
)
}
await Promise.all(moduleRegistrations)
await pluginManager.installAll()
// Initialize auth
const { auth } = await import('@/composables/useAuthService')
await auth.initialize()
// Auth guard — only redirect for routes that explicitly require auth
router.beforeEach(async (to, _from, next) => {
const requiresAuth = to.meta.requiresAuth === true
if (requiresAuth && !auth.isAuthenticated.value) {
next('/login')
} else if (to.path === '/login' && auth.isAuthenticated.value) {
next('/')
} else {
next()
}
})
// Global error handling
app.config.errorHandler = (err, _vm, info) => {
console.error('Global error:', err, info)
eventBus.emit('app:error', { error: err, info }, 'app')
}
if (appConfig.features.developmentMode) {
;(window as any).__pluginManager = pluginManager
;(window as any).__eventBus = eventBus
;(window as any).__container = container
}
console.log('✅ Sortir app initialized')
return { app, router }
}
export async function startApp() {
try {
const { app } = await createAppInstance()
app.mount('#app')
console.log('🎉 Sortir app started!')
eventBus.emit('app:started', {}, 'app')
} catch (error) {
console.error('💥 Failed to start Sortir app:', error)
document.getElementById('app')!.innerHTML = `
<div style="padding: 20px; text-align: center; color: red;">
<h1>Failed to Start</h1>
<p>${error instanceof Error ? error.message : 'Unknown error'}</p>
<p>Please refresh the page.</p>
</div>
`
}
}

View file

@ -0,0 +1,18 @@
import { startApp } from './app'
import { registerSW } from 'virtual:pwa-register'
import 'vue-sonner/style.css'
// PWA service worker with periodic updates
const intervalMS = 60 * 60 * 1000 // 1 hour
registerSW({
onRegistered(r) {
r && setInterval(() => {
r.update()
}, intervalMS)
},
onOfflineReady() {
console.log('Sortir app ready to work offline')
}
})
startApp()

View file

@ -0,0 +1,89 @@
<script setup lang="ts">
import { computed } from 'vue'
import { Button } from '@/components/ui/button'
import { Separator } from '@/components/ui/separator'
import { useTheme } from '@/components/theme-provider'
import { changeLocale, type AvailableLocale } from '@/i18n'
import { auth } from '@/composables/useAuthService'
import { useI18n } from 'vue-i18n'
import { Sun, Moon, LogIn, LogOut } from 'lucide-vue-next'
const { theme, setTheme } = useTheme()
const { locale } = useI18n()
const isAuthenticated = computed(() => auth.isAuthenticated.value)
const userPubkey = computed(() => auth.currentUser.value?.pubkey)
function toggleTheme() {
setTheme(theme.value === 'dark' ? 'light' : 'dark')
}
function setLanguage(lang: AvailableLocale) {
changeLocale(lang)
}
async function handleLogout() {
await auth.logout()
}
</script>
<template>
<div class="container mx-auto px-4 py-6 max-w-lg">
<h1 class="text-2xl font-bold text-foreground mb-6">Settings</h1>
<!-- Account -->
<div class="space-y-4">
<h2 class="text-sm font-medium text-muted-foreground uppercase tracking-wide">Account</h2>
<div v-if="isAuthenticated" class="bg-muted/50 rounded-lg p-4 space-y-3">
<p class="text-sm text-foreground font-mono truncate">
{{ userPubkey }}
</p>
<Button variant="outline" size="sm" class="w-full gap-2" @click="handleLogout">
<LogOut class="w-4 h-4" />
Log out
</Button>
</div>
<div v-else class="bg-muted/50 rounded-lg p-4">
<p class="text-sm text-muted-foreground mb-3">
Log in to bookmark activities, RSVP, and purchase tickets.
</p>
<Button size="sm" class="w-full gap-2" @click="$router.push('/login')">
<LogIn class="w-4 h-4" />
Log in
</Button>
</div>
</div>
<Separator class="my-6" />
<!-- Appearance -->
<div class="space-y-4">
<h2 class="text-sm font-medium text-muted-foreground uppercase tracking-wide">Appearance</h2>
<div class="flex items-center justify-between bg-muted/50 rounded-lg p-4">
<span class="text-sm text-foreground">Theme</span>
<Button variant="outline" size="icon" class="h-8 w-8" @click="toggleTheme">
<Sun v-if="theme === 'dark'" class="w-4 h-4" />
<Moon v-else class="w-4 h-4" />
</Button>
</div>
</div>
<Separator class="my-6" />
<!-- Language -->
<div class="space-y-4">
<h2 class="text-sm font-medium text-muted-foreground uppercase tracking-wide">Language</h2>
<div class="flex gap-2">
<Button
v-for="lang in (['fr', 'en'] as const)"
:key="lang"
:variant="locale === lang ? 'default' : 'outline'"
size="sm"
@click="setLanguage(lang)"
>
{{ lang === 'fr' ? 'Français' : 'English' }}
</Button>
</div>
</div>
</div>
</template>

View file

@ -32,6 +32,33 @@ export const activitiesModule = createModulePlugin({
requiresAuth: false, requiresAuth: false,
}, },
}, },
{
path: '/activities/calendar',
name: 'activities-calendar',
component: () => import('./views/ActivitiesCalendarPage.vue'),
meta: {
title: 'Calendar',
requiresAuth: false,
},
},
{
path: '/activities/map',
name: 'activities-map',
component: () => import('./views/ActivitiesMapPage.vue'),
meta: {
title: 'Map',
requiresAuth: false,
},
},
{
path: '/activities/favorites',
name: 'activities-favorites',
component: () => import('./views/ActivitiesFavoritesPage.vue'),
meta: {
title: 'Favorites',
requiresAuth: true,
},
},
{ {
path: '/activities/:id', path: '/activities/:id',
name: 'activity-detail', name: 'activity-detail',

View file

@ -0,0 +1,13 @@
<script setup lang="ts">
import { CalendarDays } from 'lucide-vue-next'
</script>
<template>
<div class="container mx-auto px-4 py-6">
<h1 class="text-2xl font-bold text-foreground mb-4">Calendar</h1>
<div class="flex flex-col items-center justify-center py-16 text-center">
<CalendarDays class="w-16 h-16 text-muted-foreground/30 mb-4" />
<p class="text-muted-foreground">Calendar view coming in Phase 5</p>
</div>
</div>
</template>

View file

@ -0,0 +1,14 @@
<script setup lang="ts">
import { Heart } from 'lucide-vue-next'
</script>
<template>
<div class="container mx-auto px-4 py-6">
<h1 class="text-2xl font-bold text-foreground mb-4">Favorites</h1>
<div class="flex flex-col items-center justify-center py-16 text-center">
<Heart class="w-16 h-16 text-muted-foreground/30 mb-4" />
<p class="text-muted-foreground">Bookmark your favorite activities</p>
<p class="text-sm text-muted-foreground/70 mt-1">Coming in Phase 4</p>
</div>
</div>
</template>

View file

@ -0,0 +1,13 @@
<script setup lang="ts">
import { Map } from 'lucide-vue-next'
</script>
<template>
<div class="container mx-auto px-4 py-6">
<h1 class="text-2xl font-bold text-foreground mb-4">Map</h1>
<div class="flex flex-col items-center justify-center py-16 text-center">
<Map class="w-16 h-16 text-muted-foreground/30 mb-4" />
<p class="text-muted-foreground">Map view coming in Phase 5</p>
</div>
</div>
</template>

113
vite.activities.config.ts Normal file
View file

@ -0,0 +1,113 @@
import { fileURLToPath, URL } from 'node:url'
import vue from '@vitejs/plugin-vue'
import tailwindcss from '@tailwindcss/vite'
import { defineConfig, type Plugin } from 'vite'
import { VitePWA } from 'vite-plugin-pwa'
import { ViteImageOptimizer } from 'vite-plugin-image-optimizer'
import { visualizer } from 'rollup-plugin-visualizer'
/**
* Plugin to rewrite dev server requests to activities.html
* (SPA fallback for the standalone activities app entry point)
*/
function activitiesHtmlPlugin(): Plugin {
return {
name: 'activities-html-rewrite',
configureServer(server) {
server.middlewares.use((req, _res, next) => {
// Rewrite all non-asset requests to activities.html
if (
req.url &&
!req.url.startsWith('/@') &&
!req.url.startsWith('/src/') &&
!req.url.startsWith('/node_modules/') &&
!req.url.includes('.') // skip files with extensions
) {
req.url = '/activities.html'
}
next()
})
},
}
}
/**
* Vite config for the standalone Sortir activities app.
* Deployed to sortir.ariege.io
*/
export default defineConfig(({ mode }) => ({
plugins: [
activitiesHtmlPlugin(),
vue(),
tailwindcss(),
VitePWA({
registerType: 'autoUpdate',
devOptions: {
enabled: true,
},
workbox: {
globPatterns: ['**/*.{js,css,html,ico,png,svg}'],
},
includeAssets: [
'favicon.ico',
'apple-touch-icon.png',
'mask-icon.svg',
'icon-192.png',
'icon-512.png',
'icon-maskable-192.png',
'icon-maskable-512.png',
],
manifest: {
name: 'Sortir — Activités & Événements',
short_name: 'Sortir',
description: 'Découvrez les activités et événements près de chez vous',
theme_color: '#1f2937',
background_color: '#ffffff',
display: 'standalone',
orientation: 'portrait-primary',
start_url: '/',
scope: '/',
id: 'sortir-activities',
categories: ['social', 'entertainment', 'lifestyle'],
lang: 'fr',
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' },
],
},
}),
ViteImageOptimizer({
jpg: { quality: 80 },
png: { quality: 80 },
webp: { lossless: true },
}),
mode === 'analyze' &&
visualizer({
open: true,
filename: 'dist-activities/stats.html',
gzipSize: true,
brotliSize: true,
}),
],
resolve: {
alias: {
'@': fileURLToPath(new URL('./src', import.meta.url)),
},
},
build: {
outDir: 'dist-activities',
rollupOptions: {
input: 'activities.html',
output: {
manualChunks: {
'vue-vendor': ['vue', 'vue-router', 'pinia'],
'ui-vendor': ['radix-vue', '@vueuse/core'],
'shadcn': ['class-variance-authority', 'clsx', 'tailwind-merge'],
},
},
},
chunkSizeWarningLimit: 1000,
},
}))