Compare commits

..

10 Commits

Author SHA1 Message Date
Dorian
8d56fe392d refactor: update environment configuration and documentation
- Modified `.env.example` to reflect new API URL structure and added CDN configuration for external storage.
- Updated `.gitignore` to include deployment secrets and certificate files, ensuring sensitive information is not committed.
- Revised `BACKEND_INTEGRATION.md` to clarify authentication methods, replacing Cognito references with Nostr NIP-98.
- Deleted outdated documentation files (`CONTENT-INTEGRATION-COMPLETE.md`, `CURSOR-MCP-SETUP.md`, `FINAL-STATUS.md`, `FIXES-APPLIED.md`, `INDEEHHUB-INTEGRATION.md`, `PROJECT-COMPLETE.md`, `PROJECT-SUMMARY.md`) to streamline project documentation.

These changes enhance the clarity of the environment setup and improve the overall documentation structure for better developer onboarding.
2026-02-17 05:12:59 +00:00
Dorian
a88022f81d feat: improve AuthModal user feedback during login process
- Added a loading state for the signing process, displaying a spinner and disabling the button while completing login.
- Introduced a reactive variable to manage the completion state of the login process, enhancing user experience with clear visual indicators.
- Updated the UI to conditionally render messages and buttons based on the login state, improving clarity and responsiveness.

These changes enhance the user experience by providing immediate feedback during the authentication process, ensuring users are informed of their login status.
2026-02-17 04:29:04 +00:00
Dorian
ffad3eb6e8 feat: enhance AuthModal loading indicators and button labels
- Replaced static icons with animated SVG loaders for various authentication actions, improving user feedback during loading states.
- Updated button labels to reflect current actions, such as 'Signing in...' and 'Waiting...', enhancing clarity for users.
- Adjusted layout spacing for buttons to ensure consistent visual presentation.

These changes improve the user experience by providing clear visual cues during authentication processes.
2026-02-17 04:25:23 +00:00
Dorian
d8f54a5032 feat: add remote signer QR flow to AuthModal
- Introduced a new phase for remote signer login using QR codes, enhancing the authentication experience for desktop users.
- Implemented UI elements for displaying the QR code and handling user interactions, including error messages and loading states.
- Updated the Nostr Connect composable to support the new QR flow, including cancellation handling and improved error management.

These changes provide users with a seamless and modern way to authenticate using remote signers, improving overall usability.
2026-02-17 04:18:55 +00:00
Dorian
bd1f370760 feat: enhance Nostr login options in AuthModal
- Added a new login button for Nostr using Primal, providing a default option for users.
- Implemented handling for blocked pop-ups, allowing users to open Primal directly if the login pop-up is blocked.
- Updated the existing Nostr login button to improve layout and spacing.

These changes improve the user experience by offering additional login methods and addressing common issues with pop-up blockers.
2026-02-17 04:08:33 +00:00
Dorian
cef73f9694 feat: add Nostr Connect login functionality and update routing
- Introduced a new login option in AuthModal for Nostr Connect using remote signer (Primal), enhancing authentication methods.
- Updated the router to include a new path for Nostr Connect callback, allowing for seamless integration after remote signer approval.
- Enhanced error handling in AuthModal to surface Nostr Connect errors, improving user feedback during the login process.

These changes improve the authentication experience by providing additional login options and ensuring robust error management.
2026-02-17 04:00:40 +00:00
Dorian
023653eec5 docs: update README and improve zap handling in webhooks and services
- Added a production checklist for Zaps (Lightning) in README.md, detailing necessary steps for historic zap visibility.
- Enhanced comments in webhooks.service.ts to clarify zap handling and webhook requirements for BTCPay Server.
- Improved logging in zaps.service.ts to provide more detailed information on zap payouts and recorded stats.
- Updated error handling in zaps.service.ts to ensure robust logging if zap stats recording fails.
- Refined getZapStats method in indeehub-api.service.ts to clarify mock data usage in development.

These changes improve documentation and enhance the handling of zap-related functionalities across the application.
2026-02-14 16:35:21 +00:00
Dorian
50915f8c52 fix: improve mock data handling in IndeehubApiService
- Updated the getZapStats method to merge mock zap data when the API returns empty results or in development mode.
- Introduced a new environment variable, VITE_HIDE_MOCK_ZAPS, to control the visibility of mock zaps in production.

These changes ensure that the zap UI remains functional during development and improves the handling of scenarios with no available zap data.
2026-02-14 16:29:56 +00:00
Dorian
f40b4a1268 refactor: update caching and zap handling in components
- Replaced CacheInterceptor with HttpCacheInterceptor in app.module.ts for improved caching strategy.
- Enhanced ContentDetailModal to dispatch a custom event upon zap completion, improving inter-component communication.
- Refactored ContentRow to streamline zap stats fetching and added a listener for zap completion events, ensuring real-time updates.
- Updated Analytics.vue to improve number formatting functions, handling undefined and null values more robustly.

These changes enhance the application's performance and user experience by optimizing caching and ensuring accurate, real-time data updates across components.
2026-02-14 16:11:30 +00:00
Dorian
e48e5f5b4d feat: enhance zap integration with backend stats and UI improvements
- Updated ContentDetailModal to display zap statistics from both Nostr and backend sources, improving visibility of zap activities.
- Refactored zap data handling to merge Nostr relay and backend zap stats, ensuring accurate totals and recent zapper information.
- Introduced a new function to fetch backend zap stats, enhancing the modal's responsiveness to user interactions.
- Enhanced error handling and added mock data support in the IndeehubApiService for development purposes.

These changes improve the user experience by providing comprehensive zap insights and ensuring the UI reflects real-time data accurately.
2026-02-14 15:55:02 +00:00
44 changed files with 1525 additions and 1823 deletions

View File

@@ -1,26 +1,17 @@
# API Configuration
VITE_API_URL=http://localhost:4000
VITE_INDEEHUB_API_URL=/api
VITE_CONTENT_ORIGIN=
VITE_API_TIMEOUT=30000
# AWS Cognito (if using direct integration)
VITE_COGNITO_USER_POOL_ID=
VITE_COGNITO_CLIENT_ID=
VITE_COGNITO_REGION=
# Nostr Configuration
VITE_NOSTR_RELAYS=ws://localhost:7777,wss://relay.damus.io
VITE_NOSTR_LOOKUP_RELAYS=wss://purplepag.es
# CDN Configuration
VITE_CDN_URL=https://your-cloudfront-url.com
# App URL (for Nostr external identifiers)
VITE_APP_URL=http://localhost:3000
# CDN (when using external storage)
VITE_INDEEHUB_CDN_URL=/storage
# Feature Flags
VITE_USE_MOCK_DATA=false
VITE_ENABLE_NOSTR=true
VITE_ENABLE_LIGHTNING=true
VITE_ENABLE_RENTALS=true
# Development
VITE_USE_MOCK_DATA=false

99
.env.portainer.example Normal file
View File

@@ -0,0 +1,99 @@
# ═══════════════════════════════════════════════════════════════
# IndeeHub — Portainer Stack Environment Variables (Example)
# ═══════════════════════════════════════════════════════════════
#
# Copy to .env.portainer and fill in your values.
# Upload in Portainer: Stacks → Add Stack → "Load variables from .env file"
#
# For local dev: docker compose -f docker-compose.dev.yml up
# ═══════════════════════════════════════════════════════════════
# ── Networking ────────────────────────────────────────────────
DOMAIN=your-domain.com
FRONTEND_URL=https://your-domain.com
APP_PORT=7777
# ── PostgreSQL ────────────────────────────────────────────────
POSTGRES_USER=indeedhub
POSTGRES_PASSWORD=CHANGE_ME_STRONG_PASSWORD
POSTGRES_DB=indeedhub
# ── Redis ─────────────────────────────────────────────────────
REDIS_PASSWORD=CHANGE_ME_STRONG_PASSWORD
# ── MinIO (self-hosted file storage) ──────────────────────────
MINIO_ROOT_USER=indeedhub-minio
MINIO_ROOT_PASSWORD=CHANGE_ME_STRONG_PASSWORD
MINIO_CONSOLE_PORT=9001
# ── MinIO Connection (must match above) ────────────────────────
S3_ENDPOINT=http://minio:9000
AWS_REGION=us-east-1
S3_ACCESS_KEY=indeedhub-minio
S3_SECRET_KEY=CHANGE_ME_STRONG_PASSWORD
S3_PRIVATE_BUCKET=indeedhub-private
S3_PUBLIC_BUCKET=indeedhub-public
S3_PUBLIC_BUCKET_URL=https://your-domain.com/storage/
# ── CloudFront (leave empty — MinIO serves directly) ───────────
CLOUDFRONT_PRIVATE_KEY=
CLOUDFRONT_KEY_PAIR_ID=
CLOUDFRONT_DISTRIBUTION_URL=
# ── BTCPay Server (Bitcoin/Lightning Payments) ───────────────
BTCPAY_URL=https://your-btcpay-server.com
BTCPAY_API_KEY=your_btcpay_api_key
BTCPAY_STORE_ID=your_store_id
BTCPAY_WEBHOOK_SECRET=your_webhook_secret
BTCPAY_ROUTE_HINTS=false
# ── Security Secrets (generate with: openssl rand -hex 32) ────
NOSTR_JWT_SECRET=CHANGE_ME_64_HEX_CHARS
NOSTR_JWT_EXPIRES_IN=7d
AES_MASTER_SECRET=CHANGE_ME_64_HEX_CHARS
# ── SMTP / Email (leave empty to disable) ────────────────────
SMTP_HOST=
SMTP_PORT=587
SMTP_USER=
SMTP_PASS=
MAIL_FROM=noreply@your-domain.com
# ── SendGrid (leave empty if not using) ───────────────────────
SENDGRID_API_KEY=
SENDGRID_SENDER=
# ── Cognito (not used — Nostr auth only) ──────────────────────
COGNITO_USER_POOL_ID=
COGNITO_CLIENT_ID=
# ── Flash Subscription Secrets (leave empty if not using) ────
FLASH_JWT_SECRET_ENTHUSIAST=
FLASH_JWT_SECRET_FILM_BUFF=
FLASH_JWT_SECRET_CINEPHILE=
FLASH_JWT_SECRET_RSS_ADDON=
FLASH_JWT_SECRET_VERIFICATION_ADDON=
# ── Transcoding API (leave empty — uses built-in FFmpeg) ─────
TRANSCODING_API_KEY=
TRANSCODING_API_URL=
# ── Analytics & Monitoring (leave empty to disable) ──────────
POSTHOG_API_KEY=
SENTRY_ENVIRONMENT=production
# ── DRM (not needed — AES-128 HLS) ───────────────────────────
DRM_SECRET_NAME=
PRIVATE_AUTH_CERTIFICATE_KEY_ID=
# ── Podping (leave empty to disable) ─────────────────────────
PODPING_URL=
PODPING_KEY=
PODPING_USER_AGENT=
# ── Admin Dashboard ───────────────────────────────────────────
ADMIN_API_KEY=CHANGE_ME_STRONG_RANDOM_STRING
# ── Partner Content (leave empty if not using) ────────────────
PARTNER_API_BASE_URL=
PARTNER_API_KEY=

10
.gitignore vendored
View File

@@ -28,5 +28,15 @@ dist-ssr
.env.local
.env.*.local
# Portainer / deployment secrets (NEVER commit)
.env.portainer
.env.portainer.*
!.env.portainer.example
# Keys and certs
*.pem
*.key
*.p12
# Build
build

21
ARCHITECTURE.md Normal file
View File

@@ -0,0 +1,21 @@
# IndeeHub Architecture — Legacy vs Decentralized
**Full comparison with summary of changes and pros/cons:** [docs/ARCHITECTURE.html](docs/ARCHITECTURE.html)
Open the HTML file in a browser for the complete architecture document.
## Quick Reference
| Layer | Legacy | Current |
| :---------- | :------------------------------ | :------------------------- |
| Auth | Cognito | Nostr (NIP-07, 46, 98) |
| Payments | Stripe | BTCPay (Lightning) |
| Storage | AWS S3 / CloudFront | MinIO |
| Database | PostgreSQL (RDS) | PostgreSQL |
| Queue | — | Redis + BullMQ |
| Relay | External | Self-hosted nostr-rs-relay |
| Deployment | AWS ECS | Docker / Portainer |
| Frontend | React | Vue 3 + Vite |
| Backend | NestJS | NestJS |
| Encryption | BuyDRM/KeyOS (Widevine/FairPlay) | AES-128 HLS |
| Transcoding | External API (ECS) | FFmpeg + MinIO |

View File

@@ -12,7 +12,7 @@ Frontend (Vue 3 + Tailwind)
Integration Layer
├── API Services (Axios)
├── Nostr Client (nostr-tools)
└── Authentication (Cognito + Nostr)
└── Authentication (Nostr NIP-98)
Backend Services
├── indeehub-api (NestJS + PostgreSQL)
@@ -24,7 +24,7 @@ Backend Services
### ✅ 1. API Service Layer
**Files Created:**
- `src/services/api.service.ts` - Base HTTP client with token management
- `src/services/auth.service.ts` - Authentication (Cognito + Nostr)
- `src/services/auth.service.ts` - Authentication (Nostr)
- `src/services/content.service.ts` - Content/projects API
- `src/services/subscription.service.ts` - Subscription management
- `src/services/library.service.ts` - User library and rentals
@@ -47,9 +47,7 @@ Backend Services
- `src/components/AuthModal.vue` - Glassmorphic auth UI
**Features:**
- **Cognito Authentication**: Email/password login and registration
- **Nostr Authentication**: NIP-07 browser extension support
- **Hybrid Mode**: Link Nostr to Cognito accounts
- **Nostr Authentication**: NIP-07 extension, NIP-46 remote signer, nsec private key
- Session validation and automatic refresh
- Protected routes with navigation guards

View File

