diff --git a/castle.html b/castle.html new file mode 100644 index 0000000..fe3f567 --- /dev/null +++ b/castle.html @@ -0,0 +1,19 @@ + + + + + + + + + + + Castle — Accounting + + + + +
+ + + diff --git a/package.json b/package.json index d73e560..da537c9 100644 --- a/package.json +++ b/package.json @@ -12,6 +12,9 @@ "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", + "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", "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/accounting-app/App.vue b/src/accounting-app/App.vue new file mode 100644 index 0000000..c60c051 --- /dev/null +++ b/src/accounting-app/App.vue @@ -0,0 +1,84 @@ + + + diff --git a/src/accounting-app/app.config.ts b/src/accounting-app/app.config.ts new file mode 100644 index 0000000..e588657 --- /dev/null +++ b/src/accounting-app/app.config.ts @@ -0,0 +1,73 @@ +import type { AppConfig } from '@/core/types' + +/** + * Standalone Castle accounting app configuration. + * Only enables base + expenses + wallet 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'] + } + } + }, + expenses: { + name: 'expenses', + enabled: true, + lazy: false, + config: { + apiConfig: { + baseUrl: import.meta.env.VITE_LNBITS_BASE_URL || 'http://localhost:5000', + timeout: 30000 + }, + defaultCurrency: 'sats', + maxExpenseAmount: 1000000, + requireDescription: true + } + }, + wallet: { + name: 'wallet', + enabled: true, + lazy: false, + config: { + defaultReceiveAmount: 1000, + maxReceiveAmount: 1000000, + apiConfig: { + baseUrl: import.meta.env.VITE_LNBITS_BASE_URL || 'http://localhost:5000' + }, + websocket: { + enabled: import.meta.env.VITE_WEBSOCKET_ENABLED !== 'false', + reconnectDelay: 2000, + maxReconnectAttempts: 3, + fallbackToPolling: true, + pollingInterval: 10000 + } + } + }, + }, + + features: { + pwa: true, + pushNotifications: true, + electronApp: false, + developmentMode: import.meta.env.DEV + } +} + +export default appConfig diff --git a/src/accounting-app/app.ts b/src/accounting-app/app.ts new file mode 100644 index 0000000..ea03522 --- /dev/null +++ b/src/accounting-app/app.ts @@ -0,0 +1,181 @@ +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 expensesModule from '@/modules/expenses' +import walletModule from '@/modules/wallet' + +import App from './App.vue' + +import '@/assets/index.css' +import { i18n, changeLocale, type AvailableLocale } from '@/i18n' + +/** + * Accept an auth token from a URL parameter (e.g. ?token=xxx). + * This allows the main app to link users directly into Castle + * without requiring a separate login. The token is stored in + * localStorage and the parameter is stripped from the URL. + */ +function acceptTokenFromUrl() { + const params = new URLSearchParams(window.location.search) + const token = params.get('token') + if (token) { + localStorage.setItem('lnbits_access_token', token) + // Also persist user data key so auth service picks it up + params.delete('token') + const clean = params.toString() + const newUrl = window.location.pathname + (clean ? `?${clean}` : '') + window.location.hash + window.history.replaceState({}, '', newUrl) + console.log('[Castle] Auth token accepted from URL') + } +} + +/** + * Initialize the standalone Castle accounting app + */ +export async function createAppInstance() { + console.log('Starting Castle — Accounting App...') + + // Accept token from URL before anything else (cross-subdomain auth relay) + acceptTokenFromUrl() + + const app = createApp(App) + + // Collect routes from enabled modules only + const moduleRoutes = [ + ...baseModule.routes || [], + ...expensesModule.routes || [], + ...walletModule.routes || [], + ].filter(Boolean) + + const router = createRouter({ + history: createWebHistory(), + routes: [ + // Record page is the home page in standalone mode + { + path: '/', + redirect: '/record' + }, + { + path: '/login', + name: 'login', + component: () => import('@/pages/Login.vue'), + meta: { requiresAuth: false } + }, + ...moduleRoutes, + // App-specific routes + { + path: '/record', + name: 'record', + component: () => import('./views/RecordPage.vue'), + meta: { requiresAuth: true } + }, + { + path: '/balance', + name: 'balance', + component: () => import('./views/BalancePage.vue'), + meta: { requiresAuth: true } + }, + { + path: '/settings', + name: 'settings', + component: () => import('./views/SettingsPage.vue'), + meta: { requiresAuth: false } + }, + ] + }) + + const pinia = createPinia() + + app.use(router) + app.use(pinia) + app.use(i18n) + + // Set default locale from env (user's saved preference takes priority via useStorage in i18n) + const defaultLocale = import.meta.env.VITE_DEFAULT_LOCALE as AvailableLocale | undefined + if (defaultLocale && !localStorage.getItem('user-locale')) { + await changeLocale(defaultLocale) + } + + // 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.expenses?.enabled) { + moduleRegistrations.push( + pluginManager.register(expensesModule, appConfig.modules.expenses) + ) + } + + if (appConfig.modules.wallet?.enabled) { + moduleRegistrations.push( + pluginManager.register(walletModule, appConfig.modules.wallet) + ) + } + + 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('Castle app initialized') + return { app, router } +} + +export async function startApp() { + try { + const { app } = await createAppInstance() + app.mount('#app') + console.log('Castle app started!') + eventBus.emit('app:started', {}, 'app') + } catch (error) { + console.error('Failed to start Castle app:', error) + document.getElementById('app')!.innerHTML = ` +
+

Failed to Start

+

${error instanceof Error ? error.message : 'Unknown error'}

+

Please refresh the page.

+
+ ` + } +} diff --git a/src/accounting-app/composables/useExpenseDrafts.ts b/src/accounting-app/composables/useExpenseDrafts.ts new file mode 100644 index 0000000..54079c5 --- /dev/null +++ b/src/accounting-app/composables/useExpenseDrafts.ts @@ -0,0 +1,163 @@ +import { computed } from 'vue' +import { useStorage } from '@vueuse/core' +import type { Account } from '@/modules/expenses/types' + +/** + * BTC price snapshot captured at draft creation time. + * Preserves the exchange rate for reference since BTC is volatile. + */ +export interface BtcPriceSnapshot { + price: number + currency: string // e.g. "EUR", "USD" + timestamp: string // ISO timestamp +} + +/** + * Expense draft stored in localStorage. + * Allows users to save partial entries and finish them later. + */ +export interface ExpenseDraft { + id: string + created_at: string + type: 'expense' | 'income' + account?: Account + description?: string + amount?: number + currency?: string + reference?: string + is_equity?: boolean + receipt_urls?: string[] + btc_price_snapshot?: BtcPriceSnapshot +} + +const STORAGE_KEY = 'castle-expense-drafts' + +/** + * Composable for managing expense drafts in localStorage. + * Drafts persist across sessions and are deleted when submitted. + */ +export function useExpenseDrafts() { + const drafts = useStorage(STORAGE_KEY, []) + + const draftCount = computed(() => drafts.value.length) + const hasDrafts = computed(() => drafts.value.length > 0) + + /** + * Create a new draft. Returns the draft ID. + */ + function createDraft(partial: Partial>): string { + const id = crypto.randomUUID() + const draft: ExpenseDraft = { + id, + created_at: new Date().toISOString(), + type: partial.type ?? 'expense', + account: partial.account, + description: partial.description, + amount: partial.amount, + currency: partial.currency, + reference: partial.reference, + is_equity: partial.is_equity, + receipt_urls: partial.receipt_urls, + btc_price_snapshot: partial.btc_price_snapshot, + } + drafts.value = [draft, ...drafts.value] + return id + } + + /** + * Update an existing draft. + */ + function updateDraft(id: string, updates: Partial>) { + const index = drafts.value.findIndex(d => d.id === id) + if (index === -1) return + + drafts.value = drafts.value.map((d, i) => + i === index ? { ...d, ...updates } : d + ) + } + + /** + * Delete a draft (e.g. after successful submission). + */ + function deleteDraft(id: string) { + drafts.value = drafts.value.filter(d => d.id !== id) + } + + /** + * Get a draft by ID. + */ + function getDraft(id: string): ExpenseDraft | undefined { + return drafts.value.find(d => d.id === id) + } + + /** + * Add a receipt URL to a draft. + */ + function addReceiptToDraft(id: string, url: string) { + const draft = drafts.value.find(d => d.id === id) + if (!draft) return + + const urls = [...(draft.receipt_urls ?? []), url] + updateDraft(id, { receipt_urls: urls }) + } + + /** + * Remove a receipt URL from a draft. + */ + function removeReceiptFromDraft(id: string, url: string) { + const draft = drafts.value.find(d => d.id === id) + if (!draft) return + + const urls = (draft.receipt_urls ?? []).filter(u => u !== url) + updateDraft(id, { receipt_urls: urls }) + } + + /** + * Fetch current BTC price from LNbits and return a snapshot. + * Uses the /api/v1/conversion endpoint if available. + */ + async function captureBtcPrice(fiatCurrency: string = 'EUR'): Promise { + try { + const baseUrl = import.meta.env.VITE_LNBITS_BASE_URL || 'http://localhost:5000' + const response = await fetch( + `${baseUrl}/api/v1/conversion`, + { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ from_: 'sat', amount: 100_000_000, to: fiatCurrency }), + signal: AbortSignal.timeout(10000) + } + ) + + if (!response.ok) return undefined + + const data = await response.json() + // data.sats gives us how many sats = 1 unit of fiat, or similar + // The exact shape depends on LNbits version, but we want BTC price in fiat + const btcPriceInFiat = data.amount ?? data.result + if (typeof btcPriceInFiat !== 'number') return undefined + + return { + price: btcPriceInFiat, + currency: fiatCurrency, + timestamp: new Date().toISOString() + } + } catch (error) { + console.warn('[useExpenseDrafts] Failed to capture BTC price:', error) + return undefined + } + } + + return { + drafts, + draftCount, + hasDrafts, + createDraft, + updateDraft, + deleteDraft, + getDraft, + addReceiptToDraft, + removeReceiptFromDraft, + captureBtcPrice, + } +} diff --git a/src/accounting-app/main.ts b/src/accounting-app/main.ts new file mode 100644 index 0000000..22efcbc --- /dev/null +++ b/src/accounting-app/main.ts @@ -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('Castle app ready to work offline') + } +}) + +startApp() diff --git a/src/accounting-app/views/AddIncome.vue b/src/accounting-app/views/AddIncome.vue new file mode 100644 index 0000000..1842df1 --- /dev/null +++ b/src/accounting-app/views/AddIncome.vue @@ -0,0 +1,52 @@ + + + diff --git a/src/accounting-app/views/BalancePage.vue b/src/accounting-app/views/BalancePage.vue new file mode 100644 index 0000000..1484584 --- /dev/null +++ b/src/accounting-app/views/BalancePage.vue @@ -0,0 +1,196 @@ + + + diff --git a/src/accounting-app/views/RecordPage.vue b/src/accounting-app/views/RecordPage.vue new file mode 100644 index 0000000..b5ba346 --- /dev/null +++ b/src/accounting-app/views/RecordPage.vue @@ -0,0 +1,174 @@ + + + diff --git a/src/accounting-app/views/SettingsPage.vue b/src/accounting-app/views/SettingsPage.vue new file mode 100644 index 0000000..0e88439 --- /dev/null +++ b/src/accounting-app/views/SettingsPage.vue @@ -0,0 +1,95 @@ + + + diff --git a/src/activities-app/app.ts b/src/activities-app/app.ts index 6375cf0..14f29c1 100644 --- a/src/activities-app/app.ts +++ b/src/activities-app/app.ts @@ -14,12 +14,33 @@ import App from './App.vue' import '@/assets/index.css' import { i18n, changeLocale, type AvailableLocale } from '@/i18n' +/** + * Accept an auth token from a URL parameter (e.g. ?token=xxx). + * Allows the main app to link users directly into this standalone + * app without requiring a separate login. + */ +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('[Sortir] Auth token accepted from URL') + } +} + /** * Initialize the standalone activities app */ export async function createAppInstance() { console.log('🚀 Starting Sortir — Activities App...') + // Accept token from URL before anything else (cross-subdomain auth relay) + acceptTokenFromUrl() + const app = createApp(App) // Collect routes from enabled modules only diff --git a/src/i18n/locales/en.ts b/src/i18n/locales/en.ts index fd9b1a5..9b77759 100644 --- a/src/i18n/locales/en.ts +++ b/src/i18n/locales/en.ts @@ -119,6 +119,55 @@ const messages: LocaleMessages = { language: 'Language', }, }, + castle: { + nav: { + record: 'Record', + transactions: 'Transactions', + balance: 'Balance', + wallet: 'Wallet', + settings: 'Settings', + }, + record: { + title: 'Record', + addExpense: 'Add Expense', + addExpenseDescription: 'Submit an expense for approval', + addIncome: 'Add Income', + addIncomeDescription: 'Record income or revenue received', + comingSoon: 'Coming Soon', + drafts: 'Drafts', + noDrafts: 'No saved drafts', + draftAge: 'Saved {time}', + saveDraft: 'Save Draft', + deleteDraft: 'Delete Draft', + }, + balance: { + title: 'Balance', + netBalance: 'Net Balance', + pending: 'Pending', + pendingExpenses: '{count} pending expense | {count} pending expenses', + pendingAmount: '{amount} pending approval', + noBalance: 'No balance data available', + owedToYou: 'Owed to you', + youOwe: 'You owe', + }, + settings: { + title: 'Settings', + account: 'Account', + loginPrompt: 'Log in to record expenses and view your balance.', + logIn: 'Log in', + logOut: 'Log out', + appearance: 'Appearance', + theme: 'Theme', + language: 'Language', + }, + income: { + title: 'Add Income', + description: 'Submit income for the organization', + selectAccount: 'Select the revenue account', + submitIncome: 'Submit Income', + notAvailable: 'Income submission is not yet available. This feature is coming soon.', + }, + }, dateTimeFormats: { short: { year: 'numeric', diff --git a/src/i18n/locales/es.ts b/src/i18n/locales/es.ts index f707db0..82f0816 100644 --- a/src/i18n/locales/es.ts +++ b/src/i18n/locales/es.ts @@ -119,6 +119,55 @@ const messages: LocaleMessages = { language: 'Idioma', }, }, + castle: { + nav: { + record: 'Registrar', + transactions: 'Transacciones', + balance: 'Saldo', + wallet: 'Billetera', + settings: 'Ajustes', + }, + record: { + title: 'Registrar', + addExpense: 'A\u00f1adir gasto', + addExpenseDescription: 'Enviar un gasto para aprobaci\u00f3n', + addIncome: 'A\u00f1adir ingreso', + addIncomeDescription: 'Registrar un ingreso recibido', + comingSoon: 'Pr\u00f3ximamente', + drafts: 'Borradores', + noDrafts: 'Sin borradores', + draftAge: 'Guardado {time}', + saveDraft: 'Guardar borrador', + deleteDraft: 'Eliminar borrador', + }, + balance: { + title: 'Saldo', + netBalance: 'Saldo neto', + pending: 'Pendiente', + pendingExpenses: '{count} gasto pendiente | {count} gastos pendientes', + pendingAmount: '{amount} pendiente de aprobaci\u00f3n', + noBalance: 'No hay datos de saldo disponibles', + owedToYou: 'Te deben', + youOwe: 'Debes', + }, + settings: { + title: 'Ajustes', + account: 'Cuenta', + loginPrompt: 'Inicia sesi\u00f3n para registrar gastos y ver tu saldo.', + logIn: 'Iniciar sesi\u00f3n', + logOut: 'Cerrar sesi\u00f3n', + appearance: 'Apariencia', + theme: 'Tema', + language: 'Idioma', + }, + income: { + title: 'A\u00f1adir ingreso', + description: 'Enviar un ingreso para la organizaci\u00f3n', + selectAccount: 'Seleccionar la cuenta de ingresos', + submitIncome: 'Enviar ingreso', + notAvailable: 'El registro de ingresos a\u00fan no est\u00e1 disponible. Esta funci\u00f3n llegar\u00e1 pronto.', + }, + }, dateTimeFormats: { short: { year: 'numeric', diff --git a/src/i18n/locales/fr.ts b/src/i18n/locales/fr.ts index bd012eb..b4b4c08 100644 --- a/src/i18n/locales/fr.ts +++ b/src/i18n/locales/fr.ts @@ -119,6 +119,55 @@ const messages: LocaleMessages = { language: 'Langue', }, }, + castle: { + nav: { + record: 'Saisir', + transactions: 'Transactions', + balance: 'Solde', + wallet: 'Portefeuille', + settings: 'Param\u00e8tres', + }, + record: { + title: 'Saisir', + addExpense: 'Ajouter une d\u00e9pense', + addExpenseDescription: 'Soumettre une d\u00e9pense pour approbation', + addIncome: 'Ajouter un revenu', + addIncomeDescription: 'Enregistrer un revenu re\u00e7u', + comingSoon: 'Bient\u00f4t disponible', + drafts: 'Brouillons', + noDrafts: 'Aucun brouillon', + draftAge: 'Enregistr\u00e9 {time}', + saveDraft: 'Enregistrer le brouillon', + deleteDraft: 'Supprimer le brouillon', + }, + balance: { + title: 'Solde', + netBalance: 'Solde net', + pending: 'En attente', + pendingExpenses: '{count} d\u00e9pense en attente | {count} d\u00e9penses en attente', + pendingAmount: '{amount} en attente d\u2019approbation', + noBalance: 'Aucune donn\u00e9e de solde disponible', + owedToYou: 'D\u00fb', + youOwe: 'Vous devez', + }, + settings: { + title: 'Param\u00e8tres', + account: 'Compte', + loginPrompt: 'Connectez-vous pour saisir des d\u00e9penses et voir votre solde.', + logIn: 'Se connecter', + logOut: 'Se d\u00e9connecter', + appearance: 'Apparence', + theme: 'Th\u00e8me', + language: 'Langue', + }, + income: { + title: 'Ajouter un revenu', + description: 'Soumettre un revenu pour l\u2019organisation', + selectAccount: 'S\u00e9lectionner le compte de revenus', + submitIncome: 'Soumettre le revenu', + notAvailable: 'La saisie de revenus n\u2019est pas encore disponible. Cette fonctionnalit\u00e9 arrive bient\u00f4t.', + }, + }, dateTimeFormats: { short: { year: 'numeric', diff --git a/src/i18n/types.ts b/src/i18n/types.ts index cb303a0..ddd20c6 100644 --- a/src/i18n/types.ts +++ b/src/i18n/types.ts @@ -94,6 +94,56 @@ export interface LocaleMessages { language: string } } + // Castle accounting module + castle?: { + nav: { + record: string + transactions: string + balance: string + wallet: string + settings: string + } + record: { + title: string + addExpense: string + addExpenseDescription: string + addIncome: string + addIncomeDescription: string + comingSoon: string + drafts: string + noDrafts: string + draftAge: string + saveDraft: string + deleteDraft: string + } + balance: { + title: string + netBalance: string + pending: string + pendingExpenses: string + pendingAmount: string + noBalance: string + owedToYou: string + youOwe: string + } + settings: { + title: string + account: string + loginPrompt: string + logIn: string + logOut: string + appearance: string + theme: string + language: string + } + income: { + title: string + description: string + selectAccount: string + submitIncome: string + notAvailable: string + } + } // Add date/time formats dateTimeFormats: { short: { diff --git a/vite.castle.config.ts b/vite.castle.config.ts new file mode 100644 index 0000000..d652c46 --- /dev/null +++ b/vite.castle.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' + +/** + * Plugin to rewrite dev server requests to castle.html + * (SPA fallback for the standalone Castle accounting app entry point) + */ +function castleHtmlPlugin(): Plugin { + return { + name: 'castle-html-rewrite', + configureServer(server) { + server.middlewares.use((req, _res, next) => { + // Rewrite all non-asset requests to castle.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 = '/castle.html' + } + next() + }) + }, + } +} + +/** + * Vite config for the standalone Castle accounting app. + */ +export default defineConfig(({ mode }) => ({ + plugins: [ + castleHtmlPlugin(), + 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: 'Castle — Team Accounting', + short_name: 'Castle', + description: 'Team accounting and expense management', + theme_color: '#1f2937', + background_color: '#ffffff', + display: 'standalone', + orientation: 'portrait-primary', + start_url: '/', + scope: '/', + id: 'castle-accounting', + categories: ['finance', 'business', 'productivity'], + 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-castle/stats.html', + gzipSize: true, + brotliSize: true, + }), + ], + resolve: { + alias: { + '@': fileURLToPath(new URL('./src', import.meta.url)), + // CRITICAL: Remap @/app.config to the castle app's config + // ExpensesAPI and other modules import from @/app.config directly + '@/app.config': fileURLToPath(new URL('./src/accounting-app/app.config.ts', import.meta.url)), + }, + }, + build: { + outDir: 'dist-castle', + rollupOptions: { + input: 'castle.html', + output: { + manualChunks: { + 'vue-vendor': ['vue', 'vue-router', 'pinia'], + 'ui-vendor': ['radix-vue', '@vueuse/core'], + 'shadcn': ['class-variance-authority', 'clsx', 'tailwind-merge'], + }, + }, + }, + chunkSizeWarningLimit: 1000, + }, +}))