Compare commits

..

50 commits

Author SHA1 Message Date
078c55b8e9 Fix critical bug: prevent optimistic UI updates when event publish fails
**Problem:**
Task status changes (claim/start/complete/unclaim/delete) would update the
local UI state even when the Nostr event failed to publish to ANY relays.
This caused users to see "completed" tasks that were never actually published,
leading to confusion when the UI reverted after page refresh.

**Root Cause:**
ScheduledEventService optimistically updated local state after calling
publishEvent(), without checking if any relays accepted the event. If all
relay publishes failed (result.success = 0), the UI still updated.

**Solution:**
Modified RelayHub.publishEvent() to throw an error when no relays accept the
event (success = 0). This ensures:
- Existing try-catch blocks handle the error properly
- Error toast shown to user: "Failed to publish event - none of X relay(s) accepted it"
- Local state NOT updated (UI remains accurate)
- Consistent behavior across all services using publishEvent()

**Changes:**
- relay-hub.ts: Add check after publish - throw error if successful === 0
- ScheduledEventService.ts: Update comments to reflect new behavior

**Benefits:**
- Single source of truth for publish failure handling
- No code duplication (no need to check result.success everywhere)
- Better UX: Users immediately see error instead of false success
- UI state always matches server state after operations

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-11-18 20:35:59 +01:00
3b8c82514a Add delete task functionality for task authors
Added ability for task authors to delete their own tasks from the expanded
view in the task feed.

**Features:**
- Delete button visible only to task author in expanded task view
- Confirmation dialog with destructive styling
- Publishes NIP-09 deletion event (kind 5) with 'a' tag referencing the
  task's event address (kind:pubkey:d-tag format)
- Real-time deletion handling via FeedService routing
- Optimistic local state update for immediate UI feedback

**Implementation:**
- Added deleteTask() method to ScheduledEventService
- Added handleTaskDeletion() for processing incoming deletion events
- Updated FeedService to route kind 31922 deletions to ScheduledEventService
- Added delete button and dialog flow to ScheduledEventCard component
- Integrated with existing confirmation dialog pattern

**Permissions:**
- Only task authors can delete tasks (enforced by isAuthor check)
- NIP-09 validation: relays only accept deletion from event author
- Pubkey verification in handleTaskDeletion()

**Testing:**
- Created tasks and verified delete button appears for author only
- Confirmed deletion removes task from UI immediately
- Verified deletion persists after refresh
- Tested with multiple users - others cannot delete

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-11-16 22:39:38 +01:00
8f05f4ec7c Add communication confirmation checkbox for unclaiming tasks
Require users to confirm they've communicated with the team before unclaiming a task to prevent coordination issues.

Changes:
- Add hasConfirmedCommunication checkbox state
- Show checkbox in unclaim confirmation dialog
- Disable "Unclaim Task" button until checkbox is checked
- Reset checkbox state when dialog is closed/cancelled
- Update dialog description to prompt communication

UX Flow:
1. User clicks "Unclaim" button
2. Dialog appears with message about removing claim
3. Checkbox: "I have communicated this to the team"
4. "Unclaim Task" button disabled until checkbox checked
5. Forces user acknowledgment before unclaiming

This prevents situations where:
- Someone unclaims without notifying others working on related tasks
- Team members are left confused about task status
- Work gets duplicated or blocked due to lack of communication

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-11-16 21:14:34 +01:00
4e85488921 Simplify unclaim logic - only for claimed state
Refine unclaim button visibility to prevent confusing UX where users could "unclaim" status updates they didn't claim.

Changes:
- Only show "Unclaim" button when task is in "claimed" state
- Remove "Unclaim" button from in-progress and completed states
- Maintain check that current user must be the claimer

Rationale:
- Prevents confusion where user marks task in-progress but sees "Unclaim"
- "Unclaim" makes sense only for the original claim action
- Users can still update status (mark in-progress/complete) on any task
- But only the claimer can unclaim the original claim

Example scenarios:
- Alice claims task → Alice sees "Unclaim" button
- Bob marks Alice's task as in-progress → Bob does NOT see "Unclaim"
- Only Alice can unclaim, reverting task to unclaimed state

This simple rule prevents UX confusion until we implement full per-user status tracking.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-11-16 21:01:38 +01:00
d0b3396af7 Fix unclaim permission bug - only show button for task owner
Previously, users could see and click "Unclaim" button for tasks claimed by others, leading to failed deletion attempts and temporary UI inconsistencies.

