Compare commits
50 commits
main
...
castle-web
| Author | SHA1 | Date | |
|---|---|---|---|
| 078c55b8e9 | |||
| 3b8c82514a | |||
| 8f05f4ec7c | |||
| 4e85488921 | |||
| d0b3396af7 | |||
| 76fbf7579f | |||
| d8e67984b0 | |||
| d497cfa4d9 | |||
| 2e6f215157 | |||
| 91aecd2192 | |||
| 84596e518e | |||
| 509fae1d35 | |||
| 557d7ecacc | |||
| 9c4b14f382 | |||
| be00c61c77 | |||
| 78fba2a637 | |||
| f5075ed96d | |||
| 90a5741d7a | |||
| d7cd72f850 | |||
| fff42d170e | |||
| 53c14044ef | |||
| 0f795f9d18 | |||
| 358c3056c7 | |||
| f6ecbc8faf | |||
| 8dad92f0e5 | |||
| 9c8b696f06 | |||
| 6ecaafb633 | |||
| e745caffaa | |||
| 00a99995c9 | |||
| 9ed674d0f3 | |||
| 678ccff694 | |||
| b286a0315d | |||
| 1a38c92db1 | |||
| 2620c07a23 | |||
| 76b930469d | |||
| 0e42318036 | |||
| a27a8232f2 | |||
| 706ceea84b | |||
| 8381d43268 | |||
| 098bff8acc | |||
| 62c38185e8 | |||
| 9aa8c28bef | |||
| abaf7f2f5b | |||
| 4bf1da7331 | |||
| 661b700092 | |||
| 46418ef6fd | |||
| 033113829f | |||
| 4050b33d0e | |||
| 9b05bcc238 | |||
| b6d8a78cd8 |
38 changed files with 4932 additions and 495 deletions
500
CLAUDE.md
500
CLAUDE.md
|
|
@ -10,7 +10,7 @@ This file provides guidance to Claude Code (claude.ai/code) when working with co
|
||||||
- `npm run preview` - Preview production build locally
|
- `npm run preview` - Preview production build locally
|
||||||
- `npm run analyze` - Build with bundle analysis (opens visualization)
|
- `npm run analyze` - Build with bundle analysis (opens visualization)
|
||||||
|
|
||||||
**Electron Development**
|
**Electron Development**
|
||||||
- `npm run electron:dev` - Run both Vite dev server and Electron concurrently
|
- `npm run electron:dev` - Run both Vite dev server and Electron concurrently
|
||||||
- `npm run electron:build` - Full build and package for Electron
|
- `npm run electron:build` - Full build and package for Electron
|
||||||
- `npm run start` - Start Electron using Forge
|
- `npm run start` - Start Electron using Forge
|
||||||
|
|
@ -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:
|
The application uses a plugin-based modular architecture with dependency injection for service management:
|
||||||
|
|
||||||
**Core Modules:**
|
**Core Modules:**
|
||||||
- **Base Module** (`src/modules/base/`) - Core infrastructure (Nostr, Auth, PWA)
|
- **Base Module** (`src/modules/base/`) - Core infrastructure (Nostr, Auth, PWA, Image Upload)
|
||||||
- **Wallet Module** (`src/modules/wallet/`) - Lightning wallet management with real-time balance updates
|
- **Wallet Module** (`src/modules/wallet/`) - Lightning wallet management with real-time balance updates
|
||||||
- **Nostr Feed Module** (`src/modules/nostr-feed/`) - Social feed functionality
|
- **Nostr Feed Module** (`src/modules/nostr-feed/`) - Social feed functionality
|
||||||
- **Chat Module** (`src/modules/chat/`) - Encrypted Nostr chat
|
- **Chat Module** (`src/modules/chat/`) - Encrypted Nostr chat
|
||||||
|
|
@ -90,6 +90,12 @@ const relayHub = injectService(SERVICE_TOKENS.RELAY_HUB)
|
||||||
- `SERVICE_TOKENS.VISIBILITY_SERVICE` - App visibility and connection management
|
- `SERVICE_TOKENS.VISIBILITY_SERVICE` - App visibility and connection management
|
||||||
- `SERVICE_TOKENS.WALLET_SERVICE` - Wallet operations (send, receive, transactions)
|
- `SERVICE_TOKENS.WALLET_SERVICE` - Wallet operations (send, receive, transactions)
|
||||||
- `SERVICE_TOKENS.WALLET_WEBSOCKET_SERVICE` - Real-time wallet balance updates via WebSocket
|
- `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:**
|
**Core Stack:**
|
||||||
- Vue 3 with Composition API (`<script setup>` style)
|
- Vue 3 with Composition API (`<script setup>` style)
|
||||||
|
|
@ -122,6 +128,8 @@ const relayHub = injectService(SERVICE_TOKENS.RELAY_HUB)
|
||||||
- `api/` - API integrations
|
- `api/` - API integrations
|
||||||
- `types/` - TypeScript type definitions
|
- `types/` - TypeScript type definitions
|
||||||
- `src/pages/` - Route pages
|
- `src/pages/` - Route pages
|
||||||
|
- `src/modules/` - Modular feature implementations
|
||||||
|
- `src/core/` - Core infrastructure (DI, BaseService, plugin manager)
|
||||||
- `electron/` - Electron main process code
|
- `electron/` - Electron main process code
|
||||||
|
|
||||||
**Lightning Wallet Integration:**
|
**Lightning Wallet Integration:**
|
||||||
|
|
@ -143,8 +151,10 @@ The app integrates with LNbits for Lightning Network wallet functionality with r
|
||||||
```typescript
|
```typescript
|
||||||
websocket: {
|
websocket: {
|
||||||
enabled: true, // Enable/disable WebSocket functionality
|
enabled: true, // Enable/disable WebSocket functionality
|
||||||
reconnectDelay: 1000, // Initial reconnection delay
|
reconnectDelay: 2000, // Initial reconnection delay (ms)
|
||||||
maxReconnectAttempts: 5 // Maximum reconnection attempts
|
maxReconnectAttempts: 3, // Maximum reconnection attempts
|
||||||
|
fallbackToPolling: true, // Enable polling fallback when WebSocket fails
|
||||||
|
pollingInterval: 10000 // Polling interval (ms)
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
|
|
@ -184,12 +194,12 @@ export const myModule: ModulePlugin = {
|
||||||
name: 'my-module',
|
name: 'my-module',
|
||||||
version: '1.0.0',
|
version: '1.0.0',
|
||||||
dependencies: ['base'], // Always depend on base for core services
|
dependencies: ['base'], // Always depend on base for core services
|
||||||
|
|
||||||
async install(app: App, options?: { config?: MyModuleConfig }) {
|
async install(app: App, options?: { config?: MyModuleConfig }) {
|
||||||
// Module installation logic
|
// Module installation logic
|
||||||
// Register components, initialize services, etc.
|
// Register components, initialize services, etc.
|
||||||
},
|
},
|
||||||
|
|
||||||
routes: [/* module routes */],
|
routes: [/* module routes */],
|
||||||
components: {/* exported components */},
|
components: {/* exported components */},
|
||||||
composables: {/* exported composables */}
|
composables: {/* exported composables */}
|
||||||
|
|
@ -229,6 +239,88 @@ export const myModule: ModulePlugin = {
|
||||||
- Module configs in `src/app.config.ts`
|
- Module configs in `src/app.config.ts`
|
||||||
- Centralized config parsing and validation
|
- 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**
|
### **Form Implementation Standards**
|
||||||
|
|
||||||
**CRITICAL: Always use Shadcn/UI Form Components with vee-validate**
|
**CRITICAL: Always use Shadcn/UI Form Components with vee-validate**
|
||||||
|
|
@ -289,7 +381,7 @@ const onSubmit = form.handleSubmit(async (values) => {
|
||||||
<FormItem>
|
<FormItem>
|
||||||
<FormLabel>Name *</FormLabel>
|
<FormLabel>Name *</FormLabel>
|
||||||
<FormControl>
|
<FormControl>
|
||||||
<Input
|
<Input
|
||||||
placeholder="Enter name"
|
placeholder="Enter name"
|
||||||
v-bind="componentField"
|
v-bind="componentField"
|
||||||
/>
|
/>
|
||||||
|
|
@ -330,7 +422,7 @@ const onSubmit = form.handleSubmit(async (values) => {
|
||||||
</FormField>
|
</FormField>
|
||||||
|
|
||||||
<!-- Submit Button -->
|
<!-- Submit Button -->
|
||||||
<Button
|
<Button
|
||||||
type="submit"
|
type="submit"
|
||||||
:disabled="isLoading || !isFormValid"
|
:disabled="isLoading || !isFormValid"
|
||||||
>
|
>
|
||||||
|
|
@ -382,35 +474,44 @@ 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
|
- ✅ **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
|
- ❌ **Don't Mix**: Never mix checked/model-value patterns - they have different behaviors
|
||||||
|
|
||||||
**Reference**: [Vue.js Forms Documentation](https://vuejs.org/guide/essentials/forms.html)
|
### **CSS and Styling Guidelines**
|
||||||
|
|
||||||
**❌ NEVER do this:**
|
**CRITICAL: Always use semantic, theme-aware CSS classes**
|
||||||
```vue
|
|
||||||
<!-- Wrong: Manual form handling without vee-validate -->
|
|
||||||
<form @submit.prevent="handleSubmit">
|
|
||||||
|
|
||||||
<!-- Wrong: Direct v-model bypasses form validation -->
|
|
||||||
<Input v-model="myValue" />
|
|
||||||
|
|
||||||
<!-- Wrong: Manual validation instead of using meta.valid -->
|
- ✅ **Use semantic classes** that automatically adapt to light/dark themes
|
||||||
<Button :disabled="!name || !email">Submit</Button>
|
- ❌ **Never use hard-coded colors** like `bg-white`, `text-gray-500`, `border-blue-500`
|
||||||
|
|
||||||
|
**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 */
|
||||||
```
|
```
|
||||||
|
|
||||||
**✅ ALWAYS do this:**
|
**Why Semantic Classes:**
|
||||||
```vue
|
- Ensures components work in both light and dark themes
|
||||||
<!-- Correct: Uses form.handleSubmit for proper form handling -->
|
- Maintains consistency with Shadcn/ui component library
|
||||||
<form @submit="onSubmit">
|
- Easier to maintain and update theme colors globally
|
||||||
|
- Better accessibility
|
||||||
<!-- 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**
|
### **Vue Reactivity Best Practices**
|
||||||
|
|
||||||
|
|
@ -463,23 +564,32 @@ createdObject.value = Object.assign({}, apiResponse)
|
||||||
- ✅ Input components showing external data
|
- ✅ Input components showing external data
|
||||||
- ✅ Any scenario where template doesn't update after data changes
|
- ✅ Any scenario where template doesn't update after data changes
|
||||||
|
|
||||||
**Example from Wallet Module:**
|
### **⚠️ CRITICAL: JavaScript Falsy Value Bug Prevention**
|
||||||
|
|
||||||
|
**ALWAYS use nullish coalescing (`??`) instead of logical OR (`||`) for numeric defaults:**
|
||||||
|
|
||||||
```typescript
|
```typescript
|
||||||
// Service returns complex invoice object
|
// ❌ WRONG: Treats 0 as falsy, defaults to 1 even when quantity is validly 0
|
||||||
const invoice = await walletService.createInvoice(data)
|
quantity: productData.quantity || 1
|
||||||
|
|
||||||
// Force reactivity for template updates
|
// ✅ CORRECT: Only defaults to 1 when quantity is null or undefined
|
||||||
createdInvoice.value = Object.assign({}, invoice)
|
quantity: productData.quantity ?? 1
|
||||||
```
|
```
|
||||||
|
|
||||||
```vue
|
**Why this matters:**
|
||||||
<!-- Template with forced reactivity -->
|
- JavaScript falsy values include: `false`, `0`, `""`, `null`, `undefined`, `NaN`
|
||||||
<Input
|
- Using `||` for defaults will incorrectly override valid `0` values
|
||||||
:key="`bolt11-${createdInvoice?.payment_hash}`"
|
- This caused a critical bug where products with quantity `0` displayed as quantity `1`
|
||||||
:model-value="createdInvoice?.payment_request || ''"
|
- The `??` operator only triggers for `null` and `undefined`, preserving valid `0` values
|
||||||
readonly
|
|
||||||
/>
|
**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)
|
||||||
|
|
||||||
### **Module Development Best Practices**
|
### **Module Development Best Practices**
|
||||||
|
|
||||||
|
|
@ -530,166 +640,6 @@ Before considering any module complete, verify ALL items:
|
||||||
- [ ] Configuration is properly loaded
|
- [ ] Configuration is properly loaded
|
||||||
- [ ] Module can be disabled via config
|
- [ ] 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:**
|
**Nostr Integration Rules:**
|
||||||
1. **NEVER create separate relay connections** - always use the central RelayHub
|
1. **NEVER create separate relay connections** - always use the central RelayHub
|
||||||
2. **Access RelayHub through DI**: `const relayHub = injectService(SERVICE_TOKENS.RELAY_HUB)`
|
2. **Access RelayHub through DI**: `const relayHub = injectService(SERVICE_TOKENS.RELAY_HUB)`
|
||||||
|
|
@ -710,7 +660,7 @@ export function useMyModule() {
|
||||||
const relayHub = injectService(SERVICE_TOKENS.RELAY_HUB)
|
const relayHub = injectService(SERVICE_TOKENS.RELAY_HUB)
|
||||||
const authService = injectService(SERVICE_TOKENS.AUTH_SERVICE)
|
const authService = injectService(SERVICE_TOKENS.AUTH_SERVICE)
|
||||||
const myAPI = injectService(SERVICE_TOKENS.MY_API)
|
const myAPI = injectService(SERVICE_TOKENS.MY_API)
|
||||||
|
|
||||||
// Never import services directly
|
// Never import services directly
|
||||||
// ❌ import { relayHub } from '@/modules/base/nostr/relay-hub'
|
// ❌ import { relayHub } from '@/modules/base/nostr/relay-hub'
|
||||||
// ✅ const relayHub = injectService(SERVICE_TOKENS.RELAY_HUB)
|
// ✅ const relayHub = injectService(SERVICE_TOKENS.RELAY_HUB)
|
||||||
|
|
@ -735,44 +685,34 @@ export function useMyModule() {
|
||||||
- **ALWAYS use Shadcn Form components for all form implementations**
|
- **ALWAYS use Shadcn Form components for all form implementations**
|
||||||
- **ALWAYS extend BaseService for module services**
|
- **ALWAYS extend BaseService for module services**
|
||||||
- **NEVER create direct dependencies between modules**
|
- **NEVER create direct dependencies between modules**
|
||||||
|
- **ALWAYS use semantic CSS classes, never hard-coded colors**
|
||||||
|
|
||||||
### **⚠️ CRITICAL: JavaScript Falsy Value Bug Prevention**
|
### **Build Configuration:**
|
||||||
|
|
||||||
**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
|
- Vite config includes PWA, image optimization, and bundle analysis
|
||||||
- Manual chunking strategy for vendor libraries (vue-vendor, ui-vendor, shadcn)
|
- Manual chunking strategy for vendor libraries (vue-vendor, ui-vendor, shadcn)
|
||||||
- Electron Forge configured for cross-platform packaging
|
- Electron Forge configured for cross-platform packaging
|
||||||
- TailwindCSS v4 integration via Vite plugin
|
- TailwindCSS v4 integration via Vite plugin
|
||||||
|
|
||||||
**Environment:**
|
### **Environment Variables:**
|
||||||
- Nostr relay configuration via `VITE_NOSTR_RELAYS` environment variable
|
|
||||||
- PWA manifest configured for standalone app experience
|
Required environment variables in `.env`:
|
||||||
- Service worker with automatic updates every hour
|
|
||||||
|
```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
|
||||||
|
```
|
||||||
|
|
||||||
## Mobile Browser File Input & Form Refresh Issues
|
## Mobile Browser File Input & Form Refresh Issues
|
||||||
|
|
||||||
|
|
@ -906,86 +846,6 @@ window.addEventListener('beforeunload', blockNavigation)
|
||||||
<form @submit.prevent="handleSubmit">
|
<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**
|
### **Key Takeaways**
|
||||||
|
|
||||||
1. **This is a systemic mobile browser issue**, not a bug in our application code
|
1. **This is a systemic mobile browser issue**, not a bug in our application code
|
||||||
|
|
@ -995,4 +855,4 @@ console.warn('Visibility change detected while form is open')
|
||||||
5. **Separate camera/gallery inputs** are required for proper Android browser support
|
5. **Separate camera/gallery inputs** are required for proper Android browser support
|
||||||
6. **The defensive measures are working correctly** when users can choose to prevent navigation
|
6. **The defensive measures are working correctly** when users can choose to prevent navigation
|
||||||
|
|
||||||
**⚠️ IMPORTANT**: These issues are intermittent by nature. The defensive programming approach ensures that when they do occur, users have the opportunity to save their work instead of losing form data.
|
**⚠️ IMPORTANT**: These issues are intermittent by nature. The defensive programming approach ensures that when they do occur, users have the opportunity to save their work instead of losing form data.
|
||||||
|
|
|
||||||
57
package-lock.json
generated
57
package-lock.json
generated
|
|
@ -25,7 +25,7 @@
|
||||||
"qr-scanner": "^1.4.2",
|
"qr-scanner": "^1.4.2",
|
||||||
"qrcode": "^1.5.4",
|
"qrcode": "^1.5.4",
|
||||||
"radix-vue": "^1.9.13",
|
"radix-vue": "^1.9.13",
|
||||||
"reka-ui": "^2.5.0",
|
"reka-ui": "^2.6.0",
|
||||||
"tailwind-merge": "^2.6.0",
|
"tailwind-merge": "^2.6.0",
|
||||||
"tailwindcss-animate": "^1.0.7",
|
"tailwindcss-animate": "^1.0.7",
|
||||||
"unique-names-generator": "^4.7.1",
|
"unique-names-generator": "^4.7.1",
|
||||||
|
|
@ -141,6 +141,7 @@
|
||||||
"integrity": "sha512-l+lkXCHS6tQEc5oUpK28xBOZ6+HwaH7YwoYQbLFiYb4nS2/l1tKnZEtEWkD0GuiYdvArf9qBS0XlQGXzPMsNqQ==",
|
"integrity": "sha512-l+lkXCHS6tQEc5oUpK28xBOZ6+HwaH7YwoYQbLFiYb4nS2/l1tKnZEtEWkD0GuiYdvArf9qBS0XlQGXzPMsNqQ==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
"peer": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@ampproject/remapping": "^2.2.0",
|
"@ampproject/remapping": "^2.2.0",
|
||||||
"@babel/code-frame": "^7.26.2",
|
"@babel/code-frame": "^7.26.2",
|
||||||
|
|
@ -2646,6 +2647,7 @@
|
||||||
"integrity": "sha512-zx0EIq78WlY/lBb1uXlziZmDZI4ubcCXIMJ4uGjXzZW0nS19TjSPeXPAjzzTmKQlJUZm0SbmZhPKP7tuQ1SsEw==",
|
"integrity": "sha512-zx0EIq78WlY/lBb1uXlziZmDZI4ubcCXIMJ4uGjXzZW0nS19TjSPeXPAjzzTmKQlJUZm0SbmZhPKP7tuQ1SsEw==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
"peer": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"chalk": "^4.1.1",
|
"chalk": "^4.1.1",
|
||||||
"fs-extra": "^9.0.1",
|
"fs-extra": "^9.0.1",
|
||||||
|
|
@ -5688,6 +5690,7 @@
|
||||||
"integrity": "sha512-B/gBuNg5SiMTrPkC+A2+cW0RszwxYmn6VYxB/inlBStS5nx6xHIt/ehKRhIMhqusl7a8LjQoZnjCs5vhwxOQ1g==",
|
"integrity": "sha512-B/gBuNg5SiMTrPkC+A2+cW0RszwxYmn6VYxB/inlBStS5nx6xHIt/ehKRhIMhqusl7a8LjQoZnjCs5vhwxOQ1g==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
"peer": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"fast-deep-equal": "^3.1.3",
|
"fast-deep-equal": "^3.1.3",
|
||||||
"fast-uri": "^3.0.1",
|
"fast-uri": "^3.0.1",
|
||||||
|
|
@ -5850,14 +5853,6 @@
|
||||||
"node": ">= 0.4"
|
"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": {
|
"node_modules/at-least-node": {
|
||||||
"version": "1.0.0",
|
"version": "1.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/at-least-node/-/at-least-node-1.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/at-least-node/-/at-least-node-1.0.0.tgz",
|
||||||
|
|
@ -6058,6 +6053,7 @@
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
"peer": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"caniuse-lite": "^1.0.30001688",
|
"caniuse-lite": "^1.0.30001688",
|
||||||
"electron-to-chromium": "^1.5.73",
|
"electron-to-chromium": "^1.5.73",
|
||||||
|
|
@ -7604,17 +7600,6 @@
|
||||||
"integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==",
|
"integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==",
|
||||||
"license": "MIT"
|
"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": {
|
"node_modules/end-of-stream": {
|
||||||
"version": "1.4.4",
|
"version": "1.4.4",
|
||||||
"resolved": "https://registry.npmjs.org/end-of-stream/-/end-of-stream-1.4.4.tgz",
|
"resolved": "https://registry.npmjs.org/end-of-stream/-/end-of-stream-1.4.4.tgz",
|
||||||
|
|
@ -8368,6 +8353,7 @@
|
||||||
"resolved": "https://registry.npmjs.org/fuse.js/-/fuse.js-7.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/fuse.js/-/fuse.js-7.0.0.tgz",
|
||||||
"integrity": "sha512-14F4hBIxqKvD4Zz/XjDc3y94mNZN6pRv3U13Udo0lNLCWRBUsrMv2xwcF/y/Z5sV6+FQW+/ow68cHpm4sunt8Q==",
|
"integrity": "sha512-14F4hBIxqKvD4Zz/XjDc3y94mNZN6pRv3U13Udo0lNLCWRBUsrMv2xwcF/y/Z5sV6+FQW+/ow68cHpm4sunt8Q==",
|
||||||
"license": "Apache-2.0",
|
"license": "Apache-2.0",
|
||||||
|
"peer": true,
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=10"
|
"node": ">=10"
|
||||||
}
|
}
|
||||||
|
|
@ -8904,20 +8890,6 @@
|
||||||
"ms": "^2.0.0"
|
"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": {
|
"node_modules/idb": {
|
||||||
"version": "7.1.1",
|
"version": "7.1.1",
|
||||||
"resolved": "https://registry.npmjs.org/idb/-/idb-7.1.1.tgz",
|
"resolved": "https://registry.npmjs.org/idb/-/idb-7.1.1.tgz",
|
||||||
|
|
@ -11718,6 +11690,7 @@
|
||||||
"resolved": "https://registry.npmjs.org/qrcode/-/qrcode-1.5.4.tgz",
|
"resolved": "https://registry.npmjs.org/qrcode/-/qrcode-1.5.4.tgz",
|
||||||
"integrity": "sha512-1ca71Zgiu6ORjHqFBDpnSMTR2ReToX4l1Au1VFLyVeBTFavzQnv5JxMFr3ukHVKpSrSA2MCk0lNJSykjUfz7Zg==",
|
"integrity": "sha512-1ca71Zgiu6ORjHqFBDpnSMTR2ReToX4l1Au1VFLyVeBTFavzQnv5JxMFr3ukHVKpSrSA2MCk0lNJSykjUfz7Zg==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
"peer": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"dijkstrajs": "^1.0.1",
|
"dijkstrajs": "^1.0.1",
|
||||||
"pngjs": "^5.0.0",
|
"pngjs": "^5.0.0",
|
||||||
|
|
@ -12180,9 +12153,9 @@
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/reka-ui": {
|
"node_modules/reka-ui": {
|
||||||
"version": "2.5.0",
|
"version": "2.6.0",
|
||||||
"resolved": "https://registry.npmjs.org/reka-ui/-/reka-ui-2.5.0.tgz",
|
"resolved": "https://registry.npmjs.org/reka-ui/-/reka-ui-2.6.0.tgz",
|
||||||
"integrity": "sha512-81aMAmJeVCy2k0E6x7n1kypDY6aM1ldLis5+zcdV1/JtoAlSDck5OBsyLRJU9CfgbrQp1ImnRnBSmC4fZ2fkZQ==",
|
"integrity": "sha512-NrGMKrABD97l890mFS3TNUzB0BLUfbL3hh0NjcJRIUSUljb288bx3Mzo31nOyUcdiiW0HqFGXJwyCBh9cWgb0w==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@floating-ui/dom": "^1.6.13",
|
"@floating-ui/dom": "^1.6.13",
|
||||||
|
|
@ -12361,6 +12334,7 @@
|
||||||
"integrity": "sha512-4iya7Jb76fVpQyLoiVpzUrsjQ12r3dM7fIVz+4NwoYvZOShknRmiv+iu9CClZml5ZLGb0XMcYLutK6w9tgxHDw==",
|
"integrity": "sha512-4iya7Jb76fVpQyLoiVpzUrsjQ12r3dM7fIVz+4NwoYvZOShknRmiv+iu9CClZml5ZLGb0XMcYLutK6w9tgxHDw==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
"peer": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@types/estree": "1.0.8"
|
"@types/estree": "1.0.8"
|
||||||
},
|
},
|
||||||
|
|
@ -13370,7 +13344,8 @@
|
||||||
"version": "4.0.12",
|
"version": "4.0.12",
|
||||||
"resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-4.0.12.tgz",
|
"resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-4.0.12.tgz",
|
||||||
"integrity": "sha512-bT0hJo91FtncsAMSsMzUkoo/iEU0Xs5xgFgVC9XmdM9bw5MhZuQFjPNl6wxAE0SiQF/YTZJa+PndGWYSDtuxAg==",
|
"integrity": "sha512-bT0hJo91FtncsAMSsMzUkoo/iEU0Xs5xgFgVC9XmdM9bw5MhZuQFjPNl6wxAE0SiQF/YTZJa+PndGWYSDtuxAg==",
|
||||||
"license": "MIT"
|
"license": "MIT",
|
||||||
|
"peer": true
|
||||||
},
|
},
|
||||||
"node_modules/tailwindcss-animate": {
|
"node_modules/tailwindcss-animate": {
|
||||||
"version": "1.0.7",
|
"version": "1.0.7",
|
||||||
|
|
@ -13505,6 +13480,7 @@
|
||||||
"integrity": "sha512-GWANVlPM/ZfYzuPHjq0nxT+EbOEDDN3Jwhwdg1D8TU8oSkktp8w64Uq4auuGLxFSoNTRDncTq2hQHX1Ld9KHkA==",
|
"integrity": "sha512-GWANVlPM/ZfYzuPHjq0nxT+EbOEDDN3Jwhwdg1D8TU8oSkktp8w64Uq4auuGLxFSoNTRDncTq2hQHX1Ld9KHkA==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "BSD-2-Clause",
|
"license": "BSD-2-Clause",
|
||||||
|
"peer": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@jridgewell/source-map": "^0.3.3",
|
"@jridgewell/source-map": "^0.3.3",
|
||||||
"acorn": "^8.8.2",
|
"acorn": "^8.8.2",
|
||||||
|
|
@ -13734,6 +13710,7 @@
|
||||||
"integrity": "sha512-hjcS1mhfuyi4WW8IWtjP7brDrG2cuDZukyrYrSauoXGNgx0S7zceP07adYkJycEr56BOUTNPzbInooiN3fn1qw==",
|
"integrity": "sha512-hjcS1mhfuyi4WW8IWtjP7brDrG2cuDZukyrYrSauoXGNgx0S7zceP07adYkJycEr56BOUTNPzbInooiN3fn1qw==",
|
||||||
"devOptional": true,
|
"devOptional": true,
|
||||||
"license": "Apache-2.0",
|
"license": "Apache-2.0",
|
||||||
|
"peer": true,
|
||||||
"bin": {
|
"bin": {
|
||||||
"tsc": "bin/tsc",
|
"tsc": "bin/tsc",
|
||||||
"tsserver": "bin/tsserver"
|
"tsserver": "bin/tsserver"
|
||||||
|
|
@ -13984,6 +13961,7 @@
|
||||||
"integrity": "sha512-cZn6NDFE7wdTpINgs++ZJ4N49W2vRp8LCKrn3Ob1kYNtOo21vfDoaV5GzBfLU4MovSAB8uNRm4jgzVQZ+mBzPQ==",
|
"integrity": "sha512-cZn6NDFE7wdTpINgs++ZJ4N49W2vRp8LCKrn3Ob1kYNtOo21vfDoaV5GzBfLU4MovSAB8uNRm4jgzVQZ+mBzPQ==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
"peer": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"esbuild": "^0.25.0",
|
"esbuild": "^0.25.0",
|
||||||
"fdir": "^6.4.4",
|
"fdir": "^6.4.4",
|
||||||
|
|
@ -14208,6 +14186,7 @@
|
||||||
"resolved": "https://registry.npmjs.org/vue/-/vue-3.5.13.tgz",
|
"resolved": "https://registry.npmjs.org/vue/-/vue-3.5.13.tgz",
|
||||||
"integrity": "sha512-wmeiSMxkZCSc+PM2w2VRsOYAZC8GdipNFRTsLSfodVqI9mbejKeXEGr8SckuLnrQPGe3oJN5c3K0vpoU9q/wCQ==",
|
"integrity": "sha512-wmeiSMxkZCSc+PM2w2VRsOYAZC8GdipNFRTsLSfodVqI9mbejKeXEGr8SckuLnrQPGe3oJN5c3K0vpoU9q/wCQ==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
"peer": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@vue/compiler-dom": "3.5.13",
|
"@vue/compiler-dom": "3.5.13",
|
||||||
"@vue/compiler-sfc": "3.5.13",
|
"@vue/compiler-sfc": "3.5.13",
|
||||||
|
|
@ -14660,6 +14639,7 @@
|
||||||
"integrity": "sha512-fS6iqSPZDs3dr/y7Od6y5nha8dW1YnbgtsyotCVvoFGKbERG++CVRFv1meyGDE1SNItQA8BrnCw7ScdAhRJ3XQ==",
|
"integrity": "sha512-fS6iqSPZDs3dr/y7Od6y5nha8dW1YnbgtsyotCVvoFGKbERG++CVRFv1meyGDE1SNItQA8BrnCw7ScdAhRJ3XQ==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
"peer": true,
|
||||||
"bin": {
|
"bin": {
|
||||||
"rollup": "dist/bin/rollup"
|
"rollup": "dist/bin/rollup"
|
||||||
},
|
},
|
||||||
|
|
@ -14917,6 +14897,7 @@
|
||||||
"resolved": "https://registry.npmjs.org/zod/-/zod-3.25.76.tgz",
|
"resolved": "https://registry.npmjs.org/zod/-/zod-3.25.76.tgz",
|
||||||
"integrity": "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==",
|
"integrity": "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
"peer": true,
|
||||||
"funding": {
|
"funding": {
|
||||||
"url": "https://github.com/sponsors/colinhacks"
|
"url": "https://github.com/sponsors/colinhacks"
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -34,7 +34,7 @@
|
||||||
"qr-scanner": "^1.4.2",
|
"qr-scanner": "^1.4.2",
|
||||||
"qrcode": "^1.5.4",
|
"qrcode": "^1.5.4",
|
||||||
"radix-vue": "^1.9.13",
|
"radix-vue": "^1.9.13",
|
||||||
"reka-ui": "^2.5.0",
|
"reka-ui": "^2.6.0",
|
||||||
"tailwind-merge": "^2.6.0",
|
"tailwind-merge": "^2.6.0",
|
||||||
"tailwindcss-animate": "^1.0.7",
|
"tailwindcss-animate": "^1.0.7",
|
||||||
"unique-names-generator": "^4.7.1",
|
"unique-names-generator": "^4.7.1",
|
||||||
|
|
|
||||||
|
|
@ -93,6 +93,20 @@ export const appConfig: AppConfig = {
|
||||||
pollingInterval: 10000 // 10 seconds for polling updates
|
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
|
||||||
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
|
|
|
||||||
11
src/app.ts
11
src/app.ts
|
|
@ -16,6 +16,7 @@ import chatModule from './modules/chat'
|
||||||
import eventsModule from './modules/events'
|
import eventsModule from './modules/events'
|
||||||
import marketModule from './modules/market'
|
import marketModule from './modules/market'
|
||||||
import walletModule from './modules/wallet'
|
import walletModule from './modules/wallet'
|
||||||
|
import expensesModule from './modules/expenses'
|
||||||
|
|
||||||
// Root component
|
// Root component
|
||||||
import App from './App.vue'
|
import App from './App.vue'
|
||||||
|
|
@ -43,7 +44,8 @@ export async function createAppInstance() {
|
||||||
...chatModule.routes || [],
|
...chatModule.routes || [],
|
||||||
...eventsModule.routes || [],
|
...eventsModule.routes || [],
|
||||||
...marketModule.routes || [],
|
...marketModule.routes || [],
|
||||||
...walletModule.routes || []
|
...walletModule.routes || [],
|
||||||
|
...expensesModule.routes || []
|
||||||
].filter(Boolean)
|
].filter(Boolean)
|
||||||
|
|
||||||
// Create router with all routes available immediately
|
// Create router with all routes available immediately
|
||||||
|
|
@ -126,6 +128,13 @@ 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
|
// Wait for all modules to register
|
||||||
await Promise.all(moduleRegistrations)
|
await Promise.all(moduleRegistrations)
|
||||||
|
|
||||||
|
|
|
||||||
15
src/components/ui/alert-dialog/AlertDialog.vue
Normal file
15
src/components/ui/alert-dialog/AlertDialog.vue
Normal file
|
|
@ -0,0 +1,15 @@
|
||||||
|
<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>
|
||||||
18
src/components/ui/alert-dialog/AlertDialogAction.vue
Normal file
18
src/components/ui/alert-dialog/AlertDialogAction.vue
Normal file
|
|
@ -0,0 +1,18 @@
|
||||||
|
<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>
|
||||||
25
src/components/ui/alert-dialog/AlertDialogCancel.vue
Normal file
25
src/components/ui/alert-dialog/AlertDialogCancel.vue
Normal file
|
|
@ -0,0 +1,25 @@
|
||||||
|
<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>
|
||||||
39
src/components/ui/alert-dialog/AlertDialogContent.vue
Normal file
39
src/components/ui/alert-dialog/AlertDialogContent.vue
Normal file
|
|
@ -0,0 +1,39 @@
|
||||||
|
<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>
|
||||||
23
src/components/ui/alert-dialog/AlertDialogDescription.vue
Normal file
23
src/components/ui/alert-dialog/AlertDialogDescription.vue
Normal file
|
|
@ -0,0 +1,23 @@
|
||||||
|
<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>
|
||||||
21
src/components/ui/alert-dialog/AlertDialogFooter.vue
Normal file
21
src/components/ui/alert-dialog/AlertDialogFooter.vue
Normal file
|
|
@ -0,0 +1,21 @@
|
||||||
|
<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>
|
||||||
16
src/components/ui/alert-dialog/AlertDialogHeader.vue
Normal file
16
src/components/ui/alert-dialog/AlertDialogHeader.vue
Normal file
|
|
@ -0,0 +1,16 @@
|
||||||
|
<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>
|
||||||
20
src/components/ui/alert-dialog/AlertDialogTitle.vue
Normal file
20
src/components/ui/alert-dialog/AlertDialogTitle.vue
Normal file
|
|
@ -0,0 +1,20 @@
|
||||||
|
<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>
|
||||||
12
src/components/ui/alert-dialog/AlertDialogTrigger.vue
Normal file
12
src/components/ui/alert-dialog/AlertDialogTrigger.vue
Normal file
|
|
@ -0,0 +1,12 @@
|
||||||
|
<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>
|
||||||
9
src/components/ui/alert-dialog/index.ts
Normal file
9
src/components/ui/alert-dialog/index.ts
Normal file
|
|
@ -0,0 +1,9 @@
|
||||||
|
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"
|
||||||
|
|
@ -1,22 +1,25 @@
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import type { HTMLAttributes } from 'vue'
|
import type { PrimitiveProps } from "reka-ui"
|
||||||
import { cn } from '@/lib/utils'
|
import type { HTMLAttributes } from "vue"
|
||||||
import { Primitive, type PrimitiveProps } from 'reka-ui'
|
import type { ButtonVariants } from "."
|
||||||
import { type ButtonVariants, buttonVariants } from '.'
|
import { Primitive } from "reka-ui"
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
import { buttonVariants } from "."
|
||||||
|
|
||||||
interface Props extends PrimitiveProps {
|
interface Props extends PrimitiveProps {
|
||||||
variant?: ButtonVariants['variant']
|
variant?: ButtonVariants["variant"]
|
||||||
size?: ButtonVariants['size']
|
size?: ButtonVariants["size"]
|
||||||
class?: HTMLAttributes['class']
|
class?: HTMLAttributes["class"]
|
||||||
}
|
}
|
||||||
|
|
||||||
const props = withDefaults(defineProps<Props>(), {
|
const props = withDefaults(defineProps<Props>(), {
|
||||||
as: 'button',
|
as: "button",
|
||||||
})
|
})
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<Primitive
|
<Primitive
|
||||||
|
data-slot="button"
|
||||||
:as="as"
|
:as="as"
|
||||||
:as-child="asChild"
|
:as-child="asChild"
|
||||||
:class="cn(buttonVariants({ variant, size }), props.class)"
|
:class="cn(buttonVariants({ variant, size }), props.class)"
|
||||||
|
|
|
||||||
|
|
@ -1,33 +1,37 @@
|
||||||
import { cva, type VariantProps } from 'class-variance-authority'
|
import type { VariantProps } from "class-variance-authority"
|
||||||
|
import { cva } from "class-variance-authority"
|
||||||
|
|
||||||
export { default as Button } from './Button.vue'
|
export { default as Button } from "./Button.vue"
|
||||||
|
|
||||||
export const buttonVariants = cva(
|
export const buttonVariants = cva(
|
||||||
'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',
|
"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",
|
||||||
{
|
{
|
||||||
variants: {
|
variants: {
|
||||||
variant: {
|
variant: {
|
||||||
default: 'bg-primary text-primary-foreground shadow-sm hover:bg-primary/90',
|
default:
|
||||||
|
"bg-primary text-primary-foreground shadow-xs hover:bg-primary/90",
|
||||||
destructive:
|
destructive:
|
||||||
'bg-destructive text-destructive-foreground shadow-xs hover:bg-destructive/90',
|
"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",
|
||||||
outline:
|
outline:
|
||||||
'border border-input bg-background shadow-xs hover:bg-accent hover:text-accent-foreground',
|
"border bg-background shadow-xs hover:bg-accent hover:text-accent-foreground dark:bg-input/30 dark:border-input dark:hover:bg-input/50",
|
||||||
secondary:
|
secondary:
|
||||||
'bg-secondary text-secondary-foreground shadow-xs hover:bg-secondary/80',
|
"bg-secondary text-secondary-foreground shadow-xs hover:bg-secondary/80",
|
||||||
ghost: 'hover:bg-accent hover:text-accent-foreground',
|
ghost:
|
||||||
link: 'text-primary underline-offset-4 hover:underline',
|
"hover:bg-accent hover:text-accent-foreground dark:hover:bg-accent/50",
|
||||||
|
link: "text-primary underline-offset-4 hover:underline",
|
||||||
},
|
},
|
||||||
size: {
|
size: {
|
||||||
default: 'h-9 px-4 py-2',
|
"default": "h-9 px-4 py-2 has-[>svg]:px-3",
|
||||||
xs: 'h-7 rounded px-2',
|
"sm": "h-8 rounded-md gap-1.5 px-3 has-[>svg]:px-2.5",
|
||||||
sm: 'h-8 rounded-md px-3 text-xs',
|
"lg": "h-10 rounded-md px-6 has-[>svg]:px-4",
|
||||||
lg: 'h-10 rounded-md px-8',
|
"icon": "size-9",
|
||||||
icon: 'h-9 w-9',
|
"icon-sm": "size-8",
|
||||||
|
"icon-lg": "size-10",
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
defaultVariants: {
|
defaultVariants: {
|
||||||
variant: 'default',
|
variant: "default",
|
||||||
size: 'default',
|
size: "default",
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
|
|
||||||
|
|
@ -61,30 +61,40 @@ export function useModularNavigation() {
|
||||||
|
|
||||||
// Events module items
|
// Events module items
|
||||||
if (appConfig.modules.events.enabled) {
|
if (appConfig.modules.events.enabled) {
|
||||||
items.push({
|
items.push({
|
||||||
name: 'My Tickets',
|
name: 'My Tickets',
|
||||||
href: '/my-tickets',
|
href: '/my-tickets',
|
||||||
icon: 'Ticket',
|
icon: 'Ticket',
|
||||||
requiresAuth: true
|
requiresAuth: true
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
// Market module items
|
// Market module items
|
||||||
if (appConfig.modules.market.enabled) {
|
if (appConfig.modules.market.enabled) {
|
||||||
items.push({
|
items.push({
|
||||||
name: 'Market Dashboard',
|
name: 'Market Dashboard',
|
||||||
href: '/market-dashboard',
|
href: '/market-dashboard',
|
||||||
icon: 'Store',
|
icon: 'Store',
|
||||||
requiresAuth: true
|
requiresAuth: true
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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)
|
// Base module items (always available)
|
||||||
items.push({
|
items.push({
|
||||||
name: 'Relay Hub Status',
|
name: 'Relay Hub Status',
|
||||||
href: '/relay-hub-status',
|
href: '/relay-hub-status',
|
||||||
icon: 'Activity',
|
icon: 'Activity',
|
||||||
requiresAuth: true
|
requiresAuth: true
|
||||||
})
|
})
|
||||||
|
|
||||||
return items
|
return items
|
||||||
|
|
|
||||||
95
src/composables/useQuickActions.ts
Normal file
95
src/composables/useQuickActions.ts
Normal file
|
|
@ -0,0 +1,95 @@
|
||||||
|
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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -136,6 +136,7 @@ export const SERVICE_TOKENS = {
|
||||||
FEED_SERVICE: Symbol('feedService'),
|
FEED_SERVICE: Symbol('feedService'),
|
||||||
PROFILE_SERVICE: Symbol('profileService'),
|
PROFILE_SERVICE: Symbol('profileService'),
|
||||||
REACTION_SERVICE: Symbol('reactionService'),
|
REACTION_SERVICE: Symbol('reactionService'),
|
||||||
|
SCHEDULED_EVENT_SERVICE: Symbol('scheduledEventService'),
|
||||||
|
|
||||||
// Nostr metadata services
|
// Nostr metadata services
|
||||||
NOSTR_METADATA_SERVICE: Symbol('nostrMetadataService'),
|
NOSTR_METADATA_SERVICE: Symbol('nostrMetadataService'),
|
||||||
|
|
@ -159,6 +160,9 @@ export const SERVICE_TOKENS = {
|
||||||
|
|
||||||
// Image upload services
|
// Image upload services
|
||||||
IMAGE_UPLOAD_SERVICE: Symbol('imageUploadService'),
|
IMAGE_UPLOAD_SERVICE: Symbol('imageUploadService'),
|
||||||
|
|
||||||
|
// Expenses services
|
||||||
|
EXPENSES_API: Symbol('expensesAPI'),
|
||||||
} as const
|
} as const
|
||||||
|
|
||||||
// Type-safe injection helpers
|
// Type-safe injection helpers
|
||||||
|
|
|
||||||
|
|
@ -1,37 +1,64 @@
|
||||||
import type { App, Component } from 'vue'
|
import type { App, Component } from 'vue'
|
||||||
import type { RouteRecordRaw } from 'vue-router'
|
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
|
// Base module plugin interface
|
||||||
export interface ModulePlugin {
|
export interface ModulePlugin {
|
||||||
/** Unique module name */
|
/** Unique module name */
|
||||||
name: string
|
name: string
|
||||||
|
|
||||||
/** Module version */
|
/** Module version */
|
||||||
version: string
|
version: string
|
||||||
|
|
||||||
/** Required dependencies (other module names) */
|
/** Required dependencies (other module names) */
|
||||||
dependencies?: string[]
|
dependencies?: string[]
|
||||||
|
|
||||||
/** Module configuration */
|
/** Module configuration */
|
||||||
config?: Record<string, any>
|
config?: Record<string, any>
|
||||||
|
|
||||||
/** Install the module */
|
/** Install the module */
|
||||||
install(app: App, options?: any): Promise<void> | void
|
install(app: App, options?: any): Promise<void> | void
|
||||||
|
|
||||||
/** Uninstall the module (cleanup) */
|
/** Uninstall the module (cleanup) */
|
||||||
uninstall?(): Promise<void> | void
|
uninstall?(): Promise<void> | void
|
||||||
|
|
||||||
/** Routes provided by this module */
|
/** Routes provided by this module */
|
||||||
routes?: RouteRecordRaw[]
|
routes?: RouteRecordRaw[]
|
||||||
|
|
||||||
/** Components provided by this module */
|
/** Components provided by this module */
|
||||||
components?: Record<string, Component>
|
components?: Record<string, Component>
|
||||||
|
|
||||||
/** Services provided by this module */
|
/** Services provided by this module */
|
||||||
services?: Record<string, any>
|
services?: Record<string, any>
|
||||||
|
|
||||||
/** Composables provided by this module */
|
/** Composables provided by this module */
|
||||||
composables?: Record<string, any>
|
composables?: Record<string, any>
|
||||||
|
|
||||||
|
/** Quick actions provided by this module */
|
||||||
|
quickActions?: QuickAction[]
|
||||||
}
|
}
|
||||||
|
|
||||||
// Module configuration for app setup
|
// Module configuration for app setup
|
||||||
|
|
|
||||||
|
|
@ -540,9 +540,13 @@ export class RelayHub extends BaseService {
|
||||||
const successful = results.filter(result => result.status === 'fulfilled').length
|
const successful = results.filter(result => result.status === 'fulfilled').length
|
||||||
const total = results.length
|
const total = results.length
|
||||||
|
|
||||||
|
|
||||||
this.emit('eventPublished', { eventId: event.id, success: successful, total })
|
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`)
|
||||||
|
}
|
||||||
|
|
||||||
return { success: successful, total }
|
return { success: successful, total }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
270
src/modules/expenses/components/AccountSelector.vue
Normal file
270
src/modules/expenses/components/AccountSelector.vue
Normal file
|
|
@ -0,0 +1,270 @@
|
||||||
|
<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>
|
||||||
469
src/modules/expenses/components/AddExpense.vue
Normal file
469
src/modules/expenses/components/AddExpense.vue
Normal file
|
|
@ -0,0 +1,469 @@
|
||||||
|
<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>
|
||||||
256
src/modules/expenses/components/admin/GrantPermissionDialog.vue
Normal file
256
src/modules/expenses/components/admin/GrantPermissionDialog.vue
Normal file
|
|
@ -0,0 +1,256 @@
|
||||||
|
<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>
|
||||||
399
src/modules/expenses/components/admin/PermissionManager.vue
Normal file
399
src/modules/expenses/components/admin/PermissionManager.vue
Normal file
|
|
@ -0,0 +1,399 @@
|
||||||
|
<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>
|
||||||
71
src/modules/expenses/index.ts
Normal file
71
src/modules/expenses/index.ts
Normal file
|
|
@ -0,0 +1,71 @@
|
||||||
|
/**
|
||||||
|
* 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'
|
||||||
450
src/modules/expenses/services/ExpensesAPI.ts
Normal file
450
src/modules/expenses/services/ExpensesAPI.ts
Normal file
|
|
@ -0,0 +1,450 @@
|
||||||
|
/**
|
||||||
|
* 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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
164
src/modules/expenses/types/index.ts
Normal file
164
src/modules/expenses/types/index.ts
Normal file
|
|
@ -0,0 +1,164 @@
|
||||||
|
/**
|
||||||
|
* 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
|
||||||
|
}
|
||||||
|
}
|
||||||
367
src/modules/expenses/views/TransactionsPage.vue
Normal file
367
src/modules/expenses/views/TransactionsPage.vue
Normal file
|
|
@ -0,0 +1,367 @@
|
||||||
|
<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>
|
||||||
|
|
@ -9,13 +9,16 @@ import {
|
||||||
DialogHeader,
|
DialogHeader,
|
||||||
DialogTitle,
|
DialogTitle,
|
||||||
} from '@/components/ui/dialog'
|
} from '@/components/ui/dialog'
|
||||||
import { Megaphone, RefreshCw, AlertCircle } from 'lucide-vue-next'
|
import { Megaphone, RefreshCw, AlertCircle, ChevronLeft, ChevronRight } from 'lucide-vue-next'
|
||||||
import { useFeed } from '../composables/useFeed'
|
import { useFeed } from '../composables/useFeed'
|
||||||
import { useProfiles } from '../composables/useProfiles'
|
import { useProfiles } from '../composables/useProfiles'
|
||||||
import { useReactions } from '../composables/useReactions'
|
import { useReactions } from '../composables/useReactions'
|
||||||
|
import { useScheduledEvents } from '../composables/useScheduledEvents'
|
||||||
import ThreadedPost from './ThreadedPost.vue'
|
import ThreadedPost from './ThreadedPost.vue'
|
||||||
|
import ScheduledEventCard from './ScheduledEventCard.vue'
|
||||||
import appConfig from '@/app.config'
|
import appConfig from '@/app.config'
|
||||||
import type { ContentFilter, FeedPost } from '../services/FeedService'
|
import type { ContentFilter, FeedPost } from '../services/FeedService'
|
||||||
|
import type { ScheduledEvent } from '../services/ScheduledEventService'
|
||||||
import { injectService, SERVICE_TOKENS } from '@/core/di-container'
|
import { injectService, SERVICE_TOKENS } from '@/core/di-container'
|
||||||
import type { AuthService } from '@/modules/base/auth/auth-service'
|
import type { AuthService } from '@/modules/base/auth/auth-service'
|
||||||
import type { RelayHub } from '@/modules/base/nostr/relay-hub'
|
import type { RelayHub } from '@/modules/base/nostr/relay-hub'
|
||||||
|
|
@ -95,6 +98,78 @@ const { getDisplayName, fetchProfiles } = useProfiles()
|
||||||
// Use reactions service for likes/hearts
|
// Use reactions service for likes/hearts
|
||||||
const { getEventReactions, subscribeToReactions, toggleLike } = useReactions()
|
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 for new posts and fetch their profiles and reactions
|
||||||
watch(notes, async (newNotes) => {
|
watch(notes, async (newNotes) => {
|
||||||
if (newNotes.length > 0) {
|
if (newNotes.length > 0) {
|
||||||
|
|
@ -109,6 +184,38 @@ watch(notes, async (newNotes) => {
|
||||||
}
|
}
|
||||||
}, { immediate: true })
|
}, { 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
|
// Check if we have admin pubkeys configured
|
||||||
const hasAdminPubkeys = computed(() => adminPubkeys.length > 0)
|
const hasAdminPubkeys = computed(() => adminPubkeys.length > 0)
|
||||||
|
|
||||||
|
|
@ -158,6 +265,52 @@ 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
|
// Handle collapse toggle with cascading behavior
|
||||||
function onToggleCollapse(postId: string) {
|
function onToggleCollapse(postId: string) {
|
||||||
const newCollapsed = new Set(collapsedPosts.value)
|
const newCollapsed = new Set(collapsedPosts.value)
|
||||||
|
|
@ -356,20 +509,75 @@ function cancelDelete() {
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</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 -->
|
<!-- Posts List - Natural flow without internal scrolling -->
|
||||||
<div v-else>
|
<div v-else>
|
||||||
<div class="md:space-y-4 md:py-4">
|
<!-- 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>
|
||||||
<ThreadedPost
|
<ThreadedPost
|
||||||
v-for="post in threadedPosts"
|
v-for="post in threadedPosts"
|
||||||
:key="post.id"
|
:key="post.id"
|
||||||
|
|
@ -390,8 +598,19 @@ function cancelDelete() {
|
||||||
/>
|
/>
|
||||||
</div>
|
</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 -->
|
<!-- End of feed message -->
|
||||||
<div class="text-center py-6 text-md text-muted-foreground">
|
<div v-if="threadedPosts.length > 0" class="text-center py-6 text-md text-muted-foreground">
|
||||||
<p>🐢</p>
|
<p>🐢</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
540
src/modules/nostr-feed/components/ScheduledEventCard.vue
Normal file
540
src/modules/nostr-feed/components/ScheduledEventCard.vue
Normal file
|
|
@ -0,0 +1,540 @@
|
||||||
|
<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>
|
||||||
261
src/modules/nostr-feed/composables/useScheduledEvents.ts
Normal file
261
src/modules/nostr-feed/composables/useScheduledEvents.ts
Normal file
|
|
@ -0,0 +1,261 @@
|
||||||
|
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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -88,6 +88,14 @@ export const CONTENT_FILTERS: Record<string, ContentFilter> = {
|
||||||
description: 'Rideshare requests, offers, and coordination',
|
description: 'Rideshare requests, offers, and coordination',
|
||||||
tags: ['rideshare', 'carpool'], // NIP-12 tags
|
tags: ['rideshare', 'carpool'], // NIP-12 tags
|
||||||
keywords: ['rideshare', 'ride share', 'carpool', '🚗', '🚶']
|
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'
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -110,6 +118,11 @@ export const FILTER_PRESETS: Record<string, ContentFilter[]> = {
|
||||||
// Rideshare only
|
// Rideshare only
|
||||||
rideshare: [
|
rideshare: [
|
||||||
CONTENT_FILTERS.rideshare
|
CONTENT_FILTERS.rideshare
|
||||||
|
],
|
||||||
|
|
||||||
|
// Scheduled events only
|
||||||
|
scheduledEvents: [
|
||||||
|
CONTENT_FILTERS.scheduledEvents
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,11 +1,15 @@
|
||||||
import type { App } from 'vue'
|
import type { App } from 'vue'
|
||||||
|
import { markRaw } from 'vue'
|
||||||
import type { ModulePlugin } from '@/core/types'
|
import type { ModulePlugin } from '@/core/types'
|
||||||
import { container, SERVICE_TOKENS } from '@/core/di-container'
|
import { container, SERVICE_TOKENS } from '@/core/di-container'
|
||||||
import NostrFeed from './components/NostrFeed.vue'
|
import NostrFeed from './components/NostrFeed.vue'
|
||||||
|
import NoteComposer from './components/NoteComposer.vue'
|
||||||
|
import RideshareComposer from './components/RideshareComposer.vue'
|
||||||
import { useFeed } from './composables/useFeed'
|
import { useFeed } from './composables/useFeed'
|
||||||
import { FeedService } from './services/FeedService'
|
import { FeedService } from './services/FeedService'
|
||||||
import { ProfileService } from './services/ProfileService'
|
import { ProfileService } from './services/ProfileService'
|
||||||
import { ReactionService } from './services/ReactionService'
|
import { ReactionService } from './services/ReactionService'
|
||||||
|
import { ScheduledEventService } from './services/ScheduledEventService'
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Nostr Feed Module Plugin
|
* Nostr Feed Module Plugin
|
||||||
|
|
@ -16,6 +20,28 @@ export const nostrFeedModule: ModulePlugin = {
|
||||||
version: '1.0.0',
|
version: '1.0.0',
|
||||||
dependencies: ['base'],
|
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) {
|
async install(app: App) {
|
||||||
console.log('nostr-feed module: Starting installation...')
|
console.log('nostr-feed module: Starting installation...')
|
||||||
|
|
||||||
|
|
@ -23,10 +49,12 @@ export const nostrFeedModule: ModulePlugin = {
|
||||||
const feedService = new FeedService()
|
const feedService = new FeedService()
|
||||||
const profileService = new ProfileService()
|
const profileService = new ProfileService()
|
||||||
const reactionService = new ReactionService()
|
const reactionService = new ReactionService()
|
||||||
|
const scheduledEventService = new ScheduledEventService()
|
||||||
|
|
||||||
container.provide(SERVICE_TOKENS.FEED_SERVICE, feedService)
|
container.provide(SERVICE_TOKENS.FEED_SERVICE, feedService)
|
||||||
container.provide(SERVICE_TOKENS.PROFILE_SERVICE, profileService)
|
container.provide(SERVICE_TOKENS.PROFILE_SERVICE, profileService)
|
||||||
container.provide(SERVICE_TOKENS.REACTION_SERVICE, reactionService)
|
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')
|
console.log('nostr-feed module: Services registered in DI container')
|
||||||
|
|
||||||
// Initialize services
|
// Initialize services
|
||||||
|
|
@ -43,6 +71,10 @@ export const nostrFeedModule: ModulePlugin = {
|
||||||
reactionService.initialize({
|
reactionService.initialize({
|
||||||
waitForDependencies: true,
|
waitForDependencies: true,
|
||||||
maxRetries: 3
|
maxRetries: 3
|
||||||
|
}),
|
||||||
|
scheduledEventService.initialize({
|
||||||
|
waitForDependencies: true,
|
||||||
|
maxRetries: 3
|
||||||
})
|
})
|
||||||
])
|
])
|
||||||
console.log('nostr-feed module: Services initialized')
|
console.log('nostr-feed module: Services initialized')
|
||||||
|
|
|
||||||
|
|
@ -47,6 +47,7 @@ export class FeedService extends BaseService {
|
||||||
protected relayHub: any = null
|
protected relayHub: any = null
|
||||||
protected visibilityService: any = null
|
protected visibilityService: any = null
|
||||||
protected reactionService: any = null
|
protected reactionService: any = null
|
||||||
|
protected scheduledEventService: any = null
|
||||||
|
|
||||||
// Event ID tracking for deduplication
|
// Event ID tracking for deduplication
|
||||||
private seenEventIds = new Set<string>()
|
private seenEventIds = new Set<string>()
|
||||||
|
|
@ -72,10 +73,12 @@ export class FeedService extends BaseService {
|
||||||
this.relayHub = injectService(SERVICE_TOKENS.RELAY_HUB)
|
this.relayHub = injectService(SERVICE_TOKENS.RELAY_HUB)
|
||||||
this.visibilityService = injectService(SERVICE_TOKENS.VISIBILITY_SERVICE)
|
this.visibilityService = injectService(SERVICE_TOKENS.VISIBILITY_SERVICE)
|
||||||
this.reactionService = injectService(SERVICE_TOKENS.REACTION_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: RelayHub injected:', !!this.relayHub)
|
||||||
console.log('FeedService: VisibilityService injected:', !!this.visibilityService)
|
console.log('FeedService: VisibilityService injected:', !!this.visibilityService)
|
||||||
console.log('FeedService: ReactionService injected:', !!this.reactionService)
|
console.log('FeedService: ReactionService injected:', !!this.reactionService)
|
||||||
|
console.log('FeedService: ScheduledEventService injected:', !!this.scheduledEventService)
|
||||||
|
|
||||||
if (!this.relayHub) {
|
if (!this.relayHub) {
|
||||||
throw new Error('RelayHub service not available')
|
throw new Error('RelayHub service not available')
|
||||||
|
|
@ -199,6 +202,12 @@ export class FeedService extends BaseService {
|
||||||
kinds: [5] // All deletion events (for both posts and reactions)
|
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)
|
console.log(`Creating feed subscription for ${config.feedType} with filters:`, filters)
|
||||||
|
|
||||||
// Subscribe to all events (posts, reactions, deletions) with deduplication
|
// Subscribe to all events (posts, reactions, deletions) with deduplication
|
||||||
|
|
@ -257,6 +266,25 @@ export class FeedService extends BaseService {
|
||||||
return
|
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)
|
// Skip if event already seen (for posts only, kind 1)
|
||||||
if (this.seenEventIds.has(event.id)) {
|
if (this.seenEventIds.has(event.id)) {
|
||||||
return
|
return
|
||||||
|
|
@ -355,6 +383,28 @@ export class FeedService extends BaseService {
|
||||||
return
|
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
|
// Handle post deletions (kind 1) in FeedService
|
||||||
if (deletedKind === '1' || !deletedKind) {
|
if (deletedKind === '1' || !deletedKind) {
|
||||||
// Extract event IDs to delete from 'e' tags
|
// Extract event IDs to delete from 'e' tags
|
||||||
|
|
|
||||||
678
src/modules/nostr-feed/services/ScheduledEventService.ts
Normal file
678
src/modules/nostr-feed/services/ScheduledEventService.ts
Normal file
|
|
@ -0,0 +1,678 @@
|
||||||
|
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()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -36,24 +36,20 @@
|
||||||
|
|
||||||
<!-- Main Feed Area - Takes remaining height with scrolling -->
|
<!-- Main Feed Area - Takes remaining height with scrolling -->
|
||||||
<div class="flex-1 overflow-y-auto scrollbar-thin scrollbar-thumb-border scrollbar-track-transparent">
|
<div class="flex-1 overflow-y-auto scrollbar-thin scrollbar-thumb-border scrollbar-track-transparent">
|
||||||
<!-- Collapsible Composer -->
|
<!-- Quick Action Component Area -->
|
||||||
<div v-if="showComposer || replyTo" class="border-b bg-background sticky top-0 z-10">
|
<div v-if="activeAction || 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="max-h-[70vh] overflow-y-auto scrollbar-thin scrollbar-thumb-border scrollbar-track-transparent">
|
||||||
<div class="px-4 py-3 sm:px-6">
|
<div class="px-4 py-3 sm:px-6">
|
||||||
<!-- Regular Note Composer -->
|
<!-- Dynamic Quick Action Component -->
|
||||||
<NoteComposer
|
<component
|
||||||
v-if="composerType === 'note' || replyTo"
|
:is="activeAction?.component"
|
||||||
|
v-if="activeAction"
|
||||||
:reply-to="replyTo"
|
:reply-to="replyTo"
|
||||||
@note-published="onNotePublished"
|
@note-published="onActionComplete"
|
||||||
|
@rideshare-published="onActionComplete"
|
||||||
|
@action-complete="onActionComplete"
|
||||||
@clear-reply="onClearReply"
|
@clear-reply="onClearReply"
|
||||||
@close="onCloseComposer"
|
@close="closeQuickAction"
|
||||||
/>
|
|
||||||
|
|
||||||
<!-- Rideshare Composer -->
|
|
||||||
<RideshareComposer
|
|
||||||
v-else-if="composerType === 'rideshare'"
|
|
||||||
@rideshare-published="onRidesharePublished"
|
|
||||||
@close="onCloseComposer"
|
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -72,39 +68,32 @@
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Floating Action Buttons for Compose -->
|
<!-- Floating Quick Action Button -->
|
||||||
<div v-if="!showComposer && !replyTo" class="fixed bottom-6 right-6 z-50">
|
<div v-if="!activeAction && !replyTo && quickActions.length > 0" class="fixed bottom-6 right-6 z-50">
|
||||||
<!-- Main compose button -->
|
|
||||||
<div class="flex flex-col items-end gap-3">
|
<div class="flex flex-col items-end gap-3">
|
||||||
<!-- Secondary buttons (when expanded) -->
|
<!-- Quick Action Buttons (when expanded) -->
|
||||||
<div v-if="showComposerOptions" class="flex flex-col gap-2">
|
<div v-if="showQuickActions" class="flex flex-col gap-2">
|
||||||
<Button
|
<Button
|
||||||
@click="openComposer('note')"
|
v-for="action in quickActions"
|
||||||
|
:key="action.id"
|
||||||
|
@click="openQuickAction(action)"
|
||||||
size="lg"
|
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"
|
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"
|
||||||
>
|
>
|
||||||
<MessageSquare class="h-4 w-4" />
|
<component :is="getIconComponent(action.icon)" class="h-4 w-4" />
|
||||||
<span class="text-sm font-medium">Note</span>
|
<span class="text-sm font-medium">{{ action.label }}</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>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Main FAB -->
|
<!-- Main FAB -->
|
||||||
<Button
|
<Button
|
||||||
@click="toggleComposerOptions"
|
@click="toggleQuickActions"
|
||||||
size="lg"
|
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"
|
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
|
<Plus
|
||||||
class="h-6 w-6 stroke-[2.5] transition-transform duration-200"
|
class="h-6 w-6 stroke-[2.5] transition-transform duration-200"
|
||||||
:class="{ 'rotate-45': showComposerOptions }"
|
:class="{ 'rotate-45': showQuickActions }"
|
||||||
/>
|
/>
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -136,32 +125,34 @@
|
||||||
import { ref, computed, watch } from 'vue'
|
import { ref, computed, watch } from 'vue'
|
||||||
|
|
||||||
import { Button } from '@/components/ui/button'
|
import { Button } from '@/components/ui/button'
|
||||||
import { Filter, Plus, MessageSquare, Car } from 'lucide-vue-next'
|
import { Filter, Plus } from 'lucide-vue-next'
|
||||||
|
import * as LucideIcons from 'lucide-vue-next'
|
||||||
import PWAInstallPrompt from '@/components/pwa/PWAInstallPrompt.vue'
|
import PWAInstallPrompt from '@/components/pwa/PWAInstallPrompt.vue'
|
||||||
import FeedFilters from '@/modules/nostr-feed/components/FeedFilters.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 NostrFeed from '@/modules/nostr-feed/components/NostrFeed.vue'
|
||||||
import { FILTER_PRESETS } from '@/modules/nostr-feed/config/content-filters'
|
import { FILTER_PRESETS } from '@/modules/nostr-feed/config/content-filters'
|
||||||
|
import { useQuickActions } from '@/composables/useQuickActions'
|
||||||
import appConfig from '@/app.config'
|
import appConfig from '@/app.config'
|
||||||
import type { ContentFilter } from '@/modules/nostr-feed/services/FeedService'
|
import type { ContentFilter } from '@/modules/nostr-feed/services/FeedService'
|
||||||
import type { ReplyToNote } from '@/modules/nostr-feed/components/NoteComposer.vue'
|
import type { QuickAction } from '@/core/types'
|
||||||
|
|
||||||
|
// Get quick actions from modules
|
||||||
|
const { quickActions } = useQuickActions()
|
||||||
|
|
||||||
// Get admin pubkeys from app config
|
// Get admin pubkeys from app config
|
||||||
const adminPubkeys = appConfig.modules['nostr-feed']?.config?.adminPubkeys || []
|
const adminPubkeys = appConfig.modules['nostr-feed']?.config?.adminPubkeys || []
|
||||||
|
|
||||||
// UI state
|
// UI state
|
||||||
const showFilters = ref(false)
|
const showFilters = ref(false)
|
||||||
const showComposer = ref(false)
|
const showQuickActions = ref(false)
|
||||||
const showComposerOptions = ref(false)
|
const activeAction = ref<QuickAction | null>(null)
|
||||||
const composerType = ref<'note' | 'rideshare'>('note')
|
|
||||||
|
|
||||||
// Feed configuration
|
// Feed configuration
|
||||||
const selectedFilters = ref<ContentFilter[]>(FILTER_PRESETS.all)
|
const selectedFilters = ref<ContentFilter[]>(FILTER_PRESETS.all)
|
||||||
const feedKey = ref(0) // Force feed component to re-render when filters change
|
const feedKey = ref(0) // Force feed component to re-render when filters change
|
||||||
|
|
||||||
// Note composer state
|
// Reply state (for note composer compatibility)
|
||||||
const replyTo = ref<ReplyToNote | undefined>()
|
const replyTo = ref<any | undefined>()
|
||||||
|
|
||||||
// Quick filter presets for mobile bottom bar
|
// Quick filter presets for mobile bottom bar
|
||||||
const quickFilterPresets = {
|
const quickFilterPresets = {
|
||||||
|
|
@ -221,48 +212,46 @@ const setQuickFilter = (presetKey: string) => {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const onNotePublished = (noteId: string) => {
|
// Quick action methods
|
||||||
console.log('Note published:', noteId)
|
const toggleQuickActions = () => {
|
||||||
// Refresh the feed to show the new note
|
showQuickActions.value = !showQuickActions.value
|
||||||
feedKey.value++
|
}
|
||||||
// Clear reply state and hide composer
|
|
||||||
|
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
|
||||||
|
feedKey.value++
|
||||||
|
// Close the action
|
||||||
|
activeAction.value = null
|
||||||
replyTo.value = undefined
|
replyTo.value = undefined
|
||||||
showComposer.value = false
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const onClearReply = () => {
|
const onClearReply = () => {
|
||||||
replyTo.value = undefined
|
replyTo.value = undefined
|
||||||
showComposer.value = false
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const onReplyToNote = (note: ReplyToNote) => {
|
const onReplyToNote = (note: any) => {
|
||||||
replyTo.value = note
|
replyTo.value = note
|
||||||
showComposer.value = true
|
// Find and open the note composer action
|
||||||
|
const noteAction = quickActions.value.find(a => a.id === 'note')
|
||||||
|
if (noteAction) {
|
||||||
|
activeAction.value = noteAction
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const onCloseComposer = () => {
|
// Helper to get Lucide icon component
|
||||||
showComposer.value = false
|
const getIconComponent = (iconName: string) => {
|
||||||
showComposerOptions.value = false
|
return (LucideIcons as any)[iconName] || Plus
|
||||||
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>
|
</script>
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue