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
484
CLAUDE.md
484
CLAUDE.md
|
|
@ -26,7 +26,7 @@ This is a modular Vue 3 + TypeScript + Vite application with Electron support, f
|
|||
The application uses a plugin-based modular architecture with dependency injection for service management:
|
||||
|
||||
**Core Modules:**
|
||||
- **Base Module** (`src/modules/base/`) - Core infrastructure (Nostr, Auth, PWA, Image Upload)
|
||||
- **Base Module** (`src/modules/base/`) - Core infrastructure (Nostr, Auth, PWA)
|
||||
- **Wallet Module** (`src/modules/wallet/`) - Lightning wallet management with real-time balance updates
|
||||
- **Nostr Feed Module** (`src/modules/nostr-feed/`) - Social feed functionality
|
||||
- **Chat Module** (`src/modules/chat/`) - Encrypted Nostr chat
|
||||
|
|
@ -90,12 +90,6 @@ const relayHub = injectService(SERVICE_TOKENS.RELAY_HUB)
|
|||
- `SERVICE_TOKENS.VISIBILITY_SERVICE` - App visibility and connection management
|
||||
- `SERVICE_TOKENS.WALLET_SERVICE` - Wallet operations (send, receive, transactions)
|
||||
- `SERVICE_TOKENS.WALLET_WEBSOCKET_SERVICE` - Real-time wallet balance updates via WebSocket
|
||||
- `SERVICE_TOKENS.STORAGE_SERVICE` - Local storage management
|
||||
- `SERVICE_TOKENS.TOAST_SERVICE` - Toast notification system
|
||||
- `SERVICE_TOKENS.INVOICE_SERVICE` - Lightning invoice creation and management
|
||||
- `SERVICE_TOKENS.LNBITS_API` - LNbits API client
|
||||
- `SERVICE_TOKENS.IMAGE_UPLOAD_SERVICE` - Image upload to pictrs server
|
||||
- `SERVICE_TOKENS.NOSTR_METADATA_SERVICE` - Nostr user metadata (NIP-01 kind 0)
|
||||
|
||||
**Core Stack:**
|
||||
- Vue 3 with Composition API (`<script setup>` style)
|
||||
|
|
@ -128,8 +122,6 @@ const relayHub = injectService(SERVICE_TOKENS.RELAY_HUB)
|
|||
- `api/` - API integrations
|
||||
- `types/` - TypeScript type definitions
|
||||
- `src/pages/` - Route pages
|
||||
- `src/modules/` - Modular feature implementations
|
||||
- `src/core/` - Core infrastructure (DI, BaseService, plugin manager)
|
||||
- `electron/` - Electron main process code
|
||||
|
||||
**Lightning Wallet Integration:**
|
||||
|
|
@ -151,10 +143,8 @@ The app integrates with LNbits for Lightning Network wallet functionality with r
|
|||
```typescript
|
||||
websocket: {
|
||||
enabled: true, // Enable/disable WebSocket functionality
|
||||
reconnectDelay: 2000, // Initial reconnection delay (ms)
|
||||
maxReconnectAttempts: 3, // Maximum reconnection attempts
|
||||
fallbackToPolling: true, // Enable polling fallback when WebSocket fails
|
||||
pollingInterval: 10000 // Polling interval (ms)
|
||||
reconnectDelay: 1000, // Initial reconnection delay
|
||||
maxReconnectAttempts: 5 // Maximum reconnection attempts
|
||||
}
|
||||
```
|
||||
|
||||
|
|
@ -239,88 +229,6 @@ export const myModule: ModulePlugin = {
|
|||
- Module configs in `src/app.config.ts`
|
||||
- Centralized config parsing and validation
|
||||
|
||||
### **BaseService Pattern**
|
||||
|
||||
All services MUST extend `BaseService` (`src/core/base/BaseService.ts`) for standardized initialization and dependency management:
|
||||
|
||||
**Service Implementation Pattern:**
|
||||
|
||||
```typescript
|
||||
import { BaseService } from '@/core/base/BaseService'
|
||||
|
||||
export class MyService extends BaseService {
|
||||
// 1. REQUIRED: Declare metadata with dependencies
|
||||
protected readonly metadata = {
|
||||
name: 'MyService',
|
||||
version: '1.0.0',
|
||||
dependencies: ['AuthService', 'RelayHub', 'VisibilityService']
|
||||
}
|
||||
|
||||
// 2. REQUIRED: Implement onInitialize
|
||||
protected async onInitialize(): Promise<void> {
|
||||
// Dependencies are auto-injected based on metadata.dependencies
|
||||
// Available: this.authService, this.relayHub, this.visibilityService, etc.
|
||||
|
||||
// Register with VisibilityService if using WebSockets
|
||||
if (this.visibilityService) {
|
||||
this.visibilityService.registerService(
|
||||
this.metadata.name,
|
||||
this.onResume.bind(this),
|
||||
this.onPause.bind(this)
|
||||
)
|
||||
}
|
||||
|
||||
// Your initialization logic
|
||||
await this.setupConnections()
|
||||
}
|
||||
|
||||
// 3. Implement visibility handlers for WebSocket services
|
||||
private async onResume(): Promise<void> {
|
||||
// Reconnect and restore state when app becomes visible
|
||||
}
|
||||
|
||||
private async onPause(): Promise<void> {
|
||||
// Pause operations when app loses visibility
|
||||
}
|
||||
|
||||
// 4. Optional: Cleanup logic
|
||||
protected async onDispose(): Promise<void> {
|
||||
// Cleanup connections, subscriptions, etc.
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**BaseService Features:**
|
||||
- **Automatic dependency injection** based on `metadata.dependencies`
|
||||
- **Retry logic** with configurable retries and delays
|
||||
- **Reactive state** via `isInitialized`, `isInitializing`, `initError`
|
||||
- **Event emission** for service lifecycle events
|
||||
- **Error handling** with consistent logging
|
||||
- **Debug helpers** for development
|
||||
|
||||
**Service Initialization:**
|
||||
```typescript
|
||||
// In module's index.ts
|
||||
const myService = new MyService()
|
||||
container.provide(SERVICE_TOKENS.MY_SERVICE, myService)
|
||||
|
||||
// Initialize with options
|
||||
await myService.initialize({
|
||||
waitForDependencies: true, // Wait for dependencies before initializing
|
||||
maxRetries: 3, // Retry on failure
|
||||
retryDelay: 1000 // Delay between retries (ms)
|
||||
})
|
||||
```
|
||||
|
||||
**Available Dependencies:**
|
||||
When you list these in `metadata.dependencies`, they'll be auto-injected:
|
||||
- `'RelayHub'` → `this.relayHub`
|
||||
- `'AuthService'` → `this.authService`
|
||||
- `'VisibilityService'` → `this.visibilityService`
|
||||
- `'StorageService'` → `this.storageService`
|
||||
- `'ToastService'` → `this.toastService`
|
||||
- `'LnbitsAPI'` → `this.lnbitsAPI`
|
||||
|
||||
### **Form Implementation Standards**
|
||||
|
||||
**CRITICAL: Always use Shadcn/UI Form Components with vee-validate**
|
||||
|
|
@ -474,44 +382,35 @@ For Shadcn/ui Checkbox components, you MUST use the correct Vue.js binding patte
|
|||
- ✅ **Force Re-render**: Use dynamic `:key` if checkbox doesn't reflect initial form values
|
||||
- ❌ **Don't Mix**: Never mix checked/model-value patterns - they have different behaviors
|
||||
|
||||
### **CSS and Styling Guidelines**
|
||||
**Reference**: [Vue.js Forms Documentation](https://vuejs.org/guide/essentials/forms.html)
|
||||
|
||||
**CRITICAL: Always use semantic, theme-aware CSS classes**
|
||||
**❌ NEVER do this:**
|
||||
```vue
|
||||
<!-- Wrong: Manual form handling without vee-validate -->
|
||||
<form @submit.prevent="handleSubmit">
|
||||
|
||||
- ✅ **Use semantic classes** that automatically adapt to light/dark themes
|
||||
- ❌ **Never use hard-coded colors** like `bg-white`, `text-gray-500`, `border-blue-500`
|
||||
<!-- Wrong: Direct v-model bypasses form validation -->
|
||||
<Input v-model="myValue" />
|
||||
|
||||
**Preferred Semantic Classes:**
|
||||
```css
|
||||
/* Background Colors */
|
||||
bg-background /* Instead of bg-white */
|
||||
bg-card /* Instead of bg-gray-50 */
|
||||
bg-muted /* Instead of bg-gray-100 */
|
||||
|
||||
/* Text Colors */
|
||||
text-foreground /* Instead of text-gray-900 */
|
||||
text-muted-foreground /* Instead of text-gray-600 */
|
||||
text-primary /* For primary theme color */
|
||||
text-accent /* For accent theme color */
|
||||
|
||||
/* Borders */
|
||||
border-border /* Instead of border-gray-200 */
|
||||
border-input /* Instead of border-gray-300 */
|
||||
|
||||
/* Focus States */
|
||||
focus:ring-ring /* Instead of focus:ring-blue-500 */
|
||||
focus:border-ring /* Instead of focus:border-blue-500 */
|
||||
|
||||
/* Opacity Modifiers */
|
||||
bg-primary/10 /* For subtle variations */
|
||||
text-muted-foreground/70 /* For transparency */
|
||||
<!-- Wrong: Manual validation instead of using meta.valid -->
|
||||
<Button :disabled="!name || !email">Submit</Button>
|
||||
```
|
||||
|
||||
**Why Semantic Classes:**
|
||||
- Ensures components work in both light and dark themes
|
||||
- Maintains consistency with Shadcn/ui component library
|
||||
- Easier to maintain and update theme colors globally
|
||||
- Better accessibility
|
||||
**✅ ALWAYS do this:**
|
||||
```vue
|
||||
<!-- Correct: Uses form.handleSubmit for proper form handling -->
|
||||
<form @submit="onSubmit">
|
||||
|
||||
<!-- Correct: Uses FormField with componentField binding -->
|
||||
<FormField v-slot="{ componentField }" name="fieldName">
|
||||
<FormControl>
|
||||
<Input v-bind="componentField" />
|
||||
</FormControl>
|
||||
</FormField>
|
||||
|
||||
<!-- Correct: Uses form meta for validation state -->
|
||||
<Button :disabled="!isFormValid">Submit</Button>
|
||||
```
|
||||
|
||||
### **Vue Reactivity Best Practices**
|
||||
|
||||
|
|
@ -564,32 +463,23 @@ createdObject.value = Object.assign({}, apiResponse)
|
|||
- ✅ Input components showing external data
|
||||
- ✅ Any scenario where template doesn't update after data changes
|
||||
|
||||
### **⚠️ CRITICAL: JavaScript Falsy Value Bug Prevention**
|
||||
|
||||
**ALWAYS use nullish coalescing (`??`) instead of logical OR (`||`) for numeric defaults:**
|
||||
|
||||
**Example from Wallet Module:**
|
||||
```typescript
|
||||
// ❌ WRONG: Treats 0 as falsy, defaults to 1 even when quantity is validly 0
|
||||
quantity: productData.quantity || 1
|
||||
// Service returns complex invoice object
|
||||
const invoice = await walletService.createInvoice(data)
|
||||
|
||||
// ✅ CORRECT: Only defaults to 1 when quantity is null or undefined
|
||||
quantity: productData.quantity ?? 1
|
||||
// Force reactivity for template updates
|
||||
createdInvoice.value = Object.assign({}, invoice)
|
||||
```
|
||||
|
||||
**Why this matters:**
|
||||
- JavaScript falsy values include: `false`, `0`, `""`, `null`, `undefined`, `NaN`
|
||||
- Using `||` for defaults will incorrectly override valid `0` values
|
||||
- This caused a critical bug where products with quantity `0` displayed as quantity `1`
|
||||
- The `??` operator only triggers for `null` and `undefined`, preserving valid `0` values
|
||||
|
||||
**Common scenarios where this bug occurs:**
|
||||
- Product quantities, prices, counters (any numeric value where 0 is valid)
|
||||
- Boolean flags where `false` is a valid state
|
||||
- Empty strings that should be preserved vs. undefined strings
|
||||
|
||||
**Rule of thumb:**
|
||||
- Use `||` only when `0`, `false`, or `""` should trigger the default
|
||||
- Use `??` when only `null`/`undefined` should trigger the default (most cases)
|
||||
```vue
|
||||
<!-- Template with forced reactivity -->
|
||||
<Input
|
||||
:key="`bolt11-${createdInvoice?.payment_hash}`"
|
||||
:model-value="createdInvoice?.payment_request || ''"
|
||||
readonly
|
||||
/>
|
||||
```
|
||||
|
||||
### **Module Development Best Practices**
|
||||
|
||||
|
|
@ -640,6 +530,166 @@ Before considering any module complete, verify ALL items:
|
|||
- [ ] Configuration is properly loaded
|
||||
- [ ] Module can be disabled via config
|
||||
|
||||
**Required Module Structure:**
|
||||
```
|
||||
src/modules/[module-name]/
|
||||
├── index.ts # Module plugin definition (REQUIRED)
|
||||
├── components/ # Module-specific components
|
||||
├── composables/ # Module composables (use DI for services)
|
||||
├── services/ # Module services (extend BaseService)
|
||||
│ ├── [module]Service.ts # Core module service
|
||||
│ └── [module]API.ts # LNbits API integration
|
||||
├── stores/ # Module-specific Pinia stores
|
||||
├── types/ # Module type definitions
|
||||
└── views/ # Module pages/views
|
||||
```
|
||||
|
||||
**Service Implementation Pattern:**
|
||||
|
||||
**⚠️ CRITICAL SERVICE REQUIREMENTS - MUST FOLLOW EXACTLY:**
|
||||
|
||||
```typescript
|
||||
// ✅ CORRECT: Proper BaseService implementation
|
||||
export class MyModuleService extends BaseService {
|
||||
// 1. REQUIRED: Declare metadata with dependencies
|
||||
protected readonly metadata = {
|
||||
name: 'MyModuleService',
|
||||
version: '1.0.0',
|
||||
dependencies: ['PaymentService', 'AuthService'] // List ALL service dependencies by name
|
||||
}
|
||||
|
||||
// 2. REQUIRED: DO NOT manually inject services in onInitialize
|
||||
protected async onInitialize(): Promise<void> {
|
||||
// ❌ WRONG: Manual injection
|
||||
// this.paymentService = injectService(SERVICE_TOKENS.PAYMENT_SERVICE)
|
||||
|
||||
// ✅ CORRECT: BaseService auto-injects based on metadata.dependencies
|
||||
// this.paymentService is already available here!
|
||||
|
||||
// 3. REQUIRED: Register with VisibilityService if you have ANY real-time features
|
||||
if (this.hasRealTimeFeatures()) {
|
||||
this.visibilityService.registerService(
|
||||
this.metadata.name,
|
||||
this.onResume.bind(this),
|
||||
this.onPause.bind(this)
|
||||
)
|
||||
}
|
||||
|
||||
// 4. Initialize your module-specific logic
|
||||
await this.loadInitialData()
|
||||
}
|
||||
|
||||
// 5. REQUIRED: Implement visibility handlers for connection management
|
||||
private async onResume(): Promise<void> {
|
||||
// Restore connections, refresh data when app becomes visible
|
||||
await this.checkConnectionHealth()
|
||||
await this.refreshData()
|
||||
}
|
||||
|
||||
private async onPause(): Promise<void> {
|
||||
// Pause expensive operations for battery efficiency
|
||||
this.pausePolling()
|
||||
}
|
||||
|
||||
private hasRealTimeFeatures(): boolean {
|
||||
// Return true if your service uses WebSockets, polling, or real-time updates
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
// API services for LNbits integration
|
||||
export class MyModuleAPI extends BaseService {
|
||||
private baseUrl: string
|
||||
|
||||
constructor() {
|
||||
super()
|
||||
// ❌ WRONG: Direct config import
|
||||
// import { config } from '@/lib/config'
|
||||
|
||||
// ✅ CORRECT: Use module configuration
|
||||
const moduleConfig = appConfig.modules.myModule.config
|
||||
this.baseUrl = moduleConfig.apiConfig.baseUrl
|
||||
}
|
||||
|
||||
// API methods here
|
||||
}
|
||||
```
|
||||
|
||||
**❌ COMMON MISTAKES TO AVOID:**
|
||||
1. **Manual service injection** in onInitialize - BaseService handles this
|
||||
2. **Direct config imports** - Always use module configuration
|
||||
3. **Missing metadata.dependencies** - Breaks automatic dependency injection
|
||||
4. **No VisibilityService integration** - Causes connection issues on mobile
|
||||
5. **Not using proper initialization options** - Miss dependency waiting
|
||||
|
||||
**Module Plugin Pattern:**
|
||||
|
||||
**⚠️ CRITICAL MODULE INSTALLATION REQUIREMENTS:**
|
||||
|
||||
```typescript
|
||||
export const myModule: ModulePlugin = {
|
||||
name: 'my-module',
|
||||
version: '1.0.0',
|
||||
dependencies: ['base'], // ALWAYS depend on 'base' for core infrastructure
|
||||
|
||||
async install(app: App, options?: { config?: MyModuleConfig }) {
|
||||
// 1. REQUIRED: Create service instances
|
||||
const myService = new MyModuleService()
|
||||
const myAPI = new MyModuleAPI()
|
||||
|
||||
// 2. REQUIRED: Register in DI container BEFORE initialization
|
||||
container.provide(SERVICE_TOKENS.MY_SERVICE, myService)
|
||||
container.provide(SERVICE_TOKENS.MY_API, myAPI)
|
||||
|
||||
// 3. CRITICAL: Initialize services with proper options
|
||||
await myService.initialize({
|
||||
waitForDependencies: true, // REQUIRED: Wait for dependencies
|
||||
maxRetries: 3, // RECOMMENDED: Retry on failure
|
||||
timeout: 5000 // OPTIONAL: Timeout for initialization
|
||||
})
|
||||
|
||||
// Initialize API service if it needs initialization
|
||||
if (myAPI.initialize) {
|
||||
await myAPI.initialize({
|
||||
waitForDependencies: true,
|
||||
maxRetries: 3
|
||||
})
|
||||
}
|
||||
|
||||
// 4. Register components AFTER services are initialized
|
||||
app.component('MyComponent', MyComponent)
|
||||
|
||||
// 5. OPTIONAL: Export for testing/debugging
|
||||
return {
|
||||
service: myService,
|
||||
api: myAPI
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**MODULE CONFIGURATION IN app.config.ts:**
|
||||
```typescript
|
||||
// REQUIRED: Add module configuration
|
||||
export default {
|
||||
modules: {
|
||||
'my-module': {
|
||||
enabled: true,
|
||||
config: {
|
||||
apiConfig: {
|
||||
baseUrl: import.meta.env.VITE_API_BASE_URL || 'http://localhost:5000'
|
||||
},
|
||||
// Module-specific configuration
|
||||
features: {
|
||||
realTimeUpdates: true,
|
||||
offlineSupport: false
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Nostr Integration Rules:**
|
||||
1. **NEVER create separate relay connections** - always use the central RelayHub
|
||||
2. **Access RelayHub through DI**: `const relayHub = injectService(SERVICE_TOKENS.RELAY_HUB)`
|
||||
|
|
@ -685,34 +735,44 @@ export function useMyModule() {
|
|||
- **ALWAYS use Shadcn Form components for all form implementations**
|
||||
- **ALWAYS extend BaseService for module services**
|
||||
- **NEVER create direct dependencies between modules**
|
||||
- **ALWAYS use semantic CSS classes, never hard-coded colors**
|
||||
|
||||
### **Build Configuration:**
|
||||
### **⚠️ CRITICAL: JavaScript Falsy Value Bug Prevention**
|
||||
|
||||
**ALWAYS use nullish coalescing (`??`) instead of logical OR (`||`) for numeric defaults:**
|
||||
|
||||
```typescript
|
||||
// ❌ WRONG: Treats 0 as falsy, defaults to 1 even when quantity is validly 0
|
||||
quantity: productData.quantity || 1
|
||||
|
||||
// ✅ CORRECT: Only defaults to 1 when quantity is null or undefined
|
||||
quantity: productData.quantity ?? 1
|
||||
```
|
||||
|
||||
**Why this matters:**
|
||||
- JavaScript falsy values include: `false`, `0`, `""`, `null`, `undefined`, `NaN`
|
||||
- Using `||` for defaults will incorrectly override valid `0` values
|
||||
- This caused a critical bug where products with quantity `0` displayed as quantity `1`
|
||||
- The `??` operator only triggers for `null` and `undefined`, preserving valid `0` values
|
||||
|
||||
**Common scenarios where this bug occurs:**
|
||||
- Product quantities, prices, counters (any numeric value where 0 is valid)
|
||||
- Boolean flags where `false` is a valid state
|
||||
- Empty strings that should be preserved vs. undefined strings
|
||||
|
||||
**Rule of thumb:**
|
||||
- Use `||` only when `0`, `false`, or `""` should trigger the default
|
||||
- Use `??` when only `null`/`undefined` should trigger the default (most cases)
|
||||
|
||||
**Build Configuration:**
|
||||
- Vite config includes PWA, image optimization, and bundle analysis
|
||||
- Manual chunking strategy for vendor libraries (vue-vendor, ui-vendor, shadcn)
|
||||
- Electron Forge configured for cross-platform packaging
|
||||
- TailwindCSS v4 integration via Vite plugin
|
||||
|
||||
### **Environment Variables:**
|
||||
|
||||
Required environment variables in `.env`:
|
||||
|
||||
```bash
|
||||
# LNbits server URL for Lightning wallet functionality
|
||||
VITE_LNBITS_BASE_URL=http://localhost:5000
|
||||
|
||||
# Nostr relay configuration (JSON array)
|
||||
VITE_NOSTR_RELAYS='["wss://relay1.example.com","wss://relay2.example.com"]'
|
||||
|
||||
# Image upload server (pictrs)
|
||||
VITE_PICTRS_BASE_URL=https://img.mydomain.com
|
||||
|
||||
# Admin public keys for feed moderation (JSON array)
|
||||
VITE_ADMIN_PUBKEYS='["pubkey1","pubkey2"]'
|
||||
|
||||
# Optional: Disable WebSocket if needed
|
||||
VITE_WEBSOCKET_ENABLED=true
|
||||
```
|
||||
**Environment:**
|
||||
- Nostr relay configuration via `VITE_NOSTR_RELAYS` environment variable
|
||||
- PWA manifest configured for standalone app experience
|
||||
- Service worker with automatic updates every hour
|
||||
|
||||
## Mobile Browser File Input & Form Refresh Issues
|
||||
|
||||
|
|
@ -846,6 +906,86 @@ window.addEventListener('beforeunload', blockNavigation)
|
|||
<form @submit.prevent="handleSubmit">
|
||||
```
|
||||
|
||||
### **Android 14/15 Camera Workarounds**
|
||||
|
||||
**Non-Standard MIME Type Workaround:**
|
||||
```html
|
||||
<!-- Add non-standard MIME type to force camera access -->
|
||||
<input type="file" accept="image/*,android/allowCamera" capture="environment" />
|
||||
```
|
||||
|
||||
**Plain File Input Fallback:**
|
||||
```html
|
||||
<!-- Fallback: Plain file input shows both camera and gallery options -->
|
||||
<input type="file" accept="image/*" />
|
||||
```
|
||||
|
||||
### **Industry-Standard Patterns**
|
||||
|
||||
**1. Page Visibility API (Primary Solution):**
|
||||
```javascript
|
||||
// Modern browsers: Use Page Visibility API instead of beforeunload
|
||||
document.addEventListener('visibilitychange', function() {
|
||||
if (document.visibilityState === 'visible') {
|
||||
// Resume critical operations, restore connections
|
||||
resumeOperations()
|
||||
} else {
|
||||
// Save state, pause operations for battery conservation
|
||||
saveStateAndPause()
|
||||
}
|
||||
})
|
||||
```
|
||||
|
||||
**2. Conditional BeforeUnload Protection:**
|
||||
```javascript
|
||||
// Only add beforeunload listeners when user has unsaved changes
|
||||
const addFormProtection = (hasUnsavedChanges) => {
|
||||
if (hasUnsavedChanges) {
|
||||
window.addEventListener('beforeunload', preventUnload)
|
||||
} else {
|
||||
window.removeEventListener('beforeunload', preventUnload)
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**3. Session Recovery Pattern:**
|
||||
```javascript
|
||||
// Save form state on visibility change
|
||||
document.addEventListener('visibilitychange', () => {
|
||||
if (document.visibilityState === 'hidden') {
|
||||
localStorage.setItem('formDraft', JSON.stringify(formData))
|
||||
}
|
||||
})
|
||||
|
||||
// Restore on page load
|
||||
window.addEventListener('load', () => {
|
||||
const draft = localStorage.getItem('formDraft')
|
||||
if (draft) restoreFormData(JSON.parse(draft))
|
||||
})
|
||||
```
|
||||
|
||||
### **Testing & Debugging**
|
||||
|
||||
**Reproduction Steps:**
|
||||
1. Open form with file upload on mobile device
|
||||
2. Select camera input during image upload operations
|
||||
3. Turn screen off/on during upload process
|
||||
4. Switch between apps during file selection
|
||||
5. Low memory conditions during camera usage
|
||||
|
||||
**Success Indicators:**
|
||||
- User sees confirmation dialog instead of losing form data
|
||||
- Console warnings show visibility change detection working
|
||||
- Form state preservation during app switching
|
||||
- Camera input properly separates from gallery input
|
||||
|
||||
**Debug Console Messages:**
|
||||
```javascript
|
||||
// Look for these defensive programming console messages
|
||||
console.warn('Form submission blocked during file upload')
|
||||
console.warn('Visibility change detected while form is open')
|
||||
```
|
||||
|
||||
### **Key Takeaways**
|
||||
|
||||
1. **This is a systemic mobile browser issue**, not a bug in our application code
|
||||
|
|
|
|||
57
package-lock.json
generated
57
package-lock.json
generated
|
|
@ -25,7 +25,7 @@
|
|||
"qr-scanner": "^1.4.2",
|
||||
"qrcode": "^1.5.4",
|
||||
"radix-vue": "^1.9.13",
|
||||
"reka-ui": "^2.6.0",
|
||||
"reka-ui": "^2.5.0",
|
||||
"tailwind-merge": "^2.6.0",
|
||||
"tailwindcss-animate": "^1.0.7",
|
||||
"unique-names-generator": "^4.7.1",
|
||||
|
|
@ -141,7 +141,6 @@
|
|||
"integrity": "sha512-l+lkXCHS6tQEc5oUpK28xBOZ6+HwaH7YwoYQbLFiYb4nS2/l1tKnZEtEWkD0GuiYdvArf9qBS0XlQGXzPMsNqQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@ampproject/remapping": "^2.2.0",
|
||||
"@babel/code-frame": "^7.26.2",
|
||||
|
|
@ -2647,7 +2646,6 @@
|
|||
"integrity": "sha512-zx0EIq78WlY/lBb1uXlziZmDZI4ubcCXIMJ4uGjXzZW0nS19TjSPeXPAjzzTmKQlJUZm0SbmZhPKP7tuQ1SsEw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"chalk": "^4.1.1",
|
||||
"fs-extra": "^9.0.1",
|
||||
|
|
@ -5690,7 +5688,6 @@
|
|||
"integrity": "sha512-B/gBuNg5SiMTrPkC+A2+cW0RszwxYmn6VYxB/inlBStS5nx6xHIt/ehKRhIMhqusl7a8LjQoZnjCs5vhwxOQ1g==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"fast-deep-equal": "^3.1.3",
|
||||
"fast-uri": "^3.0.1",
|
||||
|
|
@ -5853,6 +5850,14 @@
|
|||
"node": ">= 0.4"
|
||||
}
|
||||
},
|
||||
"node_modules/async-validator": {
|
||||
"version": "4.2.5",
|
||||
"resolved": "https://registry.npmjs.org/async-validator/-/async-validator-4.2.5.tgz",
|
||||
"integrity": "sha512-7HhHjtERjqlNbZtqNqy2rckN/SpOOlmDliet+lP7k+eKZEjPk3DgyeU9lIXLdeLz0uBbbVp+9Qdow9wJWgwwfg==",
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"peer": true
|
||||
},
|
||||
"node_modules/at-least-node": {
|
||||
"version": "1.0.0",
|
||||
"resolved": "https://registry.npmjs.org/at-least-node/-/at-least-node-1.0.0.tgz",
|
||||
|
|
@ -6053,7 +6058,6 @@
|
|||
}
|
||||
],
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"caniuse-lite": "^1.0.30001688",
|
||||
"electron-to-chromium": "^1.5.73",
|
||||
|
|
@ -7600,6 +7604,17 @@
|
|||
"integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/encoding": {
|
||||
"version": "0.1.13",
|
||||
"resolved": "https://registry.npmjs.org/encoding/-/encoding-0.1.13.tgz",
|
||||
"integrity": "sha512-ETBauow1T35Y/WZMkio9jiM0Z5xjHHmJ4XmjZOq1l/dXz3lr2sRn87nJy20RupqSh1F2m3HHPSp8ShIPQJrJ3A==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"dependencies": {
|
||||
"iconv-lite": "^0.6.2"
|
||||
}
|
||||
},
|
||||
"node_modules/end-of-stream": {
|
||||
"version": "1.4.4",
|
||||
"resolved": "https://registry.npmjs.org/end-of-stream/-/end-of-stream-1.4.4.tgz",
|
||||
|
|
@ -8353,7 +8368,6 @@
|
|||
"resolved": "https://registry.npmjs.org/fuse.js/-/fuse.js-7.0.0.tgz",
|
||||
"integrity": "sha512-14F4hBIxqKvD4Zz/XjDc3y94mNZN6pRv3U13Udo0lNLCWRBUsrMv2xwcF/y/Z5sV6+FQW+/ow68cHpm4sunt8Q==",
|
||||
"license": "Apache-2.0",
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">=10"
|
||||
}
|
||||
|
|
@ -8890,6 +8904,20 @@
|
|||
"ms": "^2.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/iconv-lite": {
|
||||
"version": "0.6.3",
|
||||
"resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.6.3.tgz",
|
||||
"integrity": "sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"dependencies": {
|
||||
"safer-buffer": ">= 2.1.2 < 3.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=0.10.0"
|
||||
}
|
||||
},
|
||||
"node_modules/idb": {
|
||||
"version": "7.1.1",
|
||||
"resolved": "https://registry.npmjs.org/idb/-/idb-7.1.1.tgz",
|
||||
|
|
@ -11690,7 +11718,6 @@
|
|||
"resolved": "https://registry.npmjs.org/qrcode/-/qrcode-1.5.4.tgz",
|
||||
"integrity": "sha512-1ca71Zgiu6ORjHqFBDpnSMTR2ReToX4l1Au1VFLyVeBTFavzQnv5JxMFr3ukHVKpSrSA2MCk0lNJSykjUfz7Zg==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"dijkstrajs": "^1.0.1",
|
||||
"pngjs": "^5.0.0",
|
||||
|
|
@ -12153,9 +12180,9 @@
|
|||
}
|
||||
},
|
||||
"node_modules/reka-ui": {
|
||||
"version": "2.6.0",
|
||||
"resolved": "https://registry.npmjs.org/reka-ui/-/reka-ui-2.6.0.tgz",
|
||||
"integrity": "sha512-NrGMKrABD97l890mFS3TNUzB0BLUfbL3hh0NjcJRIUSUljb288bx3Mzo31nOyUcdiiW0HqFGXJwyCBh9cWgb0w==",
|
||||
"version": "2.5.0",
|
||||
"resolved": "https://registry.npmjs.org/reka-ui/-/reka-ui-2.5.0.tgz",
|
||||
"integrity": "sha512-81aMAmJeVCy2k0E6x7n1kypDY6aM1ldLis5+zcdV1/JtoAlSDck5OBsyLRJU9CfgbrQp1ImnRnBSmC4fZ2fkZQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@floating-ui/dom": "^1.6.13",
|
||||
|
|
@ -12334,7 +12361,6 @@
|
|||
"integrity": "sha512-4iya7Jb76fVpQyLoiVpzUrsjQ12r3dM7fIVz+4NwoYvZOShknRmiv+iu9CClZml5ZLGb0XMcYLutK6w9tgxHDw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@types/estree": "1.0.8"
|
||||
},
|
||||
|
|
@ -13344,8 +13370,7 @@
|
|||
"version": "4.0.12",
|
||||
"resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-4.0.12.tgz",
|
||||
"integrity": "sha512-bT0hJo91FtncsAMSsMzUkoo/iEU0Xs5xgFgVC9XmdM9bw5MhZuQFjPNl6wxAE0SiQF/YTZJa+PndGWYSDtuxAg==",
|
||||
"license": "MIT",
|
||||
"peer": true
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/tailwindcss-animate": {
|
||||
"version": "1.0.7",
|
||||
|
|
@ -13480,7 +13505,6 @@
|
|||
"integrity": "sha512-GWANVlPM/ZfYzuPHjq0nxT+EbOEDDN3Jwhwdg1D8TU8oSkktp8w64Uq4auuGLxFSoNTRDncTq2hQHX1Ld9KHkA==",
|
||||
"dev": true,
|
||||
"license": "BSD-2-Clause",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@jridgewell/source-map": "^0.3.3",
|
||||
"acorn": "^8.8.2",
|
||||
|
|
@ -13710,7 +13734,6 @@
|
|||
"integrity": "sha512-hjcS1mhfuyi4WW8IWtjP7brDrG2cuDZukyrYrSauoXGNgx0S7zceP07adYkJycEr56BOUTNPzbInooiN3fn1qw==",
|
||||
"devOptional": true,
|
||||
"license": "Apache-2.0",
|
||||
"peer": true,
|
||||
"bin": {
|
||||
"tsc": "bin/tsc",
|
||||
"tsserver": "bin/tsserver"
|
||||
|
|
@ -13961,7 +13984,6 @@
|
|||
"integrity": "sha512-cZn6NDFE7wdTpINgs++ZJ4N49W2vRp8LCKrn3Ob1kYNtOo21vfDoaV5GzBfLU4MovSAB8uNRm4jgzVQZ+mBzPQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"esbuild": "^0.25.0",
|
||||
"fdir": "^6.4.4",
|
||||
|
|
@ -14186,7 +14208,6 @@
|
|||
"resolved": "https://registry.npmjs.org/vue/-/vue-3.5.13.tgz",
|
||||
"integrity": "sha512-wmeiSMxkZCSc+PM2w2VRsOYAZC8GdipNFRTsLSfodVqI9mbejKeXEGr8SckuLnrQPGe3oJN5c3K0vpoU9q/wCQ==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@vue/compiler-dom": "3.5.13",
|
||||
"@vue/compiler-sfc": "3.5.13",
|
||||
|
|
@ -14639,7 +14660,6 @@
|
|||
"integrity": "sha512-fS6iqSPZDs3dr/y7Od6y5nha8dW1YnbgtsyotCVvoFGKbERG++CVRFv1meyGDE1SNItQA8BrnCw7ScdAhRJ3XQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"bin": {
|
||||
"rollup": "dist/bin/rollup"
|
||||
},
|
||||
|
|
@ -14897,7 +14917,6 @@
|
|||
"resolved": "https://registry.npmjs.org/zod/-/zod-3.25.76.tgz",
|
||||
"integrity": "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/colinhacks"
|
||||
}
|
||||
|
|
|
|||
|
|
@ -34,7 +34,7 @@
|
|||
"qr-scanner": "^1.4.2",
|
||||
"qrcode": "^1.5.4",
|
||||
"radix-vue": "^1.9.13",
|
||||
"reka-ui": "^2.6.0",
|
||||
"reka-ui": "^2.5.0",
|
||||
"tailwind-merge": "^2.6.0",
|
||||
"tailwindcss-animate": "^1.0.7",
|
||||
"unique-names-generator": "^4.7.1",
|
||||
|
|
|
|||
|
|
@ -93,20 +93,6 @@ export const appConfig: AppConfig = {
|
|||
pollingInterval: 10000 // 10 seconds for polling updates
|
||||
}
|
||||
}
|
||||
},
|
||||
expenses: {
|
||||
name: 'expenses',
|
||||
enabled: true,
|
||||
lazy: false,
|
||||
config: {
|
||||
apiConfig: {
|
||||
baseUrl: import.meta.env.VITE_LNBITS_BASE_URL || 'http://localhost:5000',
|
||||
timeout: 30000 // 30 seconds for API requests
|
||||
},
|
||||
defaultCurrency: 'sats',
|
||||
maxExpenseAmount: 1000000, // 1M sats
|
||||
requireDescription: true
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
|
|
|
|||
11
src/app.ts
11
src/app.ts
|
|
@ -16,7 +16,6 @@ import chatModule from './modules/chat'
|
|||
import eventsModule from './modules/events'
|
||||
import marketModule from './modules/market'
|
||||
import walletModule from './modules/wallet'
|
||||
import expensesModule from './modules/expenses'
|
||||
|
||||
// Root component
|
||||
import App from './App.vue'
|
||||
|
|
@ -44,8 +43,7 @@ export async function createAppInstance() {
|
|||
...chatModule.routes || [],
|
||||
...eventsModule.routes || [],
|
||||
...marketModule.routes || [],
|
||||
...walletModule.routes || [],
|
||||
...expensesModule.routes || []
|
||||
...walletModule.routes || []
|
||||
].filter(Boolean)
|
||||
|
||||
// Create router with all routes available immediately
|
||||
|
|
@ -128,13 +126,6 @@ export async function createAppInstance() {
|
|||
)
|
||||
}
|
||||
|
||||
// Register expenses module
|
||||
if (appConfig.modules.expenses?.enabled) {
|
||||
moduleRegistrations.push(
|
||||
pluginManager.register(expensesModule, appConfig.modules.expenses)
|
||||
)
|
||||
}
|
||||
|
||||
// Wait for all modules to register
|
||||
await Promise.all(moduleRegistrations)
|
||||
|
||||
|
|
|
|||
|
|
@ -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">
|
||||
import type { PrimitiveProps } from "reka-ui"
|
||||
import type { HTMLAttributes } from "vue"
|
||||
import type { ButtonVariants } from "."
|
||||
import { Primitive } from "reka-ui"
|
||||
import { cn } from "@/lib/utils"
|
||||
import { buttonVariants } from "."
|
||||
import type { HTMLAttributes } from 'vue'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { Primitive, type PrimitiveProps } from 'reka-ui'
|
||||
import { type ButtonVariants, buttonVariants } from '.'
|
||||
|
||||
interface Props extends PrimitiveProps {
|
||||
variant?: ButtonVariants["variant"]
|
||||
size?: ButtonVariants["size"]
|
||||
class?: HTMLAttributes["class"]
|
||||
variant?: ButtonVariants['variant']
|
||||
size?: ButtonVariants['size']
|
||||
class?: HTMLAttributes['class']
|
||||
}
|
||||
|
||||
const props = withDefaults(defineProps<Props>(), {
|
||||
as: "button",
|
||||
as: 'button',
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Primitive
|
||||
data-slot="button"
|
||||
:as="as"
|
||||
:as-child="asChild"
|
||||
:class="cn(buttonVariants({ variant, size }), props.class)"
|
||||
|
|
|
|||
|
|
@ -1,37 +1,33 @@
|
|||
import type { VariantProps } from "class-variance-authority"
|
||||
import { cva } from "class-variance-authority"
|
||||
import { cva, type VariantProps } from 'class-variance-authority'
|
||||
|
||||
export { default as Button } from "./Button.vue"
|
||||
export { default as Button } from './Button.vue'
|
||||
|
||||
export const buttonVariants = cva(
|
||||
"inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium transition-all disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg:not([class*='size-'])]:size-4 shrink-0 [&_svg]:shrink-0 outline-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive",
|
||||
'inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium transition-colors focus-visible:outline-hidden focus-visible:ring-1 focus-visible:ring-ring disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0',
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
default:
|
||||
"bg-primary text-primary-foreground shadow-xs hover:bg-primary/90",
|
||||
default: 'bg-primary text-primary-foreground shadow-sm hover:bg-primary/90',
|
||||
destructive:
|
||||
"bg-destructive text-white shadow-xs hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40 dark:bg-destructive/60",
|
||||
'bg-destructive text-destructive-foreground shadow-xs hover:bg-destructive/90',
|
||||
outline:
|
||||
"border bg-background shadow-xs hover:bg-accent hover:text-accent-foreground dark:bg-input/30 dark:border-input dark:hover:bg-input/50",
|
||||
'border border-input bg-background shadow-xs hover:bg-accent hover:text-accent-foreground',
|
||||
secondary:
|
||||
"bg-secondary text-secondary-foreground shadow-xs hover:bg-secondary/80",
|
||||
ghost:
|
||||
"hover:bg-accent hover:text-accent-foreground dark:hover:bg-accent/50",
|
||||
link: "text-primary underline-offset-4 hover:underline",
|
||||
'bg-secondary text-secondary-foreground shadow-xs hover:bg-secondary/80',
|
||||
ghost: 'hover:bg-accent hover:text-accent-foreground',
|
||||
link: 'text-primary underline-offset-4 hover:underline',
|
||||
},
|
||||
size: {
|
||||
"default": "h-9 px-4 py-2 has-[>svg]:px-3",
|
||||
"sm": "h-8 rounded-md gap-1.5 px-3 has-[>svg]:px-2.5",
|
||||
"lg": "h-10 rounded-md px-6 has-[>svg]:px-4",
|
||||
"icon": "size-9",
|
||||
"icon-sm": "size-8",
|
||||
"icon-lg": "size-10",
|
||||
default: 'h-9 px-4 py-2',
|
||||
xs: 'h-7 rounded px-2',
|
||||
sm: 'h-8 rounded-md px-3 text-xs',
|
||||
lg: 'h-10 rounded-md px-8',
|
||||
icon: 'h-9 w-9',
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
variant: "default",
|
||||
size: "default",
|
||||
variant: 'default',
|
||||
size: 'default',
|
||||
},
|
||||
},
|
||||
)
|
||||
|
|
|
|||
|
|
@ -79,16 +79,6 @@ export function useModularNavigation() {
|
|||
})
|
||||
}
|
||||
|
||||
// Expenses module items
|
||||
if (appConfig.modules.expenses.enabled) {
|
||||
items.push({
|
||||
name: 'My Transactions',
|
||||
href: '/expenses/transactions',
|
||||
icon: 'Receipt',
|
||||
requiresAuth: true
|
||||
})
|
||||
}
|
||||
|
||||
// Base module items (always available)
|
||||
items.push({
|
||||
name: 'Relay Hub Status',
|
||||
|
|
|
|||
|
|
@ -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'),
|
||||
PROFILE_SERVICE: Symbol('profileService'),
|
||||
REACTION_SERVICE: Symbol('reactionService'),
|
||||
SCHEDULED_EVENT_SERVICE: Symbol('scheduledEventService'),
|
||||
|
||||
// Nostr metadata services
|
||||
NOSTR_METADATA_SERVICE: Symbol('nostrMetadataService'),
|
||||
|
|
@ -160,9 +159,6 @@ export const SERVICE_TOKENS = {
|
|||
|
||||
// Image upload services
|
||||
IMAGE_UPLOAD_SERVICE: Symbol('imageUploadService'),
|
||||
|
||||
// Expenses services
|
||||
EXPENSES_API: Symbol('expensesAPI'),
|
||||
} as const
|
||||
|
||||
// Type-safe injection helpers
|
||||
|
|
|
|||
|
|
@ -1,30 +1,6 @@
|
|||
import type { App, Component } from 'vue'
|
||||
import type { RouteRecordRaw } from 'vue-router'
|
||||
|
||||
// Quick action interface for modular action buttons
|
||||
export interface QuickAction {
|
||||
/** Unique action ID */
|
||||
id: string
|
||||
|
||||
/** Display label for the action */
|
||||
label: string
|
||||
|
||||
/** Lucide icon name */
|
||||
icon: string
|
||||
|
||||
/** Component to render when action is selected */
|
||||
component: Component
|
||||
|
||||
/** Display order (lower = higher priority) */
|
||||
order?: number
|
||||
|
||||
/** Action category (e.g., 'compose', 'wallet', 'utilities') */
|
||||
category?: string
|
||||
|
||||
/** Whether action requires authentication */
|
||||
requiresAuth?: boolean
|
||||
}
|
||||
|
||||
// Base module plugin interface
|
||||
export interface ModulePlugin {
|
||||
/** Unique module name */
|
||||
|
|
@ -56,9 +32,6 @@ export interface ModulePlugin {
|
|||
|
||||
/** Composables provided by this module */
|
||||
composables?: Record<string, any>
|
||||
|
||||
/** Quick actions provided by this module */
|
||||
quickActions?: QuickAction[]
|
||||
}
|
||||
|
||||
// Module configuration for app setup
|
||||
|
|
|
|||
|
|
@ -540,12 +540,8 @@ export class RelayHub extends BaseService {
|
|||
const successful = results.filter(result => result.status === 'fulfilled').length
|
||||
const total = results.length
|
||||
|
||||
this.emit('eventPublished', { eventId: event.id, success: successful, total })
|
||||
|
||||
// Throw error if no relays accepted the event
|
||||
if (successful === 0) {
|
||||
throw new Error(`Failed to publish event - none of the ${total} relay(s) accepted it`)
|
||||
}
|
||||
this.emit('eventPublished', { eventId: event.id, success: successful, total })
|
||||
|
||||
return { success: successful, total }
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
DialogTitle,
|
||||
} from '@/components/ui/dialog'
|
||||
import { Megaphone, RefreshCw, AlertCircle, ChevronLeft, ChevronRight } from 'lucide-vue-next'
|
||||
import { Megaphone, RefreshCw, AlertCircle } from 'lucide-vue-next'
|
||||
import { useFeed } from '../composables/useFeed'
|
||||
import { useProfiles } from '../composables/useProfiles'
|
||||
import { useReactions } from '../composables/useReactions'
|
||||
import { useScheduledEvents } from '../composables/useScheduledEvents'
|
||||
import ThreadedPost from './ThreadedPost.vue'
|
||||
import ScheduledEventCard from './ScheduledEventCard.vue'
|
||||
import appConfig from '@/app.config'
|
||||
import type { ContentFilter, FeedPost } from '../services/FeedService'
|
||||
import type { ScheduledEvent } from '../services/ScheduledEventService'
|
||||
import { injectService, SERVICE_TOKENS } from '@/core/di-container'
|
||||
import type { AuthService } from '@/modules/base/auth/auth-service'
|
||||
import type { RelayHub } from '@/modules/base/nostr/relay-hub'
|
||||
|
|
@ -98,78 +95,6 @@ const { getDisplayName, fetchProfiles } = useProfiles()
|
|||
// Use reactions service for likes/hearts
|
||||
const { getEventReactions, subscribeToReactions, toggleLike } = useReactions()
|
||||
|
||||
// Use scheduled events service
|
||||
const {
|
||||
getEventsForSpecificDate,
|
||||
getCompletion,
|
||||
getTaskStatus,
|
||||
claimTask,
|
||||
startTask,
|
||||
completeEvent,
|
||||
unclaimTask,
|
||||
deleteTask,
|
||||
allCompletions
|
||||
} = useScheduledEvents()
|
||||
|
||||
// Selected date for viewing scheduled tasks (defaults to today)
|
||||
const selectedDate = ref(new Date().toISOString().split('T')[0])
|
||||
|
||||
// Get scheduled tasks for the selected date (reactive)
|
||||
const scheduledEventsForDate = computed(() => getEventsForSpecificDate(selectedDate.value))
|
||||
|
||||
// Navigate to previous day
|
||||
function goToPreviousDay() {
|
||||
const date = new Date(selectedDate.value)
|
||||
date.setDate(date.getDate() - 1)
|
||||
selectedDate.value = date.toISOString().split('T')[0]
|
||||
}
|
||||
|
||||
// Navigate to next day
|
||||
function goToNextDay() {
|
||||
const date = new Date(selectedDate.value)
|
||||
date.setDate(date.getDate() + 1)
|
||||
selectedDate.value = date.toISOString().split('T')[0]
|
||||
}
|
||||
|
||||
// Go back to today
|
||||
function goToToday() {
|
||||
selectedDate.value = new Date().toISOString().split('T')[0]
|
||||
}
|
||||
|
||||
// Check if selected date is today
|
||||
const isToday = computed(() => {
|
||||
const today = new Date().toISOString().split('T')[0]
|
||||
return selectedDate.value === today
|
||||
})
|
||||
|
||||
// Format date for display
|
||||
const dateDisplayText = computed(() => {
|
||||
const today = new Date().toISOString().split('T')[0]
|
||||
const yesterday = new Date()
|
||||
yesterday.setDate(yesterday.getDate() - 1)
|
||||
const yesterdayStr = yesterday.toISOString().split('T')[0]
|
||||
const tomorrow = new Date()
|
||||
tomorrow.setDate(tomorrow.getDate() + 1)
|
||||
const tomorrowStr = tomorrow.toISOString().split('T')[0]
|
||||
|
||||
if (selectedDate.value === today) {
|
||||
return "Today's Tasks"
|
||||
} else if (selectedDate.value === yesterdayStr) {
|
||||
return "Yesterday's Tasks"
|
||||
} else if (selectedDate.value === tomorrowStr) {
|
||||
return "Tomorrow's Tasks"
|
||||
} else {
|
||||
// Format as "Tasks for Mon, Jan 15"
|
||||
const date = new Date(selectedDate.value + 'T00:00:00')
|
||||
const formatted = date.toLocaleDateString('en-US', {
|
||||
weekday: 'short',
|
||||
month: 'short',
|
||||
day: 'numeric'
|
||||
})
|
||||
return `Tasks for ${formatted}`
|
||||
}
|
||||
})
|
||||
|
||||
// Watch for new posts and fetch their profiles and reactions
|
||||
watch(notes, async (newNotes) => {
|
||||
if (newNotes.length > 0) {
|
||||
|
|
@ -184,38 +109,6 @@ watch(notes, async (newNotes) => {
|
|||
}
|
||||
}, { immediate: true })
|
||||
|
||||
// Watch for scheduled events and fetch profiles for event authors and completers
|
||||
watch(scheduledEventsForDate, async (events) => {
|
||||
if (events.length > 0) {
|
||||
const pubkeys = new Set<string>()
|
||||
|
||||
// Add event authors
|
||||
events.forEach((event: ScheduledEvent) => {
|
||||
pubkeys.add(event.pubkey)
|
||||
|
||||
// Add completer pubkey if event is completed
|
||||
const eventAddress = `31922:${event.pubkey}:${event.dTag}`
|
||||
const completion = getCompletion(eventAddress)
|
||||
if (completion) {
|
||||
pubkeys.add(completion.pubkey)
|
||||
}
|
||||
})
|
||||
|
||||
// Fetch all profiles
|
||||
if (pubkeys.size > 0) {
|
||||
await fetchProfiles([...pubkeys])
|
||||
}
|
||||
}
|
||||
}, { immediate: true })
|
||||
|
||||
// Watch for new completions and fetch profiles for completers
|
||||
watch(allCompletions, async (completions) => {
|
||||
if (completions.length > 0) {
|
||||
const pubkeys = completions.map(c => c.pubkey)
|
||||
await fetchProfiles(pubkeys)
|
||||
}
|
||||
}, { immediate: true })
|
||||
|
||||
// Check if we have admin pubkeys configured
|
||||
const hasAdminPubkeys = computed(() => adminPubkeys.length > 0)
|
||||
|
||||
|
|
@ -265,52 +158,6 @@ async function onToggleLike(note: FeedPost) {
|
|||
}
|
||||
}
|
||||
|
||||
// Task action handlers
|
||||
async function onClaimTask(event: ScheduledEvent, occurrence?: string) {
|
||||
console.log('👋 NostrFeed: Claiming task:', event.title)
|
||||
try {
|
||||
await claimTask(event, '', occurrence)
|
||||
} catch (error) {
|
||||
console.error('❌ Failed to claim task:', error)
|
||||
}
|
||||
}
|
||||
|
||||
async function onStartTask(event: ScheduledEvent, occurrence?: string) {
|
||||
console.log('▶️ NostrFeed: Starting task:', event.title)
|
||||
try {
|
||||
await startTask(event, '', occurrence)
|
||||
} catch (error) {
|
||||
console.error('❌ Failed to start task:', error)
|
||||
}
|
||||
}
|
||||
|
||||
async function onCompleteTask(event: ScheduledEvent, occurrence?: string) {
|
||||
console.log('✅ NostrFeed: Completing task:', event.title)
|
||||
try {
|
||||
await completeEvent(event, occurrence, '')
|
||||
} catch (error) {
|
||||
console.error('❌ Failed to complete task:', error)
|
||||
}
|
||||
}
|
||||
|
||||
async function onUnclaimTask(event: ScheduledEvent, occurrence?: string) {
|
||||
console.log('🔙 NostrFeed: Unclaiming task:', event.title)
|
||||
try {
|
||||
await unclaimTask(event, occurrence)
|
||||
} catch (error) {
|
||||
console.error('❌ Failed to unclaim task:', error)
|
||||
}
|
||||
}
|
||||
|
||||
async function onDeleteTask(event: ScheduledEvent) {
|
||||
console.log('🗑️ NostrFeed: Deleting task:', event.title)
|
||||
try {
|
||||
await deleteTask(event)
|
||||
} catch (error) {
|
||||
console.error('❌ Failed to delete task:', error)
|
||||
}
|
||||
}
|
||||
|
||||
// Handle collapse toggle with cascading behavior
|
||||
function onToggleCollapse(postId: string) {
|
||||
const newCollapsed = new Set(collapsedPosts.value)
|
||||
|
|
@ -509,75 +356,20 @@ function cancelDelete() {
|
|||
</p>
|
||||
</div>
|
||||
|
||||
<!-- No Posts -->
|
||||
<div v-else-if="threadedPosts.length === 0" class="text-center py-8 px-4">
|
||||
<div class="flex items-center justify-center gap-2 text-muted-foreground mb-4">
|
||||
<Megaphone class="h-5 w-5" />
|
||||
<span>No posts yet</span>
|
||||
</div>
|
||||
<p class="text-sm text-muted-foreground">
|
||||
Check back later for community updates.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- Posts List - Natural flow without internal scrolling -->
|
||||
<div v-else>
|
||||
<!-- Scheduled Tasks Section with Date Navigation -->
|
||||
<div class="my-2 md:my-4">
|
||||
<div class="flex items-center justify-between px-4 md:px-0 mb-3">
|
||||
<!-- Left Arrow -->
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
class="h-8 w-8"
|
||||
@click="goToPreviousDay"
|
||||
>
|
||||
<ChevronLeft class="h-4 w-4" />
|
||||
</Button>
|
||||
|
||||
<!-- Date Header with Today Button -->
|
||||
<div class="flex items-center gap-2">
|
||||
<h3 class="text-sm font-semibold text-muted-foreground uppercase tracking-wide">
|
||||
📅 {{ dateDisplayText }}
|
||||
</h3>
|
||||
<Button
|
||||
v-if="!isToday"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
class="h-6 text-xs"
|
||||
@click="goToToday"
|
||||
>
|
||||
Today
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<!-- Right Arrow -->
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
class="h-8 w-8"
|
||||
@click="goToNextDay"
|
||||
>
|
||||
<ChevronRight class="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<!-- Scheduled Tasks List or Empty State -->
|
||||
<div v-if="scheduledEventsForDate.length > 0" class="md:space-y-3">
|
||||
<ScheduledEventCard
|
||||
v-for="event in scheduledEventsForDate"
|
||||
:key="`${event.pubkey}:${event.dTag}`"
|
||||
:event="event"
|
||||
:get-display-name="getDisplayName"
|
||||
:get-completion="getCompletion"
|
||||
:get-task-status="getTaskStatus"
|
||||
:admin-pubkeys="adminPubkeys"
|
||||
@claim-task="onClaimTask"
|
||||
@start-task="onStartTask"
|
||||
@complete-task="onCompleteTask"
|
||||
@unclaim-task="onUnclaimTask"
|
||||
@delete-task="onDeleteTask"
|
||||
/>
|
||||
</div>
|
||||
<div v-else class="text-center py-3 text-muted-foreground text-sm px-4">
|
||||
{{ isToday ? 'no tasks today' : 'no tasks for this day' }}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Posts Section -->
|
||||
<div v-if="threadedPosts.length > 0" class="md:space-y-4 md:py-4">
|
||||
<h3 v-if="scheduledEventsForDate.length > 0" class="text-sm font-semibold text-muted-foreground uppercase tracking-wide px-4 md:px-0 mb-3 mt-6">
|
||||
💬 Posts
|
||||
</h3>
|
||||
<div class="md:space-y-4 md:py-4">
|
||||
<ThreadedPost
|
||||
v-for="post in threadedPosts"
|
||||
:key="post.id"
|
||||
|
|
@ -598,19 +390,8 @@ function cancelDelete() {
|
|||
/>
|
||||
</div>
|
||||
|
||||
<!-- No Posts Message (show whenever there are no posts, regardless of events) -->
|
||||
<div v-else class="text-center py-8 px-4">
|
||||
<div class="flex items-center justify-center gap-2 text-muted-foreground mb-4">
|
||||
<Megaphone class="h-5 w-5" />
|
||||
<span>No posts yet</span>
|
||||
</div>
|
||||
<p class="text-sm text-muted-foreground">
|
||||
Check back later for community updates.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- End of feed message -->
|
||||
<div v-if="threadedPosts.length > 0" class="text-center py-6 text-md text-muted-foreground">
|
||||
<div class="text-center py-6 text-md text-muted-foreground">
|
||||
<p>🐢</p>
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
tags: ['rideshare', 'carpool'], // NIP-12 tags
|
||||
keywords: ['rideshare', 'ride share', 'carpool', '🚗', '🚶']
|
||||
},
|
||||
|
||||
// Scheduled events (NIP-52)
|
||||
scheduledEvents: {
|
||||
id: 'scheduled-events',
|
||||
label: 'Scheduled Events',
|
||||
kinds: [31922], // NIP-52: Calendar Events
|
||||
description: 'Calendar-based tasks and scheduled activities'
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -118,11 +110,6 @@ export const FILTER_PRESETS: Record<string, ContentFilter[]> = {
|
|||
// Rideshare only
|
||||
rideshare: [
|
||||
CONTENT_FILTERS.rideshare
|
||||
],
|
||||
|
||||
// Scheduled events only
|
||||
scheduledEvents: [
|
||||
CONTENT_FILTERS.scheduledEvents
|
||||
]
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -1,15 +1,11 @@
|
|||
import type { App } from 'vue'
|
||||
import { markRaw } from 'vue'
|
||||
import type { ModulePlugin } from '@/core/types'
|
||||
import { container, SERVICE_TOKENS } from '@/core/di-container'
|
||||
import NostrFeed from './components/NostrFeed.vue'
|
||||
import NoteComposer from './components/NoteComposer.vue'
|
||||
import RideshareComposer from './components/RideshareComposer.vue'
|
||||
import { useFeed } from './composables/useFeed'
|
||||
import { FeedService } from './services/FeedService'
|
||||
import { ProfileService } from './services/ProfileService'
|
||||
import { ReactionService } from './services/ReactionService'
|
||||
import { ScheduledEventService } from './services/ScheduledEventService'
|
||||
|
||||
/**
|
||||
* Nostr Feed Module Plugin
|
||||
|
|
@ -20,28 +16,6 @@ export const nostrFeedModule: ModulePlugin = {
|
|||
version: '1.0.0',
|
||||
dependencies: ['base'],
|
||||
|
||||
// Register quick actions for the FAB menu
|
||||
quickActions: [
|
||||
{
|
||||
id: 'note',
|
||||
label: 'Note',
|
||||
icon: 'MessageSquare',
|
||||
component: markRaw(NoteComposer),
|
||||
category: 'compose',
|
||||
order: 1,
|
||||
requiresAuth: true
|
||||
},
|
||||
{
|
||||
id: 'rideshare',
|
||||
label: 'Rideshare',
|
||||
icon: 'Car',
|
||||
component: markRaw(RideshareComposer),
|
||||
category: 'compose',
|
||||
order: 2,
|
||||
requiresAuth: true
|
||||
}
|
||||
],
|
||||
|
||||
async install(app: App) {
|
||||
console.log('nostr-feed module: Starting installation...')
|
||||
|
||||
|
|
@ -49,12 +23,10 @@ export const nostrFeedModule: ModulePlugin = {
|
|||
const feedService = new FeedService()
|
||||
const profileService = new ProfileService()
|
||||
const reactionService = new ReactionService()
|
||||
const scheduledEventService = new ScheduledEventService()
|
||||
|
||||
container.provide(SERVICE_TOKENS.FEED_SERVICE, feedService)
|
||||
container.provide(SERVICE_TOKENS.PROFILE_SERVICE, profileService)
|
||||
container.provide(SERVICE_TOKENS.REACTION_SERVICE, reactionService)
|
||||
container.provide(SERVICE_TOKENS.SCHEDULED_EVENT_SERVICE, scheduledEventService)
|
||||
console.log('nostr-feed module: Services registered in DI container')
|
||||
|
||||
// Initialize services
|
||||
|
|
@ -71,10 +43,6 @@ export const nostrFeedModule: ModulePlugin = {
|
|||
reactionService.initialize({
|
||||
waitForDependencies: true,
|
||||
maxRetries: 3
|
||||
}),
|
||||
scheduledEventService.initialize({
|
||||
waitForDependencies: true,
|
||||
maxRetries: 3
|
||||
})
|
||||
])
|
||||
console.log('nostr-feed module: Services initialized')
|
||||
|
|
|
|||
|
|
@ -47,7 +47,6 @@ export class FeedService extends BaseService {
|
|||
protected relayHub: any = null
|
||||
protected visibilityService: any = null
|
||||
protected reactionService: any = null
|
||||
protected scheduledEventService: any = null
|
||||
|
||||
// Event ID tracking for deduplication
|
||||
private seenEventIds = new Set<string>()
|
||||
|
|
@ -73,12 +72,10 @@ export class FeedService extends BaseService {
|
|||
this.relayHub = injectService(SERVICE_TOKENS.RELAY_HUB)
|
||||
this.visibilityService = injectService(SERVICE_TOKENS.VISIBILITY_SERVICE)
|
||||
this.reactionService = injectService(SERVICE_TOKENS.REACTION_SERVICE)
|
||||
this.scheduledEventService = injectService(SERVICE_TOKENS.SCHEDULED_EVENT_SERVICE)
|
||||
|
||||
console.log('FeedService: RelayHub injected:', !!this.relayHub)
|
||||
console.log('FeedService: VisibilityService injected:', !!this.visibilityService)
|
||||
console.log('FeedService: ReactionService injected:', !!this.reactionService)
|
||||
console.log('FeedService: ScheduledEventService injected:', !!this.scheduledEventService)
|
||||
|
||||
if (!this.relayHub) {
|
||||
throw new Error('RelayHub service not available')
|
||||
|
|
@ -202,12 +199,6 @@ export class FeedService extends BaseService {
|
|||
kinds: [5] // All deletion events (for both posts and reactions)
|
||||
})
|
||||
|
||||
// Add scheduled events (kind 31922) and RSVPs (kind 31925)
|
||||
filters.push({
|
||||
kinds: [31922, 31925], // Calendar events and RSVPs
|
||||
limit: 200
|
||||
})
|
||||
|
||||
console.log(`Creating feed subscription for ${config.feedType} with filters:`, filters)
|
||||
|
||||
// Subscribe to all events (posts, reactions, deletions) with deduplication
|
||||
|
|
@ -266,25 +257,6 @@ export class FeedService extends BaseService {
|
|||
return
|
||||
}
|
||||
|
||||
// Route scheduled events (kind 31922) to ScheduledEventService
|
||||
if (event.kind === 31922) {
|
||||
if (this.scheduledEventService) {
|
||||
this.scheduledEventService.handleScheduledEvent(event)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
// Route RSVP/completion events (kind 31925) to ScheduledEventService
|
||||
if (event.kind === 31925) {
|
||||
console.log('🔀 FeedService: Routing kind 31925 (completion) to ScheduledEventService')
|
||||
if (this.scheduledEventService) {
|
||||
this.scheduledEventService.handleCompletionEvent(event)
|
||||
} else {
|
||||
console.warn('⚠️ FeedService: ScheduledEventService not available')
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
// Skip if event already seen (for posts only, kind 1)
|
||||
if (this.seenEventIds.has(event.id)) {
|
||||
return
|
||||
|
|
@ -383,28 +355,6 @@ export class FeedService extends BaseService {
|
|||
return
|
||||
}
|
||||
|
||||
// Route to ScheduledEventService for completion/RSVP deletions (kind 31925)
|
||||
if (deletedKind === '31925') {
|
||||
console.log('🔀 FeedService: Routing kind 5 (deletion of kind 31925) to ScheduledEventService')
|
||||
if (this.scheduledEventService) {
|
||||
this.scheduledEventService.handleDeletionEvent(event)
|
||||
} else {
|
||||
console.warn('⚠️ FeedService: ScheduledEventService not available')
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
// Route to ScheduledEventService for scheduled event deletions (kind 31922)
|
||||
if (deletedKind === '31922') {
|
||||
console.log('🔀 FeedService: Routing kind 5 (deletion of kind 31922) to ScheduledEventService')
|
||||
if (this.scheduledEventService) {
|
||||
this.scheduledEventService.handleTaskDeletion(event)
|
||||
} else {
|
||||
console.warn('⚠️ FeedService: ScheduledEventService not available')
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
// Handle post deletions (kind 1) in FeedService
|
||||
if (deletedKind === '1' || !deletedKind) {
|
||||
// Extract event IDs to delete from 'e' tags
|
||||
|
|
|
|||
|
|
@ -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 -->
|
||||
<div class="flex-1 overflow-y-auto scrollbar-thin scrollbar-thumb-border scrollbar-track-transparent">
|
||||
<!-- Quick Action Component Area -->
|
||||
<div v-if="activeAction || replyTo" class="border-b bg-background sticky top-0 z-10">
|
||||
<!-- Collapsible Composer -->
|
||||
<div v-if="showComposer || replyTo" class="border-b bg-background sticky top-0 z-10">
|
||||
<div class="max-h-[70vh] overflow-y-auto scrollbar-thin scrollbar-thumb-border scrollbar-track-transparent">
|
||||
<div class="px-4 py-3 sm:px-6">
|
||||
<!-- Dynamic Quick Action Component -->
|
||||
<component
|
||||
:is="activeAction?.component"
|
||||
v-if="activeAction"
|
||||
<!-- Regular Note Composer -->
|
||||
<NoteComposer
|
||||
v-if="composerType === 'note' || replyTo"
|
||||
:reply-to="replyTo"
|
||||
@note-published="onActionComplete"
|
||||
@rideshare-published="onActionComplete"
|
||||
@action-complete="onActionComplete"
|
||||
@note-published="onNotePublished"
|
||||
@clear-reply="onClearReply"
|
||||
@close="closeQuickAction"
|
||||
@close="onCloseComposer"
|
||||
/>
|
||||
|
||||
<!-- Rideshare Composer -->
|
||||
<RideshareComposer
|
||||
v-else-if="composerType === 'rideshare'"
|
||||
@rideshare-published="onRidesharePublished"
|
||||
@close="onCloseComposer"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -68,32 +72,39 @@
|
|||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Floating Quick Action Button -->
|
||||
<div v-if="!activeAction && !replyTo && quickActions.length > 0" class="fixed bottom-6 right-6 z-50">
|
||||
<!-- Floating Action Buttons for Compose -->
|
||||
<div v-if="!showComposer && !replyTo" class="fixed bottom-6 right-6 z-50">
|
||||
<!-- Main compose button -->
|
||||
<div class="flex flex-col items-end gap-3">
|
||||
<!-- Quick Action Buttons (when expanded) -->
|
||||
<div v-if="showQuickActions" class="flex flex-col gap-2">
|
||||
<!-- Secondary buttons (when expanded) -->
|
||||
<div v-if="showComposerOptions" class="flex flex-col gap-2">
|
||||
<Button
|
||||
v-for="action in quickActions"
|
||||
:key="action.id"
|
||||
@click="openQuickAction(action)"
|
||||
@click="openComposer('note')"
|
||||
size="lg"
|
||||
class="h-12 px-4 rounded-full shadow-lg hover:shadow-xl transition-all gap-2 bg-card border-2 border-border hover:bg-accent text-card-foreground"
|
||||
>
|
||||
<component :is="getIconComponent(action.icon)" class="h-4 w-4" />
|
||||
<span class="text-sm font-medium">{{ action.label }}</span>
|
||||
<MessageSquare class="h-4 w-4" />
|
||||
<span class="text-sm font-medium">Note</span>
|
||||
</Button>
|
||||
<Button
|
||||
@click="openComposer('rideshare')"
|
||||
size="lg"
|
||||
class="h-12 px-4 rounded-full shadow-lg hover:shadow-xl transition-all gap-2 bg-card border-2 border-border hover:bg-accent text-card-foreground"
|
||||
>
|
||||
<Car class="h-4 w-4" />
|
||||
<span class="text-sm font-medium">Rideshare</span>
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<!-- Main FAB -->
|
||||
<Button
|
||||
@click="toggleQuickActions"
|
||||
@click="toggleComposerOptions"
|
||||
size="lg"
|
||||
class="h-14 w-14 rounded-full shadow-lg hover:shadow-xl transition-all bg-primary hover:bg-primary/90 border-2 border-primary-foreground/20 flex items-center justify-center p-0"
|
||||
>
|
||||
<Plus
|
||||
class="h-6 w-6 stroke-[2.5] transition-transform duration-200"
|
||||
:class="{ 'rotate-45': showQuickActions }"
|
||||
:class="{ 'rotate-45': showComposerOptions }"
|
||||
/>
|
||||
</Button>
|
||||
</div>
|
||||
|
|
@ -125,34 +136,32 @@
|
|||
import { ref, computed, watch } from 'vue'
|
||||
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Filter, Plus } from 'lucide-vue-next'
|
||||
import * as LucideIcons from 'lucide-vue-next'
|
||||
import { Filter, Plus, MessageSquare, Car } from 'lucide-vue-next'
|
||||
import PWAInstallPrompt from '@/components/pwa/PWAInstallPrompt.vue'
|
||||
import FeedFilters from '@/modules/nostr-feed/components/FeedFilters.vue'
|
||||
import NoteComposer from '@/modules/nostr-feed/components/NoteComposer.vue'
|
||||
import RideshareComposer from '@/modules/nostr-feed/components/RideshareComposer.vue'
|
||||
import NostrFeed from '@/modules/nostr-feed/components/NostrFeed.vue'
|
||||
import { FILTER_PRESETS } from '@/modules/nostr-feed/config/content-filters'
|
||||
import { useQuickActions } from '@/composables/useQuickActions'
|
||||
import appConfig from '@/app.config'
|
||||
import type { ContentFilter } from '@/modules/nostr-feed/services/FeedService'
|
||||
import type { QuickAction } from '@/core/types'
|
||||
|
||||
// Get quick actions from modules
|
||||
const { quickActions } = useQuickActions()
|
||||
import type { ReplyToNote } from '@/modules/nostr-feed/components/NoteComposer.vue'
|
||||
|
||||
// Get admin pubkeys from app config
|
||||
const adminPubkeys = appConfig.modules['nostr-feed']?.config?.adminPubkeys || []
|
||||
|
||||
// UI state
|
||||
const showFilters = ref(false)
|
||||
const showQuickActions = ref(false)
|
||||
const activeAction = ref<QuickAction | null>(null)
|
||||
const showComposer = ref(false)
|
||||
const showComposerOptions = ref(false)
|
||||
const composerType = ref<'note' | 'rideshare'>('note')
|
||||
|
||||
// Feed configuration
|
||||
const selectedFilters = ref<ContentFilter[]>(FILTER_PRESETS.all)
|
||||
const feedKey = ref(0) // Force feed component to re-render when filters change
|
||||
|
||||
// Reply state (for note composer compatibility)
|
||||
const replyTo = ref<any | undefined>()
|
||||
// Note composer state
|
||||
const replyTo = ref<ReplyToNote | undefined>()
|
||||
|
||||
// Quick filter presets for mobile bottom bar
|
||||
const quickFilterPresets = {
|
||||
|
|
@ -212,46 +221,48 @@ const setQuickFilter = (presetKey: string) => {
|
|||
}
|
||||
}
|
||||
|
||||
// Quick action methods
|
||||
const toggleQuickActions = () => {
|
||||
showQuickActions.value = !showQuickActions.value
|
||||
}
|
||||
|
||||
const openQuickAction = (action: QuickAction) => {
|
||||
activeAction.value = action
|
||||
showQuickActions.value = false
|
||||
}
|
||||
|
||||
const closeQuickAction = () => {
|
||||
activeAction.value = null
|
||||
replyTo.value = undefined
|
||||
}
|
||||
|
||||
// Event handlers for quick action components
|
||||
const onActionComplete = (eventData?: any) => {
|
||||
console.log('Quick action completed:', activeAction.value?.id, eventData)
|
||||
// Refresh the feed to show new content
|
||||
const onNotePublished = (noteId: string) => {
|
||||
console.log('Note published:', noteId)
|
||||
// Refresh the feed to show the new note
|
||||
feedKey.value++
|
||||
// Close the action
|
||||
activeAction.value = null
|
||||
// Clear reply state and hide composer
|
||||
replyTo.value = undefined
|
||||
showComposer.value = false
|
||||
}
|
||||
|
||||
const onClearReply = () => {
|
||||
replyTo.value = undefined
|
||||
showComposer.value = false
|
||||
}
|
||||
|
||||
const onReplyToNote = (note: any) => {
|
||||
const onReplyToNote = (note: ReplyToNote) => {
|
||||
replyTo.value = note
|
||||
// Find and open the note composer action
|
||||
const noteAction = quickActions.value.find(a => a.id === 'note')
|
||||
if (noteAction) {
|
||||
activeAction.value = noteAction
|
||||
}
|
||||
showComposer.value = true
|
||||
}
|
||||
|
||||
// Helper to get Lucide icon component
|
||||
const getIconComponent = (iconName: string) => {
|
||||
return (LucideIcons as any)[iconName] || Plus
|
||||
const onCloseComposer = () => {
|
||||
showComposer.value = false
|
||||
showComposerOptions.value = false
|
||||
replyTo.value = undefined
|
||||
}
|
||||
|
||||
// New composer methods
|
||||
const toggleComposerOptions = () => {
|
||||
showComposerOptions.value = !showComposerOptions.value
|
||||
}
|
||||
|
||||
const openComposer = (type: 'note' | 'rideshare') => {
|
||||
composerType.value = type
|
||||
showComposer.value = true
|
||||
showComposerOptions.value = false
|
||||
}
|
||||
|
||||
const onRidesharePublished = (noteId: string) => {
|
||||
console.log('Rideshare post published:', noteId)
|
||||
// Refresh the feed to show the new rideshare post
|
||||
feedKey.value++
|
||||
// Hide composer
|
||||
showComposer.value = false
|
||||
showComposerOptions.value = false
|
||||
}
|
||||
</script>
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue