Compare commits

...

96 commits

Author SHA1 Message Date
Patrick Mulligan
efacc43429 merge atio features into unified main branch
Merge atio's UI improvements (sidebar layout, chat fixes, checkout
simplification, z-index fixes) into cfaun's module system (links,
tasks, expenses).

Conflicts resolved keeping cfaun's versions for:
- Home.vue (multi-action FAB with quick actions)
- di-container.ts, useModularNavigation.ts (module registry)
- nostr-feed/index.ts (disabled, replaced by links)
- package.json/lock (cfaun's dependency tree)

Branding files kept as cfaun's — per-target branding should come
from build-time config, not branches.

Removed: target-ui.md (atio reference doc, no longer needed)

Next: refactor app.config.ts to read enabled modules from VITE_*
env vars so per-target config lives in the deploy flake.
2026-04-25 04:09:20 -04:00
Patrick Mulligan
6e4449ac3d Optimize market images with pict-rs thumbnails
Market components were serving full-resolution images regardless of
display size. Now uses pict-rs on-the-fly processing to serve WebP
thumbnails at appropriate sizes:

- ProductCard: 400px thumbnails (was full-res for 192px cards)
- CartItem: 128px thumbnails (was full-res for 64px display)
- CartSummary: 64px thumbnails (was full-res for 32px display)
- CheckoutPage: 128px thumbnails (was full-res for 64px display)
- StallView logo: 128px thumbnails (was full-res for 56px display)

Adds useImageOptimizer composable wrapping ImageUploadService.

Closes #8

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-27 22:59:59 -04:00
Patrick Mulligan
667b8eebc2 Pin Add Expense dialog to top of viewport
Prevents the dialog from shifting up/down as account search results
change the content height, which caused it to move behind the
virtual keyboard on mobile.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-27 22:01:15 -04:00
Patrick Mulligan
4a89c02dbd Add fuzzy search to expense account selector
Users can now type to search accounts instead of manually navigating
the hierarchy. Uses existing FuzzySearch component with Fuse.js.
Search matches against account names and descriptions. The hierarchical
browser remains available when the search input is empty.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-26 13:04:26 -04:00
Patrick Mulligan
fd5eb9824e Restore multi-action FAB on Home page
The simplified Home.vue lost the useQuickActions system, which meant
module quick actions (expenses, submit link) were no longer accessible.
Restore the expandable FAB that shows all registered module actions.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-26 12:54:39 -04:00
Patrick Mulligan
8176ea9c69 Refactor: extract links and tasks into independent modules
- Create links module (link aggregator from atio branch) with SubmissionService,
  LinkPreviewService, voting, ranking, and full submission UI
- Create tasks module with TaskService (own relay subscription for kinds
  31922/31925), extracted from nostr-feed's ScheduledEventService
- Promote ProfileService and ReactionService to base module as shared services
- Replace Home.vue feed with SubmissionList from links module
- Disable nostr-feed module (kept in codebase for future re-enablement)
- Add links and tasks to app.config, app.ts, navigation, and DI container

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-26 12:32:32 -04:00
c74fd29bb0 update images for atio 2026-01-09 16:30:25 +01:00
651e8c035c Fix NIP-04 decryption for outgoing messages in chat
The processIncomingMessage function was also being called for outgoing
messages from loadRecentMessagesForPeer, but was using the wrong pubkey
for decryption. NIP-04 requires YOUR private key + the OTHER party's
public key.

For outgoing messages, event.pubkey is the user's own pubkey, but it
should use the recipient's pubkey from the p-tag for decryption.

This fix:
- Detects if message is outgoing (event.pubkey === userPubkey)
- For outgoing: gets recipient pubkey from p-tag
- For incoming: uses event.pubkey (sender)
- Sets sent flag correctly based on direction
- Associates message with the correct peer

Fixes "aes/pcks5: wrong padding" console errors.

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-09 16:24:05 +01:00
465dcf988e Add LNbits attribution to sidebar footer
- Add "Powered by LNbits" link to both desktop and mobile sidebars
- Links to lnbits.com in new tab
- Includes separator divider above attribution

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-09 16:24:04 +01:00
32ccd3fa92 Fix profile dropdown width to show full currency display
- Increase dropdown width from w-56 to w-64
- Prevents EverQuest-style coin display from being cut off

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-09 16:24:04 +01:00
73e0cf8567 Comment out unimplemented Settings and Relay Hub Status links
- Comment out Settings link in both desktop and mobile sidebars
- Comment out Relay Hub Status in user menu dropdown
- Add TODO comments to review implementing these in the future
- Comment out unused imports to fix TypeScript errors

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-09 16:24:04 +01:00
db4c330b03 Add styled order message cards in chat
- Create ChatMessageContent component to detect and render order messages
- Display order details in a clean card format instead of raw JSON
- Show item count, shipping zone with truck icon, and short order ID
- Falls back to plain text for non-order messages

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-09 16:24:03 +01:00
a18e0697e4 Fix View My Orders navigation to select orders tab
- Add query parameter support (?tab=orders) to Market Dashboard
- Update CheckoutPage to navigate with tab=orders query param
- Dashboard validates tab param against allowed values

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-09 16:24:02 +01:00
d14902c737 Simplify checkout flow and payment options
- Combine shipping and contact info into single step
- Remove BTC Onchain and Cashu payment options (Lightning only for now)
- Add collapsible section for optional contact info (email, npub, address)
- Keep shipping address visible when required for physical delivery
- Keep message to merchant visible by default
- Add TODO comment for future payment method expansion

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-09 16:24:01 +01:00
4a89652ad8 Fix stall update API to use PUT with full object
- Changed updateStall from PATCH to PUT (LNbits API requirement)
- Updated to send full stall object instead of partial update
- Merges form values with existing stall data before sending

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-09 16:23:34 +01:00
2ee4aafd05 Fix z-index layering for page headers below app top bar
- Changed Home.vue sticky header from z-40 to z-30
- Changed SubmissionDetail.vue sticky header from z-40 to z-30
- AppTopBar remains at z-40, ensuring it stays above page content

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-09 16:23:23 +01:00
4f596b7d97 use aio logo 2026-01-09 16:23:10 +01:00
012073c42f commit target-ui for reference 2026-01-06 23:33:37 +01:00
Patrick Mulligan
02482720fa feat(layout): Replace top navbar with sidebar layout
- Add new sidebar-based layout components:
  - AppLayout.vue: Main layout wrapper with sidebar + topbar
  - AppSidebar.vue: Desktop sidebar with navigation, theme, language
  - AppTopBar.vue: Top bar with notifications, cart, profile
  - MobileDrawer.vue: Slide-out drawer for mobile navigation
- Add Shadcn Sheet component for mobile drawer
- Update App.vue to use new AppLayout (except login page)
- Rename old Navbar.vue to Navbar.old.vue for reference

Layout structure:
- Desktop: Fixed 288px sidebar + offset content area
- Mobile: Hidden sidebar with drawer triggered from top bar
- Cart only shown when market module is enabled
- Combined notifications (chat unread count)

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-06 20:22:55 +01:00
Patrick Mulligan
a98454bfc3 fix(ui): Add top padding to sort tabs
Add spacing between header border and sort tabs for better visual
separation.

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-06 20:22:09 +01:00
Patrick Mulligan
f29646ddd7 fix(ui): Add max-width constraint to feed layout
Constrain feed content to max-w-4xl (896px) and center it, matching
the layout style of sites like programming.dev.

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-06 20:22:09 +01:00
Patrick Mulligan
bc81b44172 fix(nostr-feed): Fix TypeScript errors and remove unused imports
- Change SubmissionWithMeta from interface to type intersection
- Fix UseSubmissionsReturn types to use proper Ref/ComputedRef
- Remove unused imports across components and services

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-06 20:22:09 +01:00
Patrick Mulligan
cdba64d246 feat(nostr-feed): Replace NostrFeed with SubmissionList on Home page
The link aggregator is now the main feed, replacing the old NostrFeed
component. FAB button navigates to /submit for creating posts.

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-06 20:22:09 +01:00
Patrick Mulligan
1904a54131 fix(nostr-feed): Fetch profiles for submission authors
Add watchers to fetch profiles when submissions and comments load,
ensuring display names are shown instead of pubkeys.

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-06 20:22:09 +01:00
Patrick Mulligan
09a1e3b9f6 chore(nostr-feed): Remove link aggregator test page
Remove test page with mock data and test relay connection UI.
The link aggregator is now ready for production use.

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-06 20:22:09 +01:00
Patrick Mulligan
5d44051898 fix(nostr-feed): Use shadcn-vue Select for time range dropdown
Replace native HTML select with themed Select component for proper
dark mode support.

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-06 20:22:09 +01:00
Patrick Mulligan
2c4af6c2b5 fix(nostr-feed): Use theme-aware colors for comment threading lines
Replace hardcoded Tailwind colors with semantic chart-* colors that
adapt to light/dark theme.

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-06 20:22:09 +01:00
Patrick Mulligan
7e5e782915 feat(nostr-feed): Add inline reply forms for comments
- Add inline reply form that appears below the comment being replied to
- Pass replyingToId and isSubmittingReply props through comment tree
- Add cancel-reply and submit-reply events for inline form handling
- Top composer now only shows for new top-level comments
- Better UX: no need to scroll to top to reply to nested comments

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-06 20:22:09 +01:00
Patrick Mulligan
18cbf56a95 fix(nostr-feed): Fix comment threading lines styling
- Change border-* to bg-* classes so threading lines actually display
- Tighten spacing to match Lemmy-style threading (ml-0.5, pl-2, py-1)
- Rename borderColor to depthColor for accuracy

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-06 20:22:09 +01:00
Patrick Mulligan
9f4445f716 feat(nostr-feed): Add submission voting with persistence
- Implement downvote() for submissions using dislikeEvent
- Add refreshAllSubmissionVotes() to update all votes after EOSE
- Refresh submission votes after loading reactions in both subscribe methods
- Fixes vote state not displaying correctly on page load

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-06 20:22:09 +01:00
Patrick Mulligan
7fd310da97 feat(nostr-feed): Add comment voting with persistence
- Add fetchCommentReactions() to load reactions for all comments after EOSE
- Add updateCommentVotes() to refresh comment votes from ReactionService
- Add dislikeEvent() and undislikeEvent() to ReactionService for downvotes
- Update downvoteComment() to use ReactionService for persistence
- Fix vote button styling in SubmissionComment to properly show active state
- Wire up comment vote handlers in SubmissionDetail

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-06 20:22:09 +01:00
Patrick Mulligan
a5e64e40e8 fix(nostr-feed): Fix comment loading and deduplication
- Add subscribeToSubmission() to fetch submission + comments by ID
- Fix isComment check order - check before parseSubmission (which fails for comments)
- Update comment count on submission when comments are added
- Add duplicate comment detection to prevent double-display
- Update useSubmission composable to auto-subscribe on mount

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-06 20:22:09 +01:00
Patrick Mulligan
1fb244a0a1 feat(nostr-feed): Complete Phase 4 - comment sorting and submission
- Add createComment() method to SubmissionService for posting comments
- Add getSortedComments() with sort options: best, new, old, controversial
- Wire up comment submission in SubmissionDetail.vue
- Add comment sort dropdown in detail view
- Support replying to specific comments or root submission
- Display comment submission errors

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-06 20:22:09 +01:00
Patrick Mulligan
ab9bfb8f06 feat(nostr-feed): Add submission composer (Phase 2)
- Create SubmitComposer.vue with post type selector (text/link/media)
- Add debounced link preview fetching on URL input
- Wire up submission publishing via SubmissionService.createSubmission()
- Add /submit route with SubmitPage.vue wrapper
- Support community query param for pre-selection
- Include NSFW toggle and form validation

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-06 20:22:09 +01:00
Patrick Mulligan
73b1f0e357 feat(nostr-feed): Add submission detail view (Phase 4)
- Add SubmissionDetail.vue for full post view with content display
- Add SubmissionComment.vue for recursive threaded comments
- Add SubmissionDetailPage.vue route wrapper
- Add /submission/:id route to module
- Update SubmissionList share URL to use correct route
- Update test page to navigate to detail view on click
- Update plan document with Phase 4 completion

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-06 20:22:09 +01:00
Patrick Mulligan
57d7c5709b feat(nostr-feed): Add test page for link aggregator UI
- Add LinkAggregatorTest.vue with mock data and live submission views
- Include direct relay connection for testing kind 1111 events
- Add submission composer with self/link post types
- Add route at /link-aggregator-test

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-06 20:22:09 +01:00
Patrick Mulligan
2f36296c7f feat(nostr-feed): Add Reddit/Lemmy-style UI components (Phase 2/3)
Implement minimal, information-dense submission feed UI inspired by
old Reddit and Lemmy designs.

New components:
- VoteControls.vue: Compact vertical upvote/downvote arrows with score
- SubmissionThumbnail.vue: Small square thumbnail with fallback icons
- SubmissionRow.vue: Single submission row with title, metadata, actions
- SortTabs.vue: Sort tabs (hot, new, top, controversial) with time range
- SubmissionList.vue: Main container composing all components

UI features:
- Dense layout showing many items at once
- Hover-reveal secondary actions (share, save, hide, report)
- Domain display for link posts
- NSFW blur/badge support
- Rank numbers (optional)
- Time-ago formatting
- Score formatting (e.g., 1.2k)

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

Co-Authored-By: Claude <noreply@anthropic.com>
2026-01-06 20:22:09 +01:00
Patrick Mulligan
8f3d60fe47 feat(nostr-feed): Add link aggregator core data model (Phase 1)
Implement Reddit-style link aggregator foundation using NIP-72 (Moderated
Communities) and NIP-22 (Comments) specifications.

New files:
- types/submission.ts: Complete type definitions for submissions, voting,
  communities, link previews, and ranking algorithms
- services/SubmissionService.ts: Core service for kind 1111 submission
  events with parsing, creation, voting, and comment threading
- services/LinkPreviewService.ts: Open Graph metadata fetching with
  caching, CORS proxy support, and oEmbed fallbacks
- composables/useSubmissions.ts: Vue composable for reactive submission
  state management
- LINK_AGGREGATOR_PLAN.md: Implementation roadmap

Features:
- Three post types: link, media, self (text)
- NIP-22 compliant community-scoped posts
- NIP-92 media attachments with imeta tags
- Hot/Top/New/Controversial ranking algorithms
- Threaded comment support
- Upvote/downvote via NIP-25 reactions

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

Co-Authored-By: Claude <noreply@anthropic.com>
2026-01-06 20:22:09 +01:00
fb36caa0b2 Add shipping zones management to Store Settings
- Added full CRUD for shipping zones in MarketSettings.vue
- Added updateZone and deleteZone API methods to nostrmarketAPI.ts
- Zone form supports name, cost, and countries/regions
- Edit and delete zones with confirmation dialog
- Loading states and proper error handling with toast notifications

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-06 19:04:24 +01:00
4c62daf46c Connect Store Settings to actual store data via API
- Rewrote MarketSettings.vue to use real Stall model fields
- Added updateStall API method to nostrmarketAPI.ts
- Editable fields: name, description, imageUrl
- Read-only fields: currency, shipping zones (set at creation)
- Form validation with Zod schema
- Loading states and proper error handling

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-06 18:59:52 +01:00
39a7dc2096 Remove debug information from OrderHistory component
- Remove debug section from template
- Remove console.log statements from onMounted hook
- Remove isDevelopment computed property

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-06 18:54:45 +01:00
5ee8321029 Remove duplicate toast message on store creation
The CreateStoreDialog already shows a success toast with helpful message.

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-06 18:48:57 +01:00
b7db9bba5a Fetch available currencies from LNbits core API
- Call /api/v1/currencies endpoint on LNbits core (not nostrmarket extension)
- Ensure 'sat' is always first in the currency list and used as default
- Add loading state for currency dropdown
- Add key prop to Select component for proper reactivity

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-06 18:48:05 +01:00
2ac584b1d6 Remove redundant Add Product button and grey out Satisfaction card
- Remove duplicate Add Product button from store header (keep the one in Products tab)
- Grey out Satisfaction card with "Coming Soon" badge (feature not yet implemented)

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-06 18:34:44 +01:00
721b13afc8 Streamline Market Dashboard UI
- Remove Customer Actions and Merchant Actions cards from Overview
- Simplify My Store to single-store management (remove multi-store grid)
- Auto-select user's store when available
- Remove "Back to Stores" navigation (no longer needed)
- Clean up unused imports and functions

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-06 18:30:42 +01:00
925fe17ee8 disable events 2026-01-03 08:54:12 +01:00
7496df5ce2 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>
2026-01-03 08:54:12 +01:00
04f5ad6120 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>
2026-01-03 08:54:12 +01:00
c1b0d2d471 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>
2026-01-03 08:54:12 +01:00
64ddae164b 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>
2026-01-03 08:54:12 +01:00
afa4002d75 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>
2026-01-03 08:54:12 +01:00
c0029b3bc7 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>
2026-01-03 08:54:12 +01:00
4c371bb837 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>
2026-01-03 08:54:12 +01:00
98e4b132f1 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>
2026-01-03 08:54:12 +01:00
93fee0f24a 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>
2026-01-03 08:54:12 +01:00
4c4bf89cc5 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>
2026-01-03 08:54:12 +01:00
9e8319ac07 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>
2026-01-03 08:54:12 +01:00
30cc4426d9 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>
2026-01-03 08:54:12 +01:00
aef417c2fa 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>
2026-01-03 08:54:12 +01:00
a200652a63 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>
2026-01-03 08:54:12 +01:00
b7c07731dc 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>
2026-01-03 08:54:12 +01:00
837eaed7a2 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>
2026-01-03 08:54:12 +01:00
e141d2655f 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).
2026-01-03 08:54:12 +01:00
41cdc8b770 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.
2026-01-03 08:54:12 +01:00
8d50ea1d61 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.
2026-01-03 08:54:12 +01:00
70d462448f 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.
2026-01-03 08:54:12 +01:00
bd698758e1 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.
2026-01-03 08:54:12 +01:00
dd5ba017c5 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.
2026-01-03 08:54:12 +01:00
e60a1d61da 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.
2026-01-03 08:54:12 +01:00
67f4c69c76 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.
2026-01-03 08:54:12 +01:00
edab57c18f 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.
2026-01-03 08:54:12 +01:00
ce82a0b2d3 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.
2026-01-03 08:54:12 +01:00
27bf6fb548 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.
2026-01-03 08:54:12 +01:00
f5066a9c6b 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.
2026-01-03 08:54:12 +01:00
d91dfe21a4 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.
2026-01-03 08:54:12 +01:00
58c6b461f2 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.
2026-01-03 08:54:12 +01:00
df1c552a4b 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.
2026-01-03 08:54:12 +01:00
0f38917a6e 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.
2026-01-03 08:54:12 +01:00
94a582189c 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.
2026-01-03 08:54:12 +01:00
75e818f278 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.
2026-01-03 08:54:12 +01:00
75395ae180 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.
2026-01-03 08:54:12 +01:00
8b7135aa1e 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.
2026-01-03 08:54:12 +01:00
d8eefad7b1 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.
2026-01-03 08:54:12 +01:00
c1dfe465cc 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.
2026-01-03 08:54:12 +01:00
45f92fdb80 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.
2026-01-03 08:54:12 +01:00
84beb657d2 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.
2026-01-03 08:54:12 +01:00
ecb50d8cfe 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.
2026-01-03 08:54:12 +01:00
1d48c325ee 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.
2026-01-03 08:54:12 +01:00
832c336a82 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.
2026-01-03 08:54:12 +01:00
69d6da66c4 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.
2026-01-03 08:54:12 +01:00
2a2c1f71dc 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.
2026-01-03 08:54:12 +01:00
6364a6a6d9 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.
2026-01-03 08:54:12 +01:00
a1f14c896b 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.
2026-01-03 08:54:12 +01:00
a064ceebc2 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.
2026-01-03 08:54:12 +01:00
3e2cd88a09 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.
2026-01-03 08:54:12 +01:00
f81a282ab9 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.
2026-01-03 08:54:12 +01:00
120 changed files with 19482 additions and 1352 deletions

484
CLAUDE.md
View file

@ -26,7 +26,7 @@ This is a modular Vue 3 + TypeScript + Vite application with Electron support, f
The application uses a plugin-based modular architecture with dependency injection for service management: 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)
} }
``` ```
@ -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**
@ -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 --> - ✅ **Use semantic classes** that automatically adapt to light/dark themes
<Input v-model="myValue" /> - ❌ **Never use hard-coded colors** like `bg-white`, `text-gray-500`, `border-blue-500`
<!-- Wrong: Manual validation instead of using meta.valid --> **Preferred Semantic Classes:**
<Button :disabled="!name || !email">Submit</Button> ```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)`
@ -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

49
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",
@ -6050,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",
@ -7596,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",
@ -8360,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"
} }
@ -8896,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",
@ -11710,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",
@ -12172,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",
@ -12353,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"
}, },
@ -13362,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",
@ -13497,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",
@ -13726,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"
@ -13976,6 +13961,7 @@
"integrity": "sha512-+Oxm7q9hDoLMyJOYfUYBuHQo+dkAloi33apOPP56pzj+vsdJDzr+j1NISE5pyaAuKL4A3UD34qd0lx5+kfKp2g==", "integrity": "sha512-+Oxm7q9hDoLMyJOYfUYBuHQo+dkAloi33apOPP56pzj+vsdJDzr+j1NISE5pyaAuKL4A3UD34qd0lx5+kfKp2g==",
"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",
@ -14200,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",
@ -14652,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"
}, },
@ -14909,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",

Binary file not shown.

Before

Width:  |  Height:  |  Size: 38 KiB

After

Width:  |  Height:  |  Size: 33 KiB

Before After
Before After

Binary file not shown.

Before

Width:  |  Height:  |  Size: 15 KiB

After

Width:  |  Height:  |  Size: 15 KiB

Before After
Before After

Binary file not shown.

Before

Width:  |  Height:  |  Size: 43 KiB

After

Width:  |  Height:  |  Size: 37 KiB

Before After
Before After

Binary file not shown.

Before

Width:  |  Height:  |  Size: 313 KiB

After

Width:  |  Height:  |  Size: 224 KiB

Before After
Before After

Binary file not shown.

Before

Width:  |  Height:  |  Size: 23 KiB

After

Width:  |  Height:  |  Size: 21 KiB

Before After
Before After

Binary file not shown.

Before

Width:  |  Height:  |  Size: 151 KiB

After

Width:  |  Height:  |  Size: 115 KiB

Before After
Before After

View file

@ -1,8 +1,7 @@
<script setup lang="ts"> <script setup lang="ts">
import { onMounted, ref, computed, watch } from 'vue' import { onMounted, ref, computed, watch } from 'vue'
import { useRoute } from 'vue-router' import { useRoute } from 'vue-router'
import Navbar from '@/components/layout/Navbar.vue' import AppLayout from '@/components/layout/AppLayout.vue'
import Footer from '@/components/layout/Footer.vue'
import LoginDialog from '@/components/auth/LoginDialog.vue' import LoginDialog from '@/components/auth/LoginDialog.vue'
import { Toaster } from '@/components/ui/sonner' import { Toaster } from '@/components/ui/sonner'
import 'vue-sonner/style.css' import 'vue-sonner/style.css'
@ -20,10 +19,8 @@ useTheme()
// Initialize preloader // Initialize preloader
const marketPreloader = useMarketPreloader() const marketPreloader = useMarketPreloader()
// Relay hub initialization is now handled by the base module // Show layout on all pages except login
const showLayout = computed(() => {
// Hide navbar on login page
const showNavbar = computed(() => {
return route.path !== '/login' return route.path !== '/login'
}) })
@ -62,21 +59,14 @@ watch(() => auth.isAuthenticated.value, async (isAuthenticated) => {
<template> <template>
<div class="min-h-screen bg-background font-sans antialiased"> <div class="min-h-screen bg-background font-sans antialiased">
<div class="relative flex min-h-screen flex-col" <!-- Sidebar layout for authenticated pages -->
style="padding-top: env(safe-area-inset-top); padding-bottom: env(safe-area-inset-bottom)"> <AppLayout v-if="showLayout">
<header
v-if="showNavbar"
class="sticky top-0 z-50 w-full border-b bg-background/95 backdrop-blur supports-[backdrop-filter]:bg-background/60">
<nav class="w-full max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 xl:px-12 2xl:px-16 flex h-14 lg:h-16 xl:h-20 items-center justify-between">
<Navbar />
</nav>
</header>
<main class="flex-1">
<router-view /> <router-view />
</main> </AppLayout>
<Footer v-if="showNavbar" /> <!-- Login page without sidebar -->
<div v-else class="min-h-screen">
<router-view />
</div> </div>
<!-- Toast notifications --> <!-- Toast notifications -->

View file

@ -25,15 +25,34 @@ export const appConfig: AppConfig = {
}, },
'nostr-feed': { 'nostr-feed': {
name: 'nostr-feed', name: 'nostr-feed',
enabled: true, enabled: false, // Disabled - replaced by links module
lazy: false, lazy: false,
config: { config: {
refreshInterval: 30000, // 30 seconds refreshInterval: 30000,
maxPosts: 100, maxPosts: 100,
adminPubkeys: JSON.parse(import.meta.env.VITE_ADMIN_PUBKEYS || '[]'), adminPubkeys: JSON.parse(import.meta.env.VITE_ADMIN_PUBKEYS || '[]'),
feedTypes: ['announcements', 'general'] feedTypes: ['announcements', 'general']
} }
}, },
links: {
name: 'links',
enabled: true,
lazy: false,
config: {
maxSubmissions: 50,
corsProxyUrl: import.meta.env.VITE_CORS_PROXY_URL || '',
adminPubkeys: JSON.parse(import.meta.env.VITE_ADMIN_PUBKEYS || '[]')
}
},
tasks: {
name: 'tasks',
enabled: true,
lazy: false,
config: {
maxTasks: 200,
adminPubkeys: JSON.parse(import.meta.env.VITE_ADMIN_PUBKEYS || '[]')
}
},
market: { market: {
name: 'market', name: 'market',
enabled: true, enabled: true,
@ -64,7 +83,7 @@ export const appConfig: AppConfig = {
}, },
events: { events: {
name: 'events', name: 'events',
enabled: true, enabled: false,
lazy: false, lazy: false,
config: { config: {
apiConfig: { apiConfig: {
@ -93,6 +112,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,9 @@ 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'
import linksModule from './modules/links'
import tasksModule from './modules/tasks'
// Root component // Root component
import App from './App.vue' import App from './App.vue'
@ -43,7 +46,10 @@ export async function createAppInstance() {
...chatModule.routes || [], ...chatModule.routes || [],
...eventsModule.routes || [], ...eventsModule.routes || [],
...marketModule.routes || [], ...marketModule.routes || [],
...walletModule.routes || [] ...walletModule.routes || [],
...expensesModule.routes || [],
...linksModule.routes || [],
...tasksModule.routes || []
].filter(Boolean) ].filter(Boolean)
// Create router with all routes available immediately // Create router with all routes available immediately
@ -126,6 +132,27 @@ export async function createAppInstance() {
) )
} }
// Register expenses module
if (appConfig.modules.expenses?.enabled) {
moduleRegistrations.push(
pluginManager.register(expensesModule, appConfig.modules.expenses)
)
}
// Register links module
if (appConfig.modules.links?.enabled) {
moduleRegistrations.push(
pluginManager.register(linksModule, appConfig.modules.links)
)
}
// Register tasks module
if (appConfig.modules.tasks?.enabled) {
moduleRegistrations.push(
pluginManager.register(tasksModule, appConfig.modules.tasks)
)
}
// Wait for all modules to register // Wait for all modules to register
await Promise.all(moduleRegistrations) await Promise.all(moduleRegistrations)

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.4 MiB

After

Width:  |  Height:  |  Size: 337 KiB

Before After
Before After

View file

@ -0,0 +1,38 @@
<script setup lang="ts">
import { ref } from 'vue'
import AppSidebar from './AppSidebar.vue'
import AppTopBar from './AppTopBar.vue'
import MobileDrawer from './MobileDrawer.vue'
const sidebarOpen = ref(false)
const openSidebar = () => {
sidebarOpen.value = true
}
</script>
<template>
<div class="min-h-screen bg-background">
<!-- Mobile Drawer -->
<MobileDrawer v-model:open="sidebarOpen" />
<!-- Desktop Sidebar (fixed left) -->
<div class="hidden lg:fixed lg:inset-y-0 lg:z-50 lg:flex lg:w-72 lg:flex-col">
<AppSidebar />
</div>
<!-- Main content area (offset on desktop) -->
<div class="lg:pl-72">
<!-- Top Bar -->
<AppTopBar @open-sidebar="openSidebar" />
<!-- Main Content -->
<main
class="flex-1"
style="padding-top: env(safe-area-inset-top); padding-bottom: env(safe-area-inset-bottom)"
>
<slot />
</main>
</div>
</div>
</template>

View file

@ -0,0 +1,149 @@
<script setup lang="ts">
import { useRoute } from 'vue-router'
import { useI18n } from 'vue-i18n'
import { useTheme } from '@/components/theme-provider'
import { Button } from '@/components/ui/button'
import { Separator } from '@/components/ui/separator'
import {
Home,
Calendar,
ShoppingBag,
MessageSquare,
// Settings, // TODO: Uncomment when Settings page is implemented
Sun,
Moon
} from 'lucide-vue-next'
import LanguageSwitcher from '@/components/LanguageSwitcher.vue'
import { useModularNavigation } from '@/composables/useModularNavigation'
const route = useRoute()
const { t } = useI18n()
const { theme, setTheme } = useTheme()
const { navigation } = useModularNavigation()
// Map navigation items to icons
const navIcons: Record<string, any> = {
'/': Home,
'/events': Calendar,
'/market': ShoppingBag,
'/chat': MessageSquare,
}
const toggleTheme = () => {
setTheme(theme.value === 'dark' ? 'light' : 'dark')
}
// Check if route is active
const isActive = (href: string) => {
if (href === '/') {
return route.path === '/'
}
return route.path.startsWith(href)
}
</script>
<template>
<div class="flex grow flex-col gap-y-5 overflow-y-auto border-r border-border bg-background px-6 pb-4">
<!-- Logo -->
<div class="flex h-16 shrink-0 items-center">
<router-link to="/" class="flex items-center gap-2">
<img
src="@/assets/logo.png"
alt="Logo"
class="h-8 w-8"
/>
<span class="font-semibold text-foreground">{{ t('nav.title') }}</span>
</router-link>
</div>
<!-- Navigation -->
<nav class="flex flex-1 flex-col">
<ul role="list" class="flex flex-1 flex-col gap-y-7">
<!-- Primary Navigation -->
<li>
<ul role="list" class="-mx-2 space-y-1">
<li v-for="item in navigation" :key="item.href">
<router-link
:to="item.href"
:class="[
isActive(item.href)
? 'bg-accent text-accent-foreground'
: 'text-muted-foreground hover:bg-accent hover:text-accent-foreground',
'group flex gap-x-3 rounded-md p-2 text-sm font-semibold transition-colors'
]"
>
<component
:is="navIcons[item.href] || Home"
:class="[
isActive(item.href)
? 'text-accent-foreground'
: 'text-muted-foreground group-hover:text-accent-foreground',
'h-5 w-5 shrink-0'
]"
/>
{{ item.name }}
</router-link>
</li>
</ul>
</li>
<!-- Secondary Section (placeholder for future use) -->
<li>
<div class="text-xs font-semibold text-muted-foreground">
<!-- Future: Your stalls, subscriptions, etc. -->
</div>
<ul role="list" class="-mx-2 mt-2 space-y-1">
<!-- Empty for now -->
</ul>
</li>
<!-- Footer: Settings & Preferences -->
<li class="mt-auto space-y-2">
<Separator />
<!-- Theme & Language -->
<div class="flex items-center justify-between px-2 py-1">
<Button
variant="ghost"
size="icon"
@click="toggleTheme"
class="h-8 w-8"
>
<Sun v-if="theme === 'dark'" class="h-4 w-4" />
<Moon v-else class="h-4 w-4" />
</Button>
<LanguageSwitcher />
</div>
<!-- TODO: Review implementing Settings page in the future
<router-link
to="/settings"
:class="[
isActive('/settings')
? 'bg-accent text-accent-foreground'
: 'text-muted-foreground hover:bg-accent hover:text-accent-foreground',
'group -mx-2 flex gap-x-3 rounded-md p-2 text-sm font-semibold transition-colors'
]"
>
<Settings class="h-5 w-5 shrink-0" />
Settings
</router-link>
-->
<!-- LNbits Attribution -->
<Separator class="my-2" />
<div class="text-center">
<a
href="https://lnbits.com"
target="_blank"
rel="noopener noreferrer"
class="text-xs text-muted-foreground hover:text-foreground transition-colors"
>
Powered by LNbits
</a>
</div>
</li>
</ul>
</nav>
</div>
</template>

View file

@ -0,0 +1,242 @@
<script setup lang="ts">
import { computed } from 'vue'
import { useRouter } from 'vue-router'
import { Button } from '@/components/ui/button'
import { Badge } from '@/components/ui/badge'
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from '@/components/ui/dropdown-menu'
import {
Menu,
Bell,
ShoppingCart,
User,
LogOut,
Wallet,
Ticket,
Store,
Activity,
} from 'lucide-vue-next'
import CurrencyDisplay from '@/components/ui/CurrencyDisplay.vue'
import LoginDialog from '@/components/auth/LoginDialog.vue'
import ProfileDialog from '@/components/auth/ProfileDialog.vue'
import { LogoutConfirmDialog } from '@/components/ui/LogoutConfirmDialog'
import { auth } from '@/composables/useAuthService'
import { useModularNavigation } from '@/composables/useModularNavigation'
import { useMarketStore } from '@/modules/market/stores/market'
import { tryInjectService, SERVICE_TOKENS } from '@/core/di-container'
import appConfig from '@/app.config'
import { ref } from 'vue'
const emit = defineEmits<{
(e: 'openSidebar'): void
}>()
const router = useRouter()
const { userMenuItems } = useModularNavigation()
const marketStore = useMarketStore()
const showLoginDialog = ref(false)
const showProfileDialog = ref(false)
const showLogoutConfirm = ref(false)
// Get PaymentService for wallet balance
const paymentService = tryInjectService(SERVICE_TOKENS.PAYMENT_SERVICE) as any
// Get ChatService for notifications
const chatService = tryInjectService(SERVICE_TOKENS.CHAT_SERVICE) as any
// Wallet balance
const totalBalance = computed(() => {
return paymentService?.totalBalance || 0
})
// Combined notifications (chat unread for now)
const totalNotifications = computed(() => {
return chatService?.totalUnreadCount?.value || 0
})
// Cart item count
const cartItemCount = computed(() => {
return marketStore.totalCartItems
})
// Check if market module is enabled
const isMarketEnabled = computed(() => {
return appConfig.modules.market?.enabled ?? false
})
const openLoginDialog = () => {
showLoginDialog.value = true
}
const openProfileDialog = () => {
showProfileDialog.value = true
}
const handleLogout = async () => {
try {
auth.logout()
router.push('/login')
} catch (error) {
console.error('Logout failed:', error)
}
}
const navigateToNotifications = () => {
// For now, navigate to chat since that's our notification source
router.push('/chat')
}
</script>
<template>
<div
class="sticky top-0 z-40 flex h-14 shrink-0 items-center gap-x-4 border-b border-border bg-background/95 backdrop-blur supports-[backdrop-filter]:bg-background/60 px-4 sm:gap-x-6 sm:px-6 lg:px-8"
>
<!-- Mobile menu button -->
<Button
variant="ghost"
size="icon"
class="-m-2.5 p-2.5 lg:hidden"
@click="emit('openSidebar')"
>
<span class="sr-only">Open sidebar</span>
<Menu class="h-5 w-5" />
</Button>
<!-- Separator (mobile only) -->
<div class="h-6 w-px bg-border lg:hidden" aria-hidden="true" />
<!-- Spacer / Future search area -->
<div class="flex flex-1 gap-x-4 self-stretch lg:gap-x-6">
<!-- Search placeholder for future -->
<div class="flex-1">
<!-- Future: Global search input -->
</div>
<!-- Right side actions -->
<div class="flex items-center gap-x-4 lg:gap-x-6">
<!-- Notifications (combined) -->
<Button
v-if="auth.isAuthenticated.value"
variant="ghost"
size="icon"
class="relative"
@click="navigateToNotifications"
>
<span class="sr-only">View notifications</span>
<Bell class="h-5 w-5" />
<Badge
v-if="totalNotifications > 0"
class="absolute -top-1 -right-1 h-4 w-4 text-xs bg-blue-500 text-white border-0 p-0 flex items-center justify-center rounded-full"
>
{{ totalNotifications > 99 ? '99+' : totalNotifications }}
</Badge>
</Button>
<!-- Cart (only when market is enabled) -->
<router-link
v-if="auth.isAuthenticated.value && isMarketEnabled"
to="/cart"
class="relative flex items-center justify-center"
>
<Button variant="ghost" size="icon" class="relative">
<span class="sr-only">Shopping cart</span>
<ShoppingCart class="h-5 w-5" />
<Badge
v-if="cartItemCount > 0"
class="absolute -top-1 -right-1 h-4 w-4 text-xs bg-blue-500 text-white border-0 p-0 flex items-center justify-center rounded-full"
>
{{ cartItemCount > 99 ? '99+' : cartItemCount }}
</Badge>
</Button>
</router-link>
<!-- Separator -->
<div class="hidden lg:block lg:h-6 lg:w-px lg:bg-border" aria-hidden="true" />
<!-- Profile dropdown -->
<div v-if="auth.isAuthenticated.value">
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="ghost" class="relative flex items-center gap-2">
<User class="h-5 w-5" />
<span class="hidden lg:block text-sm font-semibold truncate max-w-32">
{{ auth.userDisplay.value?.name || 'Anonymous' }}
</span>
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" class="w-64">
<!-- Wallet Balance -->
<DropdownMenuItem @click="() => router.push('/wallet')" class="gap-2">
<Wallet class="h-4 w-4 text-muted-foreground" />
<CurrencyDisplay :balance-msat="totalBalance" />
</DropdownMenuItem>
<DropdownMenuSeparator />
<!-- Profile -->
<DropdownMenuItem @click="openProfileDialog" class="gap-2">
<User class="h-4 w-4" />
Profile
</DropdownMenuItem>
<!-- User menu items (from modules) -->
<DropdownMenuItem
v-for="item in userMenuItems"
:key="item.href"
@click="() => router.push(item.href)"
class="gap-2"
>
<component
:is="item.icon === 'Ticket' ? Ticket : item.icon === 'Store' ? Store : Activity"
class="h-4 w-4"
/>
{{ item.name }}
</DropdownMenuItem>
<DropdownMenuSeparator />
<!-- Logout -->
<DropdownMenuItem
@click="showLogoutConfirm = true"
class="gap-2 text-destructive"
>
<LogOut class="h-4 w-4" />
Logout
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</div>
<!-- Login button (when not authenticated) -->
<Button
v-else
variant="outline"
size="sm"
@click="openLoginDialog"
class="gap-2"
>
<User class="h-4 w-4" />
<span class="hidden sm:inline">Login</span>
</Button>
</div>
</div>
<!-- Login Dialog -->
<LoginDialog v-model:is-open="showLoginDialog" />
<!-- Profile Dialog -->
<ProfileDialog v-model:is-open="showProfileDialog" />
<!-- Logout Confirm Dialog -->
<LogoutConfirmDialog
v-model:is-open="showLogoutConfirm"
@confirm="handleLogout"
/>
</div>
</template>

View file

@ -0,0 +1,172 @@
<script setup lang="ts">
import { computed } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { useI18n } from 'vue-i18n'
import { useTheme } from '@/components/theme-provider'
import {
Sheet,
SheetContent,
SheetHeader,
SheetTitle,
} from '@/components/ui/sheet'
import { Button } from '@/components/ui/button'
import { Separator } from '@/components/ui/separator'
import {
Home,
Calendar,
ShoppingBag,
MessageSquare,
// Settings, // TODO: Uncomment when Settings page is implemented
Sun,
Moon,
} from 'lucide-vue-next'
import LanguageSwitcher from '@/components/LanguageSwitcher.vue'
import { useModularNavigation } from '@/composables/useModularNavigation'
const props = defineProps<{
open: boolean
}>()
const emit = defineEmits<{
(e: 'update:open', value: boolean): void
}>()
const route = useRoute()
const router = useRouter()
const { t } = useI18n()
const { theme, setTheme } = useTheme()
const { navigation } = useModularNavigation()
// Map navigation items to icons
const navIcons: Record<string, any> = {
'/': Home,
'/events': Calendar,
'/market': ShoppingBag,
'/chat': MessageSquare,
}
const isOpen = computed({
get: () => props.open,
set: (value) => emit('update:open', value),
})
const toggleTheme = () => {
setTheme(theme.value === 'dark' ? 'light' : 'dark')
}
// Check if route is active
const isActive = (href: string) => {
if (href === '/') {
return route.path === '/'
}
return route.path.startsWith(href)
}
// Navigate and close drawer
const navigateTo = (href: string) => {
router.push(href)
isOpen.value = false
}
</script>
<template>
<Sheet v-model:open="isOpen">
<SheetContent side="left" class="w-72 p-0">
<div class="flex h-full flex-col">
<!-- Header with Logo -->
<SheetHeader class="px-6 py-4 border-b border-border">
<SheetTitle class="flex items-center gap-2">
<img
src="@/assets/logo.png"
alt="Logo"
class="h-8 w-8"
/>
<span class="font-semibold">{{ t('nav.title') }}</span>
</SheetTitle>
</SheetHeader>
<!-- Navigation -->
<nav class="flex-1 overflow-y-auto px-4 py-4">
<ul role="list" class="space-y-1">
<li v-for="item in navigation" :key="item.href">
<button
@click="navigateTo(item.href)"
:class="[
isActive(item.href)
? 'bg-accent text-accent-foreground'
: 'text-muted-foreground hover:bg-accent hover:text-accent-foreground',
'group flex w-full gap-x-3 rounded-md p-2 text-sm font-semibold transition-colors'
]"
>
<component
:is="navIcons[item.href] || Home"
:class="[
isActive(item.href)
? 'text-accent-foreground'
: 'text-muted-foreground group-hover:text-accent-foreground',
'h-5 w-5 shrink-0'
]"
/>
{{ item.name }}
</button>
</li>
</ul>
<!-- Secondary Section Placeholder -->
<div class="mt-8">
<div class="text-xs font-semibold text-muted-foreground px-2">
<!-- Future: Your stalls, subscriptions, etc. -->
</div>
</div>
</nav>
<!-- Footer -->
<div class="border-t border-border px-4 py-4 space-y-2">
<!-- Theme & Language -->
<div class="flex items-center justify-between px-2">
<Button
variant="ghost"
size="icon"
@click="toggleTheme"
class="h-8 w-8"
>
<Sun v-if="theme === 'dark'" class="h-4 w-4" />
<Moon v-else class="h-4 w-4" />
</Button>
<LanguageSwitcher />
</div>
<!-- TODO: Review implementing Settings page in the future
<Separator />
<button
@click="navigateTo('/settings')"
:class="[
isActive('/settings')
? 'bg-accent text-accent-foreground'
: 'text-muted-foreground hover:bg-accent hover:text-accent-foreground',
'group flex w-full gap-x-3 rounded-md p-2 text-sm font-semibold transition-colors'
]"
>
<Settings class="h-5 w-5 shrink-0" />
Settings
</button>
-->
<!-- LNbits Attribution -->
<Separator class="my-2" />
<div class="text-center">
<a
href="https://lnbits.com"
target="_blank"
rel="noopener noreferrer"
class="text-xs text-muted-foreground hover:text-foreground transition-colors"
>
Powered by LNbits
</a>
</div>
</div>
</div>
</SheetContent>
</Sheet>
</template>

