Compare commits

..

No commits in common. "castle-web" and "main" have entirely different histories.

38 changed files with 494 additions and 4931 deletions

484
CLAUDE.md
View file

@ -26,7 +26,7 @@ This is a modular Vue 3 + TypeScript + Vite application with Electron support, f
The application uses a plugin-based modular architecture with dependency injection for service management:
**Core Modules:**
- **Base Module** (`src/modules/base/`) - Core infrastructure (Nostr, Auth, PWA, Image Upload)
- **Base Module** (`src/modules/base/`) - Core infrastructure (Nostr, Auth, PWA)
- **Wallet Module** (`src/modules/wallet/`) - Lightning wallet management with real-time balance updates
- **Nostr Feed Module** (`src/modules/nostr-feed/`) - Social feed functionality
- **Chat Module** (`src/modules/chat/`) - Encrypted Nostr chat
@ -90,12 +90,6 @@ const relayHub = injectService(SERVICE_TOKENS.RELAY_HUB)
- `SERVICE_TOKENS.VISIBILITY_SERVICE` - App visibility and connection management
- `SERVICE_TOKENS.WALLET_SERVICE` - Wallet operations (send, receive, transactions)
- `SERVICE_TOKENS.WALLET_WEBSOCKET_SERVICE` - Real-time wallet balance updates via WebSocket
- `SERVICE_TOKENS.STORAGE_SERVICE` - Local storage management
- `SERVICE_TOKENS.TOAST_SERVICE` - Toast notification system
- `SERVICE_TOKENS.INVOICE_SERVICE` - Lightning invoice creation and management
- `SERVICE_TOKENS.LNBITS_API` - LNbits API client
- `SERVICE_TOKENS.IMAGE_UPLOAD_SERVICE` - Image upload to pictrs server
- `SERVICE_TOKENS.NOSTR_METADATA_SERVICE` - Nostr user metadata (NIP-01 kind 0)
**Core Stack:**
- Vue 3 with Composition API (`<script setup>` style)
@ -128,8 +122,6 @@ const relayHub = injectService(SERVICE_TOKENS.RELAY_HUB)
- `api/` - API integrations
- `types/` - TypeScript type definitions
- `src/pages/` - Route pages
- `src/modules/` - Modular feature implementations
- `src/core/` - Core infrastructure (DI, BaseService, plugin manager)
- `electron/` - Electron main process code
**Lightning Wallet Integration:**
@ -151,10 +143,8 @@ The app integrates with LNbits for Lightning Network wallet functionality with r
```typescript
websocket: {
enabled: true, // Enable/disable WebSocket functionality
reconnectDelay: 2000, // Initial reconnection delay (ms)
maxReconnectAttempts: 3, // Maximum reconnection attempts
fallbackToPolling: true, // Enable polling fallback when WebSocket fails
pollingInterval: 10000 // Polling interval (ms)
reconnectDelay: 1000, // Initial reconnection delay
maxReconnectAttempts: 5 // Maximum reconnection attempts
}
```
@ -239,88 +229,6 @@ export const myModule: ModulePlugin = {
- Module configs in `src/app.config.ts`
- Centralized config parsing and validation
### **BaseService Pattern**
All services MUST extend `BaseService` (`src/core/base/BaseService.ts`) for standardized initialization and dependency management:
**Service Implementation Pattern:**
```typescript
import { BaseService } from '@/core/base/BaseService'
export class MyService extends BaseService {
// 1. REQUIRED: Declare metadata with dependencies
protected readonly metadata = {
name: 'MyService',
version: '1.0.0',
dependencies: ['AuthService', 'RelayHub', 'VisibilityService']
}
// 2. REQUIRED: Implement onInitialize
protected async onInitialize(): Promise<void> {
// Dependencies are auto-injected based on metadata.dependencies
// Available: this.authService, this.relayHub, this.visibilityService, etc.
// Register with VisibilityService if using WebSockets
if (this.visibilityService) {
this.visibilityService.registerService(
this.metadata.name,
this.onResume.bind(this),
this.onPause.bind(this)
)
}
// Your initialization logic
await this.setupConnections()
}
// 3. Implement visibility handlers for WebSocket services
private async onResume(): Promise<void> {
// Reconnect and restore state when app becomes visible
}
private async onPause(): Promise<void> {
// Pause operations when app loses visibility
}
// 4. Optional: Cleanup logic
protected async onDispose(): Promise<void> {
// Cleanup connections, subscriptions, etc.
}
}
```
**BaseService Features:**
- **Automatic dependency injection** based on `metadata.dependencies`
- **Retry logic** with configurable retries and delays
- **Reactive state** via `isInitialized`, `isInitializing`, `initError`
- **Event emission** for service lifecycle events
- **Error handling** with consistent logging
- **Debug helpers** for development
**Service Initialization:**
```typescript
// In module's index.ts
const myService = new MyService()
container.provide(SERVICE_TOKENS.MY_SERVICE, myService)
// Initialize with options
await myService.initialize({
waitForDependencies: true, // Wait for dependencies before initializing
maxRetries: 3, // Retry on failure
retryDelay: 1000 // Delay between retries (ms)
})
```
**Available Dependencies:**
When you list these in `metadata.dependencies`, they'll be auto-injected:
- `'RelayHub'``this.relayHub`
- `'AuthService'``this.authService`
- `'VisibilityService'``this.visibilityService`
- `'StorageService'``this.storageService`
- `'ToastService'``this.toastService`
- `'LnbitsAPI'``this.lnbitsAPI`
### **Form Implementation Standards**
**CRITICAL: Always use Shadcn/UI Form Components with vee-validate**
@ -474,44 +382,35 @@ For Shadcn/ui Checkbox components, you MUST use the correct Vue.js binding patte
- ✅ **Force Re-render**: Use dynamic `:key` if checkbox doesn't reflect initial form values
- ❌ **Don't Mix**: Never mix checked/model-value patterns - they have different behaviors
### **CSS and Styling Guidelines**
**Reference**: [Vue.js Forms Documentation](https://vuejs.org/guide/essentials/forms.html)
**CRITICAL: Always use semantic, theme-aware CSS classes**
**❌ NEVER do this:**
```vue
<!-- Wrong: Manual form handling without vee-validate -->
<form @submit.prevent="handleSubmit">
- ✅ **Use semantic classes** that automatically adapt to light/dark themes
- ❌ **Never use hard-coded colors** like `bg-white`, `text-gray-500`, `border-blue-500`
<!-- Wrong: Direct v-model bypasses form validation -->
<Input v-model="myValue" />
**Preferred Semantic Classes:**
```css
/* Background Colors */
bg-background /* Instead of bg-white */
bg-card /* Instead of bg-gray-50 */
bg-muted /* Instead of bg-gray-100 */
/* Text Colors */
text-foreground /* Instead of text-gray-900 */
text-muted-foreground /* Instead of text-gray-600 */
text-primary /* For primary theme color */
text-accent /* For accent theme color */
/* Borders */
border-border /* Instead of border-gray-200 */
border-input /* Instead of border-gray-300 */
/* Focus States */
focus:ring-ring /* Instead of focus:ring-blue-500 */
focus:border-ring /* Instead of focus:border-blue-500 */
/* Opacity Modifiers */
bg-primary/10 /* For subtle variations */
text-muted-foreground/70 /* For transparency */
<!-- Wrong: Manual validation instead of using meta.valid -->
<Button :disabled="!name || !email">Submit</Button>
```
**Why Semantic Classes:**
- Ensures components work in both light and dark themes
- Maintains consistency with Shadcn/ui component library
- Easier to maintain and update theme colors globally
- Better accessibility
**✅ ALWAYS do this:**
```vue
<!-- Correct: Uses form.handleSubmit for proper form handling -->
<form @submit="onSubmit">
<!-- Correct: Uses FormField with componentField binding -->
<FormField v-slot="{ componentField }" name="fieldName">
<FormControl>
<Input v-bind="componentField" />
</FormControl>
</FormField>
<!-- Correct: Uses form meta for validation state -->
<Button :disabled="!isFormValid">Submit</Button>
```
### **Vue Reactivity Best Practices**
@ -564,32 +463,23 @@ createdObject.value = Object.assign({}, apiResponse)
- ✅ Input components showing external data
- ✅ Any scenario where template doesn't update after data changes
### **⚠️ CRITICAL: JavaScript Falsy Value Bug Prevention**
**ALWAYS use nullish coalescing (`??`) instead of logical OR (`||`) for numeric defaults:**
**Example from Wallet Module:**
```typescript
// ❌ WRONG: Treats 0 as falsy, defaults to 1 even when quantity is validly 0
quantity: productData.quantity || 1
// Service returns complex invoice object
const invoice = await walletService.createInvoice(data)
// ✅ CORRECT: Only defaults to 1 when quantity is null or undefined
quantity: productData.quantity ?? 1
// Force reactivity for template updates
createdInvoice.value = Object.assign({}, invoice)
```
**Why this matters:**
- JavaScript falsy values include: `false`, `0`, `""`, `null`, `undefined`, `NaN`
- Using `||` for defaults will incorrectly override valid `0` values
- This caused a critical bug where products with quantity `0` displayed as quantity `1`
- The `??` operator only triggers for `null` and `undefined`, preserving valid `0` values
**Common scenarios where this bug occurs:**
- Product quantities, prices, counters (any numeric value where 0 is valid)
- Boolean flags where `false` is a valid state
- Empty strings that should be preserved vs. undefined strings
**Rule of thumb:**
- Use `||` only when `0`, `false`, or `""` should trigger the default
- Use `??` when only `null`/`undefined` should trigger the default (most cases)
```vue
<!-- Template with forced reactivity -->
<Input
:key="`bolt11-${createdInvoice?.payment_hash}`"
:model-value="createdInvoice?.payment_request || ''"
readonly
/>
```
### **Module Development Best Practices**
@ -640,6 +530,166 @@ Before considering any module complete, verify ALL items:
- [ ] Configuration is properly loaded
- [ ] Module can be disabled via config
**Required Module Structure:**
```
src/modules/[module-name]/
├── index.ts # Module plugin definition (REQUIRED)
├── components/ # Module-specific components
├── composables/ # Module composables (use DI for services)
├── services/ # Module services (extend BaseService)
│ ├── [module]Service.ts # Core module service
│ └── [module]API.ts # LNbits API integration
├── stores/ # Module-specific Pinia stores
├── types/ # Module type definitions
└── views/ # Module pages/views
```
**Service Implementation Pattern:**
**⚠️ CRITICAL SERVICE REQUIREMENTS - MUST FOLLOW EXACTLY:**
```typescript
// ✅ CORRECT: Proper BaseService implementation
export class MyModuleService extends BaseService {
// 1. REQUIRED: Declare metadata with dependencies
protected readonly metadata = {
name: 'MyModuleService',
version: '1.0.0',
dependencies: ['PaymentService', 'AuthService'] // List ALL service dependencies by name
}
// 2. REQUIRED: DO NOT manually inject services in onInitialize
protected async onInitialize(): Promise<void> {
// ❌ WRONG: Manual injection
// this.paymentService = injectService(SERVICE_TOKENS.PAYMENT_SERVICE)
// ✅ CORRECT: BaseService auto-injects based on metadata.dependencies
// this.paymentService is already available here!
// 3. REQUIRED: Register with VisibilityService if you have ANY real-time features
if (this.hasRealTimeFeatures()) {
this.visibilityService.registerService(
this.metadata.name,
this.onResume.bind(this),
this.onPause.bind(this)
)
}
// 4. Initialize your module-specific logic
await this.loadInitialData()
}
// 5. REQUIRED: Implement visibility handlers for connection management
private async onResume(): Promise<void> {
// Restore connections, refresh data when app becomes visible
await this.checkConnectionHealth()
await this.refreshData()
}
private async onPause(): Promise<void> {
// Pause expensive operations for battery efficiency
this.pausePolling()
}
private hasRealTimeFeatures(): boolean {
// Return true if your service uses WebSockets, polling, or real-time updates
return true
}
}
// API services for LNbits integration
export class MyModuleAPI extends BaseService {
private baseUrl: string
constructor() {
super()
// ❌ WRONG: Direct config import
// import { config } from '@/lib/config'
// ✅ CORRECT: Use module configuration
const moduleConfig = appConfig.modules.myModule.config
this.baseUrl = moduleConfig.apiConfig.baseUrl
}
// API methods here
}
```
**❌ COMMON MISTAKES TO AVOID:**
1. **Manual service injection** in onInitialize - BaseService handles this
2. **Direct config imports** - Always use module configuration
3. **Missing metadata.dependencies** - Breaks automatic dependency injection
4. **No VisibilityService integration** - Causes connection issues on mobile
5. **Not using proper initialization options** - Miss dependency waiting
**Module Plugin Pattern:**
**⚠️ CRITICAL MODULE INSTALLATION REQUIREMENTS:**
```typescript
export const myModule: ModulePlugin = {
name: 'my-module',
version: '1.0.0',
dependencies: ['base'], // ALWAYS depend on 'base' for core infrastructure
async install(app: App, options?: { config?: MyModuleConfig }) {
// 1. REQUIRED: Create service instances
const myService = new MyModuleService()
const myAPI = new MyModuleAPI()
// 2. REQUIRED: Register in DI container BEFORE initialization
container.provide(SERVICE_TOKENS.MY_SERVICE, myService)
container.provide(SERVICE_TOKENS.MY_API, myAPI)
// 3. CRITICAL: Initialize services with proper options
await myService.initialize({
waitForDependencies: true, // REQUIRED: Wait for dependencies
maxRetries: 3, // RECOMMENDED: Retry on failure
timeout: 5000 // OPTIONAL: Timeout for initialization
})
// Initialize API service if it needs initialization
if (myAPI.initialize) {
await myAPI.initialize({
waitForDependencies: true,
maxRetries: 3
})
}
// 4. Register components AFTER services are initialized
app.component('MyComponent', MyComponent)
// 5. OPTIONAL: Export for testing/debugging
return {
service: myService,
api: myAPI
}
}
}
```
**MODULE CONFIGURATION IN app.config.ts:**
```typescript
// REQUIRED: Add module configuration
export default {
modules: {
'my-module': {
enabled: true,
config: {
apiConfig: {
baseUrl: import.meta.env.VITE_API_BASE_URL || 'http://localhost:5000'
},
// Module-specific configuration
features: {
realTimeUpdates: true,
offlineSupport: false
}
}
}
}
}
```
**Nostr Integration Rules:**
1. **NEVER create separate relay connections** - always use the central RelayHub
2. **Access RelayHub through DI**: `const relayHub = injectService(SERVICE_TOKENS.RELAY_HUB)`
@ -685,34 +735,44 @@ export function useMyModule() {
- **ALWAYS use Shadcn Form components for all form implementations**
- **ALWAYS extend BaseService for module services**
- **NEVER create direct dependencies between modules**
- **ALWAYS use semantic CSS classes, never hard-coded colors**
### **Build Configuration:**
### **⚠️ CRITICAL: JavaScript Falsy Value Bug Prevention**
**ALWAYS use nullish coalescing (`??`) instead of logical OR (`||`) for numeric defaults:**
```typescript
// ❌ WRONG: Treats 0 as falsy, defaults to 1 even when quantity is validly 0
quantity: productData.quantity || 1
// ✅ CORRECT: Only defaults to 1 when quantity is null or undefined
quantity: productData.quantity ?? 1
```
**Why this matters:**
- JavaScript falsy values include: `false`, `0`, `""`, `null`, `undefined`, `NaN`
- Using `||` for defaults will incorrectly override valid `0` values
- This caused a critical bug where products with quantity `0` displayed as quantity `1`
- The `??` operator only triggers for `null` and `undefined`, preserving valid `0` values
**Common scenarios where this bug occurs:**
- Product quantities, prices, counters (any numeric value where 0 is valid)
- Boolean flags where `false` is a valid state
- Empty strings that should be preserved vs. undefined strings
**Rule of thumb:**
- Use `||` only when `0`, `false`, or `""` should trigger the default
- Use `??` when only `null`/`undefined` should trigger the default (most cases)
**Build Configuration:**
- Vite config includes PWA, image optimization, and bundle analysis
- Manual chunking strategy for vendor libraries (vue-vendor, ui-vendor, shadcn)
- Electron Forge configured for cross-platform packaging
- TailwindCSS v4 integration via Vite plugin
### **Environment Variables:**
Required environment variables in `.env`:
```bash
# LNbits server URL for Lightning wallet functionality
VITE_LNBITS_BASE_URL=http://localhost:5000
# Nostr relay configuration (JSON array)
VITE_NOSTR_RELAYS='["wss://relay1.example.com","wss://relay2.example.com"]'
# Image upload server (pictrs)
VITE_PICTRS_BASE_URL=https://img.mydomain.com
# Admin public keys for feed moderation (JSON array)
VITE_ADMIN_PUBKEYS='["pubkey1","pubkey2"]'
# Optional: Disable WebSocket if needed
VITE_WEBSOCKET_ENABLED=true
```
**Environment:**
- Nostr relay configuration via `VITE_NOSTR_RELAYS` environment variable
- PWA manifest configured for standalone app experience
- Service worker with automatic updates every hour
## Mobile Browser File Input & Form Refresh Issues
@ -846,6 +906,86 @@ window.addEventListener('beforeunload', blockNavigation)
<form @submit.prevent="handleSubmit">
```
### **Android 14/15 Camera Workarounds**
**Non-Standard MIME Type Workaround:**
```html
<!-- Add non-standard MIME type to force camera access -->
<input type="file" accept="image/*,android/allowCamera" capture="environment" />
```
**Plain File Input Fallback:**
```html
<!-- Fallback: Plain file input shows both camera and gallery options -->
<input type="file" accept="image/*" />
```
### **Industry-Standard Patterns**
**1. Page Visibility API (Primary Solution):**
```javascript
// Modern browsers: Use Page Visibility API instead of beforeunload
document.addEventListener('visibilitychange', function() {
if (document.visibilityState === 'visible') {
// Resume critical operations, restore connections
resumeOperations()
} else {
// Save state, pause operations for battery conservation
saveStateAndPause()
}
})
```
**2. Conditional BeforeUnload Protection:**
```javascript
// Only add beforeunload listeners when user has unsaved changes
const addFormProtection = (hasUnsavedChanges) => {
if (hasUnsavedChanges) {
window.addEventListener('beforeunload', preventUnload)
} else {
window.removeEventListener('beforeunload', preventUnload)
}
}
```
**3. Session Recovery Pattern:**
```javascript
// Save form state on visibility change
document.addEventListener('visibilitychange', () => {
if (document.visibilityState === 'hidden') {
localStorage.setItem('formDraft', JSON.stringify(formData))
}
})
// Restore on page load
window.addEventListener('load', () => {
const draft = localStorage.getItem('formDraft')
if (draft) restoreFormData(JSON.parse(draft))
})
```
### **Testing & Debugging**
**Reproduction Steps:**
1. Open form with file upload on mobile device
2. Select camera input during image upload operations
3. Turn screen off/on during upload process
4. Switch between apps during file selection
5. Low memory conditions during camera usage
**Success Indicators:**
- User sees confirmation dialog instead of losing form data
- Console warnings show visibility change detection working
- Form state preservation during app switching
- Camera input properly separates from gallery input
**Debug Console Messages:**
```javascript
// Look for these defensive programming console messages
console.warn('Form submission blocked during file upload')
console.warn('Visibility change detected while form is open')
```
### **Key Takeaways**
1. **This is a systemic mobile browser issue**, not a bug in our application code

57
package-lock.json generated
View file

@ -25,7 +25,7 @@
"qr-scanner": "^1.4.2",
"qrcode": "^1.5.4",
"radix-vue": "^1.9.13",
"reka-ui": "^2.6.0",
"reka-ui": "^2.5.0",
"tailwind-merge": "^2.6.0",
"tailwindcss-animate": "^1.0.7",
"unique-names-generator": "^4.7.1",
@ -141,7 +141,6 @@
"integrity": "sha512-l+lkXCHS6tQEc5oUpK28xBOZ6+HwaH7YwoYQbLFiYb4nS2/l1tKnZEtEWkD0GuiYdvArf9qBS0XlQGXzPMsNqQ==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"@ampproject/remapping": "^2.2.0",
"@babel/code-frame": "^7.26.2",
@ -2647,7 +2646,6 @@
"integrity": "sha512-zx0EIq78WlY/lBb1uXlziZmDZI4ubcCXIMJ4uGjXzZW0nS19TjSPeXPAjzzTmKQlJUZm0SbmZhPKP7tuQ1SsEw==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"chalk": "^4.1.1",
"fs-extra": "^9.0.1",
@ -5690,7 +5688,6 @@
"integrity": "sha512-B/gBuNg5SiMTrPkC+A2+cW0RszwxYmn6VYxB/inlBStS5nx6xHIt/ehKRhIMhqusl7a8LjQoZnjCs5vhwxOQ1g==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"fast-deep-equal": "^3.1.3",
"fast-uri": "^3.0.1",
@ -5853,6 +5850,14 @@
"node": ">= 0.4"
}
},
"node_modules/async-validator": {
"version": "4.2.5",
"resolved": "https://registry.npmjs.org/async-validator/-/async-validator-4.2.5.tgz",
"integrity": "sha512-7HhHjtERjqlNbZtqNqy2rckN/SpOOlmDliet+lP7k+eKZEjPk3DgyeU9lIXLdeLz0uBbbVp+9Qdow9wJWgwwfg==",
"license": "MIT",
"optional": true,
"peer": true
},
"node_modules/at-least-node": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/at-least-node/-/at-least-node-1.0.0.tgz",
@ -6053,7 +6058,6 @@
}
],
"license": "MIT",
"peer": true,
"dependencies": {
"caniuse-lite": "^1.0.30001688",
"electron-to-chromium": "^1.5.73",
@ -7600,6 +7604,17 @@
"integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==",
"license": "MIT"
},
"node_modules/encoding": {
"version": "0.1.13",
"resolved": "https://registry.npmjs.org/encoding/-/encoding-0.1.13.tgz",
"integrity": "sha512-ETBauow1T35Y/WZMkio9jiM0Z5xjHHmJ4XmjZOq1l/dXz3lr2sRn87nJy20RupqSh1F2m3HHPSp8ShIPQJrJ3A==",
"dev": true,
"license": "MIT",
"optional": true,
"dependencies": {
"iconv-lite": "^0.6.2"
}
},
"node_modules/end-of-stream": {
"version": "1.4.4",
"resolved": "https://registry.npmjs.org/end-of-stream/-/end-of-stream-1.4.4.tgz",
@ -8353,7 +8368,6 @@
"resolved": "https://registry.npmjs.org/fuse.js/-/fuse.js-7.0.0.tgz",
"integrity": "sha512-14F4hBIxqKvD4Zz/XjDc3y94mNZN6pRv3U13Udo0lNLCWRBUsrMv2xwcF/y/Z5sV6+FQW+/ow68cHpm4sunt8Q==",
"license": "Apache-2.0",
"peer": true,
"engines": {
"node": ">=10"
}
@ -8890,6 +8904,20 @@
"ms": "^2.0.0"
}
},
"node_modules/iconv-lite": {
"version": "0.6.3",
"resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.6.3.tgz",
"integrity": "sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw==",
"dev": true,
"license": "MIT",
"optional": true,
"dependencies": {
"safer-buffer": ">= 2.1.2 < 3.0.0"
},
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/idb": {
"version": "7.1.1",
"resolved": "https://registry.npmjs.org/idb/-/idb-7.1.1.tgz",
@ -11690,7 +11718,6 @@
"resolved": "https://registry.npmjs.org/qrcode/-/qrcode-1.5.4.tgz",
"integrity": "sha512-1ca71Zgiu6ORjHqFBDpnSMTR2ReToX4l1Au1VFLyVeBTFavzQnv5JxMFr3ukHVKpSrSA2MCk0lNJSykjUfz7Zg==",
"license": "MIT",
"peer": true,
"dependencies": {
"dijkstrajs": "^1.0.1",
"pngjs": "^5.0.0",
@ -12153,9 +12180,9 @@
}
},
"node_modules/reka-ui": {
"version": "2.6.0",
"resolved": "https://registry.npmjs.org/reka-ui/-/reka-ui-2.6.0.tgz",
"integrity": "sha512-NrGMKrABD97l890mFS3TNUzB0BLUfbL3hh0NjcJRIUSUljb288bx3Mzo31nOyUcdiiW0HqFGXJwyCBh9cWgb0w==",
"version": "2.5.0",
"resolved": "https://registry.npmjs.org/reka-ui/-/reka-ui-2.5.0.tgz",
"integrity": "sha512-81aMAmJeVCy2k0E6x7n1kypDY6aM1ldLis5+zcdV1/JtoAlSDck5OBsyLRJU9CfgbrQp1ImnRnBSmC4fZ2fkZQ==",
"license": "MIT",
"dependencies": {
"@floating-ui/dom": "^1.6.13",
@ -12334,7 +12361,6 @@
"integrity": "sha512-4iya7Jb76fVpQyLoiVpzUrsjQ12r3dM7fIVz+4NwoYvZOShknRmiv+iu9CClZml5ZLGb0XMcYLutK6w9tgxHDw==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"@types/estree": "1.0.8"
},
@ -13344,8 +13370,7 @@
"version": "4.0.12",
"resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-4.0.12.tgz",
"integrity": "sha512-bT0hJo91FtncsAMSsMzUkoo/iEU0Xs5xgFgVC9XmdM9bw5MhZuQFjPNl6wxAE0SiQF/YTZJa+PndGWYSDtuxAg==",
"license": "MIT",
"peer": true
"license": "MIT"
},
"node_modules/tailwindcss-animate": {
"version": "1.0.7",
@ -13480,7 +13505,6 @@
"integrity": "sha512-GWANVlPM/ZfYzuPHjq0nxT+EbOEDDN3Jwhwdg1D8TU8oSkktp8w64Uq4auuGLxFSoNTRDncTq2hQHX1Ld9KHkA==",
"dev": true,
"license": "BSD-2-Clause",
"peer": true,
"dependencies": {
"@jridgewell/source-map": "^0.3.3",
"acorn": "^8.8.2",
@ -13710,7 +13734,6 @@
"integrity": "sha512-hjcS1mhfuyi4WW8IWtjP7brDrG2cuDZukyrYrSauoXGNgx0S7zceP07adYkJycEr56BOUTNPzbInooiN3fn1qw==",
"devOptional": true,
"license": "Apache-2.0",
"peer": true,
"bin": {
"tsc": "bin/tsc",
"tsserver": "bin/tsserver"
@ -13961,7 +13984,6 @@
"integrity": "sha512-cZn6NDFE7wdTpINgs++ZJ4N49W2vRp8LCKrn3Ob1kYNtOo21vfDoaV5GzBfLU4MovSAB8uNRm4jgzVQZ+mBzPQ==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"esbuild": "^0.25.0",
"fdir": "^6.4.4",
@ -14186,7 +14208,6 @@
"resolved": "https://registry.npmjs.org/vue/-/vue-3.5.13.tgz",
"integrity": "sha512-wmeiSMxkZCSc+PM2w2VRsOYAZC8GdipNFRTsLSfodVqI9mbejKeXEGr8SckuLnrQPGe3oJN5c3K0vpoU9q/wCQ==",
"license": "MIT",
"peer": true,
"dependencies": {
"@vue/compiler-dom": "3.5.13",
"@vue/compiler-sfc": "3.5.13",
@ -14639,7 +14660,6 @@
"integrity": "sha512-fS6iqSPZDs3dr/y7Od6y5nha8dW1YnbgtsyotCVvoFGKbERG++CVRFv1meyGDE1SNItQA8BrnCw7ScdAhRJ3XQ==",
"dev": true,
"license": "MIT",
"peer": true,
"bin": {
"rollup": "dist/bin/rollup"
},
@ -14897,7 +14917,6 @@
"resolved": "https://registry.npmjs.org/zod/-/zod-3.25.76.tgz",
"integrity": "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==",
"license": "MIT",
"peer": true,
"funding": {
"url": "https://github.com/sponsors/colinhacks"
}

View file

@ -34,7 +34,7 @@
"qr-scanner": "^1.4.2",
"qrcode": "^1.5.4",
"radix-vue": "^1.9.13",
"reka-ui": "^2.6.0",
"reka-ui": "^2.5.0",
"tailwind-merge": "^2.6.0",
"tailwindcss-animate": "^1.0.7",
"unique-names-generator": "^4.7.1",

View file

@ -93,20 +93,6 @@ export const appConfig: AppConfig = {
pollingInterval: 10000 // 10 seconds for polling updates
}
}
},
expenses: {
name: 'expenses',
enabled: true,
lazy: false,
config: {
apiConfig: {
baseUrl: import.meta.env.VITE_LNBITS_BASE_URL || 'http://localhost:5000',
timeout: 30000 // 30 seconds for API requests
},
defaultCurrency: 'sats',
maxExpenseAmount: 1000000, // 1M sats
requireDescription: true
}
}
},

View file

@ -16,7 +16,6 @@ import chatModule from './modules/chat'
import eventsModule from './modules/events'
import marketModule from './modules/market'
import walletModule from './modules/wallet'
import expensesModule from './modules/expenses'
// Root component
import App from './App.vue'
@ -44,8 +43,7 @@ export async function createAppInstance() {
...chatModule.routes || [],
...eventsModule.routes || [],
...marketModule.routes || [],
...walletModule.routes || [],
...expensesModule.routes || []
...walletModule.routes || []
].filter(Boolean)
// Create router with all routes available immediately
@ -128,13 +126,6 @@ export async function createAppInstance() {
)
}
// Register expenses module
if (appConfig.modules.expenses?.enabled) {
moduleRegistrations.push(
pluginManager.register(expensesModule, appConfig.modules.expenses)
)
}
// Wait for all modules to register
await Promise.all(moduleRegistrations)

