Compare commits
10 Commits
66db9376ed
...
8d56fe392d
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
8d56fe392d | ||
|
|
a88022f81d | ||
|
|
ffad3eb6e8 | ||
|
|
d8f54a5032 | ||
|
|
bd1f370760 | ||
|
|
cef73f9694 | ||
|
|
023653eec5 | ||
|
|
50915f8c52 | ||
|
|
f40b4a1268 | ||
|
|
e48e5f5b4d |
19
.env.example
19
.env.example
@@ -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
99
.env.portainer.example
Normal 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
10
.gitignore
vendored
@@ -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
21
ARCHITECTURE.md
Normal 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 |
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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! 🎉**
|
||||
@@ -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.
|
||||
@@ -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`
|
||||
|
||||
189
DEV_AUTH.md
189
DEV_AUTH.md
@@ -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.
|
||||
|
||||
244
FINAL-STATUS.md
244
FINAL-STATUS.md
@@ -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
|
||||
102
FIXES-APPLIED.md
102
FIXES-APPLIED.md
@@ -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.** 🎉
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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!** 🚀
|
||||
267
README.md
267
README.md
@@ -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
|
||||
npm run type-check # TypeScript validation
|
||||
# Option A: Docker Compose
|
||||
docker compose -f docker-compose.dev.yml up
|
||||
|
||||
# Docker
|
||||
docker-compose up -d # Start container (port 7777)
|
||||
docker-compose down # Stop container
|
||||
docker-compose logs -f # View logs
|
||||
# Option B: Script (starts Postgres, Redis, MinIO, backend, frontend)
|
||||
bash scripts/dev.sh
|
||||
```
|
||||
|
||||
## 📚 Documentation
|
||||
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.
|
||||
|
||||
- **[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
|
||||
## Features
|
||||
|
||||
## 🔐 Authentication
|
||||
- **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
|
||||
|
||||
### Development Mode (Mock)
|
||||
- Any email/password works
|
||||
- Creates temporary mock users
|
||||
- Persists in sessionStorage
|
||||
- Perfect for UI testing
|
||||
## Auth Options
|
||||
|
||||
### Production Mode (Real)
|
||||
- AWS Cognito for email/password
|
||||
- Nostr NIP-07 for decentralized auth
|
||||
- JWT token management
|
||||
- Automatic token refresh
|
||||
| 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 |
|
||||
|
||||
## 🎬 Content
|
||||
## Commands
|
||||
|
||||
### 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/
|
||||
npm run dev # Dev server (port 5174)
|
||||
npm run build # Production build
|
||||
npm run type-check # TypeScript validation
|
||||
```
|
||||
|
||||
### Docker
|
||||
```bash
|
||||
docker-compose up -d
|
||||
# Available at http://localhost:7777
|
||||
```
|
||||
## Documentation
|
||||
|
||||
### 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
|
||||
```
|
||||
- [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
|
||||
|
||||
## 🐛 Troubleshooting
|
||||
## Tech Stack
|
||||
|
||||
### "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
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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.
|
||||
|
||||
|
||||
|
||||
# Running Stripe Webhooks locally
|
||||
|
||||
10
backend/package-lock.json
generated
10
backend/package-lock.json
generated
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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,
|
||||
|
||||
18
backend/src/common/interceptors/http-cache.interceptor.ts
Normal file
18
backend/src/common/interceptors/http-cache.interceptor.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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',
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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',
|
||||
});
|
||||
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -109,30 +109,41 @@ 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> {
|
||||
let row = await this.zapStatsRepository.findOneBy({ projectId });
|
||||
if (!row) {
|
||||
row = this.zapStatsRepository.create({
|
||||
projectId,
|
||||
zapCount: 0,
|
||||
zapAmountSats: 0,
|
||||
recentZapperPubkeys: [],
|
||||
});
|
||||
try {
|
||||
let row = await this.zapStatsRepository.findOneBy({ projectId });
|
||||
if (!row) {
|
||||
row = this.zapStatsRepository.create({
|
||||
projectId,
|
||||
zapCount: 0,
|
||||
zapAmountSats: 0,
|
||||
recentZapperPubkeys: [],
|
||||
});
|
||||
}
|
||||
row.zapCount += 1;
|
||||
row.zapAmountSats += sats;
|
||||
if (
|
||||
zapperPubkey &&
|
||||
Array.isArray(row.recentZapperPubkeys) &&
|
||||
row.recentZapperPubkeys.length < MAX_RECENT_ZAPPERS &&
|
||||
!row.recentZapperPubkeys.includes(zapperPubkey)
|
||||
) {
|
||||
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).`,
|
||||
);
|
||||
}
|
||||
row.zapCount += 1;
|
||||
row.zapAmountSats += sats;
|
||||
if (
|
||||
zapperPubkey &&
|
||||
Array.isArray(row.recentZapperPubkeys) &&
|
||||
row.recentZapperPubkeys.length < MAX_RECENT_ZAPPERS &&
|
||||
!row.recentZapperPubkeys.includes(zapperPubkey)
|
||||
) {
|
||||
row.recentZapperPubkeys = [...row.recentZapperPubkeys, zapperPubkey];
|
||||
}
|
||||
await this.zapStatsRepository.save(row);
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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
451
docs/ARCHITECTURE.html
Normal 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 & 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 & 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>
|
||||
24
src/assets/images/primal-icon.svg
Normal file
24
src/assets/images/primal-icon.svg
Normal 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 |
@@ -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"
|
||||
@@ -222,6 +344,7 @@
|
||||
{{ mode === 'register' ? 'Sign in' : 'Sign up' }}
|
||||
</button>
|
||||
</div>
|
||||
</template>
|
||||
</template>
|
||||
|
||||
<!-- ═══ SOVEREIGN PHASE: 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 = ''
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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,25 +95,27 @@ 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) ?? []
|
||||
if (ids.length === 0) {
|
||||
function fetchZapStats() {
|
||||
const ids = props.contents?.map((c) => c.id).filter(Boolean) ?? []
|
||||
if (ids.length === 0) {
|
||||
backendZapStats.value = {}
|
||||
return
|
||||
}
|
||||
indeehubApiService
|
||||
.getZapStats(ids)
|
||||
.then((data) => {
|
||||
backendZapStats.value = data
|
||||
})
|
||||
.catch(() => {
|
||||
backendZapStats.value = {}
|
||||
return
|
||||
}
|
||||
indeehubApiService
|
||||
.getZapStats(ids)
|
||||
.then((data) => {
|
||||
backendZapStats.value = data
|
||||
})
|
||||
.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>
|
||||
|
||||
|
||||
213
src/composables/useNostrConnect.ts
Normal file
213
src/composables/useNostrConnect.ts
Normal 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,
|
||||
}
|
||||
}
|
||||
@@ -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',
|
||||
|
||||
@@ -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(',')
|
||||
const response = await this.client.get<Record<string, { zapCount: number; zapAmountSats: number; recentZapperPubkeys: string[] }>>(
|
||||
'/zaps/stats',
|
||||
{ params: { projectIds: ids } },
|
||||
)
|
||||
return response.data ?? {}
|
||||
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 } },
|
||||
)
|
||||
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
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -108,7 +108,7 @@ html {
|
||||
display: none;
|
||||
}
|
||||
|
||||
/* Netflix-style hero gradient */
|
||||
/* Hero gradient overlay */
|
||||
.hero-gradient {
|
||||
background: linear-gradient(
|
||||
to top,
|
||||
|
||||
@@ -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[]
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
56
src/views/NostrCallback.vue
Normal file
56
src/views/NostrCallback.vue
Normal 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>
|
||||
@@ -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',
|
||||
|
||||
@@ -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)',
|
||||
|
||||
@@ -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"}
|
||||
Reference in New Issue
Block a user