View file

@ -1,3 +1,13 @@
<!--
DEPRECATED: This file is kept for reference only and will be deleted.
The top navbar layout has been replaced with a sidebar layout.
See the new layout components:
- AppLayout.vue (main layout wrapper)
- AppSidebar.vue (desktop sidebar)
- AppTopBar.vue (top bar with notifications, cart, profile)
- MobileDrawer.vue (mobile slide-out navigation)
-->
<script setup lang="ts"> <script setup lang="ts">
import { ref, computed } from 'vue' import { ref, computed } from 'vue'
import { useRouter } from 'vue-router' import { useRouter } from 'vue-router'

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

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

View file

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

View file

@ -0,0 +1,53 @@
<script setup lang="ts">
import type { DialogContentEmits, DialogContentProps } from "reka-ui"
import type { HTMLAttributes } from "vue"
import type { SheetVariants } from "."
import { reactiveOmit } from "@vueuse/core"
import { X } from "lucide-vue-next"
import {
DialogClose,
DialogContent,
DialogOverlay,
DialogPortal,
useForwardPropsEmits,
} from "reka-ui"
import { cn } from "@/lib/utils"
import { sheetVariants } from "."
interface SheetContentProps extends DialogContentProps {
class?: HTMLAttributes["class"]
side?: SheetVariants["side"]
}
defineOptions({
inheritAttrs: false,
})
const props = defineProps<SheetContentProps>()
const emits = defineEmits<DialogContentEmits>()
const delegatedProps = reactiveOmit(props, "class", "side")
const forwarded = useForwardPropsEmits(delegatedProps, emits)
</script>
<template>
<DialogPortal>
<DialogOverlay
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"
/>
<DialogContent
:class="cn(sheetVariants({ side }), props.class)"
v-bind="{ ...forwarded, ...$attrs }"
>
<slot />
<DialogClose
class="absolute right-4 top-4 rounded-sm opacity-70 ring-offset-background transition-opacity hover:opacity-100 focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:pointer-events-none data-[state=open]:bg-secondary"
>
<X class="w-4 h-4" />
</DialogClose>
</DialogContent>
</DialogPortal>
</template>

View file

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

View file

@ -0,0 +1,19 @@
<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 { DialogTitleProps } from "reka-ui"
import type { HTMLAttributes } from "vue"
import { reactiveOmit } from "@vueuse/core"
import { DialogTitle } from "reka-ui"
import { cn } from "@/lib/utils"
const props = defineProps<DialogTitleProps & { class?: HTMLAttributes["class"] }>()
const delegatedProps = reactiveOmit(props, "class")
</script>
<template>
<DialogTitle
:class="cn('text-lg font-semibold text-foreground', props.class)"
v-bind="delegatedProps"
>
<slot />
</DialogTitle>
</template>

View file

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

View file

@ -0,0 +1,32 @@
import type { VariantProps } from "class-variance-authority"
import { cva } from "class-variance-authority"
export { default as Sheet } from "./Sheet.vue"
export { default as SheetClose } from "./SheetClose.vue"
export { default as SheetContent } from "./SheetContent.vue"
export { default as SheetDescription } from "./SheetDescription.vue"
export { default as SheetFooter } from "./SheetFooter.vue"
export { default as SheetHeader } from "./SheetHeader.vue"
export { default as SheetTitle } from "./SheetTitle.vue"
export { default as SheetTrigger } from "./SheetTrigger.vue"
export const sheetVariants = cva(
"fixed z-50 gap-4 bg-background p-6 shadow-lg transition ease-in-out data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:duration-300 data-[state=open]:duration-500",
{
variants: {
side: {
top: "inset-x-0 top-0 border-b data-[state=closed]:slide-out-to-top data-[state=open]:slide-in-from-top",
bottom:
"inset-x-0 bottom-0 border-t data-[state=closed]:slide-out-to-bottom data-[state=open]:slide-in-from-bottom",
left: "inset-y-0 left-0 h-full w-3/4 border-r data-[state=closed]:slide-out-to-left data-[state=open]:slide-in-from-left sm:max-w-sm",
right:
"inset-y-0 right-0 h-full w-3/4 border-l data-[state=closed]:slide-out-to-right data-[state=open]:slide-in-from-right sm:max-w-sm",
},
},
defaultVariants: {
side: "right",
},
},
)
export type SheetVariants = VariantProps<typeof sheetVariants>

View file

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

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

@ -137,6 +137,15 @@ export const SERVICE_TOKENS = {
PROFILE_SERVICE: Symbol('profileService'), PROFILE_SERVICE: Symbol('profileService'),
REACTION_SERVICE: Symbol('reactionService'), REACTION_SERVICE: Symbol('reactionService'),
// Tasks services
TASK_SERVICE: Symbol('taskService'),
/** @deprecated Use TASK_SERVICE instead */
SCHEDULED_EVENT_SERVICE: Symbol('scheduledEventService'),
// Links services
SUBMISSION_SERVICE: Symbol('submissionService'),
LINK_PREVIEW_SERVICE: Symbol('linkPreviewService'),
// Nostr metadata services // Nostr metadata services
NOSTR_METADATA_SERVICE: Symbol('nostrMetadataService'), NOSTR_METADATA_SERVICE: Symbol('nostrMetadataService'),
@ -159,6 +168,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,6 +1,30 @@
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 */
@ -32,6 +56,9 @@ export interface ModulePlugin {
/** 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

@ -0,0 +1,43 @@
import { tryInjectService, SERVICE_TOKENS } from '@/core/di-container'
import type { ImageUploadService } from '../services/ImageUploadService'
/**
* Composable for generating optimized image URLs via pict-rs
* Handles both file aliases and full URLs
*/
export function useImageOptimizer() {
const imageService = tryInjectService<ImageUploadService>(SERVICE_TOKENS.IMAGE_UPLOAD_SERVICE)
/**
* Get a thumbnail URL (fast, lower quality - good for cards/lists)
*/
const thumbnail = (url: string | undefined, size = 256): string => {
if (!url) return ''
if (!imageService) return url
return imageService.getThumbnailUrl(url, size)
}
/**
* Get a resized URL (Lanczos2 filter - better quality for larger displays)
*/
const resized = (url: string | undefined, size = 800): string => {
if (!url) return ''
if (!imageService) return url
return imageService.getResizedUrl(url, size)
}
/**
* Get a blurred placeholder URL (for loading states)
*/
const blurred = (url: string | undefined, blur = 5): string => {
if (!url) return ''
if (!imageService) return url
return imageService.getBlurredUrl(url, blur)
}
return {
thumbnail,
resized,
blurred
}
}

View file

@ -0,0 +1,90 @@
import { ref, computed } from 'vue'
import { injectService, SERVICE_TOKENS } from '@/core/di-container'
import type { ProfileService } from '../nostr/ProfileService'
/**
* Composable for managing user profiles
*/
export function useProfiles() {
const profileService = injectService<ProfileService>(SERVICE_TOKENS.PROFILE_SERVICE)
// Reactive state
const isLoading = ref(false)
const error = ref<string | null>(null)
/**
* Get display name for a pubkey
*/
const getDisplayName = (pubkey: string): string => {
if (!profileService) return formatPubkey(pubkey)
return profileService.getDisplayName(pubkey)
}
/**
* Fetch profiles for a list of pubkeys
*/
const fetchProfiles = async (pubkeys: string[]): Promise<void> => {
if (!profileService || pubkeys.length === 0) return
try {
isLoading.value = true
error.value = null
await profileService.fetchProfiles(pubkeys)
} catch (err) {
error.value = err instanceof Error ? err.message : 'Failed to fetch profiles'
console.error('Failed to fetch profiles:', err)
} finally {
isLoading.value = false
}
}
/**
* Subscribe to profile updates for active users
*/
const subscribeToProfileUpdates = async (pubkeys: string[]): Promise<void> => {
if (!profileService) return
try {
await profileService.subscribeToProfileUpdates(pubkeys)
} catch (err) {
console.error('Failed to subscribe to profile updates:', err)
}
}
/**
* Get full profile for a pubkey
*/
const getProfile = async (pubkey: string) => {
if (!profileService) return null
return await profileService.getProfile(pubkey)
}
/**
* Format pubkey as fallback display name
*/
const formatPubkey = (pubkey: string): string => {
return `${pubkey.slice(0, 8)}...${pubkey.slice(-4)}`
}
/**
* Get all cached profiles
*/
const profiles = computed(() => {
if (!profileService) return new Map()
return profileService.profiles
})
return {
// State
isLoading,
error,
profiles,
// Methods
getDisplayName,
fetchProfiles,
subscribeToProfileUpdates,
getProfile,
formatPubkey
}
}

View file

@ -0,0 +1,102 @@
import { computed } from 'vue'
import { injectService, SERVICE_TOKENS } from '@/core/di-container'
import type { ReactionService, EventReactions } from '../nostr/ReactionService'
import { useToast } from '@/core/composables/useToast'
/**
* Composable for managing reactions
*/
export function useReactions() {
const reactionService = injectService<ReactionService>(SERVICE_TOKENS.REACTION_SERVICE)
const toast = useToast()
/**
* Get reactions for a specific event
*/
const getEventReactions = (eventId: string): EventReactions => {
if (!reactionService) {
return {
eventId,
likes: 0,
dislikes: 0,
totalReactions: 0,
userHasLiked: false,
userHasDisliked: false,
reactions: []
}
}
return reactionService.getEventReactions(eventId)
}
/**
* Subscribe to reactions for a list of event IDs
*/
const subscribeToReactions = async (eventIds: string[]): Promise<void> => {
if (!reactionService || eventIds.length === 0) return
try {
await reactionService.subscribeToReactions(eventIds)
} catch (error) {
console.error('Failed to subscribe to reactions:', error)
}
}
/**
* Toggle like on an event - like if not liked, unlike if already liked
*/
const toggleLike = async (eventId: string, eventPubkey: string, eventKind: number): Promise<void> => {
if (!reactionService) {
toast.error('Reaction service not available')
return
}
try {
await reactionService.toggleLikeEvent(eventId, eventPubkey, eventKind)
// Check if we liked or unliked
const eventReactions = reactionService.getEventReactions(eventId)
if (eventReactions.userHasLiked) {
toast.success('Post liked!')
} else {
toast.success('Like removed')
}
} catch (error) {
const message = error instanceof Error ? error.message : 'Failed to toggle reaction'
if (message.includes('authenticated')) {
toast.error('Please sign in to react to posts')
} else if (message.includes('Not connected')) {
toast.error('Not connected to relays')
} else {
toast.error(message)
}
console.error('Failed to toggle like:', error)
}
}
/**
* Get loading state
*/
const isLoading = computed(() => {
return reactionService?.isLoading ?? false
})
/**
* Get all event reactions (for debugging)
*/
const allEventReactions = computed(() => {
return reactionService?.eventReactions ?? new Map()
})
return {
// Methods
getEventReactions,
subscribeToReactions,
toggleLike,
// State
isLoading,
allEventReactions
}
}

View file

@ -3,6 +3,8 @@ import type { ModulePlugin } from '@/core/types'
import { container, SERVICE_TOKENS } from '@/core/di-container' import { container, SERVICE_TOKENS } from '@/core/di-container'
import { relayHub } from './nostr/relay-hub' import { relayHub } from './nostr/relay-hub'
import { NostrMetadataService } from './nostr/nostr-metadata-service' import { NostrMetadataService } from './nostr/nostr-metadata-service'
import { ProfileService } from './nostr/ProfileService'
import { ReactionService } from './nostr/ReactionService'
// Import auth services // Import auth services
import { auth } from './auth/auth-service' import { auth } from './auth/auth-service'
@ -28,6 +30,8 @@ const invoiceService = new InvoiceService()
const lnbitsAPI = new LnbitsAPI() const lnbitsAPI = new LnbitsAPI()
const imageUploadService = new ImageUploadService() const imageUploadService = new ImageUploadService()
const nostrMetadataService = new NostrMetadataService() const nostrMetadataService = new NostrMetadataService()
const profileService = new ProfileService()
const reactionService = new ReactionService()
/** /**
* Base Module Plugin * Base Module Plugin
@ -68,6 +72,10 @@ export const baseModule: ModulePlugin = {
// Register image upload service // Register image upload service
container.provide(SERVICE_TOKENS.IMAGE_UPLOAD_SERVICE, imageUploadService) container.provide(SERVICE_TOKENS.IMAGE_UPLOAD_SERVICE, imageUploadService)
// Register shared Nostr services (used by multiple modules)
container.provide(SERVICE_TOKENS.PROFILE_SERVICE, profileService)
container.provide(SERVICE_TOKENS.REACTION_SERVICE, reactionService)
// Register PWA service // Register PWA service
container.provide('pwaService', pwaService) container.provide('pwaService', pwaService)
@ -106,6 +114,14 @@ export const baseModule: ModulePlugin = {
waitForDependencies: true, // NostrMetadataService depends on AuthService and RelayHub waitForDependencies: true, // NostrMetadataService depends on AuthService and RelayHub
maxRetries: 3 maxRetries: 3
}) })
await profileService.initialize({
waitForDependencies: true, // ProfileService depends on RelayHub
maxRetries: 3
})
await reactionService.initialize({
waitForDependencies: true, // ReactionService depends on RelayHub and AuthService
maxRetries: 3
})
// InvoiceService doesn't need initialization as it's not a BaseService // InvoiceService doesn't need initialization as it's not a BaseService
console.log('✅ Base module installed successfully') console.log('✅ Base module installed successfully')
@ -123,6 +139,8 @@ export const baseModule: ModulePlugin = {
await toastService.dispose() await toastService.dispose()
await imageUploadService.dispose() await imageUploadService.dispose()
await nostrMetadataService.dispose() await nostrMetadataService.dispose()
await profileService.dispose()
await reactionService.dispose()
// InvoiceService doesn't need disposal as it's not a BaseService // InvoiceService doesn't need disposal as it's not a BaseService
await lnbitsAPI.dispose() await lnbitsAPI.dispose()
@ -131,6 +149,8 @@ export const baseModule: ModulePlugin = {
container.remove(SERVICE_TOKENS.INVOICE_SERVICE) container.remove(SERVICE_TOKENS.INVOICE_SERVICE)
container.remove(SERVICE_TOKENS.IMAGE_UPLOAD_SERVICE) container.remove(SERVICE_TOKENS.IMAGE_UPLOAD_SERVICE)
container.remove(SERVICE_TOKENS.NOSTR_METADATA_SERVICE) container.remove(SERVICE_TOKENS.NOSTR_METADATA_SERVICE)
container.remove(SERVICE_TOKENS.PROFILE_SERVICE)
container.remove(SERVICE_TOKENS.REACTION_SERVICE)
console.log('✅ Base module uninstalled') console.log('✅ Base module uninstalled')
}, },
@ -145,7 +165,9 @@ export const baseModule: ModulePlugin = {
invoiceService, invoiceService,
pwaService, pwaService,
imageUploadService, imageUploadService,
nostrMetadataService nostrMetadataService,
profileService,
reactionService
}, },
// No routes - base module is pure infrastructure // No routes - base module is pure infrastructure

View file

@ -0,0 +1,274 @@
import { reactive } from 'vue'
import { BaseService } from '@/core/base/BaseService'
import { injectService, SERVICE_TOKENS } from '@/core/di-container'
import type { Event as NostrEvent, Filter } from 'nostr-tools'
export interface UserProfile {
pubkey: string
name?: string
display_name?: string
about?: string
picture?: string
nip05?: string
updated_at: number
}
export class ProfileService extends BaseService {
protected readonly metadata = {
name: 'ProfileService',
version: '1.0.0',
dependencies: []
}
protected relayHub: any = null
// Profile cache - reactive for UI updates
private _profiles = reactive(new Map<string, UserProfile>())
private currentSubscription: string | null = null
private currentUnsubscribe: (() => void) | null = null
// Track which profiles we've requested to avoid duplicate requests
private requestedProfiles = new Set<string>()
protected async onInitialize(): Promise<void> {
console.log('ProfileService: Starting initialization...')
this.relayHub = injectService(SERVICE_TOKENS.RELAY_HUB)
if (!this.relayHub) {
throw new Error('RelayHub service not available')
}
console.log('ProfileService: Initialization complete')
}
/**
* Get profile for a pubkey, fetching if not cached
*/
async getProfile(pubkey: string): Promise<UserProfile | null> {
// Return cached profile if available
if (this._profiles.has(pubkey)) {
return this._profiles.get(pubkey)!
}
// If not requested yet, fetch it
if (!this.requestedProfiles.has(pubkey)) {
await this.fetchProfile(pubkey)
}
return this._profiles.get(pubkey) || null
}
/**
* Get display name for a pubkey (returns formatted pubkey if no profile)
*/
getDisplayName(pubkey: string): string {
const profile = this._profiles.get(pubkey)
if (profile?.display_name) return profile.display_name
if (profile?.name) return profile.name
// Return formatted pubkey as fallback
return `${pubkey.slice(0, 8)}...${pubkey.slice(-4)}`
}
/**
* Fetch profile for specific pubkey
*/
private async fetchProfile(pubkey: string): Promise<void> {
if (!this.relayHub || this.requestedProfiles.has(pubkey)) {
return
}
this.requestedProfiles.add(pubkey)
try {
if (!this.relayHub.isConnected) {
await this.relayHub.connect()
}
const subscriptionId = `profile-${pubkey}-${Date.now()}`
const filter: Filter = {
kinds: [0], // Profile metadata
authors: [pubkey],
limit: 1
}
console.log(`ProfileService: Fetching profile for ${pubkey.slice(0, 8)}...`)
const unsubscribe = this.relayHub.subscribe({
id: subscriptionId,
filters: [filter],
onEvent: (event: NostrEvent) => {
this.handleProfileEvent(event)
},
onEose: () => {
console.log(`Profile subscription ${subscriptionId} complete`)
// Clean up subscription after getting the profile
if (unsubscribe) {
unsubscribe()
}
}
})
} catch (error) {
console.error(`Failed to fetch profile for ${pubkey}:`, error)
this.requestedProfiles.delete(pubkey) // Allow retry
}
}
/**
* Handle incoming profile event
*/
private handleProfileEvent(event: NostrEvent): void {
try {
const metadata = JSON.parse(event.content)
const profile: UserProfile = {
pubkey: event.pubkey,
name: metadata.name,
display_name: metadata.display_name,
about: metadata.about,
picture: metadata.picture,
nip05: metadata.nip05,
updated_at: event.created_at
}
// Only update if this is newer than what we have
const existing = this._profiles.get(event.pubkey)
if (!existing || event.created_at > existing.updated_at) {
this._profiles.set(event.pubkey, profile)
console.log(`ProfileService: Updated profile for ${event.pubkey.slice(0, 8)}...`, profile.display_name || profile.name)
}
} catch (error) {
console.error('Failed to parse profile metadata:', error)
}
}
/**
* Bulk fetch profiles for multiple pubkeys
*/
async fetchProfiles(pubkeys: string[]): Promise<void> {
const unfetchedPubkeys = pubkeys.filter(pubkey =>
!this._profiles.has(pubkey) && !this.requestedProfiles.has(pubkey)
)
if (unfetchedPubkeys.length === 0) return
console.log(`ProfileService: Bulk fetching ${unfetchedPubkeys.length} profiles`)
try {
if (!this.relayHub?.isConnected) {
await this.relayHub?.connect()
}
const subscriptionId = `profiles-bulk-${Date.now()}`
// Mark all as requested
unfetchedPubkeys.forEach(pubkey => this.requestedProfiles.add(pubkey))
const filter: Filter = {
kinds: [0],
authors: unfetchedPubkeys,
limit: unfetchedPubkeys.length
}
const unsubscribe = this.relayHub.subscribe({
id: subscriptionId,
filters: [filter],
onEvent: (event: NostrEvent) => {
this.handleProfileEvent(event)
},
onEose: () => {
console.log(`Bulk profile subscription ${subscriptionId} complete`)
if (unsubscribe) {
unsubscribe()
}
}
})
} catch (error) {
console.error('Failed to bulk fetch profiles:', error)
// Remove from requested so they can be retried
unfetchedPubkeys.forEach(pubkey => this.requestedProfiles.delete(pubkey))
}
}
/**
* Subscribe to real-time profile updates for active users
*/
async subscribeToProfileUpdates(pubkeys: string[]): Promise<void> {
if (this.currentSubscription) {
await this.unsubscribeFromProfiles()
}
if (pubkeys.length === 0) return
try {
if (!this.relayHub?.isConnected) {
await this.relayHub?.connect()
}
const subscriptionId = `profile-updates-${Date.now()}`
const filter: Filter = {
kinds: [0],
authors: pubkeys
}
console.log(`ProfileService: Subscribing to profile updates for ${pubkeys.length} users`)
const unsubscribe = this.relayHub.subscribe({
id: subscriptionId,
filters: [filter],
onEvent: (event: NostrEvent) => {
this.handleProfileEvent(event)
},
onEose: () => {
console.log(`Profile updates subscription ${subscriptionId} ready`)
}
})
this.currentSubscription = subscriptionId
this.currentUnsubscribe = unsubscribe
} catch (error) {
console.error('Failed to subscribe to profile updates:', error)
}
}
/**
* Unsubscribe from profile updates
*/
async unsubscribeFromProfiles(): Promise<void> {
if (this.currentUnsubscribe) {
this.currentUnsubscribe()
this.currentSubscription = null
this.currentUnsubscribe = null
}
}
/**
* Clear profile cache
*/
clearCache(): void {
this._profiles.clear()
this.requestedProfiles.clear()
}
/**
* Get all cached profiles
*/
get profiles(): Map<string, UserProfile> {
return this._profiles
}
/**
* Cleanup
*/
protected async onDestroy(): Promise<void> {
await this.unsubscribeFromProfiles()
this.clearCache()
}
}

View file

@ -0,0 +1,581 @@
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 Reaction {
id: string
eventId: string // The event being reacted to
pubkey: string // Who reacted
content: string // The reaction content ('+', '-', emoji)
created_at: number
}
export interface EventReactions {
eventId: string
likes: number
dislikes: number
totalReactions: number
userHasLiked: boolean
userHasDisliked: boolean
userReactionId?: string // Track the user's reaction ID for deletion
reactions: Reaction[]
}
export class ReactionService extends BaseService {
protected readonly metadata = {
name: 'ReactionService',
version: '1.0.0',
dependencies: []
}
protected relayHub: any = null
protected authService: any = null
// Reaction state - indexed by event ID
private _eventReactions = reactive(new Map<string, EventReactions>())
private _isLoading = ref(false)
// Track reaction subscription
private currentSubscription: string | null = null
private currentUnsubscribe: (() => void) | null = null
// Track which events we're monitoring
private monitoredEvents = new Set<string>()
// Track deleted reactions to hide them
private deletedReactions = new Set<string>()
protected async onInitialize(): Promise<void> {
console.log('ReactionService: 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('ReactionService: Initialization complete')
}
/**
* Get reactions for a specific event
*/
getEventReactions(eventId: string): EventReactions {
if (!this._eventReactions.has(eventId)) {
this._eventReactions.set(eventId, {
eventId,
likes: 0,
dislikes: 0,
totalReactions: 0,
userHasLiked: false,
userHasDisliked: false,
reactions: []
})
}
return this._eventReactions.get(eventId)!
}
/**
* Subscribe to reactions for a list of event IDs
*/
async subscribeToReactions(eventIds: string[]): Promise<void> {
if (eventIds.length === 0) return
// Filter out events we're already monitoring
const newEventIds = eventIds.filter(id => !this.monitoredEvents.has(id))
if (newEventIds.length === 0) return
console.log(`ReactionService: Subscribing to reactions for ${newEventIds.length} events`)
try {
if (!this.relayHub?.isConnected) {
await this.relayHub?.connect()
}
// Add to monitored set
newEventIds.forEach(id => this.monitoredEvents.add(id))
const subscriptionId = `reactions-${Date.now()}`
// Subscribe to reactions (kind 7) for these events
const filters = [
{
kinds: [7], // Reactions
'#e': newEventIds, // Events being reacted to
limit: 1000
}
]
const unsubscribe = this.relayHub.subscribe({
id: subscriptionId,
filters: filters,
onEvent: (event: NostrEvent) => {
this.handleReactionEvent(event)
},
onEose: () => {
console.log(`ReactionService: Subscription ${subscriptionId} ready`)
}
})
// Store subscription info (we can have multiple)
if (!this.currentSubscription) {
this.currentSubscription = subscriptionId
this.currentUnsubscribe = unsubscribe
}
} catch (error) {
console.error('Failed to subscribe to reactions:', error)
}
}
/**
* Handle incoming reaction event
*/
public handleReactionEvent(event: NostrEvent): void {
try {
// Find the event being reacted to
const eTag = event.tags.find(tag => tag[0] === 'e')
if (!eTag || !eTag[1]) {
console.warn('Reaction event missing e tag:', event.id)
return
}
const eventId = eTag[1]
const content = event.content.trim()
// Create reaction object
const reaction: Reaction = {
id: event.id,
eventId,
pubkey: event.pubkey,
content,
created_at: event.created_at
}
// Update event reactions
const eventReactions = this.getEventReactions(eventId)
// Check if this reaction already exists (deduplication) or is deleted
const existingIndex = eventReactions.reactions.findIndex(r => r.id === reaction.id)
if (existingIndex >= 0) {
return // Already have this reaction
}
// Check if this reaction has been deleted
if (this.deletedReactions.has(reaction.id)) {
return // This reaction was deleted
}
// IMPORTANT: Remove any previous reaction from the same user
// This ensures one reaction per user per event, even if deletion events aren't processed
const previousReactionIndex = eventReactions.reactions.findIndex(r =>
r.pubkey === reaction.pubkey &&
r.content === reaction.content
)
if (previousReactionIndex >= 0) {
// Replace the old reaction with the new one
eventReactions.reactions[previousReactionIndex] = reaction
} else {
// Add as new reaction
eventReactions.reactions.push(reaction)
}
// Recalculate counts and user state
this.recalculateEventReactions(eventId)
} catch (error) {
console.error('Failed to handle reaction event:', error)
}
}
/**
* Handle deletion event (called when a kind 5 event with k=7 is received)
*/
public handleDeletionEvent(event: NostrEvent): void {
try {
// Process each deleted event
const eTags = event.tags.filter(tag => tag[0] === 'e')
const deletionAuthor = event.pubkey
for (const eTag of eTags) {
const deletedEventId = eTag[1]
if (deletedEventId) {
// Add to deleted set
this.deletedReactions.add(deletedEventId)
// Find and remove the reaction from all event reactions
for (const [eventId, eventReactions] of this._eventReactions) {
const reactionIndex = eventReactions.reactions.findIndex(r => r.id === deletedEventId)
if (reactionIndex >= 0) {
const reaction = eventReactions.reactions[reactionIndex]
// IMPORTANT: Only process deletion if it's from the same user who created the reaction
// This follows NIP-09 spec: "Relays SHOULD delete or stop publishing any referenced events
// that have an identical `pubkey` as the deletion request"
if (reaction.pubkey === deletionAuthor) {
eventReactions.reactions.splice(reactionIndex, 1)
// Recalculate counts for this event
this.recalculateEventReactions(eventId)
}
}
}
}
}
} catch (error) {
console.error('Failed to handle deletion event:', error)
}
}
/**
* Recalculate reaction counts and user state for an event
*/
private recalculateEventReactions(eventId: string): void {
const eventReactions = this.getEventReactions(eventId)
const userPubkey = this.authService?.user?.value?.pubkey
// Use Sets to track unique users who liked/disliked
const likedUsers = new Set<string>()
const dislikedUsers = new Set<string>()
let userHasLiked = false
let userHasDisliked = false
let userReactionId: string | undefined
// Group reactions by user, keeping only the most recent
const latestReactionsByUser = new Map<string, Reaction>()
for (const reaction of eventReactions.reactions) {
// Skip deleted reactions
if (this.deletedReactions.has(reaction.id)) {
continue
}
// Keep only the latest reaction from each user
const existing = latestReactionsByUser.get(reaction.pubkey)
if (!existing || reaction.created_at > existing.created_at) {
latestReactionsByUser.set(reaction.pubkey, reaction)
}
}
// Now count unique reactions
for (const reaction of latestReactionsByUser.values()) {
const isLike = reaction.content === '+' || reaction.content === '❤️' || reaction.content === ''
const isDislike = reaction.content === '-'
if (isLike) {
likedUsers.add(reaction.pubkey)
if (userPubkey && reaction.pubkey === userPubkey) {
userHasLiked = true
userReactionId = reaction.id
}
} else if (isDislike) {
dislikedUsers.add(reaction.pubkey)
if (userPubkey && reaction.pubkey === userPubkey) {
userHasDisliked = true
userReactionId = reaction.id
}
}
}
// Update the reactive state with unique user counts
eventReactions.likes = likedUsers.size
eventReactions.dislikes = dislikedUsers.size
eventReactions.totalReactions = latestReactionsByUser.size
eventReactions.userHasLiked = userHasLiked
eventReactions.userHasDisliked = userHasDisliked
eventReactions.userReactionId = userReactionId
}
/**
* Send a heart reaction (like) to an event
*/
async likeEvent(eventId: string, eventPubkey: string, eventKind: number): Promise<void> {
if (!this.authService?.isAuthenticated?.value) {
throw new Error('Must be authenticated to react')
}
if (!this.relayHub?.isConnected) {
throw new Error('Not connected to relays')
}
const userPubkey = this.authService.user.value?.pubkey
const userPrivkey = this.authService.user.value?.prvkey
if (!userPubkey || !userPrivkey) {
throw new Error('User keys not available')
}
// Check if user already liked this event
const eventReactions = this.getEventReactions(eventId)
if (eventReactions.userHasLiked) {
throw new Error('Already liked this event')
}
try {
this._isLoading.value = true
// Create reaction event template according to NIP-25
const eventTemplate: EventTemplate = {
kind: 7, // Reaction
content: '+', // Like reaction
tags: [
['e', eventId, '', eventPubkey], // Event being reacted to
['p', eventPubkey], // Author of the event being reacted to
['k', eventKind.toString()] // Kind of the event being reacted to
],
created_at: Math.floor(Date.now() / 1000)
}
// Sign the event
const privkeyBytes = this.hexToUint8Array(userPrivkey)
const signedEvent = finalizeEvent(eventTemplate, privkeyBytes)
// Publish the reaction
await this.relayHub.publishEvent(signedEvent)
// Optimistically update local state
this.handleReactionEvent(signedEvent)
} catch (error) {
console.error('Failed to like event:', error)
throw error
} finally {
this._isLoading.value = false
}
}
/**
* Send a dislike reaction to an event
*/
async dislikeEvent(eventId: string, eventPubkey: string, eventKind: number): Promise<void> {
if (!this.authService?.isAuthenticated?.value) {
throw new Error('Must be authenticated to react')
}
if (!this.relayHub?.isConnected) {
throw new Error('Not connected to relays')
}
const userPubkey = this.authService.user.value?.pubkey
const userPrivkey = this.authService.user.value?.prvkey
if (!userPubkey || !userPrivkey) {
throw new Error('User keys not available')
}
const eventReactions = this.getEventReactions(eventId)
if (eventReactions.userHasDisliked) {
throw new Error('Already disliked this event')
}
try {
this._isLoading.value = true
const eventTemplate: EventTemplate = {
kind: 7,
content: '-', // Dislike reaction
tags: [
['e', eventId, '', eventPubkey],
['p', eventPubkey],
['k', eventKind.toString()]
],
created_at: Math.floor(Date.now() / 1000)
}
const privkeyBytes = this.hexToUint8Array(userPrivkey)
const signedEvent = finalizeEvent(eventTemplate, privkeyBytes)
await this.relayHub.publishEvent(signedEvent)
this.handleReactionEvent(signedEvent)
} catch (error) {
console.error('Failed to dislike event:', error)
throw error
} finally {
this._isLoading.value = false
}
}
/**
* Remove a like from an event (unlike) using NIP-09 deletion events
*/
async unlikeEvent(eventId: string): Promise<void> {
if (!this.authService?.isAuthenticated?.value) {
throw new Error('Must be authenticated to remove reaction')
}
if (!this.relayHub?.isConnected) {
throw new Error('Not connected to relays')
}
const userPubkey = this.authService.user.value?.pubkey
const userPrivkey = this.authService.user.value?.prvkey
if (!userPubkey || !userPrivkey) {
throw new Error('User keys not available')
}
// Get the user's reaction ID to delete
const eventReactions = this.getEventReactions(eventId)
if (!eventReactions.userHasLiked || !eventReactions.userReactionId) {
throw new Error('No reaction to remove')
}
try {
this._isLoading.value = true
// Create deletion event according to NIP-09
const eventTemplate: EventTemplate = {
kind: 5, // Deletion request
content: '', // Empty content or reason
tags: [
['e', eventReactions.userReactionId], // The reaction event to delete
['k', '7'] // Kind of event being deleted (reaction)
],
created_at: Math.floor(Date.now() / 1000)
}
// Sign the event
const privkeyBytes = this.hexToUint8Array(userPrivkey)
const signedEvent = finalizeEvent(eventTemplate, privkeyBytes)
// Publish the deletion
const result = await this.relayHub.publishEvent(signedEvent)
console.log(`ReactionService: Deletion published to ${result.success}/${result.total} relays`)
// Optimistically update local state
this.handleDeletionEvent(signedEvent)
} catch (error) {
console.error('Failed to unlike event:', error)
throw error
} finally {
this._isLoading.value = false
}
}
/**
* Remove a dislike from an event using NIP-09 deletion events
*/
async undislikeEvent(eventId: string): Promise<void> {
if (!this.authService?.isAuthenticated?.value) {
throw new Error('Must be authenticated to remove reaction')
}
if (!this.relayHub?.isConnected) {
throw new Error('Not connected to relays')
}
const userPubkey = this.authService.user.value?.pubkey
const userPrivkey = this.authService.user.value?.prvkey
if (!userPubkey || !userPrivkey) {
throw new Error('User keys not available')
}
const eventReactions = this.getEventReactions(eventId)
if (!eventReactions.userHasDisliked || !eventReactions.userReactionId) {
throw new Error('No dislike to remove')
}
try {
this._isLoading.value = true
const eventTemplate: EventTemplate = {
kind: 5,
content: '',
tags: [
['e', eventReactions.userReactionId],
['k', '7']
],
created_at: Math.floor(Date.now() / 1000)
}
const privkeyBytes = this.hexToUint8Array(userPrivkey)
const signedEvent = finalizeEvent(eventTemplate, privkeyBytes)
const result = await this.relayHub.publishEvent(signedEvent)
console.log(`ReactionService: Dislike deletion published to ${result.success}/${result.total} relays`)
this.handleDeletionEvent(signedEvent)
} catch (error) {
console.error('Failed to undislike event:', error)
throw error
} finally {
this._isLoading.value = false
}
}
/**
* Toggle like on an event - like if not liked, unlike if already liked
*/
async toggleLikeEvent(eventId: string, eventPubkey: string, eventKind: number): Promise<void> {
const eventReactions = this.getEventReactions(eventId)
if (eventReactions.userHasLiked) {
// Unlike the event
await this.unlikeEvent(eventId)
} else {
// Like the event
await this.likeEvent(eventId, eventPubkey, eventKind)
}
}
/**
* Toggle dislike on an event
*/
async toggleDislikeEvent(eventId: string, eventPubkey: string, eventKind: number): Promise<void> {
const eventReactions = this.getEventReactions(eventId)
if (eventReactions.userHasDisliked) {
await this.undislikeEvent(eventId)
} else {
await this.dislikeEvent(eventId, eventPubkey, eventKind)
}
}
/**
* 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 event reactions
*/
get eventReactions(): Map<string, EventReactions> {
return this._eventReactions
}
/**
* Check if currently loading
*/
get isLoading(): boolean {
return this._isLoading.value
}
/**
* Cleanup
*/
protected async onDestroy(): Promise<void> {
if (this.currentUnsubscribe) {
this.currentUnsubscribe()
}
this._eventReactions.clear()
this.monitoredEvents.clear()
this.deletedReactions.clear()
}
}

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

@ -158,7 +158,7 @@
: 'bg-muted' : 'bg-muted'
]" ]"
> >
<p class="text-sm">{{ message.content }}</p> <ChatMessageContent :content="message.content" />
<p class="text-xs opacity-70 mt-1"> <p class="text-xs opacity-70 mt-1">
{{ formatTime(message.created_at) }} {{ formatTime(message.created_at) }}
</p> </p>
@ -325,7 +325,7 @@
: 'bg-muted' : 'bg-muted'
]" ]"
> >
<p class="text-sm">{{ message.content }}</p> <ChatMessageContent :content="message.content" />
<p class="text-xs opacity-70 mt-1"> <p class="text-xs opacity-70 mt-1">
{{ formatTime(message.created_at) }} {{ formatTime(message.created_at) }}
</p> </p>
@ -376,6 +376,7 @@ import { Badge } from '@/components/ui/badge'
import { ScrollArea } from '@/components/ui/scroll-area' import { ScrollArea } from '@/components/ui/scroll-area'
import { Avatar, AvatarImage, AvatarFallback } from '@/components/ui/avatar' import { Avatar, AvatarImage, AvatarFallback } from '@/components/ui/avatar'
import { useChat } from '../composables/useChat' import { useChat } from '../composables/useChat'
import ChatMessageContent from './ChatMessageContent.vue'
import { useFuzzySearch } from '@/composables/useFuzzySearch' import { useFuzzySearch } from '@/composables/useFuzzySearch'