View file

@ -1,15 +0,0 @@
<script setup lang="ts">
import type { AlertDialogEmits, AlertDialogProps } from "reka-ui"
import { AlertDialogRoot, useForwardPropsEmits } from "reka-ui"
const props = defineProps<AlertDialogProps>()
const emits = defineEmits<AlertDialogEmits>()
const forwarded = useForwardPropsEmits(props, emits)
</script>
<template>
<AlertDialogRoot v-bind="forwarded">
<slot />
</AlertDialogRoot>
</template>

View file

@ -1,18 +0,0 @@
<script setup lang="ts">
import type { AlertDialogActionProps } from "reka-ui"
import type { HTMLAttributes } from "vue"
import { reactiveOmit } from "@vueuse/core"
import { AlertDialogAction } from "reka-ui"
import { cn } from "@/lib/utils"
import { buttonVariants } from '@/components/ui/button'
const props = defineProps<AlertDialogActionProps & { class?: HTMLAttributes["class"] }>()
const delegatedProps = reactiveOmit(props, "class")
</script>
<template>
<AlertDialogAction v-bind="delegatedProps" :class="cn(buttonVariants(), props.class)">
<slot />
</AlertDialogAction>
</template>

View file

@ -1,25 +0,0 @@
<script setup lang="ts">
import type { AlertDialogCancelProps } from "reka-ui"
import type { HTMLAttributes } from "vue"
import { reactiveOmit } from "@vueuse/core"
import { AlertDialogCancel } from "reka-ui"
import { cn } from "@/lib/utils"
import { buttonVariants } from '@/components/ui/button'
const props = defineProps<AlertDialogCancelProps & { class?: HTMLAttributes["class"] }>()
const delegatedProps = reactiveOmit(props, "class")
</script>
<template>
<AlertDialogCancel
v-bind="delegatedProps"
:class="cn(
buttonVariants({ variant: 'outline' }),
'mt-2 sm:mt-0',
props.class,
)"
>
<slot />
</AlertDialogCancel>
</template>

View file

@ -1,39 +0,0 @@
<script setup lang="ts">
import type { AlertDialogContentEmits, AlertDialogContentProps } from "reka-ui"
import type { HTMLAttributes } from "vue"
import { reactiveOmit } from "@vueuse/core"
import {
AlertDialogContent,
AlertDialogOverlay,
AlertDialogPortal,
useForwardPropsEmits,
} from "reka-ui"
import { cn } from "@/lib/utils"
const props = defineProps<AlertDialogContentProps & { class?: HTMLAttributes["class"] }>()
const emits = defineEmits<AlertDialogContentEmits>()
const delegatedProps = reactiveOmit(props, "class")
const forwarded = useForwardPropsEmits(delegatedProps, emits)
</script>
<template>
<AlertDialogPortal>
<AlertDialogOverlay
class="fixed inset-0 z-50 bg-black/80 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0"
/>
<AlertDialogContent
v-bind="forwarded"
:class="
cn(
'fixed left-1/2 top-1/2 z-50 grid w-full max-w-lg -translate-x-1/2 -translate-y-1/2 gap-4 border bg-background p-6 shadow-lg duration-200 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[state=closed]:slide-out-to-left-1/2 data-[state=closed]:slide-out-to-top-[48%] data-[state=open]:slide-in-from-left-1/2 data-[state=open]:slide-in-from-top-[48%] sm:rounded-lg',
props.class,
)
"
>
<slot />
</AlertDialogContent>
</AlertDialogPortal>
</template>

View file

@ -1,23 +0,0 @@
<script setup lang="ts">
import type { AlertDialogDescriptionProps } from "reka-ui"
import type { HTMLAttributes } from "vue"
import { reactiveOmit } from "@vueuse/core"
import {
AlertDialogDescription,
} from "reka-ui"
import { cn } from "@/lib/utils"
const props = defineProps<AlertDialogDescriptionProps & { class?: HTMLAttributes["class"] }>()
const delegatedProps = reactiveOmit(props, "class")
</script>
<template>
<AlertDialogDescription
v-bind="delegatedProps"
:class="cn('text-sm text-muted-foreground', props.class)"
>
<slot />
</AlertDialogDescription>
</template>

View file

@ -1,21 +0,0 @@
<script setup lang="ts">
import type { HTMLAttributes } from "vue"
import { cn } from "@/lib/utils"
const props = defineProps<{
class?: HTMLAttributes["class"]
}>()
</script>
<template>
<div
:class="
cn(
'flex flex-col-reverse sm:flex-row sm:justify-end sm:gap-x-2',
props.class,
)
"
>
<slot />
</div>
</template>

View file

@ -1,16 +0,0 @@
<script setup lang="ts">
import type { HTMLAttributes } from "vue"
import { cn } from "@/lib/utils"
const props = defineProps<{
class?: HTMLAttributes["class"]
}>()
</script>
<template>
<div
:class="cn('flex flex-col gap-y-2 text-center sm:text-left', props.class)"
>
<slot />
</div>
</template>

View file

@ -1,20 +0,0 @@
<script setup lang="ts">
import type { AlertDialogTitleProps } from "reka-ui"
import type { HTMLAttributes } from "vue"
import { reactiveOmit } from "@vueuse/core"
import { AlertDialogTitle } from "reka-ui"
import { cn } from "@/lib/utils"
const props = defineProps<AlertDialogTitleProps & { class?: HTMLAttributes["class"] }>()
const delegatedProps = reactiveOmit(props, "class")
</script>
<template>
<AlertDialogTitle
v-bind="delegatedProps"
:class="cn('text-lg font-semibold', props.class)"
>
<slot />
</AlertDialogTitle>
</template>

View file

@ -1,12 +0,0 @@
<script setup lang="ts">
import type { AlertDialogTriggerProps } from "reka-ui"
import { AlertDialogTrigger } from "reka-ui"
const props = defineProps<AlertDialogTriggerProps>()
</script>
<template>
<AlertDialogTrigger v-bind="props">
<slot />
</AlertDialogTrigger>
</template>

View file

@ -1,9 +0,0 @@
export { default as AlertDialog } from "./AlertDialog.vue"
export { default as AlertDialogAction } from "./AlertDialogAction.vue"
export { default as AlertDialogCancel } from "./AlertDialogCancel.vue"
export { default as AlertDialogContent } from "./AlertDialogContent.vue"
export { default as AlertDialogDescription } from "./AlertDialogDescription.vue"
export { default as AlertDialogFooter } from "./AlertDialogFooter.vue"
export { default as AlertDialogHeader } from "./AlertDialogHeader.vue"
export { default as AlertDialogTitle } from "./AlertDialogTitle.vue"
export { default as AlertDialogTrigger } from "./AlertDialogTrigger.vue"

View file

@ -1,25 +1,22 @@
<script setup lang="ts">
import type { PrimitiveProps } from "reka-ui"
import type { HTMLAttributes } from "vue"
import type { ButtonVariants } from "."
import { Primitive } from "reka-ui"
import { cn } from "@/lib/utils"
import { buttonVariants } from "."
import type { HTMLAttributes } from 'vue'
import { cn } from '@/lib/utils'
import { Primitive, type PrimitiveProps } from 'reka-ui'
import { type ButtonVariants, buttonVariants } from '.'
interface Props extends PrimitiveProps {
variant?: ButtonVariants["variant"]
size?: ButtonVariants["size"]
class?: HTMLAttributes["class"]
variant?: ButtonVariants['variant']
size?: ButtonVariants['size']
class?: HTMLAttributes['class']
}
const props = withDefaults(defineProps<Props>(), {
as: "button",
as: 'button',
})
</script>
<template>
<Primitive
data-slot="button"
:as="as"
:as-child="asChild"
:class="cn(buttonVariants({ variant, size }), props.class)"

View file

@ -1,37 +1,33 @@
import type { VariantProps } from "class-variance-authority"
import { cva } from "class-variance-authority"
import { cva, type VariantProps } from 'class-variance-authority'
export { default as Button } from "./Button.vue"
export { default as Button } from './Button.vue'
export const buttonVariants = cva(
"inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium transition-all disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg:not([class*='size-'])]:size-4 shrink-0 [&_svg]:shrink-0 outline-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive",
'inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium transition-colors focus-visible:outline-hidden focus-visible:ring-1 focus-visible:ring-ring disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0',
{
variants: {
variant: {
default:
"bg-primary text-primary-foreground shadow-xs hover:bg-primary/90",
default: 'bg-primary text-primary-foreground shadow-sm hover:bg-primary/90',
destructive:
"bg-destructive text-white shadow-xs hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40 dark:bg-destructive/60",
'bg-destructive text-destructive-foreground shadow-xs hover:bg-destructive/90',
outline:
"border bg-background shadow-xs hover:bg-accent hover:text-accent-foreground dark:bg-input/30 dark:border-input dark:hover:bg-input/50",
'border border-input bg-background shadow-xs hover:bg-accent hover:text-accent-foreground',
secondary:
"bg-secondary text-secondary-foreground shadow-xs hover:bg-secondary/80",
ghost:
"hover:bg-accent hover:text-accent-foreground dark:hover:bg-accent/50",
link: "text-primary underline-offset-4 hover:underline",
'bg-secondary text-secondary-foreground shadow-xs hover:bg-secondary/80',
ghost: 'hover:bg-accent hover:text-accent-foreground',
link: 'text-primary underline-offset-4 hover:underline',
},
size: {
"default": "h-9 px-4 py-2 has-[>svg]:px-3",
"sm": "h-8 rounded-md gap-1.5 px-3 has-[>svg]:px-2.5",
"lg": "h-10 rounded-md px-6 has-[>svg]:px-4",
"icon": "size-9",
"icon-sm": "size-8",
"icon-lg": "size-10",
default: 'h-9 px-4 py-2',
xs: 'h-7 rounded px-2',
sm: 'h-8 rounded-md px-3 text-xs',
lg: 'h-10 rounded-md px-8',
icon: 'h-9 w-9',
},
},
defaultVariants: {
variant: "default",
size: "default",
variant: 'default',
size: 'default',
},
},
)

View file

@ -79,16 +79,6 @@ export function useModularNavigation() {
})
}
// Expenses module items
if (appConfig.modules.expenses.enabled) {
items.push({
name: 'My Transactions',
href: '/expenses/transactions',
icon: 'Receipt',
requiresAuth: true
})
}
// Base module items (always available)
items.push({
name: 'Relay Hub Status',

View file

@ -1,95 +0,0 @@
import { computed } from 'vue'
import { pluginManager } from '@/core/plugin-manager'
import type { QuickAction } from '@/core/types'
/**
* Composable for dynamic quick actions based on enabled modules
*
* Quick actions are module-provided action buttons that appear in the floating
* action button (FAB) menu. Each module can register its own quick actions
* for common tasks like composing notes, sending payments, adding expenses, etc.
*
* @example
* ```typescript
* const { quickActions, getActionsByCategory } = useQuickActions()
*
* // Get all actions
* const actions = quickActions.value
*
* // Get actions by category
* const composeActions = getActionsByCategory('compose')
* ```
*/
export function useQuickActions() {
/**
* Get all quick actions from installed modules
* Actions are sorted by order (lower = higher priority)
*/
const quickActions = computed<QuickAction[]>(() => {
const actions: QuickAction[] = []
// Iterate through installed modules
const installedModules = pluginManager.getInstalledModules()
for (const moduleName of installedModules) {
const module = pluginManager.getModule(moduleName)
if (module?.plugin.quickActions) {
actions.push(...module.plugin.quickActions)
}
}
// Sort by order (lower = higher priority), then by label
return actions.sort((a, b) => {
const orderA = a.order ?? 999
const orderB = b.order ?? 999
if (orderA !== orderB) {
return orderA - orderB
}
return a.label.localeCompare(b.label)
})
})
/**
* Get actions filtered by category
*/
const getActionsByCategory = (category: string) => {
return computed(() => {
return quickActions.value.filter(action => action.category === category)
})
}
/**
* Get a specific action by ID
*/
const getActionById = (id: string) => {
return computed(() => {
return quickActions.value.find(action => action.id === id)
})
}
/**
* Check if any actions are available
*/
const hasActions = computed(() => quickActions.value.length > 0)
/**
* Get all unique categories
*/
const categories = computed(() => {
const cats = new Set<string>()
quickActions.value.forEach(action => {
if (action.category) {
cats.add(action.category)
}
})
return Array.from(cats).sort()
})
return {
quickActions,
getActionsByCategory,
getActionById,
hasActions,
categories
}
}

View file

@ -136,7 +136,6 @@ export const SERVICE_TOKENS = {
FEED_SERVICE: Symbol('feedService'),
PROFILE_SERVICE: Symbol('profileService'),
REACTION_SERVICE: Symbol('reactionService'),
SCHEDULED_EVENT_SERVICE: Symbol('scheduledEventService'),
// Nostr metadata services
NOSTR_METADATA_SERVICE: Symbol('nostrMetadataService'),
@ -160,9 +159,6 @@ export const SERVICE_TOKENS = {
// Image upload services
IMAGE_UPLOAD_SERVICE: Symbol('imageUploadService'),
// Expenses services
EXPENSES_API: Symbol('expensesAPI'),
} as const
// Type-safe injection helpers

View file

@ -1,30 +1,6 @@
import type { App, Component } from 'vue'
import type { RouteRecordRaw } from 'vue-router'
// Quick action interface for modular action buttons
export interface QuickAction {
/** Unique action ID */
id: string
/** Display label for the action */
label: string
/** Lucide icon name */
icon: string
/** Component to render when action is selected */
component: Component
/** Display order (lower = higher priority) */
order?: number
/** Action category (e.g., 'compose', 'wallet', 'utilities') */
category?: string
/** Whether action requires authentication */
requiresAuth?: boolean
}
// Base module plugin interface
export interface ModulePlugin {
/** Unique module name */
@ -56,9 +32,6 @@ export interface ModulePlugin {
/** Composables provided by this module */
composables?: Record<string, any>
/** Quick actions provided by this module */
quickActions?: QuickAction[]
}
// Module configuration for app setup

View file

@ -540,12 +540,8 @@ export class RelayHub extends BaseService {
const successful = results.filter(result => result.status === 'fulfilled').length
const total = results.length
this.emit('eventPublished', { eventId: event.id, success: successful, total })
// Throw error if no relays accepted the event
if (successful === 0) {
throw new Error(`Failed to publish event - none of the ${total} relay(s) accepted it`)
}
this.emit('eventPublished', { eventId: event.id, success: successful, total })
return { success: successful, total }
}

View file

@ -1,270 +0,0 @@
<template>
<div class="space-y-4">
<!-- Breadcrumb showing current path -->
<div v-if="selectedPath.length > 0" class="flex items-center gap-2 text-sm">
<Button
variant="ghost"
size="sm"
@click="navigateToRoot"
class="h-7 px-2"
>
<ChevronLeft class="h-4 w-4 mr-1" />
Start
</Button>
<ChevronRight class="h-4 w-4 text-muted-foreground" />
<div class="flex items-center gap-2">
<span
v-for="(node, index) in selectedPath"
:key="node.account.id"
class="flex items-center gap-2"
>
<Button
variant="ghost"
size="sm"
@click="navigateToLevel(index)"
class="h-7 px-2 text-muted-foreground hover:text-foreground"
>
{{ getAccountDisplayName(node.account.name) }}
</Button>
<ChevronRight
v-if="index < selectedPath.length - 1"
class="h-4 w-4 text-muted-foreground"
/>
</span>
</div>
</div>
<!-- Account selection list -->
<div class="border border-border rounded-lg bg-card">
<!-- Loading state -->
<div
v-if="isLoading"
class="flex items-center justify-center py-12"
>
<Loader2 class="h-6 w-6 animate-spin text-muted-foreground" />
<span class="ml-2 text-sm text-muted-foreground">Loading accounts...</span>
</div>
<!-- Error state -->
<div
v-else-if="error"
class="flex flex-col items-center justify-center py-12 px-4"
>
<AlertCircle class="h-8 w-8 text-destructive mb-2" />
<p class="text-sm text-destructive">{{ error }}</p>
<Button
variant="outline"
size="sm"
@click="loadAccounts"
class="mt-4"
>
<RefreshCw class="h-4 w-4 mr-2" />
Retry
</Button>
</div>
<!-- Account list -->
<div v-else-if="currentNodes.length > 0" class="divide-y divide-border">
<button
v-for="node in currentNodes"
:key="node.account.id"
@click="selectNode(node)"
type="button"
class="w-full flex items-center justify-between p-4 hover:bg-muted/50 transition-colors text-left"
>
<div class="flex items-center gap-3">
<Folder
v-if="node.account.has_children"
class="h-5 w-5 text-primary"
/>
<FileText
v-else
class="h-5 w-5 text-muted-foreground"
/>
<div>
<p class="font-medium text-foreground">
{{ getAccountDisplayName(node.account.name) }}
</p>
<p
v-if="node.account.description"
class="text-sm text-muted-foreground"
>
{{ node.account.description }}
</p>
</div>
</div>
<div class="flex items-center gap-2">
<Badge
v-if="!node.account.has_children"
variant="outline"
class="text-xs"
>
{{ node.account.account_type }}
</Badge>
<ChevronRight
v-if="node.account.has_children"
class="h-5 w-5 text-muted-foreground"
/>
</div>
</button>
</div>
<!-- Empty state -->
<div
v-else
class="flex flex-col items-center justify-center py-12 px-4"
>
<Folder class="h-12 w-12 text-muted-foreground mb-2" />
<p class="text-sm text-muted-foreground">No accounts available</p>
</div>
</div>
<!-- Selected account display -->
<div
v-if="selectedAccount"
class="flex items-center justify-between p-4 rounded-lg border-2 border-primary bg-primary/5"
>
<div class="flex items-center gap-3">
<Check class="h-5 w-5 text-primary" />
<div>
<p class="font-medium text-foreground">Selected Account</p>
<p class="text-sm text-muted-foreground">{{ selectedAccount.name }}</p>
</div>
</div>
<Badge variant="default">{{ selectedAccount.account_type }}</Badge>
</div>
</div>
</template>
<script setup lang="ts">
import { ref, computed, onMounted } from 'vue'
import { Button } from '@/components/ui/button'
import { Badge } from '@/components/ui/badge'
import {
ChevronLeft,
ChevronRight,
Folder,
FileText,
Loader2,
AlertCircle,
RefreshCw,
Check
} from 'lucide-vue-next'
import { injectService, SERVICE_TOKENS } from '@/core/di-container'
import { useAuth } from '@/composables/useAuthService'
import type { ExpensesAPI } from '../services/ExpensesAPI'
import type { Account, AccountNode } from '../types'
interface Props {
rootAccount?: string
modelValue?: Account | null
}
interface Emits {
(e: 'update:modelValue', value: Account | null): void
(e: 'account-selected', value: Account): void
}
const props = defineProps<Props>()
const emit = defineEmits<Emits>()
// Inject services and composables
const expensesAPI = injectService<ExpensesAPI>(SERVICE_TOKENS.EXPENSES_API)
const { user } = useAuth()
// Component state
const isLoading = ref(false)
const error = ref<string | null>(null)
const accountHierarchy = ref<AccountNode[]>([])
const selectedPath = ref<AccountNode[]>([])
const selectedAccount = ref<Account | null>(props.modelValue ?? null)
// Current nodes to display (either root or children of selected node)
const currentNodes = computed(() => {
if (selectedPath.value.length === 0) {
return accountHierarchy.value
}
const lastNode = selectedPath.value[selectedPath.value.length - 1]
return lastNode.children
})
/**
* Get display name from full account path
* e.g., "Expenses:Groceries:Organic" -> "Organic"
*/
function getAccountDisplayName(fullName: string): string {
const parts = fullName.split(':')
return parts[parts.length - 1]
}
/**
* Load accounts from API
*/
async function loadAccounts() {
isLoading.value = true
error.value = null
try {
// Get wallet key from first wallet (invoice key for read operations)
const wallet = user.value?.wallets?.[0]
if (!wallet || !wallet.inkey) {
throw new Error('No wallet available. Please log in.')
}
// Filter by user permissions to only show authorized accounts
accountHierarchy.value = await expensesAPI.getAccountHierarchy(
wallet.inkey,
props.rootAccount,
true // filterByUser
)
console.log('[AccountSelector] Loaded user-authorized accounts:', accountHierarchy.value)
} catch (err) {
error.value = err instanceof Error ? err.message : 'Failed to load accounts'
console.error('[AccountSelector] Error loading accounts:', err)
} finally {
isLoading.value = false
}
}
/**
* Handle node selection
*/
function selectNode(node: AccountNode) {
if (node.account.has_children) {
// Navigate into folder
selectedPath.value.push(node)
selectedAccount.value = null
emit('update:modelValue', null)
} else {
// Select leaf account
selectedAccount.value = node.account
emit('update:modelValue', node.account)
emit('account-selected', node.account)
}
}
/**
* Navigate back to root
*/
function navigateToRoot() {
selectedPath.value = []
selectedAccount.value = null
emit('update:modelValue', null)
}
/**
* Navigate to specific level in breadcrumb
*/
function navigateToLevel(level: number) {
selectedPath.value = selectedPath.value.slice(0, level + 1)
selectedAccount.value = null
emit('update:modelValue', null)
}
// Load accounts on mount
onMounted(() => {
loadAccounts()
})
</script>

View file

@ -1,469 +0,0 @@
<template>
<Dialog :open="true" @update:open="(open) => !open && handleDialogClose()">
<DialogContent class="max-w-2xl max-h-[85vh] my-4 overflow-hidden flex flex-col p-0 gap-0">
<!-- Success State -->
<div v-if="showSuccessDialog" class="flex flex-col items-center justify-center p-8 space-y-6">
<!-- Success Icon -->
<div class="rounded-full bg-green-100 dark:bg-green-900/20 p-4">
<CheckCircle2 class="h-12 w-12 text-green-600 dark:text-green-400" />
</div>
<!-- Success Message -->
<div class="text-center space-y-3">
<h2 class="text-2xl font-bold">Expense Submitted Successfully!</h2>
<!-- Pending Approval Badge -->
<div class="inline-flex items-center gap-2 px-4 py-2 rounded-full bg-orange-100 dark:bg-orange-900/20 border border-orange-200 dark:border-orange-800">
<Clock class="h-4 w-4 text-orange-600 dark:text-orange-400" />
<span class="text-sm font-medium text-orange-700 dark:text-orange-300">
Pending Admin Approval
</span>
</div>
<p class="text-muted-foreground">
Your expense has been submitted successfully. An administrator will review and approve it shortly.
</p>
<p class="text-sm text-muted-foreground">
You can track the approval status in your transactions page.
</p>
</div>
<!-- Action Buttons -->
<div class="flex flex-col sm:flex-row gap-3 w-full max-w-sm">
<Button variant="outline" @click="closeSuccessDialog" class="flex-1">
Close
</Button>
<Button @click="goToTransactions" class="flex-1">
<Receipt class="h-4 w-4 mr-2" />
View My Transactions
</Button>
</div>
</div>
<!-- Form State -->
<template v-else>
<!-- Header -->
<DialogHeader class="px-6 pt-6 pb-4 border-b shrink-0">
<DialogTitle class="flex items-center gap-2">
<DollarSign class="h-5 w-5 text-primary" />
<span>Add Expense</span>
</DialogTitle>
<DialogDescription>
Submit an expense for admin approval
</DialogDescription>
</DialogHeader>
<!-- Scrollable Form Content -->
<div class="flex-1 overflow-y-auto px-6 py-4 space-y-4 min-h-0">
<!-- Step indicator -->
<div class="flex items-center justify-center gap-2 mb-4">
<div
:class="[
'flex items-center justify-center w-8 h-8 rounded-full font-medium text-sm transition-colors',
currentStep === 1
? 'bg-primary text-primary-foreground'
: selectedAccount
? 'bg-primary/20 text-primary'
: 'bg-muted text-muted-foreground'
]"
>
1
</div>
<div class="w-12 h-px bg-border" />
<div
:class="[
'flex items-center justify-center w-8 h-8 rounded-full font-medium text-sm transition-colors',
currentStep === 2
? 'bg-primary text-primary-foreground'
: 'bg-muted text-muted-foreground'
]"
>
2
</div>
</div>
<!-- Step 1: Account Selection -->
<div v-if="currentStep === 1">
<p class="text-sm text-muted-foreground mb-4">
Select the account for this expense
</p>
<AccountSelector
v-model="selectedAccount"
root-account="Expenses"
@account-selected="handleAccountSelected"
/>
</div>
<!-- Step 2: Expense Details -->
<div v-if="currentStep === 2">
<form @submit="onSubmit" class="space-y-4">
<!-- Description -->
<FormField v-slot="{ componentField }" name="description">
<FormItem>
<FormLabel>Description *</FormLabel>
<FormControl>
<Textarea
placeholder="e.g., Biocoop, Ferme des Croquantes, Foix Market, etc"
v-bind="componentField"
rows="3"
/>
</FormControl>
<FormDescription>
Describe what this expense was for
</FormDescription>
<FormMessage />
</FormItem>
</FormField>
<!-- Amount -->
<FormField v-slot="{ componentField }" name="amount">
<FormItem>
<FormLabel>Amount *</FormLabel>
<FormControl>
<Input
type="number"
placeholder="0.00"
v-bind="componentField"
min="0.01"
step="0.01"
/>
</FormControl>
<FormDescription>
Amount in selected currency
</FormDescription>
<FormMessage />
</FormItem>
</FormField>
<!-- Currency -->
<FormField v-slot="{ componentField }" name="currency">
<FormItem>
<FormLabel>Currency *</FormLabel>
<Select v-bind="componentField">
<FormControl>
<SelectTrigger>
<SelectValue placeholder="Select currency" />
</SelectTrigger>
</FormControl>
<SelectContent>
<SelectItem
v-for="currency in availableCurrencies"
:key="currency"
:value="currency"
>
{{ currency }}
</SelectItem>
</SelectContent>
</Select>
<FormDescription>
Currency for this expense
</FormDescription>
<FormMessage />
</FormItem>
</FormField>
<!-- Reference (optional) -->
<FormField v-slot="{ componentField }" name="reference">
<FormItem>
<FormLabel>Reference</FormLabel>
<FormControl>
<Input
placeholder="e.g., Invoice #123, Receipt #456"
v-bind="componentField"
/>
</FormControl>
<FormDescription>
Optional reference number or note
</FormDescription>
<FormMessage />
</FormItem>
</FormField>
<!-- Convert to equity checkbox (only show if user is equity eligible) -->
<FormField v-if="userInfo?.is_equity_eligible" v-slot="{ value, handleChange }" name="isEquity">
<FormItem>
<div class="flex items-center space-x-2">
<FormControl>
<Checkbox
:model-value="value"
@update:model-value="handleChange"
:disabled="isSubmitting"
/>
</FormControl>
<div class="space-y-1 leading-none">
<FormLabel>Convert to equity contribution</FormLabel>
<FormDescription>
Instead of cash reimbursement, increase your equity stake by this amount
</FormDescription>
</div>
</div>
</FormItem>
</FormField>
<!-- Selected account info -->
<div class="p-3 rounded-lg bg-muted/50">
<div class="flex items-center gap-2 mb-1">
<span class="text-sm text-muted-foreground">Account:</span>
<Badge variant="secondary" class="font-mono">{{ selectedAccount?.name }}</Badge>
</div>
<p
v-if="selectedAccount?.description"
class="text-xs text-muted-foreground"
>
{{ selectedAccount.description }}
</p>
</div>
<!-- Action buttons -->
<div class="flex items-center gap-2 pt-2 pb-6">
<Button
type="button"
variant="outline"
@click="currentStep = 1"
:disabled="isSubmitting"
class="flex-1"
>
<ChevronLeft class="h-4 w-4 mr-1" />
Back
</Button>
<Button
type="submit"
:disabled="isSubmitting || !isFormValid"
class="flex-1"
>
<Loader2
v-if="isSubmitting"
class="h-4 w-4 mr-2 animate-spin"
/>
<span>{{ isSubmitting ? 'Submitting...' : 'Submit Expense' }}</span>
</Button>
</div>
</form>
</div>
</div>
</template>
</DialogContent>
</Dialog>
</template>
<script setup lang="ts">
import { ref, computed, onMounted } from 'vue'
import { useRouter } from 'vue-router'
import { useForm } from 'vee-validate'
import { toTypedSchema } from '@vee-validate/zod'
import * as z from 'zod'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import { Textarea } from '@/components/ui/textarea'
import { Checkbox } from '@/components/ui/checkbox'
import { Badge } from '@/components/ui/badge'
import {
FormControl,
FormDescription,
FormField,
FormItem,
FormLabel,
FormMessage,
} from '@/components/ui/form'
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select'
import {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog'
import { DollarSign, ChevronLeft, Loader2, CheckCircle2, Receipt, Clock } from 'lucide-vue-next'
import { injectService, SERVICE_TOKENS } from '@/core/di-container'
import { useAuth } from '@/composables/useAuthService'
import { useToast } from '@/core/composables/useToast'
import type { ExpensesAPI } from '../services/ExpensesAPI'
import type { Account, UserInfo } from '../types'
import AccountSelector from './AccountSelector.vue'
interface Emits {
(e: 'close'): void
(e: 'expense-submitted'): void
(e: 'action-complete'): void
}
const emit = defineEmits<Emits>()
// Inject services and composables
const expensesAPI = injectService<ExpensesAPI>(SERVICE_TOKENS.EXPENSES_API)
const { user } = useAuth()
const toast = useToast()
const router = useRouter()
// Component state
const currentStep = ref(1)
const selectedAccount = ref<Account | null>(null)
const isSubmitting = ref(false)
const availableCurrencies = ref<string[]>([])
const loadingCurrencies = ref(true)
const userInfo = ref<UserInfo | null>(null)
const showSuccessDialog = ref(false)
// Form schema
const formSchema = toTypedSchema(
z.object({
description: z.string().min(1, 'Description is required').max(500, 'Description too long'),
amount: z.coerce.number().min(0.01, 'Amount must be at least 0.01'),
currency: z.string().min(1, 'Currency is required'),
reference: z.string().max(100, 'Reference too long').optional(),
isEquity: z.boolean().default(false)
})
)
// Set up form
const form = useForm({
validationSchema: formSchema,
initialValues: {
description: '',
amount: 0,
currency: '', // Will be set dynamically from LNbits default currency
reference: '',
isEquity: false
}
})
const { resetForm, meta } = form
const isFormValid = computed(() => meta.value.valid)
/**
* Fetch available currencies, default currency, and user info on component mount
*/
onMounted(async () => {
try {
loadingCurrencies.value = true
// Get wallet key
const wallet = user.value?.wallets?.[0]
if (!wallet || !wallet.inkey) {
console.warn('[AddExpense] No wallet available for loading data')
return
}
// Fetch available currencies
const currencies = await expensesAPI.getCurrencies()
availableCurrencies.value = currencies
console.log('[AddExpense] Loaded currencies:', currencies)
// Fetch default currency
const defaultCurrency = await expensesAPI.getDefaultCurrency()
// Set default currency: use configured default, or first available currency, or 'EUR' as final fallback
const initialCurrency = defaultCurrency || currencies[0] || 'EUR'
form.setFieldValue('currency', initialCurrency)
console.log('[AddExpense] Default currency set to:', initialCurrency)
// Fetch user info to check equity eligibility
userInfo.value = await expensesAPI.getUserInfo(wallet.inkey)
console.log('[AddExpense] User info loaded:', {
is_equity_eligible: userInfo.value.is_equity_eligible,
equity_account: userInfo.value.equity_account_name
})
} catch (error) {
console.error('[AddExpense] Failed to load data:', error)
toast.error('Failed to load form data', { description: 'Please check your connection and try again' })
availableCurrencies.value = []
} finally {
loadingCurrencies.value = false
}
})
/**
* Handle account selection
*/
function handleAccountSelected(account: Account) {
selectedAccount.value = account
currentStep.value = 2
}
/**
* Submit expense
*/
const onSubmit = form.handleSubmit(async (values) => {
if (!selectedAccount.value) {
console.error('[AddExpense] No account selected')
toast.error('No account selected', { description: 'Please select an account first' })
return
}
// Get wallet key from first wallet (invoice key for submission)
const wallet = user.value?.wallets?.[0]
if (!wallet || !wallet.inkey) {
toast.error('No wallet available', { description: 'Please log in to submit expenses' })
return
}
isSubmitting.value = true
try {
await expensesAPI.submitExpense(wallet.inkey, {
description: values.description,
amount: values.amount,
expense_account: selectedAccount.value.name,
is_equity: values.isEquity,
user_wallet: wallet.id,
reference: values.reference,
currency: values.currency
})
// Show success dialog instead of toast
showSuccessDialog.value = true
// Reset form for next submission
resetForm()
selectedAccount.value = null
currentStep.value = 1
emit('expense-submitted')
// DON'T emit 'action-complete' yet - wait for user to close success dialog
} catch (error) {
console.error('[AddExpense] Error submitting expense:', error)
const errorMessage = error instanceof Error ? error.message : 'Unknown error'
toast.error('Submission failed', { description: errorMessage })
} finally {
isSubmitting.value = false
}
})
/**
* Handle viewing transactions
*/
function goToTransactions() {
showSuccessDialog.value = false
emit('action-complete')
emit('close')
router.push('/expenses/transactions')
}
/**
* Handle closing success dialog
*/
function closeSuccessDialog() {
showSuccessDialog.value = false
emit('action-complete')
emit('close')
}
/**
* Handle dialog close (from X button or clicking outside)
*/
function handleDialogClose() {
if (showSuccessDialog.value) {
// If in success state, close the whole thing
closeSuccessDialog()
} else {
// If in form state, just close normally
emit('close')
}
}
</script>

View file

@ -1,256 +0,0 @@
<script setup lang="ts">
import { ref, computed } from 'vue'
import { useForm } from 'vee-validate'
import { toTypedSchema } from '@vee-validate/zod'
import * as z from 'zod'
import { useAuth } from '@/composables/useAuthService'
import { useToast } from '@/core/composables/useToast'
import { injectService, SERVICE_TOKENS } from '@/core/di-container'
import type { ExpensesAPI } from '../../services/ExpensesAPI'
import type { Account } from '../../types'
import { PermissionType } from '../../types'
import { Button } from '@/components/ui/button'
import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle } from '@/components/ui/dialog'
import {
FormControl,
FormDescription,
FormField,
FormItem,
FormLabel,
FormMessage
} from '@/components/ui/form'
import { Input } from '@/components/ui/input'
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'
import { Textarea } from '@/components/ui/textarea'
import { Loader2 } from 'lucide-vue-next'
interface Props {
isOpen: boolean
accounts: Account[]
}
const props = defineProps<Props>()
const emit = defineEmits<{
close: []
permissionGranted: []
}>()
const { user } = useAuth()
const toast = useToast()
const expensesAPI = injectService<ExpensesAPI>(SERVICE_TOKENS.EXPENSES_API)
const isGranting = ref(false)
const adminKey = computed(() => user.value?.wallets?.[0]?.adminkey)
// Form schema
const formSchema = toTypedSchema(
z.object({
user_id: z.string().min(1, 'User ID is required'),
account_id: z.string().min(1, 'Account is required'),
permission_type: z.nativeEnum(PermissionType, {
errorMap: () => ({ message: 'Permission type is required' })
}),
notes: z.string().optional(),
expires_at: z.string().optional()
})
)
// Setup form
const form = useForm({
validationSchema: formSchema,
initialValues: {
user_id: '',
account_id: '',
permission_type: PermissionType.READ,
notes: '',
expires_at: ''
}
})
const { resetForm, meta } = form
const isFormValid = computed(() => meta.value.valid)
// Permission type options
const permissionTypes = [
{ value: PermissionType.READ, label: 'Read', description: 'View account and balance' },
{
value: PermissionType.SUBMIT_EXPENSE,
label: 'Submit Expense',
description: 'Submit expenses to this account'
},
{ value: PermissionType.MANAGE, label: 'Manage', description: 'Full account management' }
]
// Submit form
const onSubmit = form.handleSubmit(async (values) => {
if (!adminKey.value) {
toast.error('Admin access required')
return
}
isGranting.value = true
try {
await expensesAPI.grantPermission(adminKey.value, {
user_id: values.user_id,
account_id: values.account_id,
permission_type: values.permission_type,
notes: values.notes || undefined,
expires_at: values.expires_at || undefined
})
emit('permissionGranted')
resetForm()
} catch (error) {
console.error('Failed to grant permission:', error)
toast.error('Failed to grant permission', {
description: error instanceof Error ? error.message : 'Unknown error'
})
} finally {
isGranting.value = false
}
})
// Handle dialog close
function handleClose() {
if (!isGranting.value) {
resetForm()
emit('close')
}
}
</script>
<template>
<Dialog :open="props.isOpen" @update:open="handleClose">
<DialogContent class="sm:max-w-[500px]">
<DialogHeader>
<DialogTitle>Grant Account Permission</DialogTitle>
<DialogDescription>
Grant a user permission to access an expense account. Permissions on parent accounts
cascade to children.
</DialogDescription>
</DialogHeader>
<form @submit="onSubmit" class="space-y-4">
<!-- User ID -->
<FormField v-slot="{ componentField }" name="user_id">
<FormItem>
<FormLabel>User ID *</FormLabel>
<FormControl>
<Input
placeholder="Enter user wallet ID"
v-bind="componentField"
:disabled="isGranting"
/>
</FormControl>
<FormDescription>The wallet ID of the user to grant permission to</FormDescription>
<FormMessage />
</FormItem>
</FormField>
<!-- Account -->
<FormField v-slot="{ componentField }" name="account_id">
<FormItem>
<FormLabel>Account *</FormLabel>
<Select v-bind="componentField" :disabled="isGranting">
<FormControl>
<SelectTrigger>
<SelectValue placeholder="Select account" />
</SelectTrigger>
</FormControl>
<SelectContent>
<SelectItem
v-for="account in props.accounts"
:key="account.id"
:value="account.id"
>
{{ account.name }}
</SelectItem>
</SelectContent>
</Select>
<FormDescription>Account to grant access to</FormDescription>
<FormMessage />
</FormItem>
</FormField>
<!-- Permission Type -->
<FormField v-slot="{ componentField }" name="permission_type">
<FormItem>
<FormLabel>Permission Type *</FormLabel>
<Select v-bind="componentField" :disabled="isGranting">
<FormControl>
<SelectTrigger>
<SelectValue placeholder="Select permission type" />
</SelectTrigger>
</FormControl>
<SelectContent>
<SelectItem
v-for="type in permissionTypes"
:key="type.value"
:value="type.value"
>
<div>
<div class="font-medium">{{ type.label }}</div>
<div class="text-sm text-muted-foreground">{{ type.description }}</div>
</div>
</SelectItem>
</SelectContent>
</Select>
<FormDescription>Type of permission to grant</FormDescription>
<FormMessage />
</FormItem>
</FormField>
<!-- Expiration Date (Optional) -->
<FormField v-slot="{ componentField }" name="expires_at">
<FormItem>
<FormLabel>Expiration Date (Optional)</FormLabel>
<FormControl>
<Input
type="datetime-local"
v-bind="componentField"
:disabled="isGranting"
/>
</FormControl>
<FormDescription>Leave empty for permanent access</FormDescription>
<FormMessage />
</FormItem>
</FormField>
<!-- Notes (Optional) -->
<FormField v-slot="{ componentField }" name="notes">
<FormItem>
<FormLabel>Notes (Optional)</FormLabel>
<FormControl>
<Textarea
placeholder="Add notes about this permission..."
v-bind="componentField"
:disabled="isGranting"
/>
</FormControl>
<FormDescription>Optional notes for admin reference</FormDescription>
<FormMessage />
</FormItem>
</FormField>
<DialogFooter>
<Button
type="button"
variant="outline"
@click="handleClose"
:disabled="isGranting"
>
Cancel
</Button>
<Button type="submit" :disabled="isGranting || !isFormValid">
<Loader2 v-if="isGranting" class="mr-2 h-4 w-4 animate-spin" />
{{ isGranting ? 'Granting...' : 'Grant Permission' }}
</Button>
</DialogFooter>
</form>
</DialogContent>
</Dialog>
</template>

View file

@ -1,399 +0,0 @@
<script setup lang="ts">
import { ref, onMounted, computed } from 'vue'
import { useAuth } from '@/composables/useAuthService'
import { useToast } from '@/core/composables/useToast'
import { injectService, SERVICE_TOKENS } from '@/core/di-container'
import type { ExpensesAPI } from '../../services/ExpensesAPI'
import type { AccountPermission, Account } from '../../types'
import { PermissionType } from '../../types'
import { Button } from '@/components/ui/button'
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'
import { Badge } from '@/components/ui/badge'
import { Loader2, Plus, Trash2, Users, Shield } from 'lucide-vue-next'
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow
} from '@/components/ui/table'
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle
} from '@/components/ui/alert-dialog'
import GrantPermissionDialog from './GrantPermissionDialog.vue'
const { user } = useAuth()
const toast = useToast()
const expensesAPI = injectService<ExpensesAPI>(SERVICE_TOKENS.EXPENSES_API)
const permissions = ref<AccountPermission[]>([])
const accounts = ref<Account[]>([])
const isLoading = ref(false)
const showGrantDialog = ref(false)
const permissionToRevoke = ref<AccountPermission | null>(null)
const showRevokeDialog = ref(false)
const adminKey = computed(() => user.value?.wallets?.[0]?.adminkey)
// Get permission type badge variant
function getPermissionBadge(type: PermissionType) {
switch (type) {
case PermissionType.READ:
return 'default'
case PermissionType.SUBMIT_EXPENSE:
return 'secondary'
case PermissionType.MANAGE:
return 'destructive'
default:
return 'outline'
}
}
// Get permission type label
function getPermissionLabel(type: PermissionType): string {
switch (type) {
case PermissionType.READ:
return 'Read'
case PermissionType.SUBMIT_EXPENSE:
return 'Submit Expense'
case PermissionType.MANAGE:
return 'Manage'
default:
return type
}
}
// Get account name by ID
function getAccountName(accountId: string): string {
const account = accounts.value.find((a) => a.id === accountId)
return account?.name || accountId
}
// Format date
function formatDate(dateString: string): string {
const date = new Date(dateString)
return date.toLocaleDateString() + ' ' + date.toLocaleTimeString()
}
// Load accounts
async function loadAccounts() {
if (!adminKey.value) return
try {
accounts.value = await expensesAPI.getAccounts(adminKey.value)
} catch (error) {
console.error('Failed to load accounts:', error)
toast.error('Failed to load accounts', {
description: error instanceof Error ? error.message : 'Unknown error'
})
}
}
// Load all permissions
async function loadPermissions() {
if (!adminKey.value) {
toast.error('Admin access required', {
description: 'You need admin privileges to manage permissions'
})
return
}
isLoading.value = true
try {
permissions.value = await expensesAPI.listPermissions(adminKey.value)
} catch (error) {
console.error('Failed to load permissions:', error)
toast.error('Failed to load permissions', {
description: error instanceof Error ? error.message : 'Unknown error'
})
} finally {
isLoading.value = false
}
}
// Handle permission granted
function handlePermissionGranted() {
showGrantDialog.value = false
loadPermissions()
toast.success('Permission granted', {
description: 'User permission has been successfully granted'
})
}
// Confirm revoke permission
function confirmRevoke(permission: AccountPermission) {
permissionToRevoke.value = permission
showRevokeDialog.value = true
}
// Revoke permission
async function revokePermission() {
if (!adminKey.value || !permissionToRevoke.value) return
try {
await expensesAPI.revokePermission(adminKey.value, permissionToRevoke.value.id)
toast.success('Permission revoked', {
description: 'User permission has been successfully revoked'
})
loadPermissions()
} catch (error) {
console.error('Failed to revoke permission:', error)
toast.error('Failed to revoke permission', {
description: error instanceof Error ? error.message : 'Unknown error'
})
} finally {
showRevokeDialog.value = false
permissionToRevoke.value = null
}
}
// Group permissions by user
const permissionsByUser = computed(() => {
const grouped = new Map<string, AccountPermission[]>()
for (const permission of permissions.value) {
const existing = grouped.get(permission.user_id) || []
existing.push(permission)
grouped.set(permission.user_id, existing)
}
return grouped
})
// Group permissions by account
const permissionsByAccount = computed(() => {
const grouped = new Map<string, AccountPermission[]>()
for (const permission of permissions.value) {
const existing = grouped.get(permission.account_id) || []
existing.push(permission)
grouped.set(permission.account_id, existing)
}
return grouped
})
onMounted(() => {
loadAccounts()
loadPermissions()
})
</script>
<template>
<div class="container mx-auto p-6 space-y-6">
<div class="flex items-center justify-between">
<div>
<h1 class="text-3xl font-bold">Permission Management</h1>
<p class="text-muted-foreground">
Manage user access to expense accounts
</p>
</div>
<Button @click="showGrantDialog = true" :disabled="isLoading">
<Plus class="mr-2 h-4 w-4" />
Grant Permission
</Button>
</div>
<Card>
<CardHeader>
<CardTitle class="flex items-center gap-2">
<Shield class="h-5 w-5" />
Account Permissions
</CardTitle>
<CardDescription>
View and manage all account permissions. Permissions on parent accounts cascade to
children.
</CardDescription>
</CardHeader>
<CardContent>
<Tabs default-value="by-user" class="w-full">
<TabsList class="grid w-full grid-cols-2">
<TabsTrigger value="by-user">
<Users class="mr-2 h-4 w-4" />
By User
</TabsTrigger>
<TabsTrigger value="by-account">
<Shield class="mr-2 h-4 w-4" />
By Account
</TabsTrigger>
</TabsList>
<!-- By User View -->
<TabsContent value="by-user" class="space-y-4">
<div v-if="isLoading" class="flex items-center justify-center py-8">
<Loader2 class="h-8 w-8 animate-spin text-muted-foreground" />
</div>
<div v-else-if="permissionsByUser.size === 0" class="text-center py-8">
<p class="text-muted-foreground">No permissions granted yet</p>
</div>
<div v-else class="space-y-4">
<div
v-for="[userId, userPermissions] in permissionsByUser"
:key="userId"
class="border rounded-lg p-4"
>
<h3 class="font-semibold mb-2">User: {{ userId }}</h3>
<Table>
<TableHeader>
<TableRow>
<TableHead>Account</TableHead>
<TableHead>Permission</TableHead>
<TableHead>Granted</TableHead>
<TableHead>Expires</TableHead>
<TableHead>Notes</TableHead>
<TableHead class="text-right">Actions</TableHead>
</TableRow>
</TableHeader>
<TableBody>
<TableRow v-for="permission in userPermissions" :key="permission.id">
<TableCell class="font-medium">
{{ getAccountName(permission.account_id) }}
</TableCell>
<TableCell>
<Badge :variant="getPermissionBadge(permission.permission_type)">
{{ getPermissionLabel(permission.permission_type) }}
</Badge>
</TableCell>
<TableCell>{{ formatDate(permission.granted_at) }}</TableCell>
<TableCell>
{{ permission.expires_at ? formatDate(permission.expires_at) : 'Never' }}
</TableCell>
<TableCell>
<span class="text-sm text-muted-foreground">
{{ permission.notes || '-' }}
</span>
</TableCell>
<TableCell class="text-right">
<Button
variant="ghost"
size="sm"
@click="confirmRevoke(permission)"
:disabled="isLoading"
>
<Trash2 class="h-4 w-4 text-destructive" />
</Button>
</TableCell>
</TableRow>
</TableBody>
</Table>
</div>
</div>
</TabsContent>
<!-- By Account View -->
<TabsContent value="by-account" class="space-y-4">
<div v-if="isLoading" class="flex items-center justify-center py-8">
<Loader2 class="h-8 w-8 animate-spin text-muted-foreground" />
</div>
<div v-else-if="permissionsByAccount.size === 0" class="text-center py-8">
<p class="text-muted-foreground">No permissions granted yet</p>
</div>
<div v-else class="space-y-4">
<div
v-for="[accountId, accountPermissions] in permissionsByAccount"
:key="accountId"
class="border rounded-lg p-4"
>
<h3 class="font-semibold mb-2">Account: {{ getAccountName(accountId) }}</h3>
<Table>
<TableHeader>
<TableRow>
<TableHead>User</TableHead>
<TableHead>Permission</TableHead>
<TableHead>Granted</TableHead>
<TableHead>Expires</TableHead>
<TableHead>Notes</TableHead>
<TableHead class="text-right">Actions</TableHead>
</TableRow>
</TableHeader>
<TableBody>
<TableRow v-for="permission in accountPermissions" :key="permission.id">
<TableCell class="font-medium">{{ permission.user_id }}</TableCell>
<TableCell>
<Badge :variant="getPermissionBadge(permission.permission_type)">
{{ getPermissionLabel(permission.permission_type) }}
</Badge>
</TableCell>
<TableCell>{{ formatDate(permission.granted_at) }}</TableCell>
<TableCell>
{{ permission.expires_at ? formatDate(permission.expires_at) : 'Never' }}
</TableCell>
<TableCell>
<span class="text-sm text-muted-foreground">
{{ permission.notes || '-' }}
</span>
</TableCell>
<TableCell class="text-right">
<Button
variant="ghost"
size="sm"
@click="confirmRevoke(permission)"
:disabled="isLoading"
>
<Trash2 class="h-4 w-4 text-destructive" />
</Button>
</TableCell>
</TableRow>
</TableBody>
</Table>
</div>
</div>
</TabsContent>
</Tabs>
</CardContent>
</Card>
<!-- Grant Permission Dialog -->
<GrantPermissionDialog
:is-open="showGrantDialog"
:accounts="accounts"
@close="showGrantDialog = false"
@permission-granted="handlePermissionGranted"
/>
<!-- Revoke Confirmation Dialog -->
<AlertDialog :open="showRevokeDialog" @update:open="showRevokeDialog = $event">
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>Revoke Permission?</AlertDialogTitle>
<AlertDialogDescription>
Are you sure you want to revoke this permission? The user will immediately lose access.
<div v-if="permissionToRevoke" class="mt-4 p-4 bg-muted rounded-md">
<p class="font-medium">Permission Details:</p>
<p class="text-sm mt-2">
<strong>User:</strong> {{ permissionToRevoke.user_id }}
</p>
<p class="text-sm">
<strong>Account:</strong> {{ getAccountName(permissionToRevoke.account_id) }}
</p>
<p class="text-sm">
<strong>Type:</strong> {{ getPermissionLabel(permissionToRevoke.permission_type) }}
</p>
</div>
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>Cancel</AlertDialogCancel>
<AlertDialogAction @click="revokePermission" class="bg-destructive text-destructive-foreground hover:bg-destructive/90">
Revoke
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</div>
</template>

View file

@ -1,71 +0,0 @@
/**
* Expenses Module
*
* Provides expense tracking and submission functionality
* integrated with castle LNbits extension.
*/
import type { App } from 'vue'
import { markRaw } from 'vue'
import type { ModulePlugin } from '@/core/types'
import { container, SERVICE_TOKENS } from '@/core/di-container'
import { ExpensesAPI } from './services/ExpensesAPI'
import AddExpense from './components/AddExpense.vue'
import TransactionsPage from './views/TransactionsPage.vue'
export const expensesModule: ModulePlugin = {
name: 'expenses',
version: '1.0.0',
dependencies: ['base'],
routes: [
{
path: '/expenses/transactions',
name: 'ExpenseTransactions',
component: TransactionsPage,
meta: {
requiresAuth: true,
title: 'My Transactions'
}
}
],
quickActions: [
{
id: 'add-expense',
label: 'Expense',
icon: 'DollarSign',
component: markRaw(AddExpense),
category: 'finance',
order: 10,
requiresAuth: true
}
],
async install(app: App) {
console.log('[Expenses Module] Installing...')
// 1. Create and register service
const expensesAPI = new ExpensesAPI()
container.provide(SERVICE_TOKENS.EXPENSES_API, expensesAPI)
// 2. Initialize service (wait for dependencies)
await expensesAPI.initialize({
waitForDependencies: true,
maxRetries: 3,
retryDelay: 1000
})
console.log('[Expenses Module] ExpensesAPI initialized')
// 3. Register components globally (optional, for use outside quick actions)
app.component('AddExpense', AddExpense)
console.log('[Expenses Module] Installed successfully')
}
}
export default expensesModule
// Export types for use in other modules
export type { Account, AccountNode, ExpenseEntry, ExpenseEntryRequest } from './types'

View file