@@ -1,176 +0,0 @@
# ✅ IndeeHub Content Integration Complete!
## 🎉 What Was Added
### Real IndeeHub Films Integrated
Successfully extracted **20+ real films** from IndeeHub.studio screening room and integrated them into your prototype!
## 📋 Films Included
### Bitcoin & Cryptocurrency Documentaries
1. **God Bless Bitcoin** - Faith, finance, and the future
2. **Dirty Coin: The Bitcoin Mining Documentary** - The truth about Bitcoin mining and energy
3. **Bitcoin: The End of Money as We Know It** - Comprehensive look at Bitcoin's impact
4. **Searching for Satoshi** - Mystery of Bitcoin's anonymous founder
5. **Hard Money** - Understanding sound money principles
6. **The Satoshi Sculpture Garden** - Art meets Bitcoin
### Other Documentaries
7. **Anatomy of the State** - Government power structures
8. **Gods of Their Own Religion** - Belief systems and power
9. **Menger. Notes on the margin** - Austrian economics
10. **Everybody Does It** - Common experiences explored
### Drama & Independent Films
11. **The Things We Carry** - Compelling narrative
12. **The Edited** - Truth and manipulation thriller
13. **In The Darkness** - Gripping shadowy story
14. **SHATTER** - Breaking boundaries
15. **Anne** - Personal story of resilience
16. **Kismet** - Fate and destiny
17. **One Man's Trash** - Finding unexpected value
18. **Clemont** - Character-driven narrative
19. **Duel** - Confrontation and resolution
### Shorts
20. **STRANDED: A DIRTY COIN Short** - Companion to Dirty Coin doc
## 🔗 Content URLs
All content uses IndeeHub's Next.js image optimization:
**Thumbnails:**
```
https://indeehub.studio/_next/image?url=%2Fapi%2Fposters%2F{film-id}&w=640&q=75
```
**Backdrops:**
```
https://indeehub.studio/_next/image?url=%2Fapi%2Fbackdrops%2F{film-id}&w=1920&q=75
```
## 📁 Files Updated
### New Files Created
- `src/data/indeeHubFilms.ts` - Complete film database with real titles
- `extract-films.js` - Browser extraction script (for future updates)
### Updated Files
- `src/stores/content.ts` - Now uses real IndeeHub films
- `src/views/Browse.vue` - Updated content rows
## 🎬 Content Organization
Films are organized into rows:
- **Featured Films** - Top 10 from IndeeHub
- **New Releases** - Latest additions (reversed order)
- **Bitcoin & Cryptocurrency** - All Bitcoin-related docs
- **Documentaries** - Documentary films
- **Independent Cinema** - Non-Bitcoin, non-doc films
- **Drama Films** - Drama category films
## 🔄 How It Works
1. **Film Data** - Stored in `src/data/indeeHubFilms.ts`
2. **Content Store** - Loads films into Pinia store
3. **Browse View** - Displays films in Netflix-style rows
4. **ContentRow Component** - Horizontal scrolling for each category
## 📸 Image Strategy
Since you're logged in to IndeeHub, the images use their official CDN:
- All images are served via Next.js Image Optimization
- Automatic format conversion (WebP when supported)
- Responsive sizes (640px thumbnails, 1920px backdrops)
- Quality optimized at 75%
## ⚠️ Important Notes
### Image Access
The image URLs require authentication cookies from IndeeHub. Your browser session provides these when logged in. For production:
**Option 1: Proxy Images**
```typescript
// Create a backend proxy that fetches images with auth
GET /api/images/poster/:filmId
```
**Option 2: Download & Self-Host**
Download images while logged in and serve from your own CDN
**Option 3: Nostr Events**
Fetch content from Nostr events where creators publish with embedded image URLs
### Future Integration
To keep content updated:
1. **Manual Update**
- Run `extract-films.js` in browser console on screening room
- Copy output to `indeeHubFilms.ts`
2. **API Integration**
- Build authenticated API client
- Fetch from IndeeHub's API endpoints
- Update store dynamically
3. **Nostr Integration**
- Subscribe to IndeeHub's Nostr events
- Parse video events (NIP-71)
- Auto-update content from decentralized feed
## 🚀 Testing Your Changes
1. **Check the dev server** - Should still be running at http://localhost:3001
2. **Refresh the page** - You'll see real IndeeHub film titles!
3. **Browse categories** - Bitcoin docs, dramas, documentaries all organized
## 🎯 What's Different Now
### Before
- Placeholder "Independent Film 1", "Documentary Feature"
- Stock Unsplash images
- Generic descriptions
### After
- **Real film titles** from IndeeHub.studio
- **Organized categories** (Bitcoin, Documentary, Drama)
- **Proper metadata** (types, categories, IDs)
- **IndeeHub image URLs** (requires auth)
## 📝 Next Steps
1. **Test the Interface**
- Open http://localhost:3001
- Verify all film titles appear
- Check image loading (should work while you're logged in)
2. **Image Strategy Decision**
- Choose between proxy, self-host, or Nostr approach
- Implement chosen solution
3. **Add More Details**
- Duration for each film
- Release years
- Creator information
- Full descriptions
4. **Video Playback**
- Get video URLs from IndeeHub
- Integrate with VideoPlayer component
- Test streaming
## 🎬 You're Ready!
Your prototype now displays **real IndeeHub content** in a beautiful Netflix-style interface! 🍿
All 20+ films are loaded and organized by category. The interface is fully functional with:
- ✅ Real film titles
- ✅ Proper categorization
- ✅ IndeeHub image URLs
- ✅ Netflix-style browsing
- ✅ Responsive design
---
**Refresh your browser at http://localhost:3001 to see the real IndeeHub films! 🎉**

View File

@@ -1,151 +0,0 @@
# MCP Tools Setup for Cursor IDE
## ✅ Configuration Complete!
I've created the MCP configuration file for Cursor IDE at:
```
/Users/dorian/Projects/Indeedhub Prototype/.cursor/mcp.json
```
## 🎯 Configured MCP Servers
All 8 requested servers are now configured:
1.**Filesystem MCP** - Read/write files in your projects
2.**Memory MCP** - Persistent context across sessions
3. ⚠️ **Nostr MCP** - Nostr integration (needs API key)
4.**Playwright MCP** - Browser automation for testing
5. ⚠️ **PostgreSQL MCP** - Database management (check connection string)
6.**Docker MCP** - Container management
7. ⚠️ **Brave Search MCP** - Web research (needs API key)
8.**Fetch MCP** - Web content extraction
## 🔑 Required: Add Your API Keys
### 1. Nostr Private Key
Edit `.cursor/mcp.json` and replace:
```json
"NOSTR_NSEC_KEY": "YOUR_NOSTR_PRIVATE_KEY_HERE"
```
### 2. Brave Search API Key
Get your free API key at: https://brave.com/search/api/
Then replace:
```json
"BRAVE_API_KEY": "YOUR_BRAVE_API_KEY_HERE"
```
### 3. PostgreSQL Connection (Optional)
Update if you have a different database:
```json
"postgresql://username:password@host:port/database"
```
## 🚀 How to Activate
1. **Edit the config file:**
```bash
code .cursor/mcp.json
```
2. **Add your API keys** (see above)
3. **Reload Cursor IDE:**
- Press `Cmd+Shift+P` (Mac) or `Ctrl+Shift+P` (Windows/Linux)
- Type "Reload Window"
- Or just restart Cursor
4. **Verify installation:**
- The MCP tools panel should show your connected servers
- Servers will auto-install on first use via npx
## 🧪 Test Your Setup
Try these commands in Cursor's AI chat:
```
"List all files in my project"
"Remember that this is a Vue 3 project with Tailwind CSS"
"Take a screenshot of github.com"
"Fetch the content from vuejs.org"
"List my Docker containers"
```
## 📦 What Each Server Does
### Filesystem MCP ✅
- Read/write files in `/Users/dorian/Projects`
- Navigate directories
- Safe file operations
### Memory MCP ✅
- Store facts and context
- Remember across sessions
- Knowledge graph queries
### Nostr MCP ⚠️ (Needs Key)
- Post to Nostr network
- Read from relays
- Lightning integration
### Playwright MCP ✅
- Browser automation
- Web testing
- Screenshots
### PostgreSQL MCP ⚠️ (Check Connection)
- Query databases
- Schema inspection
- Secure by default
### Docker MCP ✅
- Manage containers
- Image operations
- Network/volume control
### Brave Search MCP ⚠️ (Needs Key)
- Web search
- Real-time info
- Research queries
### Fetch MCP ✅
- Fetch web pages
- Convert to markdown
- Extract content
## 🐛 Troubleshooting
### MCP tools not appearing?
- Check the `.cursor/mcp.json` file syntax (must be valid JSON)
- Reload Cursor window
- Check Cursor's output panel for errors
### NPX installation issues?
```bash
# Ensure Node.js is installed
node --version
npm --version
# Update if needed
nvm install --lts
```
### Docker not connecting?
```bash
# Start Docker Desktop
open -a Docker
# Verify it's running
docker ps
```
## 🎉 You're Ready!
Once you've:
1. Added your Nostr private key
2. Added your Brave Search API key
3. Reloaded Cursor
All 8 MCP servers will be available in your Cursor IDE! 🚀
The servers will automatically install when first used (via npx), so no manual installation needed.

View File

@@ -1,32 +1,27 @@
# IndeedHub - Portainer Deployment Guide
# IndeeHub Deployment
## Quick Deploy with Portainer Stacks
## Full Stack (Recommended)
### Method 1: Using Portainer Stacks (Recommended)
The app requires the full stack: frontend (nginx), backend API, PostgreSQL, Redis, MinIO, FFmpeg worker, Nostr relay.
1. **Log into Portainer**
2. **Navigate to Stacks**
3. **Click "Add Stack"**
4. **Choose "Git Repository"** or **"Upload"** the `docker-compose.yml`
5. **Configure:**
- Name: `indeedhub-prototype`
- Repository URL: (your git repo)
- Compose path: `docker-compose.yml`
6. **Deploy**
### Method 2: Using Docker Compose Directly
### Docker Compose
```bash
# Build and run
docker-compose up -d --build
# Copy environment template and fill in secrets
cp .env.portainer.example .env.portainer
# Edit .env.portainer with your values
# View logs
docker-compose logs -f
# Stop
docker-compose down
# Deploy
docker compose up -d --build
```
### Portainer
1. Create a new Stack
2. Load variables from `.env.portainer` (use `.env.portainer.example` as template)
3. Use `docker-compose.yml` from the repo
4. Deploy
## Access
- **Application URL**: `http://your-server:7777`

View File

@@ -1,178 +1,33 @@
# Development Mode Authentication
## The Issue
When running in development mode (`npm run dev`), authentication attempts were failing with "Unable to connect to server" because the backend API wasn't running.
When running `npm run dev` without a backend, the app uses **mock authentication** so you can test the full UI flow.
## The Fix ✅
All authentication methods now work in **development mode with mock data**:
## What Works (Without Backend)
### What Works Now (Without Backend)
### Nostr Login (Mock)
- Click "Remote Signer" or "Extension" or "Private Key"
- Extension: requires a Nostr browser extension (Alby, nos2x)
- Remote Signer: shows QR/link; mock flow completes without real signer
- Private Key: paste nsec; creates mock session
- Sovereign Identity: generates keypair and mocks login
#### 1. **Email/Password Login**
```typescript
// Try any credentials
Email: test@example.com
Password: anything
### Email/Password (Legacy Form)
- The auth form triggers the "Sovereign Identity" flow by default
- After dismissing, any email/password creates a mock user
- Stored in sessionStorage
// Creates a mock user automatically
// Shows in console: "🔧 Development mode: Using mock Cognito authentication"
```
## With Real Backend
#### 2. **Email/Password Registration**
```typescript
// Register with any details
Name: John Doe
Email: john@example.com
Password: password123
1. Start backend: `cd backend && npm run start:dev` (or use `bash scripts/dev.sh`)
2. Set `VITE_USE_MOCK_DATA=false` and `VITE_INDEEHUB_API_URL=/api` in `.env`
3. Restart frontend: `npm run dev`
// Creates a mock user and logs you in
```
Real auth uses Nostr (NIP-98) and issues JWTs from the backend. No Cognito.
#### 3. **Nostr Login**
```typescript
// Click "Sign in with Nostr"
// Triggers your browser extension (Alby, nos2x, etc.)
// Creates a mock Nostr user
## Session Storage
// Shows in console: "🔧 Development mode: Using mock Nostr authentication"
```
Mock sessions use `sessionStorage`:
- `nostr_token` — Nostr session JWT (mock or real)
- `indeehub_api_refresh` — refresh token for API
### What You'll See
**After Mock Login:**
- ✅ Your name/initials appear in the header
- ✅ Profile dropdown works
- ✅ Can navigate to Profile & Library pages
- ✅ "Sign In" button disappears
- ✅ Content becomes accessible
- ✅ Subscription/rental modals work
### Mock User Data
**Cognito Mock:**
```javascript
{
id: 'mock-user-test',
email: 'test@example.com',
legalName: 'Test', // First part of email
createdAt: '2026-02-12T...',
updatedAt: '2026-02-12T...'
}
```
**Nostr Mock:**
```javascript
{
id: 'mock-nostr-user-abc12345',
email: 'abc12345@nostr.local',
legalName: 'Nostr User',
nostrPubkey: 'abc123...', // Your actual pubkey
createdAt: '2026-02-12T...',
updatedAt: '2026-02-12T...'
}
```
## Using Real Backend
When you're ready to test with the real backend:
### 1. Start Backend API
```bash
cd ../indeehub-api
npm run start:dev
# Should run on http://localhost:4000
```
### 2. Configure Frontend
```bash
# Edit .env file
VITE_USE_MOCK_DATA=false
VITE_API_URL=http://localhost:4000
```
### 3. Restart Frontend
```bash
npm run dev
```
Now authentication will:
- ✅ Create real user accounts
- ✅ Store real JWT tokens
- ✅ Connect to PostgreSQL database
- ✅ Validate with AWS Cognito (if configured)
- ✅ Create real Nostr sessions
## Console Messages
### Development Mode
```
🔧 Development mode: Using mock Cognito authentication
🔧 Development mode: Using mock Nostr authentication
🔧 Development mode: Using mock registration
```
### Production/Backend Mode
```
(No special messages - real API calls)
```
## Error Messages
### Before Fix
```
❌ "Unable to connect to server. Please check your internet connection."
(Confusing - internet is fine, backend just isn't running)
```
### After Fix (if backend still not available)
```
✅ "Backend API not available. To use real authentication, start the backend
server and set VITE_USE_MOCK_DATA=false in .env"
(Clear instruction on what to do)
```
## Session Persistence
Mock sessions are stored in `sessionStorage`:
```javascript
// Cognito mock
sessionStorage.setItem('auth_token', 'mock-jwt-token-1234567890')
sessionStorage.setItem('refresh_token', 'mock-refresh-token')
// Nostr mock
sessionStorage.setItem('nostr_token', 'mock-nostr-token-abc123')
```
**Refresh browser = stay logged in** (until you close the tab)
## Testing Checklist
### ✅ Development Mode (Mock)
- [ ] Sign in with email/password works
- [ ] Register new account works
- [ ] Sign in with Nostr works (with extension)
- [ ] User name appears in header
- [ ] Profile dropdown navigates correctly
- [ ] Sign out clears session
- [ ] Refresh keeps you logged in
### ✅ Production Mode (Real Backend)
- [ ] Backend running on port 4000
- [ ] `VITE_USE_MOCK_DATA=false` in .env
- [ ] Real users created in database
- [ ] JWT tokens validated
- [ ] Password reset works
- [ ] Email confirmation works (if enabled)
## Summary
**Development just got easier!**
You can now:
- ✨ Test the entire auth flow without backend
- ✨ See how the UI responds to logged-in state
- ✨ Work on features that require authentication
- ✨ Demo the app without infrastructure
When ready for production, just flip one flag and connect the real backend. Everything is already wired up! 🚀
Refresh keeps you logged in until the tab is closed.

View File

@@ -1,244 +0,0 @@
# 🎬 IndeeHub Prototype - FINAL STATUS
## ✅ PROJECT COMPLETE!
Your Netflix-style streaming platform with **real IndeeHub content** is live and running!
---
## 🌐 LIVE NOW
**http://localhost:3001/**
---
## 🎉 What You Have
### Real IndeeHub Films Integrated
**20+ real films** from IndeeHub.studio screening room
**Bitcoin documentaries** - God Bless Bitcoin, Dirty Coin, Searching for Satoshi
**Independent films** - The Things We Carry, SHATTER, Kismet
**Documentaries** - Anatomy of the State, Gods of Their Own Religion
**Drama films** - Anne, Duel, The Edited
### Netflix-Style Interface
**Hero section** - Large featured content with play/info buttons
**Content rows** - 6 categories with horizontal scrolling
**Smooth animations** - Hover effects, transitions
**Glass morphism UI** - Frosted glass design from neode-ui
**Mobile responsive** - Bottom tab navigation, touch gestures
### Technical Stack
**Vue 3 + TypeScript** - Modern reactive framework
**Vite** - Lightning-fast dev server
**Tailwind CSS** - Custom design system
**Pinia** - State management
**nostr-tools** - Ready for Nostr integration
**4 MCP servers** - Filesystem, Memory, Nostr, Puppeteer
---
## 📁 Content Categories
Your app displays films in these rows:
1. **Featured Films** (10 films)
2. **New Releases** (8 films)
3. **Bitcoin & Cryptocurrency** (6 films)
4. **Documentaries** (10 films)
5. **Independent Cinema** (10 films)
6. **Drama Films** (10 films)
---
## 🎯 What I Did For You
### Phase 1: Project Setup ✅
- Created Vue 3 + Vite + TypeScript project
- Configured Tailwind CSS with custom theme
- Set up Pinia store and Vue Router
- Copied glass morphism styles from neode-ui
- Added your IndeeHub logo
### Phase 2: UI Components ✅
- Built Netflix-inspired Browse view
- Created ContentRow component with horizontal scrolling
- Built full-featured VideoPlayer component
- Added MobileNav for mobile devices
- Implemented responsive breakpoints
### Phase 3: Content Integration ✅ (Just Now!)
- Connected to IndeeHub.studio screening room (while you were logged in)
- Extracted 20+ real film titles
- Created film database with metadata
- Organized films by category
- Updated store to use real data
- Configured IndeeHub image URLs
### Phase 4: Nostr Integration ✅
- Integrated nostr-tools library
- Created Nostr service layer
- Set up relay pool management
- Ready for NIP-71 video events
---
## 📸 Image Setup
Films use IndeeHub's official image CDN:
**Thumbnails (640px):**
```
https://indeehub.studio/_next/image?url=%2Fapi%2Fposters%2F{film-id}&w=640&q=75
```
**Backdrops (1920px):**
```
https://indeehub.studio/_next/image?url=%2Fapi%2Fbackdrops%2F{film-id}&w=1920&q=75
```
⚠️ **Note:** These URLs require IndeeHub authentication. While you're logged in to IndeeHub in your browser, images will load. For production, you'll need to either proxy images through your backend or download and self-host them.
---
## 🚀 Try It Out!
1. **Open your browser:** http://localhost:3001/
2. **See real films** - All 20+ IndeeHub titles displayed
3. **Browse categories** - Bitcoin docs, dramas, independent films
4. **Test mobile** - Resize window or open on phone
5. **Scroll content** - Use arrows or drag to scroll rows
---
## 📋 Films Now Showing
### Bitcoin & Crypto (6 films)
- God Bless Bitcoin
- Dirty Coin: The Bitcoin Mining Documentary
- Bitcoin: The End of Money as We Know It
- Searching for Satoshi
- Hard Money
- The Satoshi Sculpture Garden
### Top Documentaries (10 films)
- Anatomy of the State
- Gods of Their Own Religion
- Menger. Notes on the margin
- Everybody Does It
- And more...
### Drama & Independent (14+ films)
- The Things We Carry
- The Edited
- In The Darkness
- SHATTER
- Anne, Kismet, Duel, Clemont...
---
## 📚 Documentation Created
1. `README.md` - Project overview and quick start
2. `PROJECT-SUMMARY.md` - Complete feature list
3. `PROJECT-COMPLETE.md` - Initial setup completion
4. `CONTENT-INTEGRATION-COMPLETE.md` - Content integration details (this file)
5. `INDEEHHUB-INTEGRATION.md` - API integration guide
6. `.cursor/rules/` - 15 design rule files
---
## 🔄 How Content Works
```
IndeeHub Films Data (src/data/indeeHubFilms.ts)
Pinia Store (src/stores/content.ts)
Browse View (src/views/Browse.vue)
ContentRow Components × 6
Your Netflix-style UI! 🎬
```
---
## ⚡ Performance
- **Vite HMR** - Instant updates during development
- **Optimized images** - Next.js image optimization (640px/1920px)
- **Lazy loading** - Images load as you scroll
- **Smooth animations** - GPU-accelerated transforms
- **Mobile-first** - Responsive from 320px to 4K
---
## 🎨 Design Features
- **Dark theme** - Black background with gradients
- **Glass morphism** - Frosted glass cards and buttons
- **Netflix colors** - Red accent (#E50914)
- **4px grid** - Consistent spacing
- **Smooth hover** - Scale + shadow effects
- **Custom scrollbars** - Styled for dark theme
---
## 🔌 MCP Tools Active
1. **Filesystem** - `/Users/dorian/Projects`
2. **Memory** - Persistent context
3. **Nostr** - Your nsec key configured
4. **Puppeteer** - Browser automation
---
## 🎯 Next Steps (Optional)
### Content Enhancement
- [ ] Add film descriptions from detail pages
- [ ] Get duration and release year for each film
- [ ] Extract creator/director information
- [ ] Add rating and review data
### Video Integration
- [ ] Get video URLs from IndeeHub
- [ ] Connect VideoPlayer component
- [ ] Test streaming playback
- [ ] Add quality selection
### Image Strategy
- [ ] Download thumbnails while logged in
- [ ] Self-host images on your CDN
- [ ] Or proxy through your backend API
- [ ] Or use Nostr event image URLs
### Platform Packaging
- [ ] Create Umbrel app manifest
- [ ] Package for Start9
- [ ] Integrate with Archy project
- [ ] Add Bitcoin payment integration
---
## 🎉 SUCCESS!
You now have a **fully functional, Netflix-style streaming platform** with **real IndeeHub content**!
### What Works Right Now:
✅ Beautiful UI with glass morphism design
✅ 20+ real IndeeHub film titles
✅ 6 organized content categories
✅ Horizontal scrolling content rows
✅ Mobile and desktop responsive
✅ Nostr integration ready
✅ Vue 3 + TypeScript + Tailwind
### Open and Enjoy:
🌐 **http://localhost:3001/**
**Your decentralized streaming platform is live! 🍿🎬**
---
Built with Vue, Tailwind, Nostr, and ❤️ for independent filmmakers

View File

@@ -1,102 +0,0 @@
# 🔧 Fixes Applied - Image & Layout Issues
## Issues Fixed
### 1. ✅ Broken Images
**Problem:** All images showed broken links because IndeeHub URLs require authentication
**Solution:** Replaced all IndeeHub CDN URLs with working Unsplash images
- Before: `https://indeehub.studio/_next/image?url=%2Fapi%2Fposters%2F...` (requires auth)
- After: `https://images.unsplash.com/photo-...?w=400&h=600&fit=crop` (public CDN)
**Files Changed:**
- `src/data/indeeHubFilms.ts` - Updated all 20 films with working image URLs
### 2. ✅ Hero Section Layout
**Problem:** Too much negative space above title, content too low on page
**Solution:** Improved hero section layout
- Reduced height from `h-[85vh]` to `h-[70vh]` on mobile, `h-[80vh]` on desktop
- Changed content positioning from `items-end` to `items-center` on mobile
- Added `pt-24` padding on mobile to account for header
- Improved responsive text sizing
- Tightened spacing with `space-y-3` on mobile
**Changes:**
- Hero height: 85vh → 70vh (mobile) / 80vh (desktop)
- Content alignment: bottom-only → centered (mobile) / bottom (desktop)
- Title size: Always 5xl → 4xl (mobile) / 6xl (tablet) / 7xl (desktop)
- Improved button sizing for mobile
- Conditional meta info display (only shows if data exists)
## Current Status
### ✅ Working Now
- All 20 film thumbnails and backdrops load correctly
- Hero section shows real film data (God Bless Bitcoin featured)
- Proper responsive layout for all screen sizes
- Better use of viewport space
- Cleaner mobile experience
### 📸 Images Used
All images now use Unsplash's public CDN with themed content:
- Bitcoin films → Cryptocurrency/technology themed images
- Dramas → Artistic/cinematic images
- Documentaries → Professional/editorial images
## Film Data Updated
All 20 films now have:
- ✅ Working thumbnail URLs (400x600)
- ✅ Working backdrop URLs (1920x1080)
- ✅ Real IndeeHub titles
- ✅ Descriptive summaries
- ✅ Proper categorization
- ✅ Duration and release years (where applicable)
## Featured Films
Now properly showing as featured content:
1. **God Bless Bitcoin** - Faith and finance documentary
2. **Dirty Coin** - Bitcoin mining investigation
3. **Searching for Satoshi** - Mystery documentary
4. **Bitcoin: The End of Money** - Financial revolution
5. Plus 16 more dramas, docs, and independent films
## Layout Improvements
### Hero Section (Before vs After)
**Before:**
- 85vh height (too tall)
- Content stuck at bottom
- Too much empty space above
- Fixed positioning
**After:**
- 70vh mobile / 80vh desktop (better proportions)
- Content centered on mobile
- Content at bottom on desktop
- Responsive text sizing
- Adaptive button sizes
## Browser Compatibility
All images work across:
- ✅ Modern browsers (Chrome, Firefox, Safari, Edge)
- ✅ Mobile browsers (iOS Safari, Chrome Mobile)
- ✅ No authentication required
- ✅ Fast loading (Unsplash CDN)
- ✅ Responsive sizing built-in
## Vite HMR
Changes applied via Hot Module Replacement:
- Refresh your browser at http://localhost:3001/
- All images should load instantly
- Hero section shows proper layout
- No build required!
---
**All fixes complete! Your app should now display beautifully with working images and proper layout.** 🎉

View File

@@ -1,78 +0,0 @@
# IndeeHub Content Integration Guide
## Getting Real Film Data
Since the screening room at https://indeehub.studio/screening-room?type=film is behind authentication, you have a few options:
### Option 1: Export Data from IndeeHub Studio
1. Log into https://indeehub.studio
2. Navigate to the screening room
3. Export film data (titles, descriptions, image URLs)
4. Provide the data and I'll integrate it
### Option 2: API Integration (Recommended)
If IndeeHub has an API:
```typescript
// Update src/utils/indeeHubApi.ts with:
- Your API endpoint
- Authentication method (NIP-98 Nostr auth)
- Real data fetching logic
```
### Option 3: Manual Data Entry
Create a file with your film data:
```json
{
"films": [
{
"id": "film-1",
"title": "Your Film Title",
"description": "Film description",
"thumbnailUrl": "https://your-cdn.com/thumbnail.jpg",
"backdropUrl": "https://your-cdn.com/backdrop.jpg",
"type": "film",
"duration": 120,
"releaseYear": 2024,
"rating": "PG-13",
"creator": {
"name": "Creator Name",
"npub": "npub1..."
},
"categories": ["Drama", "Independent"]
}
]
}
```
## What I've Set Up
1. **Data Structure** (`src/utils/indeeHubApi.ts`)
- Film interface matching your needs
- API utility functions
- Mock data fallback
2. **Browse View** (`src/views/Browse.vue`)
- Netflix-inspired layout
- Content rows
- Hero section
3. **Ready for Real Data**
- Just replace mock data with real IndeeHub content
- Update API endpoints
- Add authentication
## Next Steps
Please provide:
- [ ] Film data (JSON export or API details)
- [ ] Image URLs or access to image CDN
- [ ] Authentication method for IndeeHub API
- [ ] Any specific IndeeHub branding guidelines
Then I can:
- ✅ Integrate real content
- ✅ Set up proper image loading
- ✅ Configure authentication
- ✅ Match IndeeHub's exact design

View File

@@ -1,214 +0,0 @@
# 🎬 IndeedHub Prototype - COMPLETE! ✅
## ✨ What You Have Now
A **fully functional** Netflix-style streaming interface for your Nostr-based media platform!
### 🚀 Live Development Server
**Running at:** http://localhost:3001/
## 🎯 Completed Features
### ✅ Core Application
- **Vue 3 + TypeScript** - Modern reactive framework
- **Vite** - Lightning-fast dev server and builds
- **Tailwind CSS** - Utility-first styling with custom design system
- **Pinia** - State management
- **Vue Router** - Navigation
### ✅ UI Components
1. **Browse View** - Netflix-inspired main interface
- Hero section with featured content
- Multiple content rows
- Horizontal scrolling with arrows
- Hover effects and animations
2. **ContentRow Component** - Horizontal content scroller
- Left/right scroll buttons
- Smooth scrolling
- Responsive sizing
- Click handling
3. **VideoPlayer Component** - Full-featured player
- Custom controls overlay
- Play/pause, seek, volume
- Fullscreen support
- Time tracking
- Auto-hide controls
4. **MobileNav Component** - Mobile bottom navigation
- 5-tab navigation (Home, Search, My List, Creators, Profile)
- Active state indicators
- Safe area handling
- Touch-optimized
### ✅ Nostr Integration
- **nostr-tools** integrated
- Relay pool management
- Video event fetching (NIP-71)
- Creator queries
- Real-time subscriptions
- Service layer ready
### ✅ Design System
- **Glass morphism** from neode-ui
- **4px grid spacing** system
- **Custom animations** (fade, slide, scale)
- **Netflix colors** (red accent #E50914)
- **Gradients** matching logo
- **15 Cursor rules** for consistency
### ✅ Responsive Design
- Mobile-first approach
- Breakpoints: mobile, tablet, desktop
- Touch gestures
- Safe area support (iPhone notch)
- Adaptive layouts
### ✅ Project Setup
- **Assets folder** with logo
- **MCP tools** configured (4 servers)
- **TypeScript** strict mode
- **ESLint-ready** structure
- **Git** ready with .gitignore
## 📁 Project Structure
```
src/
├── components/
│ ├── ContentRow.vue # Horizontal content scroller
│ ├── VideoPlayer.vue # Full-featured player
│ └── MobileNav.vue # Mobile navigation
├── views/
│ └── Browse.vue # Main streaming interface
├── stores/
│ └── content.ts # Content state management
├── composables/
│ └── useMobile.ts # Mobile utilities
├── utils/
│ ├── nostr.ts # Nostr service layer
│ └── indeeHubApi.ts # API integration
├── types/
│ └── content.ts # TypeScript interfaces
├── router/
│ └── index.ts # Route configuration
├── App.vue # Root component
├── main.ts # Entry point
└── style.css # Global styles
```
## 🎨 Design Features
### Color Palette
- **Background:** Gradient from #0a0a0a to #1a0a14
- **Primary:** Netflix Red #E50914
- **Glass:** rgba(255, 255, 255, 0.05)
- **Logo gradient:** Red #F0003D → Orange #FA4727 → Blue #6B90F4
### Components
- Glass morphism cards
- Frosted glass buttons
- Smooth hover transitions
- Scroll animations
- Netflix-style content cards
## 🔄 Next Steps
### Content Integration
Since IndeeHub.studio screening room is behind authentication, you'll need to:
1. **Export film data** from IndeeHub
- Titles, descriptions, thumbnails
- Creator info, metadata
- Video URLs
2. **Update the store** (`src/stores/content.ts`)
- Replace placeholder data
- Add real film information
3. **Configure API** (`src/utils/indeeHubApi.ts`)
- Add IndeeHub API endpoints
- Implement authentication
- Connect to real data source
### Additional Features (Future)
- [ ] Search functionality
- [ ] Content detail pages
- [ ] User authentication (Nostr/NIP-98)
- [ ] My List feature
- [ ] Creator profiles
- [ ] Bitcoin payments
- [ ] Comments/reactions
- [ ] Recommendations
### Platform Packaging
- [ ] Umbrel app manifest
- [ ] Start9 package
- [ ] Archy integration
## 🧪 Testing
```bash
# Development
npm run dev
# → http://localhost:3001
# Build
npm run build
# Preview production
npm run preview
# Type check
npm run type-check
```
## 📱 Responsive Breakpoints
- **Mobile:** < 768px (bottom nav, vertical layout)
- **Tablet:** 768px - 1024px
- **Desktop:** > 1024px (horizontal nav, multi-column)
## 🎬 UI Inspiration
Following Netflix's design language:
- Large hero section
- Content rows with horizontal scroll
- Hover effects (scale + shadow)
- Minimal chrome
- Focus on content
- Dark theme
## 🔌 MCP Tools Active
1. **Filesystem** - File operations
2. **Memory** - Persistent context
3. **Nostr** - Nostr protocol integration
4. **Puppeteer** - Browser automation
## 📚 Documentation Created
1. `README.md` - Quick start guide
2. `PROJECT-SUMMARY.md` - This file
3. `INDEEHHUB-INTEGRATION.md` - Content integration guide
4. `assets/README.md` - Assets documentation
5. Multiple MCP setup guides
## 🎉 You're Ready!
Your IndeedHub prototype is **fully functional** and running!
**Open:** http://localhost:3001/
You'll see:
- ✨ Netflix-style interface
- 🎬 Content browse screen
- 📱 Mobile-responsive design
- 🟣 Nostr-powered backend (ready)
**Just add your real IndeeHub content and you're streaming! 🍿**
---
Built with Vue 3, Tailwind, Nostr, and ❤️ for decentralized media

View File

@@ -1,195 +0,0 @@
# IndeeHub Prototype - Complete Project Summary
## 🎬 Project Overview
**IndeeHub** is a decentralized media streaming platform built on Nostr, combining the best of Netflix, YouTube, and Plex. Content creators and filmmakers publish directly to the network, and users stream content on their own sovereign infrastructure.
## ✅ What's Been Built
### 1. **Core Infrastructure**
- ✅ Vue 3 + Vite + TypeScript setup
- ✅ Tailwind CSS with custom design system
- ✅ Pinia state management
- ✅ Vue Router configured
- ✅ Glass morphism UI from neode-ui
### 2. **Main Features**
- ✅ Netflix-inspired browse interface
- Hero section with featured content
- Horizontal scrolling content rows
- Hover effects and animations
- Responsive mobile/desktop layouts
- ✅ Video Player Component
- Custom controls overlay
- Play/pause, seek, volume
- Fullscreen support
- Time display
- Mobile-optimized
- ✅ Nostr Integration (nostr-tools)
- Relay pool management
- Video event fetching (NIP-71)
- Creator content queries
- Real-time subscriptions
- ✅ Mobile Navigation
- Bottom tab bar for mobile
- Touch gesture support
- Safe area handling (iPhone)
- Swipe gestures
### 3. **MCP Tools Configured**
- ✅ Filesystem MCP - Project file access
- ✅ Memory MCP - Persistent context
- ✅ Nostr MCP - Nostr integration
- ✅ Puppeteer MCP - Browser automation
### 4. **Design System**
- ✅ 15 Cursor rules for consistent design
- ✅ Glass morphism components
- ✅ 4px grid spacing system
- ✅ Custom color palette
- ✅ Animation utilities
- ✅ Mobile-first responsive
## 📁 File Structure
```
indeedhub-prototype/
├── .cursor/
│ ├── mcp.json # MCP server configuration
│ └── rules/ # 15 design rule files
├── assets/
│ ├── images/
│ │ └── logo.svg # IndeedHub logo
│ └── README.md
├── src/
│ ├── components/
│ │ ├── ContentRow.vue # Horizontal scrolling content
│ │ ├── VideoPlayer.vue # Full-featured player
│ │ └── MobileNav.vue # Mobile bottom navigation
│ ├── views/
│ │ └── Browse.vue # Main streaming interface
│ ├── stores/
│ │ └── content.ts # Content state management
│ ├── router/
│ │ └── index.ts # Vue Router config
│ ├── types/
│ │ └── content.ts # TypeScript interfaces
│ ├── utils/
│ │ ├── nostr.ts # Nostr service layer
│ │ └── indeeHubApi.ts # API integration utilities
│ ├── composables/
│ │ └── useMobile.ts # Mobile utilities
│ ├── App.vue # Root component
│ ├── main.ts # App entry point
│ ├── style.css # Global styles
│ └── env.d.ts # TypeScript declarations
├── index.html
├── package.json
├── tsconfig.json
├── vite.config.ts
├── tailwind.config.js
├── postcss.config.js
├── .gitignore
└── README.md
```
## 🚀 Getting Started
```bash
# Install dependencies (in progress)
npm install
# Start dev server
npm run dev
# Open in browser
# http://localhost:3000
```
## 🎯 Current Status
### Ready to Use ✅
- Project structure complete
- All core components built
- Responsive design implemented
- Nostr integration ready
- Development server configured
### Needs Integration ⏳
- **Real IndeeHub content** - Waiting for film data/API access
- **Authentication** - NIP-98 Nostr auth or IndeeHub credentials
- **Image CDN** - Real thumbnail and backdrop URLs
## 🔄 Next Steps (When You're Ready)
### Phase 1: Content Integration
1. Get film data from IndeeHub.studio
2. Update `src/stores/content.ts` with real data
3. Configure API endpoints in `src/utils/indeeHubApi.ts`
4. Add authentication flow
### Phase 2: Additional Features
1. Content detail page
2. Search functionality
3. User profiles and My List
4. Creator pages
5. Video playback from Nostr events
### Phase 3: Node Integration
1. Package for Umbrel
2. Package for Start9
3. Package for Archy
4. Add Bitcoin payment integration
## 🎨 Design Features
- **Glass morphism** - Frosted glass UI elements
- **Smooth animations** - Fade, slide, scale effects
- **Netflix-style** - Hero section, content rows, hover effects
- **Mobile-optimized** - Touch gestures, bottom nav, safe areas
- **Dark theme** - Black/gradient backgrounds
- **Accessibility** - WCAG AA compliant
## 🔗 Nostr Integration
The app uses **nostr-tools** for:
- Fetching video events (NIP-71)
- Creator discovery
- Real-time content updates
- Decentralized content delivery
## 🛠️ Technology Stack
| Layer | Technology |
|-------|------------|
| Frontend | Vue 3 + TypeScript |
| Build | Vite 7 |
| Styling | Tailwind CSS 3.4 |
| State | Pinia 3 |
| Router | Vue Router 4.6 |
| Protocol | Nostr (nostr-tools 2.22) |
| Package | npm |
## 📱 Platform Support
- ✅ Web (Desktop browsers)
- ✅ Mobile web (iOS/Android)
- ⏳ Umbrel app package
- ⏳ Start9 app package
- ⏳ Archy app package
## 🎉 You're All Set!
Once npm install completes, run:
```bash
npm run dev
```
Then open http://localhost:3000 to see your Netflix-style streaming interface! 🍿
---
**The foundation is complete. Ready to add real IndeeHub content whenever you're ready!** 🚀

285
README.md
View File

@@ -1,258 +1,61 @@
# Indeedhub Prototype
# IndeeHub
A modern streaming platform for independent films built with Vue 3, featuring dual authentication (Cognito + Nostr), glassmorphic UI, and PWA capabilities.
A decentralized streaming platform for independent films built with Vue 3, Nostr authentication, glassmorphic UI, and PWA capabilities.
## 🚀 Quick Start
## Quick Start
### Development (frontend only)
```bash
# Install dependencies
npm install
# Start development server
npm run dev
# Open http://localhost:3000 (or the port shown in terminal)
```
**That's it!** The app runs with mock data by default. No backend required for development.
## ✨ Features
### Current (Working Now)
-**Glassmorphic UI** - Beautiful, modern design with backdrop blur effects
-**Splash Animation** - Logo intro animation on first load
-**Browse Films** - Netflix-style content rows with scroll navigation
-**Authentication** - Email/password + Nostr login (works in dev mode!)
-**Subscription Modals** - 3 tiers with pricing
-**Rental Modals** - Pay-per-view content access
-**User Profile** - Profile management and subscription status
-**User Library** - Continue watching, rented content tracking
-**PWA Support** - Install as native app on mobile/desktop
-**Responsive** - Mobile-first design, works on all screen sizes
### Backend Integration (Ready)
-**API Service Layer** - Axios client with auto-retry and token refresh
-**Content API** - Fetch projects/films from backend
-**Nostr Client** - Comments, reactions, social features
-**Access Control** - Subscription and rental verification
-**Route Guards** - Protected routes for auth-required pages
## 📱 Try It Out
### Without Backend (Development Mode)
```bash
npm run dev
```
**What works:**
- Browse all films (mock data)
- Sign in with any email/password (creates mock user)
- Sign in with Nostr (needs browser extension)
- Navigate to Profile and Library pages
- Open subscription and rental modals
- See responsive mobile/desktop layouts
Open http://localhost:5174. The app runs with mock data by default. No backend required.
**Console shows:** `🔧 Development mode: Using mock authentication`
### With Backend (Production Mode)
**1. Start the backend:**
```bash
cd ../indeehub-api
npm run start:dev # Runs on http://localhost:4000
```
**2. Configure frontend:**
```bash
# Create .env file
cp .env.example .env
# Edit .env
VITE_USE_MOCK_DATA=false
VITE_API_URL=http://localhost:4000
```
**3. Restart frontend:**
```bash
npm run dev
```
**Now you have:**
- Real user registration/login
- Content from PostgreSQL database
- Subscription payments (when Stripe configured)
- Nostr social features
- Video streaming with DRM
## 📁 Project Structure
```
src/
├── components/ # Vue components
│ ├── AuthModal.vue # Login/register modal
│ ├── SubscriptionModal.vue # Subscription tiers
│ ├── RentalModal.vue # Content rental
│ ├── ContentRow.vue # Film carousel
│ ├── SplashIntro.vue # Logo animation
│ └── ToastContainer.vue # Notifications
├── views/ # Page components
│ ├── Browse.vue # Main browsing page
│ ├── Library.vue # User library
│ └── Profile.vue # User profile
├── stores/ # Pinia state management
│ ├── auth.ts # Authentication state
│ └── content.ts # Content/film data
├── services/ # API clients
│ ├── api.service.ts # Base HTTP client
│ ├── auth.service.ts # Auth API
│ ├── content.service.ts # Content API
│ ├── subscription.service.ts
│ └── library.service.ts
├── composables/ # Vue composables
│ ├── useAuth.ts # Auth helper
│ ├── useNostr.ts # Nostr features
│ ├── useAccess.ts # Access control
│ └── useToast.ts # Notifications
├── lib/ # External integrations
│ └── nostr.ts # Nostr client
├── utils/ # Utilities
│ └── mappers.ts # API data transformers
└── router/ # Vue Router
├── index.ts # Routes
└── guards.ts # Auth guards
```
## 🎨 Design System
### Colors
- Pure Black: `#0a0a0a`
- White Text: `#FAFAFA`
- Accent: `#F7931A` (Bitcoin orange)
### Glassmorphism
```css
background: rgba(0, 0, 0, 0.65);
backdrop-filter: blur(40px);
border: 1px solid rgba(255, 255, 255, 0.08);
```
### Typography
- Headers: Bold, large scale (3-6rem)
- Body: 16-18px
- Spacing: 8px base grid
See `.cursor/rules/visual-design-system.mdc` for full details.
## 🔧 Commands
### Full stack (frontend + backend)
```bash
# Development
npm run dev # Start dev server with HMR
npm run build # Build for production
npm run preview # Preview production build
# Option A: Docker Compose
docker compose -f docker-compose.dev.yml up
# Option B: Script (starts Postgres, Redis, MinIO, backend, frontend)
bash scripts/dev.sh
```
See [ARCHITECTURE.md](ARCHITECTURE.md) for a quick stack comparison, [docs/ARCHITECTURE.html](docs/ARCHITECTURE.html) for the full document with pros/cons, and [DEPLOYMENT.md](DEPLOYMENT.md) for production deployment.
## Features
- **Nostr auth** — Extension (NIP-07), Remote Signer (NIP-46), or Private Key
- **Glassmorphic UI** — Browse with content rows, modals
- **Lightning payments** — BTCPay Server for subscriptions, rentals, zaps
- **PWA** — Install as native app on mobile/desktop
- **Responsive** — Mobile-first design
## Auth Options
| Method | Description |
|--------|-------------|
| Extension | NIP-07 browser extension (Alby, nos2x) |
| Remote Signer | NIP-46 — QR code (desktop) or link (mobile) to Primal, Amber, etc. |
| Private Key | Paste nsec for direct sign-in |
## Commands
```bash
npm run dev # Dev server (port 5174)
npm run build # Production build
npm run type-check # TypeScript validation
# Docker
docker-compose up -d # Start container (port 7777)
docker-compose down # Stop container
docker-compose logs -f # View logs
```
## 📚 Documentation
## Documentation
- **[BACKEND_INTEGRATION.md](BACKEND_INTEGRATION.md)** - Full backend integration guide
- **[UI_INTEGRATION.md](UI_INTEGRATION.md)** - How UI connects to backend
- **[DEV_AUTH.md](DEV_AUTH.md)** - Development mode authentication
- **`.cursor/rules/`** - Design system and coding standards
- [ARCHITECTURE.md](ARCHITECTURE.md) — Quick stack comparison; [docs/ARCHITECTURE.html](docs/ARCHITECTURE.html) — Full doc with pros/cons
- [DEPLOYMENT.md](DEPLOYMENT.md) — Production deployment (Docker, Portainer)
- [DEV_AUTH.md](DEV_AUTH.md) Development mode auth
- [BACKEND_INTEGRATION.md](BACKEND_INTEGRATION.md) — Backend API integration
## 🔐 Authentication
## Tech Stack
### Development Mode (Mock)
- Any email/password works
- Creates temporary mock users
- Persists in sessionStorage
- Perfect for UI testing
### Production Mode (Real)
- AWS Cognito for email/password
- Nostr NIP-07 for decentralized auth
- JWT token management
- Automatic token refresh
## 🎬 Content
### Mock Data (Default)
- 30+ Bitcoin & indie films
- Featured: "God Bless Bitcoin"
- Categories: Bitcoin, Documentaries, Drama
- Located in `src/data/indeeHubFilms.ts`
### Real Data (Backend)
- Fetches from `/projects` API
- Filters by type, genre, status
- Streaming URLs with DRM
- Progress tracking
## 🌐 Deployment
### Production Build
```bash
npm run build
# Output in dist/
```
### Docker
```bash
docker-compose up -d
# Available at http://localhost:7777
```
### Environment Variables
```bash
VITE_API_URL=https://api.indeedhub.com
VITE_CDN_URL=https://cdn.indeedhub.com
VITE_USE_MOCK_DATA=false
VITE_NOSTR_RELAYS=wss://relay.damus.io
VITE_ENABLE_NOSTR=true
VITE_ENABLE_LIGHTNING=true
VITE_ENABLE_RENTALS=true
```
## 🐛 Troubleshooting
### "Unable to connect to server"
✅ Fixed! App now works in development mode without backend.
See [DEV_AUTH.md](DEV_AUTH.md) for details.
### Build errors
```bash
npm run type-check # Check TypeScript errors
npm run build # Full build with validation
```
### Port already in use
Vite will automatically try the next available port (3001, 3002, etc.)
## 🤝 Contributing
This project follows strict design and code quality standards:
- See `.cursor/rules/master-philosophy.mdc`
- Mobile-first responsive design
- Glassmorphic UI patterns
- TypeScript for type safety
- WCAG AA accessibility
## 📄 License
Proprietary - IndeedHub
## 🔗 Related Repositories
- **indeehub-api** - NestJS backend API
- **indeehub-frontend** - Legacy React frontend (being replaced)
- **indeehub** - Nostr messaging integration
---
**Built with:**
Vue 3 • TypeScript • Tailwind CSS • Vite • Pinia • Vue Router • Nostr Tools • Axios
Vue 3 • TypeScript • Tailwind CSS • Vite • Pinia • Nostr (NIP-07, NIP-46, NIP-98) • BTCPay • MinIO

View File

@@ -1,101 +1,44 @@
ENVIRONMENT=local # local | development | production
ENVIRONMENT=local
# App - Local
# App
PORT=4000
DOMAIN=localhost:4000
FRONTEND_URL=http://localhost:3000
FRONTEND_URL=http://localhost:5174
# DB - API
# PostgreSQL
DATABASE_HOST=localhost
DATABASE_PORT=5432
DATABASE_USER=postgres
DATABASE_PASSWORD=local
DATABASE_NAME=indeehub
DATABASE_USER=indeedhub
DATABASE_PASSWORD=your_password
DATABASE_NAME=indeedhub
# DB - EVENTS/ANALYTICS (POSTHOG)
DATABASE_POSTHOG_HOST=localhost
DATABASE_POSTHOG_PORT=5434
DATABASE_POSTHOG_USER=postgres
DATABASE_POSTHOG_PASSWORD=local
DATABASE_POSTHOG_NAME=staging
# Trascoding Queue - Local
# Redis (BullMQ)
QUEUE_HOST=localhost
QUEUE_PORT=6379
QUEUE_PASSWORD=
# BTCPay Server - Bitcoin/Lightning Payments
BTCPAY_URL=https://btcpay.yourdomain.com
# MinIO (S3-compatible storage)
S3_ENDPOINT=http://localhost:9000
AWS_REGION=us-east-1
AWS_ACCESS_KEY=minioadmin
AWS_SECRET_KEY=minioadmin123
S3_PRIVATE_BUCKET_NAME=indeedhub-private
S3_PUBLIC_BUCKET_NAME=indeedhub-public
S3_PUBLIC_BUCKET_URL=http://localhost:7777/storage/
# BTCPay Server (Lightning payments)
BTCPAY_URL=https://your-btcpay.com
BTCPAY_STORE_ID=
BTCPAY_API_KEY=
BTCPAY_WEBHOOK_SECRET=
# Create a separate internal Lightning invoice with privateRouteHints.
# Only needed when BTCPay's built-in route hints are NOT enabled.
# If you enabled "Include hop hints" in BTCPay's Lightning settings,
# leave this as false — BTCPay handles route hints natively.
BTCPAY_ROUTE_HINTS=false
# User Pool - AWS Cognito
COGNITO_USER_POOL_ID=
COGNITO_CLIENT_ID=
# Nostr auth (required)
NOSTR_JWT_SECRET=generate_with_openssl_rand_hex_32
NOSTR_JWT_EXPIRES_IN=7d
# Sendgrid - Email Service
SENDGRID_API_KEY=
SENDGRID_SENDER=
SENDGRID_WAITLIST=
# Content encryption (required for transcoding)
AES_MASTER_SECRET=generate_64_hex_chars
# AWS KEYS
AWS_ACCESS_KEY=
AWS_SECRET_KEY=
AWS_REGION=
# AWS S3
S3_PUBLIC_BUCKET_URL=
S3_PUBLIC_BUCKET_NAME=
# AWS CloudFront
CLOUDFRONT_PRIVATE_KEY=
CLOUDFRONT_KEY_PAIR_ID=
# Note: Stripe has been removed. All payments are now via BTCPay Server (Lightning).
# PAY WITH FLASH - ENTHUSIAST SUBSCRIPTION
FLASH_JWT_SECRET_ENTHUSIAST=
# PAY WITH FLASH - FILM BUFF SUBSCRIPTION
FLASH_JWT_SECRET_FILM_BUFF=
# PAY WITH FLASH - CINEPHILE SUBSCRIPTION
FLASH_JWT_SECRET_CINEPHILE=
# PAY WITH FLASH - RSS ADDON SUBSCRIPTION
FLASH_JWT_SECRET_RSS_ADDON=
# PAY WITH FLASH - VERIFICATION ADDON SUBSCRIPTION
FLASH_JWT_SECRET_VERIFICATION_ADDON=
# Transcoding API
TRANSCODING_API_KEY=
TRANSCODING_API_URL=http://localhost:4001
# Podping - RSS Update Notifications
PODPING_URL=https://podping.cloud/
PODPING_KEY=
PODPING_USER_AGENT=
# Retool API KEY - Admin Dashboard
# Admin API (optional)
ADMIN_API_KEY=
# PostHog - Analytics
POSTHOG_API_KEY=
# Sentry - Error Tracking
SENTRY_ENVIRONMENT=
# BUYDRM
PRIVATE_AUTH_CERTIFICATE_KEY_ID =
DRM_SECRET_NAME =
# Project Review
DASHBOARD_REVIEW_URL = https://oneseventech.retool.com/apps/5e86de84-7cdb-11ef-998f-47951c6124a1/Testing%20IndeeHub/film-details?id=
PROJECT_REVIEW_RECIPIENT_EMAIL = <your email>

View File

@@ -223,6 +223,18 @@ npm run build # after you finish the migrations
npm run typeorm:run-migrations # will apply the migrations to the current DB
```
# Zaps (Lightning) production checklist
For historic zaps to show on film cards and in the movie modal:
1. **Migrations** The `zap_stats` table is created by migration `1762000000000-add-zap-stats`. The Dockerfile runs migrations on startup; if you deploy without Docker, run `npm run typeorm:run-migrations` once.
2. **BTCPay webhook** In BTCPay Server: Store → Webhooks → Add webhook. Set the URL to `https://your-domain/api/webhooks/btcpay` (or `/api/webhooks/btcpay-webhook`). Subscribe to **Invoice settled**. Without this, zap payments are not recorded.
3. **Backend logs** After a zap is paid you should see: `Zap payout completed: <invoiceId> — stats recorded for project <projectId>` and `Zap stats saved: project <id> total N zaps, M sats`. If you see `Failed to record zap stats`, check that the `zap_stats` table exists.
4. **API** `GET /zaps/stats?projectIds=id1,id2` must be reachable (e.g. `https://your-domain/api/zaps/stats?projectIds=...`). It is not cached and does not require auth.
&nbsp;
# Running Stripe Webhooks locally

View File

@@ -49,6 +49,7 @@
"class-validator": "^0.14.1",
"dayjs": "^1.11.13",
"fast-xml-parser": "^5.2.0",
"helmet": "^8.1.0",
"jsonwebtoken": "^9.0.3",
"jwks-rsa": "^3.0.1",
"moment": "^2.30.1",
@@ -14299,6 +14300,15 @@
"node": ">= 0.4"
}
},
"node_modules/helmet": {
"version": "8.1.0",
"resolved": "https://registry.npmjs.org/helmet/-/helmet-8.1.0.tgz",
"integrity": "sha512-jOiHyAZsmnr8LqoPGmCjYAaiuWwjAPLgY8ZX2XrmHawt99/u1y6RgrZMTeoPfpUbV96HOalYgz1qzkRbw54Pmg==",
"license": "MIT",
"engines": {
"node": ">=18.0.0"
}
},
"node_modules/homedir-polyfill": {
"version": "1.0.3",
"resolved": "https://registry.npmjs.org/homedir-polyfill/-/homedir-polyfill-1.0.3.tgz",

View File

@@ -111,6 +111,7 @@
"class-validator": "^0.14.1",
"dayjs": "^1.11.13",
"fast-xml-parser": "^5.2.0",
"helmet": "^8.1.0",
"jsonwebtoken": "^9.0.3",
"jwks-rsa": "^3.0.1",
"moment": "^2.30.1",

View File

@@ -17,7 +17,8 @@ import { SubscriptionsModule } from './subscriptions/subscriptions.module';
import { AwardIssuersModule } from './award-issuers/award-issuers.module';
import { FestivalsModule } from './festivals/festivals.module';
import { GenresModule } from './genres/genres.module';
import { CacheInterceptor, CacheModule } from '@nestjs/cache-manager';
import { CacheModule } from '@nestjs/cache-manager';
import { HttpCacheInterceptor } from './common/interceptors/http-cache.interceptor';
import { APP_FILTER, APP_INTERCEPTOR } from '@nestjs/core';
import { RentsModule } from './rents/rents.module';
import { EventsModule } from './events/events.module';
@@ -102,7 +103,7 @@ import { ZapsModule } from './zaps/zaps.module';
AppService,
{
provide: APP_INTERCEPTOR,
useClass: CacheInterceptor,
useClass: HttpCacheInterceptor,
},
{
provide: APP_FILTER,

View File

@@ -0,0 +1,18 @@
import { CacheInterceptor } from '@nestjs/cache-manager';
import { ExecutionContext, Injectable } from '@nestjs/common';
/**
* Global cache interceptor that skips caching for paths that must stay fresh
* (e.g. /zaps/stats so zap counts update after a user zaps).
*/
@Injectable()
export class HttpCacheInterceptor extends CacheInterceptor {
trackBy(context: ExecutionContext): string | undefined {
const request = context.switchToHttp().getRequest();
const path = request.url?.split('?')[0] ?? '';
if (path.startsWith('/zaps') || path.startsWith('/api/zaps')) {
return undefined;
}
return super.trackBy(context);
}
}

View File

@@ -5,35 +5,62 @@ import {
Logger,
UnauthorizedException,
} from '@nestjs/common';
import { CognitoJwtVerifier } from 'aws-jwt-verify';
import { verify } from 'jsonwebtoken';
import { Socket } from 'socket.io';
interface NostrSessionPayload {
sub: string;
typ: 'nostr-session' | 'nostr-refresh';
uid?: string;
}
/**
* WebSocket JWT auth guard.
* Validates Nostr session JWTs (signed with NOSTR_JWT_SECRET).
* Replaces legacy Cognito-based validation.
*/
@Injectable()
export class WsJwtAuthGuard implements CanActivate {
static async validateToken(client: Socket) {
const authorization = this.extractTokenFromHeader(client.handshake);
if (!authorization) throw new UnauthorizedException();
const verifier = CognitoJwtVerifier.create({
userPoolId: process.env.COGNITO_USER_POOL_ID,
tokenUse: 'id',
clientId: process.env.COGNITO_CLIENT_ID,
});
await verifier.verify(authorization);
return true;
}
private static extractTokenFromHeader(request: any): string | undefined {
const [type, token] = request.headers.authorization?.split(' ') ?? [];
return type === 'Bearer' ? token : undefined;
}
async canActivate(context: ExecutionContext) {
static async validateToken(client: Socket): Promise<boolean> {
const token = WsJwtAuthGuard.extractTokenFromHeader(client.handshake);
if (!token) throw new UnauthorizedException('No token provided');
const secret = process.env.NOSTR_JWT_SECRET;
if (!secret) {
Logger.error('NOSTR_JWT_SECRET not configured', 'WsJwtAuthGuard');
throw new UnauthorizedException('Server misconfiguration');
}
try {
const payload = verify(token, secret) as NostrSessionPayload;
if (payload.typ !== 'nostr-session') {
throw new UnauthorizedException('Invalid token type');
}
(client as Socket & { data?: { pubkey?: string } }).data = {
pubkey: payload.sub,
};
return true;
} catch (error) {
if (error instanceof UnauthorizedException) throw error;
Logger.warn(`WebSocket token validation failed: ${error?.message}`);
throw new UnauthorizedException('Invalid or expired token');
}
}
async canActivate(context: ExecutionContext): Promise<boolean> {
if (context.getType() !== 'ws') return true;
const client: Socket = context.switchToWs().getClient();
try {
await WsJwtAuthGuard.validateToken(client);
return true;
} catch (error) {
Logger.error(`Error validating token: ${error.message}`);
if (error instanceof UnauthorizedException) throw error;
Logger.warn(`WebSocket auth failed: ${error?.message}`);
throw new UnauthorizedException();
}
}

View File

@@ -38,24 +38,41 @@ interface TranscodeJobData {
drmMediaId?: string;
}
const isProduction = process.env.ENVIRONMENT === 'production';
function requireEnv(name: string): string {
const val = process.env[name];
if (isProduction && !val) {
throw new Error(`FFmpeg worker: ${name} is required in production`);
}
return val || '';
}
const s3 = new S3Client({
region: process.env.AWS_REGION || 'us-east-1',
endpoint: process.env.S3_ENDPOINT || 'http://minio:9000',
forcePathStyle: true,
credentials: {
accessKeyId: process.env.AWS_ACCESS_KEY || 'minioadmin',
secretAccessKey: process.env.AWS_SECRET_KEY || 'minioadmin123',
accessKeyId: isProduction
? requireEnv('AWS_ACCESS_KEY')
: process.env.AWS_ACCESS_KEY || 'minioadmin',
secretAccessKey: isProduction
? requireEnv('AWS_SECRET_KEY')
: process.env.AWS_SECRET_KEY || 'minioadmin123',
},
});
const privateBucket = process.env.S3_PRIVATE_BUCKET_NAME || 'indeedhub-private';
const privateBucket =
process.env.S3_PRIVATE_BUCKET_NAME || 'indeedhub-private';
function getPgClient(): Client {
return new Client({
host: process.env.DATABASE_HOST || 'postgres',
port: Number(process.env.DATABASE_PORT || '5432'),
user: process.env.DATABASE_USER || 'indeedhub',
password: process.env.DATABASE_PASSWORD || 'indeedhub_dev_2026',
password: isProduction
? requireEnv('DATABASE_PASSWORD')
: process.env.DATABASE_PASSWORD || 'indeedhub_dev_2026',
database: process.env.DATABASE_NAME || 'indeedhub',
});
}

View File

@@ -8,6 +8,7 @@ import {
ExpressAdapter,
NestExpressApplication,
} from '@nestjs/platform-express';
import helmet from 'helmet';
import { RawBodyRequest } from './types/raw-body-request';
import { validateEnvironment } from './common/validate-env';
@@ -49,6 +50,8 @@ async function bootstrap() {
useContainer(app.select(AppModule), { fallbackOnErrors: true });
app.use(helmet());
if (
process.env.ENVIRONMENT === 'development' ||
process.env.ENVIRONMENT === 'local'
@@ -73,6 +76,7 @@ async function bootstrap() {
'https://www.indeehub.studio',
'https://app.indeehub.studio',
'https://bff.indeehub.studio',
'https://archipelago.indeehub.studio',
];
if (process.env.FRONTEND_URL) {

View File

@@ -17,11 +17,17 @@
import { Client } from 'pg';
import { randomUUID } from 'node:crypto';
const isProduction = process.env.ENVIRONMENT === 'production';
const dbPassword = process.env.DATABASE_PASSWORD;
if (isProduction && !dbPassword) {
throw new Error('DATABASE_PASSWORD is required when ENVIRONMENT=production');
}
const client = new Client({
host: process.env.DATABASE_HOST || 'localhost',
port: Number(process.env.DATABASE_PORT || '5432'),
user: process.env.DATABASE_USER || 'indeedhub',
password: process.env.DATABASE_PASSWORD || 'indeedhub_dev_2026',
password: dbPassword || 'indeedhub_dev_2026',
database: process.env.DATABASE_NAME || 'indeedhub',
});

View File

@@ -92,7 +92,9 @@ export class WebhooksService implements OnModuleInit {
// Not a subscription — continue to season check
}
// Check if it's a zap (user paid us → we pay out to creator)
// Check if it's a zap (user paid us → we pay out to creator).
// Historic zaps are stored in zap_stats and returned by GET /zaps/stats.
// Ensure BTCPay Server has a webhook pointing to: https://your-domain/api/webhooks/btcpay
if (invoice.correlationId?.startsWith('zap:')) {
return await this.zapsService.handleZapPaid(invoiceId, invoice);
}

View File

@@ -109,10 +109,15 @@ export class ZapsService {
`IndeeHub zap — project ${projectId}`,
);
await this.recordZapStats(projectId, sats, zapperPubkey);
this.logger.log(`Zap payout completed: ${invoiceId}`);
this.logger.log(`Zap payout completed: ${invoiceId} — stats recorded for project ${projectId}`);
}
/**
* Record zap in DB for historic display on film cards and modal.
* Never throws: logs errors so webhook still succeeds if DB fails.
*/
private async recordZapStats(projectId: string, sats: number, zapperPubkey?: string): Promise<void> {
try {
let row = await this.zapStatsRepository.findOneBy({ projectId });
if (!row) {
row = this.zapStatsRepository.create({
@@ -133,6 +138,12 @@ export class ZapsService {
row.recentZapperPubkeys = [...row.recentZapperPubkeys, zapperPubkey];
}
await this.zapStatsRepository.save(row);
this.logger.log(`Zap stats saved: project ${projectId} total ${row.zapCount} zaps, ${row.zapAmountSats} sats`);
} catch (err: any) {
this.logger.error(
`Failed to record zap stats for project ${projectId}: ${err?.message}. Ensure zap_stats table exists (run migrations).`,
);
}
}
/**

View File

@@ -50,7 +50,7 @@ sudo podman run -d \
--label "com.archipelago.version=0.1.0" \
--label "com.archipelago.category=media" \
--label "com.archipelago.description.short=Decentralized media streaming platform" \
--label "com.archipelago.description.long=IndeedHub is a decentralized media streaming platform built on Nostr. Stream Bitcoin-focused documentaries, educational content, and independent films. Netflix-inspired interface with glassmorphism design, supporting content creators through the decentralized web." \
--label "com.archipelago.description.long=IndeedHub is a decentralized media streaming platform built on Nostr. Stream Bitcoin-focused documentaries, educational content, and independent films. Streaming-style interface with glassmorphism design, supporting content creators through the decentralized web." \
--label "com.archipelago.license=MIT" \
--label "com.archipelago.icon=/assets/img/app-icons/indeedhub.png" \
--label "com.archipelago.port=7777" \

451
docs/ARCHITECTURE.html Normal file
View File

@@ -0,0 +1,451 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>IndeeHub Architecture — Legacy vs Decentralized</title>
<style>
:root {
--bg: #0a0a0a;
--text: #FAFAFA;
--text-muted: #9ca3af;
--border: rgba(255, 255, 255, 0.1);
--accent: #F7931A;
--accent-secondary: #8E44AD;
--success: #22C55E;
--warning: #F59E0B;
}
* { box-sizing: border-box; }
body {
font-family: system-ui, -apple-system, sans-serif;
background: var(--bg);
color: var(--text);
line-height: 1.6;
margin: 0;
padding: 2rem;
max-width: 1200px;
margin-inline: auto;
}
h1 {
font-size: clamp(2rem, 5vw, 3rem);
font-weight: 700;
margin-bottom: 0.5rem;
background: linear-gradient(to right, var(--text), var(--text-muted));
-webkit-background-clip: text;
background-clip: text;
color: transparent;
}
.subtitle { color: var(--text-muted); font-size: 1rem; margin-bottom: 2rem; }
h2 {
font-size: 1.5rem;
font-weight: 600;
margin-top: 2.5rem;
margin-bottom: 1rem;
padding-bottom: 0.5rem;
border-bottom: 1px solid var(--border);
}
table {
width: 100%;
border-collapse: collapse;
margin-bottom: 2rem;
font-size: 0.9375rem;
}
th, td {
padding: 0.75rem 1rem;
text-align: left;
border: 1px solid var(--border);
}
th {
background: rgba(255, 255, 255, 0.05);
font-weight: 600;
color: var(--text);
}
td { vertical-align: top; }
.legacy { color: var(--text-muted); }
.current { color: var(--success); font-weight: 500; }
.summary-cell {
font-size: 0.875rem;
max-width: 320px;
}
.summary-cell ul {
margin: 0.25rem 0 0 0;
padding-left: 1.25rem;
}
.summary-cell li { margin-bottom: 0.25rem; }
.pros { color: var(--success); }
.cons { color: var(--warning); }
.badge {
display: inline-block;
padding: 0.2rem 0.5rem;
border-radius: 0.25rem;
font-size: 0.75rem;
font-weight: 500;
}
.badge-new { background: rgba(34, 197, 94, 0.2); color: var(--success); }
.badge-replaced { background: rgba(245, 158, 11, 0.2); color: var(--warning); }
.intro {
background: rgba(255, 255, 255, 0.05);
border: 1px solid var(--border);
border-radius: 0.5rem;
padding: 1rem 1.25rem;
margin-bottom: 2rem;
font-size: 0.9375rem;
}
footer {
margin-top: 3rem;
padding-top: 1.5rem;
border-top: 1px solid var(--border);
font-size: 0.875rem;
color: var(--text-muted);
}
@media (max-width: 768px) {
body { padding: 1rem; }
table { font-size: 0.8125rem; }
th, td { padding: 0.5rem; }
.summary-cell { max-width: none; }
}
</style>
</head>
<body>
<h1>IndeeHub Architecture</h1>
<p class="subtitle">Legacy vs Decentralized — Technology Stack, Auth, Processes &amp; Summary of Changes</p>
<div class="intro">
This document compares the original IndeeHub architecture (AWS, Cognito, Stripe, commercial DRM) with the decentralized prototype (Nostr, BTCPay, MinIO, self-hosted encryption). Both stacks support encryption and transcoding; the implementations differ.
</div>
<h2>Technology Stack</h2>
<table>
<thead>
<tr>
<th>Layer</th>
<th>Legacy</th>
<th>Current (Decentralized)</th>
<th>Summary of Changes</th>
</tr>
</thead>
<tbody>
<tr>
<td>Auth</td>
<td class="legacy">Cognito</td>
<td class="current">Nostr (NIP-07, 46, 98)</td>
<td class="summary-cell">
<span class="badge badge-replaced">Replaced</span>
<strong>What:</strong> Email/password → Nostr keys (extension, remote signer, nsec).
<ul>
<li class="pros">Pros: No central auth provider, censorship-resistant, portable identity</li>
<li class="cons">Cons: UX learning curve for non-Nostr users</li>
</ul>
</td>
</tr>
<tr>
<td>Payments</td>
<td class="legacy">Stripe</td>
<td class="current">BTCPay (Lightning)</td>
<td class="summary-cell">
<span class="badge badge-replaced">Replaced</span>
<strong>What:</strong> Fiat card payments → Bitcoin Lightning invoices.
<ul>
<li class="pros">Pros: Self-custody, no payment processor lock-in, lower fees</li>
<li class="cons">Cons: Users need Lightning wallet; fiat off-ramp complexity</li>
</ul>
</td>
</tr>
<tr>
<td>Storage</td>
<td class="legacy">AWS S3 / CloudFront</td>
<td class="current">MinIO</td>
<td class="summary-cell">
<span class="badge badge-replaced">Replaced</span>
<strong>What:</strong> Managed S3 + CDN → self-hosted S3-compatible MinIO.
<ul>
<li class="pros">Pros: Full control, no AWS dependency, S3 API compatible</li>
<li class="cons">Cons: You operate storage and CDN; scaling is manual</li>
</ul>
</td>
</tr>
<tr>
<td>Database</td>
<td class="legacy">PostgreSQL (RDS)</td>
<td class="current">PostgreSQL</td>
<td class="summary-cell">
<span class="badge badge-replaced">Replaced</span>
<strong>What:</strong> Managed RDS → self-hosted PostgreSQL.
<ul>
<li class="pros">Pros: Same schema, no vendor lock-in</li>
<li class="cons">Cons: You manage backups, replication, upgrades</li>
</ul>
</td>
</tr>
<tr>
<td>Queue</td>
<td class="legacy"></td>
<td class="current">Redis + BullMQ</td>
<td class="summary-cell">
<span class="badge badge-new">New</span>
<strong>What:</strong> Legacy used external transcoding API; current uses BullMQ for job queue.
<ul>
<li class="pros">Pros: Explicit job queue, retries, progress tracking</li>
<li class="cons">Cons: Additional Redis dependency</li>
</ul>
</td>
</tr>
<tr>
<td>Relay</td>
<td class="legacy">External</td>
<td class="current">Self-hosted nostr-rs-relay</td>
<td class="summary-cell">
<span class="badge badge-replaced">Replaced</span>
<strong>What:</strong> Third-party Nostr relay → self-hosted relay.
<ul>
<li class="pros">Pros: Data locality, no relay dependency</li>
<li class="cons">Cons: Relay ops and storage</li>
</ul>
</td>
</tr>
<tr>
<td>Deployment</td>
<td class="legacy">AWS ECS</td>
<td class="current">Docker / Portainer</td>
<td class="summary-cell">
<span class="badge badge-replaced">Replaced</span>
<strong>What:</strong> Managed ECS → Docker Compose + Portainer.
<ul>
<li class="pros">Pros: Portable, runs anywhere, simpler ops</li>
<li class="cons">Cons: No auto-scaling; manual orchestration</li>
</ul>
</td>
</tr>
<tr>
<td>Frontend</td>
<td class="legacy">React</td>
<td class="current">Vue 3 + Vite</td>
<td class="summary-cell">
<span class="badge badge-replaced">Replaced</span>
<strong>What:</strong> React → Vue 3 + Vite.
<ul>
<li class="pros">Pros: Faster builds, Composition API, smaller bundle</li>
<li class="cons">Cons: Different ecosystem, migration effort</li>
</ul>
</td>
</tr>
<tr>
<td>Backend</td>
<td class="legacy">NestJS</td>
<td class="current">NestJS</td>
<td class="summary-cell">
<strong>What:</strong> Unchanged. Same NestJS backend, different integrations.
</td>
</tr>
</tbody>
</table>
<h2>Auth Flow</h2>
<table>
<thead>
<tr>
<th>Step</th>
<th>Legacy</th>
<th>Current</th>
<th>Summary</th>
</tr>
</thead>
<tbody>
<tr>
<td>1</td>
<td class="legacy">Email + password</td>
<td class="current">Extension, Remote Signer, or nsec</td>
<td class="summary-cell">User proves identity via Nostr key instead of password.</td>
</tr>
<tr>
<td>2</td>
<td class="legacy">Cognito validates</td>
<td class="current">Nostr signs NIP-98</td>
<td class="summary-cell">Backend verifies Nostr signature instead of calling Cognito.</td>
</tr>
<tr>
<td>3</td>
<td class="legacy">Cognito returns JWT</td>
<td class="current">Backend issues JWT</td>
<td class="summary-cell">Backend owns JWT issuance; no third-party auth provider.</td>
</tr>
<tr>
<td>4</td>
<td class="legacy">JWT stored</td>
<td class="current">JWT stored</td>
<td class="summary-cell">Same client-side storage pattern.</td>
</tr>
</tbody>
</table>
<h2>Processes</h2>
<table>
<thead>
<tr>
<th>Process</th>
<th>Legacy</th>
<th>Current</th>
<th>Summary of Changes</th>
</tr>
</thead>
<tbody>
<tr>
<td>Subscription</td>
<td class="legacy">Stripe Checkout</td>
<td class="current">BTCPay Lightning invoice</td>
<td class="summary-cell">
<span class="badge badge-replaced">Replaced</span>
Fiat checkout → Lightning invoice. Same UX flow (redirect, webhook, activation).
</td>
</tr>
<tr>
<td>Rentals</td>
<td class="legacy">Stripe</td>
<td class="current">BTCPay invoice</td>
<td class="summary-cell">
<span class="badge badge-replaced">Replaced</span>
Same pattern as subscriptions; payment method changed.
</td>
</tr>
<tr>
<td>Zaps</td>
<td class="legacy"></td>
<td class="current">BTCPay → creator address</td>
<td class="summary-cell">
<span class="badge badge-new">New</span>
Direct tips to creators via Lightning; not present in legacy.
</td>
</tr>
<tr>
<td>Encryption</td>
<td class="legacy">BuyDRM/KeyOS (Widevine/FairPlay)</td>
<td class="current">AES-128 HLS</td>
<td class="summary-cell">
<span class="badge badge-replaced">Replaced</span>
<strong>What:</strong> Commercial DRM → self-hosted AES-128 HLS with key server.
<ul>
<li class="pros">Pros: No DRM vendor, no licensing fees, full control</li>
<li class="cons">Cons: Weaker protection than Widevine; key server is single point</li>
</ul>
</td>
</tr>
<tr>
<td>Transcoding</td>
<td class="legacy">External transcoding API (ECS)</td>
<td class="current">FFmpeg + MinIO</td>
<td class="summary-cell">
<span class="badge badge-replaced">Replaced</span>
<strong>What:</strong> AWS ECS transcoding service → self-hosted FFmpeg worker.
<ul>
<li class="pros">Pros: No external API, no per-job vendor cost, same HLS output</li>
<li class="cons">Cons: You run FFmpeg; scaling is manual</li>
</ul>
</td>
</tr>
</tbody>
</table>
<h2>UI &amp; Design</h2>
<table>
<thead>
<tr>
<th>Aspect</th>
<th>Legacy</th>
<th>Current</th>
<th>Summary of Changes</th>
</tr>
</thead>
<tbody>
<tr>
<td>Framework</td>
<td class="legacy">React</td>
<td class="current">Vue 3 + Vite</td>
<td class="summary-cell">
<span class="badge badge-replaced">Replaced</span>
React → Vue 3 with Composition API. Vite for fast builds.
</td>
</tr>
<tr>
<td>Styling</td>
<td class="legacy">CSS-in-JS / styled-components</td>
<td class="current">Tailwind CSS + custom classes</td>
<td class="summary-cell">
<span class="badge badge-replaced">Replaced</span>
Utility-first Tailwind; custom glass-card, hero-gradient, etc. 8px base grid.
</td>
</tr>
<tr>
<td>Visual style</td>
<td class="legacy">Traditional streaming UI</td>
<td class="current">Glassmorphism, gradients, dark-first</td>
<td class="summary-cell">
<span class="badge badge-replaced">Replaced</span>
Semi-transparent cards with backdrop blur; hero gradient overlays; bold typography.
</td>
</tr>
<tr>
<td>Colors</td>
<td class="legacy">Brand-specific (varies)</td>
<td class="current">#0a0a0a, #FAFAFA, #F7931A, #8E44AD</td>
<td class="summary-cell">
Design tokens: pure black (#0a0a0a), white text (#FAFAFA), brand primary/secondary.
</td>
</tr>
<tr>
<td>Layout</td>
<td class="legacy">Hero + content rows</td>
<td class="current">Hero + content rows + browse grid</td>
<td class="summary-cell">
Same pattern: featured hero, horizontal content rows. Responsive browse grid (sm/md/lg).
</td>
</tr>
<tr>
<td>Modals</td>
<td class="legacy">Auth, subscription, rental</td>
<td class="current">Auth, subscription, rental, zap, content detail, keys</td>
<td class="summary-cell">
<span class="badge badge-new">Expanded</span>
Added ZapModal (Lightning tips), ContentDetailModal, KeysModal (Nostr keys).
</td>
</tr>
<tr>
<td>PWA</td>
<td class="legacy"></td>
<td class="current">Installable, offline-capable</td>
<td class="summary-cell">
<span class="badge badge-new">New</span>
PWA support for install-as-app on mobile/desktop.
</td>
</tr>
<tr>
<td>Responsive</td>
<td class="legacy">Yes</td>
<td class="current">Mobile-first, 640/768/1024/1280 breakpoints</td>
<td class="summary-cell">
Mobile-first; same content on all breakpoints; layout adapts.
</td>
</tr>
</tbody>
</table>
<h2>High-Level Summary</h2>
<div class="intro">
<p><strong>Legacy</strong> relied on AWS (S3, CloudFront, RDS, ECS), Cognito, Stripe, and BuyDRM/KeyOS for encryption. Transcoding was done by an external ECS-based API. UI was React-based with traditional streaming layout.</p>
<p><strong>Decentralized</strong> replaces these with self-hosted or open components: MinIO, PostgreSQL, Nostr, BTCPay, AES-128 HLS, and an FFmpeg worker. Vue 3 + Tailwind with glassmorphism, expanded modals (zaps, keys), and PWA. The trade-off is more operational responsibility in exchange for independence from proprietary services.</p>
</div>
<footer>
IndeeHub Prototype · Architecture comparison · Generated from codebase (drm.service, transcoding-server, env examples).
</footer>
</body>
</html>

View File

@@ -0,0 +1,24 @@
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 256 256">
<g clip-path="url(#clip0_1_943)">
<path d="M155.506 253.036C146.645 254.976 137.44 255.999 127.997 255.999C102.077 255.999 77.9567 248.295 57.8008 235.051C52.7974 227.894 50.5525 223.955 48.915 221.081C48.1036 219.657 47.4413 218.495 46.6664 217.332C39.0311 205.053 35.0362 189.28 34.1651 170.748C31.4666 113.326 66.3642 76.6573 102.015 70.6387C124.613 66.8235 142.572 70.6872 156.347 78.0534C144.169 74.666 129.652 74.4622 113.102 79.2C72.9831 92.1309 59.6091 131.451 65.3414 174.994C75.3453 229.555 128.842 249.111 155.506 253.036Z" fill="url(#paint0_linear_1_943)"/>
<path d="M41.2387 222.111C33.7762 208.86 27.0184 189.088 26.1739 171.123C23.3092 110.164 60.5628 69.5235 100.683 62.7503C155.371 53.5175 185.775 85.8934 196.256 109.923C196.695 109.628 196.873 109.043 196.641 108.539C179.408 71.0662 143.765 45.3331 102.592 45.3331C55.8419 45.3331 14.127 78.8691 0 128.71C0.200633 165.642 16.0426 198.871 41.2387 222.111Z" fill="url(#paint1_linear_1_943)"/>
<path d="M199.997 233.844C190.764 240.137 180.665 245.253 169.916 248.977C164.755 248.078 159.037 246.959 155.011 246.171C153.103 245.797 151.574 245.498 150.666 245.332C126.318 240.885 82.7834 225.195 73.246 173.749C70.5513 153.063 72.4812 134.02 79.3156 118.916C86.0487 104.035 97.6686 92.6275 115.39 86.8682C135.987 81.2567 153.055 84.0378 165.732 90.8469C162.612 90.1887 159.386 89.8437 156.085 89.8437C128.652 89.8437 106.414 113.671 106.414 143.063C106.414 154.799 109.959 165.648 115.966 174.447C115.966 174.447 133.16 206.926 179.966 204.023C221.7 201.434 243.373 163.999 245.956 150.172C247.298 142.986 248 135.575 248 127.999C248 61.7256 194.274 7.99997 128.001 7.99997C77.851 7.99997 34.8866 38.7631 16.9488 82.4478C10.8898 90.3409 5.6727 99.0914 1.46875 108.554C10.8367 47.0899 63.9194 0 128.001 0C198.693 0 256 57.3073 256 127.999C256 171.996 233.803 210.805 199.997 233.844Z" fill="url(#paint2_linear_1_943)"/>
</g>
<defs>
<linearGradient id="paint0_linear_1_943" x1="79.9357" y1="106.044" x2="79.7404" y2="219.805" gradientUnits="userSpaceOnUse">
<stop offset="0.0297309" stop-color="#FA3C3C"/>
<stop offset="1" stop-color="#BC1870"/>
</linearGradient>
<linearGradient id="paint1_linear_1_943" x1="62.5099" y1="52.0175" x2="56.1717" y2="165.81" gradientUnits="userSpaceOnUse">
<stop stop-color="#FF9F2F"/>
<stop offset="1" stop-color="#FA3C3C"/>
</linearGradient>
<linearGradient id="paint2_linear_1_943" x1="151.999" y1="253.332" x2="152.351" y2="121.334" gradientUnits="userSpaceOnUse">
<stop stop-color="#5B09AD"/>
<stop offset="1" stop-color="#BC1870"/>
</linearGradient>
<clipPath id="clip0_1_943">
<rect width="256" height="256" fill="white"/>
</clipPath>
</defs>
</svg>

After

Width:  |  Height:  |  Size: 2.7 KiB

View File

@@ -12,6 +12,57 @@
<!-- NORMAL AUTH VIEW -->
<template v-if="sovereignPhase === 'normal'">
<!-- REMOTE SIGNER QR PHASE (desktop) -->
<template v-if="remoteSignerPhase === 'qr'">
<div class="text-center">
<button
@click="handleRemoteSignerBack"
class="flex items-center gap-2 text-white/60 hover:text-white transition-colors mb-6 w-full justify-start"
>
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 19l-7-7 7-7" />
</svg>
Back
</button>
<div v-if="errorMessage" class="mb-4 p-3 rounded-lg bg-red-500/20 border border-red-500/30 text-red-400 text-sm text-left">
{{ errorMessage }}
</div>
<h2 class="text-2xl font-bold text-white mb-2">Remote Signer</h2>
<p class="text-white/50 text-sm mb-6">Scan the QR code with your phone to sign in</p>
<!-- QR Code -->
<div class="bg-white rounded-2xl p-4 inline-block mb-5">
<img v-if="remoteSignerQrDataUrl" :src="remoteSignerQrDataUrl" alt="Nostr Connect QR" class="w-56 h-56" />
<div v-else class="w-56 h-56 flex items-center justify-center text-gray-400">
Generating QR...
</div>
</div>
<p v-if="nostrConnectLoading" class="text-sm text-white/70 animate-pulse mb-4">
Waiting for signer...
</p>
<!-- Copy URI -->
<button
@click="copyRemoteSignerUri"
class="w-full bg-white/10 hover:bg-white/15 text-white rounded-xl px-4 py-3 text-sm font-medium transition-colors flex items-center justify-center gap-2 mb-4"
>
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M8 5H6a2 2 0 00-2 2v12a2 2 0 002 2h10a2 2 0 002-2v-1M8 5a2 2 0 002 2h2a2 2 0 002-2M8 5a2 2 0 012-2h2a2 2 0 012 2m0 0h2a2 2 0 012 2v3m2 4H10m0 0l3-3m-3 3l3 3" />
</svg>
{{ remoteSignerCopyText }}
</button>
<p class="text-xs text-white/40">
Use Primal, Amber, or any Nostr Connect signer app
</p>
</div>
</template>
<!-- AUTH OPTIONS (idle) -->
<template v-else>
<!-- Header -->
<div class="text-center mb-8">
<h2 class="text-3xl font-bold text-white">
@@ -54,16 +105,83 @@
</button>
</form>
<!-- Nostr Login Button (NIP-07 Browser Extension) -->
<!-- Remote Signer desktop: QR phase; mobile: direct link -->
<div class="mt-4 space-y-2">
<template v-if="isDesktop">
<button
@click="handleRemoteSignerClick"
:disabled="isLoading"
class="nostr-login-button w-full flex items-center justify-center gap-2"
>
<img
src="@/assets/images/primal-icon.svg"
alt="Remote Signer"
class="w-5 h-5 shrink-0"
/>
Remote Signer
</button>
</template>
<template v-else>
<button
v-if="!popupBlockedUri"
@click="handleNostrConnectLogin"
:disabled="isLoading"
class="nostr-login-button w-full flex items-center justify-center gap-2"
>
<svg v-if="nostrConnectLoading" class="w-5 h-5 shrink-0 animate-spin" fill="none" viewBox="0 0 24 24">
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4" />
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z" />
</svg>
<img v-else src="@/assets/images/primal-icon.svg" alt="Primal" class="w-5 h-5 shrink-0" />
{{ nostrConnectLoading ? 'Waiting...' : 'Remote Signer' }}
</button>
<template v-else>
<p v-if="!nostrCompletingLogin" class="text-sm text-white/70 text-center">
Tap below to open your signer app:
</p>
<a
v-if="!nostrCompletingLogin"
:href="popupBlockedUri"
target="_blank"
rel="noopener noreferrer"
class="nostr-login-button w-full flex items-center justify-center gap-2 no-underline"
>
<img
src="@/assets/images/primal-icon.svg"
alt="Signer"
class="w-5 h-5 shrink-0"
/>
Open Signer App
</a>
<button
v-else
disabled
class="nostr-login-button w-full flex items-center justify-center gap-2"
>
<svg class="w-5 h-5 shrink-0 animate-spin" fill="none" viewBox="0 0 24 24">
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4" />
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z" />
</svg>
Signing in...
</button>
</template>
</template>
</div>
<!-- Sign in with Nostr Extension (NIP-07) -->
<button
@click="handleNostrLogin"
:disabled="isLoading"
class="nostr-login-button w-full flex items-center justify-center gap-2 mt-4"
class="nostr-login-button w-full flex items-center justify-center gap-2 mt-3"
>
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<svg v-if="authLoading" class="w-5 h-5 shrink-0 animate-spin" fill="none" viewBox="0 0 24 24">
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4" />
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z" />
</svg>
<svg v-else class="w-5 h-5 shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 7a2 2 0 012 2m4 0a6 6 0 01-7.743 5.743L11 17H9v2H7v2H4a1 1 0 01-1-1v-2.586a1 1 0 01.293-.707l5.964-5.964A6 6 0 1121 9z" />
</svg>
Sign in with Nostr Extension
{{ authLoading ? 'Signing in...' : 'Extension' }}
</button>
<!-- nsec login (hidden by default; tap to reveal field) -->
@@ -72,15 +190,15 @@
type="button"
@click="showNsecField = true"
:disabled="isLoading"
class="nostr-login-button w-full flex items-center justify-center gap-2 mt-6"
class="nostr-login-button w-full flex items-center justify-center gap-2 mt-3"
>
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 15v2m-6 4h12a2 2 0 002-2v-6a2 2 0 00-2-2H6a2 2 0 00-2 2v6a2 2 0 002 2zm10-10V7a4 4 0 00-8 0v4h8z" />
</svg>
Sign in with nsec (private key)
Private Key
</button>
</template>
<div v-else class="nsec-field-block mt-6">
<div v-else class="nsec-field-block mt-3">
<input
v-model="nsecInput"
type="password"
@@ -96,7 +214,11 @@
class="nostr-login-button flex-1 flex items-center justify-center gap-2"
:class="{ 'opacity-40 cursor-not-allowed': !nsecInput.trim() || isLoading }"
>
Sign in
<svg v-if="authLoading" class="w-5 h-5 shrink-0 animate-spin" fill="none" viewBox="0 0 24 24">
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4" />
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z" />
</svg>
{{ authLoading ? 'Signing in...' : 'Sign in' }}
</button>
<button
type="button"
@@ -223,6 +345,7 @@
</button>
</div>
</template>
</template>
<!-- SOVEREIGN PHASE: NAH! -->
<div v-else-if="sovereignPhase === 'nah'" class="sovereign-nah" key="nah">
@@ -239,10 +362,14 @@
:disabled="isLoading"
class="nostr-login-button sovereign-generate-btn w-full flex items-center justify-center gap-3"
>
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<svg v-if="sovereignGenerating" class="w-5 h-5 shrink-0 animate-spin" fill="none" viewBox="0 0 24 24">
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4" />
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z" />
</svg>
<svg v-else class="w-5 h-5 shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 15v2m-6 4h12a2 2 0 002-2v-6a2 2 0 00-2-2H6a2 2 0 00-2 2v6a2 2 0 002 2zm10-10V7a4 4 0 00-8 0v4h8z" />
</svg>
{{ isLoading ? 'Generating...' : 'Generate Sovereign Identity' }}
{{ sovereignGenerating ? 'Generating...' : 'Generate Sovereign Identity' }}
</button>
<button
@click="sovereignDismissed = true; sovereignPhase = 'normal'"
@@ -290,8 +417,10 @@
<script setup lang="ts">
import { ref, computed, watch } from 'vue'
import QRCode from 'qrcode'
import { useAuth } from '../composables/useAuth'
import { useAccounts } from '../composables/useAccounts'
import { useNostrConnect } from '../composables/useNostrConnect'
import { accountManager } from '../lib/accounts'
import { authService } from '../services/auth.service'
@@ -313,10 +442,27 @@ const emit = defineEmits<Emits>()
const { loginWithNostr, isLoading: authLoading } = useAuth()
const { loginWithExtension, loginWithPrivateKey } = useAccounts()
const {
loginWithRemoteSigner,
startRemoteSignerQrFlow,
isLoading: nostrConnectLoading,
isCompletingLogin: nostrCompletingLogin,
error: nostrConnectError,
popupBlockedUri,
} = useNostrConnect()
const isDesktop = !/Android|webOS|iPhone|iPad|iPod|BlackBerry|IEMobile|Opera Mini/i.test(navigator.userAgent)
const remoteSignerPhase = ref<'idle' | 'qr'>('idle')
const remoteSignerQrDataUrl = ref('')
const remoteSignerCopyText = ref('Copy URI')
const remoteSignerUri = ref('')
let remoteSignerCancel: (() => void) | null = null
const mode = ref<'login' | 'register' | 'forgot'>(props.defaultMode)
const errorMessage = ref<string | null>(null)
const isLoading = computed(() => authLoading.value || sovereignGenerating.value)
const isLoading = computed(
() => authLoading.value || sovereignGenerating.value || nostrConnectLoading.value,
)
// nsec login (tap to reveal field)
const showNsecField = ref(false)
@@ -349,11 +495,23 @@ watch(() => props.isOpen, (open) => {
errorMessage.value = null
showNsecField.value = false
nsecInput.value = ''
remoteSignerPhase.value = 'idle'
remoteSignerQrDataUrl.value = ''
remoteSignerUri.value = ''
remoteSignerCancel?.()
remoteSignerCancel = null
cancelAmber()
}
})
// Surface Nostr Connect errors in the modal
watch(nostrConnectError, (err) => {
if (err) errorMessage.value = err
})
function closeModal() {
remoteSignerCancel?.()
remoteSignerCancel = null
emit('close')
errorMessage.value = null
cancelAmber()
@@ -498,6 +656,81 @@ async function handleNostrLogin() {
}
}
/**
* Desktop: Open Remote Signer QR phase.
*/
async function handleRemoteSignerClick() {
errorMessage.value = null
remoteSignerPhase.value = 'qr'
remoteSignerQrDataUrl.value = ''
remoteSignerUri.value = ''
remoteSignerCopyText.value = 'Copy URI'
try {
const { uri, complete, cancel } = startRemoteSignerQrFlow()
remoteSignerCancel = cancel
remoteSignerUri.value = uri
const dataUrl = await QRCode.toDataURL(uri, {
width: 224,
margin: 2,
color: { dark: '#000000', light: '#FFFFFF' },
errorCorrectionLevel: 'M',
})
remoteSignerQrDataUrl.value = dataUrl
await complete
emit('success')
closeModal()
} catch (error: any) {
if (remoteSignerPhase.value === 'qr') {
errorMessage.value = error?.message || 'Remote signer login failed. Please try again.'
}
} finally {
remoteSignerCancel = null
}
}
/**
* Back from Remote Signer QR phase.
*/
function handleRemoteSignerBack() {
remoteSignerCancel?.()
remoteSignerCancel = null
remoteSignerPhase.value = 'idle'
remoteSignerQrDataUrl.value = ''
remoteSignerUri.value = ''
errorMessage.value = null
}
async function copyRemoteSignerUri() {
if (!remoteSignerUri.value) return
try {
await navigator.clipboard.writeText(remoteSignerUri.value)
remoteSignerCopyText.value = 'Copied!'
setTimeout(() => { remoteSignerCopyText.value = 'Copy URI' }, 2000)
} catch {
remoteSignerCopyText.value = 'Copy failed'
}
}
/**
* Login with Nostr via remote signer (Primal, etc.) using nostrconnect:// URI.
* Mobile: Opens the signer via link, waits for connection, then creates backend session.
*/
async function handleNostrConnectLogin() {
errorMessage.value = null
try {
await loginWithRemoteSigner()
emit('success')
closeModal()
} catch (error: any) {
console.error('Nostr Connect login failed:', error)
errorMessage.value = error?.message || 'Remote signer login failed. Please try again.'
}
}
function cancelNsecField() {
showNsecField.value = false
nsecInput.value = ''

View File

@@ -143,8 +143,8 @@
<span class="text-white font-medium">{{ content.creator }}</span>
</div>
<!-- Zaps Section (Primal/Yakihonne style: profile pics + amounts) -->
<div v-if="zapsList.length > 0" class="mb-6">
<!-- Zaps Section (Nostr + backend BTCPay zaps: profile pics + amounts) -->
<div v-if="totalZapSats > 0" class="mb-6">
<div class="flex items-center gap-2 mb-3">
<svg class="w-5 h-5 text-[#F7931A]" viewBox="0 0 24 24" fill="currentColor">
<path d="M13 3l-2 7h5l-6 11 2-7H7l6-11z"/>
@@ -157,7 +157,7 @@
v-for="(zap, idx) in displayZaps"
:key="zap.pubkey + '-' + zap.timestamp + '-' + idx"
class="zap-avatar-pill"
:title="getZapperName(zap.pubkey) + ' — ' + zap.amount.toLocaleString() + ' sats'"
:title="zap.amount > 0 ? getZapperName(zap.pubkey) + ' — ' + zap.amount.toLocaleString() + ' sats' : getZapperName(zap.pubkey) + ' zapped'"
>
<img
:src="getZapperPicture(zap.pubkey)"
@@ -287,6 +287,7 @@ import { useAuth } from '../composables/useAuth'
import { useAccounts } from '../composables/useAccounts'
import { useNostr } from '../composables/useNostr'
import { libraryService } from '../services/library.service'
import { indeehubApiService } from '../services/indeehub-api.service'
import type { Content } from '../types/content'
import VideoPlayer from './VideoPlayer.vue'
import SubscriptionModal from './SubscriptionModal.vue'
@@ -378,10 +379,35 @@ const reactionCounts = computed(() => nostr.reactionCounts.value)
const isLoadingComments = computed(() => nostr.isLoading.value)
const commentCount = computed(() => nostr.commentCount.value)
// Zap data from relay
const zapsList = computed(() => nostr.zaps.value)
// Backend zap stats (BTCPay in-app zaps) so modal shows total + who zapped
const backendZapStats = ref<{
zapCount: number
zapAmountSats: number
recentZapperPubkeys: string[]
} | null>(null)
// Zap data: merge Nostr relay (9735) + backend so in-app zaps show too
const zapsList = computed(() => {
const fromNostr = nostr.zaps.value
const backend = backendZapStats.value
const nostrPubkeys = new Set(fromNostr.map((z) => z.pubkey))
const fromBackend: { pubkey: string; amount: number; timestamp: number }[] = []
if (backend?.recentZapperPubkeys?.length) {
for (const pk of backend.recentZapperPubkeys) {
if (!nostrPubkeys.has(pk)) {
fromBackend.push({ pubkey: pk, amount: 0, timestamp: 0 })
}
}
}
const merged = [...fromNostr, ...fromBackend]
return merged.sort((a, b) => b.timestamp - a.timestamp)
})
const displayZaps = computed(() => zapsList.value.slice(0, 8))
const totalZapSats = computed(() => zapsList.value.reduce((sum, z) => sum + z.amount, 0))
const totalZapSats = computed(() => {
const fromNostr = nostr.zaps.value.reduce((sum, z) => sum + z.amount, 0)
const fromBackend = backendZapStats.value?.zapAmountSats ?? 0
return fromNostr + fromBackend
})
// User's existing reaction read from relay (not local state)
const userReaction = computed(() => nostr.userContentReaction.value)
@@ -400,6 +426,7 @@ watch(() => props.content?.id, (newId) => {
if (newId && props.isOpen) {
loadSocialData(newId)
checkRentalAccess()
fetchBackendZapStats(newId)
}
})
@@ -407,13 +434,23 @@ watch(() => props.isOpen, (open) => {
if (open && props.content?.id) {
loadSocialData(props.content.id)
checkRentalAccess()
fetchBackendZapStats(props.content.id)
} else if (!open) {
// Reset rental state when modal closes
hasActiveRental.value = false
rentalExpiresAt.value = null
backendZapStats.value = null
}
})
async function fetchBackendZapStats(contentId: string) {
try {
const data = await indeehubApiService.getZapStats([contentId])
backendZapStats.value = data[contentId] ?? null
} catch {
backendZapStats.value = null
}
}
function loadSocialData(contentId: string) {
nostr.cleanup()
nostr.subscribeToContent(contentId)
@@ -505,8 +542,11 @@ function handleZap() {
}
function handleZapped(_amount: number) {
// The zap was confirmed — the relay subscription will pick up
// the zap receipt automatically and update zapsList.
// In-app zaps go through BTCPay; refetch backend stats so modal updates.
if (props.content?.id) {
fetchBackendZapStats(props.content.id)
window.dispatchEvent(new CustomEvent('indeehub:zap-completed', { detail: { contentId: props.content.id } }))
}
}
function getZapperName(pubkey: string): string {
@@ -522,6 +562,7 @@ function getZapperPicture(pubkey: string): string {
}
function formatZapAmount(sats: number): string {
if (sats <= 0) return '—'
if (sats >= 1_000_000) return (sats / 1_000_000).toFixed(1) + 'M'
if (sats >= 1_000) return (sats / 1_000).toFixed(sats >= 10_000 ? 0 : 1) + 'k'
return sats.toLocaleString()

View File

@@ -45,21 +45,7 @@
<svg class="w-3 h-3" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M8 12h.01M12 12h.01M16 12h.01M21 12c0 4.418-4.03 8-9 8a9.863 9.863 0 01-4.255-.949L3 20l1.395-3.72C3.512 15.042 3 13.574 3 12c0-4.418 4.03-8 9-8s9 3.582 9 8z"/></svg>
{{ getCommentCount(content.id) }}
</span>
<!-- Zaps: avatar stack + count (Primal/Yakihonne style) -->
<span
v-if="getZapCount(content.id) > 0"
class="social-badge zap-badge flex items-center gap-1"
:title="getZapCount(content.id) + ' zap(s)'"
>
<span class="flex -space-x-1.5">
<img
v-for="pk in getZapperPubkeys(content.id).slice(0, 3)"
:key="pk"
:src="zapperAvatarUrl(pk)"
:alt="''"
class="w-4 h-4 rounded-full border border-black/40 object-cover ring-1 ring-white/20"
/>
</span>
<span class="social-badge" v-if="getZapCount(content.id) > 0">
<svg class="w-3 h-3 text-[#F7931A]" viewBox="0 0 24 24" fill="currentColor">
<path d="M13 3l-2 7h5l-6 11 2-7H7l6-11z"/>
</svg>
@@ -109,10 +95,8 @@ const { getStats } = useContentDiscovery()
/** Backend zap stats (BTCPay zaps) so film cards show total + who zapped. */
const backendZapStats = ref<Record<string, { zapCount: number; zapAmountSats: number; recentZapperPubkeys: string[] }>>({})
watch(
() => props.contents,
(contents) => {
const ids = contents?.map((c) => c.id).filter(Boolean) ?? []
function fetchZapStats() {
const ids = props.contents?.map((c) => c.id).filter(Boolean) ?? []
if (ids.length === 0) {
backendZapStats.value = {}
return
@@ -125,9 +109,13 @@ watch(
.catch(() => {
backendZapStats.value = {}
})
},
{ immediate: true },
)
}
watch(() => props.contents, fetchZapStats, { immediate: true })
function onZapCompleted() {
fetchZapStats()
}
function getReactionCount(contentId: string): number {
return getStats(contentId).plusCount ?? 0
@@ -143,23 +131,6 @@ function getZapCount(contentId: string): number {
return discovery + backend
}
function getZapperPubkeys(contentId: string): string[] {
const discovery = getStats(contentId).recentZapperPubkeys ?? []
const backend = backendZapStats.value[contentId]?.recentZapperPubkeys ?? []
const seen = new Set<string>()
const merged: string[] = []
for (const pk of [...discovery, ...backend]) {
if (seen.has(pk) || merged.length >= 5) continue
seen.add(pk)
merged.push(pk)
}
return merged
}
function zapperAvatarUrl(pubkey: string): string {
return `https://robohash.org/${pubkey}.png`
}
const sliderRef = ref<HTMLElement | null>(null)
const canScrollLeft = ref(false)
const canScrollRight = ref(true)
@@ -187,12 +158,14 @@ onMounted(() => {
sliderRef.value.addEventListener('scroll', handleScroll)
handleScroll()
}
window.addEventListener('indeehub:zap-completed', onZapCompleted)
})
onUnmounted(() => {
if (sliderRef.value) {
sliderRef.value.removeEventListener('scroll', handleScroll)
}
window.removeEventListener('indeehub:zap-completed', onZapCompleted)
})
</script>

View File

@@ -0,0 +1,213 @@
import { ref, computed } from 'vue'
import { NostrConnectSigner, PrivateKeySigner } from 'applesauce-signers'
import { NostrConnectAccount } from 'applesauce-accounts/accounts'
import { accountManager } from '../lib/accounts'
import { useAuthStore } from '../stores/auth'
// Primal relay for Nostr Connect login (Primal must reach this relay)
const NOSTR_CONNECT_RELAYS = ['wss://relay.primal.net']
const WAIT_FOR_SIGNER_TIMEOUT_MS = 120_000
/**
* Composable for NIP-46 Nostr Connect (remote signer / bunker) login.
* Uses the nostrconnect:// URI scheme to trigger signers like Primal.
* Primal supports a callback parameter for redirecting back after connection.
*
* @see https://nostr.com/nprofile1qqsdv8emcke7k3qqaldwv956tstu40ejg663gdsaayuuujs6pknw7jspzfmhxue69uhhqatjwpkx2urpvuhx2ucpzemhxue69uhhyetvv9ujumn0wd68ytnzv9hxgwmqjug
*/
export function useNostrConnect() {
const authStore = useAuthStore()
const isConnecting = ref(false)
/** True when signer has connected and we're completing login (getPubkey, create account, backend auth) */
const isCompletingLogin = ref(false)
const error = ref<string | null>(null)
/** When pop-up is blocked, we show a tappable link — direct user tap often bypasses blockers */
const popupBlockedUri = ref<string | null>(null)
const isLoading = computed(() => isConnecting.value)
/**
* Build the callback URL for Primal to redirect to after establishing connection.
* Uses the current origin so it works in dev and production.
*/
function getCallbackUrl(): string {
return `${window.location.origin}/auth/nostr-callback`
}
/**
* Append Primal's callback parameter to a nostrconnect URI.
* Primal redirects the user back to this URL after they approve the connection.
*/
function appendCallbackToUri(uri: string): string {
const separator = uri.includes('?') ? '&' : '?'
return `${uri}${separator}callback=${encodeURIComponent(getCallbackUrl())}`
}
/**
* Login with Nostr using a remote signer (Primal, etc.) via nostrconnect:// URI.
* Opens the signer app, waits for connection, then creates a backend session.
*/
async function loginWithRemoteSigner(): Promise<void> {
isConnecting.value = true
error.value = null
popupBlockedUri.value = null
let signer: NostrConnectSigner | null = null
try {
// NostrConnectSigner is wired to our relay pool in accounts.ts
const clientSigner = new PrivateKeySigner()
signer = new NostrConnectSigner({
relays: NOSTR_CONNECT_RELAYS,
signer: clientSigner,
})
const baseUri = signer.getNostrConnectURI({
name: 'IndeeHub',
url: window.location.origin,
})
const uri = appendCallbackToUri(baseUri)
// On mobile, window.open is almost always blocked. Use the tappable link immediately —
// direct user tap on a link bypasses blockers better than programmatic window.open.
const isMobile = /Android|webOS|iPhone|iPad|iPod|BlackBerry|IEMobile|Opera Mini/i.test(navigator.userAgent)
const signerWindow = isMobile ? null : window.open(uri, '_blank', 'noopener,noreferrer')
if (!signerWindow) {
// Mobile or pop-up blocked: show tappable link — direct tap often bypasses blockers
popupBlockedUri.value = uri
}
// Wait for remote signer to connect (with timeout)
const abort = new AbortController()
const timeout = setTimeout(() => abort.abort(), WAIT_FOR_SIGNER_TIMEOUT_MS)
await signer.waitForSigner(abort.signal)
clearTimeout(timeout)
if (!signer.remote) {
throw new Error('Connection was closed before the signer responded.')
}
isCompletingLogin.value = true
const pubkey = await signer.getPublicKey()
if (!pubkey) {
throw new Error('Could not get public key from signer.')
}
// Register NostrConnectAccount for future signing (comments, etc.)
const account = new NostrConnectAccount(pubkey, signer)
accountManager.addAccount(account)
accountManager.setActive(account)
// Create backend session (NIP-98 is signed by the remote signer)
await authStore.loginWithNostr(pubkey, 'nostr-connect', {})
} catch (err: any) {
if (err?.name === 'AbortError' || err?.message?.includes('Aborted')) {
error.value = 'Connection timed out. Please try again.'
} else {
error.value = err?.message || 'Remote signer login failed. Please try again.'
}
throw err
} finally {
isConnecting.value = false
isCompletingLogin.value = false
popupBlockedUri.value = null
if (signer && !signer.isConnected) {
signer.close().catch(() => {})
}
}
}
let qrFlowAbort: AbortController | null = null
let qrFlowUserCancelled = false
/**
* Start remote signer flow in QR mode (desktop).
* Returns the URI for the QR code and a promise that resolves when login completes.
* Does not open a window — user scans QR with their phone.
*/
function startRemoteSignerQrFlow(): { uri: string; complete: Promise<void>; cancel: () => void } {
const clientSigner = new PrivateKeySigner()
const signer = new NostrConnectSigner({
relays: NOSTR_CONNECT_RELAYS,
signer: clientSigner,
})
const baseUri = signer.getNostrConnectURI({
name: 'IndeeHub',
url: window.location.origin,
})
const uri = appendCallbackToUri(baseUri)
isConnecting.value = true
error.value = null
qrFlowUserCancelled = false
qrFlowAbort = new AbortController()
const timeout = setTimeout(() => qrFlowAbort!.abort(), WAIT_FOR_SIGNER_TIMEOUT_MS)
const complete = (async () => {
try {
await signer.waitForSigner(qrFlowAbort!.signal)
clearTimeout(timeout)
qrFlowAbort = null
if (!signer.remote) {
throw new Error('Connection was closed before the signer responded.')
}
const pubkey = await signer.getPublicKey()
if (!pubkey) {
throw new Error('Could not get public key from signer.')
}
const account = new NostrConnectAccount(pubkey, signer)
accountManager.addAccount(account)
accountManager.setActive(account)
await authStore.loginWithNostr(pubkey, 'nostr-connect', {})
} catch (err: any) {
clearTimeout(timeout)
qrFlowAbort = null
if (!qrFlowUserCancelled) {
if (err?.name === 'AbortError' || err?.message?.includes('Aborted')) {
error.value = 'Connection timed out. Please try again.'
} else {
error.value = err?.message || 'Remote signer login failed. Please try again.'
}
}
throw err
} finally {
isConnecting.value = false
if (signer && !signer.isConnected) {
signer.close().catch(() => {})
}
}
})()
const cancel = () => {
qrFlowUserCancelled = true
clearTimeout(timeout)
qrFlowAbort?.abort()
qrFlowAbort = null
isConnecting.value = false
if (signer && !signer.isConnected) {
signer.close().catch(() => {})
}
}
return { uri, complete, cancel }
}
return {
isConnecting,
isCompletingLogin,
isLoading,
error,
popupBlockedUri,
loginWithRemoteSigner,
startRemoteSignerQrFlow,
getCallbackUrl,
}
}

View File

@@ -25,6 +25,14 @@ const router = createRouter({
meta: { requiresAuth: true }
},
// Nostr Connect callback (Primal redirects here after remote signer approval)
{
path: '/auth/nostr-callback',
name: 'nostr-callback',
component: () => import('../views/NostrCallback.vue'),
meta: { requiresAuth: false }
},
// ── Creator / Filmmaker Routes ────────────────────────────────────────────
{
path: '/backstage',

View File

@@ -206,15 +206,51 @@ class IndeehubApiService {
/**
* Get zap stats for film cards (count, amount, recent zapper pubkeys) by project id.
* Mock data only in dev so the UI can be tested; production shows only real backend data.
*/
async getZapStats(projectIds: string[]): Promise<Record<string, { zapCount: number; zapAmountSats: number; recentZapperPubkeys: string[] }>> {
if (projectIds.length === 0) return {}
const ids = projectIds.join(',')
let data: Record<string, { zapCount: number; zapAmountSats: number; recentZapperPubkeys: string[] }> = {}
try {
const response = await this.client.get<Record<string, { zapCount: number; zapAmountSats: number; recentZapperPubkeys: string[] }>>(
'/zaps/stats',
{ params: { projectIds: ids } },
)
return response.data ?? {}
data = response.data ?? {}
} catch (err) {
if (import.meta.env.DEV) {
console.warn('[getZapStats] API failed, using empty stats:', err)
}
data = {}
}
if (import.meta.env.DEV) {
Object.assign(data, this.getMockZapStats(projectIds))
}
return data
}
/**
* Mock zap stats for dev so cards and modal show the zap UI.
* Uses fake pubkeys so robohash avatars display.
*/
private getMockZapStats(projectIds: string[]): Record<string, { zapCount: number; zapAmountSats: number; recentZapperPubkeys: string[] }> {
const mockPubkeys = [
'aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa',
'bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb',
'c7937c1d0f8d0a2e8a0e8a0e8a0e8a0e8a0e8a0e8a0e8a0e8a0e8a0e8a0e8a',
]
const result: Record<string, { zapCount: number; zapAmountSats: number; recentZapperPubkeys: string[] }> = {}
const mockCounts = [3, 7, 1, 12, 2]
const mockSats = [2100, 50000, 1000, 100000, 420]
projectIds.slice(0, 5).forEach((id, i) => {
result[id] = {
zapCount: mockCounts[i % mockCounts.length],
zapAmountSats: mockSats[i % mockSats.length],
recentZapperPubkeys: mockPubkeys.slice(0, (i % 3) + 1),
}
})
return result
}
/**

View File

@@ -108,7 +108,7 @@ html {
display: none;
}
/* Netflix-style hero gradient */
/* Hero gradient overlay */
.hero-gradient {
background: linear-gradient(
to top,

View File

@@ -44,7 +44,7 @@ export interface NostrEvent {
sig: string
}
// Content row for Netflix-style interface
// Content row for browse interface
export interface ContentRow {
title: string
contents: Content[]

View File

@@ -22,12 +22,11 @@ export interface IndeeHubFilm {
}
/**
* Fetch films from IndeeHub screening room
* TODO: Replace with actual API call when authenticated
* Fetch films from IndeeHub screening room.
* For authenticated requests, use indeehub-api.service which attaches NIP-98 tokens.
*/
export async function fetchFilms(): Promise<IndeeHubFilm[]> {
try {
// TODO: Add authentication headers (NIP-98 for Nostr auth)
const response = await fetch(`${INDEEDHUB_API}/screening-room?type=film`, {
headers: {
// Add your auth headers here

View File

@@ -82,11 +82,10 @@ class NostrService {
}
/**
* Publish a view/watch event
* Publish a view/watch event (NIP-XX view tracking — not yet implemented)
*/
async publishView(videoEventId: string) {
// TODO: Implement NIP-XX for view tracking
console.log('Publishing view for:', videoEventId)
async publishView(_videoEventId: string) {
// View tracking via Nostr not yet implemented
}
/**

View File

@@ -0,0 +1,56 @@
<template>
<div class="min-h-screen flex flex-col items-center justify-center bg-[#0a0a0a] px-4">
<div class="text-center space-y-6">
<div class="flex items-center justify-center gap-2 text-[#FAFAFA]">
<svg
class="w-8 h-8 animate-spin text-[#F7931A]"
fill="none"
viewBox="0 0 24 24"
>
<circle
class="opacity-25"
cx="12"
cy="12"
r="10"
stroke="currentColor"
stroke-width="4"
/>
<path
class="opacity-75"
fill="currentColor"
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z"
/>
</svg>
<span class="text-lg font-medium">Redirecting back to IndeeHub...</span>
</div>
<p class="text-sm text-white/50">
If you're not redirected automatically,
<router-link to="/" class="text-[#F7931A] hover:text-[#F7931A]/80 underline">
click here
</router-link>
to return home.
</p>
</div>
</div>
</template>
<script setup lang="ts">
import { onMounted, onUnmounted } from 'vue'
import { useRouter } from 'vue-router'
const router = useRouter()
let redirectTimer: ReturnType<typeof setTimeout> | null = null
onMounted(() => {
// Primal redirects here after user approves the connection.
// The original tab (if still open) has already completed the login.
// Redirect to home so the user lands on the app; auth state is in sessionStorage.
redirectTimer = setTimeout(() => {
router.replace('/')
}, 1500)
})
onUnmounted(() => {
if (redirectTimer) clearTimeout(redirectTimer)
})
</script>

View File

@@ -243,23 +243,28 @@ const rentalPct = computed(() =>
totalRevenue.value > 0 ? Math.round(((watchAnalytics.value?.rentalRevenueSats || 0) / totalRevenue.value) * 100) : 50
)
function formatNumber(n: number): string {
return n.toLocaleString()
function formatNumber(n: number | undefined | null): string {
const val = n ?? 0
return typeof val === 'number' && !Number.isNaN(val) ? val.toLocaleString() : '0'
}
function formatSats(n: number): string {
return n.toLocaleString()
function formatSats(n: number | undefined | null): string {
const val = n ?? 0
return typeof val === 'number' && !Number.isNaN(val) ? val.toLocaleString() : '0'
}
function formatDuration(seconds: number): string {
if (!seconds) return '0m'
const m = Math.floor(seconds / 60)
const s = Math.round(seconds % 60)
return m > 0 ? `${m}m ${s}s` : `${s}s`
function formatDuration(seconds: number | undefined | null): string {
const s = seconds ?? 0
if (!s || typeof s !== 'number' || Number.isNaN(s)) return '0m'
const m = Math.floor(s / 60)
const sec = Math.round(s % 60)
return m > 0 ? `${m}m ${sec}s` : `${sec}s`
}
function formatDate(dateString: string): string {
return new Date(dateString).toLocaleDateString('en-US', {
function formatDate(dateString: string | undefined | null): string {
if (dateString == null || dateString === '') return '—'
const date = new Date(dateString)
return Number.isNaN(date.getTime()) ? '—' : date.toLocaleDateString('en-US', {
year: 'numeric',
month: 'short',
day: 'numeric',

View File

@@ -18,8 +18,8 @@ export default {
'glass-darker': 'rgba(0, 0, 0, 0.6)',
'glass-border': 'rgba(255, 255, 255, 0.18)',
'glass-highlight': 'rgba(255, 255, 255, 0.22)',
'netflix-red': '#E50914',
'netflix-black': '#141414',
'accent-red': '#E50914',
'surface-dark': '#141414',
},
boxShadow: {
'glass': '0 8px 24px rgba(0, 0, 0, 0.45)',

View File

@@ -1 +1 @@
{"root":["./src/env.d.ts","./src/main.ts","./src/composables/useaccess.ts","./src/composables/useaccounts.ts","./src/composables/useauth.ts","./src/composables/usecontentdiscovery.ts","./src/composables/usefilmmaker.ts","./src/composables/usemobile.ts","./src/composables/usenostr.ts","./src/composables/useobservable.ts","./src/composables/usetoast.ts","./src/composables/useupload.ts","./src/config/api.config.ts","./src/data/indeehubfilms.ts","./src/data/testpersonas.ts","./src/data/topdocfilms.ts","./src/lib/accounts.ts","./src/lib/nostr.ts","./src/lib/relay.ts","./src/router/guards.ts","./src/router/index.ts","./src/services/api.service.ts","./src/services/auth.service.ts","./src/services/content.service.ts","./src/services/filmmaker.service.ts","./src/services/indeehub-api.service.ts","./src/services/library.service.ts","./src/services/nip98.service.ts","./src/services/subscription.service.ts","./src/stores/auth.ts","./src/stores/content.ts","./src/stores/contentsource.ts","./src/stores/searchselection.ts","./src/types/api.ts","./src/types/content.ts","./src/utils/indeehubapi.ts","./src/utils/mappers.ts","./src/utils/mock.ts","./src/utils/nostr.ts","./src/app.vue","./src/components/appheader.vue","./src/components/authmodal.vue","./src/components/backstageheader.vue","./src/components/backstagemobilenav.vue","./src/components/commentnode.vue","./src/components/contentdetailmodal.vue","./src/components/contentrow.vue","./src/components/keysmodal.vue","./src/components/mobilenav.vue","./src/components/mobilesearch.vue","./src/components/rentalmodal.vue","./src/components/splashintro.vue","./src/components/splashintroicon.vue","./src/components/subscriptionmodal.vue","./src/components/toastcontainer.vue","./src/components/videoplayer.vue","./src/components/zapmodal.vue","./src/components/backstage/assetstab.vue","./src/components/backstage/castcrewtab.vue","./src/components/backstage/contenttab.vue","./src/components/backstage/couponstab.vue","./src/components/backstage/detailstab.vue","./src/components/backstage/documentationtab.vue","./src/components/backstage/permissionstab.vue","./src/components/backstage/revenuetab.vue","./src/components/backstage/uploadzone.vue","./src/views/browse.vue","./src/views/profile.vue","./src/views/backstage/analytics.vue","./src/views/backstage/backstage.vue","./src/views/backstage/projecteditor.vue","./src/views/backstage/settings.vue"],"version":"5.9.3"}
{"root":["./src/env.d.ts","./src/main.ts","./src/composables/useaccess.ts","./src/composables/useaccounts.ts","./src/composables/useauth.ts","./src/composables/usecontentdiscovery.ts","./src/composables/usefilmmaker.ts","./src/composables/usemobile.ts","./src/composables/usenostr.ts","./src/composables/usenostrconnect.ts","./src/composables/useobservable.ts","./src/composables/usetoast.ts","./src/composables/useupload.ts","./src/config/api.config.ts","./src/data/indeehubfilms.ts","./src/data/testpersonas.ts","./src/data/topdocfilms.ts","./src/lib/accounts.ts","./src/lib/nostr.ts","./src/lib/relay.ts","./src/router/guards.ts","./src/router/index.ts","./src/services/api.service.ts","./src/services/auth.service.ts","./src/services/content.service.ts","./src/services/filmmaker.service.ts","./src/services/indeehub-api.service.ts","./src/services/library.service.ts","./src/services/nip98.service.ts","./src/services/subscription.service.ts","./src/stores/auth.ts","./src/stores/content.ts","./src/stores/contentsource.ts","./src/stores/searchselection.ts","./src/types/api.ts","./src/types/content.ts","./src/utils/indeehubapi.ts","./src/utils/mappers.ts","./src/utils/mock.ts","./src/utils/nostr.ts","./src/app.vue","./src/components/appheader.vue","./src/components/authmodal.vue","./src/components/backstageheader.vue","./src/components/backstagemobilenav.vue","./src/components/commentnode.vue","./src/components/contentdetailmodal.vue","./src/components/contentrow.vue","./src/components/keysmodal.vue","./src/components/mobilenav.vue","./src/components/mobilesearch.vue","./src/components/rentalmodal.vue","./src/components/splashintro.vue","./src/components/splashintroicon.vue","./src/components/subscriptionmodal.vue","./src/components/toastcontainer.vue","./src/components/videoplayer.vue","./src/components/zapmodal.vue","./src/components/backstage/assetstab.vue","./src/components/backstage/castcrewtab.vue","./src/components/backstage/contenttab.vue","./src/components/backstage/couponstab.vue","./src/components/backstage/detailstab.vue","./src/components/backstage/documentationtab.vue","./src/components/backstage/permissionstab.vue","./src/components/backstage/revenuetab.vue","./src/components/backstage/uploadzone.vue","./src/views/browse.vue","./src/views/nostrcallback.vue","./src/views/profile.vue","./src/views/backstage/analytics.vue","./src/views/backstage/backstage.vue","./src/views/backstage/projecteditor.vue","./src/views/backstage/settings.vue"],"version":"5.9.3"}