View file

@ -0,0 +1,115 @@
<template>
<!-- Order Message -->
<div v-if="parsedOrder" class="min-w-[200px]">
<div class="flex items-center gap-2 font-semibold text-sm mb-3">
<ShoppingBag class="w-4 h-4" />
<span>Order Placed</span>
</div>
<div class="text-xs space-y-2">
<!-- Items -->
<div v-if="parsedOrder.items?.length" class="space-y-1">
<div
v-for="(item, index) in parsedOrder.items"
:key="index"
class="flex items-center gap-2"
>
<span class="opacity-70">{{ item.quantity }}x</span>
<span>Item</span>
</div>
</div>
<!-- Divider -->
<div class="border-t border-current opacity-20 my-2" />
<!-- Shipping -->
<div v-if="shippingLabel" class="flex items-center gap-2">
<Truck class="w-3 h-3 opacity-70" />
<span>{{ shippingLabel }}</span>
</div>
<!-- Order Reference -->
<div class="opacity-60 text-[10px] font-mono mt-2">
#{{ shortOrderId }}
</div>
</div>
</div>
<!-- Regular Text Message -->
<p v-else class="text-sm whitespace-pre-wrap break-words">{{ content }}</p>
</template>
<script setup lang="ts">
import { computed } from 'vue'
import { ShoppingBag, Truck } from 'lucide-vue-next'
interface OrderItem {
product_id: string
quantity: number
}
interface OrderContact {
name?: string
email?: string
}
interface ParsedOrder {
type: number
id: string
items?: OrderItem[]
contact?: OrderContact
shipping_id?: string
}
const props = defineProps<{
content: string
}>()
// Try to parse the content as an order message
const parsedOrder = computed<ParsedOrder | null>(() => {
try {
// Check if content looks like JSON
const trimmed = props.content.trim()
if (!trimmed.startsWith('{') || !trimmed.endsWith('}')) {
return null
}
const parsed = JSON.parse(trimmed)
// Validate it's an order message (has type and id fields)
if (typeof parsed.type === 'number' && typeof parsed.id === 'string' && parsed.id.startsWith('order_')) {
return parsed as ParsedOrder
}
return null
} catch {
return null
}
})
// Format shipping label
const shippingLabel = computed(() => {
if (!parsedOrder.value?.shipping_id) return null
const id = parsedOrder.value.shipping_id
// Extract zone name if it follows the pattern "zonename-hash"
if (id.includes('-')) {
const zoneName = id.split('-')[0]
// Capitalize first letter
return zoneName.charAt(0).toUpperCase() + zoneName.slice(1)
}
return 'Standard'
})
// Short order ID for display
const shortOrderId = computed(() => {
if (!parsedOrder.value?.id) return ''
// Extract the unique part from "order_timestamp_randomstring"
const parts = parsedOrder.value.id.split('_')
if (parts.length >= 3) {
return parts[2].slice(0, 8) // Just the random part
}
return parsedOrder.value.id.slice(-8)
})
</script>

View file

@ -702,7 +702,8 @@ export class ChatService extends BaseService {
} }
} }
/** /**
* Process an incoming message event * Process a message event (incoming or outgoing)
* Note: This is called for both directions from loadRecentMessagesForPeer
*/ */
private async processIncomingMessage(event: any): Promise<void> { private async processIncomingMessage(event: any): Promise<void> {
try { try {
@ -715,10 +716,29 @@ export class ChatService extends BaseService {
console.warn('Cannot process message: user not authenticated') console.warn('Cannot process message: user not authenticated')
return return
} }
// Get sender pubkey from event
const senderPubkey = event.pubkey // Determine if this is an outgoing message (sent by us)
// Decrypt the message content const isOutgoing = event.pubkey === userPubkey
const decryptedContent = await nip04.decrypt(userPrivkey, senderPubkey, event.content)
// For NIP-04 decryption, we need the OTHER party's pubkey
// - For incoming messages: sender is the other party (event.pubkey)
// - For outgoing messages: recipient is the other party (from p-tag)
let otherPartyPubkey: string
if (isOutgoing) {
// Outgoing: get recipient from p-tag
const pTag = event.tags.find((tag: string[]) => tag[0] === 'p')
if (!pTag || !pTag[1]) {
console.warn('Cannot process outgoing message: no recipient p-tag')
return
}
otherPartyPubkey = pTag[1]
} else {
// Incoming: sender is the other party
otherPartyPubkey = event.pubkey
}
// Decrypt the message content using the other party's pubkey
const decryptedContent = await nip04.decrypt(userPrivkey, otherPartyPubkey, event.content)
// Check if this is a market-related message // Check if this is a market-related message
let isMarketMessage = false let isMarketMessage = false
try { try {
@ -764,13 +784,13 @@ export class ChatService extends BaseService {
id: event.id, id: event.id,
content: displayContent, content: displayContent,
created_at: event.created_at, created_at: event.created_at,
sent: false, sent: isOutgoing,
pubkey: senderPubkey pubkey: event.pubkey
} }
// Ensure we have a peer record for the sender // Ensure we have a peer record for the other party (the peer we're chatting with)
this.addPeer(senderPubkey) this.addPeer(otherPartyPubkey)
// Add the message // Add the message to the peer's conversation
this.addMessage(senderPubkey, message) this.addMessage(otherPartyPubkey, message)
} }
} catch (error) { } catch (error) {
console.error('Failed to process incoming message:', error) console.error('Failed to process incoming message:', error)

View file

@ -0,0 +1,378 @@
<template>
<div class="space-y-4">
<!-- Fuzzy search input -->
<FuzzySearch
:data="allLeafAccounts"
:options="searchOptions"
placeholder="Search accounts..."
:show-result-count="false"
@results="handleSearchResults"
@search="handleSearchQuery"
/>
<!-- Search results mode -->
<div v-if="isSearching" class="border border-border rounded-lg bg-card">
<div v-if="searchResults.length > 0" class="divide-y divide-border">
<button
v-for="account in searchResults"
:key="account.id"
@click="selectAccount(account)"
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">
<FileText class="h-5 w-5 text-muted-foreground" />
<div>
<p class="font-medium text-foreground">
{{ getAccountDisplayName(account.name) }}
</p>
<p class="text-xs text-muted-foreground">
{{ formatAccountPath(account.name) }}
</p>
<p
v-if="account.description"
class="text-sm text-muted-foreground"
>
{{ account.description }}
</p>
</div>
</div>
<Badge variant="outline" class="text-xs">
{{ account.account_type }}
</Badge>
</button>
</div>
<div v-else class="py-8 text-center text-sm text-muted-foreground">
No matching accounts
</div>
</div>
<!-- Browse mode (when not searching) -->
<template v-else>
<!-- 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>
</template>
<!-- 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 { FuzzySearch } from '@/components/ui/fuzzy-search'
import type { FuzzySearchOptions } from '@/composables/useFuzzySearch'
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)
// Search state
const isSearching = ref(false)
const searchResults = ref<Account[]>([])
// Fuzzy search config
const searchOptions: FuzzySearchOptions<Account> = {
fuseOptions: {
keys: [
{ name: 'name', weight: 0.7 },
{ name: 'description', weight: 0.3 }
],
threshold: 0.4,
ignoreLocation: true,
minMatchCharLength: 1
},
resultLimit: 20,
minSearchLength: 1,
matchAllWhenSearchEmpty: false
}
// Flatten hierarchy to get all selectable (leaf) accounts
const allLeafAccounts = computed(() => {
const accounts: Account[] = []
const traverse = (nodes: AccountNode[]) => {
for (const node of nodes) {
if (!node.account.has_children) {
accounts.push(node.account)
}
if (node.children.length > 0) {
traverse(node.children)
}
}
}
traverse(accountHierarchy.value)
return accounts
})
// 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
*/
function getAccountDisplayName(fullName: string): string {
const parts = fullName.split(':')
return parts[parts.length - 1]
}
/**
* Format full account path for display
*/
function formatAccountPath(fullName: string): string {
return fullName.split(':').join(' > ')
}
/**
* Handle search query changes
*/
function handleSearchQuery(query: string) {
isSearching.value = query.length > 0
}
/**
* Handle search results from FuzzySearch
*/
function handleSearchResults(results: Account[]) {
searchResults.value = results
}
/**
* Select an account directly (from search results)
*/
function selectAccount(account: Account) {
selectedAccount.value = account
emit('update:modelValue', account)
emit('account-selected', account)
}
/**
* Load accounts from API
*/
async function loadAccounts() {
isLoading.value = true
error.value = null
try {
const wallet = user.value?.wallets?.[0]
if (!wallet || !wallet.inkey) {
throw new Error('No wallet available. Please log in.')
}
accountHierarchy.value = await expensesAPI.getAccountHierarchy(
wallet.inkey,
props.rootAccount,
true
)
} 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 in browse mode
*/
function selectNode(node: AccountNode) {
if (node.account.has_children) {
selectedPath.value.push(node)
selectedAccount.value = null
emit('update:modelValue', null)
} else {
selectAccount(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)
}
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] overflow-hidden flex flex-col p-0 gap-0 top-[5%] translate-y-0 sm:top-[5%] sm:translate-y-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

@ -0,0 +1,107 @@
<script setup lang="ts">
/**
* SortTabs - Sort/filter tabs for submission list
* Minimal tab row like old Reddit: hot | new | top | controversial
*/
import { computed } from 'vue'
import { Flame, Clock, TrendingUp, Swords } from 'lucide-vue-next'
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue
} from '@/components/ui/select'
import type { SortType, TimeRange } from '../types/submission'
interface Props {
currentSort: SortType
currentTimeRange?: TimeRange
showTimeRange?: boolean
}
interface Emits {
(e: 'update:sort', sort: SortType): void
(e: 'update:timeRange', range: TimeRange): void
}
const props = withDefaults(defineProps<Props>(), {
currentTimeRange: 'day',
showTimeRange: false
})
const emit = defineEmits<Emits>()
const sortOptions: { id: SortType; label: string; icon: any }[] = [
{ id: 'hot', label: 'hot', icon: Flame },
{ id: 'new', label: 'new', icon: Clock },
{ id: 'top', label: 'top', icon: TrendingUp },
{ id: 'controversial', label: 'controversial', icon: Swords }
]
const timeRangeOptions: { id: TimeRange; label: string }[] = [
{ id: 'hour', label: 'hour' },
{ id: 'day', label: 'day' },
{ id: 'week', label: 'week' },
{ id: 'month', label: 'month' },
{ id: 'year', label: 'year' },
{ id: 'all', label: 'all time' }
]
// Show time range dropdown when top is selected
const showTimeDropdown = computed(() =>
props.showTimeRange && props.currentSort === 'top'
)
function selectSort(sort: SortType) {
emit('update:sort', sort)
}
function selectTimeRange(range: TimeRange) {
emit('update:timeRange', range)
}
</script>
<template>
<div class="flex items-center gap-1 text-sm border-b border-border pt-3 pb-2 mb-2">
<!-- Sort tabs -->
<template v-for="option in sortOptions" :key="option.id">
<button
type="button"
:class="[
'px-2 py-1 rounded transition-colors flex items-center gap-1',
currentSort === option.id
? 'bg-accent text-foreground font-medium'
: 'text-muted-foreground hover:text-foreground hover:bg-accent/50'
]"
@click="selectSort(option.id)"
>
<component :is="option.icon" class="h-3.5 w-3.5" />
<span>{{ option.label }}</span>
</button>
</template>
<!-- Time range dropdown (for top) -->
<template v-if="showTimeDropdown">
<span class="text-muted-foreground mx-1">·</span>
<Select
:model-value="currentTimeRange"
@update:model-value="selectTimeRange($event as TimeRange)"
>
<SelectTrigger class="h-auto w-auto gap-1 border-0 bg-transparent px-1 py-0.5 text-sm text-muted-foreground shadow-none hover:text-foreground focus:ring-0">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem
v-for="range in timeRangeOptions"
:key="range.id"
:value="range.id"
>
{{ range.label }}
</SelectItem>
</SelectContent>
</Select>
</template>
</div>
</template>

View file

@ -0,0 +1,275 @@
<script setup lang="ts">
/**
* SubmissionComment - Recursive comment component for submission threads
* Displays a single comment with vote controls and nested replies
*/
import { computed, ref } from 'vue'
import { formatDistanceToNow } from 'date-fns'
import { ChevronUp, ChevronDown, Reply, Flag, MoreHorizontal, Send } from 'lucide-vue-next'
import type { SubmissionComment as CommentType } from '../types/submission'
interface Props {
comment: CommentType
depth: number
collapsedComments: Set<string>
getDisplayName: (pubkey: string) => string
isAuthenticated: boolean
currentUserPubkey?: string | null
replyingToId?: string | null
isSubmittingReply?: boolean
}
interface Emits {
(e: 'toggle-collapse', commentId: string): void
(e: 'reply', comment: CommentType): void
(e: 'cancel-reply'): void
(e: 'submit-reply', commentId: string, text: string): void
(e: 'upvote', comment: CommentType): void
(e: 'downvote', comment: CommentType): void
}
const props = withDefaults(defineProps<Props>(), {
replyingToId: null,
isSubmittingReply: false
})
const emit = defineEmits<Emits>()
// Local reply text
const replyText = ref('')
// Is this comment being replied to
const isBeingRepliedTo = computed(() => props.replyingToId === props.comment.id)
// Handle reply click
function onReplyClick() {
emit('reply', props.comment)
}
// Submit the reply
function submitReply() {
if (!replyText.value.trim()) return
emit('submit-reply', props.comment.id, replyText.value.trim())
replyText.value = ''
}
// Cancel reply
function cancelReply() {
replyText.value = ''
emit('cancel-reply')
}
// Is this comment collapsed
const isCollapsed = computed(() => props.collapsedComments.has(props.comment.id))
// Has replies
const hasReplies = computed(() => props.comment.replies && props.comment.replies.length > 0)
// Count total nested replies
const replyCount = computed(() => {
const count = (c: CommentType): number => {
let total = c.replies?.length || 0
c.replies?.forEach(r => { total += count(r) })
return total
}
return count(props.comment)
})
// Format time
function formatTime(timestamp: number): string {
return formatDistanceToNow(timestamp * 1000, { addSuffix: true })
}
// Depth colors for threading lines (using theme-aware chart colors)
const depthColors = [
'bg-chart-1',
'bg-chart-2',
'bg-chart-3',
'bg-chart-4',
'bg-chart-5'
]
const depthColor = computed(() => depthColors[props.depth % depthColors.length])
// Is own comment
const isOwnComment = computed(() =>
props.currentUserPubkey && props.currentUserPubkey === props.comment.pubkey
)
</script>
<template>
<div
:class="[
'relative',
depth > 0 ? 'ml-0.5' : ''
]"
>
<!-- Threading line -->
<div
v-if="depth > 0"
:class="[
'absolute left-0 top-0 bottom-0 w-0.5',
depthColor,
'hover:w-1 transition-all cursor-pointer'
]"
@click="emit('toggle-collapse', comment.id)"
/>
<!-- Comment content -->
<div
:class="[
'py-1',
depth > 0 ? 'pl-2' : '',
'hover:bg-accent/20 transition-colors'
]"
>
<!-- Header row -->
<div class="flex items-center gap-2 text-xs">
<!-- Collapse toggle -->
<button
v-if="hasReplies"
class="text-muted-foreground hover:text-foreground"
@click="emit('toggle-collapse', comment.id)"
>
<ChevronDown v-if="!isCollapsed" class="h-3.5 w-3.5" />
<ChevronUp v-else class="h-3.5 w-3.5" />
</button>
<div v-else class="w-3.5" />
<!-- Author -->
<span class="font-medium hover:underline cursor-pointer">
{{ getDisplayName(comment.pubkey) }}
</span>
<!-- Score -->
<span class="text-muted-foreground">
{{ comment.votes.score }} {{ comment.votes.score === 1 ? 'point' : 'points' }}
</span>
<!-- Time -->
<span class="text-muted-foreground">
{{ formatTime(comment.created_at) }}
</span>
<!-- Collapsed indicator -->
<span v-if="isCollapsed && hasReplies" class="text-muted-foreground">
({{ replyCount }} {{ replyCount === 1 ? 'child' : 'children' }})
</span>
</div>
<!-- Content (hidden when collapsed) -->
<div v-if="!isCollapsed">
<!-- Comment body -->
<div class="mt-1 text-sm whitespace-pre-wrap leading-relaxed pl-5">
{{ comment.content }}
</div>
<!-- Actions -->
<div class="flex items-center gap-1 mt-1 pl-5">
<!-- Vote buttons (inline style) -->
<button
:class="[
'p-1 transition-colors',
comment.votes.userVote === 'upvote'
? 'text-orange-500'
: 'text-muted-foreground hover:text-orange-500'
]"
:disabled="!isAuthenticated"
@click="emit('upvote', comment)"
>
<ChevronUp class="h-4 w-4" />
</button>
<button
:class="[
'p-1 transition-colors',
comment.votes.userVote === 'downvote'
? 'text-blue-500'
: 'text-muted-foreground hover:text-blue-500'
]"
:disabled="!isAuthenticated"
@click="emit('downvote', comment)"
>
<ChevronDown class="h-4 w-4" />
</button>
<!-- Reply -->
<button
class="flex items-center gap-1 px-2 py-1 text-xs text-muted-foreground hover:text-foreground hover:bg-accent rounded transition-colors"
:disabled="!isAuthenticated"
@click="onReplyClick"
>
<Reply class="h-3 w-3" />
<span>reply</span>
</button>
<!-- Report (not for own comments) -->
<button
v-if="!isOwnComment"
class="flex items-center gap-1 px-2 py-1 text-xs text-muted-foreground hover:text-foreground hover:bg-accent rounded transition-colors"
>
<Flag class="h-3 w-3" />
<span>report</span>
</button>
<!-- More options -->
<button
class="p-1 text-muted-foreground hover:text-foreground hover:bg-accent rounded transition-colors"
>
<MoreHorizontal class="h-3 w-3" />
</button>
</div>
<!-- Inline reply form -->
<div v-if="isBeingRepliedTo" class="mt-2 pl-5">
<div class="border rounded-lg bg-background p-2">
<textarea
v-model="replyText"
placeholder="Write a reply..."
rows="3"
class="w-full px-2 py-1.5 text-sm bg-transparent resize-none focus:outline-none"
:disabled="isSubmittingReply"
/>
<div class="flex items-center justify-end gap-2 mt-1">
<button
class="px-3 py-1 text-xs text-muted-foreground hover:text-foreground transition-colors"
@click="cancelReply"
>
Cancel
</button>
<button
class="flex items-center gap-1 px-3 py-1 text-xs bg-primary text-primary-foreground rounded hover:bg-primary/90 transition-colors disabled:opacity-50"
:disabled="!replyText.trim() || isSubmittingReply"
@click="submitReply"
>
<Send class="h-3 w-3" />
Reply
</button>
</div>
</div>
</div>
<!-- Nested replies -->
<div v-if="hasReplies" class="mt-1">
<SubmissionComment
v-for="reply in comment.replies"
:key="reply.id"
:comment="reply"
:depth="depth + 1"
:collapsed-comments="collapsedComments"
:get-display-name="getDisplayName"
:is-authenticated="isAuthenticated"
:current-user-pubkey="currentUserPubkey"
:replying-to-id="replyingToId"
:is-submitting-reply="isSubmittingReply"
@toggle-collapse="emit('toggle-collapse', $event)"
@reply="emit('reply', $event)"
@cancel-reply="emit('cancel-reply')"
@submit-reply="(commentId, text) => emit('submit-reply', commentId, text)"
@upvote="emit('upvote', $event)"
@downvote="emit('downvote', $event)"
/>
</div>
</div>
</div>
</div>
</template>

View file

@ -0,0 +1,553 @@
<script setup lang="ts">
/**
* SubmissionDetail - Full post view with comments
* Displays complete submission content and threaded comments
*/
import { ref, computed, watch } from 'vue'
import { useRouter } from 'vue-router'
import { formatDistanceToNow } from 'date-fns'
import {
ArrowLeft,
MessageSquare,
Share2,
Bookmark,
Flag,
ExternalLink,
Image as ImageIcon,
FileText,
Loader2,
Send
} from 'lucide-vue-next'
import { Badge } from '@/components/ui/badge'
import { Button } from '@/components/ui/button'
import VoteControls from './VoteControls.vue'
import SubmissionCommentComponent from './SubmissionComment.vue'
import { useSubmission } from '../composables/useSubmissions'
import { tryInjectService, SERVICE_TOKENS } from '@/core/di-container'
import type { ProfileService } from '@/modules/base/nostr/ProfileService'
import type { SubmissionService } from '../services/SubmissionService'
import type {
SubmissionComment as SubmissionCommentType,
LinkSubmission,
MediaSubmission,
SelfSubmission
} from '../types/submission'
interface Props {
/** Submission ID to display */
submissionId: string
}
const props = defineProps<Props>()
const router = useRouter()
// Comment sort options
type CommentSort = 'best' | 'new' | 'old' | 'controversial'
// Inject services
const profileService = tryInjectService<ProfileService>(SERVICE_TOKENS.PROFILE_SERVICE)
const authService = tryInjectService<any>(SERVICE_TOKENS.AUTH_SERVICE)
const submissionService = tryInjectService<SubmissionService>(SERVICE_TOKENS.SUBMISSION_SERVICE)
// Use submission composable - handles subscription automatically
const { submission, comments, upvote, downvote, isLoading, error } = useSubmission(props.submissionId)
// Comment composer state
const showComposer = ref(false)
const replyingTo = ref<{ id: string; author: string } | null>(null)
const commentText = ref('')
const isSubmittingComment = ref(false)
const commentError = ref<string | null>(null)
// Comment sorting state
const commentSort = ref<CommentSort>('best')
// Collapsed comments state
const collapsedComments = ref(new Set<string>())
// Sorted comments
const sortedComments = computed(() => {
if (submissionService) {
return submissionService.getSortedComments(props.submissionId, commentSort.value)
}
return comments.value
})
// Auth state
const isAuthenticated = computed(() => authService?.isAuthenticated?.value || false)
const currentUserPubkey = computed(() => authService?.user?.value?.pubkey || null)
// Get display name for a pubkey
function getDisplayName(pubkey: string): string {
if (profileService) {
return profileService.getDisplayName(pubkey)
}
return `${pubkey.slice(0, 8)}...`
}
// Format timestamp
function formatTime(timestamp: number): string {
return formatDistanceToNow(timestamp * 1000, { addSuffix: true })
}
// Extract domain from URL
function extractDomain(url: string): string {
try {
return new URL(url).hostname.replace('www.', '')
} catch {
return url
}
}
// Cast submission to specific type
const linkSubmission = computed(() =>
submission.value?.postType === 'link' ? submission.value as LinkSubmission : null
)
const mediaSubmission = computed(() =>
submission.value?.postType === 'media' ? submission.value as MediaSubmission : null
)
const selfSubmission = computed(() =>
submission.value?.postType === 'self' ? submission.value as SelfSubmission : null
)
// Community name
const communityName = computed(() => {
if (!submission.value?.communityRef) return null
const parts = submission.value.communityRef.split(':')
return parts.length >= 3 ? parts.slice(2).join(':') : null
})
// Handle voting
async function onUpvote() {
if (!isAuthenticated.value) return
try {
await upvote()
} catch (err) {
console.error('Failed to upvote:', err)
}
}
async function onDownvote() {
if (!isAuthenticated.value) return
try {
await downvote()
} catch (err) {
console.error('Failed to downvote:', err)
}
}
// Handle comment voting
async function onCommentUpvote(comment: SubmissionCommentType) {
if (!isAuthenticated.value || !submissionService) return
try {
await submissionService.upvoteComment(props.submissionId, comment.id)
} catch (err) {
console.error('Failed to upvote comment:', err)
}
}
async function onCommentDownvote(comment: SubmissionCommentType) {
if (!isAuthenticated.value || !submissionService) return
try {
await submissionService.downvoteComment(props.submissionId, comment.id)
} catch (err) {
console.error('Failed to downvote comment:', err)
}
}
// Handle share
function onShare() {
const url = window.location.href
navigator.clipboard?.writeText(url)
// TODO: Show toast
}
// Computed for passing to comment components
const replyingToId = computed(() => replyingTo.value?.id || null)
// Handle comment reply - for inline replies to comments
function startReply(comment?: SubmissionCommentType) {
if (comment) {
// Replying to a comment - show inline form (handled by SubmissionComment)
replyingTo.value = { id: comment.id, author: getDisplayName(comment.pubkey) }
showComposer.value = false // Hide top composer
} else {
// Top-level comment - show top composer
replyingTo.value = null
showComposer.value = true
}
commentText.value = ''
}
function cancelReply() {
showComposer.value = false
replyingTo.value = null
commentText.value = ''
}
// Submit top-level comment (from top composer)
async function submitComment() {
if (!commentText.value.trim() || !isAuthenticated.value || !submissionService) return
isSubmittingComment.value = true
commentError.value = null
try {
await submissionService.createComment(
props.submissionId,
commentText.value.trim(),
undefined // Top-level comment
)
cancelReply()
} catch (err: any) {
console.error('Failed to submit comment:', err)
commentError.value = err.message || 'Failed to post comment'
} finally {
isSubmittingComment.value = false
}
}
// Submit inline reply (from SubmissionComment's inline form)
async function submitReply(commentId: string, text: string) {
if (!text.trim() || !isAuthenticated.value || !submissionService) return
isSubmittingComment.value = true
commentError.value = null
try {
await submissionService.createComment(
props.submissionId,
text.trim(),
commentId
)
cancelReply()
} catch (err: any) {
console.error('Failed to submit reply:', err)
commentError.value = err.message || 'Failed to post reply'
} finally {
isSubmittingComment.value = false
}
}
// Toggle comment collapse
function toggleCollapse(commentId: string) {
if (collapsedComments.value.has(commentId)) {
collapsedComments.value.delete(commentId)
} else {
collapsedComments.value.add(commentId)
}
// Trigger reactivity
collapsedComments.value = new Set(collapsedComments.value)
}
// Go back
function goBack() {
router.back()
}
// Helper to collect all pubkeys from comments recursively
function collectCommentPubkeys(comments: SubmissionCommentType[]): string[] {
const pubkeys: string[] = []
for (const comment of comments) {
pubkeys.push(comment.pubkey)
if (comment.replies?.length) {
pubkeys.push(...collectCommentPubkeys(comment.replies))
}
}
return pubkeys
}
// Fetch profiles when submission loads
watch(submission, (sub) => {
if (profileService && sub) {
profileService.fetchProfiles([sub.pubkey])
}
}, { immediate: true })
// Fetch profiles when comments load
watch(comments, (newComments) => {
if (profileService && newComments.length > 0) {
const pubkeys = [...new Set(collectCommentPubkeys(newComments))]
profileService.fetchProfiles(pubkeys)
}
}, { immediate: true })
</script>
<template>
<div class="min-h-screen bg-background">
<!-- Header -->
<header class="sticky top-0 z-30 bg-background/95 backdrop-blur border-b">
<div class="max-w-4xl mx-auto px-4 py-3">
<div class="flex items-center gap-3">
<Button variant="ghost" size="sm" @click="goBack" class="h-8 w-8 p-0">
<ArrowLeft class="h-4 w-4" />
</Button>
<div class="flex-1 min-w-0">
<h1 class="text-sm font-medium truncate">
{{ submission?.title || 'Loading...' }}
</h1>
<p v-if="communityName" class="text-xs text-muted-foreground">
{{ communityName }}
</p>
</div>
</div>
</div>
</header>
<!-- Loading state -->
<div v-if="isLoading && !submission" class="flex items-center justify-center py-16">
<Loader2 class="h-6 w-6 animate-spin text-muted-foreground" />
<span class="ml-2 text-sm text-muted-foreground">Loading submission...</span>
</div>
<!-- Error state -->
<div v-else-if="error" class="max-w-4xl mx-auto p-4">
<div class="text-center py-8">
<p class="text-sm text-destructive">{{ error }}</p>
<Button variant="outline" size="sm" class="mt-4" @click="goBack">
Go Back
</Button>
</div>
</div>
<!-- Submission content -->
<main v-else-if="submission" class="max-w-4xl mx-auto">
<article class="p-4 border-b">
<!-- Post header with votes -->
<div class="flex gap-3">
<!-- Vote controls -->
<VoteControls
:score="submission.votes.score"
:user-vote="submission.votes.userVote"
:disabled="!isAuthenticated"
@upvote="onUpvote"
@downvote="onDownvote"
/>
<!-- Content -->
<div class="flex-1 min-w-0">
<!-- Title -->
<h1 class="text-xl font-semibold leading-tight mb-2">
{{ submission.title }}
</h1>
<!-- Badges -->
<div v-if="submission.nsfw || submission.flair" class="flex items-center gap-2 mb-2">
<Badge v-if="submission.nsfw" variant="destructive" class="text-xs">
NSFW
</Badge>
<Badge v-if="submission.flair" variant="secondary" class="text-xs">
{{ submission.flair }}
</Badge>
</div>
<!-- Metadata -->
<div class="text-sm text-muted-foreground mb-4">
<span>submitted {{ formatTime(submission.created_at) }}</span>
<span> by </span>
<span class="font-medium hover:underline cursor-pointer">
{{ getDisplayName(submission.pubkey) }}
</span>
<template v-if="communityName">
<span> to </span>
<span class="font-medium hover:underline cursor-pointer">{{ communityName }}</span>
</template>
</div>
<!-- Link post content -->
<div v-if="linkSubmission" class="mb-4">
<a
:href="linkSubmission.url"
target="_blank"
rel="noopener noreferrer"
class="block p-4 border rounded-lg hover:bg-accent/30 transition-colors group"
>
<div class="flex items-start gap-4">
<!-- Preview image -->
<div
v-if="linkSubmission.preview?.image"
class="flex-shrink-0 w-32 h-24 rounded overflow-hidden bg-muted"
>
<img
:src="linkSubmission.preview.image"
:alt="linkSubmission.preview.title || ''"
class="w-full h-full object-cover"
/>
</div>
<div v-else class="flex-shrink-0 w-16 h-16 rounded bg-muted flex items-center justify-center">
<ExternalLink class="h-6 w-6 text-muted-foreground" />
</div>
<!-- Preview content -->
<div class="flex-1 min-w-0">
<div class="flex items-center gap-2 text-xs text-muted-foreground mb-1">
<ExternalLink class="h-3 w-3" />
<span>{{ extractDomain(linkSubmission.url) }}</span>
</div>
<h3 v-if="linkSubmission.preview?.title" class="font-medium text-sm group-hover:underline">
{{ linkSubmission.preview.title }}
</h3>
<p v-if="linkSubmission.preview?.description" class="text-xs text-muted-foreground mt-1 line-clamp-2">
{{ linkSubmission.preview.description }}
</p>
</div>
</div>
</a>
<!-- Optional body -->
<div v-if="linkSubmission.body" class="mt-4 text-sm whitespace-pre-wrap">
{{ linkSubmission.body }}
</div>
</div>
<!-- Media post content -->
<div v-if="mediaSubmission" class="mb-4">
<div class="rounded-lg overflow-hidden bg-muted">
<img
v-if="mediaSubmission.media.mimeType?.startsWith('image/')"
:src="mediaSubmission.media.url"
:alt="mediaSubmission.media.alt || ''"
class="max-w-full max-h-[600px] mx-auto"
/>
<video
v-else-if="mediaSubmission.media.mimeType?.startsWith('video/')"
:src="mediaSubmission.media.url"
controls
class="max-w-full max-h-[600px] mx-auto"
/>
<div v-else class="p-8 flex flex-col items-center justify-center">
<ImageIcon class="h-12 w-12 text-muted-foreground mb-2" />
<a
:href="mediaSubmission.media.url"
target="_blank"
class="text-sm text-primary hover:underline"
>
View media
</a>
</div>
</div>
<!-- Caption -->
<div v-if="mediaSubmission.body" class="mt-4 text-sm whitespace-pre-wrap">
{{ mediaSubmission.body }}
</div>
</div>
<!-- Self post content -->
<div v-if="selfSubmission" class="mb-4">
<div class="text-sm whitespace-pre-wrap leading-relaxed">
{{ selfSubmission.body }}
</div>
</div>
<!-- Actions -->
<div class="flex items-center gap-4 text-sm text-muted-foreground">
<button
class="flex items-center gap-1.5 hover:text-foreground transition-colors"
@click="startReply()"
>
<MessageSquare class="h-4 w-4" />
<span>{{ submission.commentCount }} comments</span>
</button>
<button
class="flex items-center gap-1.5 hover:text-foreground transition-colors"
@click="onShare"
>
<Share2 class="h-4 w-4" />
<span>share</span>
</button>
<button class="flex items-center gap-1.5 hover:text-foreground transition-colors">
<Bookmark class="h-4 w-4" />
<span>save</span>
</button>
<button class="flex items-center gap-1.5 hover:text-foreground transition-colors">
<Flag class="h-4 w-4" />
<span>report</span>
</button>
</div>
</div>
</div>
</article>
<!-- Top-level comment composer (only for new comments, not replies) -->
<div v-if="isAuthenticated && !replyingTo" class="p-4 border-b">
<div class="flex gap-2">
<textarea
v-model="commentText"
placeholder="Write a comment..."
rows="3"
class="flex-1 px-3 py-2 text-sm border rounded-lg bg-background resize-none focus:outline-none focus:ring-2 focus:ring-primary"
/>
</div>
<!-- Comment error -->
<div v-if="commentError" class="mt-2 text-sm text-destructive">
{{ commentError }}
</div>
<div class="flex justify-end mt-2">
<Button
size="sm"
:disabled="!commentText.trim() || isSubmittingComment"
@click="submitComment"
>
<Loader2 v-if="isSubmittingComment" class="h-4 w-4 animate-spin mr-2" />
<Send v-else class="h-4 w-4 mr-2" />
Comment
</Button>
</div>
</div>
<!-- Comments section -->
<section class="divide-y divide-border">
<!-- Comment sort selector -->
<div v-if="sortedComments.length > 0" class="px-4 py-3 border-b flex items-center gap-2">
<span class="text-sm text-muted-foreground">Sort by:</span>
<select
v-model="commentSort"
class="text-sm bg-background border rounded px-2 py-1 focus:outline-none focus:ring-2 focus:ring-primary"
>
<option value="best">Best</option>
<option value="new">New</option>
<option value="old">Old</option>
<option value="controversial">Controversial</option>
</select>
</div>
<div v-if="sortedComments.length === 0" class="p-8 text-center text-sm text-muted-foreground">
No comments yet. Be the first to comment!
</div>
<!-- Recursive comment rendering -->
<template v-for="comment in sortedComments" :key="comment.id">
<SubmissionCommentComponent
:comment="comment"
:depth="0"
:collapsed-comments="collapsedComments"
:get-display-name="getDisplayName"
:is-authenticated="isAuthenticated"
:current-user-pubkey="currentUserPubkey"
:replying-to-id="replyingToId"
:is-submitting-reply="isSubmittingComment"
@toggle-collapse="toggleCollapse"
@reply="startReply"
@cancel-reply="cancelReply"
@submit-reply="submitReply"
@upvote="onCommentUpvote"
@downvote="onCommentDownvote"
/>
</template>
</section>
</main>
<!-- Not found state -->
<div v-else class="max-w-4xl mx-auto p-4">
<div class="text-center py-16">
<FileText class="h-12 w-12 text-muted-foreground mx-auto mb-4" />
<p class="text-sm text-muted-foreground">Submission not found</p>
<Button variant="outline" size="sm" class="mt-4" @click="goBack">
Go Back
</Button>
</div>
</div>
</div>
</template>