Changes:
- Add auth service injection to ScheduledEventCard
- Add canUnclaim computed property checking if current user created the current status
- Only show "Unclaim" button when canUnclaim is true (user owns the current status)
- Applies NIP-09 rule: users can only delete their own events

Behavior:
- Alice claims task → only Alice sees "Unclaim" button
- Bob marks task in-progress → only Bob sees "Unclaim" button
- Prevents failed deletion attempts and optimistic update bugs
- Follows Nostr protocol permissions correctly

This is a quick fix. Future enhancement will track per-user status for full collaborative workflow support.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-11-16 20:54:25 +01:00
76fbf7579f Add jump ahead buttons for task workflow
Enable users to skip intermediate task states in the expanded view. Users can now directly mark tasks as in-progress or complete without going through all workflow steps.

Changes:
- Add "Mark In Progress" button for unclaimed tasks (skips claiming)
- Add "Mark Complete" button for unclaimed and claimed tasks (skips intermediate states)
- Maintain existing workflow buttons (Claim Task, Start Task, Unclaim)
- Use concise, industry-standard button labels following common task management UX

Button layout:
- Unclaimed: "Claim Task" (default), "Mark In Progress" (outline), "Mark Complete" (outline)
- Claimed: "Start Task" (default), "Mark Complete" (outline), "Unclaim" (outline)
- In Progress: "Mark Complete" (default), "Unclaim" (outline)
- Completed: "Unclaim" (outline)

This provides maximum flexibility for different task management workflows while maintaining clear visual hierarchy with primary/outline button variants.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-11-16 17:10:34 +01:00
d8e67984b0 Fix real-time unclaim updates for task workflow
Add deletion event handling to enable real-time UI updates when tasks are unclaimed. Previously, unclaiming a task required manual refresh to see the update.

Changes:
- Add handleDeletionEvent() to ScheduledEventService to process kind 5 deletion events
- Update FeedService to route kind 31925 deletions to ScheduledEventService
- Implement NIP-09 validation (only author can delete their own events)
- Remove completions from reactive Map to trigger Vue reactivity

Technical details:
- Subscription to kind 5 events was already in place
- Issue was lack of routing for kind 31925 (RSVP/completion) deletions
- Now follows same pattern as reaction and post deletions
- Deletion events properly update the _completions reactive Map
- UI automatically reflects unclaimed state through Vue reactivity

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-11-16 17:01:22 +01:00
d497cfa4d9 Implement task status workflow: claimed, in-progress, completed
Added granular task state management to scheduled events/tasks with three states plus unclaimed. Tasks now support a full workflow from claiming to completion with visual feedback at each stage.

**New Task States:**
- **Unclaimed** (no RSVP event) - Task available for anyone to claim
- **Claimed** - User has reserved the task but hasn't started
- **In Progress** - User is actively working on the task
- **Completed** - Task is done
- **Blocked** - Task is stuck (supported but not yet used in UI)
- **Cancelled** - Task won't be completed (supported but not yet used in UI)

**Service Layer (ScheduledEventService.ts):**
- Updated `EventCompletion` interface: replaced `completed: boolean` with `taskStatus: TaskStatus`
- Added `TaskStatus` type: `'claimed' | 'in-progress' | 'completed' | 'blocked' | 'cancelled'`
- New methods: `claimTask()`, `startTask()`, `getTaskStatus()`
- Refactored `completeEvent()` and renamed `uncompleteEvent()` to `unclaimTask()`
- Internal `updateTaskStatus()` method handles all state changes
- Uses `task-status` tag instead of `completed` tag in Nostr events
- `unclaimTask()` publishes deletion event (kind 5) to remove RSVP
- Backward compatibility: reads old `completed` tag and converts to new taskStatus

**Composable (useScheduledEvents.ts):**
- Exported new methods: `claimTask`, `startTask`, `unclaimTask`, `getTaskStatus`
- Updated `completeEvent` signature to accept occurrence parameter
- Marked `toggleComplete` as deprecated (still works for compatibility)

**UI (ScheduledEventCard.vue):**
- Context-aware action buttons based on current task status:
  - Unclaimed: "Claim Task" button
  - Claimed: "Start Task" + "Unclaim" buttons
  - In Progress: "Mark Complete" + "Unclaim" buttons
  - Completed: "Unclaim" button only
