diff --git a/forum.html b/forum.html
new file mode 100644
index 0000000..13e5e8a
--- /dev/null
+++ b/forum.html
@@ -0,0 +1,19 @@
+
+
+
+
+
+
+
+
+
+
+ Forum — Discussions
+
+
+
+
+
+
+
+
diff --git a/package.json b/package.json
index da537c9..95eafe8 100644
--- a/package.json
+++ b/package.json
@@ -15,6 +15,9 @@
"dev:castle": "vite --host --config vite.castle.config.ts",
"build:castle": "vue-tsc -b && vite build --config vite.castle.config.ts",
"preview:castle": "vite preview --host --config vite.castle.config.ts",
+ "dev:forum": "vite --host --config vite.forum.config.ts",
+ "build:forum": "vue-tsc -b && vite build --config vite.forum.config.ts",
+ "preview:forum": "vite preview --host --config vite.forum.config.ts",
"electron:dev": "concurrently \"vite --host\" \"electron-forge start\"",
"electron:build": "vue-tsc -b && vite build && electron-builder",
"electron:package": "electron-builder",
diff --git a/src/forum-app/App.vue b/src/forum-app/App.vue
new file mode 100644
index 0000000..8c7f8ba
--- /dev/null
+++ b/src/forum-app/App.vue
@@ -0,0 +1,47 @@
+
+
+
+
+
diff --git a/src/forum-app/app.config.ts b/src/forum-app/app.config.ts
new file mode 100644
index 0000000..2860abc
--- /dev/null
+++ b/src/forum-app/app.config.ts
@@ -0,0 +1,50 @@
+import type { AppConfig } from '@/core/types'
+
+/**
+ * Standalone Forum app configuration.
+ * Only enables base + forum 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']
+ }
+ }
+ },
+ forum: {
+ name: 'forum',
+ enabled: true,
+ lazy: false,
+ config: {
+ maxSubmissions: 50,
+ corsProxyUrl: import.meta.env.VITE_CORS_PROXY_URL || '',
+ adminPubkeys: JSON.parse(import.meta.env.VITE_ADMIN_PUBKEYS || '[]')
+ }
+ },
+ },
+
+ features: {
+ pwa: true,
+ pushNotifications: true,
+ electronApp: false,
+ developmentMode: import.meta.env.DEV
+ }
+}
+
+export default appConfig
diff --git a/src/forum-app/app.ts b/src/forum-app/app.ts
new file mode 100644
index 0000000..55d25ad
--- /dev/null
+++ b/src/forum-app/app.ts
@@ -0,0 +1,137 @@
+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 forumModule from '@/modules/forum'
+
+import App from './App.vue'
+
+import '@/assets/index.css'
+import { i18n, changeLocale, type AvailableLocale } from '@/i18n'
+
+function acceptTokenFromUrl() {
+ const params = new URLSearchParams(window.location.search)
+ const token = params.get('token')
+ if (token) {
+ localStorage.setItem('lnbits_access_token', token)
+ params.delete('token')
+ const clean = params.toString()
+ const newUrl = window.location.pathname + (clean ? `?${clean}` : '') + window.location.hash
+ window.history.replaceState({}, '', newUrl)
+ console.log('[Forum] Auth token accepted from URL')
+ }
+}
+
+export async function createAppInstance() {
+ console.log('Starting Forum app...')
+
+ acceptTokenFromUrl()
+
+ const app = createApp(App)
+
+ const moduleRoutes = [
+ ...baseModule.routes || [],
+ ...forumModule.routes || [],
+ ].filter(Boolean)
+
+ const router = createRouter({
+ history: createWebHistory(import.meta.env.BASE_URL),
+ routes: [
+ {
+ path: '/',
+ redirect: '/forum'
+ },
+ {
+ path: '/login',
+ name: 'login',
+ component: import.meta.env.VITE_DEMO_MODE === 'true'
+ ? () => import('@/pages/LoginDemo.vue')
+ : () => import('@/pages/Login.vue'),
+ meta: { requiresAuth: false }
+ },
+ ...moduleRoutes,
+ ]
+ })
+
+ const pinia = createPinia()
+
+ app.use(router)
+ app.use(pinia)
+ app.use(i18n)
+
+ const defaultLocale = import.meta.env.VITE_DEFAULT_LOCALE as AvailableLocale | undefined
+ if (defaultLocale && !localStorage.getItem('user-locale')) {
+ await changeLocale(defaultLocale)
+ }
+
+ pluginManager.init(app, router)
+
+ const moduleRegistrations = []
+
+ if (appConfig.modules.base.enabled) {
+ moduleRegistrations.push(
+ pluginManager.register(baseModule, appConfig.modules.base)
+ )
+ }
+
+ if (appConfig.modules.forum?.enabled) {
+ moduleRegistrations.push(
+ pluginManager.register(forumModule, appConfig.modules.forum)
+ )
+ }
+
+ await Promise.all(moduleRegistrations)
+ await pluginManager.installAll()
+
+ const { auth } = await import('@/composables/useAuthService')
+ await auth.initialize()
+
+ 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()
+ }
+ })
+
+ 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('Forum app initialized')
+ return { app, router }
+}
+
+export async function startApp() {
+ try {
+ const { app } = await createAppInstance()
+ app.mount('#app')
+ console.log('Forum app started!')
+ eventBus.emit('app:started', {}, 'app')
+ } catch (error) {
+ console.error('Failed to start Forum app:', error)
+ document.getElementById('app')!.innerHTML = `
+
+
Failed to Start
+
${error instanceof Error ? error.message : 'Unknown error'}
+
Please refresh the page.
+
+ `
+ }
+}
diff --git a/src/forum-app/main.ts b/src/forum-app/main.ts
new file mode 100644
index 0000000..900623b
--- /dev/null
+++ b/src/forum-app/main.ts
@@ -0,0 +1,17 @@
+import { startApp } from './app'
+import { registerSW } from 'virtual:pwa-register'
+import 'vue-sonner/style.css'
+
+const intervalMS = 60 * 60 * 1000
+registerSW({
+ onRegistered(r) {
+ r && setInterval(() => {
+ r.update()
+ }, intervalMS)
+ },
+ onOfflineReady() {
+ console.log('Forum app ready to work offline')
+ }
+})
+
+startApp()
diff --git a/src/modules/forum/index.ts b/src/modules/forum/index.ts
index b9e0a4f..c8b645b 100644
--- a/src/modules/forum/index.ts
+++ b/src/modules/forum/index.ts
@@ -25,6 +25,12 @@ export const forumModule: ModulePlugin = {
],
routes: [
+ {
+ path: '/forum',
+ name: 'forum',
+ component: () => import('./views/ForumListPage.vue'),
+ meta: { title: 'Forum', requiresAuth: false }
+ },
{
path: '/submission/:id',
name: 'submission-detail',
diff --git a/src/modules/forum/views/ForumListPage.vue b/src/modules/forum/views/ForumListPage.vue
new file mode 100644
index 0000000..9c9246b
--- /dev/null
+++ b/src/modules/forum/views/ForumListPage.vue
@@ -0,0 +1,32 @@
+
+
+
+
+
diff --git a/vite.config.ts b/vite.config.ts
index fee1c90..6a5eb28 100644
--- a/vite.config.ts
+++ b/vite.config.ts
@@ -25,7 +25,7 @@ export default defineConfig(({ mode }) => ({
'**/*.{js,css,html,ico,png,svg}'
],
// Don't intercept standalone app paths — they have their own service workers
- navigateFallbackDenylist: [/^\/sortir\//, /^\/castle\//],
+ navigateFallbackDenylist: [/^\/sortir\//, /^\/castle\//, /^\/forum\//, /^\/submit\//, /^\/submission\//],
},
includeAssets: [
'favicon.ico',
diff --git a/vite.forum.config.ts b/vite.forum.config.ts
new file mode 100644
index 0000000..1726bb5
--- /dev/null
+++ b/vite.forum.config.ts
@@ -0,0 +1,115 @@
+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'
+
+function forumHtmlPlugin(): Plugin {
+ return {
+ name: 'forum-html-rewrite',
+ configureServer(server) {
+ server.middlewares.use((req, _res, next) => {
+ if (
+ req.url &&
+ !req.url.startsWith('/@') &&
+ !req.url.startsWith('/src/') &&
+ !req.url.startsWith('/node_modules/') &&
+ !req.url.includes('.')
+ ) {
+ req.url = '/forum.html'
+ }
+ next()
+ })
+ },
+ }
+}
+
+/**
+ * Vite config for the standalone Forum app.
+ *
+ * Set VITE_BASE_PATH to deploy under a path prefix:
+ * VITE_BASE_PATH=/forum/ → app.${domain}/forum/ (shared auth)
+ * (default: /) → forum.${domain} (standalone subdomain)
+ */
+export default defineConfig(({ mode }) => ({
+ base: process.env.VITE_BASE_PATH || '/',
+ plugins: [
+ forumHtmlPlugin(),
+ vue(),
+ tailwindcss(),
+ VitePWA({
+ registerType: 'autoUpdate',
+ devOptions: { enabled: true },
+ workbox: {
+ globPatterns: ['**/*.{js,css,html,ico,png,svg}'],
+ navigateFallback: 'forum.html',
+ navigateFallbackAllowlist: [
+ new RegExp(`^${(process.env.VITE_BASE_PATH || '/').replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}`),
+ ],
+ },
+ 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: 'Forum — Discussions',
+ short_name: 'Forum',
+ description: 'Decentralized link aggregator and discussion forum on Nostr',
+ theme_color: '#2563eb',
+ background_color: '#ffffff',
+ display: 'standalone',
+ orientation: 'portrait-primary',
+ start_url: process.env.VITE_BASE_PATH || '/',
+ scope: process.env.VITE_BASE_PATH || '/',
+ id: 'forum-app',
+ categories: ['social', 'news'],
+ 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' },
+ ],
+ },
+ }),
+ ViteImageOptimizer({
+ jpg: { quality: 80 },
+ png: { quality: 80 },
+ webp: { lossless: true },
+ }),
+ mode === 'analyze' &&
+ visualizer({
+ open: true,
+ filename: 'dist-forum/stats.html',
+ gzipSize: true,
+ brotliSize: true,
+ }),
+ ],
+ resolve: {
+ alias: {
+ '@': fileURLToPath(new URL('./src', import.meta.url)),
+ '@/app.config': fileURLToPath(new URL('./src/forum-app/app.config.ts', import.meta.url)),
+ },
+ },
+ build: {
+ outDir: 'dist-forum',
+ rollupOptions: {
+ input: 'forum.html',
+ output: {
+ manualChunks: {
+ 'vue-vendor': ['vue', 'vue-router', 'pinia'],
+ 'ui-vendor': ['radix-vue', '@vueuse/core'],
+ 'shadcn': ['class-variance-authority', 'clsx', 'tailwind-merge'],
+ },
+ },
+ },
+ chunkSizeWarningLimit: 1000,
+ },
+}))