View file

@ -0,0 +1,235 @@
<script setup lang="ts">
/**
* SubmissionList - Main container for Reddit/Lemmy style submission feed
* Includes sort tabs, submission rows, and loading states
*/
import { computed, onMounted, watch } from 'vue'
import { Loader2 } from 'lucide-vue-next'
import SortTabs from './SortTabs.vue'
import SubmissionRow from './SubmissionRow.vue'
import { useSubmissions } from '../composables/useSubmissions'
import { tryInjectService, SERVICE_TOKENS } from '@/core/di-container'
import type { ProfileService } from '@/modules/base/nostr/ProfileService'
import type { SubmissionWithMeta, SortType, TimeRange, CommunityRef } from '../types/submission'
interface Props {
/** Community to filter by */
community?: CommunityRef | null
/** Show rank numbers */
showRanks?: boolean
/** Show time range selector for top sort */
showTimeRange?: boolean
/** Initial sort */
initialSort?: SortType
/** Max submissions to show */
limit?: number
}
interface Emits {
(e: 'submission-click', submission: SubmissionWithMeta): void
}
const props = withDefaults(defineProps<Props>(), {
showRanks: false,
showTimeRange: true,
initialSort: 'hot',
limit: 50
})
const emit = defineEmits<Emits>()
// Inject profile service for display names
const profileService = tryInjectService<ProfileService>(SERVICE_TOKENS.PROFILE_SERVICE)
// Auth service for checking authentication
const authService = tryInjectService<any>(SERVICE_TOKENS.AUTH_SERVICE)
// Use submissions composable
const {
submissions,
isLoading,
error,
currentSort,
currentTimeRange,
subscribe,
upvote,
downvote,
setSort
} = useSubmissions({
autoSubscribe: false,
config: {
community: props.community,
limit: props.limit
}
})
// Set initial sort
currentSort.value = props.initialSort
// Current user pubkey
const currentUserPubkey = computed(() => authService?.user?.value?.pubkey || null)
// Is user authenticated
const isAuthenticated = computed(() => authService?.isAuthenticated?.value || false)
// Get display name for a pubkey
function getDisplayName(pubkey: string): string {
if (profileService) {
return profileService.getDisplayName(pubkey)
}
// Fallback to truncated pubkey
return `${pubkey.slice(0, 8)}...`
}
// Handle sort change
function onSortChange(sort: SortType) {
setSort(sort, currentTimeRange.value)
}
// Handle time range change
function onTimeRangeChange(range: TimeRange) {
setSort(currentSort.value, range)
}
// Handle upvote
async function onUpvote(submission: SubmissionWithMeta) {
try {
await upvote(submission.id)
} catch (err) {
console.error('Failed to upvote:', err)
}
}
// Handle downvote
async function onDownvote(submission: SubmissionWithMeta) {
try {
await downvote(submission.id)
} catch (err) {
console.error('Failed to downvote:', err)
}
}
// Handle submission click
function onSubmissionClick(submission: SubmissionWithMeta) {
emit('submission-click', submission)
}
// Handle share
function onShare(submission: SubmissionWithMeta) {
// Copy link to clipboard or open share dialog
const url = `${window.location.origin}/submission/${submission.id}`
navigator.clipboard?.writeText(url)
// TODO: Show toast notification
}
// Handle save
function onSave(submission: SubmissionWithMeta) {
// TODO: Implement save functionality
console.log('Save:', submission.id)
}
// Handle hide
function onHide(submission: SubmissionWithMeta) {
// TODO: Implement hide functionality
console.log('Hide:', submission.id)
}
// Handle report
function onReport(submission: SubmissionWithMeta) {
// TODO: Implement report functionality
console.log('Report:', submission.id)
}
// Fetch profiles when submissions change
watch(submissions, (newSubmissions) => {
if (profileService && newSubmissions.length > 0) {
const pubkeys = [...new Set(newSubmissions.map(s => s.pubkey))]
profileService.fetchProfiles(pubkeys)
}
}, { immediate: true })
// Subscribe when community changes
watch(() => props.community, () => {
subscribe({
community: props.community,
limit: props.limit
})
}, { immediate: false })
// Initial subscribe
onMounted(() => {
subscribe({
community: props.community,
limit: props.limit
})
})
</script>
<template>
<div class="submission-list">
<!-- Sort tabs -->
<SortTabs
:current-sort="currentSort"
:current-time-range="currentTimeRange"
:show-time-range="showTimeRange"
@update:sort="onSortChange"
@update:time-range="onTimeRangeChange"
/>
<!-- Loading state -->
<div v-if="isLoading && submissions.length === 0" class="flex items-center justify-center py-8">
<Loader2 class="h-6 w-6 animate-spin text-muted-foreground" />
<span class="ml-2 text-sm text-muted-foreground">Loading submissions...</span>
</div>
<!-- Error state -->
<div v-else-if="error" class="text-center py-8">
<p class="text-sm text-destructive">{{ error }}</p>
<button
type="button"
class="mt-2 text-sm text-primary hover:underline"
@click="subscribe({ community, limit })"
>
Try again
</button>
</div>
<!-- Empty state -->
<div v-else-if="submissions.length === 0" class="text-center py-8">
<p class="text-sm text-muted-foreground">No submissions yet</p>
</div>
<!-- Submission list -->
<div v-else class="divide-y divide-border">
<SubmissionRow
v-for="(submission, index) in submissions"
:key="submission.id"
:submission="submission"
:rank="showRanks ? index + 1 : undefined"
:get-display-name="getDisplayName"
:current-user-pubkey="currentUserPubkey"
:is-authenticated="isAuthenticated"
@upvote="onUpvote"
@downvote="onDownvote"
@click="onSubmissionClick"
@share="onShare"
@save="onSave"
@hide="onHide"
@report="onReport"
/>
</div>
<!-- Loading more indicator -->
<div v-if="isLoading && submissions.length > 0" class="flex items-center justify-center py-4">
<Loader2 class="h-4 w-4 animate-spin text-muted-foreground" />
<span class="ml-2 text-xs text-muted-foreground">Loading more...</span>
</div>
</div>
</template>
<style scoped>
.submission-list {
max-width: 100%;
}
</style>

View file

@ -0,0 +1,247 @@
<script setup lang="ts">
/**
* SubmissionRow - Single submission row in Reddit/Lemmy style
* Compact, information-dense layout with votes, thumbnail, title, metadata
*/
import { computed } from 'vue'
import { formatDistanceToNow } from 'date-fns'
import { MessageSquare, Share2, Bookmark, EyeOff, Flag, Link2 } from 'lucide-vue-next'
import VoteControls from './VoteControls.vue'
import SubmissionThumbnail from './SubmissionThumbnail.vue'
import { Badge } from '@/components/ui/badge'
import type { SubmissionWithMeta, LinkSubmission, MediaSubmission } from '../types/submission'
import { extractDomain } from '../types/submission'
interface Props {
submission: SubmissionWithMeta
/** Display name resolver */
getDisplayName: (pubkey: string) => string
/** Current user pubkey for "own post" detection */
currentUserPubkey?: string | null
/** Show rank number */
rank?: number
/** Whether user is authenticated (for voting) */
isAuthenticated?: boolean
}
interface Emits {
(e: 'upvote', submission: SubmissionWithMeta): void
(e: 'downvote', submission: SubmissionWithMeta): void
(e: 'click', submission: SubmissionWithMeta): void
(e: 'save', submission: SubmissionWithMeta): void
(e: 'hide', submission: SubmissionWithMeta): void
(e: 'report', submission: SubmissionWithMeta): void
(e: 'share', submission: SubmissionWithMeta): void
}
const props = withDefaults(defineProps<Props>(), {
isAuthenticated: false
})
const emit = defineEmits<Emits>()
// Extract thumbnail URL based on post type
const thumbnailUrl = computed(() => {
const s = props.submission
if (s.postType === 'link') {
const link = s as LinkSubmission
return link.preview?.image || undefined
}
if (s.postType === 'media') {
const media = s as MediaSubmission
return media.media.thumbnail || media.media.url
}
return undefined
})
// Extract domain for link posts
const domain = computed(() => {
if (props.submission.postType === 'link') {
const link = props.submission as LinkSubmission
return extractDomain(link.url)
}
return null
})
// Format timestamp
const timeAgo = computed(() => {
return formatDistanceToNow(props.submission.created_at * 1000, { addSuffix: true })
})
// Author display name
const authorName = computed(() => {
return props.getDisplayName(props.submission.pubkey)
})
// Is this the user's own post?
const isOwnPost = computed(() => {
return props.currentUserPubkey === props.submission.pubkey
})
// Community name (if any)
const communityName = computed(() => {
const ref = props.submission.communityRef
if (!ref) return null
// Extract identifier from "34550:pubkey:identifier"
const parts = ref.split(':')
return parts.length >= 3 ? parts.slice(2).join(':') : null
})
// Post type indicator for self posts
const postTypeLabel = computed(() => {
if (props.submission.postType === 'self') return 'self'
return null
})
function onTitleClick() {
if (props.submission.postType === 'link') {
// Open external link
const link = props.submission as LinkSubmission
window.open(link.url, '_blank', 'noopener,noreferrer')
} else {
// Navigate to post detail
emit('click', props.submission)
}
}
function onCommentsClick() {
emit('click', props.submission)
}
</script>
<template>
<div class="flex items-start gap-2 py-2 px-1 hover:bg-accent/30 transition-colors group">
<!-- Rank number (optional) -->
<div v-if="rank" class="w-6 text-right text-sm text-muted-foreground font-medium pt-1">
{{ rank }}
</div>
<!-- Vote controls -->
<VoteControls
:score="submission.votes.score"
:user-vote="submission.votes.userVote"
:disabled="!isAuthenticated"
@upvote="emit('upvote', submission)"
@downvote="emit('downvote', submission)"
/>
<!-- Thumbnail -->
<SubmissionThumbnail
:src="thumbnailUrl"
:post-type="submission.postType"
:nsfw="submission.nsfw"
:size="70"
class="cursor-pointer"
@click="onCommentsClick"
/>
<!-- Content -->
<div class="flex-1 min-w-0">
<!-- Title row -->
<div class="flex items-start gap-2">
<h3
class="text-sm font-medium leading-snug cursor-pointer hover:underline"
@click="onTitleClick"
>
{{ submission.title }}
</h3>
<!-- Domain for link posts -->
<span v-if="domain" class="text-xs text-muted-foreground flex-shrink-0">
({{ domain }})
</span>
<!-- Self post indicator -->
<span v-if="postTypeLabel" class="text-xs text-muted-foreground flex-shrink-0">
({{ postTypeLabel }})
</span>
<!-- External link icon for link posts -->
<Link2
v-if="submission.postType === 'link'"
class="h-3 w-3 text-muted-foreground flex-shrink-0 mt-0.5"
/>
</div>
<!-- Flair badges -->
<div v-if="submission.nsfw || submission.flair" class="flex items-center gap-1 mt-0.5">
<Badge v-if="submission.nsfw" variant="destructive" class="text-[10px] px-1 py-0">
NSFW
</Badge>
<Badge v-if="submission.flair" variant="secondary" class="text-[10px] px-1 py-0">
{{ submission.flair }}
</Badge>
</div>
<!-- Metadata row -->
<div class="text-xs text-muted-foreground mt-1">
<span>submitted {{ timeAgo }}</span>
<span> by </span>
<span class="hover:underline cursor-pointer">{{ authorName }}</span>
<template v-if="communityName">
<span> to </span>
<span class="hover:underline cursor-pointer font-medium">{{ communityName }}</span>
</template>
</div>
<!-- Actions row -->
<div class="flex items-center gap-3 mt-1 text-xs text-muted-foreground">
<!-- Comments -->
<button
type="button"
class="flex items-center gap-1 hover:text-foreground transition-colors"
@click="onCommentsClick"
>
<MessageSquare class="h-3.5 w-3.5" />
<span>{{ submission.commentCount }} comments</span>
</button>
<!-- Share -->
<button
type="button"
class="flex items-center gap-1 hover:text-foreground transition-colors opacity-0 group-hover:opacity-100"
@click="emit('share', submission)"
>
<Share2 class="h-3.5 w-3.5" />
<span>share</span>
</button>
<!-- Save -->
<button
type="button"
class="flex items-center gap-1 hover:text-foreground transition-colors opacity-0 group-hover:opacity-100"
:class="{ 'text-yellow-500': submission.isSaved }"
@click="emit('save', submission)"
>
<Bookmark class="h-3.5 w-3.5" :class="{ 'fill-current': submission.isSaved }" />
<span>{{ submission.isSaved ? 'saved' : 'save' }}</span>
</button>
<!-- Hide -->
<button
type="button"
class="flex items-center gap-1 hover:text-foreground transition-colors opacity-0 group-hover:opacity-100"
@click="emit('hide', submission)"
>
<EyeOff class="h-3.5 w-3.5" />
<span>hide</span>
</button>
<!-- Report (not for own posts) -->
<button
v-if="!isOwnPost"
type="button"
class="flex items-center gap-1 hover:text-foreground transition-colors opacity-0 group-hover:opacity-100"
@click="emit('report', submission)"
>
<Flag class="h-3.5 w-3.5" />
<span>report</span>
</button>
</div>
</div>
</div>
</template>

View file

@ -0,0 +1,114 @@
<script setup lang="ts">
/**
* SubmissionThumbnail - Small square thumbnail for submissions
* Shows preview image, video indicator, or placeholder icon
*/
import { computed } from 'vue'
import { FileText, Image, ExternalLink } from 'lucide-vue-next'
import type { SubmissionType } from '../types/submission'
interface Props {
/** Thumbnail URL */
src?: string
/** Submission type for fallback icon */
postType: SubmissionType
/** Alt text */
alt?: string
/** Whether this is NSFW content */
nsfw?: boolean
/** Size in pixels */
size?: number
}
const props = withDefaults(defineProps<Props>(), {
size: 70,
nsfw: false
})
// Determine fallback icon based on post type
const FallbackIcon = computed(() => {
switch (props.postType) {
case 'link':
return ExternalLink
case 'media':
return Image
case 'self':
default:
return FileText
}
})
// Background color for fallback
const fallbackBgClass = computed(() => {
switch (props.postType) {
case 'link':
return 'bg-blue-500/10'
case 'media':
return 'bg-purple-500/10'
case 'self':
default:
return 'bg-muted'
}
})
// Icon color for fallback
const fallbackIconClass = computed(() => {
switch (props.postType) {
case 'link':
return 'text-blue-500'
case 'media':
return 'text-purple-500'
case 'self':
default:
return 'text-muted-foreground'
}
})
</script>
<template>
<div
class="flex-shrink-0 rounded overflow-hidden"
:style="{ width: `${size}px`, height: `${size}px` }"
>
<!-- NSFW blur overlay -->
<template v-if="nsfw && src">
<div class="relative w-full h-full">
<img
:src="src"
:alt="alt || 'Thumbnail'"
class="w-full h-full object-cover blur-lg"
/>
<div class="absolute inset-0 flex items-center justify-center bg-black/50">
<span class="text-[10px] font-bold text-red-500 uppercase">NSFW</span>
</div>
</div>
</template>
<!-- Image thumbnail -->
<template v-else-if="src">
<img
:src="src"
:alt="alt || 'Thumbnail'"
class="w-full h-full object-cover bg-muted"
loading="lazy"
@error="($event.target as HTMLImageElement).style.display = 'none'"
/>
</template>
<!-- Fallback icon -->
<template v-else>
<div
:class="[
'w-full h-full flex items-center justify-center',
fallbackBgClass
]"
>
<component
:is="FallbackIcon"
:class="['h-6 w-6', fallbackIconClass]"
/>
</div>
</template>
</div>
</template>

View file

@ -0,0 +1,406 @@
<script setup lang="ts">
/**
* SubmitComposer - Create new submissions (link, media, self posts)
* Similar to Lemmy's Create Post form
*/
import { ref, computed, watch } from 'vue'
import { useRouter } from 'vue-router'
import {
Link2,
FileText,
Image as ImageIcon,
Loader2,
ExternalLink,
X,
AlertCircle
} from 'lucide-vue-next'
import { Button } from '@/components/ui/button'
import { Badge } from '@/components/ui/badge'
import { tryInjectService, SERVICE_TOKENS } from '@/core/di-container'
import type { SubmissionService } from '../services/SubmissionService'
import type { LinkPreviewService } from '../services/LinkPreviewService'
import type {
LinkPreview,
SubmissionType,
LinkSubmissionForm,
SelfSubmissionForm,
SubmissionForm
} from '../types/submission'
interface Props {
/** Pre-selected community */
community?: string
/** Initial post type */
initialType?: SubmissionType
}
const props = withDefaults(defineProps<Props>(), {
initialType: 'self'
})
const emit = defineEmits<{
(e: 'submitted', submissionId: string): void
(e: 'cancel'): void
}>()
const router = useRouter()
// Services
const submissionService = tryInjectService<SubmissionService>(SERVICE_TOKENS.SUBMISSION_SERVICE)
const linkPreviewService = tryInjectService<LinkPreviewService>(SERVICE_TOKENS.LINK_PREVIEW_SERVICE)
const authService = tryInjectService<any>(SERVICE_TOKENS.AUTH_SERVICE)
// Auth state
const isAuthenticated = computed(() => authService?.isAuthenticated?.value || false)
// Form state
const postType = ref<SubmissionType>(props.initialType)
const title = ref('')
const url = ref('')
const body = ref('')
const thumbnailUrl = ref('')
const nsfw = ref(false)
// Link preview state
const linkPreview = ref<LinkPreview | null>(null)
const isLoadingPreview = ref(false)
const previewError = ref<string | null>(null)
// Submission state
const isSubmitting = ref(false)
const submitError = ref<string | null>(null)
// Validation
const isValid = computed(() => {
if (!title.value.trim()) return false
if (postType.value === 'link' && !url.value.trim()) return false
if (postType.value === 'self' && !body.value.trim()) return false
return true
})
// Debounced URL preview fetching
let previewTimeout: ReturnType<typeof setTimeout> | null = null
watch(url, (newUrl) => {
if (previewTimeout) {
clearTimeout(previewTimeout)
}
linkPreview.value = null
previewError.value = null
if (!newUrl.trim() || postType.value !== 'link') {
return
}
// Validate URL format
try {
new URL(newUrl)
} catch {
return
}
// Debounce the preview fetch
previewTimeout = setTimeout(async () => {
await fetchLinkPreview(newUrl)
}, 500)
})
async function fetchLinkPreview(urlToFetch: string) {
if (!linkPreviewService) {
previewError.value = 'Link preview service not available'
return
}
isLoadingPreview.value = true
previewError.value = null
try {
const preview = await linkPreviewService.fetchPreview(urlToFetch)
linkPreview.value = preview
} catch (err: any) {
console.error('Failed to fetch link preview:', err)
previewError.value = err.message || 'Failed to load preview'
} finally {
isLoadingPreview.value = false
}
}
function clearPreview() {
linkPreview.value = null
previewError.value = null
}
async function handleSubmit() {
if (!isValid.value || !isAuthenticated.value || !submissionService) return
isSubmitting.value = true
submitError.value = null
try {
let form: SubmissionForm
if (postType.value === 'link') {
const linkForm: LinkSubmissionForm = {
postType: 'link',
title: title.value.trim(),
url: url.value.trim(),
body: body.value.trim() || undefined,
communityRef: props.community,
nsfw: nsfw.value
}
form = linkForm
} else if (postType.value === 'self') {
const selfForm: SelfSubmissionForm = {
postType: 'self',
title: title.value.trim(),
body: body.value.trim(),
communityRef: props.community,
nsfw: nsfw.value
}
form = selfForm
} else if (postType.value === 'media') {
// TODO: Implement media submission with file upload
submitError.value = 'Media uploads not yet implemented'
return
} else {
submitError.value = 'Unknown post type'
return
}
const submissionId = await submissionService.createSubmission(form)
if (submissionId) {
emit('submitted', submissionId)
// Navigate to the new submission
router.push({ name: 'submission-detail', params: { id: submissionId } })
} else {
submitError.value = 'Failed to create submission'
}
} catch (err: any) {
console.error('Failed to submit:', err)
submitError.value = err.message || 'Failed to create submission'
} finally {
isSubmitting.value = false
}
}
function handleCancel() {
emit('cancel')
router.back()
}
function selectPostType(type: SubmissionType) {
postType.value = type
// Clear URL when switching away from link type
if (type !== 'link') {
url.value = ''
clearPreview()
}
}
</script>
<template>
<div class="max-w-2xl mx-auto p-4">
<h1 class="text-xl font-semibold mb-6">Create Post</h1>
<!-- Auth warning -->
<div v-if="!isAuthenticated" class="mb-4 p-4 bg-destructive/10 border border-destructive/20 rounded-lg">
<div class="flex items-center gap-2 text-destructive">
<AlertCircle class="h-4 w-4" />
<span class="text-sm font-medium">You must be logged in to create a post</span>
</div>
</div>
<!-- Post type selector -->
<div class="flex gap-2 mb-6">
<Button
:variant="postType === 'self' ? 'default' : 'outline'"
size="sm"
@click="selectPostType('self')"
>
<FileText class="h-4 w-4 mr-2" />
Text
</Button>
<Button
:variant="postType === 'link' ? 'default' : 'outline'"
size="sm"
@click="selectPostType('link')"
>
<Link2 class="h-4 w-4 mr-2" />
Link
</Button>
<Button
:variant="postType === 'media' ? 'default' : 'outline'"
size="sm"
@click="selectPostType('media')"
disabled
title="Coming soon"
>
<ImageIcon class="h-4 w-4 mr-2" />
Image
</Button>
</div>
<form @submit.prevent="handleSubmit" class="space-y-4">
<!-- Title -->
<div>
<label class="block text-sm font-medium mb-1.5">
Title <span class="text-destructive">*</span>
</label>
<input
v-model="title"
type="text"
placeholder="An interesting title"
maxlength="300"
class="w-full px-3 py-2 border rounded-lg bg-background focus:outline-none focus:ring-2 focus:ring-primary"
:disabled="!isAuthenticated"
/>
<div class="text-xs text-muted-foreground mt-1 text-right">
{{ title.length }}/300
</div>
</div>
<!-- URL (for link posts) -->
<div v-if="postType === 'link'">
<label class="block text-sm font-medium mb-1.5">
URL <span class="text-destructive">*</span>
</label>
<input
v-model="url"
type="url"
placeholder="https://example.com/article"
class="w-full px-3 py-2 border rounded-lg bg-background focus:outline-none focus:ring-2 focus:ring-primary"
:disabled="!isAuthenticated"
/>
<!-- Link preview -->
<div v-if="isLoadingPreview" class="mt-3 p-4 border rounded-lg bg-muted/30">
<div class="flex items-center gap-2 text-sm text-muted-foreground">
<Loader2 class="h-4 w-4 animate-spin" />
Loading preview...
</div>
</div>
<div v-else-if="previewError" class="mt-3 p-3 border border-destructive/20 rounded-lg bg-destructive/5">
<div class="flex items-center gap-2 text-sm text-destructive">
<AlertCircle class="h-4 w-4" />
{{ previewError }}
</div>
</div>
<div v-else-if="linkPreview" class="mt-3 p-4 border rounded-lg bg-muted/30">
<div class="flex items-start gap-3">
<!-- Preview image -->
<div v-if="linkPreview.image" class="flex-shrink-0 w-24 h-18 rounded overflow-hidden bg-muted">
<img
:src="linkPreview.image"
:alt="linkPreview.title || ''"
class="w-full h-full object-cover"
/>
</div>
<!-- Preview content -->
<div class="flex-1 min-w-0">
<div class="flex items-center gap-2 text-xs text-muted-foreground mb-1">
<ExternalLink class="h-3 w-3" />
<span>{{ linkPreview.domain }}</span>
<button
type="button"
class="ml-auto p-1 hover:bg-accent rounded"
@click="clearPreview"
>
<X class="h-3 w-3" />
</button>
</div>
<h4 v-if="linkPreview.title" class="font-medium text-sm line-clamp-2">
{{ linkPreview.title }}
</h4>
<p v-if="linkPreview.description" class="text-xs text-muted-foreground mt-1 line-clamp-2">
{{ linkPreview.description }}
</p>
</div>
</div>
</div>
</div>
<!-- Thumbnail URL (optional) -->
<div v-if="postType === 'link'">
<label class="block text-sm font-medium mb-1.5">
Thumbnail URL
<span class="text-muted-foreground font-normal">(optional)</span>
</label>
<input
v-model="thumbnailUrl"
type="url"
placeholder="https://example.com/image.jpg"
class="w-full px-3 py-2 border rounded-lg bg-background focus:outline-none focus:ring-2 focus:ring-primary"
:disabled="!isAuthenticated"
/>
</div>
<!-- Body -->
<div>
<label class="block text-sm font-medium mb-1.5">
Body
<span v-if="postType === 'self'" class="text-destructive">*</span>
<span v-else class="text-muted-foreground font-normal">(optional)</span>
</label>
<textarea
v-model="body"
:placeholder="postType === 'self' ? 'Write your post content...' : 'Optional description or commentary...'"
rows="6"
class="w-full px-3 py-2 border rounded-lg bg-background focus:outline-none focus:ring-2 focus:ring-primary resize-y min-h-[120px]"
:disabled="!isAuthenticated"
/>
<div class="text-xs text-muted-foreground mt-1">
Markdown supported
</div>
</div>
<!-- NSFW toggle -->
<div class="flex items-center gap-2">
<input
id="nsfw"
v-model="nsfw"
type="checkbox"
class="rounded border-muted-foreground"
:disabled="!isAuthenticated"
/>
<label for="nsfw" class="text-sm font-medium cursor-pointer">
NSFW
</label>
<Badge v-if="nsfw" variant="destructive" class="text-xs">
Not Safe For Work
</Badge>
</div>
<!-- Error message -->
<div v-if="submitError" class="p-3 border border-destructive/20 rounded-lg bg-destructive/5">
<div class="flex items-center gap-2 text-sm text-destructive">
<AlertCircle class="h-4 w-4" />
{{ submitError }}
</div>
</div>
<!-- Actions -->
<div class="flex items-center gap-3 pt-4">
<Button
type="submit"
:disabled="!isValid || isSubmitting || !isAuthenticated"
>
<Loader2 v-if="isSubmitting" class="h-4 w-4 animate-spin mr-2" />
Create
</Button>
<Button
type="button"
variant="outline"
@click="handleCancel"
>
Cancel
</Button>
</div>
</form>
</div>
</template>

View file

@ -0,0 +1,107 @@
<script setup lang="ts">
/**
* VoteControls - Compact upvote/downvote arrows with score
* Lemmy/Reddit style vertical layout
*/
import { computed } from 'vue'
import { ChevronUp, ChevronDown } from 'lucide-vue-next'
interface Props {
score: number
userVote: 'upvote' | 'downvote' | null
disabled?: boolean
}
interface Emits {
(e: 'upvote'): void
(e: 'downvote'): void
}
const props = withDefaults(defineProps<Props>(), {
disabled: false
})
const emit = defineEmits<Emits>()
// Format score for display (e.g., 1.2k for 1200)
const displayScore = computed(() => {
const score = props.score
if (Math.abs(score) >= 10000) {
return (score / 1000).toFixed(0) + 'k'
}
if (Math.abs(score) >= 1000) {
return (score / 1000).toFixed(1) + 'k'
}
return score.toString()
})
// Score color based on value
const scoreClass = computed(() => {
if (props.userVote === 'upvote') return 'text-orange-500'
if (props.userVote === 'downvote') return 'text-blue-500'
if (props.score > 0) return 'text-foreground'
if (props.score < 0) return 'text-muted-foreground'
return 'text-muted-foreground'
})
function onUpvote() {
if (!props.disabled) {
emit('upvote')
}
}
function onDownvote() {
if (!props.disabled) {
emit('downvote')
}
}
</script>
<template>
<div class="flex flex-col items-center gap-0 min-w-[40px]">
<!-- Upvote button -->
<button
type="button"
:disabled="disabled"
:class="[
'p-1 rounded transition-colors',
'hover:bg-accent disabled:opacity-50 disabled:cursor-not-allowed',
userVote === 'upvote' ? 'text-orange-500' : 'text-muted-foreground hover:text-orange-500'
]"
@click="onUpvote"
>
<ChevronUp
class="h-5 w-5"
:class="{ 'fill-current': userVote === 'upvote' }"
/>
</button>
<!-- Score -->
<span
:class="[
'text-xs font-bold tabular-nums min-w-[24px] text-center',
scoreClass
]"
>
{{ displayScore }}
</span>
<!-- Downvote button -->
<button
type="button"
:disabled="disabled"
:class="[
'p-1 rounded transition-colors',
'hover:bg-accent disabled:opacity-50 disabled:cursor-not-allowed',
userVote === 'downvote' ? 'text-blue-500' : 'text-muted-foreground hover:text-blue-500'
]"
@click="onDownvote"
>
<ChevronDown
class="h-5 w-5"
:class="{ 'fill-current': userVote === 'downvote' }"
/>
</button>
</div>
</template>

View file

@ -0,0 +1,335 @@
/**
* useSubmissions Composable
*
* Provides reactive access to the SubmissionService for Reddit-style submissions.
*/
import { computed, ref, onMounted, onUnmounted, watch, type Ref, type ComputedRef } from 'vue'
import { tryInjectService, SERVICE_TOKENS } from '@/core/di-container'
import type { SubmissionService } from '../services/SubmissionService'
import type { LinkPreviewService } from '../services/LinkPreviewService'
import type {
SubmissionWithMeta,
SubmissionForm,
SubmissionFeedConfig,
SubmissionComment,
SortType,
TimeRange,
LinkPreview
} from '../types/submission'
// ============================================================================
// Types
// ============================================================================
export interface UseSubmissionsOptions {
/** Auto-subscribe on mount */
autoSubscribe?: boolean
/** Feed configuration */
config?: Partial<SubmissionFeedConfig>
}
export interface UseSubmissionsReturn {
// State
submissions: ComputedRef<SubmissionWithMeta[]>
isLoading: ComputedRef<boolean>
error: ComputedRef<string | null>
// Sorting
currentSort: Ref<SortType>
currentTimeRange: Ref<TimeRange>
// Actions
subscribe: (config?: Partial<SubmissionFeedConfig>) => Promise<void>
unsubscribe: () => Promise<void>
refresh: () => Promise<void>
createSubmission: (form: SubmissionForm) => Promise<string>
upvote: (submissionId: string) => Promise<void>
downvote: (submissionId: string) => Promise<void>
setSort: (sort: SortType, timeRange?: TimeRange) => void
// Getters
getSubmission: (id: string) => SubmissionWithMeta | undefined
getComments: (submissionId: string) => SubmissionComment[]
getThreadedComments: (submissionId: string) => SubmissionComment[]
// Link preview
fetchLinkPreview: (url: string) => Promise<LinkPreview>
isPreviewLoading: (url: string) => boolean
}
// ============================================================================
// Composable
// ============================================================================
export function useSubmissions(options: UseSubmissionsOptions = {}): UseSubmissionsReturn {
const {
autoSubscribe = true,
config: initialConfig = {}
} = options
// Inject services
const submissionService = tryInjectService<SubmissionService>(SERVICE_TOKENS.SUBMISSION_SERVICE)
const linkPreviewService = tryInjectService<LinkPreviewService>(SERVICE_TOKENS.LINK_PREVIEW_SERVICE)
// Local state
const currentSort = ref<SortType>('hot')
const currentTimeRange = ref<TimeRange>('day')
// Default feed config
const defaultConfig: SubmissionFeedConfig = {
sort: 'hot',
timeRange: 'day',
includeNsfw: false,
limit: 50,
...initialConfig
}
// Computed values from service
const submissions = computed(() => {
if (!submissionService) return []
return submissionService.getSortedSubmissions(currentSort.value)
})
const isLoading = computed(() => submissionService?.isLoading.value ?? false)
const error = computed(() => submissionService?.error.value ?? null)
// ============================================================================
// Actions
// ============================================================================
/**
* Subscribe to submissions feed
*/
async function subscribe(config?: Partial<SubmissionFeedConfig>): Promise<void> {
if (!submissionService) {
console.warn('SubmissionService not available')
return
}
const feedConfig: SubmissionFeedConfig = {
...defaultConfig,
...config,
sort: currentSort.value,
timeRange: currentTimeRange.value
}
await submissionService.subscribe(feedConfig)
}
/**
* Unsubscribe from feed
*/
async function unsubscribe(): Promise<void> {
await submissionService?.unsubscribe()
}
/**
* Refresh the feed
*/
async function refresh(): Promise<void> {
submissionService?.clear()
await subscribe()
}
/**
* Create a new submission
*/
async function createSubmission(form: SubmissionForm): Promise<string> {
if (!submissionService) {
throw new Error('SubmissionService not available')
}
return submissionService.createSubmission(form)
}
/**
* Upvote a submission
*/
async function upvote(submissionId: string): Promise<void> {
if (!submissionService) {
throw new Error('SubmissionService not available')
}
await submissionService.upvote(submissionId)
}
/**
* Downvote a submission
*/
async function downvote(submissionId: string): Promise<void> {
if (!submissionService) {
throw new Error('SubmissionService not available')
}
await submissionService.downvote(submissionId)
}
/**
* Change sort order
*/
function setSort(sort: SortType, timeRange?: TimeRange): void {
currentSort.value = sort
if (timeRange) {
currentTimeRange.value = timeRange
}
}
// ============================================================================
// Getters
// ============================================================================
/**
* Get a single submission by ID
*/
function getSubmission(id: string): SubmissionWithMeta | undefined {
return submissionService?.getSubmission(id)
}
/**
* Get comments for a submission
*/
function getComments(submissionId: string): SubmissionComment[] {
return submissionService?.getComments(submissionId) ?? []
}
/**
* Get threaded comments for a submission
*/
function getThreadedComments(submissionId: string): SubmissionComment[] {
return submissionService?.getThreadedComments(submissionId) ?? []
}
// ============================================================================
// Link Preview
// ============================================================================
/**
* Fetch link preview for a URL
*/
async function fetchLinkPreview(url: string): Promise<LinkPreview> {
if (!linkPreviewService) {
return {
url,
domain: new URL(url).hostname.replace(/^www\./, '')
}
}
return linkPreviewService.fetchPreview(url)
}
/**
* Check if preview is loading
*/
function isPreviewLoading(url: string): boolean {
return linkPreviewService?.isLoading(url) ?? false
}
// ============================================================================
// Lifecycle
// ============================================================================
// Watch for sort changes and re-sort
watch([currentSort, currentTimeRange], async () => {
// Re-subscribe with new sort if needed for time-based filtering
if (currentSort.value === 'top') {
await subscribe()
}
})
// Auto-subscribe on mount
onMounted(() => {
if (autoSubscribe) {
subscribe()
}
})
// Cleanup on unmount
onUnmounted(() => {
unsubscribe()
})
// ============================================================================
// Return
// ============================================================================
return {
// State
submissions,
isLoading,
error,
// Sorting
currentSort,
currentTimeRange,
// Actions
subscribe,
unsubscribe,
refresh,
createSubmission,
upvote,
downvote,
setSort,
// Getters
getSubmission,
getComments,
getThreadedComments,
// Link preview
fetchLinkPreview,
isPreviewLoading
}
}
// ============================================================================
// Single Submission Hook
// ============================================================================
/**
* Hook for working with a single submission
*/
export function useSubmission(submissionId: string) {
const submissionService = tryInjectService<SubmissionService>(SERVICE_TOKENS.SUBMISSION_SERVICE)
const isLoading = ref(false)
const error = ref<string | null>(null)
const submission = computed(() => submissionService?.getSubmission(submissionId))
const comments = computed(() => submissionService?.getThreadedComments(submissionId) ?? [])
async function subscribe(): Promise<void> {
if (!submissionService) return
isLoading.value = true
error.value = null
try {
await submissionService.subscribeToSubmission(submissionId)
} catch (err: any) {
error.value = err.message || 'Failed to load submission'
} finally {
isLoading.value = false
}
}
async function upvote(): Promise<void> {
await submissionService?.upvote(submissionId)
}
async function downvote(): Promise<void> {
await submissionService?.downvote(submissionId)
}
// Subscribe on mount
onMounted(() => {
subscribe()
})
return {
submission,
comments,
isLoading: computed(() => isLoading.value || submissionService?.isLoading.value || false),
error: computed(() => error.value || submissionService?.error.value || null),
subscribe,
upvote,
downvote
}
}

View file