@ -1,450 +0,0 @@
/**
* API service for castle extension expense operations
*/
import { BaseService } from '@/core/base/BaseService'
import type {
Account,
ExpenseEntryRequest,
ExpenseEntry,
AccountNode,
UserInfo,
AccountPermission,
GrantPermissionRequest,
TransactionListResponse
} from '../types'
import { appConfig } from '@/app.config'
export class ExpensesAPI extends BaseService {
protected readonly metadata = {
name: 'ExpensesAPI',
version: '1.0.0',
dependencies: [] // No dependencies - wallet key is passed as parameter
}
private get config() {
return appConfig.modules.expenses.config
}
private get baseUrl() {
return this.config?.apiConfig?.baseUrl || ''
}
protected async onInitialize(): Promise<void> {
console.log('[ExpensesAPI] Initialized with config:', {
baseUrl: this.baseUrl,
timeout: this.config?.apiConfig?.timeout
})
}
/**
* Get authentication headers with provided wallet key
*/
private getHeaders(walletKey: string): HeadersInit {
return {
'Content-Type': 'application/json',
'X-Api-Key': walletKey
}
}
/**
* Get all accounts from castle
*
* @param walletKey - Wallet key for authentication
* @param filterByUser - If true, only return accounts the user has permissions for
* @param excludeVirtual - If true, exclude virtual parent accounts (default true for user views)
*/
async getAccounts(
walletKey: string,
filterByUser: boolean = false,
excludeVirtual: boolean = true
): Promise<Account[]> {
try {
const url = new URL(`${this.baseUrl}/castle/api/v1/accounts`)
if (filterByUser) {
url.searchParams.set('filter_by_user', 'true')
}
if (excludeVirtual) {
url.searchParams.set('exclude_virtual', 'true')
}
const response = await fetch(url.toString(), {
method: 'GET',
headers: this.getHeaders(walletKey),
signal: AbortSignal.timeout(this.config?.apiConfig?.timeout || 30000)
})
if (!response.ok) {
throw new Error(`Failed to fetch accounts: ${response.statusText}`)
}
const accounts = await response.json()
return accounts as Account[]
} catch (error) {
console.error('[ExpensesAPI] Error fetching accounts:', error)
throw error
}
}
/**
* Get accounts in hierarchical tree structure
*
* Converts flat account list to nested tree based on colon-separated names
* e.g., "Expenses:Groceries:Organic" becomes nested structure
*
* @param walletKey - Wallet key for authentication
* @param rootAccount - Optional root account to filter by (e.g., "Expenses")
* @param filterByUser - If true, only return accounts the user has permissions for
* @param excludeVirtual - If true, exclude virtual parent accounts (default true for user views)
*/
async getAccountHierarchy(
walletKey: string,
rootAccount?: string,
filterByUser: boolean = false,
excludeVirtual: boolean = true
): Promise<AccountNode[]> {
const accounts = await this.getAccounts(walletKey, filterByUser, excludeVirtual)
// Filter by root account if specified
let filteredAccounts = accounts
if (rootAccount) {
filteredAccounts = accounts.filter(
(acc) =>
acc.name === rootAccount || acc.name.startsWith(`${rootAccount}:`)
)
}
// Build hierarchy
const accountMap = new Map<string, AccountNode>()
// First pass: Create nodes for all accounts
for (const account of filteredAccounts) {
const parts = account.name.split(':')
const level = parts.length - 1
const parentName = parts.slice(0, -1).join(':')
accountMap.set(account.name, {
account: {
...account,
level,
parent_account: parentName || undefined,
has_children: false
},
children: []
})
}
// Second pass: Build parent-child relationships
const rootNodes: AccountNode[] = []
for (const [_name, node] of accountMap.entries()) {
const parentName = node.account.parent_account
if (parentName && accountMap.has(parentName)) {
// Add to parent's children
const parent = accountMap.get(parentName)!
parent.children.push(node)
parent.account.has_children = true
} else {
// Root level node
rootNodes.push(node)
}
}
// Sort children by name at each level
const sortNodes = (nodes: AccountNode[]) => {
nodes.sort((a, b) => a.account.name.localeCompare(b.account.name))
nodes.forEach((node) => sortNodes(node.children))
}
sortNodes(rootNodes)
return rootNodes
}
/**
* Submit expense entry to castle
*/
async submitExpense(walletKey: string, request: ExpenseEntryRequest): Promise<ExpenseEntry> {
try {
const response = await fetch(`${this.baseUrl}/castle/api/v1/entries/expense`, {
method: 'POST',
headers: this.getHeaders(walletKey),
body: JSON.stringify(request),
signal: AbortSignal.timeout(this.config?.apiConfig?.timeout || 30000)
})
if (!response.ok) {
const errorData = await response.json().catch(() => ({}))
const errorMessage =
errorData.detail || `Failed to submit expense: ${response.statusText}`
throw new Error(errorMessage)
}
const entry = await response.json()
return entry as ExpenseEntry
} catch (error) {
console.error('[ExpensesAPI] Error submitting expense:', error)
throw error
}
}
/**
* Get user's expense entries
*/
async getUserExpenses(walletKey: string): Promise<ExpenseEntry[]> {
try {
const response = await fetch(`${this.baseUrl}/castle/api/v1/entries/user`, {
method: 'GET',
headers: this.getHeaders(walletKey),
signal: AbortSignal.timeout(this.config?.apiConfig?.timeout || 30000)
})
if (!response.ok) {
throw new Error(
`Failed to fetch user expenses: ${response.statusText}`
)
}
const entries = await response.json()
return entries as ExpenseEntry[]
} catch (error) {
console.error('[ExpensesAPI] Error fetching user expenses:', error)
throw error
}
}
/**
* Get user's balance with castle
*/
async getUserBalance(walletKey: string): Promise<{ balance: number; currency: string }> {
try {
const response = await fetch(`${this.baseUrl}/castle/api/v1/balance`, {
method: 'GET',
headers: this.getHeaders(walletKey),
signal: AbortSignal.timeout(this.config?.apiConfig?.timeout || 30000)
})
if (!response.ok) {
throw new Error(`Failed to fetch balance: ${response.statusText}`)
}
return await response.json()
} catch (error) {
console.error('[ExpensesAPI] Error fetching balance:', error)
throw error
}
}
/**
* Get available currencies from LNbits instance
*/
async getCurrencies(): Promise<string[]> {
try {
const response = await fetch(`${this.baseUrl}/api/v1/currencies`, {
method: 'GET',
signal: AbortSignal.timeout(this.config?.apiConfig?.timeout || 30000)
})
if (!response.ok) {
throw new Error(`Failed to fetch currencies: ${response.statusText}`)
}
return await response.json()
} catch (error) {
console.error('[ExpensesAPI] Error fetching currencies:', error)
throw error
}
}
/**
* Get default currency from LNbits instance
*/
async getDefaultCurrency(): Promise<string | null> {
try {
const response = await fetch(`${this.baseUrl}/api/v1/default-currency`, {
method: 'GET',
signal: AbortSignal.timeout(this.config?.apiConfig?.timeout || 30000)
})
if (!response.ok) {
throw new Error(`Failed to fetch default currency: ${response.statusText}`)
}
const data = await response.json()
return data.default_currency
} catch (error) {
console.error('[ExpensesAPI] Error fetching default currency:', error)
throw error
}
}
/**
* Get user information including equity eligibility
*
* @param walletKey - Wallet key for authentication (invoice key)
*/
async getUserInfo(walletKey: string): Promise<UserInfo> {
try {
const response = await fetch(`${this.baseUrl}/castle/api/v1/user/info`, {
method: 'GET',
headers: this.getHeaders(walletKey),
signal: AbortSignal.timeout(this.config?.apiConfig?.timeout || 30000)
})
if (!response.ok) {
throw new Error(`Failed to fetch user info: ${response.statusText}`)
}
return await response.json()
} catch (error) {
console.error('[ExpensesAPI] Error fetching user info:', error)
// Return default non-eligible user on error
return {
user_id: '',
is_equity_eligible: false
}
}
}
/**
* List all account permissions (admin only)
*
* @param adminKey - Admin key for authentication
*/
async listPermissions(adminKey: string): Promise<AccountPermission[]> {
try {
const response = await fetch(`${this.baseUrl}/castle/api/v1/permissions`, {
method: 'GET',
headers: this.getHeaders(adminKey),
signal: AbortSignal.timeout(this.config?.apiConfig?.timeout || 30000)
})
if (!response.ok) {
throw new Error(`Failed to list permissions: ${response.statusText}`)
}
const permissions = await response.json()
return permissions as AccountPermission[]
} catch (error) {
console.error('[ExpensesAPI] Error listing permissions:', error)
throw error
}
}
/**
* Grant account permission to a user (admin only)
*
* @param adminKey - Admin key for authentication
* @param request - Permission grant request
*/
async grantPermission(
adminKey: string,
request: GrantPermissionRequest
): Promise<AccountPermission> {
try {
const response = await fetch(`${this.baseUrl}/castle/api/v1/permissions`, {
method: 'POST',
headers: this.getHeaders(adminKey),
body: JSON.stringify(request),
signal: AbortSignal.timeout(this.config?.apiConfig?.timeout || 30000)
})
if (!response.ok) {
const errorData = await response.json().catch(() => ({}))
const errorMessage =
errorData.detail || `Failed to grant permission: ${response.statusText}`
throw new Error(errorMessage)
}
const permission = await response.json()
return permission as AccountPermission
} catch (error) {
console.error('[ExpensesAPI] Error granting permission:', error)
throw error
}
}
/**
* Revoke account permission (admin only)
*
* @param adminKey - Admin key for authentication
* @param permissionId - ID of the permission to revoke
*/
async revokePermission(adminKey: string, permissionId: string): Promise<void> {
try {
const response = await fetch(
`${this.baseUrl}/castle/api/v1/permissions/${permissionId}`,
{
method: 'DELETE',
headers: this.getHeaders(adminKey),
signal: AbortSignal.timeout(this.config?.apiConfig?.timeout || 30000)
}
)
if (!response.ok) {
const errorData = await response.json().catch(() => ({}))
const errorMessage =
errorData.detail || `Failed to revoke permission: ${response.statusText}`
throw new Error(errorMessage)
}
} catch (error) {
console.error('[ExpensesAPI] Error revoking permission:', error)
throw error
}
}
/**
* Get user's transactions from journal
*
* @param walletKey - Wallet key for authentication (invoice key)
* @param options - Query options for filtering and pagination
*/
async getUserTransactions(
walletKey: string,
options?: {
limit?: number
offset?: number
days?: number // 15, 30, or 60 (default: 15)
start_date?: string // ISO format: YYYY-MM-DD
end_date?: string // ISO format: YYYY-MM-DD
filter_user_id?: string
filter_account_type?: string
}
): Promise<TransactionListResponse> {
try {
const url = new URL(`${this.baseUrl}/castle/api/v1/entries/user`)
// Add query parameters
if (options?.limit) url.searchParams.set('limit', String(options.limit))
if (options?.offset) url.searchParams.set('offset', String(options.offset))
// Custom date range takes precedence over days
if (options?.start_date && options?.end_date) {
url.searchParams.set('start_date', options.start_date)
url.searchParams.set('end_date', options.end_date)
} else if (options?.days) {
url.searchParams.set('days', String(options.days))
}
if (options?.filter_user_id)
url.searchParams.set('filter_user_id', options.filter_user_id)
if (options?.filter_account_type)
url.searchParams.set('filter_account_type', options.filter_account_type)
const response = await fetch(url.toString(), {
method: 'GET',
headers: this.getHeaders(walletKey),
signal: AbortSignal.timeout(this.config?.apiConfig?.timeout || 30000)
})
if (!response.ok) {
throw new Error(`Failed to fetch transactions: ${response.statusText}`)
}
return await response.json()
} catch (error) {
console.error('[ExpensesAPI] Error fetching transactions:', error)
throw error
}
}
}

View file

@ -1,164 +0,0 @@
/**
* Types for the Expenses module
*/
/**
* Account types in the castle double-entry accounting system
*/
export enum AccountType {
ASSET = 'asset',
LIABILITY = 'liability',
EQUITY = 'equity',
REVENUE = 'revenue',
EXPENSE = 'expense'
}
/**
* Account with hierarchical structure
*/
export interface Account {
id: string
name: string // e.g., "Expenses:Groceries:Organic"
account_type: AccountType
description?: string
user_id?: string
// Hierarchical metadata (added by frontend)
parent_account?: string
level?: number
has_children?: boolean
}
/**
* Account with user-specific permission metadata
* (Will be available once castle API implements permissions)
*/
export interface AccountWithPermissions extends Account {
user_permissions?: PermissionType[]
inherited_from?: string
}
/**
* Permission types for account access control
*/
export enum PermissionType {
READ = 'read',
SUBMIT_EXPENSE = 'submit_expense',
MANAGE = 'manage'
}
/**
* Expense entry request payload
*/
export interface ExpenseEntryRequest {
description: string
amount: number // Amount in the specified currency (or satoshis if currency is None)
expense_account: string // Account name or ID
is_equity: boolean
user_wallet: string
reference?: string
currency?: string // If None, amount is in satoshis. Otherwise, fiat currency code (e.g., "EUR", "USD")
entry_date?: string // ISO datetime string
}
/**
* Expense entry response from castle API
*/
export interface ExpenseEntry {
id: string
journal_id: string
description: string
amount: number
expense_account: string
is_equity: boolean
user_wallet: string
reference?: string
currency?: string
entry_date: string
created_at: string
status: 'pending' | 'approved' | 'rejected' | 'void'
}
/**
* Hierarchical account tree node for UI rendering
*/
export interface AccountNode {
account: Account
children: AccountNode[]
}
/**
* User information including equity eligibility
*/
export interface UserInfo {
user_id: string
is_equity_eligible: boolean
equity_account_name?: string
}
/**
* Account permission for user access control
*/
export interface AccountPermission {
id: string
user_id: string
account_id: string
permission_type: PermissionType
granted_at: string
granted_by: string
expires_at?: string
notes?: string
}
/**
* Grant permission request payload
*/
export interface GrantPermissionRequest {
user_id: string
account_id: string
permission_type: PermissionType
expires_at?: string
notes?: string
}
/**
* Transaction entry from journal (user view)
*/
export interface Transaction {
id: string
date: string
entry_date: string
flag?: string // *, !, #, x for cleared, pending, flagged, voided
description: string
payee?: string
tags: string[]
links: string[]
amount: number // Amount in satoshis
user_id?: string
username?: string
reference?: string
meta?: Record<string, any>
fiat_amount?: number
fiat_currency?: string
}
/**
* Transaction list response with pagination
*/
export interface TransactionListResponse {
entries: Transaction[]
total: number
limit: number
offset: number
has_next: boolean
has_prev: boolean
}
/**
* Module configuration
*/
export interface ExpensesConfig {
apiConfig: {
baseUrl: string
timeout: number
}
}

View file

@ -1,367 +0,0 @@
<script setup lang="ts">
import { ref, computed, onMounted } from 'vue'
import { useAuth } from '@/composables/useAuthService'
import { useToast } from '@/core/composables/useToast'
import { injectService, SERVICE_TOKENS } from '@/core/di-container'
import type { ExpensesAPI } from '../services/ExpensesAPI'
import type { Transaction } from '../types'
import type { FuzzySearchOptions } from '@/composables/useFuzzySearch'
import FuzzySearch from '@/components/ui/fuzzy-search/FuzzySearch.vue'
import { Button } from '@/components/ui/button'
import { Badge } from '@/components/ui/badge'
import { Input } from '@/components/ui/input'
import {
CheckCircle2,
Clock,
Flag,
XCircle,
RefreshCw,
Calendar
} from 'lucide-vue-next'
const { user } = useAuth()
const toast = useToast()
const expensesAPI = injectService<ExpensesAPI>(SERVICE_TOKENS.EXPENSES_API)
const transactions = ref<Transaction[]>([])
const isLoading = ref(false)
const dateRangeType = ref<number | 'custom'>(15) // 15, 30, 60, or 'custom'
const customStartDate = ref<string>('')
const customEndDate = ref<string>('')
const walletKey = computed(() => user.value?.wallets?.[0]?.inkey)
// Fuzzy search state and configuration
const searchResults = ref<Transaction[]>([])
const searchOptions: FuzzySearchOptions<Transaction> = {
fuseOptions: {
keys: [
{ name: 'description', weight: 0.7 }, // Description has highest weight
{ name: 'payee', weight: 0.5 }, // Payee is important
{ name: 'reference', weight: 0.4 }, // Reference helps identification
{ name: 'username', weight: 0.3 }, // Username for filtering
{ name: 'tags', weight: 0.2 } // Tags for categorization
],
threshold: 0.4, // Tolerant of typos
ignoreLocation: true, // Match anywhere in the string
findAllMatches: true, // Find all matches
minMatchCharLength: 2, // Minimum match length
shouldSort: true // Sort by relevance
},
resultLimit: 100, // Show up to 100 results
minSearchLength: 2, // Start searching after 2 characters
matchAllWhenSearchEmpty: true
}
// Transactions to display (search results or all transactions)
const transactionsToDisplay = computed(() => {
return searchResults.value.length > 0 ? searchResults.value : transactions.value
})
// Handle search results
function handleSearchResults(results: Transaction[]) {
searchResults.value = results
}
// Date range options (matching castle LNbits extension)
const dateRangeOptions = [
{ label: '15 days', value: 15 },
{ label: '30 days', value: 30 },
{ label: '60 days', value: 60 },
{ label: 'Custom', value: 'custom' as const }
]
// Format date for display
function formatDate(dateString: string): string {
if (!dateString) return '-'
const date = new Date(dateString)
return new Intl.DateTimeFormat('en-US', {
month: 'short',
day: 'numeric',
year: 'numeric'
}).format(date)
}
// Format amount with proper display
function formatAmount(amount: number): string {
return new Intl.NumberFormat('en-US').format(amount)
}
// Get status icon and color based on flag
function getStatusInfo(flag?: string) {
switch (flag) {
case '*':
return { icon: CheckCircle2, color: 'text-green-600', label: 'Cleared' }
case '!':
return { icon: Clock, color: 'text-orange-600', label: 'Pending' }
case '#':
return { icon: Flag, color: 'text-red-600', label: 'Flagged' }
case 'x':
return { icon: XCircle, color: 'text-muted-foreground', label: 'Voided' }
default:
return null
}
}
// Load transactions
async function loadTransactions() {
if (!walletKey.value) {
toast.error('Authentication required', {
description: 'Please log in to view your transactions'
})
return
}
isLoading.value = true
try {
// Build query params - custom date range takes precedence over preset days
const params: any = {
limit: 1000, // Load all transactions (no pagination needed)
offset: 0
}
if (dateRangeType.value === 'custom') {
// Use custom date range
if (customStartDate.value && customEndDate.value) {
params.start_date = customStartDate.value
params.end_date = customEndDate.value
} else {
// Default to 15 days if custom selected but dates not provided
params.days = 15
}
} else {
// Use preset days
params.days = dateRangeType.value
}
const response = await expensesAPI.getUserTransactions(walletKey.value, params)
transactions.value = response.entries
} catch (error) {
console.error('Failed to load transactions:', error)
toast.error('Failed to load transactions', {
description: error instanceof Error ? error.message : 'Unknown error'
})
} finally {
isLoading.value = false
}
}
// Handle date range type change
function onDateRangeTypeChange(value: number | 'custom') {
dateRangeType.value = value
if (value !== 'custom') {
// Clear custom dates when switching to preset days
customStartDate.value = ''
customEndDate.value = ''
// Load transactions immediately with preset days
loadTransactions()
}
// If switching to custom, wait for user to provide dates
}
// Apply custom date range
function applyCustomDateRange() {
if (customStartDate.value && customEndDate.value) {
loadTransactions()
} else {
toast.error('Invalid date range', {
description: 'Please select both start and end dates'
})
}
}
onMounted(() => {
loadTransactions()
})
</script>
<template>
<div class="flex flex-col">
<!-- Compact Header -->
<div class="flex flex-col gap-3 p-4 md:p-6 border-b md:bg-card/50 md:backdrop-blur-sm">
<div class="w-full max-w-3xl mx-auto">
<div class="flex items-center justify-between mb-3">
<h1 class="text-lg md:text-xl font-bold">Transaction History</h1>
<Button
variant="outline"
size="sm"
@click="loadTransactions"
:disabled="isLoading"
class="gap-2 md:h-10 md:px-4 hover:bg-accent transition-colors"
>
<RefreshCw :class="{ 'animate-spin': isLoading }" class="h-4 w-4" />
<span class="hidden md:inline">Refresh</span>
</Button>
</div>
<!-- Date Range Controls -->
<div class="flex flex-col gap-3">
<!-- Preset Days / Custom Toggle -->
<div class="flex items-center gap-2 flex-wrap">
<Calendar class="h-4 w-4 text-muted-foreground" />
<Button
v-for="option in dateRangeOptions"
:key="option.value"
:variant="dateRangeType === option.value ? 'default' : 'outline'"
size="sm"
class="h-8 px-3 text-xs"
@click="onDateRangeTypeChange(option.value)"
:disabled="isLoading"
>
{{ option.label }}
</Button>
</div>
<!-- Custom Date Range Inputs -->
<div v-if="dateRangeType === 'custom'" class="flex items-end gap-2 flex-wrap">
<div class="flex flex-col gap-1">
<label class="text-xs text-muted-foreground">From:</label>
<Input
type="date"
v-model="customStartDate"
class="h-8 text-xs"
:disabled="isLoading"
/>
</div>
<div class="flex flex-col gap-1">
<label class="text-xs text-muted-foreground">To:</label>
<Input
type="date"
v-model="customEndDate"
class="h-8 text-xs"
:disabled="isLoading"
/>
</div>
<Button
size="sm"
class="h-8 px-3 text-xs"
@click="applyCustomDateRange"
:disabled="isLoading || !customStartDate || !customEndDate"
>
Apply
</Button>
</div>
</div>
</div>
</div>
<!-- Content Container -->
<div class="w-full max-w-3xl mx-auto px-0 md:px-4">
<!-- Search Bar -->
<div class="px-4 md:px-0 py-3">
<FuzzySearch
:data="transactions"
:options="searchOptions"
placeholder="Search transactions..."
@results="handleSearchResults"
/>
</div>
<!-- Results Count -->
<div class="px-4 md:px-0 py-2 text-xs md:text-sm text-muted-foreground">
<span v-if="searchResults.length > 0">
Found {{ transactionsToDisplay.length }} matching transaction{{ transactionsToDisplay.length === 1 ? '' : 's' }}
</span>
<span v-else>
{{ transactions.length }} transaction{{ transactions.length === 1 ? '' : 's' }}
</span>
</div>
<!-- Loading State -->
<div v-if="isLoading" class="flex items-center justify-center py-12">
<div class="flex items-center gap-2">
<RefreshCw class="h-4 w-4 animate-spin" />
<span class="text-muted-foreground">Loading transactions...</span>
</div>
</div>
<!-- Empty State -->
<div v-else-if="transactionsToDisplay.length === 0" class="text-center py-12 px-4">
<p class="text-muted-foreground">No transactions found</p>
<p class="text-sm text-muted-foreground mt-2">
{{ searchResults.length > 0 ? 'Try a different search term' : 'Try selecting a different time period' }}
</p>
</div>
<!-- Transaction Items (Full-width on mobile, no nested cards) -->
<div v-else class="md:space-y-3 md:py-4">
<div
v-for="transaction in transactionsToDisplay"
:key="transaction.id"
class="border-b md:border md:rounded-lg p-4 hover:bg-muted/50 transition-colors"
>
<!-- Transaction Header -->
<div class="flex items-start justify-between gap-3 mb-2">
<div class="flex-1 min-w-0">
<div class="flex items-center gap-2 mb-1">
<!-- Status Icon -->
<component
v-if="getStatusInfo(transaction.flag)"
:is="getStatusInfo(transaction.flag)!.icon"
:class="[
'h-4 w-4 flex-shrink-0',
getStatusInfo(transaction.flag)!.color
]"
/>
<h3 class="font-medium text-sm sm:text-base truncate">
{{ transaction.description }}
</h3>
</div>
<p class="text-xs sm:text-sm text-muted-foreground">
{{ formatDate(transaction.date) }}
</p>
</div>
<!-- Amount -->
<div class="text-right flex-shrink-0">
<p class="font-semibold text-sm sm:text-base">
{{ formatAmount(transaction.amount) }} sats
</p>
<p v-if="transaction.fiat_amount" class="text-xs text-muted-foreground">
{{ transaction.fiat_amount.toFixed(2) }} {{ transaction.fiat_currency }}
</p>
</div>
</div>
<!-- Transaction Details -->
<div class="space-y-1 text-xs sm:text-sm">
<!-- Payee -->
<div v-if="transaction.payee" class="text-muted-foreground">
<span class="font-medium">Payee:</span> {{ transaction.payee }}
</div>
<!-- Reference -->
<div v-if="transaction.reference" class="text-muted-foreground">
<span class="font-medium">Ref:</span> {{ transaction.reference }}
</div>
<!-- Username (if available) -->
<div v-if="transaction.username" class="text-muted-foreground">
<span class="font-medium">User:</span> {{ transaction.username }}
</div>
<!-- Tags -->
<div v-if="transaction.tags && transaction.tags.length > 0" class="flex flex-wrap gap-1 mt-2">
<Badge v-for="tag in transaction.tags" :key="tag" variant="secondary" class="text-xs">
{{ tag }}
</Badge>
</div>
<!-- Metadata Source -->
<div v-if="transaction.meta?.source" class="text-muted-foreground mt-1">
<span class="text-xs">Source: {{ transaction.meta.source }}</span>
</div>
</div>
</div>
<!-- End of list indicator -->
<div v-if="transactionsToDisplay.length > 0" class="text-center py-6 text-md text-muted-foreground">
<p>🐢</p>
</div>
</div>
</div>
</div>
</template>

View file