- Status badges with icons and color coding:
  - 👋 Claimed (blue)
  - 🔄 In Progress (orange)
  - ✓ Completed (green)
- Shows who claimed/is working on/completed each task
- Unified confirmation dialog for all actions
- Quick action buttons in collapsed view
- Full button set in expanded view

**Feed Integration (NostrFeed.vue):**
- Added handlers: `onClaimTask`, `onStartTask`, `onCompleteTask`, `onUnclaimTask`
- Passes `getTaskStatus` prop to ScheduledEventCard
- Wired up all new event emitters

**Nostr Protocol:**
- Uses NIP-52 Calendar Event RSVP (kind 31925)
- Custom `task-status` tag for granular state tracking
- Deletion events (kind 5) for unclaiming tasks
- Fully decentralized - all state stored on Nostr relays

🐢 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-11-16 16:45:45 +01:00
2e6f215157 Rename 'Events' to 'Tasks' in NostrFeed to avoid confusion with Events module
Changed terminology in the scheduled events section of NostrFeed to use "Tasks" instead of "Events" to prevent confusion with the Events module (which handles event ticketing).

**Changes:**
- "Today's Events" → "Today's Tasks"
- "Yesterday's Events" → "Yesterday's Tasks"
- "Tomorrow's Events" → "Tomorrow's Tasks"
- "Events for Mon, Jan 15" → "Tasks for Mon, Jan 15"
- Updated comments: "Scheduled Events" → "Scheduled Tasks"

**Rationale:**
- **NostrFeed scheduled items** = Daily tasks and announcements (NIP-52 calendar events)
- **Events module** = Event ticketing system (concerts, conferences, etc.)
- Using "Tasks" makes it clear these are to-do items, not ticketed events

Empty state message already correctly used "tasks" terminology and remains unchanged.

🐢 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-11-16 16:20:13 +01:00
91aecd2192 Simplify TransactionsPage header to 'Transaction History'
Removed redundant subtitle and simplified header to just "Transaction History" for cleaner, more concise UI.

Before:
- Title: "My Transactions"
- Subtitle: "View your recent transaction history"

After:
- Title: "Transaction History"
- No subtitle

🐢 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-11-14 22:40:11 +01:00
84596e518e Update TransactionsPage to match castle extension date range API changes
Synchronized TransactionsPage with castle LNbits extension API updates that introduced custom date range filtering.

**API Changes (ExpensesAPI.ts):**
- Updated `getUserTransactions()` to support `start_date` and `end_date` parameters (YYYY-MM-DD format)
- Custom date range takes precedence over preset days parameter
- Updated comment: default changed from 5 to 15 days, options now 15/30/60 (removed 5 and 90)

**UI Changes (TransactionsPage.vue):**
- **New defaults**: Changed default from 5 days to 15 days
- **New preset options**: 15, 30, 60 days (removed 5 and 90 day options)
- **Custom date range**: Added "Custom" option with date picker inputs
  - From/To date inputs using native HTML5 date picker
  - Apply button to load transactions for custom range
  - Auto-clears custom dates when switching back to preset days
- **State management**:
  - `dateRangeType` ref supports both numbers (15, 30, 60) and 'custom' string
  - `customStartDate` and `customEndDate` refs for custom date range
- **Smart loading**: Only loads transactions after user provides both dates and clicks Apply

**Priority Logic:**
- Custom date range (start_date + end_date) takes precedence
- Falls back to preset days if custom not selected
- Defaults to 15 days if custom selected but dates not provided

Matches castle extension implementation exactly (see castle extension git diff in fava_client.py, views_api.py, and static/js/index.js).

🐢 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-11-14 22:38:34 +01:00
509fae1d35 Remove pagination from TransactionsPage - load all transactions
Fixed issue where users could only see 20 transactions at a time despite having more transactions in the selected time period.

Changes:
- **Removed pagination controls**: Eliminated prev/next page buttons and page counter
- **Load all transactions**: Set limit to 1000 to fetch all transactions for the selected time period
- **Natural scrolling**: Users can now scroll through all their transactions
- **Improved fuzzy search**: Search now works across ALL transactions, not just the current page
- **Simplified UI**: Cleaner interface without pagination complexity
- **Updated transaction count**: Now shows total count instead of "X-Y of Z"