@ -0,0 +1,77 @@
import type { App } from 'vue'
import { markRaw } from 'vue'
import type { ModulePlugin } from '@/core/types'
import { container, SERVICE_TOKENS } from '@/core/di-container'
import { SubmissionService } from './services/SubmissionService'
import { LinkPreviewService } from './services/LinkPreviewService'
import SubmissionList from './components/SubmissionList.vue'
import SubmitComposer from './components/SubmitComposer.vue'
export const linksModule: ModulePlugin = {
name: 'links',
version: '1.0.0',
dependencies: ['base'],
quickActions: [
{
id: 'submit-link',
label: 'Submit',
icon: 'Link',
component: markRaw(SubmitComposer),
category: 'compose',
order: 1,
requiresAuth: true
}
],
routes: [
{
path: '/submission/:id',
name: 'submission-detail',
component: () => import('./views/SubmissionDetailPage.vue'),
meta: { title: 'Submission', requiresAuth: false }
},
{
path: '/submit',
name: 'submit-post',
component: () => import('./views/SubmitPage.vue'),
meta: { title: 'Create Post', requiresAuth: true }
}
],
async install(app: App) {
console.log('links module: Starting installation...')
const submissionService = new SubmissionService()
const linkPreviewService = new LinkPreviewService()
container.provide(SERVICE_TOKENS.SUBMISSION_SERVICE, submissionService)
container.provide(SERVICE_TOKENS.LINK_PREVIEW_SERVICE, linkPreviewService)
console.log('links module: Services registered in DI container')
console.log('links module: Initializing services...')
await Promise.all([
submissionService.initialize({
waitForDependencies: true,
maxRetries: 3
}),
linkPreviewService.initialize({
waitForDependencies: true,
maxRetries: 3
})
])
console.log('links module: Services initialized')
app.component('SubmissionList', SubmissionList)
console.log('links module: Installation complete')
},
components: {
SubmissionList,
SubmitComposer
},
composables: {}
}
export default linksModule

View file

@ -0,0 +1,552 @@
/**
* LinkPreviewService
*
* Fetches Open Graph and meta tags from URLs to generate link previews.
* Used when creating link submissions to embed preview data.
*/
import { reactive } from 'vue'
import { BaseService } from '@/core/base/BaseService'
import type { LinkPreview } from '../types/submission'
import { extractDomain } from '../types/submission'
// ============================================================================
// Types
// ============================================================================
interface CacheEntry {
preview: LinkPreview
timestamp: number
}
// ============================================================================
// Service Definition
// ============================================================================
export class LinkPreviewService extends BaseService {
protected readonly metadata = {
name: 'LinkPreviewService',
version: '1.0.0',
dependencies: []
}
// Cache for previews (URL -> preview)
private cache = reactive(new Map<string, CacheEntry>())
// Cache TTL (15 minutes)
private readonly CACHE_TTL = 15 * 60 * 1000
// Loading state per URL
private _loading = reactive(new Map<string, boolean>())
// Error state per URL
private _errors = reactive(new Map<string, string>())
// CORS proxy URL (configurable)
private proxyUrl = ''
// ============================================================================
// Lifecycle
// ============================================================================
protected async onInitialize(): Promise<void> {
console.log('LinkPreviewService: Initializing...')
// Try to get proxy URL from environment
this.proxyUrl = import.meta.env.VITE_CORS_PROXY_URL || ''
// Clean expired cache entries periodically
setInterval(() => this.cleanCache(), this.CACHE_TTL)
console.log('LinkPreviewService: Initialization complete')
}
protected async onDispose(): Promise<void> {
this.cache.clear()
this._loading.clear()
this._errors.clear()
}
// ============================================================================
// Public API
// ============================================================================
/**
* Fetch link preview for a URL
*/
async fetchPreview(url: string): Promise<LinkPreview> {
// Normalize URL
const normalizedUrl = this.normalizeUrl(url)
// Check cache
const cached = this.getCachedPreview(normalizedUrl)
if (cached) {
return cached
}
// Check if already loading
if (this._loading.get(normalizedUrl)) {
// Wait for existing request
return this.waitForPreview(normalizedUrl)
}
// Mark as loading
this._loading.set(normalizedUrl, true)
this._errors.delete(normalizedUrl)
try {
const preview = await this.doFetch(normalizedUrl)
// Cache the result
this.cache.set(normalizedUrl, {
preview,
timestamp: Date.now()
})
return preview
} catch (error) {
const message = error instanceof Error ? error.message : 'Failed to fetch preview'
this._errors.set(normalizedUrl, message)
// Return minimal preview on error
return {
url: normalizedUrl,
domain: extractDomain(normalizedUrl)
}
} finally {
this._loading.set(normalizedUrl, false)
}
}
/**
* Get cached preview if available and not expired
*/
getCachedPreview(url: string): LinkPreview | null {
const cached = this.cache.get(url)
if (!cached) return null
// Check if expired
if (Date.now() - cached.timestamp > this.CACHE_TTL) {
this.cache.delete(url)
return null
}
return cached.preview
}
/**
* Check if URL is currently loading
*/
isLoading(url: string): boolean {
return this._loading.get(url) || false
}
/**
* Get error for URL
*/
getError(url: string): string | null {
return this._errors.get(url) || null
}
/**
* Clear cache for a specific URL or all
*/
clearCache(url?: string): void {
if (url) {
this.cache.delete(url)
} else {
this.cache.clear()
}
}
// ============================================================================
// Fetching
// ============================================================================
/**
* Perform the actual fetch
*/
private async doFetch(url: string): Promise<LinkPreview> {
// Try different methods in order of preference
// 1. Try direct fetch (works for same-origin or CORS-enabled sites)
try {
return await this.fetchDirect(url)
} catch (directError) {
this.debug('Direct fetch failed:', directError)
}
// 2. Try CORS proxy if configured
if (this.proxyUrl) {
try {
return await this.fetchViaProxy(url)
} catch (proxyError) {
this.debug('Proxy fetch failed:', proxyError)
}
}
// 3. Try oEmbed for supported sites
try {
return await this.fetchOembed(url)
} catch (oembedError) {
this.debug('oEmbed fetch failed:', oembedError)
}
// 4. Return basic preview with just the domain
return {
url,
domain: extractDomain(url)
}
}
/**
* Direct fetch (may fail due to CORS)
*/
private async fetchDirect(url: string): Promise<LinkPreview> {
const response = await fetch(url, {
method: 'GET',
headers: {
'Accept': 'text/html'
}
})
if (!response.ok) {
throw new Error(`HTTP ${response.status}`)
}
const html = await response.text()
return this.parseHtml(url, html)
}
/**
* Fetch via CORS proxy
*/
private async fetchViaProxy(url: string): Promise<LinkPreview> {
const proxyUrl = `${this.proxyUrl}${encodeURIComponent(url)}`
const response = await fetch(proxyUrl, {
method: 'GET',
headers: {
'Accept': 'text/html'
}
})
if (!response.ok) {
throw new Error(`Proxy HTTP ${response.status}`)
}
const html = await response.text()
return this.parseHtml(url, html)
}
/**
* Try oEmbed for supported providers
*/
private async fetchOembed(url: string): Promise<LinkPreview> {
// oEmbed providers and their endpoints
const providers = [
{
pattern: /youtube\.com\/watch|youtu\.be/,
endpoint: 'https://www.youtube.com/oembed'
},
{
pattern: /twitter\.com|x\.com/,
endpoint: 'https://publish.twitter.com/oembed'
},
{
pattern: /vimeo\.com/,
endpoint: 'https://vimeo.com/api/oembed.json'
}
]
const provider = providers.find(p => p.pattern.test(url))
if (!provider) {
throw new Error('No oEmbed provider for URL')
}
const oembedUrl = `${provider.endpoint}?url=${encodeURIComponent(url)}&format=json`
const response = await fetch(oembedUrl)
if (!response.ok) {
throw new Error(`oEmbed HTTP ${response.status}`)
}
const data = await response.json()
return {
url,
domain: extractDomain(url),
title: data.title,
description: data.description || data.author_name,
image: data.thumbnail_url,
siteName: data.provider_name,
type: data.type,
videoUrl: data.html?.includes('iframe') ? url : undefined
}
}
// ============================================================================
// HTML Parsing
// ============================================================================
/**
* Parse HTML to extract Open Graph and meta tags
*/
private parseHtml(url: string, html: string): LinkPreview {
const preview: LinkPreview = {
url,
domain: extractDomain(url)
}
// Create a DOM parser
const parser = new DOMParser()
const doc = parser.parseFromString(html, 'text/html')
// Extract Open Graph tags
const ogTags = this.extractOgTags(doc)
// Extract Twitter Card tags (fallback)
const twitterTags = this.extractTwitterTags(doc)
// Extract standard meta tags (fallback)
const metaTags = this.extractMetaTags(doc)
// Merge with priority: OG > Twitter > Meta > Title
preview.title = ogTags.title || twitterTags.title || metaTags.title || this.extractTitle(doc)
preview.description = ogTags.description || twitterTags.description || metaTags.description
preview.image = ogTags.image || twitterTags.image
preview.siteName = ogTags.siteName || twitterTags.site
preview.type = ogTags.type
preview.videoUrl = ogTags.video
preview.favicon = this.extractFavicon(doc, url)
return preview
}
/**
* Extract Open Graph tags
*/
private extractOgTags(doc: Document): Record<string, string | undefined> {
const tags: Record<string, string | undefined> = {}
const ogMetas = doc.querySelectorAll('meta[property^="og:"]')
ogMetas.forEach(meta => {
const property = meta.getAttribute('property')?.replace('og:', '')
const content = meta.getAttribute('content')
if (property && content) {
switch (property) {
case 'title':
tags.title = content
break
case 'description':
tags.description = content
break
case 'image':
tags.image = content
break
case 'site_name':
tags.siteName = content
break
case 'type':
tags.type = content
break
case 'video':
case 'video:url':
tags.video = content
break
}
}
})
return tags
}
/**
* Extract Twitter Card tags
*/
private extractTwitterTags(doc: Document): Record<string, string | undefined> {
const tags: Record<string, string | undefined> = {}
const twitterMetas = doc.querySelectorAll('meta[name^="twitter:"]')
twitterMetas.forEach(meta => {
const name = meta.getAttribute('name')?.replace('twitter:', '')
const content = meta.getAttribute('content')
if (name && content) {
switch (name) {
case 'title':
tags.title = content
break
case 'description':
tags.description = content
break
case 'image':
case 'image:src':
tags.image = content
break
case 'site':
tags.site = content
break
}
}
})
return tags
}
/**
* Extract standard meta tags
*/
private extractMetaTags(doc: Document): Record<string, string | undefined> {
const tags: Record<string, string | undefined> = {}
// Description
const descMeta = doc.querySelector('meta[name="description"]')
if (descMeta) {
tags.description = descMeta.getAttribute('content') || undefined
}
// Title from meta
const titleMeta = doc.querySelector('meta[name="title"]')
if (titleMeta) {
tags.title = titleMeta.getAttribute('content') || undefined
}
return tags
}
/**
* Extract page title
*/
private extractTitle(doc: Document): string | undefined {
return doc.querySelector('title')?.textContent || undefined
}
/**
* Extract favicon URL
*/
private extractFavicon(doc: Document, pageUrl: string): string | undefined {
// Try various link rel types
const selectors = [
'link[rel="icon"]',
'link[rel="shortcut icon"]',
'link[rel="apple-touch-icon"]'
]
for (const selector of selectors) {
const link = doc.querySelector(selector)
const href = link?.getAttribute('href')
if (href) {
return this.resolveUrl(href, pageUrl)
}
}
// Default favicon location
return this.resolveUrl('/favicon.ico', pageUrl)
}
// ============================================================================
// Utilities
// ============================================================================
/**
* Normalize URL
*/
private normalizeUrl(url: string): string {
let normalized = url.trim()
// Add protocol if missing
if (!normalized.startsWith('http://') && !normalized.startsWith('https://')) {
normalized = 'https://' + normalized
}
return normalized
}
/**
* Resolve relative URL to absolute
*/
private resolveUrl(href: string, base: string): string {
try {
return new URL(href, base).toString()
} catch {
return href
}
}
/**
* Wait for an in-flight preview request
*/
private async waitForPreview(url: string): Promise<LinkPreview> {
// Poll until loading is done
while (this._loading.get(url)) {
await new Promise(resolve => setTimeout(resolve, 100))
}
// Return cached result or error
const cached = this.getCachedPreview(url)
if (cached) return cached
return {
url,
domain: extractDomain(url)
}
}
/**
* Clean expired cache entries
*/
private cleanCache(): void {
const now = Date.now()
for (const [url, entry] of this.cache) {
if (now - entry.timestamp > this.CACHE_TTL) {
this.cache.delete(url)
}
}
}
// ============================================================================
// Validation
// ============================================================================
/**
* Check if URL is valid
*/
isValidUrl(url: string): boolean {
try {
const parsed = new URL(this.normalizeUrl(url))
return parsed.protocol === 'http:' || parsed.protocol === 'https:'
} catch {
return false
}
}
/**
* Check if URL is likely to be media
*/
isMediaUrl(url: string): boolean {
const mediaExtensions = [
'.jpg', '.jpeg', '.png', '.gif', '.webp', '.svg',
'.mp4', '.webm', '.mov', '.avi',
'.mp3', '.wav', '.ogg', '.flac'
]
const lowerUrl = url.toLowerCase()
return mediaExtensions.some(ext => lowerUrl.includes(ext))
}
/**
* Guess media type from URL
*/
guessMediaType(url: string): 'image' | 'video' | 'audio' | 'other' {
const lowerUrl = url.toLowerCase()
if (/\.(jpg|jpeg|png|gif|webp|svg)/.test(lowerUrl)) return 'image'
if (/\.(mp4|webm|mov|avi)/.test(lowerUrl)) return 'video'
if (/\.(mp3|wav|ogg|flac)/.test(lowerUrl)) return 'audio'
return 'other'
}
}

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,5 @@
/**
* Types index - re-export all types from the module
*/
export * from './submission'

View file

@ -0,0 +1,528 @@
/**
* Link Aggregator Types
*
* Implements Reddit-style submissions using NIP-72 (Communities) and NIP-22 (Comments).
* Submissions are kind 1111 events scoped to a community with structured metadata.
*/
// ============================================================================
// Constants
// ============================================================================
/** Nostr event kinds used by the link aggregator */
export const SUBMISSION_KINDS = {
/** Community definition (NIP-72) */
COMMUNITY: 34550,
/** Submission/comment (NIP-22) */
SUBMISSION: 1111,
/** Moderator approval (NIP-72) */
APPROVAL: 4550,
/** Reaction/vote (NIP-25) */
REACTION: 7,
/** Deletion (NIP-09) */
DELETION: 5,
/** File metadata (NIP-94) - for media references */
FILE_METADATA: 1063
} as const
/** Submission post types */
export type SubmissionType = 'link' | 'media' | 'self'
/** Vote types for reactions */
export type VoteType = 'upvote' | 'downvote' | null
/** Feed sort options */
export type SortType = 'hot' | 'new' | 'top' | 'controversial'
/** Time range for "top" sorting */
export type TimeRange = 'hour' | 'day' | 'week' | 'month' | 'year' | 'all'
// ============================================================================
// Link Preview Types
// ============================================================================
/** Open Graph metadata extracted from a URL */
export interface LinkPreview {
/** The original URL */
url: string
/** og:title or page title */
title?: string
/** og:description or meta description */
description?: string
/** og:image URL */
image?: string
/** og:site_name */
siteName?: string
/** og:type (article, video, etc.) */
type?: string
/** og:video for video embeds */
videoUrl?: string
/** Favicon URL */
favicon?: string
/** Domain extracted from URL */
domain: string
}
// ============================================================================
// Media Types (NIP-92 / NIP-94)
// ============================================================================
/** Media attachment metadata from imeta tag */
export interface MediaAttachment {
/** Media URL */
url: string
/** MIME type (e.g., "image/jpeg", "video/mp4") */
mimeType?: string
/** Dimensions in "WxH" format */
dimensions?: string
/** Width in pixels */
width?: number
/** Height in pixels */
height?: number
/** Blurhash for placeholder */
blurhash?: string
/** Alt text for accessibility */
alt?: string
/** SHA-256 hash of the file */
hash?: string
/** File size in bytes */
size?: number
/** Thumbnail URL */
thumbnail?: string
/** Fallback URLs */
fallbacks?: string[]
}
/** Media type classification */
export type MediaType = 'image' | 'video' | 'audio' | 'other'
// ============================================================================
// Submission Types
// ============================================================================
/** Base submission data shared by all post types */
export interface SubmissionBase {
/** Nostr event ID */
id: string
/** Author public key */
pubkey: string
/** Unix timestamp (seconds) */
created_at: number
/** Event kind (1111) */
kind: typeof SUBMISSION_KINDS.SUBMISSION
/** Raw event tags */
tags: string[][]
/** Submission title (required) */
title: string
/** Post type discriminator */
postType: SubmissionType
/** Community reference (a-tag format) */
communityRef?: string
/** Hashtags/topics */
hashtags: string[]
/** Whether marked NSFW */
nsfw: boolean
/** Flair/label for the post */
flair?: string
}
/** Link submission with URL and preview */
export interface LinkSubmission extends SubmissionBase {
postType: 'link'
/** External URL */
url: string
/** Link preview metadata */
preview?: LinkPreview
/** Optional body/description */
body?: string
}
/** Media submission with attachments */
export interface MediaSubmission extends SubmissionBase {
postType: 'media'
/** Primary media attachment */
media: MediaAttachment
/** Additional media attachments (gallery) */
gallery?: MediaAttachment[]
/** Caption/description */
body?: string
}
/** Self/text submission */
export interface SelfSubmission extends SubmissionBase {
postType: 'self'
/** Markdown body content */
body: string
}
/** Union type for all submission types */
export type Submission = LinkSubmission | MediaSubmission | SelfSubmission
// ============================================================================
// Voting & Scoring
// ============================================================================
/** Vote counts and user state for a submission */
export interface SubmissionVotes {
/** Total upvotes */
upvotes: number
/** Total downvotes */
downvotes: number
/** Net score (upvotes - downvotes) */
score: number
/** Current user's vote */
userVote: VoteType
/** User's vote event ID (for deletion) */
userVoteId?: string
}
/** Ranking scores for sorting */
export interface SubmissionRanking {
/** Hot rank score (activity + recency) */
hotRank: number
/** Controversy rank (balanced voting) */
controversyRank: number
/** Scaled rank (amplifies smaller communities) */
scaledRank: number
}
// ============================================================================
// Full Submission with Metadata
// ============================================================================
/** Complete submission with all associated data */
export type SubmissionWithMeta = Submission & {
/** Vote counts and user state */
votes: SubmissionVotes
/** Ranking scores */
ranking: SubmissionRanking
/** Total comment count */
commentCount: number
/** Whether the submission is saved by current user */
isSaved: boolean
/** Whether hidden by current user */
isHidden: boolean
/** Approval status in moderated community */
approvalStatus: 'pending' | 'approved' | 'rejected' | null
}
// ============================================================================
// Comments
// ============================================================================
/** Comment on a submission (also kind 1111) */
export interface SubmissionComment {
/** Nostr event ID */
id: string
/** Author public key */
pubkey: string
/** Unix timestamp */
created_at: number
/** Comment text content */
content: string
/** Root submission ID */
rootId: string
/** Direct parent ID (submission or comment) */
parentId: string
/** Depth in comment tree (0 = top-level) */
depth: number
/** Child comments */
replies: SubmissionComment[]
/** Vote data */
votes: SubmissionVotes
/** Whether collapsed in UI */
isCollapsed?: boolean
}
// ============================================================================
// Community Types (NIP-72)
// ============================================================================
/** Community moderator */
export interface CommunityModerator {
pubkey: string
relay?: string
role: 'moderator' | 'admin'
}
/** Community definition (kind 34550) */
export interface Community {
/** Unique identifier (d-tag) */
id: string
/** Creator public key */
pubkey: string
/** Display name */
name: string
/** Description/about */
description?: string
/** Banner/header image URL */
image?: string
/** Icon/avatar URL */
icon?: string
/** List of moderators */
moderators: CommunityModerator[]
/** Rules (markdown) */
rules?: string
/** Preferred relays */
relays: {
author?: string
requests?: string
approvals?: string
}
/** Tags/topics */
tags: string[]
/** Whether posts require approval */
requiresApproval: boolean
/** Creation timestamp */
created_at: number
}
/** Community reference (a-tag format) */
export interface CommunityRef {
/** "34550" */
kind: string
/** Community creator pubkey */
pubkey: string
/** Community d-tag identifier */
identifier: string
/** Relay hint */
relay?: string
}
// ============================================================================
// Form Types (for creating/editing)
// ============================================================================
/** Form data for creating a link submission */
export interface LinkSubmissionForm {
postType: 'link'
title: string
url: string
body?: string
communityRef?: string
nsfw?: boolean
flair?: string
}
/** Form data for creating a media submission */
export interface MediaSubmissionForm {
postType: 'media'
title: string
/** File to upload, or URL if already uploaded */
media: File | string
body?: string
alt?: string
communityRef?: string
nsfw?: boolean
flair?: string
}
/** Form data for creating a self/text submission */
export interface SelfSubmissionForm {
postType: 'self'
title: string
body: string
communityRef?: string
nsfw?: boolean
flair?: string
}
/** Union type for submission forms */
export type SubmissionForm = LinkSubmissionForm | MediaSubmissionForm | SelfSubmissionForm
// ============================================================================
// Feed Configuration
// ============================================================================
/** Configuration for fetching submissions */
export interface SubmissionFeedConfig {
/** Community to filter by (optional, null = all) */
community?: CommunityRef | null
/** Sort order */
sort: SortType
/** Time range for "top" sort */
timeRange?: TimeRange
/** Filter by post type */
postTypes?: SubmissionType[]
/** Include NSFW content */
includeNsfw: boolean
/** Maximum submissions to fetch */
limit: number
/** Author filter */
authors?: string[]
/** Hashtag filter */
hashtags?: string[]
}
// ============================================================================
// Helper Functions
// ============================================================================
/**
* Parse an 'a' tag into a CommunityRef
* Format: "34550:<pubkey>:<identifier>"
*/
export function parseCommunityRef(aTag: string, relay?: string): CommunityRef | null {
const parts = aTag.split(':')
if (parts.length < 3 || parts[0] !== '34550') {
return null
}
return {
kind: parts[0],
pubkey: parts[1],
identifier: parts.slice(2).join(':'), // identifier may contain colons
relay
}
}
/**
* Format a CommunityRef back to 'a' tag format
*/
export function formatCommunityRef(ref: CommunityRef): string {
return `${ref.kind}:${ref.pubkey}:${ref.identifier}`
}
/**
* Extract domain from URL
*/
export function extractDomain(url: string): string {
try {
const parsed = new URL(url)
return parsed.hostname.replace(/^www\./, '')
} catch {
return url
}
}
/**
* Classify media type from MIME type
*/
export function classifyMediaType(mimeType?: string): MediaType {
if (!mimeType) return 'other'
if (mimeType.startsWith('image/')) return 'image'
if (mimeType.startsWith('video/')) return 'video'
if (mimeType.startsWith('audio/')) return 'audio'
return 'other'
}
/**
* Parse imeta tag into MediaAttachment
* Format: ["imeta", "url <url>", "m <mime>", "dim <WxH>", ...]
*/
export function parseImetaTag(tag: string[]): MediaAttachment | null {
if (tag[0] !== 'imeta') return null
const attachment: MediaAttachment = { url: '' }
for (let i = 1; i < tag.length; i++) {
const [key, ...valueParts] = tag[i].split(' ')
const value = valueParts.join(' ')
switch (key) {
case 'url':
attachment.url = value
break
case 'm':
attachment.mimeType = value
break
case 'dim':
attachment.dimensions = value
const [w, h] = value.split('x').map(Number)
if (!isNaN(w)) attachment.width = w
if (!isNaN(h)) attachment.height = h
break
case 'blurhash':
attachment.blurhash = value
break
case 'alt':
attachment.alt = value
break
case 'x':
attachment.hash = value
break
case 'size':
attachment.size = parseInt(value, 10)
break
case 'thumb':
attachment.thumbnail = value
break
case 'fallback':
attachment.fallbacks = attachment.fallbacks || []
attachment.fallbacks.push(value)
break
}
}
return attachment.url ? attachment : null
}
/**
* Build imeta tag from MediaAttachment
*/
export function buildImetaTag(media: MediaAttachment): string[] {
const tag = ['imeta']
tag.push(`url ${media.url}`)
if (media.mimeType) tag.push(`m ${media.mimeType}`)
if (media.dimensions) tag.push(`dim ${media.dimensions}`)
else if (media.width && media.height) tag.push(`dim ${media.width}x${media.height}`)
if (media.blurhash) tag.push(`blurhash ${media.blurhash}`)
if (media.alt) tag.push(`alt ${media.alt}`)
if (media.hash) tag.push(`x ${media.hash}`)
if (media.size) tag.push(`size ${media.size}`)
if (media.thumbnail) tag.push(`thumb ${media.thumbnail}`)
media.fallbacks?.forEach(fb => tag.push(`fallback ${fb}`))
return tag
}
// ============================================================================
// Ranking Algorithms
// ============================================================================
/** Epoch for hot rank calculation (Unix timestamp) */
const HOT_RANK_EPOCH = 1134028003 // Dec 8, 2005 (Reddit's epoch)
/**
* Calculate hot rank score (Reddit/Lemmy style)
* Higher scores for posts with more upvotes that are newer
*/
export function calculateHotRank(score: number, createdAt: number): number {
const order = Math.log10(Math.max(Math.abs(score), 1))
const sign = score > 0 ? 1 : score < 0 ? -1 : 0
const seconds = createdAt - HOT_RANK_EPOCH
return sign * order + seconds / 45000
}
/**
* Calculate controversy rank
* Higher scores for posts with balanced up/down votes
*/
export function calculateControversyRank(upvotes: number, downvotes: number): number {
const total = upvotes + downvotes
if (total === 0) return 0
const magnitude = Math.pow(total, 0.8)
const balance = Math.min(upvotes, downvotes) / Math.max(upvotes, downvotes, 1)
return magnitude * balance
}
/**
* Calculate confidence score (Wilson score interval lower bound)
* Used for "best" comment sorting
*/
export function calculateConfidence(upvotes: number, downvotes: number): number {
const n = upvotes + downvotes
if (n === 0) return 0
const z = 1.96 // 95% confidence
const p = upvotes / n
const left = p + (z * z) / (2 * n)
const right = z * Math.sqrt((p * (1 - p) + (z * z) / (4 * n)) / n)
const under = 1 + (z * z) / n
return (left - right) / under
}

View file

@ -0,0 +1,18 @@
<script setup lang="ts">
/**
* SubmissionDetailPage - Page wrapper for submission detail view
* Extracts route params and passes to SubmissionDetail component
*/
import { computed } from 'vue'
import { useRoute } from 'vue-router'
import SubmissionDetail from '../components/SubmissionDetail.vue'
const route = useRoute()
const submissionId = computed(() => route.params.id as string)
</script>
<template>
<SubmissionDetail :submission-id="submissionId" />
</template>

View file

@ -0,0 +1,35 @@
<script setup lang="ts">
/**
* SubmitPage - Page wrapper for submission composer
* Handles route query params for community pre-selection
*/
import { computed } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import SubmitComposer from '../components/SubmitComposer.vue'
const route = useRoute()
const router = useRouter()
// Get community from query param if provided (e.g., /submit?community=...)
const community = computed(() => route.query.community as string | undefined)
// Handle submission completion
function onSubmitted(submissionId: string) {
// Navigation is handled by SubmitComposer
console.log('Submission created:', submissionId)
}
// Handle cancel - go back
function onCancel() {
router.back()
}
</script>
<template>
<SubmitComposer
:community="community"
@submitted="onSubmitted"
@cancel="onCancel"
/>
</template>

View file

@ -5,7 +5,7 @@
<!-- Product Image --> <!-- Product Image -->
<div class="flex-shrink-0"> <div class="flex-shrink-0">
<img <img
:src="item.product.images?.[0] || '/placeholder-product.png'" :src="thumbnail(item.product.images?.[0], 128) || '/placeholder-product.png'"
:alt="item.product.name" :alt="item.product.name"
class="w-16 h-16 object-cover rounded-md" class="w-16 h-16 object-cover rounded-md"
loading="lazy" loading="lazy"
@ -104,7 +104,7 @@
<!-- Product Image --> <!-- Product Image -->
<div class="flex-shrink-0"> <div class="flex-shrink-0">
<img <img
:src="item.product.images?.[0] || '/placeholder-product.png'" :src="thumbnail(item.product.images?.[0], 128) || '/placeholder-product.png'"
:alt="item.product.name" :alt="item.product.name"
class="w-16 h-16 object-cover rounded-md" class="w-16 h-16 object-cover rounded-md"
loading="lazy" loading="lazy"
@ -203,12 +203,14 @@ import { Button } from '@/components/ui/button'
import { Badge } from '@/components/ui/badge' import { Badge } from '@/components/ui/badge'
import { Minus, Plus, Trash2 } from 'lucide-vue-next' import { Minus, Plus, Trash2 } from 'lucide-vue-next'
import type { CartItem as CartItemType } from '../types/market' import type { CartItem as CartItemType } from '../types/market'
import { useImageOptimizer } from '@/modules/base/composables/useImageOptimizer'
interface Props { interface Props {
item: CartItemType item: CartItemType
stallId: string stallId: string
} }
const { thumbnail } = useImageOptimizer()
const props = defineProps<Props>() const props = defineProps<Props>()
const emit = defineEmits<{ const emit = defineEmits<{

View file

@ -17,7 +17,7 @@
> >
<div class="flex items-center space-x-3"> <div class="flex items-center space-x-3">
<img <img
:src="item.product.images?.[0] || '/placeholder-product.png'" :src="thumbnail(item.product.images?.[0], 64) || '/placeholder-product.png'"
:alt="item.product.name" :alt="item.product.name"
class="w-8 h-8 object-cover rounded" class="w-8 h-8 object-cover rounded"
loading="lazy" loading="lazy"
@ -144,6 +144,7 @@ import { ref, computed } from 'vue'
import { useRouter } from 'vue-router' import { useRouter } from 'vue-router'
import { Button } from '@/components/ui/button' import { Button } from '@/components/ui/button'
import { Shield } from 'lucide-vue-next' import { Shield } from 'lucide-vue-next'
import { useImageOptimizer } from '@/modules/base/composables/useImageOptimizer'
import type { ShippingZone } from '@/modules/market/stores/market' import type { ShippingZone } from '@/modules/market/stores/market'
interface Props { interface Props {
@ -188,6 +189,7 @@ interface Props {
} }
} }
const { thumbnail } = useImageOptimizer()
const props = defineProps<Props>() const props = defineProps<Props>()
const emit = defineEmits<{ const emit = defineEmits<{

View file

@ -47,10 +47,14 @@
<FormField v-slot="{ componentField }" name="currency"> <FormField v-slot="{ componentField }" name="currency">
<FormItem> <FormItem>
<FormLabel>Currency *</FormLabel> <FormLabel>Currency *</FormLabel>
<Select :disabled="isCreating" v-bind="componentField"> <Select
:key="`currency-select-${availableCurrencies.length}`"
:disabled="isCreating || isLoadingCurrencies"
v-bind="componentField"
>
<FormControl> <FormControl>
<SelectTrigger class="w-full"> <SelectTrigger class="w-full">
<SelectValue placeholder="Select currency" /> <SelectValue :placeholder="isLoadingCurrencies ? 'Loading...' : 'Select currency'" />
</SelectTrigger> </SelectTrigger>
</FormControl> </FormControl>
<SelectContent> <SelectContent>
@ -275,6 +279,7 @@ const toast = useToast()
// Local state // Local state
const isCreating = ref(false) const isCreating = ref(false)
const createError = ref<string | null>(null) const createError = ref<string | null>(null)
const isLoadingCurrencies = ref(false)
const availableCurrencies = ref<string[]>(['sat']) const availableCurrencies = ref<string[]>(['sat'])
const availableZones = ref<Zone[]>([]) const availableZones = ref<Zone[]>([])
const showNewZoneForm = ref(false) const showNewZoneForm = ref(false)
@ -331,11 +336,27 @@ const onSubmit = form.handleSubmit(async (values) => {
// Methods // Methods
const loadAvailableCurrencies = async () => { const loadAvailableCurrencies = async () => {
isLoadingCurrencies.value = true
try { try {
const currencies = await nostrmarketAPI.getCurrencies() const currencies = await nostrmarketAPI.getCurrencies()
if (currencies.length > 0) {
// Ensure 'sat' is always first in the list
const satIndex = currencies.indexOf('sat')
if (satIndex === -1) {
// Add 'sat' at the beginning if not present
availableCurrencies.value = ['sat', ...currencies]
} else if (satIndex > 0) {
// Move 'sat' to the beginning if present but not first
const withoutSat = currencies.filter(c => c !== 'sat')
availableCurrencies.value = ['sat', ...withoutSat]
} else {
availableCurrencies.value = currencies availableCurrencies.value = currencies
}
}
} catch (error) { } catch (error) {
console.error('Failed to load currencies:', error) console.error('Failed to load currencies:', error)
} finally {
isLoadingCurrencies.value = false
} }
} }

View file

@ -77,77 +77,6 @@
</div> </div>
</div> </div>
<!-- Quick Actions -->
<div class="grid grid-cols-1 md:grid-cols-2 gap-6">
<!-- Customer Actions -->
<div class="bg-card p-6 rounded-lg border shadow-sm">
<h3 class="text-lg font-semibold text-foreground mb-4 flex items-center gap-2">
<ShoppingCart class="w-5 h-5 text-primary" />
Customer Actions
</h3>
<div class="space-y-3">
<Button
@click="navigateToMarket"
variant="default"
class="w-full justify-start"
>
<Store class="w-4 h-4 mr-2" />
Browse Market
</Button>
<Button
@click="navigateToOrders"
variant="outline"
class="w-full justify-start"
>
<Package class="w-4 h-4 mr-2" />
View All Orders
</Button>
<Button
@click="navigateToCart"
variant="outline"
class="w-full justify-start"
>
<ShoppingCart class="w-4 h-4 mr-2" />
Shopping Cart
</Button>
</div>
</div>
<!-- Merchant Actions -->
<div class="bg-card p-6 rounded-lg border shadow-sm">
<h3 class="text-lg font-semibold text-foreground mb-4 flex items-center gap-2">
<Store class="w-5 h-5 text-green-500" />
Merchant Actions
</h3>
<div class="space-y-3">
<Button
@click="navigateToStore"
variant="default"
class="w-full justify-start"
>
<Store class="w-4 h-4 mr-2" />
Manage Store
</Button>
<Button
@click="navigateToProducts"
variant="outline"
class="w-full justify-start"
>
<Package class="w-4 h-4 mr-2" />
Manage Products
</Button>
<Button
@click="navigateToOrders"
variant="outline"
class="w-full justify-start"
>
<Package class="w-4 h-4 mr-2" />
View Orders
</Button>
</div>
</div>
</div>
<!-- Recent Activity --> <!-- Recent Activity -->
<div class="bg-card p-6 rounded-lg border shadow-sm"> <div class="bg-card p-6 rounded-lg border shadow-sm">
<h3 class="text-lg font-semibold text-foreground mb-4">Recent Activity</h3> <h3 class="text-lg font-semibold text-foreground mb-4">Recent Activity</h3>
@ -216,15 +145,12 @@
<script setup lang="ts"> <script setup lang="ts">
import { computed, ref, onMounted } from 'vue' import { computed, ref, onMounted } from 'vue'
import { useRouter } from 'vue-router'
import { useAuth } from '@/composables/useAuthService' import { useAuth } from '@/composables/useAuthService'
import { useMarket } from '../composables/useMarket' import { useMarket } from '../composables/useMarket'
import { Button } from '@/components/ui/button'
import { Badge } from '@/components/ui/badge' import { Badge } from '@/components/ui/badge'
import { import {
Package, Package,
Store, Store,
ShoppingCart,
DollarSign, DollarSign,
BarChart3, BarChart3,
Clock Clock
@ -233,7 +159,6 @@ import type { OrderApiResponse, NostrmarketAPI } from '../services/nostrmarketAP
import { injectService, SERVICE_TOKENS } from '@/core/di-container' import { injectService, SERVICE_TOKENS } from '@/core/di-container'
import type { PaymentService } from '@/core/services/PaymentService' import type { PaymentService } from '@/core/services/PaymentService'
const router = useRouter()
const auth = useAuth() const auth = useAuth()
const { isConnected } = useMarket() const { isConnected } = useMarket()
const nostrmarketAPI = injectService(SERVICE_TOKENS.NOSTRMARKET_API) as NostrmarketAPI const nostrmarketAPI = injectService(SERVICE_TOKENS.NOSTRMARKET_API) as NostrmarketAPI
@ -341,12 +266,6 @@ const getActivityVariant = (type: string) => {
} }
} }
const navigateToMarket = () => router.push('/market')
const navigateToOrders = () => router.push('/market-dashboard?tab=orders')
const navigateToCart = () => router.push('/cart')
const navigateToStore = () => router.push('/market-dashboard?tab=store')
const navigateToProducts = () => router.push('/market-dashboard?tab=store')
// Load orders when component mounts // Load orders when component mounts
onMounted(() => { onMounted(() => {
fetchOrders() fetchOrders()

View file

@ -2,331 +2,522 @@
<div class="space-y-6"> <div class="space-y-6">
<!-- Header --> <!-- Header -->
<div> <div>
<h2 class="text-2xl font-bold text-foreground">Market Settings</h2> <h2 class="text-2xl font-bold text-foreground">Store Settings</h2>
<p class="text-muted-foreground mt-1">Configure your store and market preferences</p> <p class="text-muted-foreground mt-1">Configure your store information</p>
</div> </div>
<!-- Settings Tabs --> <!-- Loading State -->
<div class="border-b border-border"> <div v-if="isLoading" class="flex justify-center py-12">
<nav class="flex space-x-8"> <div class="animate-spin rounded-full h-8 w-8 border-b-2 border-primary"></div>
<button
v-for="tab in settingsTabs"
:key="tab.id"
@click="activeSettingsTab = tab.id"
:class="[
'py-2 px-1 border-b-2 font-medium text-sm transition-colors',
activeSettingsTab === tab.id
? 'border-primary text-primary'
: 'border-transparent text-muted-foreground hover:text-foreground hover:border-muted-foreground'
]"
>
{{ tab.name }}
</button>
</nav>
</div> </div>
<!-- Settings Content --> <!-- No Store State -->
<div class="min-h-[500px]"> <div v-else-if="!currentStall" class="text-center py-12">
<!-- Store Settings Tab --> <div class="w-16 h-16 bg-muted rounded-full flex items-center justify-center mx-auto mb-4">
<div v-if="activeSettingsTab === 'store'" class="space-y-6"> <Store class="w-8 h-8 text-muted-foreground" />
</div>
<h3 class="text-lg font-medium text-foreground mb-2">No Store Found</h3>
<p class="text-muted-foreground">
Create a store first to manage its settings
</p>
</div>
<!-- Store Settings Form -->
<div v-else class="space-y-6">
<!-- Store Information -->
<div class="bg-card p-6 rounded-lg border shadow-sm"> <div class="bg-card p-6 rounded-lg border shadow-sm">
<h3 class="text-lg font-semibold text-foreground mb-4">Store Information</h3> <h3 class="text-lg font-semibold text-foreground mb-4">Store Information</h3>
<div class="grid grid-cols-1 md:grid-cols-2 gap-6"> <form @submit="onSubmit" class="space-y-4">
<div> <FormField v-slot="{ componentField }" name="name">
<label class="block text-sm font-medium text-foreground mb-2">Store Name</label> <FormItem>
<Input v-model="storeSettings.name" placeholder="Enter store name" /> <FormLabel>Store Name *</FormLabel>
</div> <FormControl>
<div> <Input
<label class="block text-sm font-medium text-foreground mb-2">Store Description</label> placeholder="Enter store name"
<Input v-model="storeSettings.description" placeholder="Enter store description" /> :disabled="isSaving"
</div> v-bind="componentField"
<div>
<label class="block text-sm font-medium text-foreground mb-2">Contact Email</label>
<Input v-model="storeSettings.contactEmail" type="email" placeholder="Enter contact email" />
</div>
<div>
<label class="block text-sm font-medium text-foreground mb-2">Store Category</label>
<select v-model="storeSettings.category" class="w-full px-3 py-2 border border-input rounded-md focus:outline-none focus:ring-2 focus:ring-ring focus:border-ring bg-background text-foreground">
<option value="">Select category</option>
<option value="electronics">Electronics</option>
<option value="clothing">Clothing</option>
<option value="books">Books</option>
<option value="food">Food & Beverages</option>
<option value="services">Services</option>
<option value="other">Other</option>
</select>
</div>
</div>
<div class="mt-6">
<Button @click="saveStoreSettings" variant="default">
Save Store Settings
</Button>
</div>
</div>
</div>
<!-- Payment Settings Tab -->
<div v-else-if="activeSettingsTab === 'payment'" class="space-y-6">
<div class="bg-card p-6 rounded-lg border shadow-sm">
<h3 class="text-lg font-semibold text-foreground mb-4">Payment Configuration</h3>
<div class="space-y-4">
<div>
<label class="block text-sm font-medium text-foreground mb-2">Default Currency</label>
<select v-model="paymentSettings.defaultCurrency" class="w-full px-3 py-2 border border-input rounded-md focus:outline-none focus:ring-2 focus:ring-ring focus:border-ring bg-background text-foreground">
<option value="sat">Satoshi (sats)</option>
<option value="btc">Bitcoin (BTC)</option>
<option value="usd">US Dollar (USD)</option>
<option value="eur">Euro (EUR)</option>
</select>
</div>
<div>
<label class="block text-sm font-medium text-foreground mb-2">Invoice Expiry (minutes)</label>
<Input v-model="paymentSettings.invoiceExpiry" type="number" min="5" max="1440" placeholder="60" />
</div>
<div>
<label class="block text-sm font-medium text-foreground mb-2">Auto-generate Invoices</label>
<div class="flex items-center">
<input
v-model="paymentSettings.autoGenerateInvoices"
type="checkbox"
class="h-4 w-4 text-primary focus:ring-primary border-input rounded"
/> />
<label class="ml-2 text-sm text-foreground"> </FormControl>
Automatically generate Lightning invoices for new orders <FormMessage />
</label> </FormItem>
</FormField>
<FormField v-slot="{ componentField }" name="description">
<FormItem>
<FormLabel>Description</FormLabel>
<FormControl>
<Textarea
placeholder="Describe your store and what you sell"
:disabled="isSaving"
v-bind="componentField"
rows="3"
/>
</FormControl>
<FormDescription>
This description will be shown to customers browsing your store
</FormDescription>
<FormMessage />
</FormItem>
</FormField>
<FormField v-slot="{ componentField }" name="imageUrl">
<FormItem>
<FormLabel>Store Image URL</FormLabel>
<FormControl>
<Input
placeholder="https://example.com/store-image.jpg"
:disabled="isSaving"
v-bind="componentField"
/>
</FormControl>
<FormDescription>
Optional image to represent your store
</FormDescription>
<FormMessage />
</FormItem>
</FormField>
<!-- Read-only Currency Field -->
<div class="pt-4 border-t">
<label class="block text-sm font-medium text-muted-foreground mb-1">Currency</label>
<div class="text-foreground">{{ currentStall.currency }}</div>
<p class="text-xs text-muted-foreground mt-1">Currency is set when the store is created and cannot be changed</p>
</div> </div>
</div>
</div> <div class="pt-4">
<div class="mt-6"> <Button type="submit" :disabled="isSaving || !isFormValid">
<Button @click="savePaymentSettings" variant="default"> <span v-if="isSaving">Saving...</span>
Save Payment Settings <span v-else>Save Changes</span>
</Button> </Button>
</div> </div>
</form>
</div>
<!-- Shipping Zones Section -->
<div class="bg-card p-6 rounded-lg border shadow-sm">
<div class="flex items-center justify-between mb-4">
<div>
<h3 class="text-lg font-semibold text-foreground">Shipping Zones</h3>
<p class="text-sm text-muted-foreground">Configure where you ship and the associated costs</p>
</div>
<Button
v-if="!showAddZoneForm"
@click="showAddZoneForm = true"
size="sm"
:disabled="isZoneLoading"
>
<Plus class="w-4 h-4 mr-2" />
Add Zone
</Button>
</div>
<!-- Add Zone Form -->
<div v-if="showAddZoneForm" class="mb-6 p-4 bg-muted/50 rounded-lg border">
<h4 class="font-medium text-foreground mb-4">{{ editingZone ? 'Edit Zone' : 'Add New Zone' }}</h4>
<form @submit.prevent="saveZone" class="space-y-4">
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
<div>
<label class="block text-sm font-medium text-foreground mb-1">Zone Name *</label>
<Input
v-model="zoneForm.name"
placeholder="e.g., Domestic, International"
:disabled="isZoneSaving"
/>
</div>
<div>
<label class="block text-sm font-medium text-foreground mb-1">Shipping Cost *</label>
<Input
v-model.number="zoneForm.cost"
type="number"
min="0"
step="0.01"
placeholder="0"
:disabled="isZoneSaving"
/>
</div> </div>
</div> </div>
<!-- Nostr Settings Tab -->
<div v-else-if="activeSettingsTab === 'nostr'" class="space-y-6">
<div class="bg-card p-6 rounded-lg border shadow-sm">
<h3 class="text-lg font-semibold text-foreground mb-4">Nostr Network Configuration</h3>
<div class="space-y-4">
<div> <div>
<label class="block text-sm font-medium text-foreground mb-2">Relay Connections</label> <label class="block text-sm font-medium text-foreground mb-1">Countries/Regions</label>
<div class="space-y-2"> <Input
<div v-for="relay in nostrSettings.relays" :key="relay" class="flex items-center gap-2"> v-model="zoneForm.countriesInput"
<Input :value="relay" readonly class="flex-1" /> placeholder="e.g., USA, Canada, Mexico (comma-separated)"
<Button @click="removeRelay(relay)" variant="outline" size="sm"> :disabled="isZoneSaving"
<X class="w-4 h-4" /> />
</Button> <p class="text-xs text-muted-foreground mt-1">Comma-separated list of countries or regions this zone covers</p>
</div> </div>
<div class="flex gap-2"> <div class="flex gap-2">
<Input v-model="newRelay" placeholder="wss://relay.example.com" class="flex-1" /> <Button type="submit" :disabled="isZoneSaving || !isZoneFormValid" size="sm">
<Button @click="addRelay" variant="outline"> <span v-if="isZoneSaving">Saving...</span>
Add Relay <span v-else>{{ editingZone ? 'Update Zone' : 'Add Zone' }}</span>
</Button>
<Button type="button" variant="outline" @click="cancelZoneForm" size="sm" :disabled="isZoneSaving">
Cancel
</Button> </Button>
</div> </div>
</form>
</div> </div>
<!-- Zones List -->
<div v-if="isZoneLoading" class="flex justify-center py-8">
<div class="animate-spin rounded-full h-6 w-6 border-b-2 border-primary"></div>
</div> </div>
<div>
<label class="block text-sm font-medium text-foreground mb-2">Nostr Public Key</label> <div v-else-if="zones.length === 0 && !showAddZoneForm" class="text-center py-8 text-muted-foreground">
<Input :value="nostrSettings.pubkey" readonly class="font-mono text-sm" /> <Truck class="w-12 h-12 mx-auto mb-3 opacity-50" />
<p class="text-xs text-muted-foreground mt-1">Your Nostr public key for receiving orders</p> <p>No shipping zones configured</p>
<p class="text-sm">Add a shipping zone to enable shipping for your products</p>
</div> </div>
<div>
<label class="block text-sm font-medium text-foreground mb-2">Connection Status</label> <div v-else class="space-y-3">
<div class="flex items-center gap-2">
<div <div
class="w-3 h-3 rounded-full" v-for="zone in zones"
:class="orderEvents.isSubscribed ? 'bg-green-500' : 'bg-yellow-500'" :key="zone.id"
></div> class="flex items-center justify-between p-4 bg-muted/30 rounded-lg border"
<span class="text-sm text-muted-foreground"> >
{{ orderEvents.isSubscribed ? 'Connected to Nostr network' : 'Connecting to Nostr network...' }} <div class="flex-1">
<div class="font-medium text-foreground">{{ zone.name }}</div>
<div class="text-sm text-muted-foreground">
<span>{{ formatCost(zone.cost) }} {{ zone.currency }}</span>
<span v-if="zone.countries?.length" class="ml-2">
· {{ zone.countries.join(', ') }}
</span> </span>
</div> </div>
</div> </div>
</div> <div class="flex gap-2">
<div class="mt-6"> <Button
<Button @click="saveNostrSettings" variant="default"> variant="ghost"
Save Nostr Settings size="sm"
@click="editZone(zone)"
:disabled="isZoneSaving"
>
<Pencil class="w-4 h-4" />
</Button> </Button>
<Button
variant="ghost"
size="sm"
@click="confirmDeleteZone(zone)"
:disabled="isZoneSaving"
class="text-destructive hover:text-destructive"
>
<Trash2 class="w-4 h-4" />
</Button>
</div>
</div>
</div> </div>
</div> </div>
</div> </div>
<!-- Shipping Settings Tab --> <!-- Delete Zone Confirmation Dialog -->
<div v-else-if="activeSettingsTab === 'shipping'" class="space-y-6"> <Dialog :open="showDeleteConfirm" @update:open="showDeleteConfirm = $event">
<div class="bg-card p-6 rounded-lg border shadow-sm"> <DialogContent>
<h3 class="text-lg font-semibold text-foreground mb-4">Shipping Zones</h3> <DialogHeader>
<div class="space-y-4"> <DialogTitle>Delete Shipping Zone</DialogTitle>
<div v-for="zone in shippingSettings.zones" :key="zone.id" class="border border-border rounded-lg p-4"> <DialogDescription>
<div class="grid grid-cols-1 md:grid-cols-3 gap-4"> Are you sure you want to delete "{{ zoneToDelete?.name }}"?
<div> This action cannot be undone.
<label class="block text-sm font-medium text-foreground mb-1">Zone Name</label> </DialogDescription>
<Input v-model="zone.name" placeholder="Zone name" /> </DialogHeader>
</div> <DialogFooter>
<div> <Button variant="outline" @click="showDeleteConfirm = false" :disabled="isZoneSaving">
<label class="block text-sm font-medium text-foreground mb-1">Cost</label> Cancel
<Input v-model="zone.cost" type="number" min="0" step="0.01" placeholder="0.00" />
</div>
<div>
<label class="block text-sm font-medium text-foreground mb-1">Currency</label>
<select v-model="zone.currency" class="w-full px-3 py-2 border border-input rounded-md focus:outline-none focus:ring-2 focus:ring-ring focus:border-ring bg-background text-foreground">
<option value="sat">Satoshi (sats)</option>
<option value="btc">Bitcoin (BTC)</option>
<option value="usd">US Dollar (USD)</option>
</select>
</div>
</div>
<div class="mt-3 flex justify-end">
<Button @click="removeShippingZone(zone.id)" variant="outline" size="sm">
Remove Zone
</Button> </Button>
</div> <Button
</div> @click="deleteZone"
<Button @click="addShippingZone" variant="outline"> :disabled="isZoneSaving"
<Plus class="w-4 h-4 mr-2" /> variant="destructive"
Add Shipping Zone >
<span v-if="isZoneSaving">Deleting...</span>
<span v-else>Delete</span>
</Button> </Button>
</div> </DialogFooter>
<div class="mt-6"> </DialogContent>
<Button @click="saveShippingSettings" variant="default"> </Dialog>
Save Shipping Settings
</Button>
</div>
</div>
</div>
</div>
</div> </div>
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { ref, onMounted } from 'vue' import { ref, computed, onMounted, watch } from 'vue'
// import { useOrderEvents } from '@/composables/useOrderEvents' // TODO: Move to market module import { useForm } from 'vee-validate'
import { toTypedSchema } from '@vee-validate/zod'
import * as z from 'zod'
import { Button } from '@/components/ui/button' import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input' import { Input } from '@/components/ui/input'
import { Plus, X } from 'lucide-vue-next' import { Textarea } from '@/components/ui/textarea'
import {
FormControl,
FormDescription,
FormField,
FormItem,
FormLabel,
FormMessage,
} from '@/components/ui/form'
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog'
import { Store, Plus, Pencil, Trash2, Truck } from 'lucide-vue-next'
import { injectService, SERVICE_TOKENS } from '@/core/di-container'
import type { NostrmarketAPI, Stall, Zone } from '../services/nostrmarketAPI'
import { auth } from '@/composables/useAuthService'
import { useToast } from '@/core/composables/useToast'
// const marketStore = useMarketStore() // Services
// const orderEvents = useOrderEvents() // TODO: Move to market module const nostrmarketAPI = injectService(SERVICE_TOKENS.NOSTRMARKET_API) as NostrmarketAPI
const orderEvents = { isSubscribed: ref(false) } // Temporary mock const paymentService = injectService(SERVICE_TOKENS.PAYMENT_SERVICE) as any
const toast = useToast()
// Local state // State
const activeSettingsTab = ref('store') const isLoading = ref(true)
const newRelay = ref('') const isSaving = ref(false)
const currentStall = ref<Stall | null>(null)
// Settings data // Zone state
const storeSettings = ref({ const zones = ref<Zone[]>([])
name: 'My Store', const isZoneLoading = ref(false)
description: 'A great place to shop', const isZoneSaving = ref(false)
contactEmail: 'store@example.com', const showAddZoneForm = ref(false)
category: 'other' const editingZone = ref<Zone | null>(null)
}) const showDeleteConfirm = ref(false)
const zoneToDelete = ref<Zone | null>(null)
const paymentSettings = ref({ // Zone form
defaultCurrency: 'sat', const zoneForm = ref({
invoiceExpiry: 60, name: '',
autoGenerateInvoices: true
})
const nostrSettings = ref({
relays: [
'wss://relay.damus.io',
'wss://relay.snort.social',
'wss://nostr-pub.wellorder.net'
],
pubkey: 'npub1...' // TODO: Get from auth
})
const shippingSettings = ref({
zones: [
{
id: '1',
name: 'Local',
cost: 0, cost: 0,
currency: 'sat', countriesInput: ''
estimatedDays: '1-2 days'
},
{
id: '2',
name: 'Domestic',
cost: 1000,
currency: 'sat',
estimatedDays: '3-5 days'
},
{
id: '3',
name: 'International',
cost: 5000,
currency: 'sat',
estimatedDays: '7-14 days'
}
]
}) })
// Settings tabs const isZoneFormValid = computed(() => {
const settingsTabs = [ return zoneForm.value.name.trim().length > 0 && zoneForm.value.cost >= 0
{ id: 'store', name: 'Store Settings' }, })
{ id: 'payment', name: 'Payment Settings' },
{ id: 'nostr', name: 'Nostr Network' },
{ id: 'shipping', name: 'Shipping Zones' }
]
// Methods // Form schema - only fields that exist in the Stall model
const saveStoreSettings = () => { const formSchema = toTypedSchema(z.object({
// TODO: Save store settings name: z.string().min(1, "Store name is required").max(100, "Store name must be less than 100 characters"),
console.log('Saving store settings:', storeSettings.value) description: z.string().max(500, "Description must be less than 500 characters").optional(),
imageUrl: z.string().url("Must be a valid URL").optional().or(z.literal(''))
}))
// Form setup
const form = useForm({
validationSchema: formSchema,
initialValues: {
name: '',
description: '',
imageUrl: ''
}
})
const { resetForm, meta } = form
const isFormValid = computed(() => meta.value.valid)
// Format cost for display
const formatCost = (cost: number) => {
return cost === 0 ? 'Free' : cost.toString()
} }
const savePaymentSettings = () => { // Load store data
// TODO: Save payment settings const loadStoreData = async () => {
console.log('Saving payment settings:', paymentSettings.value) const currentUser = auth.currentUser?.value
} if (!currentUser?.wallets?.length) {
isLoading.value = false
return
}
const saveNostrSettings = () => { const inkey = paymentService.getPreferredWalletInvoiceKey()
// TODO: Save Nostr settings if (!inkey) {
console.log('Saving Nostr settings:', nostrSettings.value) isLoading.value = false
} return
}
const saveShippingSettings = () => { try {
// TODO: Save shipping settings const stalls = await nostrmarketAPI.getStalls(inkey)
console.log('Saving shipping settings:', shippingSettings.value) if (stalls && stalls.length > 0) {
} currentStall.value = stalls[0]
const addRelay = () => { // Update form with current values
if (newRelay.value && !nostrSettings.value.relays.includes(newRelay.value)) { resetForm({
nostrSettings.value.relays.push(newRelay.value) values: {
newRelay.value = '' name: stalls[0].name || '',
description: stalls[0].config?.description || '',
imageUrl: stalls[0].config?.image_url || ''
}
})
// Load zones
await loadZones()
}
} catch (error) {
console.error('Failed to load store data:', error)
toast.error('Failed to load store settings')
} finally {
isLoading.value = false
} }
} }
const removeRelay = (relay: string) => { // Load shipping zones
const index = nostrSettings.value.relays.indexOf(relay) const loadZones = async () => {
if (index > -1) { const inkey = paymentService.getPreferredWalletInvoiceKey()
nostrSettings.value.relays.splice(index, 1) if (!inkey) return
isZoneLoading.value = true
try {
zones.value = await nostrmarketAPI.getZones(inkey)
} catch (error) {
console.error('Failed to load zones:', error)
toast.error('Failed to load shipping zones')
} finally {
isZoneLoading.value = false
} }
} }
const addShippingZone = () => { // Save store settings
const newZone = { const onSubmit = form.handleSubmit(async (values) => {
id: Date.now().toString(), if (!currentStall.value?.id) return
name: 'New Zone',
isSaving.value = true
try {
const adminKey = paymentService.getPreferredWalletAdminKey()
if (!adminKey) {
throw new Error('No wallet admin key available')
}
// Build full stall object with updated values (API requires PUT with full object)
const stallToUpdate = {
...currentStall.value,
name: values.name,
config: {
...currentStall.value.config,
description: values.description || '',
image_url: values.imageUrl || undefined
}
}
const updatedStall = await nostrmarketAPI.updateStall(adminKey, stallToUpdate)
currentStall.value = updatedStall
toast.success('Store settings saved successfully!')
} catch (error) {
console.error('Failed to save store settings:', error)
toast.error('Failed to save store settings')
} finally {
isSaving.value = false
}
})
// Zone form functions
const resetZoneForm = () => {
zoneForm.value = {
name: '',
cost: 0, cost: 0,
currency: 'sat', countriesInput: ''
estimatedDays: '3-5 days'
} }
shippingSettings.value.zones.push(newZone) editingZone.value = null
showAddZoneForm.value = false
} }
const removeShippingZone = (zoneId: string) => { const cancelZoneForm = () => {
const index = shippingSettings.value.zones.findIndex(z => z.id === zoneId) resetZoneForm()
if (index > -1) { }
shippingSettings.value.zones.splice(index, 1)
const editZone = (zone: Zone) => {
editingZone.value = zone
zoneForm.value = {
name: zone.name,
cost: zone.cost,
countriesInput: zone.countries?.join(', ') || ''
}
showAddZoneForm.value = true
}
const saveZone = async () => {
const adminKey = paymentService.getPreferredWalletAdminKey()
if (!adminKey || !currentStall.value) return
isZoneSaving.value = true
try {
const countries = zoneForm.value.countriesInput
.split(',')
.map(c => c.trim())
.filter(c => c.length > 0)
const zoneData: Zone = {
id: editingZone.value?.id || '',
name: zoneForm.value.name.trim(),
currency: currentStall.value.currency,
cost: zoneForm.value.cost,
countries
}
if (editingZone.value) {
// Update existing zone
await nostrmarketAPI.updateZone(adminKey, editingZone.value.id, zoneData)
toast.success('Shipping zone updated!')
} else {
// Create new zone
await nostrmarketAPI.createZone(adminKey, {
name: zoneData.name,
currency: zoneData.currency,
cost: zoneData.cost,
countries: zoneData.countries
})
toast.success('Shipping zone added!')
}
// Reload zones and reset form
await loadZones()
resetZoneForm()
} catch (error) {
console.error('Failed to save zone:', error)
toast.error(editingZone.value ? 'Failed to update zone' : 'Failed to add zone')
} finally {
isZoneSaving.value = false
} }
} }
// Lifecycle const confirmDeleteZone = (zone: Zone) => {
onMounted(() => { zoneToDelete.value = zone
console.log('Market Settings component loaded') showDeleteConfirm.value = true
}
const deleteZone = async () => {
const adminKey = paymentService.getPreferredWalletAdminKey()
if (!adminKey || !zoneToDelete.value) return
isZoneSaving.value = true
try {
await nostrmarketAPI.deleteZone(adminKey, zoneToDelete.value.id)
toast.success('Shipping zone deleted!')
await loadZones()
} catch (error) {
console.error('Failed to delete zone:', error)
toast.error('Failed to delete zone')
} finally {
isZoneSaving.value = false
showDeleteConfirm.value = false
zoneToDelete.value = null
}
}
// Watch for auth changes
watch(() => auth.currentUser?.value?.pubkey, async (newPubkey, oldPubkey) => {
if (newPubkey !== oldPubkey) {
isLoading.value = true
await loadStoreData()
}
})
// Initialize
onMounted(async () => {
await loadStoreData()
}) })
</script> </script>

View file

@ -49,84 +49,47 @@
<!-- Stores Grid (shown when merchant profile exists) --> <!-- Stores Grid (shown when merchant profile exists) -->
<div v-else> <div v-else>
<!-- Header Section -->
<div class="mb-8">
<div class="flex items-center justify-between mb-2">
<div>
<h2 class="text-2xl font-bold text-foreground">My Stores</h2>
<p class="text-muted-foreground mt-1">
Manage your stores and products
</p>
</div>
<Button @click="navigateToMarket" variant="outline">
<Store class="w-4 h-4 mr-2" />
Browse Market
</Button>
</div>
</div>
<!-- Loading State for Stalls --> <!-- Loading State for Stalls -->
<div v-if="isLoadingStalls" class="flex justify-center py-12"> <div v-if="isLoadingStalls" class="flex justify-center py-12">
<div class="animate-spin rounded-full h-8 w-8 border-b-2 border-primary"></div> <div class="animate-spin rounded-full h-8 w-8 border-b-2 border-primary"></div>
</div> </div>
<!-- Stores Cards Grid --> <!-- No Store - Create First Store -->
<div v-else class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6 mb-8"> <div v-else-if="userStalls.length === 0" class="flex flex-col items-center justify-center py-12">
<!-- Existing Store Cards --> <div class="w-24 h-24 bg-muted rounded-full flex items-center justify-center mb-6">
<StoreCard <Store class="w-12 h-12 text-muted-foreground" />
v-for="stall in userStalls"
:key="stall.id"
:stall="stall"
@manage="manageStall"
@view-products="viewStallProducts"
/>
<!-- Create New Store Card -->
<div class="bg-card rounded-lg border-2 border-dashed border-muted-foreground/25 hover:border-primary/50 transition-colors">
<button
@click="showCreateStoreDialog = true"
class="w-full h-full p-6 flex flex-col items-center justify-center min-h-[200px] hover:bg-muted/30 transition-colors"
>
<div class="w-12 h-12 bg-primary/10 rounded-full flex items-center justify-center mb-3">
<Plus class="w-6 h-6 text-primary" />
</div> </div>
<h3 class="text-lg font-semibold text-foreground mb-1">Create New Store</h3> <h2 class="text-2xl font-bold text-foreground mb-2">Create Your Store</h2>
<p class="text-sm text-muted-foreground text-center"> <p class="text-muted-foreground text-center mb-6 max-w-md">
Add another store to expand your marketplace presence Set up your store to start selling products on the Nostr marketplace.
</p> </p>
</button> <Button
</div> @click="showCreateStoreDialog = true"
variant="default"
size="lg"
>
<Plus class="w-5 h-5 mr-2" />
Create Store
</Button>
</div> </div>
<!-- Active Store Dashboard (shown when a store is selected) --> <!-- Active Store Dashboard (shown when user has a store) -->
<div v-if="activeStall"> <div v-else-if="activeStall">
<!-- Header --> <!-- Header -->
<div class="space-y-4 mb-6"> <div class="space-y-4 mb-6">
<!-- Top row with back button and currency -->
<div class="flex items-center gap-3">
<Button @click="activeStallId = null" variant="ghost" size="sm">
Back to Stores
</Button>
<div class="h-4 w-px bg-border"></div>
<Badge variant="secondary">{{ activeStall.currency }}</Badge>
</div>
<!-- Store info and actions --> <!-- Store info and actions -->
<div class="flex flex-col sm:flex-row sm:items-start sm:justify-between gap-4"> <div class="flex flex-col sm:flex-row sm:items-start sm:justify-between gap-4">
<div class="flex-1"> <div class="flex-1">
<div class="flex items-center gap-3 mb-1">
<h2 class="text-xl sm:text-2xl font-bold text-foreground">{{ activeStall.name }}</h2> <h2 class="text-xl sm:text-2xl font-bold text-foreground">{{ activeStall.name }}</h2>
<p class="text-sm sm:text-base text-muted-foreground mt-1">{{ activeStall.config?.description || 'Manage incoming orders and your products' }}</p> <Badge variant="secondary">{{ activeStall.currency }}</Badge>
</div>
<p class="text-sm sm:text-base text-muted-foreground">{{ activeStall.config?.description || 'Manage incoming orders and your products' }}</p>
</div> </div>
<div class="flex flex-col sm:flex-row gap-2 sm:gap-3">
<Button @click="navigateToMarket" variant="outline" class="w-full sm:w-auto"> <Button @click="navigateToMarket" variant="outline" class="w-full sm:w-auto">
<Store class="w-4 h-4 mr-2" /> <Store class="w-4 h-4 mr-2" />
<span class="sm:inline">Browse Market</span> <span class="sm:inline">Browse Market</span>
</Button> </Button>
<Button @click="showCreateProductDialog = true" variant="default" class="w-full sm:w-auto">
<Plus class="w-4 h-4 mr-2" />
<span>Add Product</span>
</Button>
</div>
</div> </div>
</div> </div>
@ -188,20 +151,23 @@
</div> </div>
</div> </div>
<!-- Customer Satisfaction --> <!-- Customer Satisfaction (Coming Soon) -->
<div class="bg-card p-4 sm:p-6 rounded-lg border shadow-sm"> <div class="bg-card p-4 sm:p-6 rounded-lg border shadow-sm opacity-50 relative">
<div class="absolute top-2 right-2">
<Badge variant="outline" class="text-xs">Coming Soon</Badge>
</div>
<div class="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-2"> <div class="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-2">
<div class="flex-1"> <div class="flex-1">
<p class="text-xs sm:text-sm font-medium text-muted-foreground">Satisfaction</p> <p class="text-xs sm:text-sm font-medium text-muted-foreground">Satisfaction</p>
<p class="text-xl sm:text-2xl font-bold text-foreground">{{ storeStats.satisfaction }}%</p> <p class="text-xl sm:text-2xl font-bold text-muted-foreground">--%</p>
</div> </div>
<div class="w-10 h-10 sm:w-12 sm:h-12 bg-yellow-500/10 rounded-lg flex items-center justify-center flex-shrink-0"> <div class="w-10 h-10 sm:w-12 sm:h-12 bg-muted rounded-lg flex items-center justify-center flex-shrink-0">
<Star class="w-5 h-5 sm:w-6 sm:h-6 text-yellow-500" /> <Star class="w-5 h-5 sm:w-6 sm:h-6 text-muted-foreground" />
</div> </div>
</div> </div>
<div class="mt-3 sm:mt-4"> <div class="mt-3 sm:mt-4">
<div class="flex items-center text-xs sm:text-sm text-muted-foreground"> <div class="flex items-center text-xs sm:text-sm text-muted-foreground">
<span>{{ storeStats.totalReviews }} reviews</span> <span>No reviews yet</span>
</div> </div>
</div> </div>
</div> </div>
@ -430,7 +396,6 @@ import { injectService, SERVICE_TOKENS } from '@/core/di-container'
import CreateStoreDialog from './CreateStoreDialog.vue' import CreateStoreDialog from './CreateStoreDialog.vue'
import CreateProductDialog from './CreateProductDialog.vue' import CreateProductDialog from './CreateProductDialog.vue'
import DeleteConfirmDialog from './DeleteConfirmDialog.vue' import DeleteConfirmDialog from './DeleteConfirmDialog.vue'
import StoreCard from './StoreCard.vue'
import MerchantOrders from './MerchantOrders.vue' import MerchantOrders from './MerchantOrders.vue'
const router = useRouter() const router = useRouter()
@ -652,14 +617,6 @@ const loadStallProducts = async () => {
} }
} }
const manageStall = (stallId: string) => {
activeStallId.value = stallId
}
const viewStallProducts = (stallId: string) => {
activeStallId.value = stallId
}
const navigateToMarket = () => router.push('/market') const navigateToMarket = () => router.push('/market')
const checkMerchantProfile = async () => { const checkMerchantProfile = async () => {
@ -697,7 +654,6 @@ const checkMerchantProfile = async () => {
// Event handlers // Event handlers
const onStoreCreated = async (_stall: Stall) => { const onStoreCreated = async (_stall: Stall) => {
await loadStallsList() await loadStallsList()
toast.success('Store created successfully!')
} }
const onProductCreated = async (_product: Product) => { const onProductCreated = async (_product: Product) => {

View file

@ -212,19 +212,6 @@
</div> </div>
</div> </div>
<!-- Debug Information (Development Only) -->
<div v-if="isDevelopment" class="mt-8 p-4 bg-gray-100 rounded-lg">
<h4 class="font-medium mb-2">Debug Information</h4>
<div class="text-sm space-y-1">
<div>Total Orders in Store: {{ Object.keys(marketStore.orders).length }}</div>
<div>Filtered Orders: {{ filteredOrders.length }}</div>
<div>Order Events Subscribed: {{ orderEvents.isSubscribed ? 'Yes' : 'No' }}</div>
<div>Relay Hub Connected: {{ relayHub.isConnected ? 'Yes' : 'No' }}</div>
<div>Auth Status: {{ auth.isAuthenticated ? 'Authenticated' : 'Not Authenticated' }}</div>
<div>Current User: {{ auth.currentUser?.value?.pubkey ? 'Yes' : 'No' }}</div>
</div>
</div>
<!-- Empty State --> <!-- Empty State -->
<div v-else class="text-center py-12"> <div v-else class="text-center py-12">
<div class="w-16 h-16 bg-muted rounded-full flex items-center justify-center mx-auto mb-4"> <div class="w-16 h-16 bg-muted rounded-full flex items-center justify-center mx-auto mb-4">
@ -312,8 +299,6 @@ const pendingOrders = computed(() => allOrders.value.filter(o => o.status === 'p
const paidOrders = computed(() => allOrders.value.filter(o => o.status === 'paid').length) const paidOrders = computed(() => allOrders.value.filter(o => o.status === 'paid').length)
const pendingPayments = computed(() => allOrders.value.filter(o => !isOrderPaid(o)).length) const pendingPayments = computed(() => allOrders.value.filter(o => !isOrderPaid(o)).length)
const isDevelopment = computed(() => import.meta.env.DEV)
// Methods // Methods
const isOrderPaid = (order: any) => { const isOrderPaid = (order: any) => {
// Prioritize the 'paid' field from Nostr status updates (type 2) // Prioritize the 'paid' field from Nostr status updates (type 2)
@ -497,34 +482,9 @@ onMounted(() => {
paymentService.forceResetPaymentState() paymentService.forceResetPaymentState()
} }
// Orders are already loaded in the market store
console.log('Order History component loaded with', allOrders.value.length, 'orders')
console.log('Market store orders:', marketStore.orders)
// Debug: Log order details for orders with payment requests
allOrders.value.forEach(order => {
if (order.paymentRequest) {
console.log('Order with payment request:', {
id: order.id,
paymentRequest: order.paymentRequest.substring(0, 50) + '...',
hasPaymentRequest: !!order.paymentRequest,
status: order.status,
paymentStatus: order.paymentStatus
})
}
})
console.log('Order events status:', orderEvents.isSubscribed.value)
console.log('Relay hub connected:', relayHub.isConnected.value)
console.log('Auth status:', auth.isAuthenticated)
console.log('Current user:', auth.currentUser?.value?.pubkey)
// Start listening for order events if not already listening // Start listening for order events if not already listening
if (!orderEvents.isSubscribed.value) { if (!orderEvents.isSubscribed.value) {
console.log('Starting order events listener...')
orderEvents.initialize() orderEvents.initialize()
} else {
console.log('Order events already listening')
} }
}) })

View file

@ -158,6 +158,7 @@ import { Badge } from '@/components/ui/badge'
import ProgressiveImage from '@/components/ui/image/ProgressiveImage.vue' import ProgressiveImage from '@/components/ui/image/ProgressiveImage.vue'
import { ShoppingCart, Package, ChevronLeft, ChevronRight } from 'lucide-vue-next' import { ShoppingCart, Package, ChevronLeft, ChevronRight } from 'lucide-vue-next'
import type { Product } from '@/modules/market/stores/market' import type { Product } from '@/modules/market/stores/market'
import { useImageOptimizer } from '@/modules/base/composables/useImageOptimizer'
interface Props { interface Props {
product: Product product: Product
@ -171,6 +172,7 @@ const emit = defineEmits<{
'view-stall': [stallId: string] 'view-stall': [stallId: string]
}>() }>()
const { thumbnail } = useImageOptimizer()
const imageError = ref(false) const imageError = ref(false)
const currentImageIndex = ref(0) const currentImageIndex = ref(0)
@ -188,7 +190,7 @@ const currentImage = computed(() => {
if (productImages.value.length === 0) { if (productImages.value.length === 0) {
return null return null
} }
return productImages.value[currentImageIndex.value] return thumbnail(productImages.value[currentImageIndex.value], 400)
}) })
// Image cycling methods // Image cycling methods

View file

@ -329,6 +329,28 @@ export class NostrmarketAPI extends BaseService {
return stall return stall
} }
/**
* Update an existing stall
* Note: The LNbits API uses PUT and expects the full stall object
*/
async updateStall(
walletAdminkey: string,
stallData: Stall
): Promise<Stall> {
const stall = await this.request<Stall>(
`/api/v1/stall/${stallData.id}`,
walletAdminkey,
{
method: 'PUT',
body: JSON.stringify(stallData),
}
)
this.debug('Updated stall:', { stallId: stall.id, stallName: stall.name })
return stall
}
/** /**
* Get available shipping zones * Get available shipping zones
*/ */
@ -371,30 +393,70 @@ export class NostrmarketAPI extends BaseService {
} }
/** /**
* Get available currencies * Update an existing shipping zone
*/ */
async getCurrencies(): Promise<string[]> { async updateZone(
const baseCurrencies = ['sat'] walletAdminkey: string,
zoneId: string,
try { zoneData: Zone
const apiCurrencies = await this.request<string[]>( ): Promise<Zone> {
'/api/v1/currencies', const zone = await this.request<Zone>(
'', // No authentication needed for currencies endpoint `/api/v1/zone/${zoneId}`,
{ method: 'GET' } walletAdminkey,
{
method: 'PATCH',
body: JSON.stringify(zoneData),
}
) )
if (apiCurrencies && Array.isArray(apiCurrencies)) { this.debug('Updated zone:', { zoneId: zone.id, zoneName: zone.name })
// Combine base currencies with API currencies, removing duplicates
const allCurrencies = [...baseCurrencies, ...apiCurrencies.filter(currency => !baseCurrencies.includes(currency))] return zone
this.debug('Retrieved currencies:', { count: allCurrencies.length, currencies: allCurrencies })
return allCurrencies
} }
this.debug('No API currencies returned, using base currencies only') /**
return baseCurrencies * Delete a shipping zone
*/
async deleteZone(walletAdminkey: string, zoneId: string): Promise<void> {
await this.request<void>(
`/api/v1/zone/${zoneId}`,
walletAdminkey,
{ method: 'DELETE' }
)
this.debug('Deleted zone:', { zoneId })
}
/**
* Get available currencies from the LNbits core API
* This endpoint returns currencies allowed by the server configuration
*/
async getCurrencies(): Promise<string[]> {
// Call the LNbits core API directly (not under /nostrmarket)
const url = `${this.baseUrl}/api/v1/currencies`
try {
const response = await fetch(url, {
method: 'GET',
headers: { 'Content-Type': 'application/json' }
})
if (!response.ok) {
throw new Error(`Failed to fetch currencies: ${response.status}`)
}
const apiCurrencies = await response.json()
if (apiCurrencies && Array.isArray(apiCurrencies)) {
this.debug('Retrieved currencies from LNbits core:', { count: apiCurrencies.length, currencies: apiCurrencies })
return apiCurrencies
}
this.debug('No currencies returned from server, using default')
return ['sat']
} catch (error) { } catch (error) {
this.debug('Failed to get currencies, falling back to base currencies:', error) this.debug('Failed to get currencies, using default:', error)
return baseCurrencies return ['sat']
} }
} }

View file

@ -14,7 +14,7 @@
<Button @click="$router.push('/market')" variant="outline"> <Button @click="$router.push('/market')" variant="outline">
Continue Shopping Continue Shopping
</Button> </Button>
<Button @click="$router.push('/market/dashboard')" variant="default"> <Button @click="$router.push('/market/dashboard?tab=orders')" variant="default">
View My Orders View My Orders
</Button> </Button>
</div> </div>
@ -56,7 +56,7 @@
<div class="w-16 h-16 bg-muted rounded-lg flex items-center justify-center"> <div class="w-16 h-16 bg-muted rounded-lg flex items-center justify-center">
<ProgressiveImage <ProgressiveImage
v-if="item.product.images?.[0]" v-if="item.product.images?.[0]"
:src="item.product.images[0]" :src="thumbnail(item.product.images[0], 128)"
:alt="item.product.name" :alt="item.product.name"
container-class="w-full h-full" container-class="w-full h-full"
image-class="w-full h-full object-cover rounded-lg" image-class="w-full h-full object-cover rounded-lg"
@ -108,15 +108,17 @@
</CardContent> </CardContent>
</Card> </Card>
<!-- Shipping Information --> <!-- Shipping & Contact Information -->
<Card v-if="!orderConfirmed"> <Card>
<CardHeader> <CardHeader>
<CardTitle>Shipping Information</CardTitle> <CardTitle>Shipping & Contact</CardTitle>
<CardDescription>Select your shipping zone</CardDescription> <CardDescription>Select shipping and provide your contact details</CardDescription>
</CardHeader> </CardHeader>
<CardContent> <CardContent class="space-y-6">
<!-- Shipping Zones --> <!-- Shipping Zones -->
<div v-if="availableShippingZones.length > 0" class="space-y-3"> <div v-if="availableShippingZones.length > 0">
<Label class="mb-3 block">Shipping Zone</Label>
<div class="space-y-3">
<div <div
v-for="zone in availableShippingZones" v-for="zone in availableShippingZones"
:key="zone.id" :key="zone.id"
@ -150,76 +152,29 @@
</div> </div>
</div> </div>
</div> </div>
</div>
<div v-else class="text-center py-6"> <div v-else class="text-center py-4 bg-muted/50 rounded-lg">
<p class="text-muted-foreground">This merchant hasn't configured shipping zones yet.</p> <p class="text-muted-foreground">This merchant hasn't configured shipping zones yet.</p>
<p class="text-sm text-muted-foreground">Please contact the merchant for shipping information.</p> <p class="text-sm text-muted-foreground">Please contact the merchant for shipping information.</p>
</div> </div>
<!-- Confirm Order Button --> <!-- Separator -->
<div class="mt-6"> <div class="border-t border-border" />
<Button
@click="confirmOrder"
:disabled="availableShippingZones.length > 0 && !selectedShippingZone"
class="w-full"
size="lg"
>
Confirm Order
</Button>
</div>
</CardContent>
</Card>
<!-- Contact & Payment Information --> <!-- Shipping Address (shown when required for physical delivery) -->
<Card v-if="orderConfirmed"> <div v-if="requiresShippingAddress">
<CardHeader> <Label for="address">Shipping Address *</Label>
<CardTitle>Contact & Payment Information</CardTitle>
<CardDescription>Provide your details for order processing</CardDescription>
</CardHeader>
<CardContent class="space-y-6">
<!-- Contact Form -->
<div class="grid grid-cols-1 md:grid-cols-2 gap-6">
<div>
<Label for="email">Email (optional)</Label>
<Input
id="email"
v-model="contactData.email"
type="email"
placeholder="your@email.com"
/>
<p class="text-xs text-muted-foreground mt-1">Merchant may not use email</p>
</div>
<div>
<Label for="npub">Alternative Npub (optional)</Label>
<Input
id="npub"
v-model="contactData.npub"
placeholder="npub..."
/>
<p class="text-xs text-muted-foreground mt-1">Different Npub for communication</p>
</div>
</div>
<div>
<Label for="address">
{{ selectedShippingZone?.requiresPhysicalShipping !== false ? 'Shipping Address' : 'Contact Address (optional)' }}
</Label>
<Textarea <Textarea
id="address" id="address"
v-model="contactData.address" v-model="contactData.address"
:placeholder="selectedShippingZone?.requiresPhysicalShipping !== false placeholder="Full shipping address..."
? 'Full shipping address...'
: 'Contact address (optional)...'"
rows="3" rows="3"
/> />
<p class="text-xs text-muted-foreground mt-1"> <p class="text-xs text-muted-foreground mt-1">Required for physical delivery</p>
{{ selectedShippingZone?.requiresPhysicalShipping !== false
? 'Required for physical delivery'
: 'Optional for digital items or pickup' }}
</p>
</div> </div>
<!-- Message to Merchant (visible) -->
<div> <div>
<Label for="message">Message to Merchant (optional)</Label> <Label for="message">Message to Merchant (optional)</Label>
<Textarea <Textarea
@ -230,40 +185,72 @@
/> />
</div> </div>
<!-- Payment Method Selection --> <!-- Optional Contact Info (collapsible) -->
<Collapsible v-model:open="showOptionalContact">
<CollapsibleTrigger class="flex items-center gap-2 text-sm text-muted-foreground hover:text-foreground transition-colors cursor-pointer w-full">
<ChevronDown
class="w-4 h-4 transition-transform"
:class="{ 'rotate-180': showOptionalContact }"
/>
<span>Additional contact info (optional)</span>
</CollapsibleTrigger>
<CollapsibleContent class="pt-4 space-y-4">
<!-- Contact Address (optional for digital/pickup) -->
<div v-if="!requiresShippingAddress">
<Label for="address">Contact Address</Label>
<Textarea
id="address"
v-model="contactData.address"
placeholder="Contact address (optional)..."
rows="3"
/>
<p class="text-xs text-muted-foreground mt-1">Optional for digital items or pickup</p>
</div>
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
<div> <div>
<Label>Payment Method</Label> <Label for="email">Email</Label>
<div class="flex gap-3 mt-2"> <Input
<div id="email"
v-for="method in paymentMethods" v-model="contactData.email"
:key="method.value" type="email"
@click="paymentMethod = method.value" placeholder="your@email.com"
:class="[ />
'p-3 border rounded-lg cursor-pointer text-center flex-1 transition-colors', <p class="text-xs text-muted-foreground mt-1">Merchant may not use email</p>
paymentMethod === method.value </div>
? 'border-primary bg-primary/10'
: 'border-border hover:border-primary/50' <div>
]" <Label for="npub">Alternative Npub</Label>
> <Input
<p class="font-medium">{{ method.label }}</p> id="npub"
v-model="contactData.npub"
placeholder="npub..."
/>
<p class="text-xs text-muted-foreground mt-1">Different Npub for communication</p>
</div> </div>
</div> </div>
</CollapsibleContent>
</Collapsible>
<!-- Payment Method (Lightning only for now) -->
<div class="flex items-center gap-2 text-sm text-muted-foreground">
<span></span>
<span>Payment via Lightning Network</span>
</div> </div>
<!-- Place Order Button --> <!-- Place Order Button -->
<div class="pt-4 border-t border-border"> <div class="pt-4 border-t border-border">
<Button <Button
@click="placeOrder" @click="placeOrder"
:disabled="isPlacingOrder || (selectedShippingZone?.requiresPhysicalShipping !== false && !contactData.address)" :disabled="isPlacingOrder || !canPlaceOrder"
class="w-full" class="w-full"
size="lg" size="lg"
> >
<span v-if="isPlacingOrder" class="animate-spin mr-2"></span> <span v-if="isPlacingOrder" class="animate-spin mr-2"></span>
Place Order - {{ formatPrice(orderTotal, checkoutCart.currency) }} Place Order - {{ formatPrice(orderTotal, checkoutCart.currency) }}
</Button> </Button>
<p v-if="selectedShippingZone?.requiresPhysicalShipping !== false && !contactData.address" <p v-if="!canPlaceOrder" class="text-xs text-destructive mt-2 text-center">
class="text-xs text-destructive mt-2 text-center"> {{ orderValidationMessage }}
Shipping address is required for physical delivery
</p> </p>
</div> </div>
</CardContent> </CardContent>
@ -291,11 +278,19 @@ import { Input } from '@/components/ui/input'
import { Textarea } from '@/components/ui/textarea' import { Textarea } from '@/components/ui/textarea'
import { Label } from '@/components/ui/label' import { Label } from '@/components/ui/label'
import ProgressiveImage from '@/components/ui/image/ProgressiveImage.vue' import ProgressiveImage from '@/components/ui/image/ProgressiveImage.vue'
import { useImageOptimizer } from '@/modules/base/composables/useImageOptimizer'
import { import {
Package, Package,
CheckCircle CheckCircle,
ChevronDown
} from 'lucide-vue-next' } from 'lucide-vue-next'
import {
Collapsible,
CollapsibleContent,
CollapsibleTrigger
} from '@/components/ui/collapsible'
const { thumbnail } = useImageOptimizer()
const route = useRoute() const route = useRoute()
const marketStore = useMarketStore() const marketStore = useMarketStore()
const authService = injectService(SERVICE_TOKENS.AUTH_SERVICE) as any const authService = injectService(SERVICE_TOKENS.AUTH_SERVICE) as any
@ -303,11 +298,10 @@ const authService = injectService(SERVICE_TOKENS.AUTH_SERVICE) as any
// State // State
const isLoading = ref(true) const isLoading = ref(true)
const error = ref<string | null>(null) const error = ref<string | null>(null)
const orderConfirmed = ref(false)
const orderPlaced = ref(false) const orderPlaced = ref(false)
const isPlacingOrder = ref(false) const isPlacingOrder = ref(false)
const selectedShippingZone = ref<any>(null) const selectedShippingZone = ref<any>(null)
const paymentMethod = ref('ln') const showOptionalContact = ref(false)
// Form data // Form data
const contactData = ref({ const contactData = ref({
@ -317,12 +311,7 @@ const contactData = ref({
message: '' message: ''
}) })
// Payment methods // TODO: Add BTC Onchain and Cashu payment options in the future
const paymentMethods = [
{ label: 'Lightning Network', value: 'ln' },
{ label: 'BTC Onchain', value: 'btc' },
{ label: 'Cashu', value: 'cashu' }
]
// Computed // Computed
const stallId = computed(() => route.params.stallId as string) const stallId = computed(() => route.params.stallId as string)
@ -383,26 +372,41 @@ const availableShippingZones = computed(() => {
}) })
}) })
// Determine if shipping address is required
const requiresShippingAddress = computed(() => {
return selectedShippingZone.value?.requiresPhysicalShipping !== false
})
// Validation for placing order
const canPlaceOrder = computed(() => {
// Must select shipping zone if zones are available
if (availableShippingZones.value.length > 0 && !selectedShippingZone.value) {
return false
}
// Must provide address if physical shipping is required
if (requiresShippingAddress.value && !contactData.value.address) {
return false
}
return true
})
const orderValidationMessage = computed(() => {
if (availableShippingZones.value.length > 0 && !selectedShippingZone.value) {
return 'Please select a shipping zone'
}
if (requiresShippingAddress.value && !contactData.value.address) {
return 'Shipping address is required for physical delivery'
}
return ''
})
// Methods // Methods
const selectShippingZone = (zone: any) => { const selectShippingZone = (zone: any) => {
selectedShippingZone.value = zone selectedShippingZone.value = zone
} }
const confirmOrder = () => {
// Allow proceeding if no shipping zones are available (e.g., for digital goods)
if (availableShippingZones.value.length > 0 && !selectedShippingZone.value) {
error.value = 'Please select a shipping zone'
return
}
orderConfirmed.value = true
}
const placeOrder = async () => { const placeOrder = async () => {
// Only require shipping address if selected zone requires physical shipping if (!canPlaceOrder.value) {
const requiresShippingAddress = selectedShippingZone.value?.requiresPhysicalShipping !== false
if (requiresShippingAddress && !contactData.value.address) {
error.value = 'Shipping address is required for this delivery method'
return return
} }
@ -464,7 +468,7 @@ const placeOrder = async () => {
currency: checkoutCart.value.currency, currency: checkoutCart.value.currency,
requiresPhysicalShipping: false requiresPhysicalShipping: false
}, },
paymentMethod: paymentMethod.value === 'ln' ? 'lightning' as const : 'btc_onchain' as const, paymentMethod: 'lightning' as const,
subtotal: orderSubtotal.value, subtotal: orderSubtotal.value,
shippingCost: selectedShippingZone.value?.cost || 0, shippingCost: selectedShippingZone.value?.cost || 0,
total: orderTotal.value, total: orderTotal.value,

