Compare commits
No commits in common. "castle-web" and "main" have entirely different histories.
castle-web
...
main
38 changed files with 494 additions and 4931 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, Image Upload)
|
- **Base Module** (`src/modules/base/`) - Core infrastructure (Nostr, Auth, PWA)
|
||||||
- **Wallet Module** (`src/modules/wallet/`) - Lightning wallet management with real-time balance updates
|
- **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,12 +90,6 @@ 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)
|
||||||
|
|
@ -128,8 +122,6 @@ 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:**
|
||||||
|
|
@ -151,10 +143,8 @@ 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: 2000, // Initial reconnection delay (ms)
|
reconnectDelay: 1000, // Initial reconnection delay
|
||||||
maxReconnectAttempts: 3, // Maximum reconnection attempts
|
maxReconnectAttempts: 5 // Maximum reconnection attempts
|
||||||
fallbackToPolling: true, // Enable polling fallback when WebSocket fails
|
|
||||||
pollingInterval: 10000 // Polling interval (ms)
|
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
|
|
@ -194,12 +184,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 */}
|
||||||
|
|
@ -239,88 +229,6 @@ 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**
|
||||||
|
|
@ -381,7 +289,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"
|
||||||
/>
|
/>
|
||||||
|
|
@ -422,7 +330,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"
|
||||||
>
|
>
|
||||||
|
|
@ -474,44 +382,35 @@ For Shadcn/ui Checkbox components, you MUST use the correct Vue.js binding patte
|
||||||
- ✅ **Force Re-render**: Use dynamic `:key` if checkbox doesn't reflect initial form values
|
- ✅ **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
|
||||||
|
|
||||||
### **CSS and Styling Guidelines**
|
**Reference**: [Vue.js Forms Documentation](https://vuejs.org/guide/essentials/forms.html)
|
||||||
|
|
||||||
**CRITICAL: Always use semantic, theme-aware CSS classes**
|
**❌ NEVER do this:**
|
||||||
|
```vue
|
||||||
|
<!-- Wrong: Manual form handling without vee-validate -->
|
||||||
|
<form @submit.prevent="handleSubmit">
|
||||||
|
|
||||||
|
<!-- Wrong: Direct v-model bypasses form validation -->
|
||||||
|
<Input v-model="myValue" />
|
||||||
|
|
||||||
- ✅ **Use semantic classes** that automatically adapt to light/dark themes
|
<!-- Wrong: Manual validation instead of using meta.valid -->
|
||||||
- ❌ **Never use hard-coded colors** like `bg-white`, `text-gray-500`, `border-blue-500`
|
<Button :disabled="!name || !email">Submit</Button>
|
||||||
|
|
||||||
**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 */
|
|
||||||
```
|
```
|
||||||
|
|
||||||
**Why Semantic Classes:**
|
**✅ ALWAYS do this:**
|
||||||
- Ensures components work in both light and dark themes
|
```vue
|
||||||
- Maintains consistency with Shadcn/ui component library
|
<!-- Correct: Uses form.handleSubmit for proper form handling -->
|
||||||
- Easier to maintain and update theme colors globally
|
<form @submit="onSubmit">
|
||||||
- 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**
|
||||||
|
|
||||||
|
|
@ -564,32 +463,23 @@ 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
|
||||||
|
|
||||||
### **⚠️ CRITICAL: JavaScript Falsy Value Bug Prevention**
|
**Example from Wallet Module:**
|
||||||
|
|
||||||
**ALWAYS use nullish coalescing (`??`) instead of logical OR (`||`) for numeric defaults:**
|
|
||||||
|
|
||||||
```typescript
|
```typescript
|
||||||
// ❌ WRONG: Treats 0 as falsy, defaults to 1 even when quantity is validly 0
|
// Service returns complex invoice object
|
||||||
quantity: productData.quantity || 1
|
const invoice = await walletService.createInvoice(data)
|
||||||
|
|
||||||
// ✅ CORRECT: Only defaults to 1 when quantity is null or undefined
|
// Force reactivity for template updates
|
||||||
quantity: productData.quantity ?? 1
|
createdInvoice.value = Object.assign({}, invoice)
|
||||||
```
|
```
|
||||||
|
|
||||||
**Why this matters:**
|
```vue
|
||||||
- JavaScript falsy values include: `false`, `0`, `""`, `null`, `undefined`, `NaN`
|
<!-- Template with forced reactivity -->
|
||||||
- Using `||` for defaults will incorrectly override valid `0` values
|
<Input
|
||||||
- This caused a critical bug where products with quantity `0` displayed as quantity `1`
|
:key="`bolt11-${createdInvoice?.payment_hash}`"
|
||||||
- The `??` operator only triggers for `null` and `undefined`, preserving valid `0` values
|
:model-value="createdInvoice?.payment_request || ''"
|
||||||
|
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**
|
||||||
|
|
||||||
|
|
@ -640,6 +530,166 @@ 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)`
|
||||||
|
|
@ -660,7 +710,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)
|
||||||
|
|
@ -685,34 +735,44 @@ 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**
|
|
||||||
|
|
||||||
### **Build Configuration:**
|
### **⚠️ CRITICAL: JavaScript Falsy Value Bug Prevention**
|
||||||
|
|
||||||
|
**ALWAYS use nullish coalescing (`??`) instead of logical OR (`||`) for numeric defaults:**
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// ❌ WRONG: Treats 0 as falsy, defaults to 1 even when quantity is validly 0
|
||||||
|
quantity: productData.quantity || 1
|
||||||
|
|
||||||
|
// ✅ CORRECT: Only defaults to 1 when quantity is null or undefined
|
||||||
|
quantity: productData.quantity ?? 1
|
||||||
|
```
|
||||||
|
|
||||||
|
**Why this matters:**
|
||||||
|
- JavaScript falsy values include: `false`, `0`, `""`, `null`, `undefined`, `NaN`
|
||||||
|
- Using `||` for defaults will incorrectly override valid `0` values
|
||||||
|
- This caused a critical bug where products with quantity `0` displayed as quantity `1`
|
||||||
|
- The `??` operator only triggers for `null` and `undefined`, preserving valid `0` values
|
||||||
|
|
||||||
|
**Common scenarios where this bug occurs:**
|
||||||
|
- Product quantities, prices, counters (any numeric value where 0 is valid)
|
||||||
|
- Boolean flags where `false` is a valid state
|
||||||
|
- Empty strings that should be preserved vs. undefined strings
|
||||||
|
|
||||||
|
**Rule of thumb:**
|
||||||
|
- Use `||` only when `0`, `false`, or `""` should trigger the default
|
||||||
|
- Use `??` when only `null`/`undefined` should trigger the default (most cases)
|
||||||
|
|
||||||
|
**Build Configuration:**
|
||||||
- Vite config includes PWA, image optimization, and bundle analysis
|
- 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 Variables:**
|
**Environment:**
|
||||||
|
- Nostr relay configuration via `VITE_NOSTR_RELAYS` environment variable
|
||||||
Required environment variables in `.env`:
|
- PWA manifest configured for standalone app experience
|
||||||
|
- 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
|
||||||
|
|
||||||
|
|
@ -846,6 +906,86 @@ 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
|
||||||
|
|
@ -855,4 +995,4 @@ window.addEventListener('beforeunload', blockNavigation)
|
||||||
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.6.0",
|
"reka-ui": "^2.5.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,7 +141,6 @@
|
||||||
"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",
|
||||||
|
|
@ -2647,7 +2646,6 @@
|
||||||
"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",
|
||||||
|
|
@ -5690,7 +5688,6 @@
|
||||||
"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",
|
||||||
|
|
@ -5853,6 +5850,14 @@
|
||||||
"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",
|
||||||
|
|
@ -6053,7 +6058,6 @@
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"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",
|
||||||
|
|
@ -7600,6 +7604,17 @@
|
||||||
"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",
|
||||||
|
|
@ -8353,7 +8368,6 @@
|
||||||
"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"
|
||||||
}
|
}
|
||||||
|
|
@ -8890,6 +8904,20 @@
|
||||||
"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",
|
||||||
|
|
@ -11690,7 +11718,6 @@
|
||||||
"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",
|
||||||
|
|
@ -12153,9 +12180,9 @@
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/reka-ui": {
|
"node_modules/reka-ui": {
|
||||||
"version": "2.6.0",
|
"version": "2.5.0",
|
||||||
"resolved": "https://registry.npmjs.org/reka-ui/-/reka-ui-2.6.0.tgz",
|
"resolved": "https://registry.npmjs.org/reka-ui/-/reka-ui-2.5.0.tgz",
|
||||||
"integrity": "sha512-NrGMKrABD97l890mFS3TNUzB0BLUfbL3hh0NjcJRIUSUljb288bx3Mzo31nOyUcdiiW0HqFGXJwyCBh9cWgb0w==",
|
"integrity": "sha512-81aMAmJeVCy2k0E6x7n1kypDY6aM1ldLis5+zcdV1/JtoAlSDck5OBsyLRJU9CfgbrQp1ImnRnBSmC4fZ2fkZQ==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@floating-ui/dom": "^1.6.13",
|
"@floating-ui/dom": "^1.6.13",
|
||||||
|
|
@ -12334,7 +12361,6 @@
|
||||||
"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"
|
||||||
},
|
},
|
||||||
|
|
@ -13344,8 +13370,7 @@
|
||||||
"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",
|
||||||
|
|
@ -13480,7 +13505,6 @@
|
||||||
"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",
|
||||||
|
|
@ -13710,7 +13734,6 @@
|
||||||
"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"
|
||||||
|
|
@ -13961,7 +13984,6 @@
|
||||||
"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",
|
||||||
|
|
@ -14186,7 +14208,6 @@
|
||||||
"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",
|
||||||
|
|
@ -14639,7 +14660,6 @@
|
||||||
"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"
|
||||||
},
|
},
|
||||||
|
|
@ -14897,7 +14917,6 @@
|
||||||
"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.6.0",
|
"reka-ui": "^2.5.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,20 +93,6 @@ 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,7 +16,6 @@ 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'
|
||||||
|
|
@ -44,8 +43,7 @@ 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
|
||||||
|
|
@ -128,13 +126,6 @@ export async function createAppInstance() {
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Register expenses module
|
|
||||||
if (appConfig.modules.expenses?.enabled) {
|
|
||||||
moduleRegistrations.push(
|
|
||||||
pluginManager.register(expensesModule, appConfig.modules.expenses)
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Wait for all modules to register
|
// Wait for all modules to register
|
||||||
await Promise.all(moduleRegistrations)
|
await Promise.all(moduleRegistrations)
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,15 +0,0 @@
|
||||||
<script setup lang="ts">
|
|
||||||
import type { AlertDialogEmits, AlertDialogProps } from "reka-ui"
|
|
||||||
import { AlertDialogRoot, useForwardPropsEmits } from "reka-ui"
|
|
||||||
|
|
||||||
const props = defineProps<AlertDialogProps>()
|
|
||||||
const emits = defineEmits<AlertDialogEmits>()
|
|
||||||
|
|
||||||
const forwarded = useForwardPropsEmits(props, emits)
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<template>
|
|
||||||
<AlertDialogRoot v-bind="forwarded">
|
|
||||||
<slot />
|
|
||||||
</AlertDialogRoot>
|
|
||||||
</template>
|
|
||||||
|
|
@ -1,18 +0,0 @@
|
||||||
<script setup lang="ts">
|
|
||||||
import type { AlertDialogActionProps } from "reka-ui"
|
|
||||||
import type { HTMLAttributes } from "vue"
|
|
||||||
import { reactiveOmit } from "@vueuse/core"
|
|
||||||
import { AlertDialogAction } from "reka-ui"
|
|
||||||
import { cn } from "@/lib/utils"
|
|
||||||
import { buttonVariants } from '@/components/ui/button'
|
|
||||||
|
|
||||||
const props = defineProps<AlertDialogActionProps & { class?: HTMLAttributes["class"] }>()
|
|
||||||
|
|
||||||
const delegatedProps = reactiveOmit(props, "class")
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<template>
|
|
||||||
<AlertDialogAction v-bind="delegatedProps" :class="cn(buttonVariants(), props.class)">
|
|
||||||
<slot />
|
|
||||||
</AlertDialogAction>
|
|
||||||
</template>
|
|
||||||
|
|
@ -1,25 +0,0 @@
|
||||||
<script setup lang="ts">
|
|
||||||
import type { AlertDialogCancelProps } from "reka-ui"
|
|
||||||
import type { HTMLAttributes } from "vue"
|
|
||||||
import { reactiveOmit } from "@vueuse/core"
|
|
||||||
import { AlertDialogCancel } from "reka-ui"
|
|
||||||
import { cn } from "@/lib/utils"
|
|
||||||
import { buttonVariants } from '@/components/ui/button'
|
|
||||||
|
|
||||||
const props = defineProps<AlertDialogCancelProps & { class?: HTMLAttributes["class"] }>()
|
|
||||||
|
|
||||||
const delegatedProps = reactiveOmit(props, "class")
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<template>
|
|
||||||
<AlertDialogCancel
|
|
||||||
v-bind="delegatedProps"
|
|
||||||
:class="cn(
|
|
||||||
buttonVariants({ variant: 'outline' }),
|
|
||||||
'mt-2 sm:mt-0',
|
|
||||||
props.class,
|
|
||||||
)"
|
|
||||||
>
|
|
||||||
<slot />
|
|
||||||
</AlertDialogCancel>
|
|
||||||
</template>
|
|
||||||
|
|
@ -1,39 +0,0 @@
|
||||||
<script setup lang="ts">
|
|
||||||
import type { AlertDialogContentEmits, AlertDialogContentProps } from "reka-ui"
|
|
||||||
import type { HTMLAttributes } from "vue"
|
|
||||||
import { reactiveOmit } from "@vueuse/core"
|
|
||||||
import {
|
|
||||||
AlertDialogContent,
|
|
||||||
|
|
||||||
AlertDialogOverlay,
|
|
||||||
AlertDialogPortal,
|
|
||||||
useForwardPropsEmits,
|
|
||||||
} from "reka-ui"
|
|
||||||
import { cn } from "@/lib/utils"
|
|
||||||
|
|
||||||
const props = defineProps<AlertDialogContentProps & { class?: HTMLAttributes["class"] }>()
|
|
||||||
const emits = defineEmits<AlertDialogContentEmits>()
|
|
||||||
|
|
||||||
const delegatedProps = reactiveOmit(props, "class")
|
|
||||||
|
|
||||||
const forwarded = useForwardPropsEmits(delegatedProps, emits)
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<template>
|
|
||||||
<AlertDialogPortal>
|
|
||||||
<AlertDialogOverlay
|
|
||||||
class="fixed inset-0 z-50 bg-black/80 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0"
|
|
||||||
/>
|
|
||||||
<AlertDialogContent
|
|
||||||
v-bind="forwarded"
|
|
||||||
:class="
|
|
||||||
cn(
|
|
||||||
'fixed left-1/2 top-1/2 z-50 grid w-full max-w-lg -translate-x-1/2 -translate-y-1/2 gap-4 border bg-background p-6 shadow-lg duration-200 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[state=closed]:slide-out-to-left-1/2 data-[state=closed]:slide-out-to-top-[48%] data-[state=open]:slide-in-from-left-1/2 data-[state=open]:slide-in-from-top-[48%] sm:rounded-lg',
|
|
||||||
props.class,
|
|
||||||
)
|
|
||||||
"
|
|
||||||
>
|
|
||||||
<slot />
|
|
||||||
</AlertDialogContent>
|
|
||||||
</AlertDialogPortal>
|
|
||||||
</template>
|
|
||||||
|
|
@ -1,23 +0,0 @@
|
||||||
<script setup lang="ts">
|
|
||||||
import type { AlertDialogDescriptionProps } from "reka-ui"
|
|
||||||
import type { HTMLAttributes } from "vue"
|
|
||||||
import { reactiveOmit } from "@vueuse/core"
|
|
||||||
import {
|
|
||||||
AlertDialogDescription,
|
|
||||||
|
|
||||||
} from "reka-ui"
|
|
||||||
import { cn } from "@/lib/utils"
|
|
||||||
|
|
||||||
const props = defineProps<AlertDialogDescriptionProps & { class?: HTMLAttributes["class"] }>()
|
|
||||||
|
|
||||||
const delegatedProps = reactiveOmit(props, "class")
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<template>
|
|
||||||
<AlertDialogDescription
|
|
||||||
v-bind="delegatedProps"
|
|
||||||
:class="cn('text-sm text-muted-foreground', props.class)"
|
|
||||||
>
|
|
||||||
<slot />
|
|
||||||
</AlertDialogDescription>
|
|
||||||
</template>
|
|
||||||
|
|
@ -1,21 +0,0 @@
|
||||||
<script setup lang="ts">
|
|
||||||
import type { HTMLAttributes } from "vue"
|
|
||||||
import { cn } from "@/lib/utils"
|
|
||||||
|
|
||||||
const props = defineProps<{
|
|
||||||
class?: HTMLAttributes["class"]
|
|
||||||
}>()
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<template>
|
|
||||||
<div
|
|
||||||
:class="
|
|
||||||
cn(
|
|
||||||
'flex flex-col-reverse sm:flex-row sm:justify-end sm:gap-x-2',
|
|
||||||
props.class,
|
|
||||||
)
|
|
||||||
"
|
|
||||||
>
|
|
||||||
<slot />
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
|
|
@ -1,16 +0,0 @@
|
||||||
<script setup lang="ts">
|
|
||||||
import type { HTMLAttributes } from "vue"
|
|
||||||
import { cn } from "@/lib/utils"
|
|
||||||
|
|
||||||
const props = defineProps<{
|
|
||||||
class?: HTMLAttributes["class"]
|
|
||||||
}>()
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<template>
|
|
||||||
<div
|
|
||||||
:class="cn('flex flex-col gap-y-2 text-center sm:text-left', props.class)"
|
|
||||||
>
|
|
||||||
<slot />
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
|
|
@ -1,20 +0,0 @@
|
||||||
<script setup lang="ts">
|
|
||||||
import type { AlertDialogTitleProps } from "reka-ui"
|
|
||||||
import type { HTMLAttributes } from "vue"
|
|
||||||
import { reactiveOmit } from "@vueuse/core"
|
|
||||||
import { AlertDialogTitle } from "reka-ui"
|
|
||||||
import { cn } from "@/lib/utils"
|
|
||||||
|
|
||||||
const props = defineProps<AlertDialogTitleProps & { class?: HTMLAttributes["class"] }>()
|
|
||||||
|
|
||||||
const delegatedProps = reactiveOmit(props, "class")
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<template>
|
|
||||||
<AlertDialogTitle
|
|
||||||
v-bind="delegatedProps"
|
|
||||||
:class="cn('text-lg font-semibold', props.class)"
|
|
||||||
>
|
|
||||||
<slot />
|
|
||||||
</AlertDialogTitle>
|
|
||||||
</template>
|
|
||||||
|
|
@ -1,12 +0,0 @@
|
||||||
<script setup lang="ts">
|
|
||||||
import type { AlertDialogTriggerProps } from "reka-ui"
|
|
||||||
import { AlertDialogTrigger } from "reka-ui"
|
|
||||||
|
|
||||||
const props = defineProps<AlertDialogTriggerProps>()
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<template>
|
|
||||||
<AlertDialogTrigger v-bind="props">
|
|
||||||
<slot />
|
|
||||||
</AlertDialogTrigger>
|
|
||||||
</template>
|
|
||||||
|
|
@ -1,9 +0,0 @@
|
||||||
export { default as AlertDialog } from "./AlertDialog.vue"
|
|
||||||
export { default as AlertDialogAction } from "./AlertDialogAction.vue"
|
|
||||||
export { default as AlertDialogCancel } from "./AlertDialogCancel.vue"
|
|
||||||
export { default as AlertDialogContent } from "./AlertDialogContent.vue"
|
|
||||||
export { default as AlertDialogDescription } from "./AlertDialogDescription.vue"
|
|
||||||
export { default as AlertDialogFooter } from "./AlertDialogFooter.vue"
|
|
||||||
export { default as AlertDialogHeader } from "./AlertDialogHeader.vue"
|
|
||||||
export { default as AlertDialogTitle } from "./AlertDialogTitle.vue"
|
|
||||||
export { default as AlertDialogTrigger } from "./AlertDialogTrigger.vue"
|
|
||||||
|
|
@ -1,25 +1,22 @@
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import type { PrimitiveProps } from "reka-ui"
|
import type { HTMLAttributes } from 'vue'
|
||||||
import type { HTMLAttributes } from "vue"
|
import { cn } from '@/lib/utils'
|
||||||
import type { ButtonVariants } from "."
|
import { Primitive, type PrimitiveProps } from 'reka-ui'
|
||||||
import { Primitive } from "reka-ui"
|
import { type ButtonVariants, buttonVariants } from '.'
|
||||||
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,37 +1,33 @@
|
||||||
import type { VariantProps } from "class-variance-authority"
|
import { cva, 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-all disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg:not([class*='size-'])]:size-4 shrink-0 [&_svg]:shrink-0 outline-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive",
|
'inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium transition-colors focus-visible:outline-hidden focus-visible:ring-1 focus-visible:ring-ring disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0',
|
||||||
{
|
{
|
||||||
variants: {
|
variants: {
|
||||||
variant: {
|
variant: {
|
||||||
default:
|
default: 'bg-primary text-primary-foreground shadow-sm hover:bg-primary/90',
|
||||||
"bg-primary text-primary-foreground shadow-xs hover:bg-primary/90",
|
|
||||||
destructive:
|
destructive:
|
||||||
"bg-destructive text-white shadow-xs hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40 dark:bg-destructive/60",
|
'bg-destructive text-destructive-foreground shadow-xs hover:bg-destructive/90',
|
||||||
outline:
|
outline:
|
||||||
"border bg-background shadow-xs hover:bg-accent hover:text-accent-foreground dark:bg-input/30 dark:border-input dark:hover:bg-input/50",
|
'border border-input bg-background shadow-xs hover:bg-accent hover:text-accent-foreground',
|
||||||
secondary:
|
secondary:
|
||||||
"bg-secondary text-secondary-foreground shadow-xs hover:bg-secondary/80",
|
'bg-secondary text-secondary-foreground shadow-xs hover:bg-secondary/80',
|
||||||
ghost:
|
ghost: 'hover:bg-accent hover:text-accent-foreground',
|
||||||
"hover:bg-accent hover:text-accent-foreground dark:hover:bg-accent/50",
|
link: 'text-primary underline-offset-4 hover:underline',
|
||||||
link: "text-primary underline-offset-4 hover:underline",
|
|
||||||
},
|
},
|
||||||
size: {
|
size: {
|
||||||
"default": "h-9 px-4 py-2 has-[>svg]:px-3",
|
default: 'h-9 px-4 py-2',
|
||||||
"sm": "h-8 rounded-md gap-1.5 px-3 has-[>svg]:px-2.5",
|
xs: 'h-7 rounded px-2',
|
||||||
"lg": "h-10 rounded-md px-6 has-[>svg]:px-4",
|
sm: 'h-8 rounded-md px-3 text-xs',
|
||||||
"icon": "size-9",
|
lg: 'h-10 rounded-md px-8',
|
||||||
"icon-sm": "size-8",
|
icon: 'h-9 w-9',
|
||||||
"icon-lg": "size-10",
|
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
defaultVariants: {
|
defaultVariants: {
|
||||||
variant: "default",
|
variant: 'default',
|
||||||
size: "default",
|
size: 'default',
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
|
|
||||||
|
|
@ -61,40 +61,30 @@ 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
|
||||||
|
|
|
||||||
|
|
@ -1,95 +0,0 @@
|
||||||
import { computed } from 'vue'
|
|
||||||
import { pluginManager } from '@/core/plugin-manager'
|
|
||||||
import type { QuickAction } from '@/core/types'
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Composable for dynamic quick actions based on enabled modules
|
|
||||||
*
|
|
||||||
* Quick actions are module-provided action buttons that appear in the floating
|
|
||||||
* action button (FAB) menu. Each module can register its own quick actions
|
|
||||||
* for common tasks like composing notes, sending payments, adding expenses, etc.
|
|
||||||
*
|
|
||||||
* @example
|
|
||||||
* ```typescript
|
|
||||||
* const { quickActions, getActionsByCategory } = useQuickActions()
|
|
||||||
*
|
|
||||||
* // Get all actions
|
|
||||||
* const actions = quickActions.value
|
|
||||||
*
|
|
||||||
* // Get actions by category
|
|
||||||
* const composeActions = getActionsByCategory('compose')
|
|
||||||
* ```
|
|
||||||
*/
|
|
||||||
export function useQuickActions() {
|
|
||||||
/**
|
|
||||||
* Get all quick actions from installed modules
|
|
||||||
* Actions are sorted by order (lower = higher priority)
|
|
||||||
*/
|
|
||||||
const quickActions = computed<QuickAction[]>(() => {
|
|
||||||
const actions: QuickAction[] = []
|
|
||||||
|
|
||||||
// Iterate through installed modules
|
|
||||||
const installedModules = pluginManager.getInstalledModules()
|
|
||||||
|
|
||||||
for (const moduleName of installedModules) {
|
|
||||||
const module = pluginManager.getModule(moduleName)
|
|
||||||
if (module?.plugin.quickActions) {
|
|
||||||
actions.push(...module.plugin.quickActions)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Sort by order (lower = higher priority), then by label
|
|
||||||
return actions.sort((a, b) => {
|
|
||||||
const orderA = a.order ?? 999
|
|
||||||
const orderB = b.order ?? 999
|
|
||||||
if (orderA !== orderB) {
|
|
||||||
return orderA - orderB
|
|
||||||
}
|
|
||||||
return a.label.localeCompare(b.label)
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Get actions filtered by category
|
|
||||||
*/
|
|
||||||
const getActionsByCategory = (category: string) => {
|
|
||||||
return computed(() => {
|
|
||||||
return quickActions.value.filter(action => action.category === category)
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Get a specific action by ID
|
|
||||||
*/
|
|
||||||
const getActionById = (id: string) => {
|
|
||||||
return computed(() => {
|
|
||||||
return quickActions.value.find(action => action.id === id)
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Check if any actions are available
|
|
||||||
*/
|
|
||||||
const hasActions = computed(() => quickActions.value.length > 0)
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Get all unique categories
|
|
||||||
*/
|
|
||||||
const categories = computed(() => {
|
|
||||||
const cats = new Set<string>()
|
|
||||||
quickActions.value.forEach(action => {
|
|
||||||
if (action.category) {
|
|
||||||
cats.add(action.category)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
return Array.from(cats).sort()
|
|
||||||
})
|
|
||||||
|
|
||||||
return {
|
|
||||||
quickActions,
|
|
||||||
getActionsByCategory,
|
|
||||||
getActionById,
|
|
||||||
hasActions,
|
|
||||||
categories
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -136,7 +136,6 @@ 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'),
|
||||||
|
|
@ -160,9 +159,6 @@ 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,64 +1,37 @@
|
||||||
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,12 +540,8 @@ 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 })
|
|
||||||
|
|
||||||
// Throw error if no relays accepted the event
|
this.emit('eventPublished', { eventId: event.id, success: successful, total })
|
||||||
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 }
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,270 +0,0 @@
|
||||||
<template>
|
|
||||||
<div class="space-y-4">
|
|
||||||
<!-- Breadcrumb showing current path -->
|
|
||||||
<div v-if="selectedPath.length > 0" class="flex items-center gap-2 text-sm">
|
|
||||||
<Button
|
|
||||||
variant="ghost"
|
|
||||||
size="sm"
|
|
||||||
@click="navigateToRoot"
|
|
||||||
class="h-7 px-2"
|
|
||||||
>
|
|
||||||
<ChevronLeft class="h-4 w-4 mr-1" />
|
|
||||||
Start
|
|
||||||
</Button>
|
|
||||||
<ChevronRight class="h-4 w-4 text-muted-foreground" />
|
|
||||||
<div class="flex items-center gap-2">
|
|
||||||
<span
|
|
||||||
v-for="(node, index) in selectedPath"
|
|
||||||
:key="node.account.id"
|
|
||||||
class="flex items-center gap-2"
|
|
||||||
>
|
|
||||||
<Button
|
|
||||||
variant="ghost"
|
|
||||||
size="sm"
|
|
||||||
@click="navigateToLevel(index)"
|
|
||||||
class="h-7 px-2 text-muted-foreground hover:text-foreground"
|
|
||||||
>
|
|
||||||
{{ getAccountDisplayName(node.account.name) }}
|
|
||||||
</Button>
|
|
||||||
<ChevronRight
|
|
||||||
v-if="index < selectedPath.length - 1"
|
|
||||||
class="h-4 w-4 text-muted-foreground"
|
|
||||||
/>
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Account selection list -->
|
|
||||||
<div class="border border-border rounded-lg bg-card">
|
|
||||||
<!-- Loading state -->
|
|
||||||
<div
|
|
||||||
v-if="isLoading"
|
|
||||||
class="flex items-center justify-center py-12"
|
|
||||||
>
|
|
||||||
<Loader2 class="h-6 w-6 animate-spin text-muted-foreground" />
|
|
||||||
<span class="ml-2 text-sm text-muted-foreground">Loading accounts...</span>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Error state -->
|
|
||||||
<div
|
|
||||||
v-else-if="error"
|
|
||||||
class="flex flex-col items-center justify-center py-12 px-4"
|
|
||||||
>
|
|
||||||
<AlertCircle class="h-8 w-8 text-destructive mb-2" />
|
|
||||||
<p class="text-sm text-destructive">{{ error }}</p>
|
|
||||||
<Button
|
|
||||||
variant="outline"
|
|
||||||
size="sm"
|
|
||||||
@click="loadAccounts"
|
|
||||||
class="mt-4"
|
|
||||||
>
|
|
||||||
<RefreshCw class="h-4 w-4 mr-2" />
|
|
||||||
Retry
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Account list -->
|
|
||||||
<div v-else-if="currentNodes.length > 0" class="divide-y divide-border">
|
|
||||||
<button
|
|
||||||
v-for="node in currentNodes"
|
|
||||||
:key="node.account.id"
|
|
||||||
@click="selectNode(node)"
|
|
||||||
type="button"
|
|
||||||
class="w-full flex items-center justify-between p-4 hover:bg-muted/50 transition-colors text-left"
|
|
||||||
>
|
|
||||||
<div class="flex items-center gap-3">
|
|
||||||
<Folder
|
|
||||||
v-if="node.account.has_children"
|
|
||||||
class="h-5 w-5 text-primary"
|
|
||||||
/>
|
|
||||||
<FileText
|
|
||||||
v-else
|
|
||||||
class="h-5 w-5 text-muted-foreground"
|
|
||||||
/>
|
|
||||||
<div>
|
|
||||||
<p class="font-medium text-foreground">
|
|
||||||
{{ getAccountDisplayName(node.account.name) }}
|
|
||||||
</p>
|
|
||||||
<p
|
|
||||||
v-if="node.account.description"
|
|
||||||
class="text-sm text-muted-foreground"
|
|
||||||
>
|
|
||||||
{{ node.account.description }}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="flex items-center gap-2">
|
|
||||||
<Badge
|
|
||||||
v-if="!node.account.has_children"
|
|
||||||
variant="outline"
|
|
||||||
class="text-xs"
|
|
||||||
>
|
|
||||||
{{ node.account.account_type }}
|
|
||||||
</Badge>
|
|
||||||
<ChevronRight
|
|
||||||
v-if="node.account.has_children"
|
|
||||||
class="h-5 w-5 text-muted-foreground"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Empty state -->
|
|
||||||
<div
|
|
||||||
v-else
|
|
||||||
class="flex flex-col items-center justify-center py-12 px-4"
|
|
||||||
>
|
|
||||||
<Folder class="h-12 w-12 text-muted-foreground mb-2" />
|
|
||||||
<p class="text-sm text-muted-foreground">No accounts available</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Selected account display -->
|
|
||||||
<div
|
|
||||||
v-if="selectedAccount"
|
|
||||||
class="flex items-center justify-between p-4 rounded-lg border-2 border-primary bg-primary/5"
|
|
||||||
>
|
|
||||||
<div class="flex items-center gap-3">
|
|
||||||
<Check class="h-5 w-5 text-primary" />
|
|
||||||
<div>
|
|
||||||
<p class="font-medium text-foreground">Selected Account</p>
|
|
||||||
<p class="text-sm text-muted-foreground">{{ selectedAccount.name }}</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<Badge variant="default">{{ selectedAccount.account_type }}</Badge>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<script setup lang="ts">
|
|
||||||
import { ref, computed, onMounted } from 'vue'
|
|
||||||
import { Button } from '@/components/ui/button'
|
|
||||||
import { Badge } from '@/components/ui/badge'
|
|
||||||
import {
|
|
||||||
ChevronLeft,
|
|
||||||
ChevronRight,
|
|
||||||
Folder,
|
|
||||||
FileText,
|
|
||||||
Loader2,
|
|
||||||
AlertCircle,
|
|
||||||
RefreshCw,
|
|
||||||
Check
|
|
||||||
} from 'lucide-vue-next'
|
|
||||||
import { injectService, SERVICE_TOKENS } from '@/core/di-container'
|
|
||||||
import { useAuth } from '@/composables/useAuthService'
|
|
||||||
import type { ExpensesAPI } from '../services/ExpensesAPI'
|
|
||||||
import type { Account, AccountNode } from '../types'
|
|
||||||
|
|
||||||
interface Props {
|
|
||||||
rootAccount?: string
|
|
||||||
modelValue?: Account | null
|
|
||||||
}
|
|
||||||
|
|
||||||
interface Emits {
|
|
||||||
(e: 'update:modelValue', value: Account | null): void
|
|
||||||
(e: 'account-selected', value: Account): void
|
|
||||||
}
|
|
||||||
|
|
||||||
const props = defineProps<Props>()
|
|
||||||
const emit = defineEmits<Emits>()
|
|
||||||
|
|
||||||
// Inject services and composables
|
|
||||||
const expensesAPI = injectService<ExpensesAPI>(SERVICE_TOKENS.EXPENSES_API)
|
|
||||||
const { user } = useAuth()
|
|
||||||
|
|
||||||
// Component state
|
|
||||||
const isLoading = ref(false)
|
|
||||||
const error = ref<string | null>(null)
|
|
||||||
const accountHierarchy = ref<AccountNode[]>([])
|
|
||||||
const selectedPath = ref<AccountNode[]>([])
|
|
||||||
const selectedAccount = ref<Account | null>(props.modelValue ?? null)
|
|
||||||
|
|
||||||
// Current nodes to display (either root or children of selected node)
|
|
||||||
const currentNodes = computed(() => {
|
|
||||||
if (selectedPath.value.length === 0) {
|
|
||||||
return accountHierarchy.value
|
|
||||||
}
|
|
||||||
|
|
||||||
const lastNode = selectedPath.value[selectedPath.value.length - 1]
|
|
||||||
return lastNode.children
|
|
||||||
})
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Get display name from full account path
|
|
||||||
* e.g., "Expenses:Groceries:Organic" -> "Organic"
|
|
||||||
*/
|
|
||||||
function getAccountDisplayName(fullName: string): string {
|
|
||||||
const parts = fullName.split(':')
|
|
||||||
return parts[parts.length - 1]
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Load accounts from API
|
|
||||||
*/
|
|
||||||
async function loadAccounts() {
|
|
||||||
isLoading.value = true
|
|
||||||
error.value = null
|
|
||||||
|
|
||||||
try {
|
|
||||||
// Get wallet key from first wallet (invoice key for read operations)
|
|
||||||
const wallet = user.value?.wallets?.[0]
|
|
||||||
if (!wallet || !wallet.inkey) {
|
|
||||||
throw new Error('No wallet available. Please log in.')
|
|
||||||
}
|
|
||||||
|
|
||||||
// Filter by user permissions to only show authorized accounts
|
|
||||||
accountHierarchy.value = await expensesAPI.getAccountHierarchy(
|
|
||||||
wallet.inkey,
|
|
||||||
props.rootAccount,
|
|
||||||
true // filterByUser
|
|
||||||
)
|
|
||||||
|
|
||||||
console.log('[AccountSelector] Loaded user-authorized accounts:', accountHierarchy.value)
|
|
||||||
} catch (err) {
|
|
||||||
error.value = err instanceof Error ? err.message : 'Failed to load accounts'
|
|
||||||
console.error('[AccountSelector] Error loading accounts:', err)
|
|
||||||
} finally {
|
|
||||||
isLoading.value = false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Handle node selection
|
|
||||||
*/
|
|
||||||
function selectNode(node: AccountNode) {
|
|
||||||
if (node.account.has_children) {
|
|
||||||
// Navigate into folder
|
|
||||||
selectedPath.value.push(node)
|
|
||||||
selectedAccount.value = null
|
|
||||||
emit('update:modelValue', null)
|
|
||||||
} else {
|
|
||||||
// Select leaf account
|
|
||||||
selectedAccount.value = node.account
|
|
||||||
emit('update:modelValue', node.account)
|
|
||||||
emit('account-selected', node.account)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Navigate back to root
|
|
||||||
*/
|
|
||||||
function navigateToRoot() {
|
|
||||||
selectedPath.value = []
|
|
||||||
selectedAccount.value = null
|
|
||||||
emit('update:modelValue', null)
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Navigate to specific level in breadcrumb
|
|
||||||
*/
|
|
||||||
function navigateToLevel(level: number) {
|
|
||||||
selectedPath.value = selectedPath.value.slice(0, level + 1)
|
|
||||||
selectedAccount.value = null
|
|
||||||
emit('update:modelValue', null)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Load accounts on mount
|
|
||||||
onMounted(() => {
|
|
||||||
loadAccounts()
|
|
||||||
})
|
|
||||||
</script>
|
|
||||||
|
|
@ -1,469 +0,0 @@
|
||||||
<template>
|
|
||||||
<Dialog :open="true" @update:open="(open) => !open && handleDialogClose()">
|
|
||||||
<DialogContent class="max-w-2xl max-h-[85vh] my-4 overflow-hidden flex flex-col p-0 gap-0">
|
|
||||||
<!-- Success State -->
|
|
||||||
<div v-if="showSuccessDialog" class="flex flex-col items-center justify-center p-8 space-y-6">
|
|
||||||
<!-- Success Icon -->
|
|
||||||
<div class="rounded-full bg-green-100 dark:bg-green-900/20 p-4">
|
|
||||||
<CheckCircle2 class="h-12 w-12 text-green-600 dark:text-green-400" />
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Success Message -->
|
|
||||||
<div class="text-center space-y-3">
|
|
||||||
<h2 class="text-2xl font-bold">Expense Submitted Successfully!</h2>
|
|
||||||
|
|
||||||
<!-- Pending Approval Badge -->
|
|
||||||
<div class="inline-flex items-center gap-2 px-4 py-2 rounded-full bg-orange-100 dark:bg-orange-900/20 border border-orange-200 dark:border-orange-800">
|
|
||||||
<Clock class="h-4 w-4 text-orange-600 dark:text-orange-400" />
|
|
||||||
<span class="text-sm font-medium text-orange-700 dark:text-orange-300">
|
|
||||||
Pending Admin Approval
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<p class="text-muted-foreground">
|
|
||||||
Your expense has been submitted successfully. An administrator will review and approve it shortly.
|
|
||||||
</p>
|
|
||||||
<p class="text-sm text-muted-foreground">
|
|
||||||
You can track the approval status in your transactions page.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Action Buttons -->
|
|
||||||
<div class="flex flex-col sm:flex-row gap-3 w-full max-w-sm">
|
|
||||||
<Button variant="outline" @click="closeSuccessDialog" class="flex-1">
|
|
||||||
Close
|
|
||||||
</Button>
|
|
||||||
<Button @click="goToTransactions" class="flex-1">
|
|
||||||
<Receipt class="h-4 w-4 mr-2" />
|
|
||||||
View My Transactions
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Form State -->
|
|
||||||
<template v-else>
|
|
||||||
<!-- Header -->
|
|
||||||
<DialogHeader class="px-6 pt-6 pb-4 border-b shrink-0">
|
|
||||||
<DialogTitle class="flex items-center gap-2">
|
|
||||||
<DollarSign class="h-5 w-5 text-primary" />
|
|
||||||
<span>Add Expense</span>
|
|
||||||
</DialogTitle>
|
|
||||||
<DialogDescription>
|
|
||||||
Submit an expense for admin approval
|
|
||||||
</DialogDescription>
|
|
||||||
</DialogHeader>
|
|
||||||
|
|
||||||
<!-- Scrollable Form Content -->
|
|
||||||
<div class="flex-1 overflow-y-auto px-6 py-4 space-y-4 min-h-0">
|
|
||||||
<!-- Step indicator -->
|
|
||||||
<div class="flex items-center justify-center gap-2 mb-4">
|
|
||||||
<div
|
|
||||||
:class="[
|
|
||||||
'flex items-center justify-center w-8 h-8 rounded-full font-medium text-sm transition-colors',
|
|
||||||
currentStep === 1
|
|
||||||
? 'bg-primary text-primary-foreground'
|
|
||||||
: selectedAccount
|
|
||||||
? 'bg-primary/20 text-primary'
|
|
||||||
: 'bg-muted text-muted-foreground'
|
|
||||||
]"
|
|
||||||
>
|
|
||||||
1
|
|
||||||
</div>
|
|
||||||
<div class="w-12 h-px bg-border" />
|
|
||||||
<div
|
|
||||||
:class="[
|
|
||||||
'flex items-center justify-center w-8 h-8 rounded-full font-medium text-sm transition-colors',
|
|
||||||
currentStep === 2
|
|
||||||
? 'bg-primary text-primary-foreground'
|
|
||||||
: 'bg-muted text-muted-foreground'
|
|
||||||
]"
|
|
||||||
>
|
|
||||||
2
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Step 1: Account Selection -->
|
|
||||||
<div v-if="currentStep === 1">
|
|
||||||
<p class="text-sm text-muted-foreground mb-4">
|
|
||||||
Select the account for this expense
|
|
||||||
</p>
|
|
||||||
<AccountSelector
|
|
||||||
v-model="selectedAccount"
|
|
||||||
root-account="Expenses"
|
|
||||||
@account-selected="handleAccountSelected"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Step 2: Expense Details -->
|
|
||||||
<div v-if="currentStep === 2">
|
|
||||||
<form @submit="onSubmit" class="space-y-4">
|
|
||||||
<!-- Description -->
|
|
||||||
<FormField v-slot="{ componentField }" name="description">
|
|
||||||
<FormItem>
|
|
||||||
<FormLabel>Description *</FormLabel>
|
|
||||||
<FormControl>
|
|
||||||
<Textarea
|
|
||||||
placeholder="e.g., Biocoop, Ferme des Croquantes, Foix Market, etc"
|
|
||||||
v-bind="componentField"
|
|
||||||
rows="3"
|
|
||||||
/>
|
|
||||||
</FormControl>
|
|
||||||
<FormDescription>
|
|
||||||
Describe what this expense was for
|
|
||||||
</FormDescription>
|
|
||||||
<FormMessage />
|
|
||||||
</FormItem>
|
|
||||||
</FormField>
|
|
||||||
|
|
||||||
<!-- Amount -->
|
|
||||||
<FormField v-slot="{ componentField }" name="amount">
|
|
||||||
<FormItem>
|
|
||||||
<FormLabel>Amount *</FormLabel>
|
|
||||||
<FormControl>
|
|
||||||
<Input
|
|
||||||
type="number"
|
|
||||||
placeholder="0.00"
|
|
||||||
v-bind="componentField"
|
|
||||||
min="0.01"
|
|
||||||
step="0.01"
|
|
||||||
/>
|
|
||||||
</FormControl>
|
|
||||||
<FormDescription>
|
|
||||||
Amount in selected currency
|
|
||||||
</FormDescription>
|
|
||||||
<FormMessage />
|
|
||||||
</FormItem>
|
|
||||||
</FormField>
|
|
||||||
|
|
||||||
<!-- Currency -->
|
|
||||||
<FormField v-slot="{ componentField }" name="currency">
|
|
||||||
<FormItem>
|
|
||||||
<FormLabel>Currency *</FormLabel>
|
|
||||||
<Select v-bind="componentField">
|
|
||||||
<FormControl>
|
|
||||||
<SelectTrigger>
|
|
||||||
<SelectValue placeholder="Select currency" />
|
|
||||||
</SelectTrigger>
|
|
||||||
</FormControl>
|
|
||||||
<SelectContent>
|
|
||||||
<SelectItem
|
|
||||||
v-for="currency in availableCurrencies"
|
|
||||||
:key="currency"
|
|
||||||
:value="currency"
|
|
||||||
>
|
|
||||||
{{ currency }}
|
|
||||||
</SelectItem>
|
|
||||||
</SelectContent>
|
|
||||||
</Select>
|
|
||||||
<FormDescription>
|
|
||||||
Currency for this expense
|
|
||||||
</FormDescription>
|
|
||||||
<FormMessage />
|
|
||||||
</FormItem>
|
|
||||||
</FormField>
|
|
||||||
|
|
||||||
<!-- Reference (optional) -->
|
|
||||||
<FormField v-slot="{ componentField }" name="reference">
|
|
||||||
<FormItem>
|
|
||||||
<FormLabel>Reference</FormLabel>
|
|
||||||
<FormControl>
|
|
||||||
<Input
|
|
||||||
placeholder="e.g., Invoice #123, Receipt #456"
|
|
||||||
v-bind="componentField"
|
|
||||||
/>
|
|
||||||
</FormControl>
|
|
||||||
<FormDescription>
|
|
||||||
Optional reference number or note
|
|
||||||
</FormDescription>
|
|
||||||
<FormMessage />
|
|
||||||
</FormItem>
|
|
||||||
</FormField>
|
|
||||||
|
|
||||||
<!-- Convert to equity checkbox (only show if user is equity eligible) -->
|
|
||||||
<FormField v-if="userInfo?.is_equity_eligible" v-slot="{ value, handleChange }" name="isEquity">
|
|
||||||
<FormItem>
|
|
||||||
<div class="flex items-center space-x-2">
|
|
||||||
<FormControl>
|
|
||||||
<Checkbox
|
|
||||||
:model-value="value"
|
|
||||||
@update:model-value="handleChange"
|
|
||||||
:disabled="isSubmitting"
|
|
||||||
/>
|
|
||||||
</FormControl>
|
|
||||||
<div class="space-y-1 leading-none">
|
|
||||||
<FormLabel>Convert to equity contribution</FormLabel>
|
|
||||||
<FormDescription>
|
|
||||||
Instead of cash reimbursement, increase your equity stake by this amount
|
|
||||||
</FormDescription>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</FormItem>
|
|
||||||
</FormField>
|
|
||||||
|
|
||||||
<!-- Selected account info -->
|
|
||||||
<div class="p-3 rounded-lg bg-muted/50">
|
|
||||||
<div class="flex items-center gap-2 mb-1">
|
|
||||||
<span class="text-sm text-muted-foreground">Account:</span>
|
|
||||||
<Badge variant="secondary" class="font-mono">{{ selectedAccount?.name }}</Badge>
|
|
||||||
</div>
|
|
||||||
<p
|
|
||||||
v-if="selectedAccount?.description"
|
|
||||||
class="text-xs text-muted-foreground"
|
|
||||||
>
|
|
||||||
{{ selectedAccount.description }}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Action buttons -->
|
|
||||||
<div class="flex items-center gap-2 pt-2 pb-6">
|
|
||||||
<Button
|
|
||||||
type="button"
|
|
||||||
variant="outline"
|
|
||||||
@click="currentStep = 1"
|
|
||||||
:disabled="isSubmitting"
|
|
||||||
class="flex-1"
|
|
||||||
>
|
|
||||||
<ChevronLeft class="h-4 w-4 mr-1" />
|
|
||||||
Back
|
|
||||||
</Button>
|
|
||||||
<Button
|
|
||||||
type="submit"
|
|
||||||
:disabled="isSubmitting || !isFormValid"
|
|
||||||
class="flex-1"
|
|
||||||
>
|
|
||||||
<Loader2
|
|
||||||
v-if="isSubmitting"
|
|
||||||
class="h-4 w-4 mr-2 animate-spin"
|
|
||||||
/>
|
|
||||||
<span>{{ isSubmitting ? 'Submitting...' : 'Submit Expense' }}</span>
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</form>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
</DialogContent>
|
|
||||||
</Dialog>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<script setup lang="ts">
|
|
||||||
import { ref, computed, onMounted } from 'vue'
|
|
||||||
import { useRouter } from 'vue-router'
|
|
||||||
import { useForm } from 'vee-validate'
|
|
||||||
import { toTypedSchema } from '@vee-validate/zod'
|
|
||||||
import * as z from 'zod'
|
|
||||||
import { Button } from '@/components/ui/button'
|
|
||||||
import { Input } from '@/components/ui/input'
|
|
||||||
import { Textarea } from '@/components/ui/textarea'
|
|
||||||
import { Checkbox } from '@/components/ui/checkbox'
|
|
||||||
import { Badge } from '@/components/ui/badge'
|
|
||||||
import {
|
|
||||||
FormControl,
|
|
||||||
FormDescription,
|
|
||||||
FormField,
|
|
||||||
FormItem,
|
|
||||||
FormLabel,
|
|
||||||
FormMessage,
|
|
||||||
} from '@/components/ui/form'
|
|
||||||
import {
|
|
||||||
Select,
|
|
||||||
SelectContent,
|
|
||||||
SelectItem,
|
|
||||||
SelectTrigger,
|
|
||||||
SelectValue,
|
|
||||||
} from '@/components/ui/select'
|
|
||||||
import {
|
|
||||||
Dialog,
|
|
||||||
DialogContent,
|
|
||||||
DialogDescription,
|
|
||||||
DialogHeader,
|
|
||||||
DialogTitle,
|
|
||||||
} from '@/components/ui/dialog'
|
|
||||||
import { DollarSign, ChevronLeft, Loader2, CheckCircle2, Receipt, Clock } from 'lucide-vue-next'
|
|
||||||
import { injectService, SERVICE_TOKENS } from '@/core/di-container'
|
|
||||||
import { useAuth } from '@/composables/useAuthService'
|
|
||||||
import { useToast } from '@/core/composables/useToast'
|
|
||||||
import type { ExpensesAPI } from '../services/ExpensesAPI'
|
|
||||||
import type { Account, UserInfo } from '../types'
|
|
||||||
import AccountSelector from './AccountSelector.vue'
|
|
||||||
|
|
||||||
interface Emits {
|
|
||||||
(e: 'close'): void
|
|
||||||
(e: 'expense-submitted'): void
|
|
||||||
(e: 'action-complete'): void
|
|
||||||
}
|
|
||||||
|
|
||||||
const emit = defineEmits<Emits>()
|
|
||||||
|
|
||||||
// Inject services and composables
|
|
||||||
const expensesAPI = injectService<ExpensesAPI>(SERVICE_TOKENS.EXPENSES_API)
|
|
||||||
const { user } = useAuth()
|
|
||||||
const toast = useToast()
|
|
||||||
const router = useRouter()
|
|
||||||
|
|
||||||
// Component state
|
|
||||||
const currentStep = ref(1)
|
|
||||||
const selectedAccount = ref<Account | null>(null)
|
|
||||||
const isSubmitting = ref(false)
|
|
||||||
const availableCurrencies = ref<string[]>([])
|
|
||||||
const loadingCurrencies = ref(true)
|
|
||||||
const userInfo = ref<UserInfo | null>(null)
|
|
||||||
const showSuccessDialog = ref(false)
|
|
||||||
|
|
||||||
// Form schema
|
|
||||||
const formSchema = toTypedSchema(
|
|
||||||
z.object({
|
|
||||||
description: z.string().min(1, 'Description is required').max(500, 'Description too long'),
|
|
||||||
amount: z.coerce.number().min(0.01, 'Amount must be at least 0.01'),
|
|
||||||
currency: z.string().min(1, 'Currency is required'),
|
|
||||||
reference: z.string().max(100, 'Reference too long').optional(),
|
|
||||||
isEquity: z.boolean().default(false)
|
|
||||||
})
|
|
||||||
)
|
|
||||||
|
|
||||||
// Set up form
|
|
||||||
const form = useForm({
|
|
||||||
validationSchema: formSchema,
|
|
||||||
initialValues: {
|
|
||||||
description: '',
|
|
||||||
amount: 0,
|
|
||||||
currency: '', // Will be set dynamically from LNbits default currency
|
|
||||||
reference: '',
|
|
||||||
isEquity: false
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
const { resetForm, meta } = form
|
|
||||||
const isFormValid = computed(() => meta.value.valid)
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Fetch available currencies, default currency, and user info on component mount
|
|
||||||
*/
|
|
||||||
onMounted(async () => {
|
|
||||||
try {
|
|
||||||
loadingCurrencies.value = true
|
|
||||||
|
|
||||||
// Get wallet key
|
|
||||||
const wallet = user.value?.wallets?.[0]
|
|
||||||
if (!wallet || !wallet.inkey) {
|
|
||||||
console.warn('[AddExpense] No wallet available for loading data')
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
// Fetch available currencies
|
|
||||||
const currencies = await expensesAPI.getCurrencies()
|
|
||||||
availableCurrencies.value = currencies
|
|
||||||
console.log('[AddExpense] Loaded currencies:', currencies)
|
|
||||||
|
|
||||||
// Fetch default currency
|
|
||||||
const defaultCurrency = await expensesAPI.getDefaultCurrency()
|
|
||||||
|
|
||||||
// Set default currency: use configured default, or first available currency, or 'EUR' as final fallback
|
|
||||||
const initialCurrency = defaultCurrency || currencies[0] || 'EUR'
|
|
||||||
form.setFieldValue('currency', initialCurrency)
|
|
||||||
console.log('[AddExpense] Default currency set to:', initialCurrency)
|
|
||||||
|
|
||||||
// Fetch user info to check equity eligibility
|
|
||||||
userInfo.value = await expensesAPI.getUserInfo(wallet.inkey)
|
|
||||||
console.log('[AddExpense] User info loaded:', {
|
|
||||||
is_equity_eligible: userInfo.value.is_equity_eligible,
|
|
||||||
equity_account: userInfo.value.equity_account_name
|
|
||||||
})
|
|
||||||
} catch (error) {
|
|
||||||
console.error('[AddExpense] Failed to load data:', error)
|
|
||||||
toast.error('Failed to load form data', { description: 'Please check your connection and try again' })
|
|
||||||
availableCurrencies.value = []
|
|
||||||
} finally {
|
|
||||||
loadingCurrencies.value = false
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Handle account selection
|
|
||||||
*/
|
|
||||||
function handleAccountSelected(account: Account) {
|
|
||||||
selectedAccount.value = account
|
|
||||||
currentStep.value = 2
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Submit expense
|
|
||||||
*/
|
|
||||||
const onSubmit = form.handleSubmit(async (values) => {
|
|
||||||
if (!selectedAccount.value) {
|
|
||||||
console.error('[AddExpense] No account selected')
|
|
||||||
toast.error('No account selected', { description: 'Please select an account first' })
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
// Get wallet key from first wallet (invoice key for submission)
|
|
||||||
const wallet = user.value?.wallets?.[0]
|
|
||||||
if (!wallet || !wallet.inkey) {
|
|
||||||
toast.error('No wallet available', { description: 'Please log in to submit expenses' })
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
isSubmitting.value = true
|
|
||||||
|
|
||||||
try {
|
|
||||||
await expensesAPI.submitExpense(wallet.inkey, {
|
|
||||||
description: values.description,
|
|
||||||
amount: values.amount,
|
|
||||||
expense_account: selectedAccount.value.name,
|
|
||||||
is_equity: values.isEquity,
|
|
||||||
user_wallet: wallet.id,
|
|
||||||
reference: values.reference,
|
|
||||||
currency: values.currency
|
|
||||||
})
|
|
||||||
|
|
||||||
// Show success dialog instead of toast
|
|
||||||
showSuccessDialog.value = true
|
|
||||||
|
|
||||||
// Reset form for next submission
|
|
||||||
resetForm()
|
|
||||||
selectedAccount.value = null
|
|
||||||
currentStep.value = 1
|
|
||||||
|
|
||||||
emit('expense-submitted')
|
|
||||||
// DON'T emit 'action-complete' yet - wait for user to close success dialog
|
|
||||||
} catch (error) {
|
|
||||||
console.error('[AddExpense] Error submitting expense:', error)
|
|
||||||
const errorMessage = error instanceof Error ? error.message : 'Unknown error'
|
|
||||||
toast.error('Submission failed', { description: errorMessage })
|
|
||||||
} finally {
|
|
||||||
isSubmitting.value = false
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Handle viewing transactions
|
|
||||||
*/
|
|
||||||
function goToTransactions() {
|
|
||||||
showSuccessDialog.value = false
|
|
||||||
emit('action-complete')
|
|
||||||
emit('close')
|
|
||||||
router.push('/expenses/transactions')
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Handle closing success dialog
|
|
||||||
*/
|
|
||||||
function closeSuccessDialog() {
|
|
||||||
showSuccessDialog.value = false
|
|
||||||
emit('action-complete')
|
|
||||||
emit('close')
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Handle dialog close (from X button or clicking outside)
|
|
||||||
*/
|
|
||||||
function handleDialogClose() {
|
|
||||||
if (showSuccessDialog.value) {
|
|
||||||
// If in success state, close the whole thing
|
|
||||||
closeSuccessDialog()
|
|
||||||
} else {
|
|
||||||
// If in form state, just close normally
|
|
||||||
emit('close')
|
|
||||||
}
|
|
||||||
}
|
|
||||||
</script>
|
|
||||||
|
|
@ -1,256 +0,0 @@
|
||||||
<script setup lang="ts">
|
|
||||||
import { ref, computed } from 'vue'
|
|
||||||
import { useForm } from 'vee-validate'
|
|
||||||
import { toTypedSchema } from '@vee-validate/zod'
|
|
||||||
import * as z from 'zod'
|
|
||||||
import { useAuth } from '@/composables/useAuthService'
|
|
||||||
import { useToast } from '@/core/composables/useToast'
|
|
||||||
import { injectService, SERVICE_TOKENS } from '@/core/di-container'
|
|
||||||
import type { ExpensesAPI } from '../../services/ExpensesAPI'
|
|
||||||
import type { Account } from '../../types'
|
|
||||||
import { PermissionType } from '../../types'
|
|
||||||
|
|
||||||
import { Button } from '@/components/ui/button'
|
|
||||||
import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle } from '@/components/ui/dialog'
|
|
||||||
import {
|
|
||||||
FormControl,
|
|
||||||
FormDescription,
|
|
||||||
FormField,
|
|
||||||
FormItem,
|
|
||||||
FormLabel,
|
|
||||||
FormMessage
|
|
||||||
} from '@/components/ui/form'
|
|
||||||
import { Input } from '@/components/ui/input'
|
|
||||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'
|
|
||||||
import { Textarea } from '@/components/ui/textarea'
|
|
||||||
import { Loader2 } from 'lucide-vue-next'
|
|
||||||
|
|
||||||
interface Props {
|
|
||||||
isOpen: boolean
|
|
||||||
accounts: Account[]
|
|
||||||
}
|
|
||||||
|
|
||||||
const props = defineProps<Props>()
|
|
||||||
|
|
||||||
const emit = defineEmits<{
|
|
||||||
close: []
|
|
||||||
permissionGranted: []
|
|
||||||
}>()
|
|
||||||
|
|
||||||
const { user } = useAuth()
|
|
||||||
const toast = useToast()
|
|
||||||
const expensesAPI = injectService<ExpensesAPI>(SERVICE_TOKENS.EXPENSES_API)
|
|
||||||
|
|
||||||
const isGranting = ref(false)
|
|
||||||
|
|
||||||
const adminKey = computed(() => user.value?.wallets?.[0]?.adminkey)
|
|
||||||
|
|
||||||
// Form schema
|
|
||||||
const formSchema = toTypedSchema(
|
|
||||||
z.object({
|
|
||||||
user_id: z.string().min(1, 'User ID is required'),
|
|
||||||
account_id: z.string().min(1, 'Account is required'),
|
|
||||||
permission_type: z.nativeEnum(PermissionType, {
|
|
||||||
errorMap: () => ({ message: 'Permission type is required' })
|
|
||||||
}),
|
|
||||||
notes: z.string().optional(),
|
|
||||||
expires_at: z.string().optional()
|
|
||||||
})
|
|
||||||
)
|
|
||||||
|
|
||||||
// Setup form
|
|
||||||
const form = useForm({
|
|
||||||
validationSchema: formSchema,
|
|
||||||
initialValues: {
|
|
||||||
user_id: '',
|
|
||||||
account_id: '',
|
|
||||||
permission_type: PermissionType.READ,
|
|
||||||
notes: '',
|
|
||||||
expires_at: ''
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
const { resetForm, meta } = form
|
|
||||||
const isFormValid = computed(() => meta.value.valid)
|
|
||||||
|
|
||||||
// Permission type options
|
|
||||||
const permissionTypes = [
|
|
||||||
{ value: PermissionType.READ, label: 'Read', description: 'View account and balance' },
|
|
||||||
{
|
|
||||||
value: PermissionType.SUBMIT_EXPENSE,
|
|
||||||
label: 'Submit Expense',
|
|
||||||
description: 'Submit expenses to this account'
|
|
||||||
},
|
|
||||||
{ value: PermissionType.MANAGE, label: 'Manage', description: 'Full account management' }
|
|
||||||
]
|
|
||||||
|
|
||||||
// Submit form
|
|
||||||
const onSubmit = form.handleSubmit(async (values) => {
|
|
||||||
if (!adminKey.value) {
|
|
||||||
toast.error('Admin access required')
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
isGranting.value = true
|
|
||||||
|
|
||||||
try {
|
|
||||||
await expensesAPI.grantPermission(adminKey.value, {
|
|
||||||
user_id: values.user_id,
|
|
||||||
account_id: values.account_id,
|
|
||||||
permission_type: values.permission_type,
|
|
||||||
notes: values.notes || undefined,
|
|
||||||
expires_at: values.expires_at || undefined
|
|
||||||
})
|
|
||||||
|
|
||||||
emit('permissionGranted')
|
|
||||||
resetForm()
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Failed to grant permission:', error)
|
|
||||||
toast.error('Failed to grant permission', {
|
|
||||||
description: error instanceof Error ? error.message : 'Unknown error'
|
|
||||||
})
|
|
||||||
} finally {
|
|
||||||
isGranting.value = false
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
// Handle dialog close
|
|
||||||
function handleClose() {
|
|
||||||
if (!isGranting.value) {
|
|
||||||
resetForm()
|
|
||||||
emit('close')
|
|
||||||
}
|
|
||||||
}
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<template>
|
|
||||||
<Dialog :open="props.isOpen" @update:open="handleClose">
|
|
||||||
<DialogContent class="sm:max-w-[500px]">
|
|
||||||
<DialogHeader>
|
|
||||||
<DialogTitle>Grant Account Permission</DialogTitle>
|
|
||||||
<DialogDescription>
|
|
||||||
Grant a user permission to access an expense account. Permissions on parent accounts
|
|
||||||
cascade to children.
|
|
||||||
</DialogDescription>
|
|
||||||
</DialogHeader>
|
|
||||||
|
|
||||||
<form @submit="onSubmit" class="space-y-4">
|
|
||||||
<!-- User ID -->
|
|
||||||
<FormField v-slot="{ componentField }" name="user_id">
|
|
||||||
<FormItem>
|
|
||||||
<FormLabel>User ID *</FormLabel>
|
|
||||||
<FormControl>
|
|
||||||
<Input
|
|
||||||
placeholder="Enter user wallet ID"
|
|
||||||
v-bind="componentField"
|
|
||||||
:disabled="isGranting"
|
|
||||||
/>
|
|
||||||
</FormControl>
|
|
||||||
<FormDescription>The wallet ID of the user to grant permission to</FormDescription>
|
|
||||||
<FormMessage />
|
|
||||||
</FormItem>
|
|
||||||
</FormField>
|
|
||||||
|
|
||||||
<!-- Account -->
|
|
||||||
<FormField v-slot="{ componentField }" name="account_id">
|
|
||||||
<FormItem>
|
|
||||||
<FormLabel>Account *</FormLabel>
|
|
||||||
<Select v-bind="componentField" :disabled="isGranting">
|
|
||||||
<FormControl>
|
|
||||||
<SelectTrigger>
|
|
||||||
<SelectValue placeholder="Select account" />
|
|
||||||
</SelectTrigger>
|
|
||||||
</FormControl>
|
|
||||||
<SelectContent>
|
|
||||||
<SelectItem
|
|
||||||
v-for="account in props.accounts"
|
|
||||||
:key="account.id"
|
|
||||||
:value="account.id"
|
|
||||||
>
|
|
||||||
{{ account.name }}
|
|
||||||
</SelectItem>
|
|
||||||
</SelectContent>
|
|
||||||
</Select>
|
|
||||||
<FormDescription>Account to grant access to</FormDescription>
|
|
||||||
<FormMessage />
|
|
||||||
</FormItem>
|
|
||||||
</FormField>
|
|
||||||
|
|
||||||
<!-- Permission Type -->
|
|
||||||
<FormField v-slot="{ componentField }" name="permission_type">
|
|
||||||
<FormItem>
|
|
||||||
<FormLabel>Permission Type *</FormLabel>
|
|
||||||
<Select v-bind="componentField" :disabled="isGranting">
|
|
||||||
<FormControl>
|
|
||||||
<SelectTrigger>
|
|
||||||
<SelectValue placeholder="Select permission type" />
|
|
||||||
</SelectTrigger>
|
|
||||||
</FormControl>
|
|
||||||
<SelectContent>
|
|
||||||
<SelectItem
|
|
||||||
v-for="type in permissionTypes"
|
|
||||||
:key="type.value"
|
|
||||||
:value="type.value"
|
|
||||||
>
|
|
||||||
<div>
|
|
||||||
<div class="font-medium">{{ type.label }}</div>
|
|
||||||
<div class="text-sm text-muted-foreground">{{ type.description }}</div>
|
|
||||||
</div>
|
|
||||||
</SelectItem>
|
|
||||||
</SelectContent>
|
|
||||||
</Select>
|
|
||||||
<FormDescription>Type of permission to grant</FormDescription>
|
|
||||||
<FormMessage />
|
|
||||||
</FormItem>
|
|
||||||
</FormField>
|
|
||||||
|
|
||||||
<!-- Expiration Date (Optional) -->
|
|
||||||
<FormField v-slot="{ componentField }" name="expires_at">
|
|
||||||
<FormItem>
|
|
||||||
<FormLabel>Expiration Date (Optional)</FormLabel>
|
|
||||||
<FormControl>
|
|
||||||
<Input
|
|
||||||
type="datetime-local"
|
|
||||||
v-bind="componentField"
|
|
||||||
:disabled="isGranting"
|
|
||||||
/>
|
|
||||||
</FormControl>
|
|
||||||
<FormDescription>Leave empty for permanent access</FormDescription>
|
|
||||||
<FormMessage />
|
|
||||||
</FormItem>
|
|
||||||
</FormField>
|
|
||||||
|
|
||||||
<!-- Notes (Optional) -->
|
|
||||||
<FormField v-slot="{ componentField }" name="notes">
|
|
||||||
<FormItem>
|
|
||||||
<FormLabel>Notes (Optional)</FormLabel>
|
|
||||||
<FormControl>
|
|
||||||
<Textarea
|
|
||||||
placeholder="Add notes about this permission..."
|
|
||||||
v-bind="componentField"
|
|
||||||
:disabled="isGranting"
|
|
||||||
/>
|
|
||||||
</FormControl>
|
|
||||||
<FormDescription>Optional notes for admin reference</FormDescription>
|
|
||||||
<FormMessage />
|
|
||||||
</FormItem>
|
|
||||||
</FormField>
|
|
||||||
|
|
||||||
<DialogFooter>
|
|
||||||
<Button
|
|
||||||
type="button"
|
|
||||||
variant="outline"
|
|
||||||
@click="handleClose"
|
|
||||||
:disabled="isGranting"
|
|
||||||
>
|
|
||||||
Cancel
|
|
||||||
</Button>
|
|
||||||
<Button type="submit" :disabled="isGranting || !isFormValid">
|
|
||||||
<Loader2 v-if="isGranting" class="mr-2 h-4 w-4 animate-spin" />
|
|
||||||
{{ isGranting ? 'Granting...' : 'Grant Permission' }}
|
|
||||||
</Button>
|
|
||||||
</DialogFooter>
|
|
||||||
</form>
|
|
||||||
</DialogContent>
|
|
||||||
</Dialog>
|
|
||||||
</template>
|
|
||||||
|
|
@ -1,399 +0,0 @@
|
||||||
<script setup lang="ts">
|
|
||||||
import { ref, onMounted, computed } from 'vue'
|
|
||||||
import { useAuth } from '@/composables/useAuthService'
|
|
||||||
import { useToast } from '@/core/composables/useToast'
|
|
||||||
import { injectService, SERVICE_TOKENS } from '@/core/di-container'
|
|
||||||
import type { ExpensesAPI } from '../../services/ExpensesAPI'
|
|
||||||
import type { AccountPermission, Account } from '../../types'
|
|
||||||
import { PermissionType } from '../../types'
|
|
||||||
|
|
||||||
import { Button } from '@/components/ui/button'
|
|
||||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'
|
|
||||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'
|
|
||||||
import { Badge } from '@/components/ui/badge'
|
|
||||||
import { Loader2, Plus, Trash2, Users, Shield } from 'lucide-vue-next'
|
|
||||||
import {
|
|
||||||
Table,
|
|
||||||
TableBody,
|
|
||||||
TableCell,
|
|
||||||
TableHead,
|
|
||||||
TableHeader,
|
|
||||||
TableRow
|
|
||||||
} from '@/components/ui/table'
|
|
||||||
import {
|
|
||||||
AlertDialog,
|
|
||||||
AlertDialogAction,
|
|
||||||
AlertDialogCancel,
|
|
||||||
AlertDialogContent,
|
|
||||||
AlertDialogDescription,
|
|
||||||
AlertDialogFooter,
|
|
||||||
AlertDialogHeader,
|
|
||||||
AlertDialogTitle
|
|
||||||
} from '@/components/ui/alert-dialog'
|
|
||||||
|
|
||||||
import GrantPermissionDialog from './GrantPermissionDialog.vue'
|
|
||||||
|
|
||||||
const { user } = useAuth()
|
|
||||||
const toast = useToast()
|
|
||||||
const expensesAPI = injectService<ExpensesAPI>(SERVICE_TOKENS.EXPENSES_API)
|
|
||||||
|
|
||||||
const permissions = ref<AccountPermission[]>([])
|
|
||||||
const accounts = ref<Account[]>([])
|
|
||||||
const isLoading = ref(false)
|
|
||||||
const showGrantDialog = ref(false)
|
|
||||||
const permissionToRevoke = ref<AccountPermission | null>(null)
|
|
||||||
const showRevokeDialog = ref(false)
|
|
||||||
|
|
||||||
const adminKey = computed(() => user.value?.wallets?.[0]?.adminkey)
|
|
||||||
|
|
||||||
// Get permission type badge variant
|
|
||||||
function getPermissionBadge(type: PermissionType) {
|
|
||||||
switch (type) {
|
|
||||||
case PermissionType.READ:
|
|
||||||
return 'default'
|
|
||||||
case PermissionType.SUBMIT_EXPENSE:
|
|
||||||
return 'secondary'
|
|
||||||
case PermissionType.MANAGE:
|
|
||||||
return 'destructive'
|
|
||||||
default:
|
|
||||||
return 'outline'
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Get permission type label
|
|
||||||
function getPermissionLabel(type: PermissionType): string {
|
|
||||||
switch (type) {
|
|
||||||
case PermissionType.READ:
|
|
||||||
return 'Read'
|
|
||||||
case PermissionType.SUBMIT_EXPENSE:
|
|
||||||
return 'Submit Expense'
|
|
||||||
case PermissionType.MANAGE:
|
|
||||||
return 'Manage'
|
|
||||||
default:
|
|
||||||
return type
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Get account name by ID
|
|
||||||
function getAccountName(accountId: string): string {
|
|
||||||
const account = accounts.value.find((a) => a.id === accountId)
|
|
||||||
return account?.name || accountId
|
|
||||||
}
|
|
||||||
|
|
||||||
// Format date
|
|
||||||
function formatDate(dateString: string): string {
|
|
||||||
const date = new Date(dateString)
|
|
||||||
return date.toLocaleDateString() + ' ' + date.toLocaleTimeString()
|
|
||||||
}
|
|
||||||
|
|
||||||
// Load accounts
|
|
||||||
async function loadAccounts() {
|
|
||||||
if (!adminKey.value) return
|
|
||||||
|
|
||||||
try {
|
|
||||||
accounts.value = await expensesAPI.getAccounts(adminKey.value)
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Failed to load accounts:', error)
|
|
||||||
toast.error('Failed to load accounts', {
|
|
||||||
description: error instanceof Error ? error.message : 'Unknown error'
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Load all permissions
|
|
||||||
async function loadPermissions() {
|
|
||||||
if (!adminKey.value) {
|
|
||||||
toast.error('Admin access required', {
|
|
||||||
description: 'You need admin privileges to manage permissions'
|
|
||||||
})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
isLoading.value = true
|
|
||||||
try {
|
|
||||||
permissions.value = await expensesAPI.listPermissions(adminKey.value)
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Failed to load permissions:', error)
|
|
||||||
toast.error('Failed to load permissions', {
|
|
||||||
description: error instanceof Error ? error.message : 'Unknown error'
|
|
||||||
})
|
|
||||||
} finally {
|
|
||||||
isLoading.value = false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Handle permission granted
|
|
||||||
function handlePermissionGranted() {
|
|
||||||
showGrantDialog.value = false
|
|
||||||
loadPermissions()
|
|
||||||
toast.success('Permission granted', {
|
|
||||||
description: 'User permission has been successfully granted'
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
// Confirm revoke permission
|
|
||||||
function confirmRevoke(permission: AccountPermission) {
|
|
||||||
permissionToRevoke.value = permission
|
|
||||||
showRevokeDialog.value = true
|
|
||||||
}
|
|
||||||
|
|
||||||
// Revoke permission
|
|
||||||
async function revokePermission() {
|
|
||||||
if (!adminKey.value || !permissionToRevoke.value) return
|
|
||||||
|
|
||||||
try {
|
|
||||||
await expensesAPI.revokePermission(adminKey.value, permissionToRevoke.value.id)
|
|
||||||
toast.success('Permission revoked', {
|
|
||||||
description: 'User permission has been successfully revoked'
|
|
||||||
})
|
|
||||||
loadPermissions()
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Failed to revoke permission:', error)
|
|
||||||
toast.error('Failed to revoke permission', {
|
|
||||||
description: error instanceof Error ? error.message : 'Unknown error'
|
|
||||||
})
|
|
||||||
} finally {
|
|
||||||
showRevokeDialog.value = false
|
|
||||||
permissionToRevoke.value = null
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Group permissions by user
|
|
||||||
const permissionsByUser = computed(() => {
|
|
||||||
const grouped = new Map<string, AccountPermission[]>()
|
|
||||||
|
|
||||||
for (const permission of permissions.value) {
|
|
||||||
const existing = grouped.get(permission.user_id) || []
|
|
||||||
existing.push(permission)
|
|
||||||
grouped.set(permission.user_id, existing)
|
|
||||||
}
|
|
||||||
|
|
||||||
return grouped
|
|
||||||
})
|
|
||||||
|
|
||||||
// Group permissions by account
|
|
||||||
const permissionsByAccount = computed(() => {
|
|
||||||
const grouped = new Map<string, AccountPermission[]>()
|
|
||||||
|
|
||||||
for (const permission of permissions.value) {
|
|
||||||
const existing = grouped.get(permission.account_id) || []
|
|
||||||
existing.push(permission)
|
|
||||||
grouped.set(permission.account_id, existing)
|
|
||||||
}
|
|
||||||
|
|
||||||
return grouped
|
|
||||||
})
|
|
||||||
|
|
||||||
onMounted(() => {
|
|
||||||
loadAccounts()
|
|
||||||
loadPermissions()
|
|
||||||
})
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<template>
|
|
||||||
<div class="container mx-auto p-6 space-y-6">
|
|
||||||
<div class="flex items-center justify-between">
|
|
||||||
<div>
|
|
||||||
<h1 class="text-3xl font-bold">Permission Management</h1>
|
|
||||||
<p class="text-muted-foreground">
|
|
||||||
Manage user access to expense accounts
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
<Button @click="showGrantDialog = true" :disabled="isLoading">
|
|
||||||
<Plus class="mr-2 h-4 w-4" />
|
|
||||||
Grant Permission
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<Card>
|
|
||||||
<CardHeader>
|
|
||||||
<CardTitle class="flex items-center gap-2">
|
|
||||||
<Shield class="h-5 w-5" />
|
|
||||||
Account Permissions
|
|
||||||
</CardTitle>
|
|
||||||
<CardDescription>
|
|
||||||
View and manage all account permissions. Permissions on parent accounts cascade to
|
|
||||||
children.
|
|
||||||
</CardDescription>
|
|
||||||
</CardHeader>
|
|
||||||
<CardContent>
|
|
||||||
<Tabs default-value="by-user" class="w-full">
|
|
||||||
<TabsList class="grid w-full grid-cols-2">
|
|
||||||
<TabsTrigger value="by-user">
|
|
||||||
<Users class="mr-2 h-4 w-4" />
|
|
||||||
By User
|
|
||||||
</TabsTrigger>
|
|
||||||
<TabsTrigger value="by-account">
|
|
||||||
<Shield class="mr-2 h-4 w-4" />
|
|
||||||
By Account
|
|
||||||
</TabsTrigger>
|
|
||||||
</TabsList>
|
|
||||||
|
|
||||||
<!-- By User View -->
|
|
||||||
<TabsContent value="by-user" class="space-y-4">
|
|
||||||
<div v-if="isLoading" class="flex items-center justify-center py-8">
|
|
||||||
<Loader2 class="h-8 w-8 animate-spin text-muted-foreground" />
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div v-else-if="permissionsByUser.size === 0" class="text-center py-8">
|
|
||||||
<p class="text-muted-foreground">No permissions granted yet</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div v-else class="space-y-4">
|
|
||||||
<div
|
|
||||||
v-for="[userId, userPermissions] in permissionsByUser"
|
|
||||||
:key="userId"
|
|
||||||
class="border rounded-lg p-4"
|
|
||||||
>
|
|
||||||
<h3 class="font-semibold mb-2">User: {{ userId }}</h3>
|
|
||||||
<Table>
|
|
||||||
<TableHeader>
|
|
||||||
<TableRow>
|
|
||||||
<TableHead>Account</TableHead>
|
|
||||||
<TableHead>Permission</TableHead>
|
|
||||||
<TableHead>Granted</TableHead>
|
|
||||||
<TableHead>Expires</TableHead>
|
|
||||||
<TableHead>Notes</TableHead>
|
|
||||||
<TableHead class="text-right">Actions</TableHead>
|
|
||||||
</TableRow>
|
|
||||||
</TableHeader>
|
|
||||||
<TableBody>
|
|
||||||
<TableRow v-for="permission in userPermissions" :key="permission.id">
|
|
||||||
<TableCell class="font-medium">
|
|
||||||
{{ getAccountName(permission.account_id) }}
|
|
||||||
</TableCell>
|
|
||||||
<TableCell>
|
|
||||||
<Badge :variant="getPermissionBadge(permission.permission_type)">
|
|
||||||
{{ getPermissionLabel(permission.permission_type) }}
|
|
||||||
</Badge>
|
|
||||||
</TableCell>
|
|
||||||
<TableCell>{{ formatDate(permission.granted_at) }}</TableCell>
|
|
||||||
<TableCell>
|
|
||||||
{{ permission.expires_at ? formatDate(permission.expires_at) : 'Never' }}
|
|
||||||
</TableCell>
|
|
||||||
<TableCell>
|
|
||||||
<span class="text-sm text-muted-foreground">
|
|
||||||
{{ permission.notes || '-' }}
|
|
||||||
</span>
|
|
||||||
</TableCell>
|
|
||||||
<TableCell class="text-right">
|
|
||||||
<Button
|
|
||||||
variant="ghost"
|
|
||||||
size="sm"
|
|
||||||
@click="confirmRevoke(permission)"
|
|
||||||
:disabled="isLoading"
|
|
||||||
>
|
|
||||||
<Trash2 class="h-4 w-4 text-destructive" />
|
|
||||||
</Button>
|
|
||||||
</TableCell>
|
|
||||||
</TableRow>
|
|
||||||
</TableBody>
|
|
||||||
</Table>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</TabsContent>
|
|
||||||
|
|
||||||
<!-- By Account View -->
|
|
||||||
<TabsContent value="by-account" class="space-y-4">
|
|
||||||
<div v-if="isLoading" class="flex items-center justify-center py-8">
|
|
||||||
<Loader2 class="h-8 w-8 animate-spin text-muted-foreground" />
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div v-else-if="permissionsByAccount.size === 0" class="text-center py-8">
|
|
||||||
<p class="text-muted-foreground">No permissions granted yet</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div v-else class="space-y-4">
|
|
||||||
<div
|
|
||||||
v-for="[accountId, accountPermissions] in permissionsByAccount"
|
|
||||||
:key="accountId"
|
|
||||||
class="border rounded-lg p-4"
|
|
||||||
>
|
|
||||||
<h3 class="font-semibold mb-2">Account: {{ getAccountName(accountId) }}</h3>
|
|
||||||
<Table>
|
|
||||||
<TableHeader>
|
|
||||||
<TableRow>
|
|
||||||
<TableHead>User</TableHead>
|
|
||||||
<TableHead>Permission</TableHead>
|
|
||||||
<TableHead>Granted</TableHead>
|
|
||||||
<TableHead>Expires</TableHead>
|
|
||||||
<TableHead>Notes</TableHead>
|
|
||||||
<TableHead class="text-right">Actions</TableHead>
|
|
||||||
</TableRow>
|
|
||||||
</TableHeader>
|
|
||||||
<TableBody>
|
|
||||||
<TableRow v-for="permission in accountPermissions" :key="permission.id">
|
|
||||||
<TableCell class="font-medium">{{ permission.user_id }}</TableCell>
|
|
||||||
<TableCell>
|
|
||||||
<Badge :variant="getPermissionBadge(permission.permission_type)">
|
|
||||||
{{ getPermissionLabel(permission.permission_type) }}
|
|
||||||
</Badge>
|
|
||||||
</TableCell>
|
|
||||||
<TableCell>{{ formatDate(permission.granted_at) }}</TableCell>
|
|
||||||
<TableCell>
|
|
||||||
{{ permission.expires_at ? formatDate(permission.expires_at) : 'Never' }}
|
|
||||||
</TableCell>
|
|
||||||
<TableCell>
|
|
||||||
<span class="text-sm text-muted-foreground">
|
|
||||||
{{ permission.notes || '-' }}
|
|
||||||
</span>
|
|
||||||
</TableCell>
|
|
||||||
<TableCell class="text-right">
|
|
||||||
<Button
|
|
||||||
variant="ghost"
|
|
||||||
size="sm"
|
|
||||||
@click="confirmRevoke(permission)"
|
|
||||||
:disabled="isLoading"
|
|
||||||
>
|
|
||||||
<Trash2 class="h-4 w-4 text-destructive" />
|
|
||||||
</Button>
|
|
||||||
</TableCell>
|
|
||||||
</TableRow>
|
|
||||||
</TableBody>
|
|
||||||
</Table>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</TabsContent>
|
|
||||||
</Tabs>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
|
|
||||||
<!-- Grant Permission Dialog -->
|
|
||||||
<GrantPermissionDialog
|
|
||||||
:is-open="showGrantDialog"
|
|
||||||
:accounts="accounts"
|
|
||||||
@close="showGrantDialog = false"
|
|
||||||
@permission-granted="handlePermissionGranted"
|
|
||||||
/>
|
|
||||||
|
|
||||||
<!-- Revoke Confirmation Dialog -->
|
|
||||||
<AlertDialog :open="showRevokeDialog" @update:open="showRevokeDialog = $event">
|
|
||||||
<AlertDialogContent>
|
|
||||||
<AlertDialogHeader>
|
|
||||||
<AlertDialogTitle>Revoke Permission?</AlertDialogTitle>
|
|
||||||
<AlertDialogDescription>
|
|
||||||
Are you sure you want to revoke this permission? The user will immediately lose access.
|
|
||||||
<div v-if="permissionToRevoke" class="mt-4 p-4 bg-muted rounded-md">
|
|
||||||
<p class="font-medium">Permission Details:</p>
|
|
||||||
<p class="text-sm mt-2">
|
|
||||||
<strong>User:</strong> {{ permissionToRevoke.user_id }}
|
|
||||||
</p>
|
|
||||||
<p class="text-sm">
|
|
||||||
<strong>Account:</strong> {{ getAccountName(permissionToRevoke.account_id) }}
|
|
||||||
</p>
|
|
||||||
<p class="text-sm">
|
|
||||||
<strong>Type:</strong> {{ getPermissionLabel(permissionToRevoke.permission_type) }}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</AlertDialogDescription>
|
|
||||||
</AlertDialogHeader>
|
|
||||||
<AlertDialogFooter>
|
|
||||||
<AlertDialogCancel>Cancel</AlertDialogCancel>
|
|
||||||
<AlertDialogAction @click="revokePermission" class="bg-destructive text-destructive-foreground hover:bg-destructive/90">
|
|
||||||
Revoke
|
|
||||||
</AlertDialogAction>
|
|
||||||
</AlertDialogFooter>
|
|
||||||
</AlertDialogContent>
|
|
||||||
</AlertDialog>
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
|
|
@ -1,71 +0,0 @@
|
||||||
/**
|
|
||||||
* Expenses Module
|
|
||||||
*
|
|
||||||
* Provides expense tracking and submission functionality
|
|
||||||
* integrated with castle LNbits extension.
|
|
||||||
*/
|
|
||||||
|
|
||||||
import type { App } from 'vue'
|
|
||||||
import { markRaw } from 'vue'
|
|
||||||
import type { ModulePlugin } from '@/core/types'
|
|
||||||
import { container, SERVICE_TOKENS } from '@/core/di-container'
|
|
||||||
import { ExpensesAPI } from './services/ExpensesAPI'
|
|
||||||
import AddExpense from './components/AddExpense.vue'
|
|
||||||
import TransactionsPage from './views/TransactionsPage.vue'
|
|
||||||
|
|
||||||
export const expensesModule: ModulePlugin = {
|
|
||||||
name: 'expenses',
|
|
||||||
version: '1.0.0',
|
|
||||||
dependencies: ['base'],
|
|
||||||
|
|
||||||
routes: [
|
|
||||||
{
|
|
||||||
path: '/expenses/transactions',
|
|
||||||
name: 'ExpenseTransactions',
|
|
||||||
component: TransactionsPage,
|
|
||||||
meta: {
|
|
||||||
requiresAuth: true,
|
|
||||||
title: 'My Transactions'
|
|
||||||
}
|
|
||||||
}
|
|
||||||
],
|
|
||||||
|
|
||||||
quickActions: [
|
|
||||||
{
|
|
||||||
id: 'add-expense',
|
|
||||||
label: 'Expense',
|
|
||||||
icon: 'DollarSign',
|
|
||||||
component: markRaw(AddExpense),
|
|
||||||
category: 'finance',
|
|
||||||
order: 10,
|
|
||||||
requiresAuth: true
|
|
||||||
}
|
|
||||||
],
|
|
||||||
|
|
||||||
async install(app: App) {
|
|
||||||
console.log('[Expenses Module] Installing...')
|
|
||||||
|
|
||||||
// 1. Create and register service
|
|
||||||
const expensesAPI = new ExpensesAPI()
|
|
||||||
container.provide(SERVICE_TOKENS.EXPENSES_API, expensesAPI)
|
|
||||||
|
|
||||||
// 2. Initialize service (wait for dependencies)
|
|
||||||
await expensesAPI.initialize({
|
|
||||||
waitForDependencies: true,
|
|
||||||
maxRetries: 3,
|
|
||||||
retryDelay: 1000
|
|
||||||
})
|
|
||||||
|
|
||||||
console.log('[Expenses Module] ExpensesAPI initialized')
|
|
||||||
|
|
||||||
// 3. Register components globally (optional, for use outside quick actions)
|
|
||||||
app.component('AddExpense', AddExpense)
|
|
||||||
|
|
||||||
console.log('[Expenses Module] Installed successfully')
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export default expensesModule
|
|
||||||
|
|
||||||
// Export types for use in other modules
|
|
||||||
export type { Account, AccountNode, ExpenseEntry, ExpenseEntryRequest } from './types'
|
|
||||||
|
|
@ -1,450 +0,0 @@
|
||||||
/**
|
|
||||||
* API service for castle extension expense operations
|
|
||||||
*/
|
|
||||||
|
|
||||||
import { BaseService } from '@/core/base/BaseService'
|
|
||||||
import type {
|
|
||||||
Account,
|
|
||||||
ExpenseEntryRequest,
|
|
||||||
ExpenseEntry,
|
|
||||||
AccountNode,
|
|
||||||
UserInfo,
|
|
||||||
AccountPermission,
|
|
||||||
GrantPermissionRequest,
|
|
||||||
TransactionListResponse
|
|
||||||
} from '../types'
|
|
||||||
import { appConfig } from '@/app.config'
|
|
||||||
|
|
||||||
export class ExpensesAPI extends BaseService {
|
|
||||||
protected readonly metadata = {
|
|
||||||
name: 'ExpensesAPI',
|
|
||||||
version: '1.0.0',
|
|
||||||
dependencies: [] // No dependencies - wallet key is passed as parameter
|
|
||||||
}
|
|
||||||
|
|
||||||
private get config() {
|
|
||||||
return appConfig.modules.expenses.config
|
|
||||||
}
|
|
||||||
|
|
||||||
private get baseUrl() {
|
|
||||||
return this.config?.apiConfig?.baseUrl || ''
|
|
||||||
}
|
|
||||||
|
|
||||||
protected async onInitialize(): Promise<void> {
|
|
||||||
console.log('[ExpensesAPI] Initialized with config:', {
|
|
||||||
baseUrl: this.baseUrl,
|
|
||||||
timeout: this.config?.apiConfig?.timeout
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Get authentication headers with provided wallet key
|
|
||||||
*/
|
|
||||||
private getHeaders(walletKey: string): HeadersInit {
|
|
||||||
return {
|
|
||||||
'Content-Type': 'application/json',
|
|
||||||
'X-Api-Key': walletKey
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Get all accounts from castle
|
|
||||||
*
|
|
||||||
* @param walletKey - Wallet key for authentication
|
|
||||||
* @param filterByUser - If true, only return accounts the user has permissions for
|
|
||||||
* @param excludeVirtual - If true, exclude virtual parent accounts (default true for user views)
|
|
||||||
*/
|
|
||||||
async getAccounts(
|
|
||||||
walletKey: string,
|
|
||||||
filterByUser: boolean = false,
|
|
||||||
excludeVirtual: boolean = true
|
|
||||||
): Promise<Account[]> {
|
|
||||||
try {
|
|
||||||
const url = new URL(`${this.baseUrl}/castle/api/v1/accounts`)
|
|
||||||
if (filterByUser) {
|
|
||||||
url.searchParams.set('filter_by_user', 'true')
|
|
||||||
}
|
|
||||||
if (excludeVirtual) {
|
|
||||||
url.searchParams.set('exclude_virtual', 'true')
|
|
||||||
}
|
|
||||||
|
|
||||||
const response = await fetch(url.toString(), {
|
|
||||||
method: 'GET',
|
|
||||||
headers: this.getHeaders(walletKey),
|
|
||||||
signal: AbortSignal.timeout(this.config?.apiConfig?.timeout || 30000)
|
|
||||||
})
|
|
||||||
|
|
||||||
if (!response.ok) {
|
|
||||||
throw new Error(`Failed to fetch accounts: ${response.statusText}`)
|
|
||||||
}
|
|
||||||
|
|
||||||
const accounts = await response.json()
|
|
||||||
return accounts as Account[]
|
|
||||||
} catch (error) {
|
|
||||||
console.error('[ExpensesAPI] Error fetching accounts:', error)
|
|
||||||
throw error
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Get accounts in hierarchical tree structure
|
|
||||||
*
|
|
||||||
* Converts flat account list to nested tree based on colon-separated names
|
|
||||||
* e.g., "Expenses:Groceries:Organic" becomes nested structure
|
|
||||||
*
|
|
||||||
* @param walletKey - Wallet key for authentication
|
|
||||||
* @param rootAccount - Optional root account to filter by (e.g., "Expenses")
|
|
||||||
* @param filterByUser - If true, only return accounts the user has permissions for
|
|
||||||
* @param excludeVirtual - If true, exclude virtual parent accounts (default true for user views)
|
|
||||||
*/
|
|
||||||
async getAccountHierarchy(
|
|
||||||
walletKey: string,
|
|
||||||
rootAccount?: string,
|
|
||||||
filterByUser: boolean = false,
|
|
||||||
excludeVirtual: boolean = true
|
|
||||||
): Promise<AccountNode[]> {
|
|
||||||
const accounts = await this.getAccounts(walletKey, filterByUser, excludeVirtual)
|
|
||||||
|
|
||||||
// Filter by root account if specified
|
|
||||||
let filteredAccounts = accounts
|
|
||||||
if (rootAccount) {
|
|
||||||
filteredAccounts = accounts.filter(
|
|
||||||
(acc) =>
|
|
||||||
acc.name === rootAccount || acc.name.startsWith(`${rootAccount}:`)
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Build hierarchy
|
|
||||||
const accountMap = new Map<string, AccountNode>()
|
|
||||||
|
|
||||||
// First pass: Create nodes for all accounts
|
|
||||||
for (const account of filteredAccounts) {
|
|
||||||
const parts = account.name.split(':')
|
|
||||||
const level = parts.length - 1
|
|
||||||
const parentName = parts.slice(0, -1).join(':')
|
|
||||||
|
|
||||||
accountMap.set(account.name, {
|
|
||||||
account: {
|
|
||||||
...account,
|
|
||||||
level,
|
|
||||||
parent_account: parentName || undefined,
|
|
||||||
has_children: false
|
|
||||||
},
|
|
||||||
children: []
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
// Second pass: Build parent-child relationships
|
|
||||||
const rootNodes: AccountNode[] = []
|
|
||||||
|
|
||||||
for (const [_name, node] of accountMap.entries()) {
|
|
||||||
const parentName = node.account.parent_account
|
|
||||||
|
|
||||||
if (parentName && accountMap.has(parentName)) {
|
|
||||||
// Add to parent's children
|
|
||||||
const parent = accountMap.get(parentName)!
|
|
||||||
parent.children.push(node)
|
|
||||||
parent.account.has_children = true
|
|
||||||
} else {
|
|
||||||
// Root level node
|
|
||||||
rootNodes.push(node)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Sort children by name at each level
|
|
||||||
const sortNodes = (nodes: AccountNode[]) => {
|
|
||||||
nodes.sort((a, b) => a.account.name.localeCompare(b.account.name))
|
|
||||||
nodes.forEach((node) => sortNodes(node.children))
|
|
||||||
}
|
|
||||||
sortNodes(rootNodes)
|
|
||||||
|
|
||||||
return rootNodes
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Submit expense entry to castle
|
|
||||||
*/
|
|
||||||
async submitExpense(walletKey: string, request: ExpenseEntryRequest): Promise<ExpenseEntry> {
|
|
||||||
try {
|
|
||||||
const response = await fetch(`${this.baseUrl}/castle/api/v1/entries/expense`, {
|
|
||||||
method: 'POST',
|
|
||||||
headers: this.getHeaders(walletKey),
|
|
||||||
body: JSON.stringify(request),
|
|
||||||
signal: AbortSignal.timeout(this.config?.apiConfig?.timeout || 30000)
|
|
||||||
})
|
|
||||||
|
|
||||||
if (!response.ok) {
|
|
||||||
const errorData = await response.json().catch(() => ({}))
|
|
||||||
const errorMessage =
|
|
||||||
errorData.detail || `Failed to submit expense: ${response.statusText}`
|
|
||||||
throw new Error(errorMessage)
|
|
||||||
}
|
|
||||||
|
|
||||||
const entry = await response.json()
|
|
||||||
return entry as ExpenseEntry
|
|
||||||
} catch (error) {
|
|
||||||
console.error('[ExpensesAPI] Error submitting expense:', error)
|
|
||||||
throw error
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Get user's expense entries
|
|
||||||
*/
|
|
||||||
async getUserExpenses(walletKey: string): Promise<ExpenseEntry[]> {
|
|
||||||
try {
|
|
||||||
const response = await fetch(`${this.baseUrl}/castle/api/v1/entries/user`, {
|
|
||||||
method: 'GET',
|
|
||||||
headers: this.getHeaders(walletKey),
|
|
||||||
signal: AbortSignal.timeout(this.config?.apiConfig?.timeout || 30000)
|
|
||||||
})
|
|
||||||
|
|
||||||
if (!response.ok) {
|
|
||||||
throw new Error(
|
|
||||||
`Failed to fetch user expenses: ${response.statusText}`
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
const entries = await response.json()
|
|
||||||
return entries as ExpenseEntry[]
|
|
||||||
} catch (error) {
|
|
||||||
console.error('[ExpensesAPI] Error fetching user expenses:', error)
|
|
||||||
throw error
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Get user's balance with castle
|
|
||||||
*/
|
|
||||||
async getUserBalance(walletKey: string): Promise<{ balance: number; currency: string }> {
|
|
||||||
try {
|
|
||||||
const response = await fetch(`${this.baseUrl}/castle/api/v1/balance`, {
|
|
||||||
method: 'GET',
|
|
||||||
headers: this.getHeaders(walletKey),
|
|
||||||
signal: AbortSignal.timeout(this.config?.apiConfig?.timeout || 30000)
|
|
||||||
})
|
|
||||||
|
|
||||||
if (!response.ok) {
|
|
||||||
throw new Error(`Failed to fetch balance: ${response.statusText}`)
|
|
||||||
}
|
|
||||||
|
|
||||||
return await response.json()
|
|
||||||
} catch (error) {
|
|
||||||
console.error('[ExpensesAPI] Error fetching balance:', error)
|
|
||||||
throw error
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Get available currencies from LNbits instance
|
|
||||||
*/
|
|
||||||
async getCurrencies(): Promise<string[]> {
|
|
||||||
try {
|
|
||||||
const response = await fetch(`${this.baseUrl}/api/v1/currencies`, {
|
|
||||||
method: 'GET',
|
|
||||||
signal: AbortSignal.timeout(this.config?.apiConfig?.timeout || 30000)
|
|
||||||
})
|
|
||||||
|
|
||||||
if (!response.ok) {
|
|
||||||
throw new Error(`Failed to fetch currencies: ${response.statusText}`)
|
|
||||||
}
|
|
||||||
|
|
||||||
return await response.json()
|
|
||||||
} catch (error) {
|
|
||||||
console.error('[ExpensesAPI] Error fetching currencies:', error)
|
|
||||||
throw error
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Get default currency from LNbits instance
|
|
||||||
*/
|
|
||||||
async getDefaultCurrency(): Promise<string | null> {
|
|
||||||
try {
|
|
||||||
const response = await fetch(`${this.baseUrl}/api/v1/default-currency`, {
|
|
||||||
method: 'GET',
|
|
||||||
signal: AbortSignal.timeout(this.config?.apiConfig?.timeout || 30000)
|
|
||||||
})
|
|
||||||
|
|
||||||
if (!response.ok) {
|
|
||||||
throw new Error(`Failed to fetch default currency: ${response.statusText}`)
|
|
||||||
}
|
|
||||||
|
|
||||||
const data = await response.json()
|
|
||||||
return data.default_currency
|
|
||||||
} catch (error) {
|
|
||||||
console.error('[ExpensesAPI] Error fetching default currency:', error)
|
|
||||||
throw error
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Get user information including equity eligibility
|
|
||||||
*
|
|
||||||
* @param walletKey - Wallet key for authentication (invoice key)
|
|
||||||
*/
|
|
||||||
async getUserInfo(walletKey: string): Promise<UserInfo> {
|
|
||||||
try {
|
|
||||||
const response = await fetch(`${this.baseUrl}/castle/api/v1/user/info`, {
|
|
||||||
method: 'GET',
|
|
||||||
headers: this.getHeaders(walletKey),
|
|
||||||
signal: AbortSignal.timeout(this.config?.apiConfig?.timeout || 30000)
|
|
||||||
})
|
|
||||||
|
|
||||||
if (!response.ok) {
|
|
||||||
throw new Error(`Failed to fetch user info: ${response.statusText}`)
|
|
||||||
}
|
|
||||||
|
|
||||||
return await response.json()
|
|
||||||
} catch (error) {
|
|
||||||
console.error('[ExpensesAPI] Error fetching user info:', error)
|
|
||||||
// Return default non-eligible user on error
|
|
||||||
return {
|
|
||||||
user_id: '',
|
|
||||||
is_equity_eligible: false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* List all account permissions (admin only)
|
|
||||||
*
|
|
||||||
* @param adminKey - Admin key for authentication
|
|
||||||
*/
|
|
||||||
async listPermissions(adminKey: string): Promise<AccountPermission[]> {
|
|
||||||
try {
|
|
||||||
const response = await fetch(`${this.baseUrl}/castle/api/v1/permissions`, {
|
|
||||||
method: 'GET',
|
|
||||||
headers: this.getHeaders(adminKey),
|
|
||||||
signal: AbortSignal.timeout(this.config?.apiConfig?.timeout || 30000)
|
|
||||||
})
|
|
||||||
|
|
||||||
if (!response.ok) {
|
|
||||||
throw new Error(`Failed to list permissions: ${response.statusText}`)
|
|
||||||
}
|
|
||||||
|
|
||||||
const permissions = await response.json()
|
|
||||||
return permissions as AccountPermission[]
|
|
||||||
} catch (error) {
|
|
||||||
console.error('[ExpensesAPI] Error listing permissions:', error)
|
|
||||||
throw error
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Grant account permission to a user (admin only)
|
|
||||||
*
|
|
||||||
* @param adminKey - Admin key for authentication
|
|
||||||
* @param request - Permission grant request
|
|
||||||
*/
|
|
||||||
async grantPermission(
|
|
||||||
adminKey: string,
|
|
||||||
request: GrantPermissionRequest
|
|
||||||
): Promise<AccountPermission> {
|
|
||||||
try {
|
|
||||||
const response = await fetch(`${this.baseUrl}/castle/api/v1/permissions`, {
|
|
||||||
method: 'POST',
|
|
||||||
headers: this.getHeaders(adminKey),
|
|
||||||
body: JSON.stringify(request),
|
|
||||||
signal: AbortSignal.timeout(this.config?.apiConfig?.timeout || 30000)
|
|
||||||
})
|
|
||||||
|
|
||||||
if (!response.ok) {
|
|
||||||
const errorData = await response.json().catch(() => ({}))
|
|
||||||
const errorMessage =
|
|
||||||
errorData.detail || `Failed to grant permission: ${response.statusText}`
|
|
||||||
throw new Error(errorMessage)
|
|
||||||
}
|
|
||||||
|
|
||||||
const permission = await response.json()
|
|
||||||
return permission as AccountPermission
|
|
||||||
} catch (error) {
|
|
||||||
console.error('[ExpensesAPI] Error granting permission:', error)
|
|
||||||
throw error
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Revoke account permission (admin only)
|
|
||||||
*
|
|
||||||
* @param adminKey - Admin key for authentication
|
|
||||||
* @param permissionId - ID of the permission to revoke
|
|
||||||
*/
|
|
||||||
async revokePermission(adminKey: string, permissionId: string): Promise<void> {
|
|
||||||
try {
|
|
||||||
const response = await fetch(
|
|
||||||
`${this.baseUrl}/castle/api/v1/permissions/${permissionId}`,
|
|
||||||
{
|
|
||||||
method: 'DELETE',
|
|
||||||
headers: this.getHeaders(adminKey),
|
|
||||||
signal: AbortSignal.timeout(this.config?.apiConfig?.timeout || 30000)
|
|
||||||
}
|
|
||||||
)
|
|
||||||
|
|
||||||
if (!response.ok) {
|
|
||||||
const errorData = await response.json().catch(() => ({}))
|
|
||||||
const errorMessage =
|
|
||||||
errorData.detail || `Failed to revoke permission: ${response.statusText}`
|
|
||||||
throw new Error(errorMessage)
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
console.error('[ExpensesAPI] Error revoking permission:', error)
|
|
||||||
throw error
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Get user's transactions from journal
|
|
||||||
*
|
|
||||||
* @param walletKey - Wallet key for authentication (invoice key)
|
|
||||||
* @param options - Query options for filtering and pagination
|
|
||||||
*/
|
|
||||||
async getUserTransactions(
|
|
||||||
walletKey: string,
|
|
||||||
options?: {
|
|
||||||
limit?: number
|
|
||||||
offset?: number
|
|
||||||
days?: number // 15, 30, or 60 (default: 15)
|
|
||||||
start_date?: string // ISO format: YYYY-MM-DD
|
|
||||||
end_date?: string // ISO format: YYYY-MM-DD
|
|
||||||
filter_user_id?: string
|
|
||||||
filter_account_type?: string
|
|
||||||
}
|
|
||||||
): Promise<TransactionListResponse> {
|
|
||||||
try {
|
|
||||||
const url = new URL(`${this.baseUrl}/castle/api/v1/entries/user`)
|
|
||||||
|
|
||||||
// Add query parameters
|
|
||||||
if (options?.limit) url.searchParams.set('limit', String(options.limit))
|
|
||||||
if (options?.offset) url.searchParams.set('offset', String(options.offset))
|
|
||||||
|
|
||||||
// Custom date range takes precedence over days
|
|
||||||
if (options?.start_date && options?.end_date) {
|
|
||||||
url.searchParams.set('start_date', options.start_date)
|
|
||||||
url.searchParams.set('end_date', options.end_date)
|
|
||||||
} else if (options?.days) {
|
|
||||||
url.searchParams.set('days', String(options.days))
|
|
||||||
}
|
|
||||||
|
|
||||||
if (options?.filter_user_id)
|
|
||||||
url.searchParams.set('filter_user_id', options.filter_user_id)
|
|
||||||
if (options?.filter_account_type)
|
|
||||||
url.searchParams.set('filter_account_type', options.filter_account_type)
|
|
||||||
|
|
||||||
const response = await fetch(url.toString(), {
|
|
||||||
method: 'GET',
|
|
||||||
headers: this.getHeaders(walletKey),
|
|
||||||
signal: AbortSignal.timeout(this.config?.apiConfig?.timeout || 30000)
|
|
||||||
})
|
|
||||||
|
|
||||||
if (!response.ok) {
|
|
||||||
throw new Error(`Failed to fetch transactions: ${response.statusText}`)
|
|
||||||
}
|
|
||||||
|
|
||||||
return await response.json()
|
|
||||||
} catch (error) {
|
|
||||||
console.error('[ExpensesAPI] Error fetching transactions:', error)
|
|
||||||
throw error
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,164 +0,0 @@
|
||||||
/**
|
|
||||||
* Types for the Expenses module
|
|
||||||
*/
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Account types in the castle double-entry accounting system
|
|
||||||
*/
|
|
||||||
export enum AccountType {
|
|
||||||
ASSET = 'asset',
|
|
||||||
LIABILITY = 'liability',
|
|
||||||
EQUITY = 'equity',
|
|
||||||
REVENUE = 'revenue',
|
|
||||||
EXPENSE = 'expense'
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Account with hierarchical structure
|
|
||||||
*/
|
|
||||||
export interface Account {
|
|
||||||
id: string
|
|
||||||
name: string // e.g., "Expenses:Groceries:Organic"
|
|
||||||
account_type: AccountType
|
|
||||||
description?: string
|
|
||||||
user_id?: string
|
|
||||||
// Hierarchical metadata (added by frontend)
|
|
||||||
parent_account?: string
|
|
||||||
level?: number
|
|
||||||
has_children?: boolean
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Account with user-specific permission metadata
|
|
||||||
* (Will be available once castle API implements permissions)
|
|
||||||
*/
|
|
||||||
export interface AccountWithPermissions extends Account {
|
|
||||||
user_permissions?: PermissionType[]
|
|
||||||
inherited_from?: string
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Permission types for account access control
|
|
||||||
*/
|
|
||||||
export enum PermissionType {
|
|
||||||
READ = 'read',
|
|
||||||
SUBMIT_EXPENSE = 'submit_expense',
|
|
||||||
MANAGE = 'manage'
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Expense entry request payload
|
|
||||||
*/
|
|
||||||
export interface ExpenseEntryRequest {
|
|
||||||
description: string
|
|
||||||
amount: number // Amount in the specified currency (or satoshis if currency is None)
|
|
||||||
expense_account: string // Account name or ID
|
|
||||||
is_equity: boolean
|
|
||||||
user_wallet: string
|
|
||||||
reference?: string
|
|
||||||
currency?: string // If None, amount is in satoshis. Otherwise, fiat currency code (e.g., "EUR", "USD")
|
|
||||||
entry_date?: string // ISO datetime string
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Expense entry response from castle API
|
|
||||||
*/
|
|
||||||
export interface ExpenseEntry {
|
|
||||||
id: string
|
|
||||||
journal_id: string
|
|
||||||
description: string
|
|
||||||
amount: number
|
|
||||||
expense_account: string
|
|
||||||
is_equity: boolean
|
|
||||||
user_wallet: string
|
|
||||||
reference?: string
|
|
||||||
currency?: string
|
|
||||||
entry_date: string
|
|
||||||
created_at: string
|
|
||||||
status: 'pending' | 'approved' | 'rejected' | 'void'
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Hierarchical account tree node for UI rendering
|
|
||||||
*/
|
|
||||||
export interface AccountNode {
|
|
||||||
account: Account
|
|
||||||
children: AccountNode[]
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* User information including equity eligibility
|
|
||||||
*/
|
|
||||||
export interface UserInfo {
|
|
||||||
user_id: string
|
|
||||||
is_equity_eligible: boolean
|
|
||||||
equity_account_name?: string
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Account permission for user access control
|
|
||||||
*/
|
|
||||||
export interface AccountPermission {
|
|
||||||
id: string
|
|
||||||
user_id: string
|
|
||||||
account_id: string
|
|
||||||
permission_type: PermissionType
|
|
||||||
granted_at: string
|
|
||||||
granted_by: string
|
|
||||||
expires_at?: string
|
|
||||||
notes?: string
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Grant permission request payload
|
|
||||||
*/
|
|
||||||
export interface GrantPermissionRequest {
|
|
||||||
user_id: string
|
|
||||||
account_id: string
|
|
||||||
permission_type: PermissionType
|
|
||||||
expires_at?: string
|
|
||||||
notes?: string
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Transaction entry from journal (user view)
|
|
||||||
*/
|
|
||||||
export interface Transaction {
|
|
||||||
id: string
|
|
||||||
date: string
|
|
||||||
entry_date: string
|
|
||||||
flag?: string // *, !, #, x for cleared, pending, flagged, voided
|
|
||||||
description: string
|
|
||||||
payee?: string
|
|
||||||
tags: string[]
|
|
||||||
links: string[]
|
|
||||||
amount: number // Amount in satoshis
|
|
||||||
user_id?: string
|
|
||||||
username?: string
|
|
||||||
reference?: string
|
|
||||||
meta?: Record<string, any>
|
|
||||||
fiat_amount?: number
|
|
||||||
fiat_currency?: string
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Transaction list response with pagination
|
|
||||||
*/
|
|
||||||
export interface TransactionListResponse {
|
|
||||||
entries: Transaction[]
|
|
||||||
total: number
|
|
||||||
limit: number
|
|
||||||
offset: number
|
|
||||||
has_next: boolean
|
|
||||||
has_prev: boolean
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Module configuration
|
|
||||||
*/
|
|
||||||
export interface ExpensesConfig {
|
|
||||||
apiConfig: {
|
|
||||||
baseUrl: string
|
|
||||||
timeout: number
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,367 +0,0 @@
|
||||||
<script setup lang="ts">
|
|
||||||
import { ref, computed, onMounted } from 'vue'
|
|
||||||
import { useAuth } from '@/composables/useAuthService'
|
|
||||||
import { useToast } from '@/core/composables/useToast'
|
|
||||||
import { injectService, SERVICE_TOKENS } from '@/core/di-container'
|
|
||||||
import type { ExpensesAPI } from '../services/ExpensesAPI'
|
|
||||||
import type { Transaction } from '../types'
|
|
||||||
import type { FuzzySearchOptions } from '@/composables/useFuzzySearch'
|
|
||||||
import FuzzySearch from '@/components/ui/fuzzy-search/FuzzySearch.vue'
|
|
||||||
|
|
||||||
import { Button } from '@/components/ui/button'
|
|
||||||
import { Badge } from '@/components/ui/badge'
|
|
||||||
import { Input } from '@/components/ui/input'
|
|
||||||
import {
|
|
||||||
CheckCircle2,
|
|
||||||
Clock,
|
|
||||||
Flag,
|
|
||||||
XCircle,
|
|
||||||
RefreshCw,
|
|
||||||
Calendar
|
|
||||||
} from 'lucide-vue-next'
|
|
||||||
|
|
||||||
const { user } = useAuth()
|
|
||||||
const toast = useToast()
|
|
||||||
const expensesAPI = injectService<ExpensesAPI>(SERVICE_TOKENS.EXPENSES_API)
|
|
||||||
|
|
||||||
const transactions = ref<Transaction[]>([])
|
|
||||||
const isLoading = ref(false)
|
|
||||||
const dateRangeType = ref<number | 'custom'>(15) // 15, 30, 60, or 'custom'
|
|
||||||
const customStartDate = ref<string>('')
|
|
||||||
const customEndDate = ref<string>('')
|
|
||||||
|
|
||||||
const walletKey = computed(() => user.value?.wallets?.[0]?.inkey)
|
|
||||||
|
|
||||||
// Fuzzy search state and configuration
|
|
||||||
const searchResults = ref<Transaction[]>([])
|
|
||||||
|
|
||||||
const searchOptions: FuzzySearchOptions<Transaction> = {
|
|
||||||
fuseOptions: {
|
|
||||||
keys: [
|
|
||||||
{ name: 'description', weight: 0.7 }, // Description has highest weight
|
|
||||||
{ name: 'payee', weight: 0.5 }, // Payee is important
|
|
||||||
{ name: 'reference', weight: 0.4 }, // Reference helps identification
|
|
||||||
{ name: 'username', weight: 0.3 }, // Username for filtering
|
|
||||||
{ name: 'tags', weight: 0.2 } // Tags for categorization
|
|
||||||
],
|
|
||||||
threshold: 0.4, // Tolerant of typos
|
|
||||||
ignoreLocation: true, // Match anywhere in the string
|
|
||||||
findAllMatches: true, // Find all matches
|
|
||||||
minMatchCharLength: 2, // Minimum match length
|
|
||||||
shouldSort: true // Sort by relevance
|
|
||||||
},
|
|
||||||
resultLimit: 100, // Show up to 100 results
|
|
||||||
minSearchLength: 2, // Start searching after 2 characters
|
|
||||||
matchAllWhenSearchEmpty: true
|
|
||||||
}
|
|
||||||
|
|
||||||
// Transactions to display (search results or all transactions)
|
|
||||||
const transactionsToDisplay = computed(() => {
|
|
||||||
return searchResults.value.length > 0 ? searchResults.value : transactions.value
|
|
||||||
})
|
|
||||||
|
|
||||||
// Handle search results
|
|
||||||
function handleSearchResults(results: Transaction[]) {
|
|
||||||
searchResults.value = results
|
|
||||||
}
|
|
||||||
|
|
||||||
// Date range options (matching castle LNbits extension)
|
|
||||||
const dateRangeOptions = [
|
|
||||||
{ label: '15 days', value: 15 },
|
|
||||||
{ label: '30 days', value: 30 },
|
|
||||||
{ label: '60 days', value: 60 },
|
|
||||||
{ label: 'Custom', value: 'custom' as const }
|
|
||||||
]
|
|
||||||
|
|
||||||
// Format date for display
|
|
||||||
function formatDate(dateString: string): string {
|
|
||||||
if (!dateString) return '-'
|
|
||||||
const date = new Date(dateString)
|
|
||||||
return new Intl.DateTimeFormat('en-US', {
|
|
||||||
month: 'short',
|
|
||||||
day: 'numeric',
|
|
||||||
year: 'numeric'
|
|
||||||
}).format(date)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Format amount with proper display
|
|
||||||
function formatAmount(amount: number): string {
|
|
||||||
return new Intl.NumberFormat('en-US').format(amount)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Get status icon and color based on flag
|
|
||||||
function getStatusInfo(flag?: string) {
|
|
||||||
switch (flag) {
|
|
||||||
case '*':
|
|
||||||
return { icon: CheckCircle2, color: 'text-green-600', label: 'Cleared' }
|
|
||||||
case '!':
|
|
||||||
return { icon: Clock, color: 'text-orange-600', label: 'Pending' }
|
|
||||||
case '#':
|
|
||||||
return { icon: Flag, color: 'text-red-600', label: 'Flagged' }
|
|
||||||
case 'x':
|
|
||||||
return { icon: XCircle, color: 'text-muted-foreground', label: 'Voided' }
|
|
||||||
default:
|
|
||||||
return null
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Load transactions
|
|
||||||
async function loadTransactions() {
|
|
||||||
if (!walletKey.value) {
|
|
||||||
toast.error('Authentication required', {
|
|
||||||
description: 'Please log in to view your transactions'
|
|
||||||
})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
isLoading.value = true
|
|
||||||
try {
|
|
||||||
// Build query params - custom date range takes precedence over preset days
|
|
||||||
const params: any = {
|
|
||||||
limit: 1000, // Load all transactions (no pagination needed)
|
|
||||||
offset: 0
|
|
||||||
}
|
|
||||||
|
|
||||||
if (dateRangeType.value === 'custom') {
|
|
||||||
// Use custom date range
|
|
||||||
if (customStartDate.value && customEndDate.value) {
|
|
||||||
params.start_date = customStartDate.value
|
|
||||||
params.end_date = customEndDate.value
|
|
||||||
} else {
|
|
||||||
// Default to 15 days if custom selected but dates not provided
|
|
||||||
params.days = 15
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
// Use preset days
|
|
||||||
params.days = dateRangeType.value
|
|
||||||
}
|
|
||||||
|
|
||||||
const response = await expensesAPI.getUserTransactions(walletKey.value, params)
|
|
||||||
transactions.value = response.entries
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Failed to load transactions:', error)
|
|
||||||
toast.error('Failed to load transactions', {
|
|
||||||
description: error instanceof Error ? error.message : 'Unknown error'
|
|
||||||
})
|
|
||||||
} finally {
|
|
||||||
isLoading.value = false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Handle date range type change
|
|
||||||
function onDateRangeTypeChange(value: number | 'custom') {
|
|
||||||
dateRangeType.value = value
|
|
||||||
|
|
||||||
if (value !== 'custom') {
|
|
||||||
// Clear custom dates when switching to preset days
|
|
||||||
customStartDate.value = ''
|
|
||||||
customEndDate.value = ''
|
|
||||||
// Load transactions immediately with preset days
|
|
||||||
loadTransactions()
|
|
||||||
}
|
|
||||||
// If switching to custom, wait for user to provide dates
|
|
||||||
}
|
|
||||||
|
|
||||||
// Apply custom date range
|
|
||||||
function applyCustomDateRange() {
|
|
||||||
if (customStartDate.value && customEndDate.value) {
|
|
||||||
loadTransactions()
|
|
||||||
} else {
|
|
||||||
toast.error('Invalid date range', {
|
|
||||||
description: 'Please select both start and end dates'
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
onMounted(() => {
|
|
||||||
loadTransactions()
|
|
||||||
})
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<template>
|
|
||||||
<div class="flex flex-col">
|
|
||||||
<!-- Compact Header -->
|
|
||||||
<div class="flex flex-col gap-3 p-4 md:p-6 border-b md:bg-card/50 md:backdrop-blur-sm">
|
|
||||||
<div class="w-full max-w-3xl mx-auto">
|
|
||||||
<div class="flex items-center justify-between mb-3">
|
|
||||||
<h1 class="text-lg md:text-xl font-bold">Transaction History</h1>
|
|
||||||
<Button
|
|
||||||
variant="outline"
|
|
||||||
size="sm"
|
|
||||||
@click="loadTransactions"
|
|
||||||
:disabled="isLoading"
|
|
||||||
class="gap-2 md:h-10 md:px-4 hover:bg-accent transition-colors"
|
|
||||||
>
|
|
||||||
<RefreshCw :class="{ 'animate-spin': isLoading }" class="h-4 w-4" />
|
|
||||||
<span class="hidden md:inline">Refresh</span>
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Date Range Controls -->
|
|
||||||
<div class="flex flex-col gap-3">
|
|
||||||
<!-- Preset Days / Custom Toggle -->
|
|
||||||
<div class="flex items-center gap-2 flex-wrap">
|
|
||||||
<Calendar class="h-4 w-4 text-muted-foreground" />
|
|
||||||
<Button
|
|
||||||
v-for="option in dateRangeOptions"
|
|
||||||
:key="option.value"
|
|
||||||
:variant="dateRangeType === option.value ? 'default' : 'outline'"
|
|
||||||
size="sm"
|
|
||||||
class="h-8 px-3 text-xs"
|
|
||||||
@click="onDateRangeTypeChange(option.value)"
|
|
||||||
:disabled="isLoading"
|
|
||||||
>
|
|
||||||
{{ option.label }}
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Custom Date Range Inputs -->
|
|
||||||
<div v-if="dateRangeType === 'custom'" class="flex items-end gap-2 flex-wrap">
|
|
||||||
<div class="flex flex-col gap-1">
|
|
||||||
<label class="text-xs text-muted-foreground">From:</label>
|
|
||||||
<Input
|
|
||||||
type="date"
|
|
||||||
v-model="customStartDate"
|
|
||||||
class="h-8 text-xs"
|
|
||||||
:disabled="isLoading"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div class="flex flex-col gap-1">
|
|
||||||
<label class="text-xs text-muted-foreground">To:</label>
|
|
||||||
<Input
|
|
||||||
type="date"
|
|
||||||
v-model="customEndDate"
|
|
||||||
class="h-8 text-xs"
|
|
||||||
:disabled="isLoading"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<Button
|
|
||||||
size="sm"
|
|
||||||
class="h-8 px-3 text-xs"
|
|
||||||
@click="applyCustomDateRange"
|
|
||||||
:disabled="isLoading || !customStartDate || !customEndDate"
|
|
||||||
>
|
|
||||||
Apply
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Content Container -->
|
|
||||||
<div class="w-full max-w-3xl mx-auto px-0 md:px-4">
|
|
||||||
<!-- Search Bar -->
|
|
||||||
<div class="px-4 md:px-0 py-3">
|
|
||||||
<FuzzySearch
|
|
||||||
:data="transactions"
|
|
||||||
:options="searchOptions"
|
|
||||||
placeholder="Search transactions..."
|
|
||||||
@results="handleSearchResults"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Results Count -->
|
|
||||||
<div class="px-4 md:px-0 py-2 text-xs md:text-sm text-muted-foreground">
|
|
||||||
<span v-if="searchResults.length > 0">
|
|
||||||
Found {{ transactionsToDisplay.length }} matching transaction{{ transactionsToDisplay.length === 1 ? '' : 's' }}
|
|
||||||
</span>
|
|
||||||
<span v-else>
|
|
||||||
{{ transactions.length }} transaction{{ transactions.length === 1 ? '' : 's' }}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Loading State -->
|
|
||||||
<div v-if="isLoading" class="flex items-center justify-center py-12">
|
|
||||||
<div class="flex items-center gap-2">
|
|
||||||
<RefreshCw class="h-4 w-4 animate-spin" />
|
|
||||||
<span class="text-muted-foreground">Loading transactions...</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Empty State -->
|
|
||||||
<div v-else-if="transactionsToDisplay.length === 0" class="text-center py-12 px-4">
|
|
||||||
<p class="text-muted-foreground">No transactions found</p>
|
|
||||||
<p class="text-sm text-muted-foreground mt-2">
|
|
||||||
{{ searchResults.length > 0 ? 'Try a different search term' : 'Try selecting a different time period' }}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Transaction Items (Full-width on mobile, no nested cards) -->
|
|
||||||
<div v-else class="md:space-y-3 md:py-4">
|
|
||||||
<div
|
|
||||||
v-for="transaction in transactionsToDisplay"
|
|
||||||
:key="transaction.id"
|
|
||||||
class="border-b md:border md:rounded-lg p-4 hover:bg-muted/50 transition-colors"
|
|
||||||
>
|
|
||||||
<!-- Transaction Header -->
|
|
||||||
<div class="flex items-start justify-between gap-3 mb-2">
|
|
||||||
<div class="flex-1 min-w-0">
|
|
||||||
<div class="flex items-center gap-2 mb-1">
|
|
||||||
<!-- Status Icon -->
|
|
||||||
<component
|
|
||||||
v-if="getStatusInfo(transaction.flag)"
|
|
||||||
:is="getStatusInfo(transaction.flag)!.icon"
|
|
||||||
:class="[
|
|
||||||
'h-4 w-4 flex-shrink-0',
|
|
||||||
getStatusInfo(transaction.flag)!.color
|
|
||||||
]"
|
|
||||||
/>
|
|
||||||
<h3 class="font-medium text-sm sm:text-base truncate">
|
|
||||||
{{ transaction.description }}
|
|
||||||
</h3>
|
|
||||||
</div>
|
|
||||||
<p class="text-xs sm:text-sm text-muted-foreground">
|
|
||||||
{{ formatDate(transaction.date) }}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Amount -->
|
|
||||||
<div class="text-right flex-shrink-0">
|
|
||||||
<p class="font-semibold text-sm sm:text-base">
|
|
||||||
{{ formatAmount(transaction.amount) }} sats
|
|
||||||
</p>
|
|
||||||
<p v-if="transaction.fiat_amount" class="text-xs text-muted-foreground">
|
|
||||||
{{ transaction.fiat_amount.toFixed(2) }} {{ transaction.fiat_currency }}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Transaction Details -->
|
|
||||||
<div class="space-y-1 text-xs sm:text-sm">
|
|
||||||
<!-- Payee -->
|
|
||||||
<div v-if="transaction.payee" class="text-muted-foreground">
|
|
||||||
<span class="font-medium">Payee:</span> {{ transaction.payee }}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Reference -->
|
|
||||||
<div v-if="transaction.reference" class="text-muted-foreground">
|
|
||||||
<span class="font-medium">Ref:</span> {{ transaction.reference }}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Username (if available) -->
|
|
||||||
<div v-if="transaction.username" class="text-muted-foreground">
|
|
||||||
<span class="font-medium">User:</span> {{ transaction.username }}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Tags -->
|
|
||||||
<div v-if="transaction.tags && transaction.tags.length > 0" class="flex flex-wrap gap-1 mt-2">
|
|
||||||
<Badge v-for="tag in transaction.tags" :key="tag" variant="secondary" class="text-xs">
|
|
||||||
{{ tag }}
|
|
||||||
</Badge>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Metadata Source -->
|
|
||||||
<div v-if="transaction.meta?.source" class="text-muted-foreground mt-1">
|
|
||||||
<span class="text-xs">Source: {{ transaction.meta.source }}</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- End of list indicator -->
|
|
||||||
<div v-if="transactionsToDisplay.length > 0" class="text-center py-6 text-md text-muted-foreground">
|
|
||||||
<p>🐢</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
|
|
@ -9,16 +9,13 @@ import {
|
||||||
DialogHeader,
|
DialogHeader,
|
||||||
DialogTitle,
|
DialogTitle,
|
||||||
} from '@/components/ui/dialog'
|
} from '@/components/ui/dialog'
|
||||||
import { Megaphone, RefreshCw, AlertCircle, ChevronLeft, ChevronRight } from 'lucide-vue-next'
|
import { Megaphone, RefreshCw, AlertCircle } from 'lucide-vue-next'
|
||||||
import { useFeed } from '../composables/useFeed'
|
import { 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'
|
||||||
|
|
@ -98,78 +95,6 @@ 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) {
|
||||||
|
|
@ -184,38 +109,6 @@ 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)
|
||||||
|
|
||||||
|
|
@ -265,52 +158,6 @@ async function onToggleLike(note: FeedPost) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Task action handlers
|
|
||||||
async function onClaimTask(event: ScheduledEvent, occurrence?: string) {
|
|
||||||
console.log('👋 NostrFeed: Claiming task:', event.title)
|
|
||||||
try {
|
|
||||||
await claimTask(event, '', occurrence)
|
|
||||||
} catch (error) {
|
|
||||||
console.error('❌ Failed to claim task:', error)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function onStartTask(event: ScheduledEvent, occurrence?: string) {
|
|
||||||
console.log('▶️ NostrFeed: Starting task:', event.title)
|
|
||||||
try {
|
|
||||||
await startTask(event, '', occurrence)
|
|
||||||
} catch (error) {
|
|
||||||
console.error('❌ Failed to start task:', error)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function onCompleteTask(event: ScheduledEvent, occurrence?: string) {
|
|
||||||
console.log('✅ NostrFeed: Completing task:', event.title)
|
|
||||||
try {
|
|
||||||
await completeEvent(event, occurrence, '')
|
|
||||||
} catch (error) {
|
|
||||||
console.error('❌ Failed to complete task:', error)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function onUnclaimTask(event: ScheduledEvent, occurrence?: string) {
|
|
||||||
console.log('🔙 NostrFeed: Unclaiming task:', event.title)
|
|
||||||
try {
|
|
||||||
await unclaimTask(event, occurrence)
|
|
||||||
} catch (error) {
|
|
||||||
console.error('❌ Failed to unclaim task:', error)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function onDeleteTask(event: ScheduledEvent) {
|
|
||||||
console.log('🗑️ NostrFeed: Deleting task:', event.title)
|
|
||||||
try {
|
|
||||||
await deleteTask(event)
|
|
||||||
} catch (error) {
|
|
||||||
console.error('❌ Failed to delete task:', error)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Handle collapse toggle with cascading behavior
|
// 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)
|
||||||
|
|
@ -509,75 +356,20 @@ 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>
|
||||||
<!-- Scheduled Tasks Section with Date Navigation -->
|
<div class="md:space-y-4 md:py-4">
|
||||||
<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"
|
||||||
|
|
@ -598,19 +390,8 @@ 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 v-if="threadedPosts.length > 0" class="text-center py-6 text-md text-muted-foreground">
|
<div class="text-center py-6 text-md text-muted-foreground">
|
||||||
<p>🐢</p>
|
<p>🐢</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -1,540 +0,0 @@
|
||||||
<script setup lang="ts">
|
|
||||||
import { computed, ref } from 'vue'
|
|
||||||
import { Badge } from '@/components/ui/badge'
|
|
||||||
import { Button } from '@/components/ui/button'
|
|
||||||
import { Checkbox } from '@/components/ui/checkbox'
|
|
||||||
import {
|
|
||||||
Dialog,
|
|
||||||
DialogContent,
|
|
||||||
DialogDescription,
|
|
||||||
DialogFooter,
|
|
||||||
DialogHeader,
|
|
||||||
DialogTitle,
|
|
||||||
} from '@/components/ui/dialog'
|
|
||||||
import {
|
|
||||||
Collapsible,
|
|
||||||
CollapsibleContent,
|
|
||||||
CollapsibleTrigger,
|
|
||||||
} from '@/components/ui/collapsible'
|
|
||||||
import { Calendar, MapPin, Clock, CheckCircle, PlayCircle, Hand, Trash2 } from 'lucide-vue-next'
|
|
||||||
import type { ScheduledEvent, EventCompletion, TaskStatus } from '../services/ScheduledEventService'
|
|
||||||
import { injectService, SERVICE_TOKENS } from '@/core/di-container'
|
|
||||||
import type { AuthService } from '@/modules/base/auth/auth-service'
|
|
||||||
|
|
||||||
interface Props {
|
|
||||||
event: ScheduledEvent
|
|
||||||
getDisplayName: (pubkey: string) => string
|
|
||||||
getCompletion: (eventAddress: string, occurrence?: string) => EventCompletion | undefined
|
|
||||||
getTaskStatus: (eventAddress: string, occurrence?: string) => TaskStatus | null
|
|
||||||
adminPubkeys?: string[]
|
|
||||||
}
|
|
||||||
|
|
||||||
interface Emits {
|
|
||||||
(e: 'claim-task', event: ScheduledEvent, occurrence?: string): void
|
|
||||||
(e: 'start-task', event: ScheduledEvent, occurrence?: string): void
|
|
||||||
(e: 'complete-task', event: ScheduledEvent, occurrence?: string): void
|
|
||||||
(e: 'unclaim-task', event: ScheduledEvent, occurrence?: string): void
|
|
||||||
(e: 'delete-task', event: ScheduledEvent): void
|
|
||||||
}
|
|
||||||
|
|
||||||
const props = withDefaults(defineProps<Props>(), {
|
|
||||||
adminPubkeys: () => []
|
|
||||||
})
|
|
||||||
|
|
||||||
const emit = defineEmits<Emits>()
|
|
||||||
|
|
||||||
// Get auth service to check current user
|
|
||||||
const authService = injectService<AuthService>(SERVICE_TOKENS.AUTH_SERVICE)
|
|
||||||
|
|
||||||
// Confirmation dialog state
|
|
||||||
const showConfirmDialog = ref(false)
|
|
||||||
const hasConfirmedCommunication = ref(false)
|
|
||||||
|
|
||||||
// Event address for tracking completion
|
|
||||||
const eventAddress = computed(() => `31922:${props.event.pubkey}:${props.event.dTag}`)
|
|
||||||
|
|
||||||
// Check if this is a recurring event
|
|
||||||
const isRecurring = computed(() => !!props.event.recurrence)
|
|
||||||
|
|
||||||
// For recurring events, occurrence is today's date. For non-recurring, it's undefined.
|
|
||||||
const occurrence = computed(() => {
|
|
||||||
if (!isRecurring.value) return undefined
|
|
||||||
return new Date().toISOString().split('T')[0] // YYYY-MM-DD
|
|
||||||
})
|
|
||||||
|
|
||||||
// Check if this is an admin event
|
|
||||||
const isAdminEvent = computed(() => props.adminPubkeys.includes(props.event.pubkey))
|
|
||||||
|
|
||||||
// Get current task status
|
|
||||||
const taskStatus = computed(() => props.getTaskStatus(eventAddress.value, occurrence.value))
|
|
||||||
|
|
||||||
// Check if event is completable (task type)
|
|
||||||
const isCompletable = computed(() => props.event.eventType === 'task')
|
|
||||||
|
|
||||||
// Get completion data
|
|
||||||
const completion = computed(() => props.getCompletion(eventAddress.value, occurrence.value))
|
|
||||||
|
|
||||||
// Get current user's pubkey
|
|
||||||
const currentUserPubkey = computed(() => authService?.user.value?.pubkey)
|
|
||||||
|
|
||||||
// Check if current user can unclaim
|
|
||||||
// Only show unclaim for "claimed" state, and only if current user is the one who claimed it
|
|
||||||
const canUnclaim = computed(() => {
|
|
||||||
if (!completion.value || !currentUserPubkey.value) return false
|
|
||||||
if (taskStatus.value !== 'claimed') return false
|
|
||||||
return completion.value.pubkey === currentUserPubkey.value
|
|
||||||
})
|
|
||||||
|
|
||||||
// Check if current user is the author of the task
|
|
||||||
const isAuthor = computed(() => {
|
|
||||||
if (!currentUserPubkey.value) return false
|
|
||||||
return props.event.pubkey === currentUserPubkey.value
|
|
||||||
})
|
|
||||||
|
|
||||||
// Status badges configuration
|
|
||||||
const statusConfig = computed(() => {
|
|
||||||
switch (taskStatus.value) {
|
|
||||||
case 'claimed':
|
|
||||||
return { label: 'Claimed', variant: 'secondary' as const, icon: Hand, color: 'text-blue-600' }
|
|
||||||
case 'in-progress':
|
|
||||||
return { label: 'In Progress', variant: 'default' as const, icon: PlayCircle, color: 'text-orange-600' }
|
|
||||||
case 'completed':
|
|
||||||
return { label: 'Completed', variant: 'secondary' as const, icon: CheckCircle, color: 'text-green-600' }
|
|
||||||
default:
|
|
||||||
return null
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
// Format the date/time
|
|
||||||
const formattedDate = computed(() => {
|
|
||||||
try {
|
|
||||||
const date = new Date(props.event.start)
|
|
||||||
|
|
||||||
// Check if it's a datetime or just date
|
|
||||||
if (props.event.start.includes('T')) {
|
|
||||||
// Full datetime - show date and time
|
|
||||||
return date.toLocaleString('en-US', {
|
|
||||||
weekday: 'short',
|
|
||||||
month: 'short',
|
|
||||||
day: 'numeric',
|
|
||||||
hour: 'numeric',
|
|
||||||
minute: '2-digit'
|
|
||||||
})
|
|
||||||
} else {
|
|
||||||
// Just date
|
|
||||||
return date.toLocaleDateString('en-US', {
|
|
||||||
weekday: 'short',
|
|
||||||
month: 'short',
|
|
||||||
day: 'numeric'
|
|
||||||
})
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
return props.event.start
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
// Format the time range if end time exists
|
|
||||||
const formattedTimeRange = computed(() => {
|
|
||||||
if (!props.event.end || !props.event.start.includes('T')) return null
|
|
||||||
|
|
||||||
try {
|
|
||||||
const start = new Date(props.event.start)
|
|
||||||
const end = new Date(props.event.end)
|
|
||||||
|
|
||||||
const startTime = start.toLocaleTimeString('en-US', {
|
|
||||||
hour: 'numeric',
|
|
||||||
minute: '2-digit'
|
|
||||||
})
|
|
||||||
const endTime = end.toLocaleTimeString('en-US', {
|
|
||||||
hour: 'numeric',
|
|
||||||
minute: '2-digit'
|
|
||||||
})
|
|
||||||
|
|
||||||
return `${startTime} - ${endTime}`
|
|
||||||
} catch (error) {
|
|
||||||
return null
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
// Action type for confirmation dialog
|
|
||||||
const pendingAction = ref<'claim' | 'start' | 'complete' | 'unclaim' | 'delete' | null>(null)
|
|
||||||
|
|
||||||
// Handle claim task
|
|
||||||
function handleClaimTask() {
|
|
||||||
pendingAction.value = 'claim'
|
|
||||||
showConfirmDialog.value = true
|
|
||||||
}
|
|
||||||
|
|
||||||
// Handle start task
|
|
||||||
function handleStartTask() {
|
|
||||||
pendingAction.value = 'start'
|
|
||||||
showConfirmDialog.value = true
|
|
||||||
}
|
|
||||||
|
|
||||||
// Handle complete task
|
|
||||||
function handleCompleteTask() {
|
|
||||||
pendingAction.value = 'complete'
|
|
||||||
showConfirmDialog.value = true
|
|
||||||
}
|
|
||||||
|
|
||||||
// Handle unclaim task
|
|
||||||
function handleUnclaimTask() {
|
|
||||||
pendingAction.value = 'unclaim'
|
|
||||||
showConfirmDialog.value = true
|
|
||||||
}
|
|
||||||
|
|
||||||
// Handle delete task
|
|
||||||
function handleDeleteTask() {
|
|
||||||
pendingAction.value = 'delete'
|
|
||||||
showConfirmDialog.value = true
|
|
||||||
}
|
|
||||||
|
|
||||||
// Confirm action
|
|
||||||
function confirmAction() {
|
|
||||||
if (!pendingAction.value) return
|
|
||||||
|
|
||||||
// For unclaim action, require checkbox confirmation
|
|
||||||
if (pendingAction.value === 'unclaim' && !hasConfirmedCommunication.value) {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
switch (pendingAction.value) {
|
|
||||||
case 'claim':
|
|
||||||
emit('claim-task', props.event, occurrence.value)
|
|
||||||
break
|
|
||||||
case 'start':
|
|
||||||
emit('start-task', props.event, occurrence.value)
|
|
||||||
break
|
|
||||||
case 'complete':
|
|
||||||
emit('complete-task', props.event, occurrence.value)
|
|
||||||
break
|
|
||||||
case 'unclaim':
|
|
||||||
emit('unclaim-task', props.event, occurrence.value)
|
|
||||||
break
|
|
||||||
case 'delete':
|
|
||||||
emit('delete-task', props.event)
|
|
||||||
break
|
|
||||||
}
|
|
||||||
|
|
||||||
showConfirmDialog.value = false
|
|
||||||
pendingAction.value = null
|
|
||||||
hasConfirmedCommunication.value = false
|
|
||||||
}
|
|
||||||
|
|
||||||
// Cancel action
|
|
||||||
function cancelAction() {
|
|
||||||
showConfirmDialog.value = false
|
|
||||||
pendingAction.value = null
|
|
||||||
hasConfirmedCommunication.value = false
|
|
||||||
}
|
|
||||||
|
|
||||||
// Get dialog content based on pending action
|
|
||||||
const dialogContent = computed(() => {
|
|
||||||
switch (pendingAction.value) {
|
|
||||||
case 'claim':
|
|
||||||
return {
|
|
||||||
title: 'Claim Task?',
|
|
||||||
description: `This will mark "${props.event.title}" as claimed by you. You can start working on it later.`,
|
|
||||||
confirmText: 'Claim Task'
|
|
||||||
}
|
|
||||||
case 'start':
|
|
||||||
return {
|
|
||||||
title: 'Start Task?',
|
|
||||||
description: `This will mark "${props.event.title}" as in-progress. Others will see you're actively working on it.`,
|
|
||||||
confirmText: 'Start Task'
|
|
||||||
}
|
|
||||||
case 'complete':
|
|
||||||
return {
|
|
||||||
title: 'Complete Task?',
|
|
||||||
description: `This will mark "${props.event.title}" as completed by you. Other users will be able to see that you completed this task.`,
|
|
||||||
confirmText: 'Mark Complete'
|
|
||||||
}
|
|
||||||
case 'unclaim':
|
|
||||||
return {
|
|
||||||
title: 'Unclaim Task?',
|
|
||||||
description: `This will remove your claim on "${props.event.title}" and make it available for others.\n\nHave you communicated to others that you are unclaiming this task?`,
|
|
||||||
confirmText: 'Unclaim Task'
|
|
||||||
}
|
|
||||||
case 'delete':
|
|
||||||
return {
|
|
||||||
title: 'Delete Task?',
|
|
||||||
description: `This will permanently delete "${props.event.title}". This action cannot be undone.`,
|
|
||||||
confirmText: 'Delete Task'
|
|
||||||
}
|
|
||||||
default:
|
|
||||||
return {
|
|
||||||
title: '',
|
|
||||||
description: '',
|
|
||||||
confirmText: ''
|
|
||||||
}
|
|
||||||
}
|
|
||||||
})
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<template>
|
|
||||||
<Collapsible class="border-b md:border md:rounded-lg bg-card transition-all"
|
|
||||||
:class="{ 'opacity-60': isCompletable && taskStatus === 'completed' }">
|
|
||||||
<!-- Collapsed View (Trigger) -->
|
|
||||||
<CollapsibleTrigger as-child>
|
|
||||||
<div class="flex items-center gap-3 p-3 md:p-4 cursor-pointer hover:bg-accent/50 transition-colors">
|
|
||||||
<!-- Time -->
|
|
||||||
<div class="flex items-center gap-1.5 text-sm text-muted-foreground shrink-0">
|
|
||||||
<Clock class="h-3.5 w-3.5" />
|
|
||||||
<span class="font-medium">{{ formattedTimeRange || formattedDate }}</span>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Title -->
|
|
||||||
<h3 class="font-semibold text-sm md:text-base flex-1 truncate"
|
|
||||||
:class="{ 'line-through': isCompletable && taskStatus === 'completed' }">
|
|
||||||
{{ event.title }}
|
|
||||||
</h3>
|
|
||||||
|
|
||||||
<!-- Badges and Actions -->
|
|
||||||
<div class="flex items-center gap-2 shrink-0">
|
|
||||||
<!-- Quick Action Button (context-aware) -->
|
|
||||||
<Button
|
|
||||||
v-if="isCompletable && !taskStatus"
|
|
||||||
@click.stop="handleClaimTask"
|
|
||||||
variant="ghost"
|
|
||||||
size="sm"
|
|
||||||
class="h-7 px-2 text-xs gap-1"
|
|
||||||
>
|
|
||||||
<Hand class="h-3.5 w-3.5" />
|
|
||||||
<span class="hidden sm:inline">Claim</span>
|
|
||||||
</Button>
|
|
||||||
|
|
||||||
<Button
|
|
||||||
v-else-if="isCompletable && taskStatus === 'claimed'"
|
|
||||||
@click.stop="handleStartTask"
|
|
||||||
variant="ghost"
|
|
||||||
size="sm"
|
|
||||||
class="h-7 px-2 text-xs gap-1"
|
|
||||||
>
|
|
||||||
<PlayCircle class="h-3.5 w-3.5" />
|
|
||||||
<span class="hidden sm:inline">Start</span>
|
|
||||||
</Button>
|
|
||||||
|
|
||||||
<Button
|
|
||||||
v-else-if="isCompletable && taskStatus === 'in-progress'"
|
|
||||||
@click.stop="handleCompleteTask"
|
|
||||||
variant="ghost"
|
|
||||||
size="sm"
|
|
||||||
class="h-7 px-2 text-xs gap-1"
|
|
||||||
>
|
|
||||||
<CheckCircle class="h-3.5 w-3.5" />
|
|
||||||
<span class="hidden sm:inline">Complete</span>
|
|
||||||
</Button>
|
|
||||||
|
|
||||||
<!-- Status Badge with claimer/completer name -->
|
|
||||||
<Badge v-if="isCompletable && statusConfig && completion" :variant="statusConfig.variant" class="text-xs gap-1">
|
|
||||||
<component :is="statusConfig.icon" class="h-3 w-3" :class="statusConfig.color" />
|
|
||||||
<span>{{ getDisplayName(completion.pubkey) }}</span>
|
|
||||||
</Badge>
|
|
||||||
|
|
||||||
<!-- Recurring Badge -->
|
|
||||||
<Badge v-if="isRecurring" variant="outline" class="text-xs">
|
|
||||||
🔄
|
|
||||||
</Badge>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</CollapsibleTrigger>
|
|
||||||
|
|
||||||
<!-- Expanded View (Content) -->
|
|
||||||
<CollapsibleContent class="p-4 md:p-6 pt-0">
|
|
||||||
<!-- Event Details -->
|
|
||||||
<div class="flex-1 min-w-0">
|
|
||||||
<!-- Date/Time -->
|
|
||||||
<div class="flex items-center gap-4 text-sm text-muted-foreground mb-2 flex-wrap">
|
|
||||||
<div class="flex items-center gap-1.5">
|
|
||||||
<Calendar class="h-4 w-4" />
|
|
||||||
<span>{{ formattedDate }}</span>
|
|
||||||
</div>
|
|
||||||
<div v-if="formattedTimeRange" class="flex items-center gap-1.5">
|
|
||||||
<Clock class="h-4 w-4" />
|
|
||||||
<span>{{ formattedTimeRange }}</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Location -->
|
|
||||||
<div v-if="event.location" class="flex items-center gap-1.5 text-sm text-muted-foreground mb-3">
|
|
||||||
<MapPin class="h-4 w-4" />
|
|
||||||
<span>{{ event.location }}</span>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Description/Content -->
|
|
||||||
<div v-if="event.description || event.content" class="text-sm mb-3">
|
|
||||||
<p class="whitespace-pre-wrap break-words">{{ event.description || event.content }}</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Task Status Info (only for completable events with status) -->
|
|
||||||
<div v-if="isCompletable && completion" class="text-xs mb-3">
|
|
||||||
<div v-if="taskStatus === 'completed'" class="text-muted-foreground">
|
|
||||||
✓ Completed by {{ getDisplayName(completion.pubkey) }}
|
|
||||||
<span v-if="completion.notes"> - {{ completion.notes }}</span>
|
|
||||||
</div>
|
|
||||||
<div v-else-if="taskStatus === 'in-progress'" class="text-orange-600 dark:text-orange-400 font-medium">
|
|
||||||
🔄 In Progress by {{ getDisplayName(completion.pubkey) }}
|
|
||||||
<span v-if="completion.notes"> - {{ completion.notes }}</span>
|
|
||||||
</div>
|
|
||||||
<div v-else-if="taskStatus === 'claimed'" class="text-blue-600 dark:text-blue-400 font-medium">
|
|
||||||
👋 Claimed by {{ getDisplayName(completion.pubkey) }}
|
|
||||||
<span v-if="completion.notes"> - {{ completion.notes }}</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Author (if not admin) -->
|
|
||||||
<div v-if="!isAdminEvent" class="text-xs text-muted-foreground mb-3">
|
|
||||||
Posted by {{ getDisplayName(event.pubkey) }}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Action Buttons (only for completable task events) -->
|
|
||||||
<div v-if="isCompletable" class="mt-3 flex flex-wrap gap-2">
|
|
||||||
<!-- Unclaimed Task - Show all options including jump ahead -->
|
|
||||||
<template v-if="!taskStatus">
|
|
||||||
<Button
|
|
||||||
@click.stop="handleClaimTask"
|
|
||||||
variant="default"
|
|
||||||
size="sm"
|
|
||||||
class="gap-2"
|
|
||||||
>
|
|
||||||
<Hand class="h-4 w-4" />
|
|
||||||
Claim Task
|
|
||||||
</Button>
|
|
||||||
<Button
|
|
||||||
@click.stop="handleStartTask"
|
|
||||||
variant="outline"
|
|
||||||
size="sm"
|
|
||||||
class="gap-2"
|
|
||||||
>
|
|
||||||
<PlayCircle class="h-4 w-4" />
|
|
||||||
Mark In Progress
|
|
||||||
</Button>
|
|
||||||
<Button
|
|
||||||
@click.stop="handleCompleteTask"
|
|
||||||
variant="outline"
|
|
||||||
size="sm"
|
|
||||||
class="gap-2"
|
|
||||||
>
|
|
||||||
<CheckCircle class="h-4 w-4" />
|
|
||||||
Mark Complete
|
|
||||||
</Button>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<!-- Claimed Task - Show start and option to skip directly to complete -->
|
|
||||||
<template v-else-if="taskStatus === 'claimed'">
|
|
||||||
<Button
|
|
||||||
@click.stop="handleStartTask"
|
|
||||||
variant="default"
|
|
||||||
size="sm"
|
|
||||||
class="gap-2"
|
|
||||||
>
|
|
||||||
<PlayCircle class="h-4 w-4" />
|
|
||||||
Start Task
|
|
||||||
</Button>
|
|
||||||
<Button
|
|
||||||
@click.stop="handleCompleteTask"
|
|
||||||
variant="outline"
|
|
||||||
size="sm"
|
|
||||||
class="gap-2"
|
|
||||||
>
|
|
||||||
<CheckCircle class="h-4 w-4" />
|
|
||||||
Mark Complete
|
|
||||||
</Button>
|
|
||||||
<Button
|
|
||||||
v-if="canUnclaim"
|
|
||||||
@click.stop="handleUnclaimTask"
|
|
||||||
variant="outline"
|
|
||||||
size="sm"
|
|
||||||
>
|
|
||||||
Unclaim
|
|
||||||
</Button>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<!-- In Progress Task -->
|
|
||||||
<template v-else-if="taskStatus === 'in-progress'">
|
|
||||||
<Button
|
|
||||||
@click.stop="handleCompleteTask"
|
|
||||||
variant="default"
|
|
||||||
size="sm"
|
|
||||||
class="gap-2"
|
|
||||||
>
|
|
||||||
<CheckCircle class="h-4 w-4" />
|
|
||||||
Mark Complete
|
|
||||||
</Button>
|
|
||||||
<Button
|
|
||||||
v-if="canUnclaim"
|
|
||||||
@click.stop="handleUnclaimTask"
|
|
||||||
variant="outline"
|
|
||||||
size="sm"
|
|
||||||
>
|
|
||||||
Unclaim
|
|
||||||
</Button>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<!-- Completed Task -->
|
|
||||||
<template v-else-if="taskStatus === 'completed'">
|
|
||||||
<Button
|
|
||||||
v-if="canUnclaim"
|
|
||||||
@click.stop="handleUnclaimTask"
|
|
||||||
variant="outline"
|
|
||||||
size="sm"
|
|
||||||
>
|
|
||||||
Unclaim
|
|
||||||
</Button>
|
|
||||||
</template>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Delete Task Button (only for task author) -->
|
|
||||||
<div v-if="isAuthor" class="mt-4 pt-4 border-t border-border">
|
|
||||||
<Button
|
|
||||||
@click.stop="handleDeleteTask"
|
|
||||||
variant="destructive"
|
|
||||||
size="sm"
|
|
||||||
class="gap-2"
|
|
||||||
>
|
|
||||||
<Trash2 class="h-4 w-4" />
|
|
||||||
Delete Task
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</CollapsibleContent>
|
|
||||||
|
|
||||||
</Collapsible>
|
|
||||||
|
|
||||||
<!-- Confirmation Dialog -->
|
|
||||||
<Dialog :open="showConfirmDialog" @update:open="(val: boolean) => showConfirmDialog = val">
|
|
||||||
<DialogContent>
|
|
||||||
<DialogHeader>
|
|
||||||
<DialogTitle>{{ dialogContent.title }}</DialogTitle>
|
|
||||||
<DialogDescription>
|
|
||||||
{{ dialogContent.description }}
|
|
||||||
</DialogDescription>
|
|
||||||
</DialogHeader>
|
|
||||||
|
|
||||||
<!-- Communication confirmation checkbox (only for unclaim) -->
|
|
||||||
<div v-if="pendingAction === 'unclaim'" class="flex items-start space-x-3 py-4">
|
|
||||||
<Checkbox
|
|
||||||
:model-value="hasConfirmedCommunication"
|
|
||||||
@update:model-value="(val) => hasConfirmedCommunication = !!val"
|
|
||||||
id="confirm-communication"
|
|
||||||
/>
|
|
||||||
<label
|
|
||||||
for="confirm-communication"
|
|
||||||
class="text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70 cursor-pointer"
|
|
||||||
>
|
|
||||||
I have communicated this to the team.
|
|
||||||
</label>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<DialogFooter>
|
|
||||||
<Button variant="outline" @click="cancelAction">Cancel</Button>
|
|
||||||
<Button
|
|
||||||
@click="confirmAction"
|
|
||||||
:disabled="pendingAction === 'unclaim' && !hasConfirmedCommunication"
|
|
||||||
>
|
|
||||||
{{ dialogContent.confirmText }}
|
|
||||||
</Button>
|
|
||||||
</DialogFooter>
|
|
||||||
</DialogContent>
|
|
||||||
</Dialog>
|
|
||||||
</template>
|
|
||||||
|
|
@ -1,261 +0,0 @@
|
||||||
import { computed } from 'vue'
|
|
||||||
import { injectService, SERVICE_TOKENS } from '@/core/di-container'
|
|
||||||
import type { ScheduledEventService, ScheduledEvent, EventCompletion, TaskStatus } from '../services/ScheduledEventService'
|
|
||||||
import type { AuthService } from '@/modules/base/auth/auth-service'
|
|
||||||
import { useToast } from '@/core/composables/useToast'
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Composable for managing scheduled events in the feed
|
|
||||||
*/
|
|
||||||
export function useScheduledEvents() {
|
|
||||||
const scheduledEventService = injectService<ScheduledEventService>(SERVICE_TOKENS.SCHEDULED_EVENT_SERVICE)
|
|
||||||
const authService = injectService<AuthService>(SERVICE_TOKENS.AUTH_SERVICE)
|
|
||||||
const toast = useToast()
|
|
||||||
|
|
||||||
// Get current user's pubkey
|
|
||||||
const currentUserPubkey = computed(() => authService?.user.value?.pubkey)
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Get all scheduled events
|
|
||||||
*/
|
|
||||||
const getScheduledEvents = (): ScheduledEvent[] => {
|
|
||||||
if (!scheduledEventService) return []
|
|
||||||
return scheduledEventService.getScheduledEvents()
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Get events for a specific date (YYYY-MM-DD)
|
|
||||||
*/
|
|
||||||
const getEventsForDate = (date: string): ScheduledEvent[] => {
|
|
||||||
if (!scheduledEventService) return []
|
|
||||||
return scheduledEventService.getEventsForDate(date)
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Get events for a specific date (filtered by current user participation)
|
|
||||||
* @param date - ISO date string (YYYY-MM-DD). Defaults to today.
|
|
||||||
*/
|
|
||||||
const getEventsForSpecificDate = (date?: string): ScheduledEvent[] => {
|
|
||||||
if (!scheduledEventService) return []
|
|
||||||
return scheduledEventService.getEventsForSpecificDate(date, currentUserPubkey.value)
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Get today's scheduled events (filtered by current user participation)
|
|
||||||
*/
|
|
||||||
const getTodaysEvents = (): ScheduledEvent[] => {
|
|
||||||
if (!scheduledEventService) return []
|
|
||||||
return scheduledEventService.getTodaysEvents(currentUserPubkey.value)
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Get completion status for an event
|
|
||||||
*/
|
|
||||||
const getCompletion = (eventAddress: string): EventCompletion | undefined => {
|
|
||||||
if (!scheduledEventService) return undefined
|
|
||||||
return scheduledEventService.getCompletion(eventAddress)
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Check if an event is completed
|
|
||||||
*/
|
|
||||||
const isCompleted = (eventAddress: string): boolean => {
|
|
||||||
if (!scheduledEventService) return false
|
|
||||||
return scheduledEventService.isCompleted(eventAddress)
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Get task status for an event
|
|
||||||
*/
|
|
||||||
const getTaskStatus = (eventAddress: string, occurrence?: string): TaskStatus | null => {
|
|
||||||
if (!scheduledEventService) return null
|
|
||||||
return scheduledEventService.getTaskStatus(eventAddress, occurrence)
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Claim a task
|
|
||||||
*/
|
|
||||||
const claimTask = async (event: ScheduledEvent, notes: string = '', occurrence?: string): Promise<void> => {
|
|
||||||
if (!scheduledEventService) {
|
|
||||||
toast.error('Scheduled event service not available')
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
await scheduledEventService.claimTask(event, notes, occurrence)
|
|
||||||
toast.success('Task claimed!')
|
|
||||||
} catch (error) {
|
|
||||||
const message = error instanceof Error ? error.message : 'Failed to claim task'
|
|
||||||
if (message.includes('authenticated')) {
|
|
||||||
toast.error('Please sign in to claim tasks')
|
|
||||||
} else {
|
|
||||||
toast.error(message)
|
|
||||||
}
|
|
||||||
console.error('Failed to claim task:', error)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Start a task (mark as in-progress)
|
|
||||||
*/
|
|
||||||
const startTask = async (event: ScheduledEvent, notes: string = '', occurrence?: string): Promise<void> => {
|
|
||||||
if (!scheduledEventService) {
|
|
||||||
toast.error('Scheduled event service not available')
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
await scheduledEventService.startTask(event, notes, occurrence)
|
|
||||||
toast.success('Task started!')
|
|
||||||
} catch (error) {
|
|
||||||
const message = error instanceof Error ? error.message : 'Failed to start task'
|
|
||||||
toast.error(message)
|
|
||||||
console.error('Failed to start task:', error)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Unclaim a task (remove task status)
|
|
||||||
*/
|
|
||||||
const unclaimTask = async (event: ScheduledEvent, occurrence?: string): Promise<void> => {
|
|
||||||
if (!scheduledEventService) {
|
|
||||||
toast.error('Scheduled event service not available')
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
await scheduledEventService.unclaimTask(event, occurrence)
|
|
||||||
toast.success('Task unclaimed')
|
|
||||||
} catch (error) {
|
|
||||||
const message = error instanceof Error ? error.message : 'Failed to unclaim task'
|
|
||||||
toast.error(message)
|
|
||||||
console.error('Failed to unclaim task:', error)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Toggle completion status of an event (optionally for a specific occurrence)
|
|
||||||
* DEPRECATED: Use claimTask, startTask, completeEvent, or unclaimTask instead for more granular control
|
|
||||||
*/
|
|
||||||
const toggleComplete = async (event: ScheduledEvent, occurrence?: string, notes: string = ''): Promise<void> => {
|
|
||||||
console.log('🔧 useScheduledEvents: toggleComplete called for event:', event.title, 'occurrence:', occurrence)
|
|
||||||
|
|
||||||
if (!scheduledEventService) {
|
|
||||||
console.error('❌ useScheduledEvents: Scheduled event service not available')
|
|
||||||
toast.error('Scheduled event service not available')
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
const eventAddress = `31922:${event.pubkey}:${event.dTag}`
|
|
||||||
const currentlyCompleted = scheduledEventService.isCompleted(eventAddress, occurrence)
|
|
||||||
console.log('📊 useScheduledEvents: Current completion status:', currentlyCompleted)
|
|
||||||
|
|
||||||
if (currentlyCompleted) {
|
|
||||||
console.log('⬇️ useScheduledEvents: Unclaiming task...')
|
|
||||||
await scheduledEventService.unclaimTask(event, occurrence)
|
|
||||||
toast.success('Task unclaimed')
|
|
||||||
} else {
|
|
||||||
console.log('⬆️ useScheduledEvents: Marking as complete...')
|
|
||||||
await scheduledEventService.completeEvent(event, notes, occurrence)
|
|
||||||
toast.success('Task completed!')
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
const message = error instanceof Error ? error.message : 'Failed to toggle completion'
|
|
||||||
|
|
||||||
if (message.includes('authenticated')) {
|
|
||||||
toast.error('Please sign in to complete tasks')
|
|
||||||
} else if (message.includes('Not connected')) {
|
|
||||||
toast.error('Not connected to relays')
|
|
||||||
} else {
|
|
||||||
toast.error(message)
|
|
||||||
}
|
|
||||||
|
|
||||||
console.error('❌ useScheduledEvents: Failed to toggle completion:', error)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Complete an event with optional notes
|
|
||||||
*/
|
|
||||||
const completeEvent = async (event: ScheduledEvent, occurrence?: string, notes: string = ''): Promise<void> => {
|
|
||||||
if (!scheduledEventService) {
|
|
||||||
toast.error('Scheduled event service not available')
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
await scheduledEventService.completeEvent(event, notes, occurrence)
|
|
||||||
toast.success('Task completed!')
|
|
||||||
} catch (error) {
|
|
||||||
const message = error instanceof Error ? error.message : 'Failed to complete task'
|
|
||||||
toast.error(message)
|
|
||||||
console.error('Failed to complete task:', error)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Get loading state
|
|
||||||
*/
|
|
||||||
const isLoading = computed(() => {
|
|
||||||
return scheduledEventService?.isLoading ?? false
|
|
||||||
})
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Get all scheduled events (reactive)
|
|
||||||
*/
|
|
||||||
const allScheduledEvents = computed(() => {
|
|
||||||
return scheduledEventService?.scheduledEvents ?? new Map()
|
|
||||||
})
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Delete a task (only author can delete)
|
|
||||||
*/
|
|
||||||
const deleteTask = async (event: ScheduledEvent): Promise<void> => {
|
|
||||||
if (!scheduledEventService) {
|
|
||||||
toast.error('Scheduled event service not available')
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
await scheduledEventService.deleteTask(event)
|
|
||||||
toast.success('Task deleted!')
|
|
||||||
} catch (error) {
|
|
||||||
const message = error instanceof Error ? error.message : 'Failed to delete task'
|
|
||||||
toast.error(message)
|
|
||||||
console.error('Failed to delete task:', error)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Get all completions (reactive) - returns array for better reactivity
|
|
||||||
*/
|
|
||||||
const allCompletions = computed(() => {
|
|
||||||
if (!scheduledEventService?.completions) return []
|
|
||||||
return Array.from(scheduledEventService.completions.values())
|
|
||||||
})
|
|
||||||
|
|
||||||
return {
|
|
||||||
// Methods - Getters
|
|
||||||
getScheduledEvents,
|
|
||||||
getEventsForDate,
|
|
||||||
getEventsForSpecificDate,
|
|
||||||
getTodaysEvents,
|
|
||||||
getCompletion,
|
|
||||||
isCompleted,
|
|
||||||
getTaskStatus,
|
|
||||||
|
|
||||||
// Methods - Actions
|
|
||||||
claimTask,
|
|
||||||
startTask,
|
|
||||||
completeEvent,
|
|
||||||
unclaimTask,
|
|
||||||
deleteTask,
|
|
||||||
toggleComplete, // DEPRECATED: Use specific actions instead
|
|
||||||
|
|
||||||
// State
|
|
||||||
isLoading,
|
|
||||||
allScheduledEvents,
|
|
||||||
allCompletions
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -88,14 +88,6 @@ 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'
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -118,11 +110,6 @@ 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,15 +1,11 @@
|
||||||
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
|
||||||
|
|
@ -20,28 +16,6 @@ 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...')
|
||||||
|
|
||||||
|
|
@ -49,12 +23,10 @@ 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
|
||||||
|
|
@ -71,10 +43,6 @@ 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,7 +47,6 @@ 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>()
|
||||||
|
|
@ -73,12 +72,10 @@ 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')
|
||||||
|
|
@ -202,12 +199,6 @@ 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
|
||||||
|
|
@ -266,25 +257,6 @@ 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
|
||||||
|
|
@ -383,28 +355,6 @@ 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
|
||||||
|
|
|
||||||
|
|
@ -1,678 +0,0 @@
|
||||||
import { ref, reactive } from 'vue'
|
|
||||||
import { BaseService } from '@/core/base/BaseService'
|
|
||||||
import { injectService, SERVICE_TOKENS } from '@/core/di-container'
|
|
||||||
import { finalizeEvent, type EventTemplate } from 'nostr-tools'
|
|
||||||
import type { Event as NostrEvent } from 'nostr-tools'
|
|
||||||
|
|
||||||
export interface RecurrencePattern {
|
|
||||||
frequency: 'daily' | 'weekly'
|
|
||||||
dayOfWeek?: string // For weekly: 'monday', 'tuesday', etc.
|
|
||||||
endDate?: string // ISO date string - when to stop recurring (optional)
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface ScheduledEvent {
|
|
||||||
id: string
|
|
||||||
pubkey: string
|
|
||||||
created_at: number
|
|
||||||
dTag: string // Unique identifier from 'd' tag
|
|
||||||
title: string
|
|
||||||
start: string // ISO date string (YYYY-MM-DD or ISO datetime)
|
|
||||||
end?: string
|
|
||||||
description?: string
|
|
||||||
location?: string
|
|
||||||
status: string
|
|
||||||
eventType?: string // 'task' for completable events, 'announcement' for informational
|
|
||||||
participants?: Array<{ pubkey: string; type?: string }> // 'required', 'optional', 'organizer'
|
|
||||||
content: string
|
|
||||||
tags: string[][]
|
|
||||||
recurrence?: RecurrencePattern // Optional: for recurring events
|
|
||||||
}
|
|
||||||
|
|
||||||
export type TaskStatus = 'claimed' | 'in-progress' | 'completed' | 'blocked' | 'cancelled'
|
|
||||||
|
|
||||||
export interface EventCompletion {
|
|
||||||
id: string
|
|
||||||
eventAddress: string // "31922:pubkey:d-tag"
|
|
||||||
occurrence?: string // ISO date string for the specific occurrence (YYYY-MM-DD)
|
|
||||||
pubkey: string // Who claimed/completed it
|
|
||||||
created_at: number
|
|
||||||
taskStatus: TaskStatus
|
|
||||||
completedAt?: number // Unix timestamp when completed
|
|
||||||
notes: string
|
|
||||||
}
|
|
||||||
|
|
||||||
export class ScheduledEventService extends BaseService {
|
|
||||||
protected readonly metadata = {
|
|
||||||
name: 'ScheduledEventService',
|
|
||||||
version: '1.0.0',
|
|
||||||
dependencies: []
|
|
||||||
}
|
|
||||||
|
|
||||||
protected relayHub: any = null
|
|
||||||
protected authService: any = null
|
|
||||||
|
|
||||||
// Scheduled events state - indexed by event address
|
|
||||||
private _scheduledEvents = reactive(new Map<string, ScheduledEvent>())
|
|
||||||
private _completions = reactive(new Map<string, EventCompletion>())
|
|
||||||
private _isLoading = ref(false)
|
|
||||||
|
|
||||||
protected async onInitialize(): Promise<void> {
|
|
||||||
console.log('ScheduledEventService: Starting initialization...')
|
|
||||||
|
|
||||||
this.relayHub = injectService(SERVICE_TOKENS.RELAY_HUB)
|
|
||||||
this.authService = injectService(SERVICE_TOKENS.AUTH_SERVICE)
|
|
||||||
|
|
||||||
if (!this.relayHub) {
|
|
||||||
throw new Error('RelayHub service not available')
|
|
||||||
}
|
|
||||||
|
|
||||||
console.log('ScheduledEventService: Initialization complete')
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Handle incoming scheduled event (kind 31922)
|
|
||||||
* Made public so FeedService can route kind 31922 events to this service
|
|
||||||
*/
|
|
||||||
public handleScheduledEvent(event: NostrEvent): void {
|
|
||||||
try {
|
|
||||||
// Extract event data from tags
|
|
||||||
const dTag = event.tags.find(tag => tag[0] === 'd')?.[1]
|
|
||||||
if (!dTag) {
|
|
||||||
console.warn('Scheduled event missing d tag:', event.id)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
const title = event.tags.find(tag => tag[0] === 'title')?.[1] || 'Untitled Event'
|
|
||||||
const start = event.tags.find(tag => tag[0] === 'start')?.[1]
|
|
||||||
const end = event.tags.find(tag => tag[0] === 'end')?.[1]
|
|
||||||
const description = event.tags.find(tag => tag[0] === 'description')?.[1]
|
|
||||||
const location = event.tags.find(tag => tag[0] === 'location')?.[1]
|
|
||||||
const status = event.tags.find(tag => tag[0] === 'status')?.[1] || 'pending'
|
|
||||||
const eventType = event.tags.find(tag => tag[0] === 'event-type')?.[1]
|
|
||||||
|
|
||||||
// Parse participant tags: ["p", "<pubkey>", "<relay-hint>", "<participation-type>"]
|
|
||||||
const participantTags = event.tags.filter(tag => tag[0] === 'p')
|
|
||||||
const participants = participantTags.map(tag => ({
|
|
||||||
pubkey: tag[1],
|
|
||||||
type: tag[3] // 'required', 'optional', 'organizer'
|
|
||||||
}))
|
|
||||||
|
|
||||||
// Parse recurrence tags
|
|
||||||
const recurrenceFreq = event.tags.find(tag => tag[0] === 'recurrence')?.[1] as 'daily' | 'weekly' | undefined
|
|
||||||
const recurrenceDayOfWeek = event.tags.find(tag => tag[0] === 'recurrence-day')?.[1]
|
|
||||||
const recurrenceEndDate = event.tags.find(tag => tag[0] === 'recurrence-end')?.[1]
|
|
||||||
|
|
||||||
let recurrence: RecurrencePattern | undefined
|
|
||||||
if (recurrenceFreq === 'daily' || recurrenceFreq === 'weekly') {
|
|
||||||
recurrence = {
|
|
||||||
frequency: recurrenceFreq,
|
|
||||||
dayOfWeek: recurrenceDayOfWeek,
|
|
||||||
endDate: recurrenceEndDate
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!start) {
|
|
||||||
console.warn('Scheduled event missing start date:', event.id)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
// Create event address: "kind:pubkey:d-tag"
|
|
||||||
const eventAddress = `31922:${event.pubkey}:${dTag}`
|
|
||||||
|
|
||||||
const scheduledEvent: ScheduledEvent = {
|
|
||||||
id: event.id,
|
|
||||||
pubkey: event.pubkey,
|
|
||||||
created_at: event.created_at,
|
|
||||||
dTag,
|
|
||||||
title,
|
|
||||||
start,
|
|
||||||
end,
|
|
||||||
description,
|
|
||||||
location,
|
|
||||||
status,
|
|
||||||
eventType,
|
|
||||||
participants: participants.length > 0 ? participants : undefined,
|
|
||||||
content: event.content,
|
|
||||||
tags: event.tags,
|
|
||||||
recurrence
|
|
||||||
}
|
|
||||||
|
|
||||||
// Store or update the event (replaceable by d-tag)
|
|
||||||
this._scheduledEvents.set(eventAddress, scheduledEvent)
|
|
||||||
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Failed to handle scheduled event:', error)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Handle RSVP/completion event (kind 31925)
|
|
||||||
* Made public so FeedService can route kind 31925 events to this service
|
|
||||||
*/
|
|
||||||
public handleCompletionEvent(event: NostrEvent): void {
|
|
||||||
console.log('🔔 ScheduledEventService: Received completion event (kind 31925)', event.id)
|
|
||||||
|
|
||||||
try {
|
|
||||||
// Find the event being responded to
|
|
||||||
const aTag = event.tags.find(tag => tag[0] === 'a')?.[1]
|
|
||||||
if (!aTag) {
|
|
||||||
console.warn('Completion event missing a tag:', event.id)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
// Parse task status (new approach)
|
|
||||||
const taskStatusTag = event.tags.find(tag => tag[0] === 'task-status')?.[1] as TaskStatus | undefined
|
|
||||||
|
|
||||||
// Backward compatibility: check old 'completed' tag if task-status not present
|
|
||||||
let taskStatus: TaskStatus
|
|
||||||
if (taskStatusTag) {
|
|
||||||
taskStatus = taskStatusTag
|
|
||||||
} else {
|
|
||||||
// Legacy support: convert old 'completed' tag to new taskStatus
|
|
||||||
const completed = event.tags.find(tag => tag[0] === 'completed')?.[1] === 'true'
|
|
||||||
taskStatus = completed ? 'completed' : 'claimed'
|
|
||||||
}
|
|
||||||
|
|
||||||
const completedAtTag = event.tags.find(tag => tag[0] === 'completed_at')?.[1]
|
|
||||||
const completedAt = completedAtTag ? parseInt(completedAtTag) : undefined
|
|
||||||
const occurrence = event.tags.find(tag => tag[0] === 'occurrence')?.[1] // ISO date string
|
|
||||||
|
|
||||||
console.log('📋 Completion details:', {
|
|
||||||
aTag,
|
|
||||||
occurrence,
|
|
||||||
taskStatus,
|
|
||||||
pubkey: event.pubkey,
|
|
||||||
eventId: event.id
|
|
||||||
})
|
|
||||||
|
|
||||||
const completion: EventCompletion = {
|
|
||||||
id: event.id,
|
|
||||||
eventAddress: aTag,
|
|
||||||
occurrence,
|
|
||||||
pubkey: event.pubkey,
|
|
||||||
created_at: event.created_at,
|
|
||||||
taskStatus,
|
|
||||||
completedAt,
|
|
||||||
notes: event.content
|
|
||||||
}
|
|
||||||
|
|
||||||
// Store completion (most recent one wins)
|
|
||||||
// For recurring events, include occurrence in the key: "eventAddress:occurrence"
|
|
||||||
// For non-recurring, just use eventAddress
|
|
||||||
const completionKey = occurrence ? `${aTag}:${occurrence}` : aTag
|
|
||||||
const existing = this._completions.get(completionKey)
|
|
||||||
if (!existing || event.created_at > existing.created_at) {
|
|
||||||
this._completions.set(completionKey, completion)
|
|
||||||
console.log('✅ Stored completion for:', completionKey, '- status:', taskStatus)
|
|
||||||
} else {
|
|
||||||
console.log('⏭️ Skipped older completion for:', completionKey)
|
|
||||||
}
|
|
||||||
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Failed to handle completion event:', error)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Handle deletion event (kind 5) for completion events
|
|
||||||
* Made public so FeedService can route deletion events to this service
|
|
||||||
*/
|
|
||||||
public handleDeletionEvent(event: NostrEvent): void {
|
|
||||||
console.log('🗑️ ScheduledEventService: Received deletion event (kind 5)', event.id)
|
|
||||||
|
|
||||||
try {
|
|
||||||
// Extract event IDs to delete from 'e' tags
|
|
||||||
const eventIdsToDelete = event.tags
|
|
||||||
?.filter((tag: string[]) => tag[0] === 'e')
|
|
||||||
.map((tag: string[]) => tag[1]) || []
|
|
||||||
|
|
||||||
if (eventIdsToDelete.length === 0) {
|
|
||||||
console.warn('Deletion event missing e tags:', event.id)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
console.log('🔍 Looking for completions to delete:', eventIdsToDelete)
|
|
||||||
|
|
||||||
// Find and remove completions that match the deleted event IDs
|
|
||||||
let deletedCount = 0
|
|
||||||
for (const [completionKey, completion] of this._completions.entries()) {
|
|
||||||
// Only delete if:
|
|
||||||
// 1. The completion event ID matches one being deleted
|
|
||||||
// 2. The deletion request comes from the same author (NIP-09 validation)
|
|
||||||
if (eventIdsToDelete.includes(completion.id) && completion.pubkey === event.pubkey) {
|
|
||||||
this._completions.delete(completionKey)
|
|
||||||
console.log('✅ Deleted completion:', completionKey, 'event ID:', completion.id)
|
|
||||||
deletedCount++
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
console.log(`🗑️ Deleted ${deletedCount} completion(s) from deletion event`)
|
|
||||||
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Failed to handle deletion event:', error)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Handle deletion event (kind 5) for scheduled events (kind 31922)
|
|
||||||
* Made public so FeedService can route deletion events to this service
|
|
||||||
*/
|
|
||||||
public handleTaskDeletion(event: NostrEvent): void {
|
|
||||||
console.log('🗑️ ScheduledEventService: Received task deletion event (kind 5)', event.id)
|
|
||||||
|
|
||||||
try {
|
|
||||||
// Extract event addresses to delete from 'a' tags
|
|
||||||
const eventAddressesToDelete = event.tags
|
|
||||||
?.filter((tag: string[]) => tag[0] === 'a')
|
|
||||||
.map((tag: string[]) => tag[1]) || []
|
|
||||||
|
|
||||||
if (eventAddressesToDelete.length === 0) {
|
|
||||||
console.warn('Task deletion event missing a tags:', event.id)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
console.log('🔍 Looking for tasks to delete:', eventAddressesToDelete)
|
|
||||||
|
|
||||||
// Find and remove tasks that match the deleted event addresses
|
|
||||||
let deletedCount = 0
|
|
||||||
for (const eventAddress of eventAddressesToDelete) {
|
|
||||||
const task = this._scheduledEvents.get(eventAddress)
|
|
||||||
|
|
||||||
// Only delete if:
|
|
||||||
// 1. The task exists
|
|
||||||
// 2. The deletion request comes from the task author (NIP-09 validation)
|
|
||||||
if (task && task.pubkey === event.pubkey) {
|
|
||||||
this._scheduledEvents.delete(eventAddress)
|
|
||||||
console.log('✅ Deleted task:', eventAddress)
|
|
||||||
deletedCount++
|
|
||||||
} else if (task) {
|
|
||||||
console.warn('⚠️ Deletion request not from task author:', eventAddress)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
console.log(`🗑️ Deleted ${deletedCount} task(s) from deletion event`)
|
|
||||||
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Failed to handle task deletion event:', error)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Get all scheduled events
|
|
||||||
*/
|
|
||||||
getScheduledEvents(): ScheduledEvent[] {
|
|
||||||
return Array.from(this._scheduledEvents.values())
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Get events scheduled for a specific date (YYYY-MM-DD)
|
|
||||||
*/
|
|
||||||
getEventsForDate(date: string): ScheduledEvent[] {
|
|
||||||
return this.getScheduledEvents().filter(event => {
|
|
||||||
// Simple date matching (start date)
|
|
||||||
// For ISO datetime strings, extract just the date part
|
|
||||||
const eventDate = event.start.split('T')[0]
|
|
||||||
return eventDate === date
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Check if a recurring event occurs on a specific date
|
|
||||||
*/
|
|
||||||
private doesRecurringEventOccurOnDate(event: ScheduledEvent, targetDate: string): boolean {
|
|
||||||
if (!event.recurrence) return false
|
|
||||||
|
|
||||||
const target = new Date(targetDate)
|
|
||||||
const eventStart = new Date(event.start.split('T')[0]) // Get date part only
|
|
||||||
|
|
||||||
// Check if target date is before the event start date
|
|
||||||
if (target < eventStart) return false
|
|
||||||
|
|
||||||
// Check if target date is after the event end date (if specified)
|
|
||||||
if (event.recurrence.endDate) {
|
|
||||||
const endDate = new Date(event.recurrence.endDate)
|
|
||||||
if (target > endDate) return false
|
|
||||||
}
|
|
||||||
|
|
||||||
// Check frequency-specific rules
|
|
||||||
if (event.recurrence.frequency === 'daily') {
|
|
||||||
// Daily events occur every day within the range
|
|
||||||
return true
|
|
||||||
} else if (event.recurrence.frequency === 'weekly') {
|
|
||||||
// Weekly events occur on specific day of week
|
|
||||||
const targetDayOfWeek = target.toLocaleDateString('en-US', { weekday: 'long' }).toLowerCase()
|
|
||||||
const eventDayOfWeek = event.recurrence.dayOfWeek?.toLowerCase()
|
|
||||||
return targetDayOfWeek === eventDayOfWeek
|
|
||||||
}
|
|
||||||
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Get events for a specific date, optionally filtered by user participation
|
|
||||||
* @param date - ISO date string (YYYY-MM-DD). Defaults to today.
|
|
||||||
* @param userPubkey - Optional user pubkey to filter by participation
|
|
||||||
*/
|
|
||||||
getEventsForSpecificDate(date?: string, userPubkey?: string): ScheduledEvent[] {
|
|
||||||
const targetDate = date || new Date().toISOString().split('T')[0]
|
|
||||||
|
|
||||||
// Get one-time events for the date (exclude recurring events to avoid duplicates)
|
|
||||||
const oneTimeEvents = this.getEventsForDate(targetDate).filter(event => !event.recurrence)
|
|
||||||
|
|
||||||
// Get all events and check for recurring events that occur on this date
|
|
||||||
const allEvents = this.getScheduledEvents()
|
|
||||||
const recurringEventsOnDate = allEvents.filter(event =>
|
|
||||||
event.recurrence && this.doesRecurringEventOccurOnDate(event, targetDate)
|
|
||||||
)
|
|
||||||
|
|
||||||
// Combine one-time and recurring events
|
|
||||||
let events = [...oneTimeEvents, ...recurringEventsOnDate]
|
|
||||||
|
|
||||||
// Filter events based on participation (if user pubkey provided)
|
|
||||||
if (userPubkey) {
|
|
||||||
events = events.filter(event => {
|
|
||||||
// If event has no participants, it's community-wide (show to everyone)
|
|
||||||
if (!event.participants || event.participants.length === 0) return true
|
|
||||||
|
|
||||||
// Otherwise, only show if user is a participant
|
|
||||||
return event.participants.some(p => p.pubkey === userPubkey)
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
// Sort by start time (ascending order)
|
|
||||||
events.sort((a, b) => {
|
|
||||||
// ISO datetime strings can be compared lexicographically
|
|
||||||
return a.start.localeCompare(b.start)
|
|
||||||
})
|
|
||||||
|
|
||||||
return events
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Get events for today, optionally filtered by user participation
|
|
||||||
*/
|
|
||||||
getTodaysEvents(userPubkey?: string): ScheduledEvent[] {
|
|
||||||
return this.getEventsForSpecificDate(undefined, userPubkey)
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Get completion status for an event (optionally for a specific occurrence)
|
|
||||||
*/
|
|
||||||
getCompletion(eventAddress: string, occurrence?: string): EventCompletion | undefined {
|
|
||||||
const completionKey = occurrence ? `${eventAddress}:${occurrence}` : eventAddress
|
|
||||||
return this._completions.get(completionKey)
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Check if an event is completed (optionally for a specific occurrence)
|
|
||||||
*/
|
|
||||||
isCompleted(eventAddress: string, occurrence?: string): boolean {
|
|
||||||
const completion = this.getCompletion(eventAddress, occurrence)
|
|
||||||
return completion?.taskStatus === 'completed'
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Get task status for an event
|
|
||||||
*/
|
|
||||||
getTaskStatus(eventAddress: string, occurrence?: string): TaskStatus | null {
|
|
||||||
const completion = this.getCompletion(eventAddress, occurrence)
|
|
||||||
return completion?.taskStatus || null
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Claim a task (mark as claimed)
|
|
||||||
*/
|
|
||||||
async claimTask(event: ScheduledEvent, notes: string = '', occurrence?: string): Promise<void> {
|
|
||||||
await this.updateTaskStatus(event, 'claimed', notes, occurrence)
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Start a task (mark as in-progress)
|
|
||||||
*/
|
|
||||||
async startTask(event: ScheduledEvent, notes: string = '', occurrence?: string): Promise<void> {
|
|
||||||
await this.updateTaskStatus(event, 'in-progress', notes, occurrence)
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Mark an event as complete (optionally for a specific occurrence)
|
|
||||||
*/
|
|
||||||
async completeEvent(event: ScheduledEvent, notes: string = '', occurrence?: string): Promise<void> {
|
|
||||||
await this.updateTaskStatus(event, 'completed', notes, occurrence)
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Internal method to update task status
|
|
||||||
*/
|
|
||||||
private async updateTaskStatus(
|
|
||||||
event: ScheduledEvent,
|
|
||||||
taskStatus: TaskStatus,
|
|
||||||
notes: string = '',
|
|
||||||
occurrence?: string
|
|
||||||
): Promise<void> {
|
|
||||||
if (!this.authService?.isAuthenticated?.value) {
|
|
||||||
throw new Error('Must be authenticated to update task status')
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!this.relayHub?.isConnected) {
|
|
||||||
throw new Error('Not connected to relays')
|
|
||||||
}
|
|
||||||
|
|
||||||
const userPrivkey = this.authService.user.value?.prvkey
|
|
||||||
if (!userPrivkey) {
|
|
||||||
throw new Error('User private key not available')
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
this._isLoading.value = true
|
|
||||||
|
|
||||||
const eventAddress = `31922:${event.pubkey}:${event.dTag}`
|
|
||||||
|
|
||||||
// Create RSVP event with task-status tag
|
|
||||||
const tags: string[][] = [
|
|
||||||
['a', eventAddress],
|
|
||||||
['task-status', taskStatus]
|
|
||||||
]
|
|
||||||
|
|
||||||
// Add completed_at timestamp if task is completed
|
|
||||||
if (taskStatus === 'completed') {
|
|
||||||
tags.push(['completed_at', Math.floor(Date.now() / 1000).toString()])
|
|
||||||
}
|
|
||||||
|
|
||||||
// Add occurrence tag if provided (for recurring events)
|
|
||||||
if (occurrence) {
|
|
||||||
tags.push(['occurrence', occurrence])
|
|
||||||
}
|
|
||||||
|
|
||||||
const eventTemplate: EventTemplate = {
|
|
||||||
kind: 31925, // Calendar Event RSVP
|
|
||||||
content: notes,
|
|
||||||
tags,
|
|
||||||
created_at: Math.floor(Date.now() / 1000)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Sign the event
|
|
||||||
const privkeyBytes = this.hexToUint8Array(userPrivkey)
|
|
||||||
const signedEvent = finalizeEvent(eventTemplate, privkeyBytes)
|
|
||||||
|
|
||||||
// Publish the status update
|
|
||||||
console.log(`📤 Publishing task status update (${taskStatus}) for:`, eventAddress)
|
|
||||||
const result = await this.relayHub.publishEvent(signedEvent)
|
|
||||||
console.log('✅ Task status published to', result.success, '/', result.total, 'relays')
|
|
||||||
|
|
||||||
// Update local state (publishEvent throws if no relays accepted)
|
|
||||||
console.log('🔄 Updating local state (event published successfully)')
|
|
||||||
this.handleCompletionEvent(signedEvent)
|
|
||||||
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Failed to update task status:', error)
|
|
||||||
throw error
|
|
||||||
} finally {
|
|
||||||
this._isLoading.value = false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Unclaim/reset a task (removes task status - makes it unclaimed)
|
|
||||||
* Note: In Nostr, we can't truly "delete" an event, but we can publish
|
|
||||||
* a deletion request (kind 5) to ask relays to remove our RSVP
|
|
||||||
*/
|
|
||||||
async unclaimTask(event: ScheduledEvent, occurrence?: string): Promise<void> {
|
|
||||||
if (!this.authService?.isAuthenticated?.value) {
|
|
||||||
throw new Error('Must be authenticated to unclaim tasks')
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!this.relayHub?.isConnected) {
|
|
||||||
throw new Error('Not connected to relays')
|
|
||||||
}
|
|
||||||
|
|
||||||
const userPrivkey = this.authService.user.value?.prvkey
|
|
||||||
if (!userPrivkey) {
|
|
||||||
throw new Error('User private key not available')
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
this._isLoading.value = true
|
|
||||||
|
|
||||||
const eventAddress = `31922:${event.pubkey}:${event.dTag}`
|
|
||||||
const completionKey = occurrence ? `${eventAddress}:${occurrence}` : eventAddress
|
|
||||||
const completion = this._completions.get(completionKey)
|
|
||||||
|
|
||||||
if (!completion) {
|
|
||||||
console.log('No completion to unclaim')
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
// Create deletion event (kind 5) for the RSVP
|
|
||||||
const deletionEvent: EventTemplate = {
|
|
||||||
kind: 5,
|
|
||||||
content: 'Task unclaimed',
|
|
||||||
tags: [
|
|
||||||
['e', completion.id], // Reference to the RSVP event being deleted
|
|
||||||
['k', '31925'] // Kind of event being deleted
|
|
||||||
],
|
|
||||||
created_at: Math.floor(Date.now() / 1000)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Sign the event
|
|
||||||
const privkeyBytes = this.hexToUint8Array(userPrivkey)
|
|
||||||
const signedEvent = finalizeEvent(deletionEvent, privkeyBytes)
|
|
||||||
|
|
||||||
// Publish the deletion request
|
|
||||||
console.log('📤 Publishing deletion request for task RSVP:', completion.id)
|
|
||||||
const result = await this.relayHub.publishEvent(signedEvent)
|
|
||||||
console.log('✅ Deletion request published to', result.success, '/', result.total, 'relays')
|
|
||||||
|
|
||||||
// Remove from local state (publishEvent throws if no relays accepted)
|
|
||||||
this._completions.delete(completionKey)
|
|
||||||
console.log('🗑️ Removed completion from local state:', completionKey)
|
|
||||||
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Failed to unclaim task:', error)
|
|
||||||
throw error
|
|
||||||
} finally {
|
|
||||||
this._isLoading.value = false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Delete a scheduled event (kind 31922)
|
|
||||||
* Only the author can delete their own event
|
|
||||||
*/
|
|
||||||
async deleteTask(event: ScheduledEvent): Promise<void> {
|
|
||||||
if (!this.authService?.isAuthenticated?.value) {
|
|
||||||
throw new Error('Must be authenticated to delete tasks')
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!this.relayHub?.isConnected) {
|
|
||||||
throw new Error('Not connected to relays')
|
|
||||||
}
|
|
||||||
|
|
||||||
const userPrivkey = this.authService.user.value?.prvkey
|
|
||||||
const userPubkey = this.authService.user.value?.pubkey
|
|
||||||
|
|
||||||
if (!userPrivkey || !userPubkey) {
|
|
||||||
throw new Error('User credentials not available')
|
|
||||||
}
|
|
||||||
|
|
||||||
// Only author can delete
|
|
||||||
if (userPubkey !== event.pubkey) {
|
|
||||||
throw new Error('Only the task author can delete this task')
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
this._isLoading.value = true
|
|
||||||
|
|
||||||
const eventAddress = `31922:${event.pubkey}:${event.dTag}`
|
|
||||||
|
|
||||||
// Create deletion event (kind 5) for the scheduled event
|
|
||||||
const deletionEvent: EventTemplate = {
|
|
||||||
kind: 5,
|
|
||||||
content: 'Task deleted',
|
|
||||||
tags: [
|
|
||||||
['a', eventAddress], // Reference to the parameterized replaceable event being deleted
|
|
||||||
['k', '31922'] // Kind of event being deleted
|
|
||||||
],
|
|
||||||
created_at: Math.floor(Date.now() / 1000)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Sign the event
|
|
||||||
const privkeyBytes = this.hexToUint8Array(userPrivkey)
|
|
||||||
const signedEvent = finalizeEvent(deletionEvent, privkeyBytes)
|
|
||||||
|
|
||||||
// Publish the deletion request
|
|
||||||
console.log('📤 Publishing deletion request for task:', eventAddress)
|
|
||||||
const result = await this.relayHub.publishEvent(signedEvent)
|
|
||||||
console.log('✅ Task deletion request published to', result.success, '/', result.total, 'relays')
|
|
||||||
|
|
||||||
// Remove from local state (publishEvent throws if no relays accepted)
|
|
||||||
this._scheduledEvents.delete(eventAddress)
|
|
||||||
console.log('🗑️ Removed task from local state:', eventAddress)
|
|
||||||
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Failed to delete task:', error)
|
|
||||||
throw error
|
|
||||||
} finally {
|
|
||||||
this._isLoading.value = false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Helper function to convert hex string to Uint8Array
|
|
||||||
*/
|
|
||||||
private hexToUint8Array(hex: string): Uint8Array {
|
|
||||||
const bytes = new Uint8Array(hex.length / 2)
|
|
||||||
for (let i = 0; i < hex.length; i += 2) {
|
|
||||||
bytes[i / 2] = parseInt(hex.substr(i, 2), 16)
|
|
||||||
}
|
|
||||||
return bytes
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Get all scheduled events
|
|
||||||
*/
|
|
||||||
get scheduledEvents(): Map<string, ScheduledEvent> {
|
|
||||||
return this._scheduledEvents
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Get all completions
|
|
||||||
*/
|
|
||||||
get completions(): Map<string, EventCompletion> {
|
|
||||||
return this._completions
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Check if currently loading
|
|
||||||
*/
|
|
||||||
get isLoading(): boolean {
|
|
||||||
return this._isLoading.value
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Cleanup
|
|
||||||
*/
|
|
||||||
protected async onDestroy(): Promise<void> {
|
|
||||||
this._scheduledEvents.clear()
|
|
||||||
this._completions.clear()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -36,20 +36,24 @@
|
||||||
|
|
||||||
<!-- 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">
|
||||||
<!-- Quick Action Component Area -->
|
<!-- Collapsible Composer -->
|
||||||
<div v-if="activeAction || replyTo" class="border-b bg-background sticky top-0 z-10">
|
<div v-if="showComposer || replyTo" class="border-b bg-background sticky top-0 z-10">
|
||||||
<div class="max-h-[70vh] overflow-y-auto scrollbar-thin scrollbar-thumb-border scrollbar-track-transparent">
|
<div class="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">
|
||||||
<!-- Dynamic Quick Action Component -->
|
<!-- Regular Note Composer -->
|
||||||
<component
|
<NoteComposer
|
||||||
:is="activeAction?.component"
|
v-if="composerType === 'note' || replyTo"
|
||||||
v-if="activeAction"
|
|
||||||
:reply-to="replyTo"
|
:reply-to="replyTo"
|
||||||
@note-published="onActionComplete"
|
@note-published="onNotePublished"
|
||||||
@rideshare-published="onActionComplete"
|
|
||||||
@action-complete="onActionComplete"
|
|
||||||
@clear-reply="onClearReply"
|
@clear-reply="onClearReply"
|
||||||
@close="closeQuickAction"
|
@close="onCloseComposer"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<!-- Rideshare Composer -->
|
||||||
|
<RideshareComposer
|
||||||
|
v-else-if="composerType === 'rideshare'"
|
||||||
|
@rideshare-published="onRidesharePublished"
|
||||||
|
@close="onCloseComposer"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -68,32 +72,39 @@
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Floating Quick Action Button -->
|
<!-- Floating Action Buttons for Compose -->
|
||||||
<div v-if="!activeAction && !replyTo && quickActions.length > 0" class="fixed bottom-6 right-6 z-50">
|
<div v-if="!showComposer && !replyTo" class="fixed bottom-6 right-6 z-50">
|
||||||
|
<!-- Main compose button -->
|
||||||
<div class="flex flex-col items-end gap-3">
|
<div class="flex flex-col items-end gap-3">
|
||||||
<!-- Quick Action Buttons (when expanded) -->
|
<!-- Secondary buttons (when expanded) -->
|
||||||
<div v-if="showQuickActions" class="flex flex-col gap-2">
|
<div v-if="showComposerOptions" class="flex flex-col gap-2">
|
||||||
<Button
|
<Button
|
||||||
v-for="action in quickActions"
|
@click="openComposer('note')"
|
||||||
: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"
|
||||||
>
|
>
|
||||||
<component :is="getIconComponent(action.icon)" class="h-4 w-4" />
|
<MessageSquare class="h-4 w-4" />
|
||||||
<span class="text-sm font-medium">{{ action.label }}</span>
|
<span class="text-sm font-medium">Note</span>
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
@click="openComposer('rideshare')"
|
||||||
|
size="lg"
|
||||||
|
class="h-12 px-4 rounded-full shadow-lg hover:shadow-xl transition-all gap-2 bg-card border-2 border-border hover:bg-accent text-card-foreground"
|
||||||
|
>
|
||||||
|
<Car class="h-4 w-4" />
|
||||||
|
<span class="text-sm font-medium">Rideshare</span>
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Main FAB -->
|
<!-- Main FAB -->
|
||||||
<Button
|
<Button
|
||||||
@click="toggleQuickActions"
|
@click="toggleComposerOptions"
|
||||||
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': showQuickActions }"
|
:class="{ 'rotate-45': showComposerOptions }"
|
||||||
/>
|
/>
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -125,34 +136,32 @@
|
||||||
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 } from 'lucide-vue-next'
|
import { Filter, Plus, MessageSquare, Car } 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 { QuickAction } from '@/core/types'
|
import type { ReplyToNote } from '@/modules/nostr-feed/components/NoteComposer.vue'
|
||||||
|
|
||||||
// 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 showQuickActions = ref(false)
|
const showComposer = ref(false)
|
||||||
const activeAction = ref<QuickAction | null>(null)
|
const showComposerOptions = ref(false)
|
||||||
|
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
|
||||||
|
|
||||||
// Reply state (for note composer compatibility)
|
// Note composer state
|
||||||
const replyTo = ref<any | undefined>()
|
const replyTo = ref<ReplyToNote | undefined>()
|
||||||
|
|
||||||
// Quick filter presets for mobile bottom bar
|
// Quick filter presets for mobile bottom bar
|
||||||
const quickFilterPresets = {
|
const quickFilterPresets = {
|
||||||
|
|
@ -212,46 +221,48 @@ const setQuickFilter = (presetKey: string) => {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Quick action methods
|
const onNotePublished = (noteId: string) => {
|
||||||
const toggleQuickActions = () => {
|
console.log('Note published:', noteId)
|
||||||
showQuickActions.value = !showQuickActions.value
|
// Refresh the feed to show the new note
|
||||||
}
|
|
||||||
|
|
||||||
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++
|
feedKey.value++
|
||||||
// Close the action
|
// Clear reply state and hide composer
|
||||||
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: any) => {
|
const onReplyToNote = (note: ReplyToNote) => {
|
||||||
replyTo.value = note
|
replyTo.value = note
|
||||||
// Find and open the note composer action
|
showComposer.value = true
|
||||||
const noteAction = quickActions.value.find(a => a.id === 'note')
|
|
||||||
if (noteAction) {
|
|
||||||
activeAction.value = noteAction
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Helper to get Lucide icon component
|
const onCloseComposer = () => {
|
||||||
const getIconComponent = (iconName: string) => {
|
showComposer.value = false
|
||||||
return (LucideIcons as any)[iconName] || Plus
|
showComposerOptions.value = false
|
||||||
|
replyTo.value = undefined
|
||||||
|
}
|
||||||
|
|
||||||
|
// New composer methods
|
||||||
|
const toggleComposerOptions = () => {
|
||||||
|
showComposerOptions.value = !showComposerOptions.value
|
||||||
|
}
|
||||||
|
|
||||||
|
const openComposer = (type: 'note' | 'rideshare') => {
|
||||||
|
composerType.value = type
|
||||||
|
showComposer.value = true
|
||||||
|
showComposerOptions.value = false
|
||||||
|
}
|
||||||
|
|
||||||
|
const onRidesharePublished = (noteId: string) => {
|
||||||
|
console.log('Rideshare post published:', noteId)
|
||||||
|
// Refresh the feed to show the new rideshare post
|
||||||
|
feedKey.value++
|
||||||
|
// Hide composer
|
||||||
|
showComposer.value = false
|
||||||
|
showComposerOptions.value = false
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue