Rename castle-app to libra-app
Match the upstream LNbits extension rebrand (Castle Accounting → Libra).
Renames the standalone PWA build artifacts and all references:
- castle.html → libra.html
- vite.castle.config.ts → vite.libra.config.ts (PWA name "Libra —
Team Accounting", short_name "Libra", manifest id libra-accounting)
- npm scripts: build:castle/dev:castle/preview:castle → build:libra
etc; dev:all and build:demo chains updated; dist-castle → dist-libra
- Hub tile: Lucide icon Castle → Scale (the scales/balance metaphor),
label "Castle" → "Libra", env var VITE_HUB_CASTLE_URL → VITE_HUB_LIBRA_URL
- ExpensesAPI: /castle/api/v1/* → /libra/api/v1/* (matches the renamed
LNbits extension's URL prefix)
- Feature flags VITE_CASTLE_INCOME_ENABLED/VITE_CASTLE_BUDGETS_ENABLED →
VITE_LIBRA_*
- i18n: top-level "castle" namespace → "libra" across en/es/fr; all
t('castle.*') usages updated
- localStorage key castle-expense-drafts → libra-expense-drafts
- nginx.conf.example: /castle/ routes + castle.<domain> redirect → libra
- Comments and identifiers: castleOwesUser → libraOwesUser, castle.api
references in docs
Source dir src/accounting-app/ stays as-is (already feature-named, not
brand-named).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
8792a884cd
commit
442a755a51
27 changed files with 116 additions and 116 deletions
|
|
@ -58,7 +58,7 @@ VITE_MARKET_NADDR=naddr1qqjxgdp4vv6rydej943n2dny956rwwf4943xzwfc95ekyd3evenrsvrr
|
|||
# In LOCAL DEV with `npm run dev:all` use the per-app dev ports (defined
|
||||
# in the vite configs):
|
||||
# VITE_HUB_ACTIVITIES_URL=http://localhost:5181
|
||||
# VITE_HUB_CASTLE_URL=http://localhost:5180
|
||||
# VITE_HUB_LIBRA_URL=http://localhost:5180
|
||||
# VITE_HUB_WALLET_URL=http://localhost:5182
|
||||
# VITE_HUB_CHAT_URL=http://localhost:5183
|
||||
# VITE_HUB_FORUM_URL=http://localhost:5184
|
||||
|
|
@ -67,7 +67,7 @@ VITE_MARKET_NADDR=naddr1qqjxgdp4vv6rydej943n2dny956rwwf4943xzwfc95ekyd3evenrsvrr
|
|||
#
|
||||
# In PATH-MODE production (recommended for demo) — note the trailing slash:
|
||||
# VITE_HUB_ACTIVITIES_URL=https://demo.example.com/activities/
|
||||
# VITE_HUB_CASTLE_URL=https://demo.example.com/castle/
|
||||
# VITE_HUB_LIBRA_URL=https://demo.example.com/libra/
|
||||
# VITE_HUB_WALLET_URL=https://demo.example.com/wallet/
|
||||
# VITE_HUB_CHAT_URL=https://demo.example.com/chat/
|
||||
# VITE_HUB_FORUM_URL=https://demo.example.com/forum/
|
||||
|
|
@ -76,11 +76,11 @@ VITE_MARKET_NADDR=naddr1qqjxgdp4vv6rydej943n2dny956rwwf4943xzwfc95ekyd3evenrsvrr
|
|||
#
|
||||
# In SUBDOMAIN-MODE production:
|
||||
# VITE_HUB_ACTIVITIES_URL=https://sortir.example.com
|
||||
# VITE_HUB_CASTLE_URL=https://castle.example.com
|
||||
# VITE_HUB_LIBRA_URL=https://libra.example.com
|
||||
# ...etc
|
||||
# ───────────────────────────────────────────────────────────────────────
|
||||
VITE_HUB_ACTIVITIES_URL=
|
||||
VITE_HUB_CASTLE_URL=
|
||||
VITE_HUB_LIBRA_URL=
|
||||
VITE_HUB_WALLET_URL=
|
||||
VITE_HUB_CHAT_URL=
|
||||
VITE_HUB_FORUM_URL=
|
||||
|
|
|
|||
|
|
@ -9,8 +9,8 @@
|
|||
<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>Castle — Accounting</title>
|
||||
<meta name="apple-mobile-web-app-title" content="Castle">
|
||||
<title>Libra — Accounting</title>
|
||||
<meta name="apple-mobile-web-app-title" content="Libra">
|
||||
<meta name="description" content="Team accounting and expense management">
|
||||
</head>
|
||||
<body>
|
||||
|
|
@ -21,7 +21,7 @@ http {
|
|||
# demo.<domain>.<com>/chat/ — chat standalone
|
||||
# demo.<domain>.<com>/forum/ — forum standalone
|
||||
# demo.<domain>.<com>/tasks/ — tasks standalone
|
||||
# demo.<domain>.<com>/castle/ — castle (accounting) standalone
|
||||
# demo.<domain>.<com>/libra/ — libra (accounting) standalone
|
||||
#
|
||||
# Each standalone is built with VITE_BASE_PATH=/<name>/ so its asset URLs
|
||||
# are prefixed correctly. The hub's chakra tiles point at the canonical
|
||||
|
|
@ -88,11 +88,11 @@ http {
|
|||
try_files $uri $uri/ /tasks.html;
|
||||
}
|
||||
|
||||
# ── Castle (accounting) ──────────────────────────────────────────
|
||||
location = /castle { return 301 /castle/$is_args$args; }
|
||||
location /castle/ {
|
||||
alias /var/www/aio/dist-castle/;
|
||||
try_files $uri $uri/ /castle.html;
|
||||
# ── Libra (accounting) ──────────────────────────────────────────
|
||||
location = /libra { return 301 /libra/$is_args$args; }
|
||||
location /libra/ {
|
||||
alias /var/www/aio/dist-libra/;
|
||||
try_files $uri $uri/ /libra.html;
|
||||
}
|
||||
|
||||
# ── Static asset MIME / cache (applies to all bundles) ───────────
|
||||
|
|
@ -142,8 +142,8 @@ http {
|
|||
}
|
||||
server {
|
||||
listen 8080;
|
||||
server_name castle.demo.<domain>.<com>;
|
||||
return 301 https://demo.<domain>.<com>/castle/$request_uri;
|
||||
server_name libra.demo.<domain>.<com>;
|
||||
return 301 https://demo.<domain>.<com>/libra/$request_uri;
|
||||
}
|
||||
|
||||
# ───────────────────────────────────────────────────────────────────────
|
||||
|
|
@ -159,7 +159,7 @@ http {
|
|||
# server { server_name chat.<domain>; root /var/www/aio/dist-chat; ... }
|
||||
# server { server_name forum.<domain>; root /var/www/aio/dist-forum; ... }
|
||||
# server { server_name tasks.<domain>; root /var/www/aio/dist-tasks; ... }
|
||||
# server { server_name castle.<domain>; root /var/www/aio/dist-castle; ... }
|
||||
# server { server_name libra.<domain>; root /var/www/aio/dist-libra; ... }
|
||||
#
|
||||
# Each block uses `location / { try_files $uri $uri/ /<name>.html; }`.
|
||||
# In subdomain mode, build each standalone WITHOUT VITE_BASE_PATH (the
|
||||
|
|
|
|||
10
package.json
10
package.json
|
|
@ -12,9 +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",
|
||||
"dev:libra": "vite --host --config vite.libra.config.ts",
|
||||
"build:libra": "vue-tsc -b && vite build --config vite.libra.config.ts",
|
||||
"preview:libra": "vite preview --host --config vite.libra.config.ts",
|
||||
"dev:wallet": "vite --host --config vite.wallet.config.ts",
|
||||
"build:wallet": "vue-tsc -b && vite build --config vite.wallet.config.ts",
|
||||
"preview:wallet": "vite preview --host --config vite.wallet.config.ts",
|
||||
|
|
@ -30,8 +30,8 @@
|
|||
"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",
|
||||
"dev:all": "concurrently -n hub,castle,sortir,wallet,chat,forum,market,tasks -c blue,magenta,cyan,yellow,green,blue,red,gray \"npm:dev\" \"npm:dev:castle\" \"npm:dev:activities\" \"npm:dev:wallet\" \"npm:dev:chat\" \"npm:dev:forum\" \"npm:dev:market\" \"npm:dev:tasks\"",
|
||||
"build:demo": "npm run build && VITE_BASE_PATH=/sortir/ npm run build:activities && VITE_BASE_PATH=/castle/ npm run build:castle && VITE_BASE_PATH=/wallet/ npm run build:wallet && VITE_BASE_PATH=/chat/ npm run build:chat && VITE_BASE_PATH=/forum/ npm run build:forum && VITE_BASE_PATH=/market/ npm run build:market && VITE_BASE_PATH=/tasks/ npm run build:tasks",
|
||||
"dev:all": "concurrently -n hub,libra,sortir,wallet,chat,forum,market,tasks -c blue,magenta,cyan,yellow,green,blue,red,gray \"npm:dev\" \"npm:dev:libra\" \"npm:dev:activities\" \"npm:dev:wallet\" \"npm:dev:chat\" \"npm:dev:forum\" \"npm:dev:market\" \"npm:dev:tasks\"",
|
||||
"build:demo": "npm run build && VITE_BASE_PATH=/sortir/ npm run build:activities && VITE_BASE_PATH=/libra/ npm run build:libra && VITE_BASE_PATH=/wallet/ npm run build:wallet && VITE_BASE_PATH=/chat/ npm run build:chat && VITE_BASE_PATH=/forum/ npm run build:forum && VITE_BASE_PATH=/market/ npm run build:market && VITE_BASE_PATH=/tasks/ npm run build:tasks",
|
||||
"electron:dev": "concurrently \"vite --host\" \"electron-forge start\"",
|
||||
"electron:build": "vue-tsc -b && vite build && electron-builder",
|
||||
"electron:package": "electron-builder",
|
||||
|
|
|
|||
|
|
@ -23,11 +23,11 @@ const showLoginDialog = ref(false)
|
|||
|
||||
// Bottom navigation tabs
|
||||
const bottomTabs = computed(() => [
|
||||
{ name: t('castle.nav.record'), icon: PlusCircle, path: '/record' },
|
||||
{ name: t('castle.nav.transactions'), icon: List, path: '/expenses/transactions' },
|
||||
{ name: t('castle.nav.balance'), icon: Scale, path: '/balance' },
|
||||
{ name: t('castle.nav.wallet'), icon: Wallet, path: '/wallet' },
|
||||
{ name: t('castle.nav.settings'), icon: Settings, path: '/settings' },
|
||||
{ name: t('libra.nav.record'), icon: PlusCircle, path: '/record' },
|
||||
{ name: t('libra.nav.transactions'), icon: List, path: '/expenses/transactions' },
|
||||
{ name: t('libra.nav.balance'), icon: Scale, path: '/balance' },
|
||||
{ name: t('libra.nav.wallet'), icon: Wallet, path: '/wallet' },
|
||||
{ name: t('libra.nav.settings'), icon: Settings, path: '/settings' },
|
||||
])
|
||||
|
||||
const isLoginPage = computed(() => route.path === '/login')
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
import type { AppConfig } from '@/core/types'
|
||||
|
||||
/**
|
||||
* Standalone Castle accounting app configuration.
|
||||
* Standalone Libra accounting app configuration.
|
||||
* Only enables base + expenses + wallet modules.
|
||||
*/
|
||||
export const appConfig: AppConfig = {
|
||||
|
|
|
|||
|
|
@ -18,13 +18,13 @@ import { installStrictAuthGuard, markAuthReady, catchAllRoute } from '@/lib/rout
|
|||
import { acceptTokenFromUrl } from '@/lib/url-token'
|
||||
|
||||
/**
|
||||
* Initialize the standalone Castle accounting app
|
||||
* Initialize the standalone Libra accounting app
|
||||
*/
|
||||
export async function createAppInstance() {
|
||||
console.log('Starting Castle — Accounting App...')
|
||||
console.log('Starting Libra — Accounting App...')
|
||||
|
||||
// Accept token from URL before anything else (cross-subdomain auth relay)
|
||||
acceptTokenFromUrl('Castle')
|
||||
acceptTokenFromUrl('Libra')
|
||||
|
||||
const app = createApp(App)
|
||||
|
||||
|
|
@ -75,7 +75,7 @@ export async function createAppInstance() {
|
|||
]
|
||||
})
|
||||
|
||||
// Castle has no public view — every non-login route requires auth.
|
||||
// Libra has no public view — every non-login route requires auth.
|
||||
installStrictAuthGuard(router)
|
||||
|
||||
const pinia = createPinia()
|
||||
|
|
@ -135,7 +135,7 @@ export async function createAppInstance() {
|
|||
;(window as any).__container = container
|
||||
}
|
||||
|
||||
console.log('Castle app initialized')
|
||||
console.log('Libra app initialized')
|
||||
return { app, router }
|
||||
}
|
||||
|
||||
|
|
@ -143,10 +143,10 @@ export async function startApp() {
|
|||
try {
|
||||
const { app } = await createAppInstance()
|
||||
app.mount('#app')
|
||||
console.log('Castle app started!')
|
||||
console.log('Libra app started!')
|
||||
eventBus.emit('app:started', {}, 'app')
|
||||
} catch (error) {
|
||||
console.error('Failed to start Castle app:', error)
|
||||
console.error('Failed to start Libra app:', error)
|
||||
document.getElementById('app')!.innerHTML = `
|
||||
<div style="padding: 20px; text-align: center; color: red;">
|
||||
<h1>Failed to Start</h1>
|
||||
|
|
|
|||
|
|
@ -30,7 +30,7 @@ export interface ExpenseDraft {
|
|||
btc_price_snapshot?: BtcPriceSnapshot
|
||||
}
|
||||
|
||||
const STORAGE_KEY = 'castle-expense-drafts'
|
||||
const STORAGE_KEY = 'libra-expense-drafts'
|
||||
|
||||
/**
|
||||
* Composable for managing expense drafts in localStorage.
|
||||
|
|
|
|||
|
|
@ -14,7 +14,7 @@ registerSW({
|
|||
}, intervalMS)
|
||||
},
|
||||
onOfflineReady() {
|
||||
console.log('Castle app ready to work offline')
|
||||
console.log('Libra app ready to work offline')
|
||||
}
|
||||
})
|
||||
|
||||
|
|
|
|||
|
|
@ -28,10 +28,10 @@ function handleClose() {
|
|||
<DialogHeader>
|
||||
<DialogTitle class="flex items-center gap-2">
|
||||
<TrendingUp class="h-5 w-5 text-green-600 dark:text-green-400" />
|
||||
<span>{{ t('castle.income.title') }}</span>
|
||||
<span>{{ t('libra.income.title') }}</span>
|
||||
</DialogTitle>
|
||||
<DialogDescription>
|
||||
{{ t('castle.income.description') }}
|
||||
{{ t('libra.income.description') }}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
|
|
@ -41,7 +41,7 @@ function handleClose() {
|
|||
<Info class="h-8 w-8 text-muted-foreground" />
|
||||
</div>
|
||||
<p class="text-sm text-muted-foreground text-center max-w-xs">
|
||||
{{ t('castle.income.notAvailable') }}
|
||||
{{ t('libra.income.notAvailable') }}
|
||||
</p>
|
||||
<Button variant="outline" @click="handleClose">
|
||||
Close
|
||||
|
|
|
|||
|
|
@ -23,7 +23,7 @@ const isLoading = ref(true)
|
|||
const isRefreshing = ref(false)
|
||||
|
||||
const walletKey = computed(() => user.value?.wallets?.[0]?.inkey)
|
||||
const budgetsEnabled = computed(() => import.meta.env.VITE_CASTLE_BUDGETS_ENABLED === 'true')
|
||||
const budgetsEnabled = computed(() => import.meta.env.VITE_LIBRA_BUDGETS_ENABLED === 'true')
|
||||
|
||||
const pendingCount = computed(() => pendingTransactions.value.length)
|
||||
|
||||
|
|
@ -42,8 +42,8 @@ const pendingFiatCurrency = computed(() => {
|
|||
return tx?.fiat_currency ?? null
|
||||
})
|
||||
|
||||
// Castle API: positive = user owes castle, negative = castle owes user
|
||||
const castleOwesUser = computed(() => (balance.value ?? 0) <= 0)
|
||||
// Libra API: positive = user owes libra, negative = libra owes user
|
||||
const libraOwesUser = computed(() => (balance.value ?? 0) <= 0)
|
||||
|
||||
async function loadData() {
|
||||
if (!walletKey.value) return
|
||||
|
|
@ -96,7 +96,7 @@ function formatFiat(amount: number, currency: string): string {
|
|||
<div class="container mx-auto px-4 py-6 max-w-lg">
|
||||
<!-- Header with refresh -->
|
||||
<div class="flex items-center justify-between mb-6">
|
||||
<h1 class="text-2xl font-bold text-foreground">{{ t('castle.balance.title') }}</h1>
|
||||
<h1 class="text-2xl font-bold text-foreground">{{ t('libra.balance.title') }}</h1>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
|
|
@ -116,27 +116,27 @@ function formatFiat(amount: number, currency: string): string {
|
|||
<template v-else>
|
||||
<!-- Balance Hero -->
|
||||
<div class="rounded-xl border bg-card p-6 mb-6">
|
||||
<p class="text-sm text-muted-foreground mb-1">{{ t('castle.balance.netBalance') }}</p>
|
||||
<p class="text-sm text-muted-foreground mb-1">{{ t('libra.balance.netBalance') }}</p>
|
||||
|
||||
<div v-if="balance !== null" class="space-y-1">
|
||||
<div class="flex items-center gap-2">
|
||||
<component
|
||||
:is="castleOwesUser ? ArrowDown : ArrowUp"
|
||||
:is="libraOwesUser ? ArrowDown : ArrowUp"
|
||||
class="w-5 h-5"
|
||||
:class="castleOwesUser ? 'text-green-500' : 'text-red-500'"
|
||||
:class="libraOwesUser ? 'text-green-500' : 'text-red-500'"
|
||||
/>
|
||||
<span class="text-3xl font-bold text-foreground">
|
||||
{{ formatAmount(balance) }}
|
||||
</span>
|
||||
<span class="text-lg text-muted-foreground">{{ balanceCurrency }}</span>
|
||||
</div>
|
||||
<p class="text-sm" :class="castleOwesUser ? 'text-green-600 dark:text-green-400' : 'text-red-600 dark:text-red-400'">
|
||||
{{ castleOwesUser ? t('castle.balance.owedToYou') : t('castle.balance.youOwe') }}
|
||||
<p class="text-sm" :class="libraOwesUser ? 'text-green-600 dark:text-green-400' : 'text-red-600 dark:text-red-400'">
|
||||
{{ libraOwesUser ? t('libra.balance.owedToYou') : t('libra.balance.youOwe') }}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div v-else class="text-muted-foreground">
|
||||
{{ t('castle.balance.noBalance') }}
|
||||
{{ t('libra.balance.noBalance') }}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
|
@ -144,14 +144,14 @@ function formatFiat(amount: number, currency: string): string {
|
|||
<div v-if="pendingCount > 0" class="rounded-xl border bg-card p-5 mb-6">
|
||||
<div class="flex items-center gap-2 mb-3">
|
||||
<Clock class="w-4 h-4 text-orange-500" />
|
||||
<h2 class="text-sm font-medium text-foreground">{{ t('castle.balance.pending') }}</h2>
|
||||
<h2 class="text-sm font-medium text-foreground">{{ t('libra.balance.pending') }}</h2>
|
||||
<Badge variant="secondary" class="text-xs">{{ pendingCount }}</Badge>
|
||||
</div>
|
||||
|
||||
<div class="space-y-2">
|
||||
<div class="flex items-center justify-between">
|
||||
<span class="text-sm text-muted-foreground">
|
||||
{{ t('castle.balance.pendingAmount', { amount: formatAmount(pendingTotal) + ' ' + balanceCurrency }) }}
|
||||
{{ t('libra.balance.pendingAmount', { amount: formatAmount(pendingTotal) + ' ' + balanceCurrency }) }}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
|
|
|
|||
|
|
@ -16,7 +16,7 @@ const { drafts, hasDrafts, deleteDraft } = useExpenseDrafts()
|
|||
const showAddExpense = ref(false)
|
||||
const showAddIncome = ref(false)
|
||||
|
||||
const incomeEnabled = computed(() => import.meta.env.VITE_CASTLE_INCOME_ENABLED === 'true')
|
||||
const incomeEnabled = computed(() => import.meta.env.VITE_LIBRA_INCOME_ENABLED === 'true')
|
||||
|
||||
function handleExpenseSubmitted() {
|
||||
// Could refresh balance or show notification
|
||||
|
|
@ -47,7 +47,7 @@ function draftTimeAgo(isoDate: string) {
|
|||
|
||||
<template>
|
||||
<div class="container mx-auto px-4 py-6 max-w-lg">
|
||||
<h1 class="text-2xl font-bold text-foreground mb-6">{{ t('castle.record.title') }}</h1>
|
||||
<h1 class="text-2xl font-bold text-foreground mb-6">{{ t('libra.record.title') }}</h1>
|
||||
|
||||
<!-- Action Cards -->
|
||||
<div class="grid gap-4">
|
||||
|
|
@ -60,8 +60,8 @@ function draftTimeAgo(isoDate: string) {
|
|||
<DollarSign class="w-6 h-6 text-red-600 dark:text-red-400" />
|
||||
</div>
|
||||
<div class="min-w-0">
|
||||
<h2 class="text-lg font-semibold text-foreground">{{ t('castle.record.addExpense') }}</h2>
|
||||
<p class="text-sm text-muted-foreground mt-0.5">{{ t('castle.record.addExpenseDescription') }}</p>
|
||||
<h2 class="text-lg font-semibold text-foreground">{{ t('libra.record.addExpense') }}</h2>
|
||||
<p class="text-sm text-muted-foreground mt-0.5">{{ t('libra.record.addExpenseDescription') }}</p>
|
||||
</div>
|
||||
</button>
|
||||
|
||||
|
|
@ -79,12 +79,12 @@ function draftTimeAgo(isoDate: string) {
|
|||
</div>
|
||||
<div class="min-w-0 flex-1">
|
||||
<div class="flex items-center gap-2">
|
||||
<h2 class="text-lg font-semibold text-foreground">{{ t('castle.record.addIncome') }}</h2>
|
||||
<h2 class="text-lg font-semibold text-foreground">{{ t('libra.record.addIncome') }}</h2>
|
||||
<Badge v-if="!incomeEnabled" variant="secondary" class="text-xs">
|
||||
{{ t('castle.record.comingSoon') }}
|
||||
{{ t('libra.record.comingSoon') }}
|
||||
</Badge>
|
||||
</div>
|
||||
<p class="text-sm text-muted-foreground mt-0.5">{{ t('castle.record.addIncomeDescription') }}</p>
|
||||
<p class="text-sm text-muted-foreground mt-0.5">{{ t('libra.record.addIncomeDescription') }}</p>
|
||||
</div>
|
||||
</button>
|
||||
</div>
|
||||
|
|
@ -92,7 +92,7 @@ function draftTimeAgo(isoDate: string) {
|
|||
<!-- Info hint when income is disabled -->
|
||||
<div v-if="!incomeEnabled" class="mt-4 flex items-start gap-2 p-3 rounded-lg bg-muted/50">
|
||||
<Info class="w-4 h-4 text-muted-foreground mt-0.5 shrink-0" />
|
||||
<p class="text-xs text-muted-foreground">{{ t('castle.income.notAvailable') }}</p>
|
||||
<p class="text-xs text-muted-foreground">{{ t('libra.income.notAvailable') }}</p>
|
||||
</div>
|
||||
|
||||
<!-- Drafts Section -->
|
||||
|
|
@ -101,7 +101,7 @@ function draftTimeAgo(isoDate: string) {
|
|||
|
||||
<div>
|
||||
<h2 class="text-sm font-medium text-muted-foreground uppercase tracking-wide mb-3">
|
||||
{{ t('castle.record.drafts') }}
|
||||
{{ t('libra.record.drafts') }}
|
||||
<Badge variant="secondary" class="ml-1 text-xs">{{ drafts.length }}</Badge>
|
||||
</h2>
|
||||
|
||||
|
|
@ -129,7 +129,7 @@ function draftTimeAgo(isoDate: string) {
|
|||
{{ draft.description || draft.account?.name || 'Untitled draft' }}
|
||||
</p>
|
||||
<div class="flex items-center gap-2 text-xs text-muted-foreground">
|
||||
<span>{{ t('castle.record.draftAge', { time: draftTimeAgo(draft.created_at) }) }}</span>
|
||||
<span>{{ t('libra.record.draftAge', { time: draftTimeAgo(draft.created_at) }) }}</span>
|
||||
<span v-if="draft.amount">
|
||||
· {{ draft.amount }} {{ draft.currency || 'sats' }}
|
||||
</span>
|
||||
|
|
|
|||
|
|
@ -35,27 +35,27 @@ async function handleLogout() {
|
|||
|
||||
<template>
|
||||
<div class="container mx-auto px-4 py-6 max-w-lg">
|
||||
<h1 class="text-2xl font-bold text-foreground mb-6">{{ t('castle.settings.title') }}</h1>
|
||||
<h1 class="text-2xl font-bold text-foreground mb-6">{{ t('libra.settings.title') }}</h1>
|
||||
|
||||
<!-- Account -->
|
||||
<div class="space-y-4">
|
||||
<h2 class="text-sm font-medium text-muted-foreground uppercase tracking-wide">{{ t('castle.settings.account') }}</h2>
|
||||
<h2 class="text-sm font-medium text-muted-foreground uppercase tracking-wide">{{ t('libra.settings.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" />
|
||||
{{ t('castle.settings.logOut') }}
|
||||
{{ t('libra.settings.logOut') }}
|
||||
</Button>
|
||||
</div>
|
||||
<div v-else class="bg-muted/50 rounded-lg p-4">
|
||||
<p class="text-sm text-muted-foreground mb-3">
|
||||
{{ t('castle.settings.loginPrompt') }}
|
||||
{{ t('libra.settings.loginPrompt') }}
|
||||
</p>
|
||||
<Button size="sm" class="w-full gap-2" @click="$router.push('/login')">
|
||||
<LogIn class="w-4 h-4" />
|
||||
{{ t('castle.settings.logIn') }}
|
||||
{{ t('libra.settings.logIn') }}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -64,9 +64,9 @@ async function handleLogout() {
|
|||
|
||||
<!-- Appearance -->
|
||||
<div class="space-y-4">
|
||||
<h2 class="text-sm font-medium text-muted-foreground uppercase tracking-wide">{{ t('castle.settings.appearance') }}</h2>
|
||||
<h2 class="text-sm font-medium text-muted-foreground uppercase tracking-wide">{{ t('libra.settings.appearance') }}</h2>
|
||||
<div class="flex items-center justify-between bg-muted/50 rounded-lg p-4">
|
||||
<span class="text-sm text-foreground">{{ t('castle.settings.theme') }}</span>
|
||||
<span class="text-sm text-foreground">{{ t('libra.settings.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" />
|
||||
|
|
@ -78,7 +78,7 @@ async function handleLogout() {
|
|||
|
||||
<!-- Language -->
|
||||
<div class="space-y-4">
|
||||
<h2 class="text-sm font-medium text-muted-foreground uppercase tracking-wide">{{ t('castle.settings.language') }}</h2>
|
||||
<h2 class="text-sm font-medium text-muted-foreground uppercase tracking-wide">{{ t('libra.settings.language') }}</h2>
|
||||
<div class="flex gap-2">
|
||||
<Button
|
||||
v-for="lang in languages"
|
||||
|
|
|
|||
|
|
@ -4,7 +4,7 @@ import type { AppConfig } from './core/types'
|
|||
* Minimal AIO hub configuration.
|
||||
* The all-in-one app at app.${domain} ships only the base module —
|
||||
* each feature module (wallet, chat, market, tasks, forum, activities,
|
||||
* castle) is now its own standalone PWA at its own subdomain.
|
||||
* libra) is now its own standalone PWA at its own subdomain.
|
||||
*/
|
||||
export const appConfig: AppConfig = {
|
||||
modules: {
|
||||
|
|
|
|||
|
|
@ -20,7 +20,7 @@ import { installLenientAuthGuard, markAuthReady, catchAllRoute } from '@/lib/rou
|
|||
*
|
||||
* The all-in-one app at app.${domain} now ships only the base module
|
||||
* plus a chakra icon hub linking out to the standalone module apps
|
||||
* (wallet, chat, market, tasks, forum, activities, castle).
|
||||
* (wallet, chat, market, tasks, forum, activities, libra).
|
||||
*/
|
||||
export async function createAppInstance() {
|
||||
console.log('🚀 Starting AIO hub...')
|
||||
|
|
|
|||
|
|
@ -119,7 +119,7 @@ const messages: LocaleMessages = {
|
|||
language: 'Language',
|
||||
},
|
||||
},
|
||||
castle: {
|
||||
libra: {
|
||||
nav: {
|
||||
record: 'Record',
|
||||
transactions: 'Transactions',
|
||||
|
|
|
|||
|
|
@ -119,7 +119,7 @@ const messages: LocaleMessages = {
|
|||
language: 'Idioma',
|
||||
},
|
||||
},
|
||||
castle: {
|
||||
libra: {
|
||||
nav: {
|
||||
record: 'Registrar',
|
||||
transactions: 'Transacciones',
|
||||
|
|
|
|||
|
|
@ -119,7 +119,7 @@ const messages: LocaleMessages = {
|
|||
language: 'Langue',
|
||||
},
|
||||
},
|
||||
castle: {
|
||||
libra: {
|
||||
nav: {
|
||||
record: 'Saisir',
|
||||
transactions: 'Transactions',
|
||||
|
|
|
|||
|
|
@ -94,8 +94,8 @@ export interface LocaleMessages {
|
|||
language: string
|
||||
}
|
||||
}
|
||||
// Castle accounting module
|
||||
castle?: {
|
||||
// Libra accounting module
|
||||
libra?: {
|
||||
nav: {
|
||||
record: string
|
||||
transactions: string
|
||||
|
|
|
|||
|
|
@ -41,7 +41,7 @@ function isFullyAuthed(auth: AuthLike): boolean {
|
|||
|
||||
/**
|
||||
* Strict guard — every non-/login route requires auth.
|
||||
* Used by wallet, chat, castle (no public view).
|
||||
* Used by wallet, chat, libra (no public view).
|
||||
*/
|
||||
export function installStrictAuthGuard(router: Router): void {
|
||||
router.beforeEach(async (to) => {
|
||||
|
|
|
|||
|
|
@ -2,7 +2,7 @@
|
|||
* Expenses Module
|
||||
*
|
||||
* Provides expense tracking and submission functionality
|
||||
* integrated with castle LNbits extension.
|
||||
* integrated with libra LNbits extension.
|
||||
*/
|
||||
|
||||
import type { App } from 'vue'
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
/**
|
||||
* API service for castle extension expense operations
|
||||
* API service for libra extension expense operations
|
||||
*/
|
||||
|
||||
import { BaseService } from '@/core/base/BaseService'
|
||||
|
|
@ -48,7 +48,7 @@ export class ExpensesAPI extends BaseService {
|
|||
}
|
||||
|
||||
/**
|
||||
* Get all accounts from castle
|
||||
* Get all accounts from libra
|
||||
*
|
||||
* @param walletKey - Wallet key for authentication
|
||||
* @param filterByUser - If true, only return accounts the user has permissions for
|
||||
|
|
@ -60,7 +60,7 @@ export class ExpensesAPI extends BaseService {
|
|||
excludeVirtual: boolean = true
|
||||
): Promise<Account[]> {
|
||||
try {
|
||||
const url = new URL(`${this.baseUrl}/castle/api/v1/accounts`)
|
||||
const url = new URL(`${this.baseUrl}/libra/api/v1/accounts`)
|
||||
if (filterByUser) {
|
||||
url.searchParams.set('filter_by_user', 'true')
|
||||
}
|
||||
|
|
@ -162,11 +162,11 @@ export class ExpensesAPI extends BaseService {
|
|||
}
|
||||
|
||||
/**
|
||||
* Submit expense entry to castle
|
||||
* Submit expense entry to libra
|
||||
*/
|
||||
async submitExpense(walletKey: string, request: ExpenseEntryRequest): Promise<ExpenseEntry> {
|
||||
try {
|
||||
const response = await fetch(`${this.baseUrl}/castle/api/v1/entries/expense`, {
|
||||
const response = await fetch(`${this.baseUrl}/libra/api/v1/entries/expense`, {
|
||||
method: 'POST',
|
||||
headers: this.getHeaders(walletKey),
|
||||
body: JSON.stringify(request),
|
||||
|
|
@ -193,7 +193,7 @@ export class ExpensesAPI extends BaseService {
|
|||
*/
|
||||
async getUserExpenses(walletKey: string): Promise<ExpenseEntry[]> {
|
||||
try {
|
||||
const response = await fetch(`${this.baseUrl}/castle/api/v1/entries/user`, {
|
||||
const response = await fetch(`${this.baseUrl}/libra/api/v1/entries/user`, {
|
||||
method: 'GET',
|
||||
headers: this.getHeaders(walletKey),
|
||||
signal: AbortSignal.timeout(this.config?.apiConfig?.timeout || 30000)
|
||||
|
|
@ -214,11 +214,11 @@ export class ExpensesAPI extends BaseService {
|
|||
}
|
||||
|
||||
/**
|
||||
* Get user's balance with castle
|
||||
* Get user's balance with libra
|
||||
*/
|
||||
async getUserBalance(walletKey: string): Promise<{ balance: number; currency: string }> {
|
||||
try {
|
||||
const response = await fetch(`${this.baseUrl}/castle/api/v1/balance`, {
|
||||
const response = await fetch(`${this.baseUrl}/libra/api/v1/balance`, {
|
||||
method: 'GET',
|
||||
headers: this.getHeaders(walletKey),
|
||||
signal: AbortSignal.timeout(this.config?.apiConfig?.timeout || 30000)
|
||||
|
|
@ -285,7 +285,7 @@ export class ExpensesAPI extends BaseService {
|
|||
*/
|
||||
async getUserInfo(walletKey: string): Promise<UserInfo> {
|
||||
try {
|
||||
const response = await fetch(`${this.baseUrl}/castle/api/v1/user/info`, {
|
||||
const response = await fetch(`${this.baseUrl}/libra/api/v1/user/info`, {
|
||||
method: 'GET',
|
||||
headers: this.getHeaders(walletKey),
|
||||
signal: AbortSignal.timeout(this.config?.apiConfig?.timeout || 30000)
|
||||
|
|
@ -313,7 +313,7 @@ export class ExpensesAPI extends BaseService {
|
|||
*/
|
||||
async listPermissions(adminKey: string): Promise<AccountPermission[]> {
|
||||
try {
|
||||
const response = await fetch(`${this.baseUrl}/castle/api/v1/permissions`, {
|
||||
const response = await fetch(`${this.baseUrl}/libra/api/v1/permissions`, {
|
||||
method: 'GET',
|
||||
headers: this.getHeaders(adminKey),
|
||||
signal: AbortSignal.timeout(this.config?.apiConfig?.timeout || 30000)
|
||||
|
|
@ -342,7 +342,7 @@ export class ExpensesAPI extends BaseService {
|
|||
request: GrantPermissionRequest
|
||||
): Promise<AccountPermission> {
|
||||
try {
|
||||
const response = await fetch(`${this.baseUrl}/castle/api/v1/permissions`, {
|
||||
const response = await fetch(`${this.baseUrl}/libra/api/v1/permissions`, {
|
||||
method: 'POST',
|
||||
headers: this.getHeaders(adminKey),
|
||||
body: JSON.stringify(request),
|
||||
|
|
@ -373,7 +373,7 @@ export class ExpensesAPI extends BaseService {
|
|||
async revokePermission(adminKey: string, permissionId: string): Promise<void> {
|
||||
try {
|
||||
const response = await fetch(
|
||||
`${this.baseUrl}/castle/api/v1/permissions/${permissionId}`,
|
||||
`${this.baseUrl}/libra/api/v1/permissions/${permissionId}`,
|
||||
{
|
||||
method: 'DELETE',
|
||||
headers: this.getHeaders(adminKey),
|
||||
|
|
@ -412,7 +412,7 @@ export class ExpensesAPI extends BaseService {
|
|||
}
|
||||
): Promise<TransactionListResponse> {
|
||||
try {
|
||||
const url = new URL(`${this.baseUrl}/castle/api/v1/entries/user`)
|
||||
const url = new URL(`${this.baseUrl}/libra/api/v1/entries/user`)
|
||||
|
||||
// Add query parameters
|
||||
if (options?.limit) url.searchParams.set('limit', String(options.limit))
|
||||
|
|
|
|||
|
|
@ -3,7 +3,7 @@
|
|||
*/
|
||||
|
||||
/**
|
||||
* Account types in the castle double-entry accounting system
|
||||
* Account types in the libra double-entry accounting system
|
||||
*/
|
||||
export enum AccountType {
|
||||
ASSET = 'asset',
|
||||
|
|
@ -30,7 +30,7 @@ export interface Account {
|
|||
|
||||
/**
|
||||
* Account with user-specific permission metadata
|
||||
* (Will be available once castle API implements permissions)
|
||||
* (Will be available once libra API implements permissions)
|
||||
*/
|
||||
export interface AccountWithPermissions extends Account {
|
||||
user_permissions?: PermissionType[]
|
||||
|
|
@ -61,7 +61,7 @@ export interface ExpenseEntryRequest {
|
|||
}
|
||||
|
||||
/**
|
||||
* Expense entry response from castle API
|
||||
* Expense entry response from libra API
|
||||
*/
|
||||
export interface ExpenseEntry {
|
||||
id: string
|
||||
|
|
|
|||
|
|
@ -65,7 +65,7 @@ function handleSearchResults(results: Transaction[]) {
|
|||
searchResults.value = results
|
||||
}
|
||||
|
||||
// Date range options (matching castle LNbits extension)
|
||||
// Date range options (matching libra LNbits extension)
|
||||
const dateRangeOptions = [
|
||||
{ label: '15 days', value: 15 },
|
||||
{ label: '30 days', value: 30 },
|
||||
|
|
|
|||
|
|
@ -6,7 +6,7 @@ import { useTheme } from '@/components/theme-provider'
|
|||
import { useLocale } from '@/composables/useLocale'
|
||||
import { toast } from 'vue-sonner'
|
||||
import {
|
||||
Castle, ListTodo, Newspaper, MessageCircle, Wallet, CalendarDays,
|
||||
Scale, ListTodo, Newspaper, MessageCircle, Wallet, CalendarDays,
|
||||
Store, UtensilsCrossed,
|
||||
User as UserIcon, LogIn, Sun, Moon, Monitor, Globe, Coins,
|
||||
} from 'lucide-vue-next'
|
||||
|
|
@ -48,7 +48,7 @@ const modules: Module[] = [
|
|||
{ label: 'Chat', chakra: 'Anahata', icon: MessageCircle, bgClass: '', glow: 'rgba(0,200,80,0.5)', envKey: 'VITE_HUB_CHAT_URL', status: 'alpha', authRequired: true },
|
||||
{ label: 'Forum', chakra: 'Vishuddha', icon: Newspaper, bgClass: '', glow: 'rgba(60,120,255,0.5)', envKey: 'VITE_HUB_FORUM_URL', status: 'alpha' },
|
||||
{ label: 'Tasks', chakra: 'Ajna', icon: ListTodo, bgClass: '', glow: 'rgba(99,80,200,0.5)', envKey: 'VITE_HUB_TASKS_URL', status: 'alpha', authRequired: true },
|
||||
{ label: 'Castle', chakra: 'Sahasrara', icon: Castle, bgClass: '', glow: 'rgba(160,80,220,0.5)', envKey: 'VITE_HUB_CASTLE_URL', status: 'beta', authRequired: true },
|
||||
{ label: 'Libra', chakra: 'Sahasrara', icon: Scale, bgClass: '', glow: 'rgba(160,80,220,0.5)', envKey: 'VITE_HUB_LIBRA_URL', status: 'beta', authRequired: true },
|
||||
]
|
||||
// Crown at top, root at bottom
|
||||
const orderedModules = computed(() => [...modules].reverse())
|
||||
|
|
@ -57,7 +57,7 @@ const token = computed(() => localStorage.getItem('lnbits_access_token') || '')
|
|||
|
||||
function hubLink(m: Module): string | null {
|
||||
if (!m.envKey) return null
|
||||
// Auth-only modules (wallet, chat, castle, tasks) are ghosted when not logged in.
|
||||
// Auth-only modules (wallet, chat, libra, tasks) are ghosted when not logged in.
|
||||
if (m.authRequired && !isAuthenticated.value) return null
|
||||
const url = import.meta.env[m.envKey] as string | undefined
|
||||
if (!url) return null
|
||||
|
|
|
|||
|
|
@ -33,7 +33,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\//, /^\/wallet\//, /^\/chat\//, /^\/market\//, /^\/cart\//, /^\/checkout\//, /^\/tasks\//, /^\/forum\//, /^\/submit\//, /^\/submission\//],
|
||||
navigateFallbackDenylist: [/^\/sortir\//, /^\/libra\//, /^\/wallet\//, /^\/chat\//, /^\/market\//, /^\/cart\//, /^\/checkout\//, /^\/tasks\//, /^\/forum\//, /^\/submit\//, /^\/submission\//],
|
||||
},
|
||||
includeAssets: [
|
||||
'favicon.ico',
|
||||
|
|
|
|||
|
|
@ -7,15 +7,15 @@ 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)
|
||||
* Plugin to rewrite dev server requests to libra.html
|
||||
* (SPA fallback for the standalone Libra accounting app entry point)
|
||||
*/
|
||||
function castleHtmlPlugin(): Plugin {
|
||||
function libraHtmlPlugin(): Plugin {
|
||||
return {
|
||||
name: 'castle-html-rewrite',
|
||||
name: 'libra-html-rewrite',
|
||||
configureServer(server) {
|
||||
server.middlewares.use((req, _res, next) => {
|
||||
// Rewrite all non-asset requests to castle.html.
|
||||
// Rewrite all non-asset requests to libra.html.
|
||||
// Strip query before checking for an extension — JWTs (e.g. ?token=...)
|
||||
// contain dots and would otherwise get mistaken for an asset request.
|
||||
const path = req.url ? req.url.split('?')[0] : ''
|
||||
|
|
@ -26,7 +26,7 @@ function castleHtmlPlugin(): Plugin {
|
|||
!req.url.startsWith('/node_modules/') &&
|
||||
!path.includes('.')
|
||||
) {
|
||||
req.url = '/castle.html'
|
||||
req.url = '/libra.html'
|
||||
}
|
||||
next()
|
||||
})
|
||||
|
|
@ -35,22 +35,22 @@ function castleHtmlPlugin(): Plugin {
|
|||
}
|
||||
|
||||
/**
|
||||
* Vite config for the standalone Castle accounting app.
|
||||
* Vite config for the standalone Libra accounting app.
|
||||
*
|
||||
* Set VITE_BASE_PATH to deploy under a path prefix:
|
||||
* VITE_BASE_PATH=/castle/ → app.ariege.io/castle/ (shared auth)
|
||||
* (default: /) → castle.ariege.io (standalone subdomain)
|
||||
* VITE_BASE_PATH=/libra/ → app.ariege.io/libra/ (shared auth)
|
||||
* (default: /) → libra.ariege.io (standalone subdomain)
|
||||
*/
|
||||
export default defineConfig(({ mode }) => ({
|
||||
base: process.env.VITE_BASE_PATH || '/',
|
||||
// Per-app dep cache so concurrent dev servers don't race on .vite/deps
|
||||
cacheDir: 'node_modules/.vite-castle',
|
||||
cacheDir: 'node_modules/.vite-libra',
|
||||
server: {
|
||||
port: 5180,
|
||||
strictPort: true,
|
||||
},
|
||||
plugins: [
|
||||
castleHtmlPlugin(),
|
||||
libraHtmlPlugin(),
|
||||
vue(),
|
||||
tailwindcss(),
|
||||
VitePWA({
|
||||
|
|
@ -60,7 +60,7 @@ export default defineConfig(({ mode }) => ({
|
|||
},
|
||||
workbox: {
|
||||
globPatterns: ['**/*.{js,css,html,ico,png,svg}'],
|
||||
navigateFallback: 'castle.html',
|
||||
navigateFallback: 'libra.html',
|
||||
navigateFallbackAllowlist: [
|
||||
new RegExp(`^${(process.env.VITE_BASE_PATH || '/').replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}`),
|
||||
],
|
||||
|
|
@ -75,8 +75,8 @@ export default defineConfig(({ mode }) => ({
|
|||
'icon-maskable-512.png',
|
||||
],
|
||||
manifest: {
|
||||
name: 'Castle — Team Accounting',
|
||||
short_name: 'Castle',
|
||||
name: 'Libra — Team Accounting',
|
||||
short_name: 'Libra',
|
||||
description: 'Team accounting and expense management',
|
||||
theme_color: '#1f2937',
|
||||
background_color: '#ffffff',
|
||||
|
|
@ -84,7 +84,7 @@ export default defineConfig(({ mode }) => ({
|
|||
orientation: 'portrait-primary',
|
||||
start_url: process.env.VITE_BASE_PATH || '/',
|
||||
scope: process.env.VITE_BASE_PATH || '/',
|
||||
id: 'castle-accounting',
|
||||
id: 'libra-accounting',
|
||||
categories: ['finance', 'business', 'productivity'],
|
||||
lang: 'en',
|
||||
icons: [
|
||||
|
|
@ -103,7 +103,7 @@ export default defineConfig(({ mode }) => ({
|
|||
mode === 'analyze' &&
|
||||
visualizer({
|
||||
open: true,
|
||||
filename: 'dist-castle/stats.html',
|
||||
filename: 'dist-libra/stats.html',
|
||||
gzipSize: true,
|
||||
brotliSize: true,
|
||||
}),
|
||||
|
|
@ -120,9 +120,9 @@ export default defineConfig(({ mode }) => ({
|
|||
},
|
||||
},
|
||||
build: {
|
||||
outDir: 'dist-castle',
|
||||
outDir: 'dist-libra',
|
||||
rollupOptions: {
|
||||
input: 'castle.html',
|
||||
input: 'libra.html',
|
||||
output: {
|
||||
manualChunks: {
|
||||
'vue-vendor': ['vue', 'vue-router', 'pinia'],
|
||||
Loading…
Add table
Add a link
Reference in a new issue