View file

@ -62,6 +62,7 @@
<script setup lang="ts"> <script setup lang="ts">
import { ref, computed, onMounted } from 'vue' import { ref, computed, onMounted } from 'vue'
import { useRoute } from 'vue-router'
import { useMarketStore } from '@/modules/market/stores/market' import { useMarketStore } from '@/modules/market/stores/market'
import { Badge } from '@/components/ui/badge' import { Badge } from '@/components/ui/badge'
import { import {
@ -77,10 +78,13 @@ import MerchantStore from '../components/MerchantStore.vue'
import MarketSettings from '../components/MarketSettings.vue' import MarketSettings from '../components/MarketSettings.vue'
import { auth } from '@/composables/useAuthService' import { auth } from '@/composables/useAuthService'
const route = useRoute()
const marketStore = useMarketStore() const marketStore = useMarketStore()
// Local state // Local state - check for tab query param
const activeTab = ref('overview') const validTabs = ['overview', 'orders', 'store', 'settings']
const initialTab = validTabs.includes(route.query.tab as string) ? route.query.tab as string : 'overview'
const activeTab = ref(initialTab)
// Computed properties for tab badges // Computed properties for tab badges
const orderCount = computed(() => { const orderCount = computed(() => {

View file

@ -21,7 +21,7 @@
<div class="flex-shrink-0"> <div class="flex-shrink-0">
<div v-if="stall?.logo" class="w-12 h-12 sm:w-14 sm:h-14 rounded-xl bg-gradient-to-br from-primary/20 to-accent/20 border-2 border-primary/20 shadow-lg overflow-hidden ring-2 ring-primary/10"> <div v-if="stall?.logo" class="w-12 h-12 sm:w-14 sm:h-14 rounded-xl bg-gradient-to-br from-primary/20 to-accent/20 border-2 border-primary/20 shadow-lg overflow-hidden ring-2 ring-primary/10">
<img <img
:src="stall.logo" :src="thumbnail(stall.logo, 128)"
:alt="stall.name" :alt="stall.name"
class="w-full h-full object-cover" class="w-full h-full object-cover"
loading="lazy" loading="lazy"
@ -157,7 +157,9 @@ import CartButton from '../components/CartButton.vue'
import CategoryFilterBar from '../components/CategoryFilterBar.vue' import CategoryFilterBar from '../components/CategoryFilterBar.vue'
import type { Product, Stall } from '../types/market' import type { Product, Stall } from '../types/market'
import type { FuzzySearchOptions } from '@/composables/useFuzzySearch' import type { FuzzySearchOptions } from '@/composables/useFuzzySearch'
import { useImageOptimizer } from '@/modules/base/composables/useImageOptimizer'
const { thumbnail } = useImageOptimizer()
const route = useRoute() const route = useRoute()
const router = useRouter() const router = useRouter()
const marketStore = useMarketStore() const marketStore = useMarketStore()

View file

@ -0,0 +1,176 @@
# Link Aggregator Implementation Plan
## Overview
Transform the nostr-feed module into a Reddit-style link aggregator with support for:
- **Link posts** - External URLs with Open Graph previews
- **Media posts** - Images/videos with inline display
- **Self posts** - Text/markdown content
## NIP Compliance
| NIP | Purpose | Usage |
|-----|---------|-------|
| NIP-72 | Moderated Communities | Community definitions (kind 34550), approvals (kind 4550) |
| NIP-22 | Comments | Community posts (kind 1111) with scoped threading |
| NIP-92 | Media Attachments | `imeta` tags for media metadata |
| NIP-94 | File Metadata | Reference for media fields |
| NIP-25 | Reactions | Upvote (`+`) / Downvote (`-`) |
| NIP-10 | Reply Threading | Fallback for kind 1 compatibility |
## Event Structure
### Submission (kind 1111)
```jsonc
{
"kind": 1111,
"content": "<self-post body or link description>",
"tags": [
// Community scope (NIP-72 + NIP-22)
["A", "34550:<community-pubkey>:<community-d>", "<relay>"],
["a", "34550:<community-pubkey>:<community-d>", "<relay>"],
["K", "34550"],
["k", "34550"],
["P", "<community-pubkey>"],
["p", "<community-pubkey>"],
// Submission metadata
["title", "<post title>"],
["post-type", "link|media|self"],
// Link post fields
["r", "<url>"],
["preview-title", "<og:title>"],
["preview-description", "<og:description>"],
["preview-image", "<og:image>"],
["preview-site-name", "<og:site_name>"],
// Media post fields (NIP-92)
["imeta", "url <url>", "m <mime>", "dim <WxH>", "blurhash <hash>", "alt <desc>"],
// Common
["t", "<hashtag>"],
["nsfw", "true|false"]
]
}
```
### Comment on Submission (kind 1111)
```jsonc
{
"kind": 1111,
"content": "<comment text>",
"tags": [
// Root scope (the community)
["A", "34550:<community-pubkey>:<community-d>", "<relay>"],
["K", "34550"],
["P", "<community-pubkey>"],
// Parent (the submission or parent comment)
["e", "<parent-event-id>", "<relay>", "<parent-pubkey>"],
["k", "1111"],
["p", "<parent-pubkey>"]
]
}
```
## Implementation Phases
### Phase 1: Core Data Model
- [x] Create feature branch
- [x] Document plan
- [x] Create `types/submission.ts` - Type definitions
- [x] Create `SubmissionService.ts` - Submission CRUD
- [x] Create `LinkPreviewService.ts` - OG tag fetching
- [x] Extend `FeedService.ts` - Handle kind 1111
### Phase 2: Post Creation
- [x] Create `SubmitComposer.vue` - Multi-type composer
- [x] Add link preview on URL paste
- [x] Add NSFW toggle
- [x] Add route `/submit` for composer
- [ ] Integrate with pictrs for media upload (Future)
### Phase 3: Feed Display
- [x] Create `SubmissionRow.vue` - Link aggregator row (Reddit/Lemmy style)
- [x] Create `VoteControls.vue` - Up/down voting
- [x] Create `SortTabs.vue` - Sort tabs (hot, new, top, controversial)
- [x] Create `SubmissionList.vue` - Main feed container
- [x] Create `SubmissionThumbnail.vue` - Thumbnail display
- [x] Add feed sorting (hot, new, top, controversial)
- [x] Add score calculation
- [x] Create `LinkAggregatorTest.vue` - Test page with mock data & live mode
### Phase 4: Detail View
- [x] Create `SubmissionDetail.vue` - Full post view with content display
- [x] Create `SubmissionComment.vue` - Recursive threaded comments
- [x] Create `SubmissionDetailPage.vue` - Route page wrapper
- [x] Add route `/submission/:id` for detail view
- [x] Add comment sorting (best, new, old, controversial)
- [x] Integrate comment submission via SubmissionService.createComment()
### Phase 5: Communities (Future)
- [ ] Create `CommunityService.ts`
- [ ] Create community browser
- [ ] Add moderation queue
## Ranking Algorithms
### Hot Rank (Lemmy-style)
```typescript
function hotRank(score: number, createdAt: Date): number {
const order = Math.log10(Math.max(Math.abs(score), 1))
const sign = score > 0 ? 1 : score < 0 ? -1 : 0
const seconds = (createdAt.getTime() - EPOCH.getTime()) / 1000
return sign * order + seconds / 45000
}
```
### Controversy Rank
```typescript
function controversyRank(upvotes: number, downvotes: number): number {
const total = upvotes + downvotes
if (total === 0) return 0
const magnitude = Math.pow(total, 0.8)
const balance = total > 0 ? Math.min(upvotes, downvotes) / Math.max(upvotes, downvotes) : 0
return magnitude * balance
}
```
## File Structure
```
src/modules/nostr-feed/
├── types/
│ └── submission.ts # NEW
├── services/
│ ├── FeedService.ts # MODIFY
│ ├── SubmissionService.ts # NEW
│ ├── LinkPreviewService.ts # NEW
│ ├── CommunityService.ts # NEW (Phase 5)
│ ├── ProfileService.ts # EXISTING
│ └── ReactionService.ts # EXISTING (enhance for up/down)
├── components/
│ ├── SubmissionCard.vue # NEW (Phase 3)
│ ├── SubmitComposer.vue # NEW (Phase 2)
│ ├── SubmissionDetail.vue # NEW (Phase 4)
│ ├── VoteButtons.vue # NEW (Phase 3)
│ ├── ThreadedPost.vue # EXISTING (reuse)
│ ├── NostrFeed.vue # EXISTING (modify)
│ └── NoteComposer.vue # EXISTING
├── composables/
│ ├── useSubmissions.ts # NEW
│ ├── useCommunities.ts # NEW (Phase 5)
│ ├── useFeed.ts # EXISTING
│ └── useReactions.ts # EXISTING
└── config/
└── content-filters.ts # MODIFY
```
## Migration Strategy
1. **Backwards compatible** - Continue supporting kind 1 notes
2. **Gradual adoption** - Add kind 1111 alongside existing
3. **Feature flag** - Toggle between classic feed and link aggregator view

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 '@/modules/base/composables/useProfiles'
import { useReactions } from '../composables/useReactions' import { useReactions } from '@/modules/base/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,107 @@
<script setup lang="ts">
/**
* SortTabs - Sort/filter tabs for submission list
* Minimal tab row like old Reddit: hot | new | top | controversial
*/
import { computed } from 'vue'
import { Flame, Clock, TrendingUp, Swords } from 'lucide-vue-next'
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue
} from '@/components/ui/select'
import type { SortType, TimeRange } from '../types/submission'
interface Props {
currentSort: SortType
currentTimeRange?: TimeRange
showTimeRange?: boolean
}
interface Emits {
(e: 'update:sort', sort: SortType): void
(e: 'update:timeRange', range: TimeRange): void
}
const props = withDefaults(defineProps<Props>(), {
currentTimeRange: 'day',
showTimeRange: false
})
const emit = defineEmits<Emits>()
const sortOptions: { id: SortType; label: string; icon: any }[] = [
{ id: 'hot', label: 'hot', icon: Flame },
{ id: 'new', label: 'new', icon: Clock },
{ id: 'top', label: 'top', icon: TrendingUp },
{ id: 'controversial', label: 'controversial', icon: Swords }
]
const timeRangeOptions: { id: TimeRange; label: string }[] = [
{ id: 'hour', label: 'hour' },
{ id: 'day', label: 'day' },
{ id: 'week', label: 'week' },
{ id: 'month', label: 'month' },
{ id: 'year', label: 'year' },
{ id: 'all', label: 'all time' }
]
// Show time range dropdown when top is selected
const showTimeDropdown = computed(() =>
props.showTimeRange && props.currentSort === 'top'
)
function selectSort(sort: SortType) {
emit('update:sort', sort)
}
function selectTimeRange(range: TimeRange) {
emit('update:timeRange', range)
}
</script>
<template>
<div class="flex items-center gap-1 text-sm border-b border-border pt-3 pb-2 mb-2">
<!-- Sort tabs -->
<template v-for="option in sortOptions" :key="option.id">
<button
type="button"
:class="[
'px-2 py-1 rounded transition-colors flex items-center gap-1',
currentSort === option.id
? 'bg-accent text-foreground font-medium'
: 'text-muted-foreground hover:text-foreground hover:bg-accent/50'
]"
@click="selectSort(option.id)"
>
<component :is="option.icon" class="h-3.5 w-3.5" />
<span>{{ option.label }}</span>
</button>
</template>
<!-- Time range dropdown (for top) -->
<template v-if="showTimeDropdown">
<span class="text-muted-foreground mx-1">·</span>
<Select
:model-value="currentTimeRange"
@update:model-value="selectTimeRange($event as TimeRange)"
>
<SelectTrigger class="h-auto w-auto gap-1 border-0 bg-transparent px-1 py-0.5 text-sm text-muted-foreground shadow-none hover:text-foreground focus:ring-0">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem
v-for="range in timeRangeOptions"
:key="range.id"
:value="range.id"
>
{{ range.label }}
</SelectItem>
</SelectContent>
</Select>
</template>
</div>
</template>

View file

@ -0,0 +1,275 @@
<script setup lang="ts">
/**
* SubmissionComment - Recursive comment component for submission threads
* Displays a single comment with vote controls and nested replies
*/
import { computed, ref } from 'vue'
import { formatDistanceToNow } from 'date-fns'
import { ChevronUp, ChevronDown, Reply, Flag, MoreHorizontal, Send } from 'lucide-vue-next'
import type { SubmissionComment as CommentType } from '../types/submission'
interface Props {
comment: CommentType
depth: number
collapsedComments: Set<string>
getDisplayName: (pubkey: string) => string
isAuthenticated: boolean
currentUserPubkey?: string | null
replyingToId?: string | null
isSubmittingReply?: boolean
}
interface Emits {
(e: 'toggle-collapse', commentId: string): void
(e: 'reply', comment: CommentType): void
(e: 'cancel-reply'): void
(e: 'submit-reply', commentId: string, text: string): void
(e: 'upvote', comment: CommentType): void
(e: 'downvote', comment: CommentType): void
}
const props = withDefaults(defineProps<Props>(), {
replyingToId: null,
isSubmittingReply: false
})
const emit = defineEmits<Emits>()
// Local reply text
const replyText = ref('')
// Is this comment being replied to
const isBeingRepliedTo = computed(() => props.replyingToId === props.comment.id)
// Handle reply click
function onReplyClick() {
emit('reply', props.comment)
}
// Submit the reply
function submitReply() {
if (!replyText.value.trim()) return
emit('submit-reply', props.comment.id, replyText.value.trim())
replyText.value = ''
}
// Cancel reply
function cancelReply() {
replyText.value = ''
emit('cancel-reply')
}
// Is this comment collapsed
const isCollapsed = computed(() => props.collapsedComments.has(props.comment.id))
// Has replies
const hasReplies = computed(() => props.comment.replies && props.comment.replies.length > 0)
// Count total nested replies
const replyCount = computed(() => {
const count = (c: CommentType): number => {
let total = c.replies?.length || 0
c.replies?.forEach(r => { total += count(r) })
return total
}
return count(props.comment)
})
// Format time
function formatTime(timestamp: number): string {
return formatDistanceToNow(timestamp * 1000, { addSuffix: true })
}
// Depth colors for threading lines (using theme-aware chart colors)
const depthColors = [
'bg-chart-1',
'bg-chart-2',
'bg-chart-3',
'bg-chart-4',
'bg-chart-5'
]
const depthColor = computed(() => depthColors[props.depth % depthColors.length])
// Is own comment
const isOwnComment = computed(() =>
props.currentUserPubkey && props.currentUserPubkey === props.comment.pubkey
)
</script>
<template>
<div
:class="[
'relative',
depth > 0 ? 'ml-0.5' : ''
]"
>
<!-- Threading line -->
<div
v-if="depth > 0"
:class="[
'absolute left-0 top-0 bottom-0 w-0.5',
depthColor,
'hover:w-1 transition-all cursor-pointer'
]"
@click="emit('toggle-collapse', comment.id)"
/>
<!-- Comment content -->
<div
:class="[
'py-1',
depth > 0 ? 'pl-2' : '',
'hover:bg-accent/20 transition-colors'
]"
>
<!-- Header row -->
<div class="flex items-center gap-2 text-xs">
<!-- Collapse toggle -->
<button
v-if="hasReplies"
class="text-muted-foreground hover:text-foreground"
@click="emit('toggle-collapse', comment.id)"
>
<ChevronDown v-if="!isCollapsed" class="h-3.5 w-3.5" />
<ChevronUp v-else class="h-3.5 w-3.5" />
</button>
<div v-else class="w-3.5" />
<!-- Author -->
<span class="font-medium hover:underline cursor-pointer">
{{ getDisplayName(comment.pubkey) }}
</span>
<!-- Score -->
<span class="text-muted-foreground">
{{ comment.votes.score }} {{ comment.votes.score === 1 ? 'point' : 'points' }}
</span>
<!-- Time -->
<span class="text-muted-foreground">
{{ formatTime(comment.created_at) }}
</span>
<!-- Collapsed indicator -->
<span v-if="isCollapsed && hasReplies" class="text-muted-foreground">
({{ replyCount }} {{ replyCount === 1 ? 'child' : 'children' }})
</span>
</div>
<!-- Content (hidden when collapsed) -->
<div v-if="!isCollapsed">
<!-- Comment body -->
<div class="mt-1 text-sm whitespace-pre-wrap leading-relaxed pl-5">
{{ comment.content }}
</div>
<!-- Actions -->
<div class="flex items-center gap-1 mt-1 pl-5">
<!-- Vote buttons (inline style) -->
<button
:class="[
'p-1 transition-colors',
comment.votes.userVote === 'upvote'
? 'text-orange-500'
: 'text-muted-foreground hover:text-orange-500'
]"
:disabled="!isAuthenticated"
@click="emit('upvote', comment)"
>
<ChevronUp class="h-4 w-4" />
</button>
<button
:class="[
'p-1 transition-colors',
comment.votes.userVote === 'downvote'
? 'text-blue-500'
: 'text-muted-foreground hover:text-blue-500'
]"
:disabled="!isAuthenticated"
@click="emit('downvote', comment)"
>
<ChevronDown class="h-4 w-4" />
</button>
<!-- Reply -->
<button
class="flex items-center gap-1 px-2 py-1 text-xs text-muted-foreground hover:text-foreground hover:bg-accent rounded transition-colors"
:disabled="!isAuthenticated"
@click="onReplyClick"
>
<Reply class="h-3 w-3" />
<span>reply</span>
</button>
<!-- Report (not for own comments) -->
<button
v-if="!isOwnComment"
class="flex items-center gap-1 px-2 py-1 text-xs text-muted-foreground hover:text-foreground hover:bg-accent rounded transition-colors"
>
<Flag class="h-3 w-3" />
<span>report</span>
</button>
<!-- More options -->
<button
class="p-1 text-muted-foreground hover:text-foreground hover:bg-accent rounded transition-colors"
>
<MoreHorizontal class="h-3 w-3" />
</button>
</div>
<!-- Inline reply form -->
<div v-if="isBeingRepliedTo" class="mt-2 pl-5">
<div class="border rounded-lg bg-background p-2">
<textarea
v-model="replyText"
placeholder="Write a reply..."
rows="3"
class="w-full px-2 py-1.5 text-sm bg-transparent resize-none focus:outline-none"
:disabled="isSubmittingReply"
/>
<div class="flex items-center justify-end gap-2 mt-1">
<button
class="px-3 py-1 text-xs text-muted-foreground hover:text-foreground transition-colors"
@click="cancelReply"
>
Cancel
</button>
<button
class="flex items-center gap-1 px-3 py-1 text-xs bg-primary text-primary-foreground rounded hover:bg-primary/90 transition-colors disabled:opacity-50"
:disabled="!replyText.trim() || isSubmittingReply"
@click="submitReply"
>
<Send class="h-3 w-3" />
Reply
</button>
</div>
</div>
</div>
<!-- Nested replies -->
<div v-if="hasReplies" class="mt-1">
<SubmissionComment
v-for="reply in comment.replies"
:key="reply.id"
:comment="reply"
:depth="depth + 1"
:collapsed-comments="collapsedComments"
:get-display-name="getDisplayName"
:is-authenticated="isAuthenticated"
:current-user-pubkey="currentUserPubkey"
:replying-to-id="replyingToId"
:is-submitting-reply="isSubmittingReply"
@toggle-collapse="emit('toggle-collapse', $event)"
@reply="emit('reply', $event)"
@cancel-reply="emit('cancel-reply')"
@submit-reply="(commentId, text) => emit('submit-reply', commentId, text)"
@upvote="emit('upvote', $event)"
@downvote="emit('downvote', $event)"
/>
</div>
</div>
</div>
</div>
</template>

View file