Previous behavior:
- Limited to 20 transactions per page
- Required manual pagination to see more
- Fuzzy search only searched current page (20 transactions)

New behavior:
- Loads up to 1000 transactions at once
- Single scrollable list
- Fuzzy search works across all loaded transactions
- Lightweight (text-only data)

🐢 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-11-14 16:50:59 +01:00
557d7ecacc Optimize TransactionsPage for mobile view
Dramatically reduced wasted space and improved mobile UX by:

- **Compact header**: Moved refresh button inline with title, similar to NostrFeed
- **Compact controls**: All day filter buttons now on one row with Calendar icon
- **Removed nested cards**: Eliminated Card wrapper around transactions list
- **Full-width layout**: Transactions now use full screen width on mobile (border-b) and rounded cards on desktop (md:border md:rounded-lg)
- **Consistent padding**: Uses px-0 on mobile, px-4 on desktop, matching NostrFeed patterns
- **Reduced vertical space**: Compacted header section to about half the original height
- **Cleaner imports**: Removed unused Card, CardContent, CardHeader, CardTitle, CardDescription, and Separator components

Layout now follows NostrFeed's mobile-optimized patterns with max-w-3xl container and responsive spacing.

Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-11-14 16:39:32 +01:00
9c4b14f382 Emphasize pending approval status in expense success dialog
- Added prominent orange "Pending Admin Approval" badge with clock icon
- Updated messaging to clarify admin review process
- Improved visual hierarchy in success confirmation
- Enhanced user awareness of approval workflow

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-11-13 16:57:06 +01:00
be00c61c77 Add transactions page with fuzzy search and success dialog for expenses
Features:
- Created TransactionsPage with mobile-optimized layout
  - Card-based transaction items with status indicators
  - Fuzzy search by description, payee, reference, username, and tags
  - Day filter options (5, 30, 60, 90 days)
  - Pagination support
  - Responsive design for mobile and desktop
- Added getUserTransactions API method to ExpensesAPI
  - Supports filtering by days, user ID, and account type
  - Returns paginated transaction data
- Updated AddExpense component with success confirmation
  - Shows success message in same dialog after submission
  - Provides option to navigate to transactions page
  - Clean single-dialog approach
- Added "My Transactions" link to navbar menu
- Added Transaction and TransactionListResponse types
- Added permission management types and API methods (grantPermission, listPermissions, revokePermission)
- Installed alert-dialog component for UI consistency

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-11-13 09:57:28 +01:00
78fba2a637 Add exclude_virtual parameter to match backend API
Adds support for the new exclude_virtual parameter added to the Castle backend API.

Changes:
- Added excludeVirtual parameter to getAccounts() (defaults to true)
- Added excludeVirtual parameter to getAccountHierarchy() (defaults to true)
- Both methods now pass the parameter to the backend API

This ensures virtual parent accounts are excluded from user views by default,
while still allowing permission inheritance to work correctly on the backend.

The default value of true means existing code automatically benefits from
the change without modification - virtual accounts won't appear in user
account selectors.