@ -9,16 +9,13 @@ import {
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog'
import { Megaphone, RefreshCw, AlertCircle, ChevronLeft, ChevronRight } from 'lucide-vue-next'
import { Megaphone, RefreshCw, AlertCircle } from 'lucide-vue-next'
import { useFeed } from '../composables/useFeed'
import { useProfiles } from '../composables/useProfiles'
import { useReactions } from '../composables/useReactions'
import { useScheduledEvents } from '../composables/useScheduledEvents'
import ThreadedPost from './ThreadedPost.vue'
import ScheduledEventCard from './ScheduledEventCard.vue'
import appConfig from '@/app.config'
import type { ContentFilter, FeedPost } from '../services/FeedService'
import type { ScheduledEvent } from '../services/ScheduledEventService'
import { injectService, SERVICE_TOKENS } from '@/core/di-container'
import type { AuthService } from '@/modules/base/auth/auth-service'
import type { RelayHub } from '@/modules/base/nostr/relay-hub'
@ -98,78 +95,6 @@ const { getDisplayName, fetchProfiles } = useProfiles()
// Use reactions service for likes/hearts
const { getEventReactions, subscribeToReactions, toggleLike } = useReactions()
// Use scheduled events service
const {
getEventsForSpecificDate,
getCompletion,
getTaskStatus,
claimTask,
startTask,
completeEvent,
unclaimTask,
deleteTask,
allCompletions
} = useScheduledEvents()
// Selected date for viewing scheduled tasks (defaults to today)
const selectedDate = ref(new Date().toISOString().split('T')[0])
// Get scheduled tasks for the selected date (reactive)
const scheduledEventsForDate = computed(() => getEventsForSpecificDate(selectedDate.value))
// Navigate to previous day
function goToPreviousDay() {
const date = new Date(selectedDate.value)
date.setDate(date.getDate() - 1)
selectedDate.value = date.toISOString().split('T')[0]
}
// Navigate to next day
function goToNextDay() {
const date = new Date(selectedDate.value)
date.setDate(date.getDate() + 1)
selectedDate.value = date.toISOString().split('T')[0]
}
// Go back to today
function goToToday() {
selectedDate.value = new Date().toISOString().split('T')[0]
}
// Check if selected date is today
const isToday = computed(() => {
const today = new Date().toISOString().split('T')[0]
return selectedDate.value === today
})
// Format date for display
const dateDisplayText = computed(() => {
const today = new Date().toISOString().split('T')[0]
const yesterday = new Date()
yesterday.setDate(yesterday.getDate() - 1)
const yesterdayStr = yesterday.toISOString().split('T')[0]
const tomorrow = new Date()
tomorrow.setDate(tomorrow.getDate() + 1)
const tomorrowStr = tomorrow.toISOString().split('T')[0]
if (selectedDate.value === today) {
return "Today's Tasks"
} else if (selectedDate.value === yesterdayStr) {
return "Yesterday's Tasks"
} else if (selectedDate.value === tomorrowStr) {
return "Tomorrow's Tasks"
} else {
// Format as "Tasks for Mon, Jan 15"
const date = new Date(selectedDate.value + 'T00:00:00')
const formatted = date.toLocaleDateString('en-US', {
weekday: 'short',
month: 'short',
day: 'numeric'
})
return `Tasks for ${formatted}`
}
})
// Watch for new posts and fetch their profiles and reactions
watch(notes, async (newNotes) => {
if (newNotes.length > 0) {
@ -184,38 +109,6 @@ watch(notes, async (newNotes) => {
}
}, { immediate: true })
// Watch for scheduled events and fetch profiles for event authors and completers
watch(scheduledEventsForDate, async (events) => {
if (events.length > 0) {
const pubkeys = new Set<string>()
// Add event authors
events.forEach((event: ScheduledEvent) => {
pubkeys.add(event.pubkey)
// Add completer pubkey if event is completed
const eventAddress = `31922:${event.pubkey}:${event.dTag}`
const completion = getCompletion(eventAddress)
if (completion) {
pubkeys.add(completion.pubkey)
}
})
// Fetch all profiles
if (pubkeys.size > 0) {
await fetchProfiles([...pubkeys])
}
}
}, { immediate: true })
// Watch for new completions and fetch profiles for completers
watch(allCompletions, async (completions) => {
if (completions.length > 0) {
const pubkeys = completions.map(c => c.pubkey)
await fetchProfiles(pubkeys)
}
}, { immediate: true })
// Check if we have admin pubkeys configured
const hasAdminPubkeys = computed(() => adminPubkeys.length > 0)
@ -265,52 +158,6 @@ async function onToggleLike(note: FeedPost) {
}
}
// Task action handlers
async function onClaimTask(event: ScheduledEvent, occurrence?: string) {
console.log('👋 NostrFeed: Claiming task:', event.title)
try {
await claimTask(event, '', occurrence)
} catch (error) {
console.error('❌ Failed to claim task:', error)
}
}
async function onStartTask(event: ScheduledEvent, occurrence?: string) {
console.log('▶️ NostrFeed: Starting task:', event.title)
try {
await startTask(event, '', occurrence)
} catch (error) {
console.error('❌ Failed to start task:', error)
}
}
async function onCompleteTask(event: ScheduledEvent, occurrence?: string) {
console.log('✅ NostrFeed: Completing task:', event.title)
try {
await completeEvent(event, occurrence, '')
} catch (error) {
console.error('❌ Failed to complete task:', error)
}
}
async function onUnclaimTask(event: ScheduledEvent, occurrence?: string) {
console.log('🔙 NostrFeed: Unclaiming task:', event.title)
try {
await unclaimTask(event, occurrence)
} catch (error) {
console.error('❌ Failed to unclaim task:', error)
}
}
async function onDeleteTask(event: ScheduledEvent) {
console.log('🗑️ NostrFeed: Deleting task:', event.title)
try {
await deleteTask(event)
} catch (error) {
console.error('❌ Failed to delete task:', error)
}
}
// Handle collapse toggle with cascading behavior
function onToggleCollapse(postId: string) {
const newCollapsed = new Set(collapsedPosts.value)
@ -509,75 +356,20 @@ function cancelDelete() {
</p>
</div>
<!-- No Posts -->
<div v-else-if="threadedPosts.length === 0" class="text-center py-8 px-4">
<div class="flex items-center justify-center gap-2 text-muted-foreground mb-4">
<Megaphone class="h-5 w-5" />
<span>No posts yet</span>
</div>
<p class="text-sm text-muted-foreground">
Check back later for community updates.
</p>
</div>
<!-- Posts List - Natural flow without internal scrolling -->
<div v-else>
<!-- Scheduled Tasks Section with Date Navigation -->
<div class="my-2 md:my-4">
<div class="flex items-center justify-between px-4 md:px-0 mb-3">
<!-- Left Arrow -->
<Button
variant="ghost"
size="icon"
class="h-8 w-8"
@click="goToPreviousDay"
>
<ChevronLeft class="h-4 w-4" />
</Button>
<!-- Date Header with Today Button -->
<div class="flex items-center gap-2">
<h3 class="text-sm font-semibold text-muted-foreground uppercase tracking-wide">
📅 {{ dateDisplayText }}
</h3>
<Button
v-if="!isToday"
variant="outline"
size="sm"
class="h-6 text-xs"
@click="goToToday"
>
Today
</Button>
</div>
<!-- Right Arrow -->
<Button
variant="ghost"
size="icon"
class="h-8 w-8"
@click="goToNextDay"
>
<ChevronRight class="h-4 w-4" />
</Button>
</div>
<!-- Scheduled Tasks List or Empty State -->
<div v-if="scheduledEventsForDate.length > 0" class="md:space-y-3">
<ScheduledEventCard
v-for="event in scheduledEventsForDate"
:key="`${event.pubkey}:${event.dTag}`"
:event="event"
:get-display-name="getDisplayName"
:get-completion="getCompletion"
:get-task-status="getTaskStatus"
:admin-pubkeys="adminPubkeys"
@claim-task="onClaimTask"
@start-task="onStartTask"
@complete-task="onCompleteTask"
@unclaim-task="onUnclaimTask"
@delete-task="onDeleteTask"
/>
</div>
<div v-else class="text-center py-3 text-muted-foreground text-sm px-4">
{{ isToday ? 'no tasks today' : 'no tasks for this day' }}
</div>
</div>
<!-- Posts Section -->
<div v-if="threadedPosts.length > 0" class="md:space-y-4 md:py-4">
<h3 v-if="scheduledEventsForDate.length > 0" class="text-sm font-semibold text-muted-foreground uppercase tracking-wide px-4 md:px-0 mb-3 mt-6">
💬 Posts
</h3>
<div class="md:space-y-4 md:py-4">
<ThreadedPost
v-for="post in threadedPosts"
:key="post.id"
@ -598,19 +390,8 @@ function cancelDelete() {
/>
</div>
<!-- No Posts Message (show whenever there are no posts, regardless of events) -->
<div v-else class="text-center py-8 px-4">
<div class="flex items-center justify-center gap-2 text-muted-foreground mb-4">
<Megaphone class="h-5 w-5" />
<span>No posts yet</span>
</div>
<p class="text-sm text-muted-foreground">
Check back later for community updates.
</p>
</div>
<!-- End of feed message -->
<div v-if="threadedPosts.length > 0" class="text-center py-6 text-md text-muted-foreground">
<div class="text-center py-6 text-md text-muted-foreground">
<p>🐢</p>
</div>
</div>

View file

@ -1,540 +0,0 @@
<script setup lang="ts">
import { computed, ref } from 'vue'
import { Badge } from '@/components/ui/badge'
import { Button } from '@/components/ui/button'
import { Checkbox } from '@/components/ui/checkbox'
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog'
import {
Collapsible,
CollapsibleContent,
CollapsibleTrigger,
} from '@/components/ui/collapsible'
import { Calendar, MapPin, Clock, CheckCircle, PlayCircle, Hand, Trash2 } from 'lucide-vue-next'
import type { ScheduledEvent, EventCompletion, TaskStatus } from '../services/ScheduledEventService'
import { injectService, SERVICE_TOKENS } from '@/core/di-container'
import type { AuthService } from '@/modules/base/auth/auth-service'
interface Props {
event: ScheduledEvent
getDisplayName: (pubkey: string) => string
getCompletion: (eventAddress: string, occurrence?: string) => EventCompletion | undefined
getTaskStatus: (eventAddress: string, occurrence?: string) => TaskStatus | null
adminPubkeys?: string[]
}
interface Emits {
(e: 'claim-task', event: ScheduledEvent, occurrence?: string): void
(e: 'start-task', event: ScheduledEvent, occurrence?: string): void
(e: 'complete-task', event: ScheduledEvent, occurrence?: string): void
(e: 'unclaim-task', event: ScheduledEvent, occurrence?: string): void
(e: 'delete-task', event: ScheduledEvent): void
}
const props = withDefaults(defineProps<Props>(), {
adminPubkeys: () => []
})
const emit = defineEmits<Emits>()
// Get auth service to check current user
const authService = injectService<AuthService>(SERVICE_TOKENS.AUTH_SERVICE)
// Confirmation dialog state
const showConfirmDialog = ref(false)
const hasConfirmedCommunication = ref(false)
// Event address for tracking completion
const eventAddress = computed(() => `31922:${props.event.pubkey}:${props.event.dTag}`)
// Check if this is a recurring event
const isRecurring = computed(() => !!props.event.recurrence)
// For recurring events, occurrence is today's date. For non-recurring, it's undefined.
const occurrence = computed(() => {
if (!isRecurring.value) return undefined
return new Date().toISOString().split('T')[0] // YYYY-MM-DD
})
// Check if this is an admin event
const isAdminEvent = computed(() => props.adminPubkeys.includes(props.event.pubkey))
// Get current task status
const taskStatus = computed(() => props.getTaskStatus(eventAddress.value, occurrence.value))
// Check if event is completable (task type)
const isCompletable = computed(() => props.event.eventType === 'task')
// Get completion data
const completion = computed(() => props.getCompletion(eventAddress.value, occurrence.value))
// Get current user's pubkey
const currentUserPubkey = computed(() => authService?.user.value?.pubkey)
// Check if current user can unclaim
// Only show unclaim for "claimed" state, and only if current user is the one who claimed it
const canUnclaim = computed(() => {
if (!completion.value || !currentUserPubkey.value) return false
if (taskStatus.value !== 'claimed') return false
return completion.value.pubkey === currentUserPubkey.value
})
// Check if current user is the author of the task
const isAuthor = computed(() => {
if (!currentUserPubkey.value) return false
return props.event.pubkey === currentUserPubkey.value
})
// Status badges configuration
const statusConfig = computed(() => {
switch (taskStatus.value) {
case 'claimed':
return { label: 'Claimed', variant: 'secondary' as const, icon: Hand, color: 'text-blue-600' }
case 'in-progress':
return { label: 'In Progress', variant: 'default' as const, icon: PlayCircle, color: 'text-orange-600' }
case 'completed':
return { label: 'Completed', variant: 'secondary' as const, icon: CheckCircle, color: 'text-green-600' }
default:
return null
}
})
// Format the date/time
const formattedDate = computed(() => {
try {
const date = new Date(props.event.start)
// Check if it's a datetime or just date
if (props.event.start.includes('T')) {
// Full datetime - show date and time
return date.toLocaleString('en-US', {
weekday: 'short',
month: 'short',
day: 'numeric',
hour: 'numeric',
minute: '2-digit'
})
} else {
// Just date
return date.toLocaleDateString('en-US', {
weekday: 'short',
month: 'short',
day: 'numeric'
})
}
} catch (error) {
return props.event.start
}
})
// Format the time range if end time exists
const formattedTimeRange = computed(() => {
if (!props.event.end || !props.event.start.includes('T')) return null
try {
const start = new Date(props.event.start)
const end = new Date(props.event.end)
const startTime = start.toLocaleTimeString('en-US', {
hour: 'numeric',
minute: '2-digit'
})
const endTime = end.toLocaleTimeString('en-US', {
hour: 'numeric',
minute: '2-digit'
})
return `${startTime} - ${endTime}`
} catch (error) {
return null
}
})
// Action type for confirmation dialog
const pendingAction = ref<'claim' | 'start' | 'complete' | 'unclaim' | 'delete' | null>(null)
// Handle claim task
function handleClaimTask() {
pendingAction.value = 'claim'
showConfirmDialog.value = true
}
// Handle start task
function handleStartTask() {
pendingAction.value = 'start'
showConfirmDialog.value = true
}
// Handle complete task
function handleCompleteTask() {
pendingAction.value = 'complete'
showConfirmDialog.value = true
}
// Handle unclaim task
function handleUnclaimTask() {
pendingAction.value = 'unclaim'
showConfirmDialog.value = true
}
// Handle delete task
function handleDeleteTask() {
pendingAction.value = 'delete'
showConfirmDialog.value = true
}
// Confirm action
function confirmAction() {
if (!pendingAction.value) return
// For unclaim action, require checkbox confirmation
if (pendingAction.value === 'unclaim' && !hasConfirmedCommunication.value) {
return
}
switch (pendingAction.value) {
case 'claim':
emit('claim-task', props.event, occurrence.value)
break
case 'start':
emit('start-task', props.event, occurrence.value)
break
case 'complete':
emit('complete-task', props.event, occurrence.value)
break
case 'unclaim':
emit('unclaim-task', props.event, occurrence.value)
break
case 'delete':
emit('delete-task', props.event)
break
}
showConfirmDialog.value = false
pendingAction.value = null
hasConfirmedCommunication.value = false
}
// Cancel action
function cancelAction() {
showConfirmDialog.value = false
pendingAction.value = null
hasConfirmedCommunication.value = false
}
// Get dialog content based on pending action
const dialogContent = computed(() => {
switch (pendingAction.value) {
case 'claim':
return {
title: 'Claim Task?',
description: `This will mark "${props.event.title}" as claimed by you. You can start working on it later.`,
confirmText: 'Claim Task'
}
case 'start':
return {
title: 'Start Task?',
description: `This will mark "${props.event.title}" as in-progress. Others will see you're actively working on it.`,
confirmText: 'Start Task'
}
case 'complete':
return {
title: 'Complete Task?',
description: `This will mark "${props.event.title}" as completed by you. Other users will be able to see that you completed this task.`,
confirmText: 'Mark Complete'
}
case 'unclaim':
return {
title: 'Unclaim Task?',
description: `This will remove your claim on "${props.event.title}" and make it available for others.\n\nHave you communicated to others that you are unclaiming this task?`,
confirmText: 'Unclaim Task'
}
case 'delete':
return {
title: 'Delete Task?',
description: `This will permanently delete "${props.event.title}". This action cannot be undone.`,
confirmText: 'Delete Task'
}
default:
return {
title: '',
description: '',
confirmText: ''
}
}
})
</script>
<template>
<Collapsible class="border-b md:border md:rounded-lg bg-card transition-all"
:class="{ 'opacity-60': isCompletable && taskStatus === 'completed' }">
<!-- Collapsed View (Trigger) -->
<CollapsibleTrigger as-child>
<div class="flex items-center gap-3 p-3 md:p-4 cursor-pointer hover:bg-accent/50 transition-colors">
<!-- Time -->
<div class="flex items-center gap-1.5 text-sm text-muted-foreground shrink-0">
<Clock class="h-3.5 w-3.5" />
<span class="font-medium">{{ formattedTimeRange || formattedDate }}</span>
</div>
<!-- Title -->
<h3 class="font-semibold text-sm md:text-base flex-1 truncate"
:class="{ 'line-through': isCompletable && taskStatus === 'completed' }">
{{ event.title }}
</h3>
<!-- Badges and Actions -->
<div class="flex items-center gap-2 shrink-0">
<!-- Quick Action Button (context-aware) -->
<Button
v-if="isCompletable && !taskStatus"
@click.stop="handleClaimTask"
variant="ghost"
size="sm"
class="h-7 px-2 text-xs gap-1"
>
<Hand class="h-3.5 w-3.5" />
<span class="hidden sm:inline">Claim</span>
</Button>
<Button
v-else-if="isCompletable && taskStatus === 'claimed'"
@click.stop="handleStartTask"
variant="ghost"
size="sm"
class="h-7 px-2 text-xs gap-1"
>
<PlayCircle class="h-3.5 w-3.5" />
<span class="hidden sm:inline">Start</span>
</Button>
<Button
v-else-if="isCompletable && taskStatus === 'in-progress'"
@click.stop="handleCompleteTask"
variant="ghost"
size="sm"
class="h-7 px-2 text-xs gap-1"
>
<CheckCircle class="h-3.5 w-3.5" />
<span class="hidden sm:inline">Complete</span>
</Button>
<!-- Status Badge with claimer/completer name -->
<Badge v-if="isCompletable && statusConfig && completion" :variant="statusConfig.variant" class="text-xs gap-1">
<component :is="statusConfig.icon" class="h-3 w-3" :class="statusConfig.color" />
<span>{{ getDisplayName(completion.pubkey) }}</span>
</Badge>
<!-- Recurring Badge -->
<Badge v-if="isRecurring" variant="outline" class="text-xs">
🔄
</Badge>
</div>
</div>
</CollapsibleTrigger>
<!-- Expanded View (Content) -->
<CollapsibleContent class="p-4 md:p-6 pt-0">
<!-- Event Details -->
<div class="flex-1 min-w-0">
<!-- Date/Time -->
<div class="flex items-center gap-4 text-sm text-muted-foreground mb-2 flex-wrap">
<div class="flex items-center gap-1.5">
<Calendar class="h-4 w-4" />
<span>{{ formattedDate }}</span>
</div>
<div v-if="formattedTimeRange" class="flex items-center gap-1.5">
<Clock class="h-4 w-4" />
<span>{{ formattedTimeRange }}</span>
</div>
</div>
<!-- Location -->
<div v-if="event.location" class="flex items-center gap-1.5 text-sm text-muted-foreground mb-3">
<MapPin class="h-4 w-4" />
<span>{{ event.location }}</span>
</div>
<!-- Description/Content -->
<div v-if="event.description || event.content" class="text-sm mb-3">
<p class="whitespace-pre-wrap break-words">{{ event.description || event.content }}</p>
</div>
<!-- Task Status Info (only for completable events with status) -->
<div v-if="isCompletable && completion" class="text-xs mb-3">
<div v-if="taskStatus === 'completed'" class="text-muted-foreground">
Completed by {{ getDisplayName(completion.pubkey) }}
<span v-if="completion.notes"> - {{ completion.notes }}</span>
</div>
<div v-else-if="taskStatus === 'in-progress'" class="text-orange-600 dark:text-orange-400 font-medium">
🔄 In Progress by {{ getDisplayName(completion.pubkey) }}
<span v-if="completion.notes"> - {{ completion.notes }}</span>
</div>
<div v-else-if="taskStatus === 'claimed'" class="text-blue-600 dark:text-blue-400 font-medium">
👋 Claimed by {{ getDisplayName(completion.pubkey) }}
<span v-if="completion.notes"> - {{ completion.notes }}</span>
</div>
</div>
<!-- Author (if not admin) -->
<div v-if="!isAdminEvent" class="text-xs text-muted-foreground mb-3">
Posted by {{ getDisplayName(event.pubkey) }}
</div>
<!-- Action Buttons (only for completable task events) -->
<div v-if="isCompletable" class="mt-3 flex flex-wrap gap-2">
<!-- Unclaimed Task - Show all options including jump ahead -->
<template v-if="!taskStatus">
<Button
@click.stop="handleClaimTask"
variant="default"
size="sm"
class="gap-2"
>
<Hand class="h-4 w-4" />
Claim Task
</Button>
<Button
@click.stop="handleStartTask"
variant="outline"
size="sm"
class="gap-2"
>
<PlayCircle class="h-4 w-4" />
Mark In Progress
</Button>
<Button
@click.stop="handleCompleteTask"
variant="outline"
size="sm"
class="gap-2"
>
<CheckCircle class="h-4 w-4" />
Mark Complete
</Button>
</template>
<!-- Claimed Task - Show start and option to skip directly to complete -->
<template v-else-if="taskStatus === 'claimed'">
<Button
@click.stop="handleStartTask"
variant="default"
size="sm"
class="gap-2"
>
<PlayCircle class="h-4 w-4" />
Start Task
</Button>
<Button
@click.stop="handleCompleteTask"
variant="outline"
size="sm"
class="gap-2"
>
<CheckCircle class="h-4 w-4" />
Mark Complete
</Button>
<Button
v-if="canUnclaim"
@click.stop="handleUnclaimTask"
variant="outline"
size="sm"
>
Unclaim
</Button>
</template>
<!-- In Progress Task -->
<template v-else-if="taskStatus === 'in-progress'">
<Button
@click.stop="handleCompleteTask"
variant="default"
size="sm"
class="gap-2"
>
<CheckCircle class="h-4 w-4" />
Mark Complete
</Button>
<Button
v-if="canUnclaim"
@click.stop="handleUnclaimTask"
variant="outline"
size="sm"
>
Unclaim
</Button>
</template>
<!-- Completed Task -->
<template v-else-if="taskStatus === 'completed'">
<Button
v-if="canUnclaim"
@click.stop="handleUnclaimTask"
variant="outline"
size="sm"
>
Unclaim
</Button>
</template>
</div>
<!-- Delete Task Button (only for task author) -->
<div v-if="isAuthor" class="mt-4 pt-4 border-t border-border">
<Button
@click.stop="handleDeleteTask"
variant="destructive"
size="sm"
class="gap-2"
>
<Trash2 class="h-4 w-4" />
Delete Task
</Button>
</div>
</div>
</CollapsibleContent>
</Collapsible>
<!-- Confirmation Dialog -->
<Dialog :open="showConfirmDialog" @update:open="(val: boolean) => showConfirmDialog = val">
<DialogContent>
<DialogHeader>
<DialogTitle>{{ dialogContent.title }}</DialogTitle>
<DialogDescription>
{{ dialogContent.description }}
</DialogDescription>
</DialogHeader>
<!-- Communication confirmation checkbox (only for unclaim) -->
<div v-if="pendingAction === 'unclaim'" class="flex items-start space-x-3 py-4">
<Checkbox
:model-value="hasConfirmedCommunication"
@update:model-value="(val) => hasConfirmedCommunication = !!val"
id="confirm-communication"
/>
<label
for="confirm-communication"
class="text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70 cursor-pointer"
>
I have communicated this to the team.
</label>
</div>
<DialogFooter>
<Button variant="outline" @click="cancelAction">Cancel</Button>
<Button
@click="confirmAction"
:disabled="pendingAction === 'unclaim' && !hasConfirmedCommunication"
>
{{ dialogContent.confirmText }}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</template>

View file

@ -1,261 +0,0 @@
import { computed } from 'vue'
import { injectService, SERVICE_TOKENS } from '@/core/di-container'
import type { ScheduledEventService, ScheduledEvent, EventCompletion, TaskStatus } from '../services/ScheduledEventService'
import type { AuthService } from '@/modules/base/auth/auth-service'
import { useToast } from '@/core/composables/useToast'
/**
* Composable for managing scheduled events in the feed
*/
export function useScheduledEvents() {
const scheduledEventService = injectService<ScheduledEventService>(SERVICE_TOKENS.SCHEDULED_EVENT_SERVICE)
const authService = injectService<AuthService>(SERVICE_TOKENS.AUTH_SERVICE)
const toast = useToast()
// Get current user's pubkey
const currentUserPubkey = computed(() => authService?.user.value?.pubkey)
/**
* Get all scheduled events
*/
const getScheduledEvents = (): ScheduledEvent[] => {
if (!scheduledEventService) return []
return scheduledEventService.getScheduledEvents()
}
/**
* Get events for a specific date (YYYY-MM-DD)
*/
const getEventsForDate = (date: string): ScheduledEvent[] => {
if (!scheduledEventService) return []
return scheduledEventService.getEventsForDate(date)
}
/**
* Get events for a specific date (filtered by current user participation)
* @param date - ISO date string (YYYY-MM-DD). Defaults to today.
*/
const getEventsForSpecificDate = (date?: string): ScheduledEvent[] => {
if (!scheduledEventService) return []
return scheduledEventService.getEventsForSpecificDate(date, currentUserPubkey.value)
}
/**
* Get today's scheduled events (filtered by current user participation)
*/
const getTodaysEvents = (): ScheduledEvent[] => {
if (!scheduledEventService) return []
return scheduledEventService.getTodaysEvents(currentUserPubkey.value)
}
/**
* Get completion status for an event
*/
const getCompletion = (eventAddress: string): EventCompletion | undefined => {
if (!scheduledEventService) return undefined
return scheduledEventService.getCompletion(eventAddress)
}
/**
* Check if an event is completed
*/
const isCompleted = (eventAddress: string): boolean => {
if (!scheduledEventService) return false
return scheduledEventService.isCompleted(eventAddress)
}
/**
* Get task status for an event
*/
const getTaskStatus = (eventAddress: string, occurrence?: string): TaskStatus | null => {
if (!scheduledEventService) return null
return scheduledEventService.getTaskStatus(eventAddress, occurrence)
}
/**
* Claim a task
*/
const claimTask = async (event: ScheduledEvent, notes: string = '', occurrence?: string): Promise<void> => {
if (!scheduledEventService) {
toast.error('Scheduled event service not available')
return
}
try {
await scheduledEventService.claimTask(event, notes, occurrence)
toast.success('Task claimed!')
} catch (error) {
const message = error instanceof Error ? error.message : 'Failed to claim task'
if (message.includes('authenticated')) {
toast.error('Please sign in to claim tasks')
} else {
toast.error(message)
}
console.error('Failed to claim task:', error)
}
}
/**
* Start a task (mark as in-progress)
*/
const startTask = async (event: ScheduledEvent, notes: string = '', occurrence?: string): Promise<void> => {
if (!scheduledEventService) {
toast.error('Scheduled event service not available')
return
}
try {
await scheduledEventService.startTask(event, notes, occurrence)
toast.success('Task started!')
} catch (error) {
const message = error instanceof Error ? error.message : 'Failed to start task'
toast.error(message)
console.error('Failed to start task:', error)
}
}
/**
* Unclaim a task (remove task status)
*/
const unclaimTask = async (event: ScheduledEvent, occurrence?: string): Promise<void> => {
if (!scheduledEventService) {
toast.error('Scheduled event service not available')
return
}
try {
await scheduledEventService.unclaimTask(event, occurrence)
toast.success('Task unclaimed')
} catch (error) {
const message = error instanceof Error ? error.message : 'Failed to unclaim task'
toast.error(message)
console.error('Failed to unclaim task:', error)
}
}
/**
* Toggle completion status of an event (optionally for a specific occurrence)
* DEPRECATED: Use claimTask, startTask, completeEvent, or unclaimTask instead for more granular control
*/
const toggleComplete = async (event: ScheduledEvent, occurrence?: string, notes: string = ''): Promise<void> => {
console.log('🔧 useScheduledEvents: toggleComplete called for event:', event.title, 'occurrence:', occurrence)
if (!scheduledEventService) {
console.error('❌ useScheduledEvents: Scheduled event service not available')
toast.error('Scheduled event service not available')
return
}
try {
const eventAddress = `31922:${event.pubkey}:${event.dTag}`
const currentlyCompleted = scheduledEventService.isCompleted(eventAddress, occurrence)
console.log('📊 useScheduledEvents: Current completion status:', currentlyCompleted)
if (currentlyCompleted) {
console.log('⬇️ useScheduledEvents: Unclaiming task...')
await scheduledEventService.unclaimTask(event, occurrence)
toast.success('Task unclaimed')
} else {
console.log('⬆️ useScheduledEvents: Marking as complete...')
await scheduledEventService.completeEvent(event, notes, occurrence)
toast.success('Task completed!')
}
} catch (error) {
const message = error instanceof Error ? error.message : 'Failed to toggle completion'
if (message.includes('authenticated')) {
toast.error('Please sign in to complete tasks')
} else if (message.includes('Not connected')) {
toast.error('Not connected to relays')
} else {
toast.error(message)
}
console.error('❌ useScheduledEvents: Failed to toggle completion:', error)
}
}
/**
* Complete an event with optional notes
*/
const completeEvent = async (event: ScheduledEvent, occurrence?: string, notes: string = ''): Promise<void> => {
if (!scheduledEventService) {
toast.error('Scheduled event service not available')
return
}
try {
await scheduledEventService.completeEvent(event, notes, occurrence)
toast.success('Task completed!')
} catch (error) {
const message = error instanceof Error ? error.message : 'Failed to complete task'
toast.error(message)
console.error('Failed to complete task:', error)
}
}
/**
* Get loading state
*/
const isLoading = computed(() => {
return scheduledEventService?.isLoading ?? false
})
/**
* Get all scheduled events (reactive)
*/
const allScheduledEvents = computed(() => {
return scheduledEventService?.scheduledEvents ?? new Map()
})
/**
* Delete a task (only author can delete)
*/
const deleteTask = async (event: ScheduledEvent): Promise<void> => {
if (!scheduledEventService) {
toast.error('Scheduled event service not available')
return
}
try {
await scheduledEventService.deleteTask(event)
toast.success('Task deleted!')
} catch (error) {
const message = error instanceof Error ? error.message : 'Failed to delete task'
toast.error(message)
console.error('Failed to delete task:', error)
}
}
/**
* Get all completions (reactive) - returns array for better reactivity
*/
const allCompletions = computed(() => {
if (!scheduledEventService?.completions) return []
return Array.from(scheduledEventService.completions.values())
})
return {
// Methods - Getters
getScheduledEvents,
getEventsForDate,
getEventsForSpecificDate,
getTodaysEvents,
getCompletion,
isCompleted,
getTaskStatus,
// Methods - Actions
claimTask,
startTask,
completeEvent,
unclaimTask,
deleteTask,
toggleComplete, // DEPRECATED: Use specific actions instead
// State
isLoading,
allScheduledEvents,
allCompletions
}
}

View file

@ -88,14 +88,6 @@ export const CONTENT_FILTERS: Record<string, ContentFilter> = {
description: 'Rideshare requests, offers, and coordination',
tags: ['rideshare', 'carpool'], // NIP-12 tags
keywords: ['rideshare', 'ride share', 'carpool', '🚗', '🚶']
},
// Scheduled events (NIP-52)
scheduledEvents: {
id: 'scheduled-events',
label: 'Scheduled Events',
kinds: [31922], // NIP-52: Calendar Events
description: 'Calendar-based tasks and scheduled activities'
}
}
@ -118,11 +110,6 @@ export const FILTER_PRESETS: Record<string, ContentFilter[]> = {
// Rideshare only
rideshare: [
CONTENT_FILTERS.rideshare
],
// Scheduled events only
scheduledEvents: [
CONTENT_FILTERS.scheduledEvents
]
}

View file

@ -1,15 +1,11 @@
import type { App } from 'vue'
import { markRaw } from 'vue'
import type { ModulePlugin } from '@/core/types'
import { container, SERVICE_TOKENS } from '@/core/di-container'
import NostrFeed from './components/NostrFeed.vue'
import NoteComposer from './components/NoteComposer.vue'
import RideshareComposer from './components/RideshareComposer.vue'
import { useFeed } from './composables/useFeed'
import { FeedService } from './services/FeedService'
import { ProfileService } from './services/ProfileService'
import { ReactionService } from './services/ReactionService'
import { ScheduledEventService } from './services/ScheduledEventService'
/**
* Nostr Feed Module Plugin
@ -20,28 +16,6 @@ export const nostrFeedModule: ModulePlugin = {
version: '1.0.0',
dependencies: ['base'],
// Register quick actions for the FAB menu
quickActions: [
{
id: 'note',
label: 'Note',
icon: 'MessageSquare',
component: markRaw(NoteComposer),
category: 'compose',
order: 1,
requiresAuth: true
},
{
id: 'rideshare',
label: 'Rideshare',
icon: 'Car',
component: markRaw(RideshareComposer),
category: 'compose',
order: 2,
requiresAuth: true
}
],
async install(app: App) {
console.log('nostr-feed module: Starting installation...')
@ -49,12 +23,10 @@ export const nostrFeedModule: ModulePlugin = {
const feedService = new FeedService()
const profileService = new ProfileService()
const reactionService = new ReactionService()
const scheduledEventService = new ScheduledEventService()
container.provide(SERVICE_TOKENS.FEED_SERVICE, feedService)
container.provide(SERVICE_TOKENS.PROFILE_SERVICE, profileService)
container.provide(SERVICE_TOKENS.REACTION_SERVICE, reactionService)
container.provide(SERVICE_TOKENS.SCHEDULED_EVENT_SERVICE, scheduledEventService)
console.log('nostr-feed module: Services registered in DI container')
// Initialize services
@ -71,10 +43,6 @@ export const nostrFeedModule: ModulePlugin = {
reactionService.initialize({
waitForDependencies: true,
maxRetries: 3
}),
scheduledEventService.initialize({
waitForDependencies: true,
maxRetries: 3
})
])
console.log('nostr-feed module: Services initialized')

View file

@ -47,7 +47,6 @@ export class FeedService extends BaseService {
protected relayHub: any = null
protected visibilityService: any = null
protected reactionService: any = null
protected scheduledEventService: any = null
// Event ID tracking for deduplication
private seenEventIds = new Set<string>()
@ -73,12 +72,10 @@ export class FeedService extends BaseService {
this.relayHub = injectService(SERVICE_TOKENS.RELAY_HUB)
this.visibilityService = injectService(SERVICE_TOKENS.VISIBILITY_SERVICE)
this.reactionService = injectService(SERVICE_TOKENS.REACTION_SERVICE)
this.scheduledEventService = injectService(SERVICE_TOKENS.SCHEDULED_EVENT_SERVICE)
console.log('FeedService: RelayHub injected:', !!this.relayHub)
console.log('FeedService: VisibilityService injected:', !!this.visibilityService)
console.log('FeedService: ReactionService injected:', !!this.reactionService)
console.log('FeedService: ScheduledEventService injected:', !!this.scheduledEventService)
if (!this.relayHub) {
throw new Error('RelayHub service not available')
@ -202,12 +199,6 @@ export class FeedService extends BaseService {
kinds: [5] // All deletion events (for both posts and reactions)
})
// Add scheduled events (kind 31922) and RSVPs (kind 31925)
filters.push({
kinds: [31922, 31925], // Calendar events and RSVPs
limit: 200
})
console.log(`Creating feed subscription for ${config.feedType} with filters:`, filters)
// Subscribe to all events (posts, reactions, deletions) with deduplication
@ -266,25 +257,6 @@ export class FeedService extends BaseService {
return
}
// Route scheduled events (kind 31922) to ScheduledEventService
if (event.kind === 31922) {
if (this.scheduledEventService) {
this.scheduledEventService.handleScheduledEvent(event)
}
return
}
// Route RSVP/completion events (kind 31925) to ScheduledEventService
if (event.kind === 31925) {
console.log('🔀 FeedService: Routing kind 31925 (completion) to ScheduledEventService')
if (this.scheduledEventService) {
this.scheduledEventService.handleCompletionEvent(event)
} else {
console.warn('⚠️ FeedService: ScheduledEventService not available')
}
return
}
// Skip if event already seen (for posts only, kind 1)
if (this.seenEventIds.has(event.id)) {
return
@ -383,28 +355,6 @@ export class FeedService extends BaseService {
return
}
// Route to ScheduledEventService for completion/RSVP deletions (kind 31925)
if (deletedKind === '31925') {
console.log('🔀 FeedService: Routing kind 5 (deletion of kind 31925) to ScheduledEventService')
if (this.scheduledEventService) {
this.scheduledEventService.handleDeletionEvent(event)
} else {
console.warn('⚠️ FeedService: ScheduledEventService not available')
}
return
}
// Route to ScheduledEventService for scheduled event deletions (kind 31922)
if (deletedKind === '31922') {
console.log('🔀 FeedService: Routing kind 5 (deletion of kind 31922) to ScheduledEventService')
if (this.scheduledEventService) {
this.scheduledEventService.handleTaskDeletion(event)
} else {
console.warn('⚠️ FeedService: ScheduledEventService not available')
}
return
}
// Handle post deletions (kind 1) in FeedService
if (deletedKind === '1' || !deletedKind) {
// Extract event IDs to delete from 'e' tags

View file

@ -1,678 +0,0 @@
import { ref, reactive } from 'vue'
import { BaseService } from '@/core/base/BaseService'
import { injectService, SERVICE_TOKENS } from '@/core/di-container'
import { finalizeEvent, type EventTemplate } from 'nostr-tools'
import type { Event as NostrEvent } from 'nostr-tools'
export interface RecurrencePattern {
frequency: 'daily' | 'weekly'
dayOfWeek?: string // For weekly: 'monday', 'tuesday', etc.
endDate?: string // ISO date string - when to stop recurring (optional)
}
export interface ScheduledEvent {
id: string
pubkey: string
created_at: number
dTag: string // Unique identifier from 'd' tag
title: string
start: string // ISO date string (YYYY-MM-DD or ISO datetime)
end?: string
description?: string
location?: string
status: string
eventType?: string // 'task' for completable events, 'announcement' for informational
participants?: Array<{ pubkey: string; type?: string }> // 'required', 'optional', 'organizer'
content: string
tags: string[][]
recurrence?: RecurrencePattern // Optional: for recurring events
}
export type TaskStatus = 'claimed' | 'in-progress' | 'completed' | 'blocked' | 'cancelled'
export interface EventCompletion {
id: string
eventAddress: string // "31922:pubkey:d-tag"
occurrence?: string // ISO date string for the specific occurrence (YYYY-MM-DD)
pubkey: string // Who claimed/completed it
created_at: number
taskStatus: TaskStatus
completedAt?: number // Unix timestamp when completed
notes: string
}
export class ScheduledEventService extends BaseService {
protected readonly metadata = {
name: 'ScheduledEventService',
version: '1.0.0',
dependencies: []
}
protected relayHub: any = null
protected authService: any = null
// Scheduled events state - indexed by event address
private _scheduledEvents = reactive(new Map<string, ScheduledEvent>())
private _completions = reactive(new Map<string, EventCompletion>())
private _isLoading = ref(false)
protected async onInitialize(): Promise<void> {
console.log('ScheduledEventService: Starting initialization...')
this.relayHub = injectService(SERVICE_TOKENS.RELAY_HUB)
this.authService = injectService(SERVICE_TOKENS.AUTH_SERVICE)
if (!this.relayHub) {
throw new Error('RelayHub service not available')
}
console.log('ScheduledEventService: Initialization complete')
}
/**
* Handle incoming scheduled event (kind 31922)
* Made public so FeedService can route kind 31922 events to this service
*/
public handleScheduledEvent(event: NostrEvent): void {
try {
// Extract event data from tags
const dTag = event.tags.find(tag => tag[0] === 'd')?.[1]
if (!dTag) {
console.warn('Scheduled event missing d tag:', event.id)
return
}
const title = event.tags.find(tag => tag[0] === 'title')?.[1] || 'Untitled Event'
const start = event.tags.find(tag => tag[0] === 'start')?.[1]
const end = event.tags.find(tag => tag[0] === 'end')?.[1]
const description = event.tags.find(tag => tag[0] === 'description')?.[1]
const location = event.tags.find(tag => tag[0] === 'location')?.[1]
const status = event.tags.find(tag => tag[0] === 'status')?.[1] || 'pending'
const eventType = event.tags.find(tag => tag[0] === 'event-type')?.[1]
// Parse participant tags: ["p", "<pubkey>", "<relay-hint>", "<participation-type>"]
const participantTags = event.tags.filter(tag => tag[0] === 'p')
const participants = participantTags.map(tag => ({
pubkey: tag[1],
type: tag[3] // 'required', 'optional', 'organizer'
}))
// Parse recurrence tags
const recurrenceFreq = event.tags.find(tag => tag[0] === 'recurrence')?.[1] as 'daily' | 'weekly' | undefined
const recurrenceDayOfWeek = event.tags.find(tag => tag[0] === 'recurrence-day')?.[1]
const recurrenceEndDate = event.tags.find(tag => tag[0] === 'recurrence-end')?.[1]
let recurrence: RecurrencePattern | undefined
if (recurrenceFreq === 'daily' || recurrenceFreq === 'weekly') {
recurrence = {
frequency: recurrenceFreq,
dayOfWeek: recurrenceDayOfWeek,
endDate: recurrenceEndDate
}
}
if (!start) {
console.warn('Scheduled event missing start date:', event.id)
return
}
// Create event address: "kind:pubkey:d-tag"
const eventAddress = `31922:${event.pubkey}:${dTag}`
const scheduledEvent: ScheduledEvent = {
id: event.id,
pubkey: event.pubkey,
created_at: event.created_at,
dTag,
title,
start,
end,
description,
location,
status,
eventType,
participants: participants.length > 0 ? participants : undefined,
content: event.content,
tags: event.tags,
recurrence
}
// Store or update the event (replaceable by d-tag)
this._scheduledEvents.set(eventAddress, scheduledEvent)
} catch (error) {
console.error('Failed to handle scheduled event:', error)
}
}
/**
* Handle RSVP/completion event (kind 31925)
* Made public so FeedService can route kind 31925 events to this service
*/
public handleCompletionEvent(event: NostrEvent): void {
console.log('🔔 ScheduledEventService: Received completion event (kind 31925)', event.id)
try {
// Find the event being responded to
const aTag = event.tags.find(tag => tag[0] === 'a')?.[1]
if (!aTag) {
console.warn('Completion event missing a tag:', event.id)
return
}
// Parse task status (new approach)
const taskStatusTag = event.tags.find(tag => tag[0] === 'task-status')?.[1] as TaskStatus | undefined
// Backward compatibility: check old 'completed' tag if task-status not present
let taskStatus: TaskStatus
if (taskStatusTag) {
taskStatus = taskStatusTag
} else {
// Legacy support: convert old 'completed' tag to new taskStatus
const completed = event.tags.find(tag => tag[0] === 'completed')?.[1] === 'true'
taskStatus = completed ? 'completed' : 'claimed'
}
const completedAtTag = event.tags.find(tag => tag[0] === 'completed_at')?.[1]
const completedAt = completedAtTag ? parseInt(completedAtTag) : undefined
const occurrence = event.tags.find(tag => tag[0] === 'occurrence')?.[1] // ISO date string
console.log('📋 Completion details:', {
aTag,
occurrence,
taskStatus,
pubkey: event.pubkey,
eventId: event.id
})
const completion: EventCompletion = {
id: event.id,
eventAddress: aTag,
occurrence,
pubkey: event.pubkey,
created_at: event.created_at,
taskStatus,
completedAt,
notes: event.content
}
// Store completion (most recent one wins)
// For recurring events, include occurrence in the key: "eventAddress:occurrence"
// For non-recurring, just use eventAddress
const completionKey = occurrence ? `${aTag}:${occurrence}` : aTag
const existing = this._completions.get(completionKey)
if (!existing || event.created_at > existing.created_at) {
this._completions.set(completionKey, completion)
console.log('✅ Stored completion for:', completionKey, '- status:', taskStatus)
} else {
console.log('⏭️ Skipped older completion for:', completionKey)
}
} catch (error) {
console.error('Failed to handle completion event:', error)
}
}
/**
* Handle deletion event (kind 5) for completion events
* Made public so FeedService can route deletion events to this service
*/
public handleDeletionEvent(event: NostrEvent): void {
console.log('🗑️ ScheduledEventService: Received deletion event (kind 5)', event.id)
try {
// Extract event IDs to delete from 'e' tags
const eventIdsToDelete = event.tags
?.filter((tag: string[]) => tag[0] === 'e')
.map((tag: string[]) => tag[1]) || []
if (eventIdsToDelete.length === 0) {
console.warn('Deletion event missing e tags:', event.id)
return
}
console.log('🔍 Looking for completions to delete:', eventIdsToDelete)
// Find and remove completions that match the deleted event IDs
let deletedCount = 0
for (const [completionKey, completion] of this._completions.entries()) {
// Only delete if:
// 1. The completion event ID matches one being deleted
// 2. The deletion request comes from the same author (NIP-09 validation)
if (eventIdsToDelete.includes(completion.id) && completion.pubkey === event.pubkey) {
this._completions.delete(completionKey)
console.log('✅ Deleted completion:', completionKey, 'event ID:', completion.id)
deletedCount++
}
}
console.log(`🗑️ Deleted ${deletedCount} completion(s) from deletion event`)
} catch (error) {
console.error('Failed to handle deletion event:', error)
}
}
/**
* Handle deletion event (kind 5) for scheduled events (kind 31922)
* Made public so FeedService can route deletion events to this service
*/
public handleTaskDeletion(event: NostrEvent): void {
console.log('🗑️ ScheduledEventService: Received task deletion event (kind 5)', event.id)
try {
// Extract event addresses to delete from 'a' tags
const eventAddressesToDelete = event.tags
?.filter((tag: string[]) => tag[0] === 'a')
.map((tag: string[]) => tag[1]) || []
if (eventAddressesToDelete.length === 0) {
console.warn('Task deletion event missing a tags:', event.id)
return
}
console.log('🔍 Looking for tasks to delete:', eventAddressesToDelete)
// Find and remove tasks that match the deleted event addresses
let deletedCount = 0
for (const eventAddress of eventAddressesToDelete) {
const task = this._scheduledEvents.get(eventAddress)
// Only delete if:
// 1. The task exists
// 2. The deletion request comes from the task author (NIP-09 validation)
if (task && task.pubkey === event.pubkey) {
this._scheduledEvents.delete(eventAddress)
console.log('✅ Deleted task:', eventAddress)
deletedCount++
} else if (task) {
console.warn('⚠️ Deletion request not from task author:', eventAddress)
}
}
console.log(`🗑️ Deleted ${deletedCount} task(s) from deletion event`)
} catch (error) {
console.error('Failed to handle task deletion event:', error)
}
}
/**
* Get all scheduled events
*/
getScheduledEvents(): ScheduledEvent[] {
return Array.from(this._scheduledEvents.values())
}
/**
* Get events scheduled for a specific date (YYYY-MM-DD)
*/
getEventsForDate(date: string): ScheduledEvent[] {
return this.getScheduledEvents().filter(event => {
// Simple date matching (start date)
// For ISO datetime strings, extract just the date part
const eventDate = event.start.split('T')[0]
return eventDate === date
})
}
/**
* Check if a recurring event occurs on a specific date
*/
private doesRecurringEventOccurOnDate(event: ScheduledEvent, targetDate: string): boolean {
if (!event.recurrence) return false
const target = new Date(targetDate)
const eventStart = new Date(event.start.split('T')[0]) // Get date part only
// Check if target date is before the event start date
if (target < eventStart) return false
// Check if target date is after the event end date (if specified)
if (event.recurrence.endDate) {
const endDate = new Date(event.recurrence.endDate)
if (target > endDate) return false
}
// Check frequency-specific rules
if (event.recurrence.frequency === 'daily') {
// Daily events occur every day within the range
return true
} else if (event.recurrence.frequency === 'weekly') {
// Weekly events occur on specific day of week
const targetDayOfWeek = target.toLocaleDateString('en-US', { weekday: 'long' }).toLowerCase()
const eventDayOfWeek = event.recurrence.dayOfWeek?.toLowerCase()
return targetDayOfWeek === eventDayOfWeek
}
return false
}
/**
* Get events for a specific date, optionally filtered by user participation
* @param date - ISO date string (YYYY-MM-DD). Defaults to today.
* @param userPubkey - Optional user pubkey to filter by participation
*/
getEventsForSpecificDate(date?: string, userPubkey?: string): ScheduledEvent[] {
const targetDate = date || new Date().toISOString().split('T')[0]
// Get one-time events for the date (exclude recurring events to avoid duplicates)
const oneTimeEvents = this.getEventsForDate(targetDate).filter(event => !event.recurrence)
// Get all events and check for recurring events that occur on this date
const allEvents = this.getScheduledEvents()
const recurringEventsOnDate = allEvents.filter(event =>
event.recurrence && this.doesRecurringEventOccurOnDate(event, targetDate)
)
// Combine one-time and recurring events
let events = [...oneTimeEvents, ...recurringEventsOnDate]
// Filter events based on participation (if user pubkey provided)
if (userPubkey) {
events = events.filter(event => {
// If event has no participants, it's community-wide (show to everyone)
if (!event.participants || event.participants.length === 0) return true
// Otherwise, only show if user is a participant
return event.participants.some(p => p.pubkey === userPubkey)
})
}
// Sort by start time (ascending order)
events.sort((a, b) => {
// ISO datetime strings can be compared lexicographically
return a.start.localeCompare(b.start)
})
return events
}
/**
* Get events for today, optionally filtered by user participation
*/
getTodaysEvents(userPubkey?: string): ScheduledEvent[] {
return this.getEventsForSpecificDate(undefined, userPubkey)
}
/**
* Get completion status for an event (optionally for a specific occurrence)
*/
getCompletion(eventAddress: string, occurrence?: string): EventCompletion | undefined {
const completionKey = occurrence ? `${eventAddress}:${occurrence}` : eventAddress
return this._completions.get(completionKey)
}
/**
* Check if an event is completed (optionally for a specific occurrence)
*/
isCompleted(eventAddress: string, occurrence?: string): boolean {
const completion = this.getCompletion(eventAddress, occurrence)
return completion?.taskStatus === 'completed'
}
/**
* Get task status for an event
*/
getTaskStatus(eventAddress: string, occurrence?: string): TaskStatus | null {
const completion = this.getCompletion(eventAddress, occurrence)
return completion?.taskStatus || null
}
/**
* Claim a task (mark as claimed)
*/
async claimTask(event: ScheduledEvent, notes: string = '', occurrence?: string): Promise<void> {
await this.updateTaskStatus(event, 'claimed', notes, occurrence)
}
/**
* Start a task (mark as in-progress)
*/
async startTask(event: ScheduledEvent, notes: string = '', occurrence?: string): Promise<void> {
await this.updateTaskStatus(event, 'in-progress', notes, occurrence)
}
/**
* Mark an event as complete (optionally for a specific occurrence)
*/
async completeEvent(event: ScheduledEvent, notes: string = '', occurrence?: string): Promise<void> {
await this.updateTaskStatus(event, 'completed', notes, occurrence)
}
/**
* Internal method to update task status
*/
private async updateTaskStatus(
event: ScheduledEvent,
taskStatus: TaskStatus,
notes: string = '',
occurrence?: string
): Promise<void> {
if (!this.authService?.isAuthenticated?.value) {
throw new Error('Must be authenticated to update task status')
}
if (!this.relayHub?.isConnected) {
throw new Error('Not connected to relays')
}
const userPrivkey = this.authService.user.value?.prvkey
if (!userPrivkey) {
throw new Error('User private key not available')
}
try {
this._isLoading.value = true
const eventAddress = `31922:${event.pubkey}:${event.dTag}`
// Create RSVP event with task-status tag
const tags: string[][] = [
['a', eventAddress],
['task-status', taskStatus]
]
// Add completed_at timestamp if task is completed
if (taskStatus === 'completed') {
tags.push(['completed_at', Math.floor(Date.now() / 1000).toString()])
}
// Add occurrence tag if provided (for recurring events)
if (occurrence) {
tags.push(['occurrence', occurrence])
}
const eventTemplate: EventTemplate = {
kind: 31925, // Calendar Event RSVP
content: notes,
tags,
created_at: Math.floor(Date.now() / 1000)
}
// Sign the event
const privkeyBytes = this.hexToUint8Array(userPrivkey)
const signedEvent = finalizeEvent(eventTemplate, privkeyBytes)
// Publish the status update
console.log(`📤 Publishing task status update (${taskStatus}) for:`, eventAddress)
const result = await this.relayHub.publishEvent(signedEvent)
console.log('✅ Task status published to', result.success, '/', result.total, 'relays')
// Update local state (publishEvent throws if no relays accepted)
console.log('🔄 Updating local state (event published successfully)')
this.handleCompletionEvent(signedEvent)
} catch (error) {
console.error('Failed to update task status:', error)
throw error
} finally {
this._isLoading.value = false
}
}
/**
* Unclaim/reset a task (removes task status - makes it unclaimed)
* Note: In Nostr, we can't truly "delete" an event, but we can publish
* a deletion request (kind 5) to ask relays to remove our RSVP
*/
async unclaimTask(event: ScheduledEvent, occurrence?: string): Promise<void> {
if (!this.authService?.isAuthenticated?.value) {
throw new Error('Must be authenticated to unclaim tasks')
}
if (!this.relayHub?.isConnected) {
throw new Error('Not connected to relays')
}
const userPrivkey = this.authService.user.value?.prvkey
if (!userPrivkey) {
throw new Error('User private key not available')
}
try {
this._isLoading.value = true
const eventAddress = `31922:${event.pubkey}:${event.dTag}`
const completionKey = occurrence ? `${eventAddress}:${occurrence}` : eventAddress
const completion = this._completions.get(completionKey)
if (!completion) {
console.log('No completion to unclaim')
return
}
// Create deletion event (kind 5) for the RSVP
const deletionEvent: EventTemplate = {
kind: 5,
content: 'Task unclaimed',
tags: [
['e', completion.id], // Reference to the RSVP event being deleted
['k', '31925'] // Kind of event being deleted
],
created_at: Math.floor(Date.now() / 1000)
}
// Sign the event
const privkeyBytes = this.hexToUint8Array(userPrivkey)
const signedEvent = finalizeEvent(deletionEvent, privkeyBytes)
// Publish the deletion request
console.log('📤 Publishing deletion request for task RSVP:', completion.id)
const result = await this.relayHub.publishEvent(signedEvent)
console.log('✅ Deletion request published to', result.success, '/', result.total, 'relays')
// Remove from local state (publishEvent throws if no relays accepted)
this._completions.delete(completionKey)
console.log('🗑️ Removed completion from local state:', completionKey)
} catch (error) {
console.error('Failed to unclaim task:', error)
throw error
} finally {
this._isLoading.value = false
}
}
/**
* Delete a scheduled event (kind 31922)
* Only the author can delete their own event
*/
async deleteTask(event: ScheduledEvent): Promise<void> {
if (!this.authService?.isAuthenticated?.value) {
throw new Error('Must be authenticated to delete tasks')
}
if (!this.relayHub?.isConnected) {
throw new Error('Not connected to relays')
}
const userPrivkey = this.authService.user.value?.prvkey
const userPubkey = this.authService.user.value?.pubkey
if (!userPrivkey || !userPubkey) {
throw new Error('User credentials not available')
}
// Only author can delete
if (userPubkey !== event.pubkey) {
throw new Error('Only the task author can delete this task')
}
try {
this._isLoading.value = true
const eventAddress = `31922:${event.pubkey}:${event.dTag}`
// Create deletion event (kind 5) for the scheduled event
const deletionEvent: EventTemplate = {
kind: 5,
content: 'Task deleted',
tags: [
['a', eventAddress], // Reference to the parameterized replaceable event being deleted
['k', '31922'] // Kind of event being deleted
],
created_at: Math.floor(Date.now() / 1000)
}
// Sign the event
const privkeyBytes = this.hexToUint8Array(userPrivkey)
const signedEvent = finalizeEvent(deletionEvent, privkeyBytes)
// Publish the deletion request
console.log('📤 Publishing deletion request for task:', eventAddress)
const result = await this.relayHub.publishEvent(signedEvent)
console.log('✅ Task deletion request published to', result.success, '/', result.total, 'relays')
// Remove from local state (publishEvent throws if no relays accepted)
this._scheduledEvents.delete(eventAddress)
console.log('🗑️ Removed task from local state:', eventAddress)
} catch (error) {
console.error('Failed to delete task:', error)
throw error
} finally {
this._isLoading.value = false
}
}
/**
* Helper function to convert hex string to Uint8Array
*/
private hexToUint8Array(hex: string): Uint8Array {
const bytes = new Uint8Array(hex.length / 2)
for (let i = 0; i < hex.length; i += 2) {
bytes[i / 2] = parseInt(hex.substr(i, 2), 16)
}
return bytes
}
/**
* Get all scheduled events
*/
get scheduledEvents(): Map<string, ScheduledEvent> {
return this._scheduledEvents
}
/**
* Get all completions
*/
get completions(): Map<string, EventCompletion> {
return this._completions
}
/**
* Check if currently loading
*/
get isLoading(): boolean {
return this._isLoading.value
}
/**
* Cleanup
*/
protected async onDestroy(): Promise<void> {
this._scheduledEvents.clear()
this._completions.clear()
}
}

View file

@ -36,20 +36,24 @@
<!-- Main Feed Area - Takes remaining height with scrolling -->
<div class="flex-1 overflow-y-auto scrollbar-thin scrollbar-thumb-border scrollbar-track-transparent">
<!-- Quick Action Component Area -->
<div v-if="activeAction || replyTo" class="border-b bg-background sticky top-0 z-10">
<!-- Collapsible Composer -->
<div v-if="showComposer || replyTo" class="border-b bg-background sticky top-0 z-10">
<div class="max-h-[70vh] overflow-y-auto scrollbar-thin scrollbar-thumb-border scrollbar-track-transparent">
<div class="px-4 py-3 sm:px-6">
<!-- Dynamic Quick Action Component -->
<component
:is="activeAction?.component"
v-if="activeAction"
<!-- Regular Note Composer -->
<NoteComposer
v-if="composerType === 'note' || replyTo"
:reply-to="replyTo"
@note-published="onActionComplete"
@rideshare-published="onActionComplete"
@action-complete="onActionComplete"
@note-published="onNotePublished"
@clear-reply="onClearReply"
@close="closeQuickAction"
@close="onCloseComposer"
/>
<!-- Rideshare Composer -->
<RideshareComposer
v-else-if="composerType === 'rideshare'"
@rideshare-published="onRidesharePublished"
@close="onCloseComposer"
/>
</div>
</div>
@ -68,32 +72,39 @@
</div>
</div>
<!-- Floating Quick Action Button -->
<div v-if="!activeAction && !replyTo && quickActions.length > 0" class="fixed bottom-6 right-6 z-50">
<!-- Floating Action Buttons for Compose -->
<div v-if="!showComposer && !replyTo" class="fixed bottom-6 right-6 z-50">
<!-- Main compose button -->
<div class="flex flex-col items-end gap-3">
<!-- Quick Action Buttons (when expanded) -->
<div v-if="showQuickActions" class="flex flex-col gap-2">
<!-- Secondary buttons (when expanded) -->
<div v-if="showComposerOptions" class="flex flex-col gap-2">
<Button
v-for="action in quickActions"
:key="action.id"
@click="openQuickAction(action)"
@click="openComposer('note')"
size="lg"
class="h-12 px-4 rounded-full shadow-lg hover:shadow-xl transition-all gap-2 bg-card border-2 border-border hover:bg-accent text-card-foreground"
>
<component :is="getIconComponent(action.icon)" class="h-4 w-4" />
<span class="text-sm font-medium">{{ action.label }}</span>
<MessageSquare class="h-4 w-4" />
<span class="text-sm font-medium">Note</span>
</Button>
<Button
@click="openComposer('rideshare')"
size="lg"
class="h-12 px-4 rounded-full shadow-lg hover:shadow-xl transition-all gap-2 bg-card border-2 border-border hover:bg-accent text-card-foreground"
>
<Car class="h-4 w-4" />
<span class="text-sm font-medium">Rideshare</span>
</Button>
</div>
<!-- Main FAB -->
<Button
@click="toggleQuickActions"
@click="toggleComposerOptions"
size="lg"
class="h-14 w-14 rounded-full shadow-lg hover:shadow-xl transition-all bg-primary hover:bg-primary/90 border-2 border-primary-foreground/20 flex items-center justify-center p-0"
>
<Plus
class="h-6 w-6 stroke-[2.5] transition-transform duration-200"
:class="{ 'rotate-45': showQuickActions }"
:class="{ 'rotate-45': showComposerOptions }"
/>
</Button>
</div>
@ -125,34 +136,32 @@
import { ref, computed, watch } from 'vue'
import { Button } from '@/components/ui/button'
import { Filter, Plus } from 'lucide-vue-next'
import * as LucideIcons from 'lucide-vue-next'
import { Filter, Plus, MessageSquare, Car } from 'lucide-vue-next'
import PWAInstallPrompt from '@/components/pwa/PWAInstallPrompt.vue'
import FeedFilters from '@/modules/nostr-feed/components/FeedFilters.vue'
import NoteComposer from '@/modules/nostr-feed/components/NoteComposer.vue'
import RideshareComposer from '@/modules/nostr-feed/components/RideshareComposer.vue'
import NostrFeed from '@/modules/nostr-feed/components/NostrFeed.vue'
import { FILTER_PRESETS } from '@/modules/nostr-feed/config/content-filters'
import { useQuickActions } from '@/composables/useQuickActions'
import appConfig from '@/app.config'
import type { ContentFilter } from '@/modules/nostr-feed/services/FeedService'
import type { QuickAction } from '@/core/types'
// Get quick actions from modules
const { quickActions } = useQuickActions()
import type { ReplyToNote } from '@/modules/nostr-feed/components/NoteComposer.vue'
// Get admin pubkeys from app config
const adminPubkeys = appConfig.modules['nostr-feed']?.config?.adminPubkeys || []
// UI state
const showFilters = ref(false)
const showQuickActions = ref(false)
const activeAction = ref<QuickAction | null>(null)
const showComposer = ref(false)
const showComposerOptions = ref(false)
const composerType = ref<'note' | 'rideshare'>('note')
// Feed configuration
const selectedFilters = ref<ContentFilter[]>(FILTER_PRESETS.all)
const feedKey = ref(0) // Force feed component to re-render when filters change
// Reply state (for note composer compatibility)
const replyTo = ref<any | undefined>()
// Note composer state
const replyTo = ref<ReplyToNote | undefined>()
// Quick filter presets for mobile bottom bar
const quickFilterPresets = {
@ -212,46 +221,48 @@ const setQuickFilter = (presetKey: string) => {
}
}
// Quick action methods
const toggleQuickActions = () => {
showQuickActions.value = !showQuickActions.value
}
const openQuickAction = (action: QuickAction) => {
activeAction.value = action
showQuickActions.value = false
}
const closeQuickAction = () => {
activeAction.value = null
replyTo.value = undefined
}
// Event handlers for quick action components
const onActionComplete = (eventData?: any) => {
console.log('Quick action completed:', activeAction.value?.id, eventData)
// Refresh the feed to show new content
const onNotePublished = (noteId: string) => {
console.log('Note published:', noteId)
// Refresh the feed to show the new note
feedKey.value++
// Close the action
activeAction.value = null
// Clear reply state and hide composer
replyTo.value = undefined
showComposer.value = false
}
const onClearReply = () => {
replyTo.value = undefined
showComposer.value = false
}
const onReplyToNote = (note: any) => {
const onReplyToNote = (note: ReplyToNote) => {
replyTo.value = note
// Find and open the note composer action
const noteAction = quickActions.value.find(a => a.id === 'note')
if (noteAction) {
activeAction.value = noteAction
}
showComposer.value = true
}
// Helper to get Lucide icon component
const getIconComponent = (iconName: string) => {
return (LucideIcons as any)[iconName] || Plus
const onCloseComposer = () => {
showComposer.value = false
showComposerOptions.value = false
replyTo.value = undefined
}
// New composer methods
const toggleComposerOptions = () => {
showComposerOptions.value = !showComposerOptions.value
}
const openComposer = (type: 'note' | 'rideshare') => {
composerType.value = type
showComposer.value = true
showComposerOptions.value = false
}
const onRidesharePublished = (noteId: string) => {
console.log('Rideshare post published:', noteId)
// Refresh the feed to show the new rideshare post
feedKey.value++
// Hide composer
showComposer.value = false
showComposerOptions.value = false
}
</script>