@ -0,0 +1,553 @@
<script setup lang="ts">
/**
* SubmissionDetail - Full post view with comments
* Displays complete submission content and threaded comments
*/
import { ref, computed, watch } from 'vue'
import { useRouter } from 'vue-router'
import { formatDistanceToNow } from 'date-fns'
import {
ArrowLeft,
MessageSquare,
Share2,
Bookmark,
Flag,
ExternalLink,
Image as ImageIcon,
FileText,
Loader2,
Send
} from 'lucide-vue-next'
import { Badge } from '@/components/ui/badge'
import { Button } from '@/components/ui/button'
import VoteControls from './VoteControls.vue'
import SubmissionCommentComponent from './SubmissionComment.vue'
import { useSubmission } from '../composables/useSubmissions'
import { tryInjectService, SERVICE_TOKENS } from '@/core/di-container'
import type { ProfileService } from '../services/ProfileService'
import type { SubmissionService } from '../services/SubmissionService'
import type {
SubmissionComment as SubmissionCommentType,
LinkSubmission,
MediaSubmission,
SelfSubmission
} from '../types/submission'
interface Props {
/** Submission ID to display */
submissionId: string
}
const props = defineProps<Props>()
const router = useRouter()
// Comment sort options
type CommentSort = 'best' | 'new' | 'old' | 'controversial'
// Inject services
const profileService = tryInjectService<ProfileService>(SERVICE_TOKENS.PROFILE_SERVICE)
const authService = tryInjectService<any>(SERVICE_TOKENS.AUTH_SERVICE)
const submissionService = tryInjectService<SubmissionService>(SERVICE_TOKENS.SUBMISSION_SERVICE)
// Use submission composable - handles subscription automatically
const { submission, comments, upvote, downvote, isLoading, error } = useSubmission(props.submissionId)
// Comment composer state
const showComposer = ref(false)
const replyingTo = ref<{ id: string; author: string } | null>(null)
const commentText = ref('')
const isSubmittingComment = ref(false)
const commentError = ref<string | null>(null)
// Comment sorting state
const commentSort = ref<CommentSort>('best')
// Collapsed comments state
const collapsedComments = ref(new Set<string>())
// Sorted comments
const sortedComments = computed(() => {
if (submissionService) {
return submissionService.getSortedComments(props.submissionId, commentSort.value)
}
return comments.value
})
// Auth state
const isAuthenticated = computed(() => authService?.isAuthenticated?.value || false)
const currentUserPubkey = computed(() => authService?.user?.value?.pubkey || null)
// Get display name for a pubkey
function getDisplayName(pubkey: string): string {
if (profileService) {
return profileService.getDisplayName(pubkey)
}
return `${pubkey.slice(0, 8)}...`
}
// Format timestamp
function formatTime(timestamp: number): string {
return formatDistanceToNow(timestamp * 1000, { addSuffix: true })
}
// Extract domain from URL
function extractDomain(url: string): string {
try {
return new URL(url).hostname.replace('www.', '')
} catch {
return url
}
}
// Cast submission to specific type
const linkSubmission = computed(() =>
submission.value?.postType === 'link' ? submission.value as LinkSubmission : null
)
const mediaSubmission = computed(() =>
submission.value?.postType === 'media' ? submission.value as MediaSubmission : null
)
const selfSubmission = computed(() =>
submission.value?.postType === 'self' ? submission.value as SelfSubmission : null
)
// Community name
const communityName = computed(() => {
if (!submission.value?.communityRef) return null
const parts = submission.value.communityRef.split(':')
return parts.length >= 3 ? parts.slice(2).join(':') : null
})
// Handle voting
async function onUpvote() {
if (!isAuthenticated.value) return
try {
await upvote()
} catch (err) {
console.error('Failed to upvote:', err)
}
}
async function onDownvote() {
if (!isAuthenticated.value) return
try {
await downvote()
} catch (err) {
console.error('Failed to downvote:', err)
}
}
// Handle comment voting
async function onCommentUpvote(comment: SubmissionCommentType) {
if (!isAuthenticated.value || !submissionService) return
try {
await submissionService.upvoteComment(props.submissionId, comment.id)
} catch (err) {
console.error('Failed to upvote comment:', err)
}
}
async function onCommentDownvote(comment: SubmissionCommentType) {
if (!isAuthenticated.value || !submissionService) return
try {
await submissionService.downvoteComment(props.submissionId, comment.id)
} catch (err) {
console.error('Failed to downvote comment:', err)
}
}
// Handle share
function onShare() {
const url = window.location.href
navigator.clipboard?.writeText(url)
// TODO: Show toast
}
// Computed for passing to comment components
const replyingToId = computed(() => replyingTo.value?.id || null)
// Handle comment reply - for inline replies to comments
function startReply(comment?: SubmissionCommentType) {
if (comment) {
// Replying to a comment - show inline form (handled by SubmissionComment)
replyingTo.value = { id: comment.id, author: getDisplayName(comment.pubkey) }
showComposer.value = false // Hide top composer
} else {
// Top-level comment - show top composer
replyingTo.value = null
showComposer.value = true
}
commentText.value = ''
}
function cancelReply() {
showComposer.value = false
replyingTo.value = null
commentText.value = ''
}
// Submit top-level comment (from top composer)
async function submitComment() {
if (!commentText.value.trim() || !isAuthenticated.value || !submissionService) return
isSubmittingComment.value = true
commentError.value = null
try {
await submissionService.createComment(
props.submissionId,
commentText.value.trim(),
undefined // Top-level comment
)
cancelReply()
} catch (err: any) {
console.error('Failed to submit comment:', err)
commentError.value = err.message || 'Failed to post comment'
} finally {
isSubmittingComment.value = false
}
}
// Submit inline reply (from SubmissionComment's inline form)
async function submitReply(commentId: string, text: string) {
if (!text.trim() || !isAuthenticated.value || !submissionService) return
isSubmittingComment.value = true
commentError.value = null
try {
await submissionService.createComment(
props.submissionId,
text.trim(),
commentId
)
cancelReply()
} catch (err: any) {
console.error('Failed to submit reply:', err)
commentError.value = err.message || 'Failed to post reply'
} finally {
isSubmittingComment.value = false
}
}
// Toggle comment collapse
function toggleCollapse(commentId: string) {
if (collapsedComments.value.has(commentId)) {
collapsedComments.value.delete(commentId)
} else {
collapsedComments.value.add(commentId)
}
// Trigger reactivity
collapsedComments.value = new Set(collapsedComments.value)
}
// Go back
function goBack() {
router.back()
}
// Helper to collect all pubkeys from comments recursively
function collectCommentPubkeys(comments: SubmissionCommentType[]): string[] {
const pubkeys: string[] = []
for (const comment of comments) {
pubkeys.push(comment.pubkey)
if (comment.replies?.length) {
pubkeys.push(...collectCommentPubkeys(comment.replies))
}
}
return pubkeys
}
// Fetch profiles when submission loads
watch(submission, (sub) => {
if (profileService && sub) {
profileService.fetchProfiles([sub.pubkey])
}
}, { immediate: true })
// Fetch profiles when comments load
watch(comments, (newComments) => {
if (profileService && newComments.length > 0) {
const pubkeys = [...new Set(collectCommentPubkeys(newComments))]
profileService.fetchProfiles(pubkeys)
}
}, { immediate: true })
</script>
<template>
<div class="min-h-screen bg-background">
<!-- Header -->
<header class="sticky top-0 z-30 bg-background/95 backdrop-blur border-b">
<div class="max-w-4xl mx-auto px-4 py-3">
<div class="flex items-center gap-3">
<Button variant="ghost" size="sm" @click="goBack" class="h-8 w-8 p-0">
<ArrowLeft class="h-4 w-4" />
</Button>
<div class="flex-1 min-w-0">
<h1 class="text-sm font-medium truncate">
{{ submission?.title || 'Loading...' }}
</h1>
<p v-if="communityName" class="text-xs text-muted-foreground">
{{ communityName }}
</p>
</div>
</div>
</div>
</header>
<!-- Loading state -->
<div v-if="isLoading && !submission" class="flex items-center justify-center py-16">
<Loader2 class="h-6 w-6 animate-spin text-muted-foreground" />
<span class="ml-2 text-sm text-muted-foreground">Loading submission...</span>
</div>
<!-- Error state -->
<div v-else-if="error" class="max-w-4xl mx-auto p-4">
<div class="text-center py-8">
<p class="text-sm text-destructive">{{ error }}</p>
<Button variant="outline" size="sm" class="mt-4" @click="goBack">
Go Back
</Button>
</div>
</div>
<!-- Submission content -->
<main v-else-if="submission" class="max-w-4xl mx-auto">
<article class="p-4 border-b">
<!-- Post header with votes -->
<div class="flex gap-3">
<!-- Vote controls -->
<VoteControls
:score="submission.votes.score"
:user-vote="submission.votes.userVote"
:disabled="!isAuthenticated"
@upvote="onUpvote"
@downvote="onDownvote"
/>
<!-- Content -->
<div class="flex-1 min-w-0">
<!-- Title -->
<h1 class="text-xl font-semibold leading-tight mb-2">
{{ submission.title }}
</h1>
<!-- Badges -->
<div v-if="submission.nsfw || submission.flair" class="flex items-center gap-2 mb-2">
<Badge v-if="submission.nsfw" variant="destructive" class="text-xs">
NSFW
</Badge>
<Badge v-if="submission.flair" variant="secondary" class="text-xs">
{{ submission.flair }}
</Badge>
</div>
<!-- Metadata -->
<div class="text-sm text-muted-foreground mb-4">
<span>submitted {{ formatTime(submission.created_at) }}</span>
<span> by </span>
<span class="font-medium hover:underline cursor-pointer">
{{ getDisplayName(submission.pubkey) }}
</span>
<template v-if="communityName">
<span> to </span>
<span class="font-medium hover:underline cursor-pointer">{{ communityName }}</span>
</template>
</div>
<!-- Link post content -->
<div v-if="linkSubmission" class="mb-4">
<a
:href="linkSubmission.url"
target="_blank"
rel="noopener noreferrer"
class="block p-4 border rounded-lg hover:bg-accent/30 transition-colors group"
>
<div class="flex items-start gap-4">
<!-- Preview image -->
<div
v-if="linkSubmission.preview?.image"
class="flex-shrink-0 w-32 h-24 rounded overflow-hidden bg-muted"
>
<img
:src="linkSubmission.preview.image"
:alt="linkSubmission.preview.title || ''"
class="w-full h-full object-cover"
/>
</div>
<div v-else class="flex-shrink-0 w-16 h-16 rounded bg-muted flex items-center justify-center">
<ExternalLink class="h-6 w-6 text-muted-foreground" />
</div>
<!-- Preview content -->
<div class="flex-1 min-w-0">
<div class="flex items-center gap-2 text-xs text-muted-foreground mb-1">
<ExternalLink class="h-3 w-3" />
<span>{{ extractDomain(linkSubmission.url) }}</span>
</div>
<h3 v-if="linkSubmission.preview?.title" class="font-medium text-sm group-hover:underline">
{{ linkSubmission.preview.title }}
</h3>
<p v-if="linkSubmission.preview?.description" class="text-xs text-muted-foreground mt-1 line-clamp-2">
{{ linkSubmission.preview.description }}
</p>
</div>
</div>
</a>
<!-- Optional body -->
<div v-if="linkSubmission.body" class="mt-4 text-sm whitespace-pre-wrap">
{{ linkSubmission.body }}
</div>
</div>
<!-- Media post content -->
<div v-if="mediaSubmission" class="mb-4">
<div class="rounded-lg overflow-hidden bg-muted">
<img
v-if="mediaSubmission.media.mimeType?.startsWith('image/')"
:src="mediaSubmission.media.url"
:alt="mediaSubmission.media.alt || ''"
class="max-w-full max-h-[600px] mx-auto"
/>
<video
v-else-if="mediaSubmission.media.mimeType?.startsWith('video/')"
:src="mediaSubmission.media.url"
controls
class="max-w-full max-h-[600px] mx-auto"
/>
<div v-else class="p-8 flex flex-col items-center justify-center">
<ImageIcon class="h-12 w-12 text-muted-foreground mb-2" />
<a
:href="mediaSubmission.media.url"
target="_blank"
class="text-sm text-primary hover:underline"
>
View media
</a>
</div>
</div>
<!-- Caption -->
<div v-if="mediaSubmission.body" class="mt-4 text-sm whitespace-pre-wrap">
{{ mediaSubmission.body }}
</div>
</div>
<!-- Self post content -->
<div v-if="selfSubmission" class="mb-4">
<div class="text-sm whitespace-pre-wrap leading-relaxed">
{{ selfSubmission.body }}
</div>
</div>
<!-- Actions -->
<div class="flex items-center gap-4 text-sm text-muted-foreground">
<button
class="flex items-center gap-1.5 hover:text-foreground transition-colors"
@click="startReply()"
>
<MessageSquare class="h-4 w-4" />
<span>{{ submission.commentCount }} comments</span>
</button>
<button
class="flex items-center gap-1.5 hover:text-foreground transition-colors"
@click="onShare"
>
<Share2 class="h-4 w-4" />
<span>share</span>
</button>
<button class="flex items-center gap-1.5 hover:text-foreground transition-colors">
<Bookmark class="h-4 w-4" />
<span>save</span>
</button>
<button class="flex items-center gap-1.5 hover:text-foreground transition-colors">
<Flag class="h-4 w-4" />
<span>report</span>
</button>
</div>
</div>
</div>
</article>
<!-- Top-level comment composer (only for new comments, not replies) -->
<div v-if="isAuthenticated && !replyingTo" class="p-4 border-b">
<div class="flex gap-2">
<textarea
v-model="commentText"
placeholder="Write a comment..."
rows="3"
class="flex-1 px-3 py-2 text-sm border rounded-lg bg-background resize-none focus:outline-none focus:ring-2 focus:ring-primary"
/>
</div>
<!-- Comment error -->
<div v-if="commentError" class="mt-2 text-sm text-destructive">
{{ commentError }}
</div>
<div class="flex justify-end mt-2">
<Button
size="sm"
:disabled="!commentText.trim() || isSubmittingComment"
@click="submitComment"
>
<Loader2 v-if="isSubmittingComment" class="h-4 w-4 animate-spin mr-2" />
<Send v-else class="h-4 w-4 mr-2" />
Comment
</Button>
</div>
</div>
<!-- Comments section -->
<section class="divide-y divide-border">
<!-- Comment sort selector -->
<div v-if="sortedComments.length > 0" class="px-4 py-3 border-b flex items-center gap-2">
<span class="text-sm text-muted-foreground">Sort by:</span>
<select
v-model="commentSort"
class="text-sm bg-background border rounded px-2 py-1 focus:outline-none focus:ring-2 focus:ring-primary"
>
<option value="best">Best</option>
<option value="new">New</option>
<option value="old">Old</option>
<option value="controversial">Controversial</option>
</select>
</div>
<div v-if="sortedComments.length === 0" class="p-8 text-center text-sm text-muted-foreground">
No comments yet. Be the first to comment!
</div>
<!-- Recursive comment rendering -->
<template v-for="comment in sortedComments" :key="comment.id">
<SubmissionCommentComponent
:comment="comment"
:depth="0"
:collapsed-comments="collapsedComments"
:get-display-name="getDisplayName"
:is-authenticated="isAuthenticated"
:current-user-pubkey="currentUserPubkey"
:replying-to-id="replyingToId"
:is-submitting-reply="isSubmittingComment"
@toggle-collapse="toggleCollapse"
@reply="startReply"
@cancel-reply="cancelReply"
@submit-reply="submitReply"
@upvote="onCommentUpvote"
@downvote="onCommentDownvote"
/>
</template>
</section>
</main>
<!-- Not found state -->
<div v-else class="max-w-4xl mx-auto p-4">
<div class="text-center py-16">
<FileText class="h-12 w-12 text-muted-foreground mx-auto mb-4" />
<p class="text-sm text-muted-foreground">Submission not found</p>
<Button variant="outline" size="sm" class="mt-4" @click="goBack">
Go Back
</Button>
</div>
</div>
</div>
</template>

View file

@ -0,0 +1,235 @@
<script setup lang="ts">
/**
* SubmissionList - Main container for Reddit/Lemmy style submission feed
* Includes sort tabs, submission rows, and loading states
*/
import { computed, onMounted, watch } from 'vue'
import { Loader2 } from 'lucide-vue-next'
import SortTabs from './SortTabs.vue'
import SubmissionRow from './SubmissionRow.vue'
import { useSubmissions } from '../composables/useSubmissions'
import { tryInjectService, SERVICE_TOKENS } from '@/core/di-container'
import type { ProfileService } from '../services/ProfileService'
import type { SubmissionWithMeta, SortType, TimeRange, CommunityRef } from '../types/submission'
interface Props {
/** Community to filter by */
community?: CommunityRef | null
/** Show rank numbers */
showRanks?: boolean
/** Show time range selector for top sort */
showTimeRange?: boolean
/** Initial sort */
initialSort?: SortType
/** Max submissions to show */
limit?: number
}
interface Emits {
(e: 'submission-click', submission: SubmissionWithMeta): void
}
const props = withDefaults(defineProps<Props>(), {
showRanks: false,
showTimeRange: true,
initialSort: 'hot',
limit: 50
})
const emit = defineEmits<Emits>()
// Inject profile service for display names
const profileService = tryInjectService<ProfileService>(SERVICE_TOKENS.PROFILE_SERVICE)
// Auth service for checking authentication
const authService = tryInjectService<any>(SERVICE_TOKENS.AUTH_SERVICE)
// Use submissions composable
const {
submissions,
isLoading,
error,
currentSort,
currentTimeRange,
subscribe,
upvote,
downvote,
setSort
} = useSubmissions({
autoSubscribe: false,
config: {
community: props.community,
limit: props.limit
}
})
// Set initial sort
currentSort.value = props.initialSort
// Current user pubkey
const currentUserPubkey = computed(() => authService?.user?.value?.pubkey || null)
// Is user authenticated
const isAuthenticated = computed(() => authService?.isAuthenticated?.value || false)
// Get display name for a pubkey
function getDisplayName(pubkey: string): string {
if (profileService) {
return profileService.getDisplayName(pubkey)
}
// Fallback to truncated pubkey
return `${pubkey.slice(0, 8)}...`
}
// Handle sort change
function onSortChange(sort: SortType) {
setSort(sort, currentTimeRange.value)
}
// Handle time range change
function onTimeRangeChange(range: TimeRange) {
setSort(currentSort.value, range)
}
// Handle upvote
async function onUpvote(submission: SubmissionWithMeta) {
try {
await upvote(submission.id)
} catch (err) {
console.error('Failed to upvote:', err)
}
}
// Handle downvote
async function onDownvote(submission: SubmissionWithMeta) {
try {
await downvote(submission.id)
} catch (err) {
console.error('Failed to downvote:', err)
}
}
// Handle submission click
function onSubmissionClick(submission: SubmissionWithMeta) {
emit('submission-click', submission)
}
// Handle share
function onShare(submission: SubmissionWithMeta) {
// Copy link to clipboard or open share dialog
const url = `${window.location.origin}/submission/${submission.id}`
navigator.clipboard?.writeText(url)
// TODO: Show toast notification
}
// Handle save
function onSave(submission: SubmissionWithMeta) {
// TODO: Implement save functionality
console.log('Save:', submission.id)
}
// Handle hide
function onHide(submission: SubmissionWithMeta) {
// TODO: Implement hide functionality
console.log('Hide:', submission.id)
}
// Handle report
function onReport(submission: SubmissionWithMeta) {
// TODO: Implement report functionality
console.log('Report:', submission.id)
}
// Fetch profiles when submissions change
watch(submissions, (newSubmissions) => {
if (profileService && newSubmissions.length > 0) {
const pubkeys = [...new Set(newSubmissions.map(s => s.pubkey))]
profileService.fetchProfiles(pubkeys)
}
}, { immediate: true })
// Subscribe when community changes
watch(() => props.community, () => {
subscribe({
community: props.community,
limit: props.limit
})
}, { immediate: false })
// Initial subscribe
onMounted(() => {
subscribe({
community: props.community,
limit: props.limit
})
})
</script>
<template>
<div class="submission-list">
<!-- Sort tabs -->
<SortTabs
:current-sort="currentSort"
:current-time-range="currentTimeRange"
:show-time-range="showTimeRange"
@update:sort="onSortChange"
@update:time-range="onTimeRangeChange"
/>
<!-- Loading state -->
<div v-if="isLoading && submissions.length === 0" class="flex items-center justify-center py-8">
<Loader2 class="h-6 w-6 animate-spin text-muted-foreground" />
<span class="ml-2 text-sm text-muted-foreground">Loading submissions...</span>
</div>
<!-- Error state -->
<div v-else-if="error" class="text-center py-8">
<p class="text-sm text-destructive">{{ error }}</p>
<button
type="button"
class="mt-2 text-sm text-primary hover:underline"
@click="subscribe({ community, limit })"
>
Try again
</button>
</div>
<!-- Empty state -->
<div v-else-if="submissions.length === 0" class="text-center py-8">
<p class="text-sm text-muted-foreground">No submissions yet</p>
</div>
<!-- Submission list -->
<div v-else class="divide-y divide-border">
<SubmissionRow
v-for="(submission, index) in submissions"
:key="submission.id"
:submission="submission"
:rank="showRanks ? index + 1 : undefined"
:get-display-name="getDisplayName"
:current-user-pubkey="currentUserPubkey"
:is-authenticated="isAuthenticated"
@upvote="onUpvote"
@downvote="onDownvote"
@click="onSubmissionClick"
@share="onShare"
@save="onSave"
@hide="onHide"
@report="onReport"
/>
</div>
<!-- Loading more indicator -->
<div v-if="isLoading && submissions.length > 0" class="flex items-center justify-center py-4">
<Loader2 class="h-4 w-4 animate-spin text-muted-foreground" />
<span class="ml-2 text-xs text-muted-foreground">Loading more...</span>
</div>
</div>
</template>
<style scoped>
.submission-list {
max-width: 100%;
}
</style>

View file

@ -0,0 +1,247 @@
<script setup lang="ts">
/**
* SubmissionRow - Single submission row in Reddit/Lemmy style
* Compact, information-dense layout with votes, thumbnail, title, metadata
*/
import { computed } from 'vue'
import { formatDistanceToNow } from 'date-fns'
import { MessageSquare, Share2, Bookmark, EyeOff, Flag, Link2 } from 'lucide-vue-next'
import VoteControls from './VoteControls.vue'
import SubmissionThumbnail from './SubmissionThumbnail.vue'
import { Badge } from '@/components/ui/badge'
import type { SubmissionWithMeta, LinkSubmission, MediaSubmission } from '../types/submission'
import { extractDomain } from '../types/submission'
interface Props {
submission: SubmissionWithMeta
/** Display name resolver */
getDisplayName: (pubkey: string) => string
/** Current user pubkey for "own post" detection */
currentUserPubkey?: string | null
/** Show rank number */
rank?: number
/** Whether user is authenticated (for voting) */
isAuthenticated?: boolean
}
interface Emits {
(e: 'upvote', submission: SubmissionWithMeta): void
(e: 'downvote', submission: SubmissionWithMeta): void
(e: 'click', submission: SubmissionWithMeta): void
(e: 'save', submission: SubmissionWithMeta): void
(e: 'hide', submission: SubmissionWithMeta): void
(e: 'report', submission: SubmissionWithMeta): void
(e: 'share', submission: SubmissionWithMeta): void
}
const props = withDefaults(defineProps<Props>(), {
isAuthenticated: false
})
const emit = defineEmits<Emits>()
// Extract thumbnail URL based on post type
const thumbnailUrl = computed(() => {
const s = props.submission
if (s.postType === 'link') {
const link = s as LinkSubmission
return link.preview?.image || undefined
}
if (s.postType === 'media') {
const media = s as MediaSubmission
return media.media.thumbnail || media.media.url
}
return undefined
})
// Extract domain for link posts
const domain = computed(() => {
if (props.submission.postType === 'link') {
const link = props.submission as LinkSubmission
return extractDomain(link.url)
}
return null
})
// Format timestamp
const timeAgo = computed(() => {
return formatDistanceToNow(props.submission.created_at * 1000, { addSuffix: true })
})
// Author display name
const authorName = computed(() => {
return props.getDisplayName(props.submission.pubkey)
})
// Is this the user's own post?
const isOwnPost = computed(() => {
return props.currentUserPubkey === props.submission.pubkey
})
// Community name (if any)
const communityName = computed(() => {
const ref = props.submission.communityRef
if (!ref) return null
// Extract identifier from "34550:pubkey:identifier"
const parts = ref.split(':')
return parts.length >= 3 ? parts.slice(2).join(':') : null
})
// Post type indicator for self posts
const postTypeLabel = computed(() => {
if (props.submission.postType === 'self') return 'self'
return null
})
function onTitleClick() {
if (props.submission.postType === 'link') {
// Open external link
const link = props.submission as LinkSubmission
window.open(link.url, '_blank', 'noopener,noreferrer')
} else {
// Navigate to post detail
emit('click', props.submission)
}
}
function onCommentsClick() {
emit('click', props.submission)
}
</script>
<template>
<div class="flex items-start gap-2 py-2 px-1 hover:bg-accent/30 transition-colors group">
<!-- Rank number (optional) -->
<div v-if="rank" class="w-6 text-right text-sm text-muted-foreground font-medium pt-1">
{{ rank }}
</div>
<!-- Vote controls -->
<VoteControls
:score="submission.votes.score"
:user-vote="submission.votes.userVote"
:disabled="!isAuthenticated"
@upvote="emit('upvote', submission)"
@downvote="emit('downvote', submission)"
/>
<!-- Thumbnail -->
<SubmissionThumbnail
:src="thumbnailUrl"
:post-type="submission.postType"
:nsfw="submission.nsfw"
:size="70"
class="cursor-pointer"
@click="onCommentsClick"
/>
<!-- Content -->
<div class="flex-1 min-w-0">
<!-- Title row -->
<div class="flex items-start gap-2">
<h3
class="text-sm font-medium leading-snug cursor-pointer hover:underline"
@click="onTitleClick"
>
{{ submission.title }}
</h3>
<!-- Domain for link posts -->
<span v-if="domain" class="text-xs text-muted-foreground flex-shrink-0">
({{ domain }})
</span>
<!-- Self post indicator -->
<span v-if="postTypeLabel" class="text-xs text-muted-foreground flex-shrink-0">
({{ postTypeLabel }})
</span>
<!-- External link icon for link posts -->
<Link2
v-if="submission.postType === 'link'"
class="h-3 w-3 text-muted-foreground flex-shrink-0 mt-0.5"
/>
</div>
<!-- Flair badges -->
<div v-if="submission.nsfw || submission.flair" class="flex items-center gap-1 mt-0.5">
<Badge v-if="submission.nsfw" variant="destructive" class="text-[10px] px-1 py-0">
NSFW
</Badge>
<Badge v-if="submission.flair" variant="secondary" class="text-[10px] px-1 py-0">
{{ submission.flair }}
</Badge>
</div>
<!-- Metadata row -->
<div class="text-xs text-muted-foreground mt-1">
<span>submitted {{ timeAgo }}</span>
<span> by </span>
<span class="hover:underline cursor-pointer">{{ authorName }}</span>
<template v-if="communityName">
<span> to </span>
<span class="hover:underline cursor-pointer font-medium">{{ communityName }}</span>
</template>
</div>
<!-- Actions row -->
<div class="flex items-center gap-3 mt-1 text-xs text-muted-foreground">
<!-- Comments -->
<button
type="button"
class="flex items-center gap-1 hover:text-foreground transition-colors"
@click="onCommentsClick"
>
<MessageSquare class="h-3.5 w-3.5" />
<span>{{ submission.commentCount }} comments</span>
</button>
<!-- Share -->
<button
type="button"
class="flex items-center gap-1 hover:text-foreground transition-colors opacity-0 group-hover:opacity-100"
@click="emit('share', submission)"
>
<Share2 class="h-3.5 w-3.5" />
<span>share</span>
</button>
<!-- Save -->
<button
type="button"
class="flex items-center gap-1 hover:text-foreground transition-colors opacity-0 group-hover:opacity-100"
:class="{ 'text-yellow-500': submission.isSaved }"
@click="emit('save', submission)"
>
<Bookmark class="h-3.5 w-3.5" :class="{ 'fill-current': submission.isSaved }" />
<span>{{ submission.isSaved ? 'saved' : 'save' }}</span>
</button>
<!-- Hide -->
<button
type="button"
class="flex items-center gap-1 hover:text-foreground transition-colors opacity-0 group-hover:opacity-100"
@click="emit('hide', submission)"
>
<EyeOff class="h-3.5 w-3.5" />
<span>hide</span>
</button>
<!-- Report (not for own posts) -->
<button
v-if="!isOwnPost"
type="button"
class="flex items-center gap-1 hover:text-foreground transition-colors opacity-0 group-hover:opacity-100"
@click="emit('report', submission)"
>
<Flag class="h-3.5 w-3.5" />
<span>report</span>
</button>
</div>
</div>
</div>
</template>

View file

@ -0,0 +1,114 @@
<script setup lang="ts">
/**
* SubmissionThumbnail - Small square thumbnail for submissions
* Shows preview image, video indicator, or placeholder icon
*/
import { computed } from 'vue'
import { FileText, Image, ExternalLink } from 'lucide-vue-next'
import type { SubmissionType } from '../types/submission'
interface Props {
/** Thumbnail URL */
src?: string
/** Submission type for fallback icon */
postType: SubmissionType
/** Alt text */
alt?: string
/** Whether this is NSFW content */
nsfw?: boolean
/** Size in pixels */
size?: number
}
const props = withDefaults(defineProps<Props>(), {
size: 70,
nsfw: false
})
// Determine fallback icon based on post type
const FallbackIcon = computed(() => {
switch (props.postType) {
case 'link':
return ExternalLink
case 'media':
return Image
case 'self':
default:
return FileText
}
})
// Background color for fallback
const fallbackBgClass = computed(() => {
switch (props.postType) {
case 'link':
return 'bg-blue-500/10'
case 'media':
return 'bg-purple-500/10'
case 'self':
default:
return 'bg-muted'
}
})
// Icon color for fallback
const fallbackIconClass = computed(() => {
switch (props.postType) {
case 'link':
return 'text-blue-500'
case 'media':
return 'text-purple-500'
case 'self':
default:
return 'text-muted-foreground'
}
})
</script>
<template>
<div
class="flex-shrink-0 rounded overflow-hidden"
:style="{ width: `${size}px`, height: `${size}px` }"
>
<!-- NSFW blur overlay -->
<template v-if="nsfw && src">
<div class="relative w-full h-full">
<img
:src="src"
:alt="alt || 'Thumbnail'"
class="w-full h-full object-cover blur-lg"
/>
<div class="absolute inset-0 flex items-center justify-center bg-black/50">
<span class="text-[10px] font-bold text-red-500 uppercase">NSFW</span>
</div>
</div>
</template>
<!-- Image thumbnail -->
<template v-else-if="src">
<img
:src="src"
:alt="alt || 'Thumbnail'"
class="w-full h-full object-cover bg-muted"
loading="lazy"
@error="($event.target as HTMLImageElement).style.display = 'none'"
/>
</template>
<!-- Fallback icon -->
<template v-else>
<div
:class="[
'w-full h-full flex items-center justify-center',
fallbackBgClass
]"
>
<component
:is="FallbackIcon"
:class="['h-6 w-6', fallbackIconClass]"
/>
</div>
</template>
</div>
</template>

View file

@ -0,0 +1,406 @@
<script setup lang="ts">
/**
* SubmitComposer - Create new submissions (link, media, self posts)
* Similar to Lemmy's Create Post form
*/
import { ref, computed, watch } from 'vue'
import { useRouter } from 'vue-router'
import {
Link2,
FileText,
Image as ImageIcon,
Loader2,
ExternalLink,
X,
AlertCircle
} from 'lucide-vue-next'
import { Button } from '@/components/ui/button'
import { Badge } from '@/components/ui/badge'
import { tryInjectService, SERVICE_TOKENS } from '@/core/di-container'
import type { SubmissionService } from '../services/SubmissionService'
import type { LinkPreviewService } from '../services/LinkPreviewService'
import type {
LinkPreview,
SubmissionType,
LinkSubmissionForm,
SelfSubmissionForm,
SubmissionForm
} from '../types/submission'
interface Props {
/** Pre-selected community */
community?: string
/** Initial post type */
initialType?: SubmissionType
}
const props = withDefaults(defineProps<Props>(), {
initialType: 'self'
})
const emit = defineEmits<{
(e: 'submitted', submissionId: string): void
(e: 'cancel'): void
}>()
const router = useRouter()
// Services
const submissionService = tryInjectService<SubmissionService>(SERVICE_TOKENS.SUBMISSION_SERVICE)
const linkPreviewService = tryInjectService<LinkPreviewService>(SERVICE_TOKENS.LINK_PREVIEW_SERVICE)
const authService = tryInjectService<any>(SERVICE_TOKENS.AUTH_SERVICE)
// Auth state
const isAuthenticated = computed(() => authService?.isAuthenticated?.value || false)
// Form state
const postType = ref<SubmissionType>(props.initialType)
const title = ref('')
const url = ref('')
const body = ref('')
const thumbnailUrl = ref('')
const nsfw = ref(false)
// Link preview state
const linkPreview = ref<LinkPreview | null>(null)
const isLoadingPreview = ref(false)
const previewError = ref<string | null>(null)
// Submission state
const isSubmitting = ref(false)
const submitError = ref<string | null>(null)
// Validation
const isValid = computed(() => {
if (!title.value.trim()) return false
if (postType.value === 'link' && !url.value.trim()) return false
if (postType.value === 'self' && !body.value.trim()) return false
return true
})
// Debounced URL preview fetching
let previewTimeout: ReturnType<typeof setTimeout> | null = null
watch(url, (newUrl) => {
if (previewTimeout) {
clearTimeout(previewTimeout)
}
linkPreview.value = null
previewError.value = null
if (!newUrl.trim() || postType.value !== 'link') {
return
}
// Validate URL format
try {
new URL(newUrl)
} catch {
return
}
// Debounce the preview fetch
previewTimeout = setTimeout(async () => {
await fetchLinkPreview(newUrl)
}, 500)
})
async function fetchLinkPreview(urlToFetch: string) {
if (!linkPreviewService) {
previewError.value = 'Link preview service not available'
return
}
isLoadingPreview.value = true
previewError.value = null
try {
const preview = await linkPreviewService.fetchPreview(urlToFetch)
linkPreview.value = preview
} catch (err: any) {
console.error('Failed to fetch link preview:', err)
previewError.value = err.message || 'Failed to load preview'
} finally {
isLoadingPreview.value = false
}
}
function clearPreview() {
linkPreview.value = null
previewError.value = null
}
async function handleSubmit() {
if (!isValid.value || !isAuthenticated.value || !submissionService) return
isSubmitting.value = true
submitError.value = null
try {
let form: SubmissionForm
if (postType.value === 'link') {
const linkForm: LinkSubmissionForm = {
postType: 'link',
title: title.value.trim(),
url: url.value.trim(),
body: body.value.trim() || undefined,
communityRef: props.community,
nsfw: nsfw.value
}
form = linkForm
} else if (postType.value === 'self') {
const selfForm: SelfSubmissionForm = {
postType: 'self',
title: title.value.trim(),
body: body.value.trim(),
communityRef: props.community,
nsfw: nsfw.value
}
form = selfForm
} else if (postType.value === 'media') {
// TODO: Implement media submission with file upload
submitError.value = 'Media uploads not yet implemented'
return
} else {
submitError.value = 'Unknown post type'
return
}
const submissionId = await submissionService.createSubmission(form)
if (submissionId) {
emit('submitted', submissionId)
// Navigate to the new submission
router.push({ name: 'submission-detail', params: { id: submissionId } })
} else {
submitError.value = 'Failed to create submission'
}
} catch (err: any) {
console.error('Failed to submit:', err)
submitError.value = err.message || 'Failed to create submission'
} finally {
isSubmitting.value = false
}
}
function handleCancel() {
emit('cancel')
router.back()
}
function selectPostType(type: SubmissionType) {
postType.value = type
// Clear URL when switching away from link type
if (type !== 'link') {
url.value = ''
clearPreview()
}
}
</script>
<template>
<div class="max-w-2xl mx-auto p-4">
<h1 class="text-xl font-semibold mb-6">Create Post</h1>
<!-- Auth warning -->
<div v-if="!isAuthenticated" class="mb-4 p-4 bg-destructive/10 border border-destructive/20 rounded-lg">
<div class="flex items-center gap-2 text-destructive">
<AlertCircle class="h-4 w-4" />
<span class="text-sm font-medium">You must be logged in to create a post</span>
</div>
</div>
<!-- Post type selector -->
<div class="flex gap-2 mb-6">
<Button
:variant="postType === 'self' ? 'default' : 'outline'"
size="sm"
@click="selectPostType('self')"
>
<FileText class="h-4 w-4 mr-2" />
Text
</Button>
<Button
:variant="postType === 'link' ? 'default' : 'outline'"
size="sm"
@click="selectPostType('link')"
>
<Link2 class="h-4 w-4 mr-2" />
Link
</Button>
<Button
:variant="postType === 'media' ? 'default' : 'outline'"
size="sm"
@click="selectPostType('media')"
disabled
title="Coming soon"
>
<ImageIcon class="h-4 w-4 mr-2" />
Image
</Button>
</div>
<form @submit.prevent="handleSubmit" class="space-y-4">
<!-- Title -->
<div>
<label class="block text-sm font-medium mb-1.5">
Title <span class="text-destructive">*</span>
</label>
<input
v-model="title"
type="text"
placeholder="An interesting title"
maxlength="300"
class="w-full px-3 py-2 border rounded-lg bg-background focus:outline-none focus:ring-2 focus:ring-primary"
:disabled="!isAuthenticated"
/>
<div class="text-xs text-muted-foreground mt-1 text-right">
{{ title.length }}/300
</div>
</div>
<!-- URL (for link posts) -->
<div v-if="postType === 'link'">
<label class="block text-sm font-medium mb-1.5">
URL <span class="text-destructive">*</span>
</label>
<input
v-model="url"
type="url"
placeholder="https://example.com/article"
class="w-full px-3 py-2 border rounded-lg bg-background focus:outline-none focus:ring-2 focus:ring-primary"
:disabled="!isAuthenticated"
/>
<!-- Link preview -->
<div v-if="isLoadingPreview" class="mt-3 p-4 border rounded-lg bg-muted/30">
<div class="flex items-center gap-2 text-sm text-muted-foreground">
<Loader2 class="h-4 w-4 animate-spin" />
Loading preview...
</div>
</div>
<div v-else-if="previewError" class="mt-3 p-3 border border-destructive/20 rounded-lg bg-destructive/5">
<div class="flex items-center gap-2 text-sm text-destructive">
<AlertCircle class="h-4 w-4" />
{{ previewError }}
</div>
</div>
<div v-else-if="linkPreview" class="mt-3 p-4 border rounded-lg bg-muted/30">
<div class="flex items-start gap-3">
<!-- Preview image -->
<div v-if="linkPreview.image" class="flex-shrink-0 w-24 h-18 rounded overflow-hidden bg-muted">
<img
:src="linkPreview.image"
:alt="linkPreview.title || ''"
class="w-full h-full object-cover"
/>
</div>
<!-- Preview content -->
<div class="flex-1 min-w-0">
<div class="flex items-center gap-2 text-xs text-muted-foreground mb-1">
<ExternalLink class="h-3 w-3" />
<span>{{ linkPreview.domain }}</span>
<button
type="button"
class="ml-auto p-1 hover:bg-accent rounded"
@click="clearPreview"
>
<X class="h-3 w-3" />
</button>
</div>
<h4 v-if="linkPreview.title" class="font-medium text-sm line-clamp-2">
{{ linkPreview.title }}
</h4>
<p v-if="linkPreview.description" class="text-xs text-muted-foreground mt-1 line-clamp-2">
{{ linkPreview.description }}
</p>
</div>
</div>
</div>
</div>
<!-- Thumbnail URL (optional) -->
<div v-if="postType === 'link'">
<label class="block text-sm font-medium mb-1.5">
Thumbnail URL
<span class="text-muted-foreground font-normal">(optional)</span>
</label>
<input
v-model="thumbnailUrl"
type="url"
placeholder="https://example.com/image.jpg"
class="w-full px-3 py-2 border rounded-lg bg-background focus:outline-none focus:ring-2 focus:ring-primary"
:disabled="!isAuthenticated"
/>
</div>
<!-- Body -->
<div>
<label class="block text-sm font-medium mb-1.5">
Body
<span v-if="postType === 'self'" class="text-destructive">*</span>
<span v-else class="text-muted-foreground font-normal">(optional)</span>
</label>
<textarea
v-model="body"
:placeholder="postType === 'self' ? 'Write your post content...' : 'Optional description or commentary...'"
rows="6"
class="w-full px-3 py-2 border rounded-lg bg-background focus:outline-none focus:ring-2 focus:ring-primary resize-y min-h-[120px]"
:disabled="!isAuthenticated"
/>
<div class="text-xs text-muted-foreground mt-1">
Markdown supported
</div>
</div>
<!-- NSFW toggle -->
<div class="flex items-center gap-2">
<input
id="nsfw"
v-model="nsfw"
type="checkbox"
class="rounded border-muted-foreground"
:disabled="!isAuthenticated"
/>
<label for="nsfw" class="text-sm font-medium cursor-pointer">
NSFW
</label>
<Badge v-if="nsfw" variant="destructive" class="text-xs">
Not Safe For Work
</Badge>
</div>
<!-- Error message -->
<div v-if="submitError" class="p-3 border border-destructive/20 rounded-lg bg-destructive/5">
<div class="flex items-center gap-2 text-sm text-destructive">
<AlertCircle class="h-4 w-4" />
{{ submitError }}
</div>
</div>
<!-- Actions -->
<div class="flex items-center gap-3 pt-4">
<Button
type="submit"
:disabled="!isValid || isSubmitting || !isAuthenticated"
>
<Loader2 v-if="isSubmitting" class="h-4 w-4 animate-spin mr-2" />
Create
</Button>
<Button
type="button"
variant="outline"
@click="handleCancel"
>
Cancel
</Button>
</div>
</form>
</div>
</template>

View file

@ -0,0 +1,107 @@
<script setup lang="ts">
/**
* VoteControls - Compact upvote/downvote arrows with score
* Lemmy/Reddit style vertical layout
*/
import { computed } from 'vue'
import { ChevronUp, ChevronDown } from 'lucide-vue-next'
interface Props {
score: number
userVote: 'upvote' | 'downvote' | null
disabled?: boolean
}
interface Emits {
(e: 'upvote'): void
(e: 'downvote'): void
}
const props = withDefaults(defineProps<Props>(), {
disabled: false
})
const emit = defineEmits<Emits>()
// Format score for display (e.g., 1.2k for 1200)
const displayScore = computed(() => {
const score = props.score
if (Math.abs(score) >= 10000) {
return (score / 1000).toFixed(0) + 'k'
}
if (Math.abs(score) >= 1000) {
return (score / 1000).toFixed(1) + 'k'
}
return score.toString()
})
// Score color based on value
const scoreClass = computed(() => {
if (props.userVote === 'upvote') return 'text-orange-500'
if (props.userVote === 'downvote') return 'text-blue-500'
if (props.score > 0) return 'text-foreground'
if (props.score < 0) return 'text-muted-foreground'
return 'text-muted-foreground'
})
function onUpvote() {
if (!props.disabled) {
emit('upvote')
}
}
function onDownvote() {
if (!props.disabled) {
emit('downvote')
}
}
</script>
<template>
<div class="flex flex-col items-center gap-0 min-w-[40px]">
<!-- Upvote button -->
<button
type="button"
:disabled="disabled"
:class="[
'p-1 rounded transition-colors',
'hover:bg-accent disabled:opacity-50 disabled:cursor-not-allowed',
userVote === 'upvote' ? 'text-orange-500' : 'text-muted-foreground hover:text-orange-500'
]"
@click="onUpvote"
>
<ChevronUp
class="h-5 w-5"
:class="{ 'fill-current': userVote === 'upvote' }"
/>
</button>
<!-- Score -->
<span
:class="[
'text-xs font-bold tabular-nums min-w-[24px] text-center',
scoreClass
]"
>
{{ displayScore }}
</span>
<!-- Downvote button -->
<button
type="button"
:disabled="disabled"
:class="[
'p-1 rounded transition-colors',
'hover:bg-accent disabled:opacity-50 disabled:cursor-not-allowed',
userVote === 'downvote' ? 'text-blue-500' : 'text-muted-foreground hover:text-blue-500'
]"
@click="onDownvote"
>
<ChevronDown
class="h-5 w-5"
:class="{ 'fill-current': userVote === 'downvote' }"
/>
</button>
</div>
</template>

Some files were not shown because too many files have changed in this diff Show more