🤖 Generated with Claude Code (https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-11-11 03:29:36 +01:00
f5075ed96d Clarifies amount field in ExpenseEntryRequest
Updates the description of the `amount` and `currency` fields in the `ExpenseEntryRequest` interface to clarify that the amount is in the specified currency (or satoshis if currency is None).
2025-11-10 15:50:33 +01:00
90a5741d7a Updates description placeholder for clarity
Updates the description placeholder in the AddExpense component to provide more specific and helpful examples for users.

This aims to guide users in providing more detailed and relevant descriptions of their expenses, improving the overall expense tracking experience.
2025-11-08 11:48:18 +01:00
d7cd72f850 Enhances account display in expense form
Improves the visual presentation of the selected account in the add expense form by using a badge.

This makes the selected account more prominent and provides a clearer distinction from other text elements.
2025-11-08 00:22:13 +01:00
fff42d170e Adds equity eligibility check for expenses
Improves the expense tracking component by fetching user information to determine equity eligibility.

This allows displaying the "Convert to equity" checkbox only to eligible users, enhancing the user experience and ensuring accurate expense categorization.

Also includes error handling to prevent the form from breaking if user information cannot be loaded.
2025-11-08 00:10:40 +01:00
53c14044ef Filters accounts by user permissions
Ensures that the account selector only displays accounts
that the user has permissions for.

This change modifies the `ExpensesAPI` to include a
`filterByUser` parameter when fetching accounts, which is
then passed to the backend to retrieve only authorized
accounts. A log statement was added to confirm proper
filtering.
2025-11-07 22:18:56 +01:00
0f795f9d18 Improves Add Expense dialog layout
Enhances the Add Expense dialog's layout for better usability.

Reduces the maximum height of the dialog, adjusts padding and margins,
and ensures scrollable content has a minimum height for a more consistent
user experience.
2025-11-07 22:06:58 +01:00
358c3056c7 Wraps expense form in Dialog component
Refactors the AddExpense component to utilize the Dialog component
from the UI library. This provides a more structured and accessible
modal for adding expenses, and includes a header with title and description.
It also improves the layout using flexbox for better content management
and scrollability.
2025-11-07 22:05:27 +01:00
f6ecbc8faf Fetches and sets default currency for expenses
Ensures the expense form defaults to the LNbits instance's configured currency.

This change retrieves the default currency from the LNbits API and sets it as the initial value in the expense form. If no default is configured, it falls back to the first available currency or EUR.
2025-11-07 21:51:10 +01:00
8dad92f0e5 Enables currency selection for expenses
Allows users to select a currency when adding an expense.

Fetches available currencies from the backend and displays them
in a dropdown menu, defaulting to EUR if the fetch fails.
The expense submission process now includes the selected currency.
2025-11-07 21:39:16 +01:00
9c8b696f06 Updates expense input to EUR
Updates the expense input fields to accept and display amounts in Euros instead of satoshis.

This change ensures that the amount field is configured to handle decimal values with a minimum value of €0.01.
2025-11-07 21:36:22 +01:00
6ecaafb633 Adds permission management components
Implements components for granting and revoking account permissions.

This introduces a `GrantPermissionDialog` for assigning access rights to users,
and a `PermissionManager` component to list and revoke existing permissions.
The UI provides options to view permissions grouped by user or by account.
2025-11-07 17:51:34 +01:00
e745caffaa Updates "account from equity" label
Changes the "Account from equity" label and description
to "Convert to equity contribution" for clarity.

The updated description explains that instead of cash
reimbursement, the expense will increase the user's
equity stake.
2025-11-07 16:36:56 +01:00
00a99995c9 Enhances expense submission with user wallet
Updates expense submission to require a user wallet.

Retrieves wallet information from the authentication context
and includes it in the expense submission request to the backend.
This ensures that expenses are correctly associated with the user's
wallet, enabling accurate tracking and management of expenses.
Also adds error handling and user feedback.
2025-11-07 16:29:50 +01:00
9ed674d0f3 Adds expense tracking module
Adds a new module for tracking user expenses.

The module includes:
- Configuration settings for the LNbits API endpoint and timeouts.
- An ExpensesAPI service for fetching accounts and submitting expense entries.
- A UI component for adding expenses, including account selection and form input.
- Dependency injection for the ExpensesAPI service.

This allows users to submit expense entries with account selection and reference data, which will be linked to their wallet.
2025-11-07 16:21:59 +01:00
678ccff694 Adds dynamic quick actions via modules
Introduces a dynamic quick action system, allowing modules to register actions that appear in a floating action button menu.

This provides a flexible way for modules to extend the application's functionality with common tasks like composing notes or initiating payments.
2025-11-07 16:00:07 +01:00
b286a0315d Updates project documentation
Refines project documentation to reflect recent architectural changes and coding standards.

Adds detailed explanations of the BaseService pattern, module structure, and JavaScript best practices to enhance developer understanding and consistency.

Clarifies CSS styling guidelines, emphasizing semantic classes for theme-aware styling.

Includes critical bug prevention techniques related to JavaScript falsy values and correct usage of the nullish coalescing operator.

Updates build configuration details, environment variable requirements, and mobile browser workaround strategies.
2025-11-07 14:35:38 +01:00
1a38c92db1 Improves task display and spacing
Refines the presentation of scheduled events by adjusting spacing and text displayed when there are no tasks. This enhances visual clarity and provides a more user-friendly experience.
2025-11-06 11:30:42 +01:00
2620c07a23 Removes redundant admin badge
Removes the admin badge from the scheduled event card.

The badge was deemed unnecessary as the information it conveyed
is already implicitly clear.
2025-11-06 11:30:42 +01:00
76b930469d FIX: Show events even if no posts
Moves the "no posts" message to only display when there are no posts and no scheduled events.

Also, ensures "end of feed" message is displayed only when there are posts to show.
2025-11-06 11:30:42 +01:00
0e42318036 Adds date navigation to scheduled events
Implements date navigation for scheduled events, allowing users to view events for different days.

This change replaces the static "Today's Events" section with a dynamic date selector.
It introduces buttons for navigating to the previous and next days, as well as a "Today" button to return to the current date.
A date display shows the selected date, and a message indicates when there are no scheduled events for a given day.
2025-11-06 11:30:42 +01:00
a27a8232f2 Filters one-time events to avoid duplicates
Ensures that one-time events exclude recurring events, preventing duplicate entries.

This resolves an issue where recurring events were incorrectly included in the list of one-time events, leading to events being displayed multiple times.
2025-11-06 11:30:42 +01:00
706ceea84b Enables recurring scheduled event completion
Extends scheduled event completion to support recurring events.

The changes introduce the concept of an "occurrence" for recurring events,
allowing users to mark individual instances of a recurring event as complete.
This involves:
- Adding recurrence information to the ScheduledEvent model.
- Modifying completion logic to handle recurring events with daily/weekly frequencies
- Updating UI to display recurrence information and mark individual occurrences as complete.
2025-11-06 11:30:42 +01:00
8381d43268 Filters and sorts scheduled events
Improves scheduled event retrieval by filtering events
based on user participation and sorting them by start time.

This ensures that users only see events they are participating
in or events that are open to the entire community.
2025-11-06 11:30:42 +01:00
098bff8acc Shows completer name on completed badge
Updates the completed badge to display the name of the user who marked the event as complete.

This provides better context and clarity regarding who triggered the completion status.
2025-11-06 11:30:42 +01:00
62c38185e8 Filters scheduled events by participation
Ensures users only see scheduled events they are participating in or events that are open to everyone.

This change filters the list of today's scheduled events based on the current user's participation.
It only displays events where the user is listed as a participant or events that do not have any participants specified.
2025-11-06 11:30:42 +01:00
9aa8c28bef Replaces custom expand/collapse with Collapsible
Migrates ScheduledEventCard to use the Collapsible component from the UI library.

This simplifies the component's structure and improves accessibility by leveraging the built-in features of the Collapsible component.
Removes custom logic for managing the expanded/collapsed state.
2025-11-06 11:30:42 +01:00
abaf7f2f5b Uses array for completions to improve reactivity
Changes the `allCompletions` computed property to return an array instead of a Map.
This improves reactivity in the component that uses it, as Vue can more efficiently track changes in an array.
Also simplifies the pubkey extraction process.
2025-11-06 11:30:42 +01:00
4bf1da7331 Adds expandable event card
Improves the Scheduled Event Card component by adding an expandable view.

This change introduces a collapsed view that shows the event time and title, and an expanded view which displays all event details. This allows users to quickly scan the scheduled events and expand those they are interested in.
2025-11-06 11:30:42 +01:00
661b700092 Adds support for completable task events
Enables marking scheduled events as complete based on a new "event-type" tag.

This change introduces the concept of "completable" events, specifically for events of type "task". It modifies the ScheduledEventCard component to:

- Display completion information only for completable events
- Show the "Mark Complete" button only for completable events that are not yet completed
- Adjust the opacity and strikethrough styling based on the event's completable and completed status.

The ScheduledEventService is updated to extract the event type from the "event-type" tag.
2025-11-06 11:30:42 +01:00
46418ef6fd Fetches profiles for event authors/completers
Ensures profiles are fetched for authors and completers of scheduled events,
improving user experience by displaying relevant user information.

This is achieved by watching for scheduled events and completions, then
fetching profiles for any new pubkeys encountered.
2025-11-06 11:30:42 +01:00
033113829f Adds confirmation dialog for marking events complete
Improves user experience by adding a confirmation dialog
before marking a scheduled event as complete. This helps
prevent accidental completion of events.
2025-11-06 11:30:42 +01:00
4050b33d0e Enables marking scheduled events as complete
Implements a feature to mark scheduled events as complete, replacing the checkbox with a button for improved UX.

This commit enhances the Scheduled Events functionality by allowing users to mark events as complete. It also includes:

- Replaces the checkbox with a "Mark Complete" button for better usability.
- Adds logging for debugging purposes during event completion toggling.
- Routes completion events (kind 31925) to the ScheduledEventService.
- Optimistically updates the local state after publishing completion events.
2025-11-06 11:30:42 +01:00
9b05bcc238 Adds scheduled events to the feed
Implements NIP-52 scheduled events, allowing users to view and interact with calendar events.

A new `ScheduledEventService` is introduced to manage fetching, storing, and completing scheduled events. A new `ScheduledEventCard` component is introduced for displaying the scheduled events.
2025-11-06 11:30:42 +01:00
b6d8a78cd8 Adds peer dependencies to package-lock.json
Ensures that necessary peer dependencies are correctly installed
when the project is set up, preventing potential runtime errors or
unexpected behavior due to missing dependencies.
2025-11-06 11:30:36 +01:00
38 changed files with 4932 additions and 495 deletions

500
CLAUDE.md
View file

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

57
package-lock.json generated
View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -61,30 +61,40 @@ export function useModularNavigation() {
// Events module items // Events module items
if (appConfig.modules.events.enabled) { if (appConfig.modules.events.enabled) {
items.push({ items.push({
name: 'My Tickets', name: 'My Tickets',
href: '/my-tickets', href: '/my-tickets',
icon: 'Ticket', icon: 'Ticket',
requiresAuth: true requiresAuth: true
}) })
} }
// Market module items // Market module items
if (appConfig.modules.market.enabled) { if (appConfig.modules.market.enabled) {
items.push({ items.push({
name: 'Market Dashboard', name: 'Market Dashboard',
href: '/market-dashboard', href: '/market-dashboard',
icon: 'Store', icon: 'Store',
requiresAuth: true requiresAuth: true
})
}
// Expenses module items
if (appConfig.modules.expenses.enabled) {
items.push({
name: 'My Transactions',
href: '/expenses/transactions',
icon: 'Receipt',
requiresAuth: true
}) })
} }
// Base module items (always available) // Base module items (always available)
items.push({ items.push({
name: 'Relay Hub Status', name: 'Relay Hub Status',
href: '/relay-hub-status', href: '/relay-hub-status',
icon: 'Activity', icon: 'Activity',
requiresAuth: true requiresAuth: true
}) })
return items return items

View file

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

View file

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

View file

@ -1,37 +1,64 @@
import type { App, Component } from 'vue' import type { App, Component } from 'vue'
import type { RouteRecordRaw } from 'vue-router' import type { RouteRecordRaw } from 'vue-router'
// Quick action interface for modular action buttons
export interface QuickAction {
/** Unique action ID */
id: string
/** Display label for the action */
label: string
/** Lucide icon name */
icon: string
/** Component to render when action is selected */
component: Component
/** Display order (lower = higher priority) */
order?: number
/** Action category (e.g., 'compose', 'wallet', 'utilities') */
category?: string
/** Whether action requires authentication */
requiresAuth?: boolean
}
// Base module plugin interface // Base module plugin interface
export interface ModulePlugin { export interface ModulePlugin {
/** Unique module name */ /** Unique module name */
name: string name: string
/** Module version */ /** Module version */
version: string version: string
/** Required dependencies (other module names) */ /** Required dependencies (other module names) */
dependencies?: string[] dependencies?: string[]
/** Module configuration */ /** Module configuration */
config?: Record<string, any> config?: Record<string, any>
/** Install the module */ /** Install the module */
install(app: App, options?: any): Promise<void> | void install(app: App, options?: any): Promise<void> | void
/** Uninstall the module (cleanup) */ /** Uninstall the module (cleanup) */
uninstall?(): Promise<void> | void uninstall?(): Promise<void> | void
/** Routes provided by this module */ /** Routes provided by this module */
routes?: RouteRecordRaw[] routes?: RouteRecordRaw[]
/** Components provided by this module */ /** Components provided by this module */
components?: Record<string, Component> components?: Record<string, Component>
/** Services provided by this module */ /** Services provided by this module */
services?: Record<string, any> services?: Record<string, any>
/** Composables provided by this module */ /** Composables provided by this module */
composables?: Record<string, any> composables?: Record<string, any>
/** Quick actions provided by this module */
quickActions?: QuickAction[]
} }
// Module configuration for app setup // Module configuration for app setup

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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