Implement backend API and database services in Docker setup

- Added a new `api` service for the NestJS backend, including health checks and dependencies on PostgreSQL, Redis, and MinIO.
- Introduced PostgreSQL and Redis services with health checks and configurations for data persistence.
- Added MinIO for S3-compatible object storage and a one-shot service to initialize required buckets.
- Updated the Nginx configuration to proxy requests to the new backend API and MinIO storage.
- Enhanced the Dockerfile to support the new API environment variables and configurations.
- Updated the `package.json` and `package-lock.json` to include new dependencies for QR code generation and other utilities.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Dorian
2026-02-12 20:14:39 +00:00
parent f19fd6feef
commit cdd24a5def
478 changed files with 55355 additions and 529 deletions

View File

@@ -1,7 +1,11 @@
<template>
<div id="app" class="min-h-screen">
<!-- Shared Header (hidden on mobile when search overlay is open) -->
<!-- Backstage Header (for creator routes) -->
<BackstageHeader v-if="isBackstageRoute" />
<!-- Audience Header (hidden on mobile when search overlay is open, hidden on backstage) -->
<AppHeader
v-else
:class="{ 'header-hidden-mobile': showMobileSearch }"
@openAuth="handleOpenAuth"
@selectContent="handleSearchSelect"
@@ -11,16 +15,19 @@
<!-- Route Content -->
<RouterView @openAuth="handleOpenAuth" />
<!-- Mobile Navigation (hidden on desktop) -->
<!-- Mobile Navigation: Backstage tab bar on creator routes, audience tab bar otherwise -->
<BackstageMobileNav v-if="isBackstageRoute" />
<MobileNav
v-else
:searchActive="showMobileSearch"
@openAuth="handleOpenAuth"
@openSearch="showMobileSearch = true"
@closeSearch="showMobileSearch = false"
/>
<!-- Mobile Search Overlay -->
<!-- Mobile Search Overlay (audience only) -->
<MobileSearch
v-if="!isBackstageRoute"
:isOpen="showMobileSearch"
@close="showMobileSearch = false"
@select="handleSearchSelect"
@@ -39,21 +46,33 @@
</template>
<script setup lang="ts">
import { ref, onMounted } from 'vue'
import { useRouter } from 'vue-router'
import { ref, computed, onMounted } from 'vue'
import { useRouter, useRoute } from 'vue-router'
import { useAuthStore } from './stores/auth'
import { useSearchSelectionStore } from './stores/searchSelection'
import AppHeader from './components/AppHeader.vue'
import BackstageHeader from './components/BackstageHeader.vue'
import AuthModal from './components/AuthModal.vue'
import BackstageMobileNav from './components/BackstageMobileNav.vue'
import MobileNav from './components/MobileNav.vue'
import MobileSearch from './components/MobileSearch.vue'
import ToastContainer from './components/ToastContainer.vue'
import type { Content } from './types/content'
const router = useRouter()
const route = useRoute()
const authStore = useAuthStore()
const searchSelection = useSearchSelectionStore()
/**
* True when current route is any /backstage/* path.
* Checks both Vue Router's resolved path and the browser URL
* to handle the initial render before async guards resolve.
*/
const isBackstageRoute = computed(() =>
route.path.startsWith('/backstage') || window.location.pathname.startsWith('/backstage')
)
const showAuthModal = ref(false)
const showMobileSearch = ref(false)
const pendingRedirect = ref<string | null>(null)

View File

@@ -220,16 +220,32 @@
</svg>
<span>My Library</span>
</button>
<!-- Content Source Toggle -->
<button @click="handleSourceToggle" class="profile-menu-item flex items-center gap-3 px-4 py-2.5 w-full text-left">
<!-- Backstage (filmmaker only) -->
<button v-if="isFilmmakerUser" @click="navigateTo('/backstage')" class="profile-menu-item flex items-center gap-3 px-4 py-2.5 w-full text-left">
<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="M8 7h12m0 0l-4-4m4 4l-4 4m0 6H4m0 0l4 4m-4-4l4-4" />
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M7 4V20M17 4V20M3 8H7M17 8H21M3 12H21M3 16H7M17 16H21M4 20H20C20.5523 20 21 19.5523 21 19V5C21 4.44772 20.5523 4 20 4H4C3.44772 4 3 4.44772 3 5V19C3 19.5523 3.44772 20 4 20Z" />
</svg>
<span class="flex-1">{{ contentSourceStore.activeSource === 'indeehub' ? 'IndeeHub Films' : 'TopDoc Films' }}</span>
<span class="text-[10px] text-white/40 uppercase tracking-wider">Switch</span>
<span>Backstage</span>
</button>
<!-- Content Source Selector -->
<div class="px-4 py-2">
<p class="text-[10px] text-white/40 uppercase tracking-wider mb-2">Content Source</p>
<div class="flex flex-col gap-1">
<button
v-for="source in contentSourceStore.availableSources"
:key="source.id"
@click="handleSourceSelect(source.id)"
class="profile-menu-item flex items-center gap-3 px-3 py-2 w-full text-left rounded-lg transition-all"
:class="contentSourceStore.activeSource === source.id ? 'bg-white/10 text-white' : 'text-white/60 hover:text-white'"
>
<span class="w-2 h-2 rounded-full" :class="contentSourceStore.activeSource === source.id ? 'bg-[#F7931A]' : 'bg-white/20'"></span>
<span class="flex-1 text-sm">{{ source.label }}</span>
<span v-if="contentSourceStore.activeSource === source.id" class="text-[10px] text-[#F7931A] uppercase tracking-wider">Active</span>
</button>
</div>
</div>
<div class="border-t border-white/10 my-1"></div>
<button @click="handleLogout" class="profile-menu-item flex items-center gap-3 px-4 py-2.5 text-red-400 w-full text-left">
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
@@ -311,7 +327,7 @@ const emit = defineEmits<Emits>()
const router = useRouter()
const route = useRoute()
const { user, isAuthenticated, loginWithNostr: appLoginWithNostr, logout: appLogout } = useAuth()
const { user, isAuthenticated, isFilmmaker: isFilmmakerComputed, loginWithNostr: appLoginWithNostr, logout: appLogout } = useAuth()
const {
isLoggedIn: nostrLoggedIn,
activePubkey: nostrActivePubkey,
@@ -332,13 +348,16 @@ const {
const contentSourceStore = useContentSourceStore()
const contentStore = useContentStore()
const isFilmmakerUser = isFilmmakerComputed
/** Toggle between IndeeHub and TopDocFilms catalogs, then reload content */
function handleSourceToggle() {
contentSourceStore.toggle()
/** Switch content source and reload */
function handleSourceSelect(sourceId: string) {
contentSourceStore.setSource(sourceId as any)
contentStore.fetchContent()
}
// Source toggle is now handled by handleSourceSelect above
const dropdownOpen = ref(false)
const personaMenuOpen = ref(false)
const algosMenuOpen = ref(false)
@@ -352,6 +371,10 @@ const searchWrapperRef = ref<HTMLElement | null>(null)
/** All films from the active content source for searching */
const allContent = computed<Content[]>(() => {
// When using API source, search from the content store's loaded data
if (contentSourceStore.activeSource === 'indeehub-api') {
return Object.values(contentStore.contentRows).flat()
}
return contentSourceStore.activeSource === 'topdocfilms'
? topDocFilms
: indeeHubFilms

View File

@@ -137,6 +137,7 @@
import { ref, computed } from 'vue'
import { useAuth } from '../composables/useAuth'
import { useAccounts } from '../composables/useAccounts'
import { accountManager } from '../lib/accounts'
interface Props {
isOpen: boolean
@@ -205,32 +206,19 @@ async function handleNostrLogin() {
return
}
// Get public key from extension
const pubkey = await window.nostr.getPublicKey()
// First, register the extension account in accountManager.
// This sets up the signer that createNip98AuthHeader needs.
await loginWithExtension()
// Create authentication event
const authEvent = {
kind: 27235, // NIP-98 HTTP Auth
created_at: Math.floor(Date.now() / 1000),
tags: [
['u', window.location.origin],
['method', 'POST'],
],
content: '',
const pubkey = accountManager.active?.pubkey
if (!pubkey) {
errorMessage.value = 'Could not get public key from extension.'
return
}
// Sign event with extension
const signedEvent = await window.nostr.signEvent(authEvent)
// Create session with backend (auth store — subscription/My List)
await loginWithNostr(pubkey, signedEvent.sig, signedEvent)
// Also register extension account in accountManager (commenting/reactions)
try {
await loginWithExtension()
} catch {
// Non-critical — extension account already obtained pubkey above
}
// Create backend session (NIP-98 auth is handled internally
// by authService using the active accountManager signer)
await loginWithNostr(pubkey, 'extension', {})
emit('success')
closeModal()

View File

@@ -0,0 +1,320 @@
<template>
<header class="fixed top-0 left-0 right-0 z-50 pt-4 px-4">
<div class="floating-glass-header mx-auto px-4 md:px-6 py-3.5 rounded-2xl transition-all duration-300" style="max-width: 100%;">
<div class="flex items-center justify-between">
<!-- Left: Logo + Backstage Nav -->
<div class="flex items-center gap-4 lg:gap-8">
<router-link to="/" class="flex-shrink-0">
<img src="/assets/images/logo-desktop.svg" alt="IndeeHub" class="h-8 md:h-10 ml-2 md:ml-0" />
</router-link>
<!-- Backstage Navigation (desktop only mobile uses bottom tab bar) -->
<nav class="hidden md:flex items-center gap-3">
<router-link
to="/backstage"
:class="isExactRoute('/backstage') ? 'nav-button-active' : 'nav-button'"
>
<svg class="w-4 h-4 inline mr-1.5 -mt-0.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M7 4V20M17 4V20M3 8H7M17 8H21M3 12H21M3 16H7M17 16H21M4 20H20C20.5523 20 21 19.5523 21 19V5C21 4.44772 20.5523 4 20 4H4C3.44772 4 3 4.44772 3 5V19C3 19.5523 3.44772 20 4 20Z" />
</svg>
Projects
</router-link>
<router-link
to="/backstage/analytics"
:class="isRoute('/backstage/analytics') ? 'nav-button-active' : 'nav-button'"
>
<svg class="w-4 h-4 inline mr-1.5 -mt-0.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 19v-6a2 2 0 00-2-2H5a2 2 0 00-2 2v6a2 2 0 002 2h2a2 2 0 002-2zm0 0V9a2 2 0 012-2h2a2 2 0 012 2v10m-6 0a2 2 0 002 2h2a2 2 0 002-2m0 0V5a2 2 0 012-2h2a2 2 0 012 2v14a2 2 0 01-2 2h-2a2 2 0 01-2-2z" />
</svg>
Analytics
</router-link>
<router-link
to="/backstage/settings"
:class="isRoute('/backstage/settings') ? 'nav-button-active' : 'nav-button'"
>
<svg class="w-4 h-4 inline mr-1.5 -mt-0.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M10.325 4.317c.426-1.756 2.924-1.756 3.35 0a1.724 1.724 0 002.573 1.066c1.543-.94 3.31.826 2.37 2.37a1.724 1.724 0 001.066 2.573c1.756.426 1.756 2.924 0 3.35a1.724 1.724 0 00-1.066 2.573c.94 1.543-.826 3.31-2.37 2.37a1.724 1.724 0 00-2.573 1.066c-.426 1.756-2.924 1.756-3.35 0a1.724 1.724 0 00-2.573-1.066c-1.543.94-3.31-.826-2.37-2.37a1.724 1.724 0 00-1.066-2.573c-1.756-.426-1.756-2.924 0-3.35a1.724 1.724 0 001.066-2.573c-.94-1.543.826-3.31 2.37-2.37.996.608 2.296.07 2.572-1.065z" />
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 12a3 3 0 11-6 0 3 3 0 016 0z" />
</svg>
Settings
</router-link>
</nav>
<!-- Mobile: "Backstage" label instead of nav tabs -->
<span class="md:hidden text-white/70 text-sm font-medium tracking-wide">Backstage</span>
</div>
<!-- Right: Profile -->
<div class="flex items-center gap-3">
<!-- Profile -->
<div v-if="nostrLoggedIn" class="relative profile-dropdown">
<button @click="toggleDropdown" class="profile-button flex items-center gap-2">
<img
v-if="nostrActivePubkey"
:src="`https://robohash.org/${nostrActivePubkey}.png`"
class="w-8 h-8 rounded-full"
alt="Avatar"
/>
<span class="text-white text-sm font-medium hidden lg:inline">{{ nostrActiveName }}</span>
<svg class="w-4 h-4 transition-transform" :class="{ 'rotate-180': dropdownOpen }" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 9l-7 7-7-7" />
</svg>
</button>
<div v-if="dropdownOpen" class="profile-menu absolute right-0 mt-2 w-48">
<div class="floating-glass-header py-2 rounded-xl">
<button @click="navigateTo('/profile')" class="profile-menu-item flex items-center gap-3 px-4 py-2.5 w-full text-left">
<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="M16 7a4 4 0 11-8 0 4 4 0 018 0zM12 14a7 7 0 00-7 7h14a7 7 0 00-7-7z" />
</svg>
<span>Profile</span>
</button>
<button @click="navigateTo('/')" class="profile-menu-item flex items-center gap-3 px-4 py-2.5 w-full text-left">
<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 10l4.553-2.276A1 1 0 0121 8.618v6.764a1 1 0 01-1.447.894L15 14M5 18h8a2 2 0 002-2V8a2 2 0 00-2-2H5a2 2 0 00-2 2v8a2 2 0 002 2z" />
</svg>
<span>Browse Films</span>
</button>
<div class="border-t border-white/10 my-1"></div>
<button @click="handleLogout" class="profile-menu-item flex items-center gap-3 px-4 py-2.5 text-red-400 w-full text-left">
<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="M17 16l4-4m0 0l-4-4m4 4H7m6 4v1a3 3 0 01-3 3H6a3 3 0 01-3-3V7a3 3 0 013-3h4a3 3 0 013 3v1" />
</svg>
<span>Sign Out</span>
</button>
</div>
</div>
</div>
<!-- Fallback avatar for app auth -->
<div v-else-if="isAuthenticated" class="flex items-center gap-2">
<div class="w-8 h-8 rounded-full bg-gradient-to-br from-orange-500 to-pink-500 flex items-center justify-center text-xs font-bold text-white">
{{ userInitials }}
</div>
</div>
</div>
</div>
</div>
</header>
</template>
<script setup lang="ts">
import { ref, computed, onMounted, onUnmounted } from 'vue'
import { useRouter, useRoute } from 'vue-router'
import { useAuth } from '../composables/useAuth'
import { useAccounts } from '../composables/useAccounts'
const router = useRouter()
const route = useRoute()
const { user, isAuthenticated, logout: appLogout } = useAuth()
const {
isLoggedIn: nostrLoggedIn,
activePubkey: nostrActivePubkey,
activeName: nostrActiveName,
logout: nostrLogout,
} = useAccounts()
const dropdownOpen = ref(false)
const userInitials = computed(() => {
if (!user.value?.legalName) return 'U'
const names = user.value.legalName.split(' ')
return names.length > 1
? `${names[0][0]}${names[names.length - 1][0]}`
: names[0][0]
})
function isRoute(path: string): boolean {
return route.path.startsWith(path)
}
function isExactRoute(path: string): boolean {
// Match /backstage exactly, or /backstage/project/* (editing a project is still "Projects" tab)
return route.path === path || route.path.startsWith('/backstage/project/')
}
function toggleDropdown() {
dropdownOpen.value = !dropdownOpen.value
}
function navigateTo(path: string) {
dropdownOpen.value = false
router.push(path)
}
async function handleLogout() {
nostrLogout()
await appLogout()
dropdownOpen.value = false
router.push('/')
}
function handleClickOutside(event: MouseEvent) {
const dropdown = document.querySelector('.profile-dropdown')
if (dropdown && !dropdown.contains(event.target as Node)) {
dropdownOpen.value = false
}
}
onMounted(() => {
window.addEventListener('click', handleClickOutside)
})
onUnmounted(() => {
window.removeEventListener('click', handleClickOutside)
})
</script>
<style scoped>
/* Glass Header */
.floating-glass-header {
background: rgba(0, 0, 0, 0.65);
backdrop-filter: blur(40px);
-webkit-backdrop-filter: blur(40px);
border-radius: 24px;
border: 1px solid rgba(255, 255, 255, 0.06);
box-shadow:
0 20px 60px rgba(0, 0, 0, 0.3),
inset 0 1px 0 rgba(255, 255, 255, 0.1);
}
/* Navigation Button Styles */
.nav-button {
position: relative;
padding: 10px 20px;
font-size: 14px;
font-weight: 500;
line-height: 1.4;
border-radius: 14px;
background: rgba(0, 0, 0, 0.25);
color: rgba(255, 255, 255, 0.85);
box-shadow:
0 6px 20px rgba(0, 0, 0, 0.4),
inset 0 1px 0 rgba(255, 255, 255, 0.18);
backdrop-filter: blur(24px);
-webkit-backdrop-filter: blur(24px);
border: none;
cursor: pointer;
transition: all 0.3s ease;
text-decoration: none;
display: inline-flex;
align-items: center;
white-space: nowrap;
letter-spacing: 0.02em;
}
.nav-button::before {
content: '';
position: absolute;
inset: 0;
border-radius: inherit;
padding: 2px;
background: linear-gradient(135deg, rgba(0, 0, 0, 0.8), transparent);
-webkit-mask:
linear-gradient(#fff 0 0) content-box,
linear-gradient(#fff 0 0);
-webkit-mask-composite: xor;
mask-composite: exclude;
pointer-events: none;
}
.nav-button:hover {
transform: translateY(-2px);
background: rgba(0, 0, 0, 0.35);
box-shadow:
0 10px 28px rgba(0, 0, 0, 0.55),
inset 0 1px 0 rgba(255, 255, 255, 0.22);
}
.nav-button:hover::before {
background: linear-gradient(135deg, rgba(255, 255, 255, 0.3), transparent);
}
.nav-button-active {
position: relative;
padding: 10px 20px;
font-size: 14px;
font-weight: 600;
line-height: 1.4;
border-radius: 14px;
background: rgba(0, 0, 0, 0.35);
color: rgba(255, 255, 255, 1);
box-shadow:
0 10px 28px rgba(0, 0, 0, 0.55),
inset 0 1px 0 rgba(255, 255, 255, 0.22);
backdrop-filter: blur(24px);
-webkit-backdrop-filter: blur(24px);
border: none;
cursor: pointer;
transition: all 0.3s ease;
text-decoration: none;
display: inline-flex;
align-items: center;
white-space: nowrap;
letter-spacing: 0.02em;
}
.nav-button-active::before {
content: '';
position: absolute;
inset: 0;
border-radius: inherit;
padding: 2px;
background: linear-gradient(135deg, rgba(255, 255, 255, 0.3), transparent);
-webkit-mask:
linear-gradient(#fff 0 0) content-box,
linear-gradient(#fff 0 0);
-webkit-mask-composite: xor;
mask-composite: exclude;
pointer-events: none;
}
/* Profile Dropdown */
.profile-button {
padding: 6px 12px 6px 6px;
border-radius: 12px;
background: transparent;
border: none;
cursor: pointer;
transition: all 0.3s ease;
}
.profile-button:hover {
background: rgba(255, 255, 255, 0.05);
}
.profile-menu {
z-index: 100;
animation: slideDown 0.2s ease-out;
}
@keyframes slideDown {
from { opacity: 0; transform: translateY(-10px); }
to { opacity: 1; transform: translateY(0); }
}
.profile-menu-item {
color: rgba(255, 255, 255, 0.9);
text-decoration: none;
font-size: 14px;
font-weight: 500;
transition: all 0.2s ease;
display: flex;
align-items: center;
}
.profile-menu-item:hover {
background: rgba(255, 255, 255, 0.1);
color: rgba(255, 255, 255, 1);
}
.profile-menu-item svg {
opacity: 0.8;
}
.profile-menu-item:hover svg {
opacity: 1;
}
</style>

View File

@@ -0,0 +1,136 @@
<template>
<nav class="mobile-nav fixed bottom-0 left-0 right-0 z-50 md:hidden pb-4 px-4">
<div class="floating-glass-nav px-4 py-3 rounded-2xl">
<div class="flex items-center justify-around gap-1">
<!-- Projects -->
<router-link
to="/backstage"
class="flex flex-col items-center gap-1 nav-tab flex-1"
:class="{ 'nav-tab-active': isProjectsActive }"
>
<svg class="w-6 h-6 flex-shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M7 4V20M17 4V20M3 8H7M17 8H21M3 12H21M3 16H7M17 16H21M4 20H20C20.5523 20 21 19.5523 21 19V5C21 4.44772 20.5523 4 20 4H4C3.44772 4 3 4.44772 3 5V19C3 19.5523 3.44772 20 4 20Z" />
</svg>
<span class="text-xs font-medium whitespace-nowrap">Projects</span>
</router-link>
<!-- Analytics -->
<router-link
to="/backstage/analytics"
class="flex flex-col items-center gap-1 nav-tab flex-1"
:class="{ 'nav-tab-active': isActive('/backstage/analytics') }"
>
<svg class="w-6 h-6 flex-shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 19v-6a2 2 0 00-2-2H5a2 2 0 00-2 2v6a2 2 0 002 2h2a2 2 0 002-2zm0 0V9a2 2 0 012-2h2a2 2 0 012 2v10m-6 0a2 2 0 002 2h2a2 2 0 002-2m0 0V5a2 2 0 012-2h2a2 2 0 012 2v14a2 2 0 01-2 2h-2a2 2 0 01-2-2z" />
</svg>
<span class="text-xs font-medium whitespace-nowrap">Analytics</span>
</router-link>
<!-- Settings -->
<router-link
to="/backstage/settings"
class="flex flex-col items-center gap-1 nav-tab flex-1"
:class="{ 'nav-tab-active': isActive('/backstage/settings') }"
>
<svg class="w-6 h-6 flex-shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M10.325 4.317c.426-1.756 2.924-1.756 3.35 0a1.724 1.724 0 002.573 1.066c1.543-.94 3.31.826 2.37 2.37a1.724 1.724 0 001.066 2.573c1.756.426 1.756 2.924 0 3.35a1.724 1.724 0 00-1.066 2.573c.94 1.543-.826 3.31-2.37 2.37a1.724 1.724 0 00-2.573 1.066c-.426 1.756-2.924 1.756-3.35 0a1.724 1.724 0 00-2.573-1.066c-1.543.94-3.31-.826-2.37-2.37a1.724 1.724 0 00-1.066-2.573c-1.756-.426-1.756-2.924 0-3.35a1.724 1.724 0 001.066-2.573c-.94-1.543.826-3.31 2.37-2.37.996.608 2.296.07 2.572-1.065z" />
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 12a3 3 0 11-6 0 3 3 0 016 0z" />
</svg>
<span class="text-xs font-medium whitespace-nowrap">Settings</span>
</router-link>
<!-- Exit (back to audience view) -->
<router-link
to="/"
class="flex flex-col items-center gap-1 nav-tab flex-1"
>
<svg class="w-6 h-6 flex-shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M17 16l4-4m0 0l-4-4m4 4H7m6 4v1a3 3 0 01-3 3H6a3 3 0 01-3-3V7a3 3 0 013-3h4a3 3 0 013 3v1" />
</svg>
<span class="text-xs font-medium whitespace-nowrap">Exit</span>
</router-link>
</div>
</div>
</nav>
</template>
<script setup lang="ts">
import { computed } from 'vue'
import { useRoute } from 'vue-router'
const route = useRoute()
/** Projects tab is active on /backstage exactly or any project editor route */
const isProjectsActive = computed(() =>
route.path === '/backstage' || route.path.startsWith('/backstage/project/')
)
function isActive(path: string): boolean {
return route.path.startsWith(path)
}
</script>
<style scoped>
.mobile-nav {
/* Safe area for iPhone notch/home indicator */
padding-bottom: calc(env(safe-area-inset-bottom, 0) + 16px);
}
.floating-glass-nav {
background: rgba(0, 0, 0, 0.65);
backdrop-filter: blur(40px);
-webkit-backdrop-filter: blur(40px);
border-radius: 24px;
border: 1px solid rgba(255, 255, 255, 0.06);
box-shadow:
0 20px 60px rgba(0, 0, 0, 0.3),
inset 0 1px 0 rgba(255, 255, 255, 0.1);
}
.nav-tab {
position: relative;
color: rgba(255, 255, 255, 0.5);
text-decoration: none;
transition: all 0.3s ease;
padding: 8px 12px;
border-radius: 14px;
background: transparent;
border: none;
cursor: pointer;
font-weight: 500;
max-width: 80px;
display: flex;
flex-direction: column;
align-items: center;
}
.nav-tab:active {
background: rgba(255, 255, 255, 0.08);
}
.nav-tab-active {
color: rgba(255, 255, 255, 1);
font-weight: 600;
background: rgba(0, 0, 0, 0.35);
box-shadow:
0 8px 20px rgba(0, 0, 0, 0.5),
inset 0 1px 0 rgba(255, 255, 255, 0.2);
backdrop-filter: blur(24px);
-webkit-backdrop-filter: blur(24px);
}
.nav-tab-active::before {
content: '';
position: absolute;
inset: 0;
border-radius: inherit;
padding: 1.5px;
background: linear-gradient(135deg, rgba(255, 255, 255, 0.25), transparent);
-webkit-mask:
linear-gradient(#fff 0 0) content-box,
linear-gradient(#fff 0 0);
-webkit-mask-composite: xor;
mask-composite: exclude;
pointer-events: none;
}
</style>

View File

@@ -32,6 +32,14 @@
<span v-if="content.releaseYear">{{ content.releaseYear }}</span>
<span v-if="content.duration">{{ content.duration }} min</span>
<span v-if="content.type" class="capitalize">{{ content.type }}</span>
<!-- Pricing / Access badge -->
<span v-if="hasActiveSubscription" class="bg-green-500/20 text-green-400 border border-green-500/30 px-2.5 py-0.5 rounded font-medium">
Included with subscription
</span>
<span v-else-if="content.rentalPrice" class="bg-amber-500/20 text-amber-400 border border-amber-500/30 px-2.5 py-0.5 rounded font-medium">
{{ content.rentalPrice.toLocaleString() }} sats
</span>
</div>
<!-- Action Buttons -->
@@ -41,7 +49,7 @@
<svg class="w-5 h-5" fill="currentColor" viewBox="0 0 24 24">
<path d="M8 5v14l11-7z"/>
</svg>
Play
{{ hasActiveSubscription ? 'Play' : content?.rentalPrice ? 'Rent & Play' : 'Play' }}
</button>
<!-- Add to My List -->

View File

@@ -4,96 +4,198 @@
<div class="modal-container">
<div class="modal-content">
<!-- Close Button -->
<button @click="closeModal" class="absolute top-4 right-4 text-white/60 hover:text-white transition-colors">
<button @click="closeModal" class="absolute top-4 right-4 text-white/60 hover:text-white transition-colors z-10">
<svg class="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
<!-- Header with Content Info -->
<div class="flex gap-6 mb-8">
<img
v-if="content?.thumbnail"
:src="content.thumbnail"
:alt="content.title"
class="w-32 h-48 object-cover rounded-lg"
/>
<div class="flex-1">
<h2 class="text-2xl font-bold text-white mb-2">{{ content?.title }}</h2>
<p class="text-white/60 text-sm mb-4 line-clamp-3">{{ content?.description }}</p>
<div class="flex items-center gap-4 text-sm text-white/80">
<span v-if="content?.releaseYear">{{ content.releaseYear }}</span>
<span v-if="content?.duration">{{ formatDuration(content.duration) }}</span>
<span v-if="content?.rating">{{ content.rating }}</span>
</div>
</div>
</div>
<!-- Rental Info -->
<div class="bg-white/5 rounded-xl p-6 mb-6">
<div class="flex justify-between items-center mb-4">
<div>
<h3 class="text-xl font-bold text-white mb-1">Rental Details</h3>
<p class="text-white/60 text-sm">48-hour viewing period</p>
</div>
<div class="text-right">
<div class="text-3xl font-bold text-white">${{ content?.rentalPrice || '4.99' }}</div>
<div class="text-white/60 text-sm">One-time payment</div>
<!-- INITIAL STATE: Content Info + Rent Button -->
<template v-if="paymentState === 'initial'">
<!-- Header with Content Info -->
<div class="flex gap-6 mb-8">
<img
v-if="content?.thumbnail"
:src="content.thumbnail"
:alt="content.title"
class="w-32 h-48 object-cover rounded-lg"
/>
<div class="flex-1">
<h2 class="text-2xl font-bold text-white mb-2">{{ content?.title }}</h2>
<p class="text-white/60 text-sm mb-4 line-clamp-3">{{ content?.description }}</p>
<div class="flex items-center gap-4 text-sm text-white/80">
<span v-if="content?.releaseYear">{{ content.releaseYear }}</span>
<span v-if="content?.duration">{{ formatDuration(content.duration) }}</span>
<span v-if="content?.rating">{{ content.rating }}</span>
</div>
</div>
</div>
<ul class="space-y-2 text-sm text-white/80">
<li class="flex items-center gap-2">
<svg class="w-5 h-5 text-green-400" fill="currentColor" viewBox="0 0 20 20">
<path fill-rule="evenodd" d="M10 18a8 8 0 100-16 8 8 0 000 16zm3.707-9.293a1 1 0 00-1.414-1.414L9 10.586 7.707 9.293a1 1 0 00-1.414 1.414l2 2a1 1 0 001.414 0l4-4z" clip-rule="evenodd" />
</svg>
Watch for 48 hours from purchase
</li>
<li class="flex items-center gap-2">
<svg class="w-5 h-5 text-green-400" fill="currentColor" viewBox="0 0 20 20">
<path fill-rule="evenodd" d="M10 18a8 8 0 100-16 8 8 0 000 16zm3.707-9.293a1 1 0 00-1.414-1.414L9 10.586 7.707 9.293a1 1 0 00-1.414 1.414l2 2a1 1 0 001.414 0l4-4z" clip-rule="evenodd" />
</svg>
HD streaming quality
</li>
<li class="flex items-center gap-2">
<svg class="w-5 h-5 text-green-400" fill="currentColor" viewBox="0 0 20 20">
<path fill-rule="evenodd" d="M10 18a8 8 0 100-16 8 8 0 000 16zm3.707-9.293a1 1 0 00-1.414-1.414L9 10.586 7.707 9.293a1 1 0 00-1.414 1.414l2 2a1 1 0 001.414 0l4-4z" clip-rule="evenodd" />
</svg>
Stream on any device
</li>
</ul>
</div>
<!-- Rental Info -->
<div class="bg-white/5 rounded-xl p-6 mb-6">
<div class="flex justify-between items-center mb-4">
<div>
<h3 class="text-xl font-bold text-white mb-1">Rental Details</h3>
<p class="text-white/60 text-sm">48-hour viewing period</p>
</div>
<div class="text-right">
<div class="text-3xl font-bold text-white">${{ content?.rentalPrice || '4.99' }}</div>
<div class="text-white/60 text-sm">One-time payment</div>
</div>
</div>
<ul class="space-y-2 text-sm text-white/80">
<li class="flex items-center gap-2">
<svg class="w-5 h-5 text-green-400" fill="currentColor" viewBox="0 0 20 20">
<path fill-rule="evenodd" d="M10 18a8 8 0 100-16 8 8 0 000 16zm3.707-9.293a1 1 0 00-1.414-1.414L9 10.586 7.707 9.293a1 1 0 00-1.414 1.414l2 2a1 1 0 001.414 0l4-4z" clip-rule="evenodd" />
</svg>
Watch for 48 hours from purchase
</li>
<li class="flex items-center gap-2">
<svg class="w-5 h-5 text-green-400" fill="currentColor" viewBox="0 0 20 20">
<path fill-rule="evenodd" d="M10 18a8 8 0 100-16 8 8 0 000 16zm3.707-9.293a1 1 0 00-1.414-1.414L9 10.586 7.707 9.293a1 1 0 00-1.414 1.414l2 2a1 1 0 001.414 0l4-4z" clip-rule="evenodd" />
</svg>
HD streaming quality
</li>
<li class="flex items-center gap-2">
<svg class="w-5 h-5 text-green-400" fill="currentColor" viewBox="0 0 20 20">
<path fill-rule="evenodd" d="M10 18a8 8 0 100-16 8 8 0 000 16zm3.707-9.293a1 1 0 00-1.414-1.414L9 10.586 7.707 9.293a1 1 0 00-1.414 1.414l2 2a1 1 0 001.414 0l4-4z" clip-rule="evenodd" />
</svg>
Stream on any device
</li>
</ul>
</div>
<!-- Error Message -->
<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">
{{ errorMessage }}
</div>
<!-- Error Message -->
<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">
{{ errorMessage }}
</div>
<!-- Rent Button -->
<button
@click="handleRent"
:disabled="isLoading"
class="hero-play-button w-full flex items-center justify-center mb-4"
>
<span v-if="!isLoading">Rent for ${{ content?.rentalPrice || '4.99' }}</span>
<span v-else>Processing...</span>
</button>
<!-- Subscribe Alternative -->
<div class="text-center">
<p class="text-white/60 text-sm mb-2">Or get unlimited access with a subscription</p>
<!-- Rent Button -->
<button
@click="$emit('openSubscription')"
class="text-orange-500 hover:text-orange-400 font-medium text-sm transition-colors"
@click="handleRent"
:disabled="isLoading"
class="hero-play-button w-full flex items-center justify-center mb-4"
>
View subscription plans
<svg v-if="!isLoading" class="w-5 h-5 mr-2" viewBox="0 0 24 24" fill="currentColor">
<path d="M13 10h-3V7H7v3H4v3h3v3h3v-3h3v-3zm8-6a2 2 0 00-2-2H5a2 2 0 00-2 2v14a2 2 0 002 2h14a2 2 0 002-2V4zm-2 0l.01 14H5V4h14z"/>
</svg>
<span v-if="!isLoading">Pay with Lightning</span>
<span v-else>Creating invoice...</span>
</button>
</div>
<p class="text-center text-xs text-white/40 mt-6">
Rental starts immediately after payment. No refunds.
</p>
<!-- Subscribe Alternative -->
<div class="text-center">
<p class="text-white/60 text-sm mb-2">Or get unlimited access with a subscription</p>
<button
@click="$emit('openSubscription')"
class="text-orange-500 hover:text-orange-400 font-medium text-sm transition-colors"
>
View subscription plans
</button>
</div>
<p class="text-center text-xs text-white/40 mt-6">
Rental starts immediately after payment. No refunds.
</p>
</template>
<!-- INVOICE STATE: QR Code + BOLT11 -->
<template v-if="paymentState === 'invoice'">
<div class="text-center">
<h2 class="text-2xl font-bold text-white mb-2">Pay with Lightning</h2>
<p class="text-white/60 text-sm mb-6">
Scan the QR code or copy the invoice to pay
</p>
<!-- QR Code -->
<div class="bg-white rounded-2xl p-4 inline-block mb-6">
<img v-if="qrCodeDataUrl" :src="qrCodeDataUrl" alt="Lightning Invoice QR Code" class="w-64 h-64" />
<div v-else class="w-64 h-64 flex items-center justify-center text-gray-400">
Generating QR...
</div>
</div>
<!-- Amount in sats -->
<div class="mb-4">
<div class="text-lg font-bold text-white">
{{ formatSats(invoiceData?.sourceAmount?.amount) }} sats
</div>
<div class="text-sm text-white/60">
${{ content?.rentalPrice || '4.99' }} USD
</div>
</div>
<!-- Expiration Countdown -->
<div class="mb-6 flex items-center justify-center gap-2 text-sm" :class="countdownSeconds <= 60 ? 'text-red-400' : 'text-white/60'">
<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="M12 8v4l3 3m6-3a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
<span>Expires in {{ formatCountdown(countdownSeconds) }}</span>
</div>
<!-- Copy BOLT11 Button -->
<button
@click="copyInvoice"
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>
{{ copyButtonText }}
</button>
<!-- Open in wallet link -->
<a
:href="'lightning:' + bolt11Invoice"
class="block text-center text-orange-500 hover:text-orange-400 text-sm font-medium transition-colors mb-4"
>
Open in wallet app
</a>
<p class="text-xs text-white/40">
Waiting for payment confirmation...
</p>
</div>
</template>
<!-- SUCCESS STATE -->
<template v-if="paymentState === 'success'">
<div class="text-center py-8">
<div class="w-20 h-20 bg-green-500/20 rounded-full flex items-center justify-center mx-auto mb-6">
<svg class="w-10 h-10 text-green-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 13l4 4L19 7" />
</svg>
</div>
<h2 class="text-2xl font-bold text-white mb-2">Payment Confirmed!</h2>
<p class="text-white/60 mb-6">Your rental is now active. Enjoy watching!</p>
<button
@click="handleSuccess"
class="hero-play-button w-full flex items-center justify-center"
>
Start Watching
</button>
</div>
</template>
<!-- EXPIRED STATE -->
<template v-if="paymentState === 'expired'">
<div class="text-center py-8">
<div class="w-20 h-20 bg-yellow-500/20 rounded-full flex items-center justify-center mx-auto mb-6">
<svg class="w-10 h-10 text-yellow-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 8v4l3 3m6-3a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
</div>
<h2 class="text-2xl font-bold text-white mb-2">Invoice Expired</h2>
<p class="text-white/60 mb-6">The payment window has closed. Please try again.</p>
<button
@click="resetAndRetry"
class="hero-play-button w-full flex items-center justify-center"
>
Try Again
</button>
</div>
</template>
</div>
</div>
</div>
@@ -101,9 +203,11 @@
</template>
<script setup lang="ts">
import { ref } from 'vue'
import { ref, watch, onUnmounted } from 'vue'
import QRCode from 'qrcode'
import { libraryService } from '../services/library.service'
import type { Content } from '../types/content'
import { USE_MOCK } from '../utils/mock'
interface Props {
isOpen: boolean
@@ -119,20 +223,131 @@ interface Emits {
const props = defineProps<Props>()
const emit = defineEmits<Emits>()
type PaymentState = 'initial' | 'invoice' | 'success' | 'expired'
const paymentState = ref<PaymentState>('initial')
const isLoading = ref(false)
const errorMessage = ref<string | null>(null)
// Invoice data
const invoiceData = ref<any>(null)
const bolt11Invoice = ref('')
const qrCodeDataUrl = ref('')
const copyButtonText = ref('Copy Invoice')
// Countdown
const countdownSeconds = ref(0)
let countdownInterval: ReturnType<typeof setInterval> | null = null
// Polling
let pollInterval: ReturnType<typeof setInterval> | null = null
// USE_MOCK imported from utils/mock
function closeModal() {
emit('close')
cleanup()
paymentState.value = 'initial'
errorMessage.value = null
invoiceData.value = null
bolt11Invoice.value = ''
qrCodeDataUrl.value = ''
copyButtonText.value = 'Copy Invoice'
emit('close')
}
function cleanup() {
if (countdownInterval) {
clearInterval(countdownInterval)
countdownInterval = null
}
if (pollInterval) {
clearInterval(pollInterval)
pollInterval = null
}
}
onUnmounted(cleanup)
// Reset state when modal closes externally
watch(() => props.isOpen, (newVal) => {
if (!newVal) {
cleanup()
paymentState.value = 'initial'
errorMessage.value = null
}
})
function formatDuration(minutes: number): string {
const hours = Math.floor(minutes / 60)
const mins = minutes % 60
return hours > 0 ? `${hours}h ${mins}m` : `${mins}m`
}
function formatSats(amount: string | undefined): string {
if (!amount) return '---'
// Amount from BTCPay is in BTC, convert to sats
const btcAmount = parseFloat(amount)
if (isNaN(btcAmount)) return amount
const sats = Math.round(btcAmount * 100_000_000)
return sats.toLocaleString()
}
function formatCountdown(seconds: number): string {
const mins = Math.floor(seconds / 60)
const secs = seconds % 60
return `${mins}:${secs.toString().padStart(2, '0')}`
}
async function generateQRCode(bolt11: string) {
try {
qrCodeDataUrl.value = await QRCode.toDataURL(bolt11.toUpperCase(), {
width: 256,
margin: 2,
color: {
dark: '#000000',
light: '#FFFFFF',
},
errorCorrectionLevel: 'M',
})
} catch (err) {
console.error('QR Code generation failed:', err)
}
}
function startCountdown(expirationDate: Date | string) {
const expiry = new Date(expirationDate).getTime()
const updateCountdown = () => {
const now = Date.now()
const remaining = Math.max(0, Math.floor((expiry - now) / 1000))
countdownSeconds.value = remaining
if (remaining <= 0) {
cleanup()
paymentState.value = 'expired'
}
}
updateCountdown()
countdownInterval = setInterval(updateCountdown, 1000)
}
function startPolling(contentId: string) {
pollInterval = setInterval(async () => {
try {
if (USE_MOCK) return // mock mode handles differently
const response = await libraryService.checkRentExists(contentId)
if (response.exists) {
cleanup()
paymentState.value = 'success'
}
} catch {
// Silently retry polling
}
}, 3000)
}
async function handleRent() {
if (!props.content) return
@@ -140,29 +355,74 @@ async function handleRent() {
errorMessage.value = null
try {
// Check if we're in development mode
const useMockData = import.meta.env.VITE_USE_MOCK_DATA === 'true' || import.meta.env.DEV
if (useMockData) {
// Mock rental for development
console.log('🔧 Development mode: Mock rental successful')
await new Promise(resolve => setTimeout(resolve, 500))
emit('success')
closeModal()
if (USE_MOCK) {
// Simulate mock Lightning invoice
console.log('Development mode: Simulating Lightning invoice')
await new Promise(resolve => setTimeout(resolve, 800))
const mockBolt11 = 'lnbc50u1pjxyz123456789abcdefghijklmnopqrstuvwxyz0123456789abcdefghijklmnopqrstuvwxyz'
bolt11Invoice.value = mockBolt11
invoiceData.value = {
sourceAmount: { amount: '0.00005000', currency: 'BTC' },
expiration: new Date(Date.now() + 15 * 60 * 1000).toISOString(),
}
await generateQRCode(mockBolt11)
paymentState.value = 'invoice'
startCountdown(invoiceData.value.expiration)
// Simulate payment after 5 seconds in mock mode
setTimeout(() => {
if (paymentState.value === 'invoice') {
cleanup()
paymentState.value = 'success'
}
}, 5000)
return
}
// Real API call
await libraryService.rentContent(props.content.id)
emit('success')
closeModal()
// Real API call — create Lightning invoice
const result = await libraryService.rentContent(props.content.id)
invoiceData.value = result
bolt11Invoice.value = result.lnInvoice
await generateQRCode(result.lnInvoice)
paymentState.value = 'invoice'
startCountdown(result.expiration)
startPolling(props.content.id)
} catch (error: any) {
errorMessage.value = error.message || 'Rental failed. Please try again.'
errorMessage.value = error.message || 'Failed to create invoice. Please try again.'
} finally {
isLoading.value = false
}
}
async function copyInvoice() {
try {
await navigator.clipboard.writeText(bolt11Invoice.value)
copyButtonText.value = 'Copied!'
setTimeout(() => {
copyButtonText.value = 'Copy Invoice'
}, 2000)
} catch {
copyButtonText.value = 'Copy failed'
}
}
function handleSuccess() {
emit('success')
closeModal()
}
function resetAndRetry() {
cleanup()
paymentState.value = 'initial'
errorMessage.value = null
invoiceData.value = null
bolt11Invoice.value = ''
qrCodeDataUrl.value = ''
}
</script>
<style scoped>

View File

@@ -4,97 +4,204 @@
<div class="modal-container">
<div class="modal-content">
<!-- Close Button -->
<button @click="closeModal" class="absolute top-4 right-4 text-white/60 hover:text-white transition-colors">
<button @click="closeModal" class="absolute top-4 right-4 text-white/60 hover:text-white transition-colors z-10">
<svg class="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
<!-- Header -->
<div class="text-center mb-8">
<h2 class="text-3xl font-bold text-white mb-2">Choose Your Plan</h2>
<p class="text-white/60">Unlimited streaming. Cancel anytime.</p>
</div>
<!-- Period Toggle -->
<div class="flex justify-center mb-8">
<div class="inline-flex rounded-xl bg-white/5 p-1">
<button
@click="period = 'monthly'"
:class="[
'px-6 py-2 rounded-lg font-medium transition-all',
period === 'monthly'
? 'bg-white text-black'
: 'text-white/60 hover:text-white'
]"
>
Monthly
</button>
<button
@click="period = 'annual'"
:class="[
'px-6 py-2 rounded-lg font-medium transition-all flex items-center gap-2',
period === 'annual'
? 'bg-white text-black'
: 'text-white/60 hover:text-white'
]"
>
Annual
<span class="text-xs bg-orange-500 text-white px-2 py-0.5 rounded-full">Save 17%</span>
</button>
<!-- PLAN SELECTION STATE -->
<template v-if="paymentState === 'select'">
<!-- Header -->
<div class="text-center mb-8">
<h2 class="text-3xl font-bold text-white mb-2">Choose Your Plan</h2>
<p class="text-white/60">Unlimited streaming. Pay with Lightning.</p>
</div>
</div>
<!-- Error Message -->
<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">
{{ errorMessage }}
</div>
<!-- Subscription Tiers -->
<div class="grid md:grid-cols-3 gap-4 mb-6">
<div
v-for="tier in tiers"
:key="tier.tier"
:class="[
'tier-card',
selectedTier === tier.tier && 'selected'
]"
@click="selectedTier = tier.tier"
>
<h3 class="text-xl font-bold text-white mb-2">{{ tier.name }}</h3>
<div class="mb-4">
<span class="text-3xl font-bold text-white">
${{ period === 'monthly' ? tier.monthlyPrice : tier.annualPrice }}
</span>
<span class="text-white/60 text-sm">
/{{ period === 'monthly' ? 'month' : 'year' }}
</span>
<!-- Period Toggle -->
<div class="flex justify-center mb-8">
<div class="inline-flex rounded-xl bg-white/5 p-1">
<button
@click="period = 'monthly'"
:class="[
'px-6 py-2 rounded-lg font-medium transition-all',
period === 'monthly'
? 'bg-white text-black'
: 'text-white/60 hover:text-white'
]"
>
Monthly
</button>
<button
@click="period = 'annual'"
:class="[
'px-6 py-2 rounded-lg font-medium transition-all flex items-center gap-2',
period === 'annual'
? 'bg-white text-black'
: 'text-white/60 hover:text-white'
]"
>
Annual
<span class="text-xs bg-orange-500 text-white px-2 py-0.5 rounded-full">Save 17%</span>
</button>
</div>
<ul class="space-y-2 text-sm text-white/80">
<li v-for="feature in tier.features" :key="feature" class="flex items-start gap-2">
<svg class="w-5 h-5 text-green-400 flex-shrink-0" fill="currentColor" viewBox="0 0 20 20">
<path fill-rule="evenodd" d="M10 18a8 8 0 100-16 8 8 0 000 16zm3.707-9.293a1 1 0 00-1.414-1.414L9 10.586 7.707 9.293a1 1 0 00-1.414 1.414l2 2a1 1 0 001.414 0l4-4z" clip-rule="evenodd" />
</svg>
{{ feature }}
</li>
</ul>
</div>
</div>
<!-- Subscribe Button -->
<button
@click="handleSubscribe"
:disabled="isLoading || !selectedTier"
class="hero-play-button w-full flex items-center justify-center"
>
<span v-if="!isLoading">Subscribe to {{ selectedTierName }}</span>
<span v-else>Processing...</span>
</button>
<!-- Error Message -->
<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">
{{ errorMessage }}
</div>
<!-- Subscription Tiers -->
<div class="grid md:grid-cols-3 gap-4 mb-6">
<div
v-for="tier in tiers"
:key="tier.tier"
:class="[
'tier-card',
selectedTier === tier.tier && 'selected'
]"
@click="selectedTier = tier.tier"
>
<h3 class="text-xl font-bold text-white mb-2">{{ tier.name }}</h3>
<div class="mb-4">
<span class="text-3xl font-bold text-white">
${{ period === 'monthly' ? tier.monthlyPrice : tier.annualPrice }}
</span>
<span class="text-white/60 text-sm">
/{{ period === 'monthly' ? 'month' : 'year' }}
</span>
</div>
<ul class="space-y-2 text-sm text-white/80">
<li v-for="feature in tier.features" :key="feature" class="flex items-start gap-2">
<svg class="w-5 h-5 text-green-400 flex-shrink-0" fill="currentColor" viewBox="0 0 20 20">
<path fill-rule="evenodd" d="M10 18a8 8 0 100-16 8 8 0 000 16zm3.707-9.293a1 1 0 00-1.414-1.414L9 10.586 7.707 9.293a1 1 0 00-1.414 1.414l2 2a1 1 0 001.414 0l4-4z" clip-rule="evenodd" />
</svg>
{{ feature }}
</li>
</ul>
</div>
</div>
<!-- Subscribe Button -->
<button
@click="handleSubscribe"
:disabled="isLoading || !selectedTier"
class="hero-play-button w-full flex items-center justify-center"
>
<svg v-if="!isLoading" class="w-5 h-5 mr-2" viewBox="0 0 24 24" fill="currentColor">
<path d="M13 10h-3V7H7v3H4v3h3v3h3v-3h3v-3zm8-6a2 2 0 00-2-2H5a2 2 0 00-2 2v14a2 2 0 002 2h14a2 2 0 002-2V4zm-2 0l.01 14H5V4h14z"/>
</svg>
<span v-if="!isLoading">Pay with Lightning ${{ selectedPrice }}</span>
<span v-else>Creating invoice...</span>
</button>
<p class="text-center text-xs text-white/40 mt-4">
Pay once per period. Renew manually when your plan expires.
</p>
</template>
<!-- INVOICE STATE: QR Code + BOLT11 -->
<template v-if="paymentState === 'invoice'">
<div class="text-center">
<h2 class="text-2xl font-bold text-white mb-2">Pay with Lightning</h2>
<p class="text-white/60 text-sm mb-1">
{{ selectedTierName }} {{ period === 'monthly' ? 'Monthly' : 'Annual' }}
</p>
<p class="text-white/40 text-xs mb-6">
Scan the QR code or copy the invoice to pay
</p>
<!-- QR Code -->
<div class="bg-white rounded-2xl p-4 inline-block mb-6">
<img v-if="qrCodeDataUrl" :src="qrCodeDataUrl" alt="Lightning Invoice QR Code" class="w-64 h-64" />
<div v-else class="w-64 h-64 flex items-center justify-center text-gray-400">
Generating QR...
</div>
</div>
<!-- Amount -->
<div class="mb-4">
<div class="text-lg font-bold text-white">
{{ formatSats(invoiceData?.sourceAmount?.amount) }} sats
</div>
<div class="text-sm text-white/60"> ${{ selectedPrice }} USD</div>
</div>
<!-- Expiration Countdown -->
<div class="mb-6 flex items-center justify-center gap-2 text-sm" :class="countdownSeconds <= 60 ? 'text-red-400' : 'text-white/60'">
<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="M12 8v4l3 3m6-3a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
<span>Expires in {{ formatCountdown(countdownSeconds) }}</span>
</div>
<!-- Copy BOLT11 Button -->
<button
@click="copyInvoice"
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>
{{ copyButtonText }}
</button>
<!-- Open in wallet -->
<a
:href="'lightning:' + bolt11Invoice"
class="block text-center text-orange-500 hover:text-orange-400 text-sm font-medium transition-colors mb-4"
>
Open in wallet app
</a>
<p class="text-xs text-white/40">
Waiting for payment confirmation...
</p>
</div>
</template>
<!-- SUCCESS STATE -->
<template v-if="paymentState === 'success'">
<div class="text-center py-8">
<div class="w-20 h-20 bg-green-500/20 rounded-full flex items-center justify-center mx-auto mb-6">
<svg class="w-10 h-10 text-green-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 13l4 4L19 7" />
</svg>
</div>
<h2 class="text-2xl font-bold text-white mb-2">Subscription Active!</h2>
<p class="text-white/60 mb-6">
Welcome to {{ selectedTierName }}. Enjoy unlimited streaming!
</p>
<button
@click="handleSuccess"
class="hero-play-button w-full flex items-center justify-center"
>
Start Watching
</button>
</div>
</template>
<!-- EXPIRED STATE -->
<template v-if="paymentState === 'expired'">
<div class="text-center py-8">
<div class="w-20 h-20 bg-yellow-500/20 rounded-full flex items-center justify-center mx-auto mb-6">
<svg class="w-10 h-10 text-yellow-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 8v4l3 3m6-3a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
</div>
<h2 class="text-2xl font-bold text-white mb-2">Invoice Expired</h2>
<p class="text-white/60 mb-6">The payment window has closed. Please try again.</p>
<button
@click="resetAndRetry"
class="hero-play-button w-full flex items-center justify-center"
>
Try Again
</button>
</div>
</template>
<p class="text-center text-xs text-white/40 mt-4">
By subscribing, you agree to our Terms of Service and Privacy Policy.
</p>
</div>
</div>
</div>
@@ -102,8 +209,10 @@
</template>
<script setup lang="ts">
import { ref, computed, onMounted } from 'vue'
import { ref, computed, watch, onMounted, onUnmounted } from 'vue'
import QRCode from 'qrcode'
import { subscriptionService } from '../services/subscription.service'
import { USE_MOCK } from '../utils/mock'
interface Props {
isOpen: boolean
@@ -117,24 +226,114 @@ interface Emits {
const props = defineProps<Props>()
const emit = defineEmits<Emits>()
type PaymentState = 'select' | 'invoice' | 'success' | 'expired'
const paymentState = ref<PaymentState>('select')
const period = ref<'monthly' | 'annual'>('monthly')
const selectedTier = ref<string>('film-buff')
const tiers = ref<any[]>([])
const isLoading = ref(false)
const errorMessage = ref<string | null>(null)
// Invoice data
const invoiceData = ref<any>(null)
const bolt11Invoice = ref('')
const qrCodeDataUrl = ref('')
const copyButtonText = ref('Copy Invoice')
// Countdown
const countdownSeconds = ref(0)
let countdownInterval: ReturnType<typeof setInterval> | null = null
let pollInterval: ReturnType<typeof setInterval> | null = null
// USE_MOCK imported from utils/mock
const selectedTierName = computed(() => {
const tier = tiers.value.find((t) => t.tier === selectedTier.value)
return tier?.name || ''
})
const selectedPrice = computed(() => {
const tier = tiers.value.find((t) => t.tier === selectedTier.value)
if (!tier) return '0'
return period.value === 'monthly' ? tier.monthlyPrice : tier.annualPrice
})
onMounted(async () => {
tiers.value = await subscriptionService.getSubscriptionTiers()
})
onUnmounted(cleanup)
watch(() => props.isOpen, (newVal) => {
if (!newVal) {
cleanup()
paymentState.value = 'select'
errorMessage.value = null
}
})
function cleanup() {
if (countdownInterval) {
clearInterval(countdownInterval)
countdownInterval = null
}
if (pollInterval) {
clearInterval(pollInterval)
pollInterval = null
}
}
function closeModal() {
emit('close')
cleanup()
paymentState.value = 'select'
errorMessage.value = null
invoiceData.value = null
bolt11Invoice.value = ''
qrCodeDataUrl.value = ''
copyButtonText.value = 'Copy Invoice'
emit('close')
}
function formatSats(amount: string | undefined): string {
if (!amount) return '---'
const btcAmount = parseFloat(amount)
if (isNaN(btcAmount)) return amount
const sats = Math.round(btcAmount * 100_000_000)
return sats.toLocaleString()
}
function formatCountdown(seconds: number): string {
const mins = Math.floor(seconds / 60)
const secs = seconds % 60
return `${mins}:${secs.toString().padStart(2, '0')}`
}
async function generateQRCode(bolt11: string) {
try {
qrCodeDataUrl.value = await QRCode.toDataURL(bolt11.toUpperCase(), {
width: 256,
margin: 2,
color: { dark: '#000000', light: '#FFFFFF' },
errorCorrectionLevel: 'M',
})
} catch (err) {
console.error('QR Code generation failed:', err)
}
}
function startCountdown(expirationDate: Date | string) {
const expiry = new Date(expirationDate).getTime()
const updateCountdown = () => {
const remaining = Math.max(0, Math.floor((expiry - Date.now()) / 1000))
countdownSeconds.value = remaining
if (remaining <= 0) {
cleanup()
paymentState.value = 'expired'
}
}
updateCountdown()
countdownInterval = setInterval(updateCountdown, 1000)
}
async function handleSubscribe() {
@@ -144,34 +343,87 @@ async function handleSubscribe() {
errorMessage.value = null
try {
// Check if we're in development mode
const useMockData = import.meta.env.VITE_USE_MOCK_DATA === 'true' || import.meta.env.DEV
if (useMockData) {
// Mock subscription for development
console.log('🔧 Development mode: Mock subscription successful')
console.log(`📝 Subscribed to: ${selectedTierName.value} (${period.value})`)
await new Promise(resolve => setTimeout(resolve, 500))
emit('success')
closeModal()
if (USE_MOCK) {
console.log('Development mode: Simulating Lightning subscription invoice')
console.log(`Subscribed to: ${selectedTierName.value} (${period.value})`)
await new Promise(resolve => setTimeout(resolve, 800))
const mockBolt11 = 'lnbc200u1pjxyz123456789abcdefghijklmnopqrstuvwxyz0123456789abcdefghijklmnop'
bolt11Invoice.value = mockBolt11
invoiceData.value = {
sourceAmount: { amount: '0.00020000', currency: 'BTC' },
expiration: new Date(Date.now() + 15 * 60 * 1000).toISOString(),
}
await generateQRCode(mockBolt11)
paymentState.value = 'invoice'
startCountdown(invoiceData.value.expiration)
// Simulate payment after 5 seconds in mock mode
setTimeout(() => {
if (paymentState.value === 'invoice') {
cleanup()
paymentState.value = 'success'
}
}, 5000)
return
}
// Real API call
await subscriptionService.subscribe({
tier: selectedTier.value as any,
// Real API call — create Lightning subscription invoice
const result = await subscriptionService.createLightningSubscription({
type: selectedTier.value as any,
period: period.value,
})
emit('success')
closeModal()
invoiceData.value = result
bolt11Invoice.value = result.lnInvoice
await generateQRCode(result.lnInvoice)
paymentState.value = 'invoice'
startCountdown(result.expiration)
// Poll for payment confirmation
pollInterval = setInterval(async () => {
try {
const active = await subscriptionService.getActiveSubscription()
if (active) {
cleanup()
paymentState.value = 'success'
}
} catch {
// Silently retry
}
}, 3000)
} catch (error: any) {
errorMessage.value = error.message || 'Subscription failed. Please try again.'
errorMessage.value = error.message || 'Failed to create invoice. Please try again.'
} finally {
isLoading.value = false
}
}
async function copyInvoice() {
try {
await navigator.clipboard.writeText(bolt11Invoice.value)
copyButtonText.value = 'Copied!'
setTimeout(() => { copyButtonText.value = 'Copy Invoice' }, 2000)
} catch {
copyButtonText.value = 'Copy failed'
}
}
function handleSuccess() {
emit('success')
closeModal()
}
function resetAndRetry() {
cleanup()
paymentState.value = 'select'
errorMessage.value = null
invoiceData.value = null
bolt11Invoice.value = ''
qrCodeDataUrl.value = ''
}
</script>
<style scoped>

View File

@@ -0,0 +1,159 @@
<template>
<div class="space-y-8">
<!-- Main Film File -->
<div>
<h3 class="text-lg font-semibold text-white mb-1">Main File</h3>
<p class="text-white/40 text-sm mb-4">Upload the main video file for your project. Supported formats: MP4, MOV, MKV.</p>
<UploadZone
label="Drag & drop your video file, or click to browse"
accept="video/*"
:current-file="uploads.file.fileName || (project as any)?.file"
:status="uploads.file.status"
:progress="uploads.file.progress"
:file-name="uploads.file.fileName"
@file-selected="(f: File) => handleFileUpload('file', f)"
/>
</div>
<!-- Poster -->
<div>
<h3 class="text-lg font-semibold text-white mb-1">Poster</h3>
<p class="text-white/40 text-sm mb-4">Poster image used as the thumbnail across the platform. 2:3 aspect ratio recommended.</p>
<UploadZone
label="Upload poster image"
accept="image/*"
:preview="uploads.poster.previewUrl || project?.poster"
:status="uploads.poster.status"
:progress="uploads.poster.progress"
:file-name="uploads.poster.fileName"
@file-selected="(f: File) => handleFileUpload('poster', f)"
/>
</div>
<!-- Trailer -->
<div>
<h3 class="text-lg font-semibold text-white mb-1">Trailer</h3>
<p class="text-white/40 text-sm mb-4">Optional trailer to showcase your project.</p>
<UploadZone
label="Upload trailer video"
accept="video/*"
:current-file="uploads.trailer.fileName || project?.trailer"
:status="uploads.trailer.status"
:progress="uploads.trailer.progress"
:file-name="uploads.trailer.fileName"
@file-selected="(f: File) => handleFileUpload('trailer', f)"
/>
</div>
<!-- Subtitles -->
<div>
<h3 class="text-lg font-semibold text-white mb-1">Subtitles</h3>
<p class="text-white/40 text-sm mb-4">Upload subtitle files (.srt, .vtt) for accessibility.</p>
<UploadZone
label="Upload subtitle file"
accept=".srt,.vtt"
:current-file="uploads.subtitles.fileName"
:status="uploads.subtitles.status"
:progress="uploads.subtitles.progress"
:file-name="uploads.subtitles.fileName"
@file-selected="(f: File) => handleFileUpload('subtitles', f)"
/>
</div>
</div>
</template>
<script setup lang="ts">
import { reactive } from 'vue'
import type { ApiProject } from '../../types/api'
import UploadZone from './UploadZone.vue'
import { USE_MOCK } from '../../utils/mock'
defineProps<{
project: ApiProject | null
}>()
const emit = defineEmits<{
(e: 'update', field: string, value: any): void
}>()
interface UploadState {
status: 'idle' | 'uploading' | 'completed' | 'error'
progress: number
fileName: string
previewUrl: string
}
const uploads = reactive<Record<string, UploadState>>({
file: { status: 'idle', progress: 0, fileName: '', previewUrl: '' },
poster: { status: 'idle', progress: 0, fileName: '', previewUrl: '' },
trailer: { status: 'idle', progress: 0, fileName: '', previewUrl: '' },
subtitles: { status: 'idle', progress: 0, fileName: '', previewUrl: '' },
})
/**
* Simulate upload progress for development mode
* Mimics realistic chunked upload behavior with variable speed
*/
function simulateMockUpload(field: string, file: File): Promise<void> {
return new Promise((resolve) => {
const state = uploads[field]
state.status = 'uploading'
state.progress = 0
state.fileName = file.name
// Simulate faster for small files, slower for large
const sizeMB = file.size / (1024 * 1024)
const totalDuration = Math.min(800 + sizeMB * 20, 4000) // 0.8s to 4s
const steps = 20
const interval = totalDuration / steps
let step = 0
const timer = setInterval(() => {
step++
// Simulate non-linear progress (fast start, slow middle, fast end)
const rawProgress = step / steps
const easedProgress = rawProgress < 0.5
? 2 * rawProgress * rawProgress
: 1 - Math.pow(-2 * rawProgress + 2, 2) / 2
state.progress = Math.round(easedProgress * 100)
if (step >= steps) {
clearInterval(timer)
state.progress = 100
state.status = 'completed'
resolve()
}
}, interval)
})
}
async function handleFileUpload(field: string, file: File) {
const state = uploads[field]
// Generate a preview URL for images
if (file.type.startsWith('image/')) {
state.previewUrl = URL.createObjectURL(file)
}
if (USE_MOCK) {
// Mock mode: simulate upload with progress
await simulateMockUpload(field, file)
// Emit the blob preview URL for images so the poster is displayable,
// or the filename for non-image files (video, subtitles)
const value = state.previewUrl || file.name
emit('update', field, value)
} else {
// Real mode: trigger actual upload (handled by parent/service)
state.status = 'uploading'
state.fileName = file.name
state.progress = 0
try {
// Emit to parent, which would handle the real upload
emit('update', field, file)
} catch {
state.status = 'error'
}
}
}
</script>

View File

@@ -0,0 +1,160 @@
<template>
<div>
<div class="flex items-center justify-between mb-6">
<div>
<h3 class="text-lg font-semibold text-white">Cast & Crew</h3>
<p class="text-white/40 text-sm mt-1">Add the people involved in your project.</p>
</div>
<button @click="showAddModal = true" class="add-button flex items-center gap-2">
<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="M12 4v16m8-8H4" />
</svg>
Add Member
</button>
</div>
<!-- Cast list -->
<div v-if="members.length > 0" class="space-y-3">
<div v-for="member in members" :key="member.id" class="member-row">
<div class="w-10 h-10 rounded-full bg-white/10 flex items-center justify-center text-white/40 text-sm font-bold flex-shrink-0">
{{ member.name[0]?.toUpperCase() }}
</div>
<div class="flex-1 min-w-0">
<p class="text-white font-medium text-sm truncate">{{ member.name }}</p>
<p class="text-white/40 text-xs capitalize">{{ member.role }} · {{ member.type }}</p>
</div>
<button @click="removeMember(member.id)" class="text-white/20 hover:text-red-400 transition-colors">
<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="M6 18L18 6M6 6l12 12" />
</svg>
</button>
</div>
</div>
<!-- Empty state -->
<div v-else class="text-center py-12">
<svg class="w-12 h-12 mx-auto text-white/15 mb-3" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" d="M17 20h5v-2a3 3 0 00-5.356-1.857M17 20H7m10 0v-2c0-.656-.126-1.283-.356-1.857M7 20H2v-2a3 3 0 015.356-1.857M7 20v-2c0-.656.126-1.283.356-1.857m0 0a5.002 5.002 0 019.288 0M15 7a3 3 0 11-6 0 3 3 0 016 0z" />
</svg>
<p class="text-white/40 text-sm">No cast or crew added yet</p>
</div>
<!-- Add member modal -->
<Transition name="modal">
<div v-if="showAddModal" class="fixed inset-0 z-[100] flex items-center justify-center p-4" @click.self="showAddModal = false">
<div class="fixed inset-0 bg-black/60 backdrop-blur-sm" @click="showAddModal = false"></div>
<div class="modal-card relative z-10 w-full max-w-sm p-6">
<h3 class="text-xl font-bold text-white mb-4">Add Member</h3>
<div class="space-y-4">
<div>
<label class="field-label">Name</label>
<input v-model="newMember.name" class="field-input" placeholder="Full name" />
</div>
<div>
<label class="field-label">Role</label>
<input v-model="newMember.role" class="field-input" placeholder="e.g. Director, Actor" />
</div>
<div>
<label class="field-label">Type</label>
<div class="flex gap-2">
<button
@click="newMember.type = 'cast'"
:class="newMember.type === 'cast' ? 'type-btn-active' : 'type-btn'"
>Cast</button>
<button
@click="newMember.type = 'crew'"
:class="newMember.type === 'crew' ? 'type-btn-active' : 'type-btn'"
>Crew</button>
</div>
</div>
</div>
<div class="flex gap-3 mt-6">
<button @click="showAddModal = false" class="flex-1 cancel-button">Cancel</button>
<button @click="addMember" :disabled="!newMember.name" class="flex-1 create-button disabled:opacity-40">Add</button>
</div>
</div>
</div>
</Transition>
</div>
</template>
<script setup lang="ts">
import { ref, reactive } from 'vue'
import type { ApiProject, ApiCastMember } from '../../types/api'
defineProps<{
project: ApiProject | null
}>()
const showAddModal = ref(false)
const members = ref<ApiCastMember[]>([])
const newMember = reactive({ name: '', role: '', type: 'cast' as 'cast' | 'crew' })
function addMember() {
if (!newMember.name) return
members.value.push({
id: Date.now().toString(),
name: newMember.name,
role: newMember.role,
type: newMember.type,
})
newMember.name = ''
newMember.role = ''
newMember.type = 'cast'
showAddModal.value = false
}
function removeMember(id: string) {
members.value = members.value.filter((m) => m.id !== id)
}
</script>
<style scoped>
.member-row {
display: flex;
align-items: center;
gap: 12px;
padding: 12px 14px;
border-radius: 12px;
background: rgba(255, 255, 255, 0.04);
border: 1px solid rgba(255, 255, 255, 0.06);
}
.add-button {
padding: 8px 16px;
font-size: 13px;
font-weight: 600;
border-radius: 10px;
background: rgba(255, 255, 255, 0.08);
color: rgba(255, 255, 255, 0.8);
border: 1px solid rgba(255, 255, 255, 0.1);
cursor: pointer;
transition: all 0.2s ease;
}
.add-button:hover { background: rgba(255, 255, 255, 0.12); color: white; }
.modal-card {
background: rgba(20, 20, 20, 0.95);
backdrop-filter: blur(40px);
border-radius: 20px;
border: 1px solid rgba(255, 255, 255, 0.1);
box-shadow: 0 24px 80px rgba(0, 0, 0, 0.6), inset 0 1px 0 rgba(255, 255, 255, 0.08);
}
.field-label { display: block; color: rgba(255, 255, 255, 0.6); font-size: 14px; font-weight: 500; margin-bottom: 6px; }
.field-input { width: 100%; padding: 10px 14px; border-radius: 12px; border: 1px solid rgba(255, 255, 255, 0.1); background: rgba(255, 255, 255, 0.06); color: white; font-size: 14px; outline: none; }
.field-input::placeholder { color: rgba(255, 255, 255, 0.25); }
.field-input:focus { border-color: rgba(255, 255, 255, 0.2); background: rgba(255, 255, 255, 0.08); }
.type-btn { padding: 6px 14px; font-size: 13px; border-radius: 10px; background: rgba(255, 255, 255, 0.04); color: rgba(255, 255, 255, 0.5); border: 1px solid rgba(255, 255, 255, 0.08); cursor: pointer; transition: all 0.2s; }
.type-btn:hover { color: rgba(255, 255, 255, 0.8); background: rgba(255, 255, 255, 0.08); }
.type-btn-active { padding: 6px 14px; font-size: 13px; border-radius: 10px; background: rgba(247, 147, 26, 0.15); color: #F7931A; border: 1px solid rgba(247, 147, 26, 0.3); cursor: pointer; }
.cancel-button { padding: 10px 24px; font-size: 14px; border-radius: 14px; background: rgba(255, 255, 255, 0.08); color: rgba(255, 255, 255, 0.7); border: 1px solid rgba(255, 255, 255, 0.1); cursor: pointer; }
.create-button { padding: 10px 24px; font-size: 14px; font-weight: 600; border-radius: 14px; background: rgba(255, 255, 255, 0.85); color: rgba(0, 0, 0, 0.9); border: none; cursor: pointer; }
.modal-enter-active { transition: all 0.3s ease-out; }
.modal-leave-active { transition: all 0.2s ease-in; }
.modal-enter-from, .modal-leave-to { opacity: 0; }
</style>

View File

@@ -0,0 +1,118 @@
<template>
<div>
<div class="flex items-center justify-between mb-6">
<div>
<h3 class="text-lg font-semibold text-white">Episodes & Seasons</h3>
<p class="text-white/40 text-sm mt-1">Manage episodes and seasons for your episodic project.</p>
</div>
<button @click="showAddEpisode = true" class="add-button flex items-center gap-2">
<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="M12 4v16m8-8H4" />
</svg>
Add Episode
</button>
</div>
<!-- Empty state -->
<div v-if="!(project as any)?.contents?.length" class="text-center py-12">
<svg class="w-12 h-12 mx-auto text-white/15 mb-3" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" d="M19 11H5m14 0a2 2 0 012 2v6a2 2 0 01-2 2H5a2 2 0 01-2-2v-6a2 2 0 012-2m14 0V9a2 2 0 00-2-2M5 11V9a2 2 0 012-2m0 0V5a2 2 0 012-2h6a2 2 0 012 2v2M7 7h10" />
</svg>
<p class="text-white/40 text-sm">No episodes added yet</p>
</div>
<!-- Episode list placeholder -->
<div v-else class="space-y-3">
<div
v-for="(episode, idx) in (project as any)?.contents || []"
:key="episode.id || idx"
class="episode-row"
>
<span class="text-white/30 text-sm font-mono w-8">{{ String(Number(idx) + 1).padStart(2, '0') }}</span>
<span class="text-white font-medium flex-1 truncate">{{ episode.title || `Episode ${Number(idx) + 1}` }}</span>
<span class="text-white/30 text-xs capitalize">{{ episode.status || 'draft' }}</span>
</div>
</div>
<!-- Add episode modal placeholder -->
<Transition name="modal">
<div v-if="showAddEpisode" class="fixed inset-0 z-[100] flex items-center justify-center p-4" @click.self="showAddEpisode = false">
<div class="fixed inset-0 bg-black/60 backdrop-blur-sm" @click="showAddEpisode = false"></div>
<div class="modal-card relative z-10 w-full max-w-md p-6">
<h3 class="text-xl font-bold text-white mb-4">Add Episode</h3>
<p class="text-white/50 text-sm mb-6">Episode management will use the content upload pipeline. This feature connects to the backend API for content creation.</p>
<button @click="showAddEpisode = false" class="cancel-button w-full">Close</button>
</div>
</div>
</Transition>
</div>
</template>
<script setup lang="ts">
import { ref } from 'vue'
import type { ApiProject } from '../../types/api'
defineProps<{
project: ApiProject | null
}>()
const showAddEpisode = ref(false)
</script>
<style scoped>
.add-button {
padding: 8px 16px;
font-size: 13px;
font-weight: 600;
border-radius: 10px;
background: rgba(255, 255, 255, 0.08);
color: rgba(255, 255, 255, 0.8);
border: 1px solid rgba(255, 255, 255, 0.1);
cursor: pointer;
transition: all 0.2s ease;
}
.add-button:hover {
background: rgba(255, 255, 255, 0.12);
color: white;
}
.episode-row {
display: flex;
align-items: center;
gap: 12px;
padding: 14px 16px;
border-radius: 12px;
background: rgba(255, 255, 255, 0.04);
border: 1px solid rgba(255, 255, 255, 0.06);
transition: background 0.2s ease;
}
.episode-row:hover {
background: rgba(255, 255, 255, 0.07);
}
.modal-card {
background: rgba(20, 20, 20, 0.95);
backdrop-filter: blur(40px);
border-radius: 20px;
border: 1px solid rgba(255, 255, 255, 0.1);
box-shadow: 0 24px 80px rgba(0, 0, 0, 0.6), inset 0 1px 0 rgba(255, 255, 255, 0.08);
}
.cancel-button {
padding: 10px 24px;
font-size: 14px;
font-weight: 500;
border-radius: 14px;
background: rgba(255, 255, 255, 0.08);
color: rgba(255, 255, 255, 0.7);
border: 1px solid rgba(255, 255, 255, 0.1);
cursor: pointer;
transition: all 0.2s ease;
}
.modal-enter-active { transition: all 0.3s ease-out; }
.modal-leave-active { transition: all 0.2s ease-in; }
.modal-enter-from, .modal-leave-to { opacity: 0; }
</style>

View File

@@ -0,0 +1,136 @@
<template>
<div>
<div class="flex items-center justify-between mb-6">
<div>
<h3 class="text-lg font-semibold text-white">Coupons</h3>
<p class="text-white/40 text-sm mt-1">Create discount codes for your project.</p>
</div>
<button @click="showCreateModal = true" class="add-button flex items-center gap-2">
<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="M12 4v16m8-8H4" />
</svg>
Create Coupon
</button>
</div>
<!-- Coupon list -->
<div v-if="coupons.length > 0" class="space-y-3">
<div v-for="coupon in coupons" :key="coupon.id" class="coupon-row">
<div class="flex-1 min-w-0">
<p class="text-white font-mono font-bold text-sm">{{ coupon.code }}</p>
<p class="text-white/40 text-xs mt-0.5">
{{ coupon.discountType === 'percentage' ? `${coupon.discountValue}% off` : `${coupon.discountValue} sats off` }}
· Used {{ coupon.usedCount }}/{{ coupon.usageLimit }}
</p>
</div>
<button @click="removeCoupon(coupon.id)" class="text-white/20 hover:text-red-400 transition-colors">
<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="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16" />
</svg>
</button>
</div>
</div>
<!-- Empty state -->
<div v-else class="text-center py-12">
<svg class="w-12 h-12 mx-auto text-white/15 mb-3" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" d="M15 5v2m0 4v2m0 4v2M5 5a2 2 0 00-2 2v3a2 2 0 110 4v3a2 2 0 002 2h14a2 2 0 002-2v-3a2 2 0 110-4V7a2 2 0 00-2-2H5z" />
</svg>
<p class="text-white/40 text-sm">No coupons created yet</p>
</div>
<!-- Create coupon modal -->
<Transition name="modal">
<div v-if="showCreateModal" class="fixed inset-0 z-[100] flex items-center justify-center p-4" @click.self="showCreateModal = false">
<div class="fixed inset-0 bg-black/60 backdrop-blur-sm" @click="showCreateModal = false"></div>
<div class="modal-card relative z-10 w-full max-w-sm p-6">
<h3 class="text-xl font-bold text-white mb-4">Create Coupon</h3>
<div class="space-y-4">
<div>
<label class="field-label">Code</label>
<input v-model="newCoupon.code" class="field-input font-mono" placeholder="SUMMER2026" />
</div>
<div class="grid grid-cols-2 gap-3">
<div>
<label class="field-label">Discount</label>
<input v-model.number="newCoupon.discountValue" type="number" class="field-input" placeholder="20" min="1" />
</div>
<div>
<label class="field-label">Type</label>
<select v-model="newCoupon.discountType" class="field-select">
<option value="percentage">Percentage</option>
<option value="fixed">Fixed (sats)</option>
</select>
</div>
</div>
<div>
<label class="field-label">Usage Limit</label>
<input v-model.number="newCoupon.usageLimit" type="number" class="field-input" placeholder="100" min="1" />
</div>
</div>
<div class="flex gap-3 mt-6">
<button @click="showCreateModal = false" class="flex-1 cancel-button">Cancel</button>
<button @click="handleCreate" :disabled="!newCoupon.code" class="flex-1 create-button disabled:opacity-40">Create</button>
</div>
</div>
</div>
</Transition>
</div>
</template>
<script setup lang="ts">
import { ref, reactive } from 'vue'
import type { ApiProject, ApiCoupon } from '../../types/api'
defineProps<{
project: ApiProject | null
}>()
const showCreateModal = ref(false)
const coupons = ref<ApiCoupon[]>([])
const newCoupon = reactive({
code: '',
discountType: 'percentage' as 'percentage' | 'fixed',
discountValue: 20,
usageLimit: 100,
})
function handleCreate() {
if (!newCoupon.code) return
coupons.value.push({
id: Date.now().toString(),
code: newCoupon.code.toUpperCase(),
projectId: '',
discountType: newCoupon.discountType,
discountValue: newCoupon.discountValue,
usageLimit: newCoupon.usageLimit,
usedCount: 0,
createdAt: new Date().toISOString(),
})
newCoupon.code = ''
showCreateModal.value = false
}
function removeCoupon(id: string) {
coupons.value = coupons.value.filter((c) => c.id !== id)
}
</script>
<style scoped>
.coupon-row { display: flex; align-items: center; gap: 12px; padding: 14px 16px; border-radius: 12px; background: rgba(255, 255, 255, 0.04); border: 1px solid rgba(255, 255, 255, 0.06); }
.add-button { padding: 8px 16px; font-size: 13px; font-weight: 600; border-radius: 10px; background: rgba(255, 255, 255, 0.08); color: rgba(255, 255, 255, 0.8); border: 1px solid rgba(255, 255, 255, 0.1); cursor: pointer; transition: all 0.2s; }
.add-button:hover { background: rgba(255, 255, 255, 0.12); color: white; }
.modal-card { background: rgba(20, 20, 20, 0.95); backdrop-filter: blur(40px); border-radius: 20px; border: 1px solid rgba(255, 255, 255, 0.1); box-shadow: 0 24px 80px rgba(0, 0, 0, 0.6), inset 0 1px 0 rgba(255, 255, 255, 0.08); }
.field-label { display: block; color: rgba(255, 255, 255, 0.6); font-size: 14px; font-weight: 500; margin-bottom: 6px; }
.field-input { width: 100%; padding: 10px 14px; border-radius: 12px; border: 1px solid rgba(255, 255, 255, 0.1); background: rgba(255, 255, 255, 0.06); color: white; font-size: 14px; outline: none; }
.field-input::placeholder { color: rgba(255, 255, 255, 0.25); }
.field-input:focus { border-color: rgba(255, 255, 255, 0.2); background: rgba(255, 255, 255, 0.08); }
.field-select { width: 100%; padding: 10px 14px; border-radius: 12px; border: 1px solid rgba(255, 255, 255, 0.1); background: rgba(255, 255, 255, 0.06); color: rgba(255, 255, 255, 0.8); font-size: 14px; outline: none; cursor: pointer; }
.field-select option { background: #1a1a1a; color: white; }
.cancel-button { padding: 10px 24px; font-size: 14px; border-radius: 14px; background: rgba(255, 255, 255, 0.08); color: rgba(255, 255, 255, 0.7); border: 1px solid rgba(255, 255, 255, 0.1); cursor: pointer; }
.create-button { padding: 10px 24px; font-size: 14px; font-weight: 600; border-radius: 14px; background: rgba(255, 255, 255, 0.85); color: rgba(0, 0, 0, 0.9); border: none; cursor: pointer; }
.modal-enter-active { transition: all 0.3s ease-out; }
.modal-leave-active { transition: all 0.2s ease-in; }
.modal-enter-from, .modal-leave-to { opacity: 0; }
</style>

View File

@@ -0,0 +1,216 @@
<template>
<div class="space-y-6">
<!-- Title -->
<div>
<label class="field-label">Title</label>
<input
:value="project?.title"
@input="emit('update', 'title', ($event.target as HTMLInputElement).value)"
class="field-input"
placeholder="Give your project a title"
/>
</div>
<!-- Synopsis -->
<div>
<label class="field-label">Synopsis</label>
<textarea
:value="project?.synopsis"
@input="emit('update', 'synopsis', ($event.target as HTMLTextAreaElement).value)"
class="field-input min-h-[120px] resize-y"
placeholder="Write a compelling synopsis..."
rows="4"
></textarea>
</div>
<!-- Slug -->
<div>
<label class="field-label">URL Slug</label>
<div class="flex items-center gap-2">
<span class="text-white/30 text-sm">indeedhub.com/watch/</span>
<input
:value="project?.slug"
@input="emit('update', 'slug', ($event.target as HTMLInputElement).value)"
class="field-input flex-1"
placeholder="my-awesome-film"
/>
</div>
</div>
<!-- Two columns: Category + Format -->
<div class="grid grid-cols-1 sm:grid-cols-2 gap-6">
<div>
<label class="field-label">Category</label>
<select
:value="project?.category"
@change="emit('update', 'category', ($event.target as HTMLSelectElement).value)"
class="field-select"
>
<option value="">Select category</option>
<option value="narrative">Narrative</option>
<option value="documentary">Documentary</option>
<option value="experimental">Experimental</option>
<option value="animation">Animation</option>
</select>
</div>
<div>
<label class="field-label">Format</label>
<select
:value="project?.format"
@change="emit('update', 'format', ($event.target as HTMLSelectElement).value)"
class="field-select"
>
<option value="">Select format</option>
<option value="feature">Feature</option>
<option value="short">Short</option>
<option value="medium">Medium Length</option>
</select>
</div>
</div>
<!-- Genres -->
<div>
<label class="field-label">Genres</label>
<div class="flex flex-wrap gap-2">
<button
v-for="genre in genres"
:key="genre.id"
@click="toggleGenre(genre.slug)"
:class="selectedGenres.includes(genre.slug) ? 'genre-tag-active' : 'genre-tag'"
>
{{ genre.name }}
</button>
</div>
<p v-if="genres.length === 0" class="text-white/30 text-sm mt-2">Loading genres...</p>
</div>
<!-- Release Date -->
<div>
<label class="field-label">Release Date</label>
<input
type="date"
:value="project?.releaseDate?.split('T')[0]"
@input="emit('update', 'releaseDate', ($event.target as HTMLInputElement).value)"
class="field-input"
/>
</div>
</div>
</template>
<script setup lang="ts">
import { ref, watch } from 'vue'
import type { ApiProject, ApiGenre } from '../../types/api'
const props = defineProps<{
project: ApiProject | null
genres: ApiGenre[]
}>()
const emit = defineEmits<{
(e: 'update', field: string, value: any): void
}>()
const selectedGenres = ref<string[]>([])
// Sync genres from project
watch(
() => props.project?.genres,
(genres) => {
if (genres) {
selectedGenres.value = genres.map((g) => g.slug)
}
},
{ immediate: true }
)
function toggleGenre(slug: string) {
const idx = selectedGenres.value.indexOf(slug)
if (idx === -1) {
selectedGenres.value.push(slug)
} else {
selectedGenres.value.splice(idx, 1)
}
emit('update', 'genres', [...selectedGenres.value])
}
</script>
<style scoped>
.field-label {
display: block;
color: rgba(255, 255, 255, 0.6);
font-size: 14px;
font-weight: 500;
margin-bottom: 8px;
}
.field-input {
width: 100%;
padding: 10px 14px;
border-radius: 12px;
border: 1px solid rgba(255, 255, 255, 0.1);
background: rgba(255, 255, 255, 0.06);
color: rgba(255, 255, 255, 0.95);
font-size: 14px;
outline: none;
transition: all 0.2s ease;
}
.field-input::placeholder {
color: rgba(255, 255, 255, 0.25);
}
.field-input:focus {
border-color: rgba(255, 255, 255, 0.2);
background: rgba(255, 255, 255, 0.08);
box-shadow: 0 0 0 3px rgba(255, 255, 255, 0.04);
}
.field-select {
width: 100%;
padding: 10px 14px;
border-radius: 12px;
border: 1px solid rgba(255, 255, 255, 0.1);
background: rgba(255, 255, 255, 0.06);
color: rgba(255, 255, 255, 0.8);
font-size: 14px;
outline: none;
appearance: none;
cursor: pointer;
transition: all 0.2s ease;
}
.field-select option {
background: #1a1a1a;
color: white;
}
.genre-tag {
padding: 6px 14px;
font-size: 13px;
font-weight: 500;
border-radius: 10px;
background: rgba(255, 255, 255, 0.04);
color: rgba(255, 255, 255, 0.5);
border: 1px solid rgba(255, 255, 255, 0.08);
cursor: pointer;
transition: all 0.2s ease;
}
.genre-tag:hover {
color: rgba(255, 255, 255, 0.8);
background: rgba(255, 255, 255, 0.08);
}
.genre-tag-active {
padding: 6px 14px;
font-size: 13px;
font-weight: 600;
border-radius: 10px;
background: rgba(247, 147, 26, 0.15);
color: #F7931A;
border: 1px solid rgba(247, 147, 26, 0.3);
cursor: pointer;
transition: all 0.2s ease;
}
</style>

View File

@@ -0,0 +1,106 @@
<template>
<div>
<h3 class="text-lg font-semibold text-white mb-1">Project Documentation</h3>
<p class="text-white/40 text-sm mb-6">Upload legal documents, contracts, and press kits related to your project.</p>
<!-- Upload zone -->
<div
class="upload-zone"
@dragover.prevent
@drop.prevent="handleDrop"
@click="triggerInput"
>
<div class="flex flex-col items-center gap-3 py-8">
<svg class="w-10 h-10 text-white/20" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z" />
</svg>
<span class="text-white/40 text-sm text-center px-4">Drop files here or click to upload documents</span>
<span class="text-white/20 text-xs">PDF, DOC, DOCX, TXT</span>
</div>
<input ref="fileInput" type="file" class="hidden" accept=".pdf,.doc,.docx,.txt" multiple @change="handleInput" />
</div>
<!-- Documents list -->
<div v-if="documents.length > 0" class="mt-4 space-y-2">
<div v-for="doc in documents" :key="doc.name" class="doc-row">
<svg class="w-4 h-4 text-white/30 flex-shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z" />
</svg>
<span class="text-white/70 text-sm flex-1 truncate">{{ doc.name }}</span>
<span class="text-white/30 text-xs">{{ formatSize(doc.size) }}</span>
<button @click="removeDoc(doc.name)" class="text-white/20 hover:text-red-400 transition-colors ml-2">
<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="M6 18L18 6M6 6l12 12" />
</svg>
</button>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { ref } from 'vue'
import type { ApiProject } from '../../types/api'
defineProps<{
project: ApiProject | null
}>()
const fileInput = ref<HTMLInputElement | null>(null)
const documents = ref<Array<{ name: string; size: number }>>([])
function triggerInput() {
fileInput.value?.click()
}
function handleInput(event: Event) {
const target = event.target as HTMLInputElement
if (target.files) {
Array.from(target.files).forEach((f) => {
documents.value.push({ name: f.name, size: f.size })
})
}
}
function handleDrop(event: DragEvent) {
if (event.dataTransfer?.files) {
Array.from(event.dataTransfer.files).forEach((f) => {
documents.value.push({ name: f.name, size: f.size })
})
}
}
function removeDoc(name: string) {
documents.value = documents.value.filter((d) => d.name !== name)
}
function formatSize(bytes: number): string {
if (bytes < 1024) return bytes + ' B'
if (bytes < 1024 * 1024) return (bytes / 1024).toFixed(1) + ' KB'
return (bytes / (1024 * 1024)).toFixed(1) + ' MB'
}
</script>
<style scoped>
.upload-zone {
border: 2px dashed rgba(255, 255, 255, 0.1);
border-radius: 14px;
cursor: pointer;
transition: all 0.2s ease;
}
.upload-zone:hover {
border-color: rgba(255, 255, 255, 0.2);
background: rgba(255, 255, 255, 0.03);
}
.doc-row {
display: flex;
align-items: center;
gap: 10px;
padding: 10px 14px;
border-radius: 10px;
background: rgba(255, 255, 255, 0.04);
border: 1px solid rgba(255, 255, 255, 0.06);
}
</style>

View File

@@ -0,0 +1,101 @@
<template>
<div>
<div class="flex items-center justify-between mb-6">
<div>
<h3 class="text-lg font-semibold text-white">Team Permissions</h3>
<p class="text-white/40 text-sm mt-1">Manage who can access and edit this project.</p>
</div>
<button @click="showInviteModal = true" class="add-button flex items-center gap-2">
<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="M18 9v3m0 0v3m0-3h3m-3 0h-3m-2-5a4 4 0 11-8 0 4 4 0 018 0zM3 20a6 6 0 0112 0v1H3v-1z" />
</svg>
Invite
</button>
</div>
<!-- Permissions list -->
<div class="space-y-3">
<div class="member-row">
<div class="w-10 h-10 rounded-full bg-[#F7931A]/20 flex items-center justify-center text-[#F7931A] text-sm font-bold flex-shrink-0">
O
</div>
<div class="flex-1 min-w-0">
<p class="text-white font-medium text-sm">You</p>
<p class="text-white/40 text-xs">Owner</p>
</div>
<span class="role-badge">Owner</span>
</div>
</div>
<p class="text-white/30 text-xs mt-6">
Team collaboration with multiple roles (admin, editor, viewer, revenue-manager) is managed through the backend API.
</p>
<!-- Invite modal -->
<Transition name="modal">
<div v-if="showInviteModal" class="fixed inset-0 z-[100] flex items-center justify-center p-4" @click.self="showInviteModal = false">
<div class="fixed inset-0 bg-black/60 backdrop-blur-sm" @click="showInviteModal = false"></div>
<div class="modal-card relative z-10 w-full max-w-sm p-6">
<h3 class="text-xl font-bold text-white mb-4">Invite Team Member</h3>
<div class="space-y-4">
<div>
<label class="field-label">Email</label>
<input v-model="inviteEmail" class="field-input" placeholder="team@example.com" type="email" />
</div>
<div>
<label class="field-label">Role</label>
<select v-model="inviteRole" class="field-select">
<option value="editor">Editor</option>
<option value="viewer">Viewer</option>
<option value="revenue-manager">Revenue Manager</option>
<option value="admin">Admin</option>
</select>
</div>
</div>
<div class="flex gap-3 mt-6">
<button @click="showInviteModal = false" class="flex-1 cancel-button">Cancel</button>
<button @click="handleInvite" :disabled="!inviteEmail" class="flex-1 create-button disabled:opacity-40">Send Invite</button>
</div>
</div>
</div>
</Transition>
</div>
</template>
<script setup lang="ts">
import { ref } from 'vue'
import type { ApiProject } from '../../types/api'
defineProps<{
project: ApiProject | null
}>()
const showInviteModal = ref(false)
const inviteEmail = ref('')
const inviteRole = ref('editor')
function handleInvite() {
console.log('Invite:', inviteEmail.value, inviteRole.value)
showInviteModal.value = false
inviteEmail.value = ''
}
</script>
<style scoped>
.member-row { display: flex; align-items: center; gap: 12px; padding: 14px 16px; border-radius: 12px; background: rgba(255, 255, 255, 0.04); border: 1px solid rgba(255, 255, 255, 0.06); }
.add-button { padding: 8px 16px; font-size: 13px; font-weight: 600; border-radius: 10px; background: rgba(255, 255, 255, 0.08); color: rgba(255, 255, 255, 0.8); border: 1px solid rgba(255, 255, 255, 0.1); cursor: pointer; transition: all 0.2s; }
.add-button:hover { background: rgba(255, 255, 255, 0.12); color: white; }
.role-badge { padding: 4px 10px; font-size: 11px; font-weight: 600; border-radius: 8px; background: rgba(247, 147, 26, 0.15); color: #F7931A; text-transform: uppercase; letter-spacing: 0.05em; }
.modal-card { background: rgba(20, 20, 20, 0.95); backdrop-filter: blur(40px); border-radius: 20px; border: 1px solid rgba(255, 255, 255, 0.1); box-shadow: 0 24px 80px rgba(0, 0, 0, 0.6), inset 0 1px 0 rgba(255, 255, 255, 0.08); }
.field-label { display: block; color: rgba(255, 255, 255, 0.6); font-size: 14px; font-weight: 500; margin-bottom: 6px; }
.field-input { width: 100%; padding: 10px 14px; border-radius: 12px; border: 1px solid rgba(255, 255, 255, 0.1); background: rgba(255, 255, 255, 0.06); color: white; font-size: 14px; outline: none; }
.field-input::placeholder { color: rgba(255, 255, 255, 0.25); }
.field-input:focus { border-color: rgba(255, 255, 255, 0.2); background: rgba(255, 255, 255, 0.08); }
.field-select { width: 100%; padding: 10px 14px; border-radius: 12px; border: 1px solid rgba(255, 255, 255, 0.1); background: rgba(255, 255, 255, 0.06); color: rgba(255, 255, 255, 0.8); font-size: 14px; outline: none; appearance: none; cursor: pointer; }
.field-select option { background: #1a1a1a; color: white; }
.cancel-button { padding: 10px 24px; font-size: 14px; border-radius: 14px; background: rgba(255, 255, 255, 0.08); color: rgba(255, 255, 255, 0.7); border: 1px solid rgba(255, 255, 255, 0.1); cursor: pointer; }
.create-button { padding: 10px 24px; font-size: 14px; font-weight: 600; border-radius: 14px; background: rgba(255, 255, 255, 0.85); color: rgba(0, 0, 0, 0.9); border: none; cursor: pointer; }
.modal-enter-active { transition: all 0.3s ease-out; }
.modal-leave-active { transition: all 0.2s ease-in; }
.modal-enter-from, .modal-leave-to { opacity: 0; }
</style>

View File

@@ -0,0 +1,140 @@
<template>
<div class="space-y-8">
<!-- Rental Price -->
<div>
<h3 class="text-lg font-semibold text-white mb-1">Rental Price</h3>
<p class="text-white/40 text-sm mb-4">Set the price for renting your project. Prices are in satoshis.</p>
<div class="flex items-center gap-3">
<div class="relative flex-1 max-w-xs">
<input
type="number"
:value="project?.rentalPrice"
@input="emit('update', 'rentalPrice', Number(($event.target as HTMLInputElement).value))"
class="field-input pr-12"
placeholder="0"
min="0"
/>
<span class="absolute right-3 top-1/2 -translate-y-1/2 text-white/30 text-sm">sats</span>
</div>
</div>
</div>
<!-- Delivery Mode -->
<div>
<h3 class="text-lg font-semibold text-white mb-1">Delivery Mode</h3>
<p class="text-white/40 text-sm mb-4">Choose how your content is delivered to viewers.</p>
<div class="flex gap-3">
<button
@click="emit('update', 'deliveryMode', 'native')"
:class="((project as any)?.deliveryMode || 'native') === 'native' ? 'mode-active' : 'mode-inactive'"
>
<svg class="w-5 h-5 mb-1" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 12h14M5 12a2 2 0 01-2-2V6a2 2 0 012-2h14a2 2 0 012 2v4a2 2 0 01-2 2M5 12a2 2 0 00-2 2v4a2 2 0 002 2h14a2 2 0 002-2v-4a2 2 0 00-2-2" />
</svg>
<span class="text-sm font-medium">Self-Hosted</span>
<span class="text-xs opacity-60">AES-128 HLS</span>
</button>
<button
@click="emit('update', 'deliveryMode', 'partner')"
:class="(project as any)?.deliveryMode === 'partner' ? 'mode-active' : 'mode-inactive'"
>
<svg class="w-5 h-5 mb-1" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M3 15a4 4 0 004 4h9a5 5 0 10-.1-9.999 5.002 5.002 0 10-9.78 2.096A4.001 4.001 0 003 15z" />
</svg>
<span class="text-sm font-medium">Partner CDN</span>
<span class="text-xs opacity-60">DRM Protected</span>
</button>
</div>
</div>
<!-- Revenue Split (placeholder) -->
<div>
<h3 class="text-lg font-semibold text-white mb-1">Revenue Split</h3>
<p class="text-white/40 text-sm mb-4">Define how revenue is shared among stakeholders.</p>
<div class="split-row">
<div class="w-10 h-10 rounded-full bg-[#F7931A]/20 flex items-center justify-center text-[#F7931A] text-sm font-bold flex-shrink-0">
You
</div>
<span class="text-white font-medium flex-1">Owner</span>
<span class="text-white/70 text-sm font-mono">100%</span>
</div>
<p class="text-white/30 text-xs mt-3">Revenue split management with multiple stakeholders is available through the backend API.</p>
</div>
</div>
</template>
<script setup lang="ts">
import type { ApiProject } from '../../types/api'
defineProps<{
project: ApiProject | null
}>()
const emit = defineEmits<{
(e: 'update', field: string, value: any): void
}>()
</script>
<style scoped>
.field-input {
width: 100%;
padding: 10px 14px;
border-radius: 12px;
border: 1px solid rgba(255, 255, 255, 0.1);
background: rgba(255, 255, 255, 0.06);
color: rgba(255, 255, 255, 0.95);
font-size: 14px;
outline: none;
transition: all 0.2s ease;
}
.field-input::placeholder { color: rgba(255, 255, 255, 0.25); }
.field-input:focus { border-color: rgba(255, 255, 255, 0.2); background: rgba(255, 255, 255, 0.08); }
.mode-inactive {
display: flex;
flex-direction: column;
align-items: center;
gap: 4px;
padding: 16px 24px;
border-radius: 14px;
background: rgba(255, 255, 255, 0.04);
color: rgba(255, 255, 255, 0.5);
border: 1px solid rgba(255, 255, 255, 0.08);
cursor: pointer;
transition: all 0.2s ease;
min-width: 140px;
}
.mode-inactive:hover {
color: rgba(255, 255, 255, 0.8);
background: rgba(255, 255, 255, 0.08);
}
.mode-active {
display: flex;
flex-direction: column;
align-items: center;
gap: 4px;
padding: 16px 24px;
border-radius: 14px;
background: rgba(247, 147, 26, 0.12);
color: #F7931A;
border: 1px solid rgba(247, 147, 26, 0.3);
cursor: pointer;
transition: all 0.2s ease;
min-width: 140px;
}
.split-row {
display: flex;
align-items: center;
gap: 12px;
padding: 14px 16px;
border-radius: 12px;
background: rgba(255, 255, 255, 0.04);
border: 1px solid rgba(255, 255, 255, 0.06);
}
</style>

View File

@@ -0,0 +1,188 @@
<template>
<div
class="upload-zone"
:class="{
'upload-zone-active': isDragging,
'upload-zone-has-content': preview || currentFile || status === 'completed',
'upload-zone-uploading': status === 'uploading',
'upload-zone-error': status === 'error',
}"
@dragover.prevent="isDragging = true"
@dragleave.prevent="isDragging = false"
@drop.prevent="handleDrop"
@click="triggerInput"
>
<!-- Uploading state -->
<div v-if="status === 'uploading'" class="flex flex-col items-center gap-3 py-8 px-4">
<div class="upload-spinner"></div>
<div class="w-full max-w-xs">
<div class="flex justify-between text-xs mb-1.5">
<span class="text-white/60 truncate max-w-[200px]">{{ fileName }}</span>
<span class="text-white/40 font-mono">{{ progress }}%</span>
</div>
<div class="h-1.5 rounded-full bg-white/10 overflow-hidden">
<div
class="h-full rounded-full bg-[#F7931A] transition-all duration-300 ease-out"
:style="{ width: `${progress}%` }"
></div>
</div>
</div>
<span class="text-white/30 text-xs">Uploading...</span>
</div>
<!-- Completed / success state -->
<div v-else-if="status === 'completed' || (currentFile && status !== 'error')" class="flex flex-col items-center gap-2 py-6 px-4">
<svg class="w-8 h-8 text-green-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 13l4 4L19 7" />
</svg>
<span class="text-white/70 text-sm text-center truncate max-w-full px-2">{{ fileName || currentFile }}</span>
<span class="text-white/30 text-xs">Click to replace</span>
</div>
<!-- Error state -->
<div v-else-if="status === 'error'" class="flex flex-col items-center gap-2 py-6 px-4">
<svg class="w-8 h-8 text-red-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 8v4m0 4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
<span class="text-red-400/70 text-sm">Upload failed</span>
<span class="text-white/30 text-xs">Click to retry</span>
</div>
<!-- Preview image -->
<div v-else-if="preview" class="upload-preview">
<img :src="preview" alt="Preview" class="w-full h-full object-cover rounded-xl" />
<div class="upload-preview-overlay">
<span class="text-white text-sm font-medium">Click to replace</span>
</div>
</div>
<!-- Upload prompt (idle) -->
<div v-else class="flex flex-col items-center gap-3 py-8">
<svg class="w-10 h-10 text-white/20" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" d="M7 16a4 4 0 01-.88-7.903A5 5 0 1115.9 6L16 6a5 5 0 011 9.9M15 13l-3-3m0 0l-3 3m3-3v12" />
</svg>
<span class="text-white/40 text-sm text-center px-4">{{ label }}</span>
</div>
<input
ref="fileInput"
type="file"
:accept="accept"
class="hidden"
@change="handleInput"
/>
</div>
</template>
<script setup lang="ts">
import { ref } from 'vue'
defineProps<{
label?: string
accept?: string
preview?: string
currentFile?: string
status?: 'idle' | 'uploading' | 'completed' | 'error'
progress?: number
fileName?: string
}>()
const emit = defineEmits<{
(e: 'file-selected', file: File): void
}>()
const fileInput = ref<HTMLInputElement | null>(null)
const isDragging = ref(false)
function triggerInput() {
fileInput.value?.click()
}
function handleInput(event: Event) {
const target = event.target as HTMLInputElement
const file = target.files?.[0]
if (file) emit('file-selected', file)
if (target) target.value = ''
}
function handleDrop(event: DragEvent) {
isDragging.value = false
const file = event.dataTransfer?.files?.[0]
if (file) emit('file-selected', file)
}
</script>
<style scoped>
.upload-zone {
position: relative;
border: 2px dashed rgba(255, 255, 255, 0.1);
border-radius: 14px;
cursor: pointer;
transition: all 0.2s ease;
overflow: hidden;
}
.upload-zone:hover {
border-color: rgba(255, 255, 255, 0.2);
background: rgba(255, 255, 255, 0.03);
}
.upload-zone-active {
border-color: rgba(247, 147, 26, 0.4);
background: rgba(247, 147, 26, 0.05);
}
.upload-zone-uploading {
border-color: rgba(247, 147, 26, 0.25);
border-style: solid;
background: rgba(247, 147, 26, 0.03);
cursor: default;
}
.upload-zone-has-content {
border-style: solid;
border-color: rgba(255, 255, 255, 0.08);
}
.upload-zone-error {
border-color: rgba(239, 68, 68, 0.3);
border-style: dashed;
background: rgba(239, 68, 68, 0.03);
}
.upload-preview {
position: relative;
aspect-ratio: 2 / 3;
max-height: 240px;
}
.upload-preview-overlay {
position: absolute;
inset: 0;
background: rgba(0, 0, 0, 0.5);
display: flex;
align-items: center;
justify-content: center;
opacity: 0;
transition: opacity 0.2s ease;
border-radius: 12px;
}
.upload-zone:hover .upload-preview-overlay {
opacity: 1;
}
/* Spinner animation */
.upload-spinner {
width: 32px;
height: 32px;
border: 3px solid rgba(255, 255, 255, 0.1);
border-top-color: #F7931A;
border-radius: 50%;
animation: spin 0.8s linear infinite;
}
@keyframes spin {
to { transform: rotate(360deg); }
}
</style>

View File

@@ -2,6 +2,7 @@ import { computed } from 'vue'
import { libraryService } from '../services/library.service'
import { subscriptionService } from '../services/subscription.service'
import { useAuthStore } from '../stores/auth'
import { USE_MOCK } from '../utils/mock'
/**
* Access Control Composable
@@ -27,10 +28,7 @@ export function useAccess() {
return { hasAccess: true, method: 'subscription' }
}
// Check if we're in development mode
const useMockData = import.meta.env.VITE_USE_MOCK_DATA === 'true' || import.meta.env.DEV
if (useMockData) {
if (USE_MOCK) {
// In dev mode without subscription, no access (prompt rental)
return { hasAccess: false }
}

View File

@@ -131,14 +131,18 @@ export function useAccounts() {
}
/**
* Logout current account
* Logout current account.
* removeAccount already clears the active account if it's the one being removed.
*/
function logout() {
const current = accountManager.active
if (current) {
accountManager.removeAccount(current)
try {
accountManager.removeAccount(current)
} catch (err) {
console.debug('Account removal cleanup:', err)
}
}
accountManager.setActive(null as any)
}
/**

View File

@@ -0,0 +1,340 @@
/**
* useFilmmaker composable
*
* Reactive state and actions for the filmmaker/creator dashboard.
* In dev mode without a backend, uses local mock data so the
* backstage UI is fully functional for prototyping.
*/
import { ref, computed } from 'vue'
import { filmmakerService } from '../services/filmmaker.service'
import type {
ApiProject,
ApiFilmmakerAnalytics,
ApiWatchAnalytics,
ApiPaymentMethod,
ApiPayment,
ApiGenre,
CreateProjectData,
UpdateProjectData,
ProjectType,
} from '../types/api'
import { USE_MOCK } from '../utils/mock'
// ── Shared reactive state (singleton across components) ─────────────────────────
const projects = ref<ApiProject[]>([])
const projectsCount = ref(0)
const isLoading = ref(false)
const error = ref<string | null>(null)
const genres = ref<ApiGenre[]>([])
// Analytics state
const analytics = ref<ApiFilmmakerAnalytics | null>(null)
const watchAnalytics = ref<ApiWatchAnalytics | null>(null)
// Payment state
const paymentMethods = ref<ApiPaymentMethod[]>([])
const payments = ref<ApiPayment[]>([])
// Filters
const activeTab = ref<'projects' | 'resume' | 'stakeholder'>('projects')
const typeFilter = ref<ProjectType | null>(null)
const sortBy = ref<'a-z' | 'z-a' | 'recent'>('recent')
const searchQuery = ref('')
// ── Mock data helpers ────────────────────────────────────────────────────────────
let mockIdCounter = 1
function createMockProject(data: CreateProjectData): ApiProject {
const id = `mock-project-${mockIdCounter++}`
const now = new Date().toISOString()
return {
id,
name: data.name,
title: data.name,
type: data.type,
status: 'draft',
slug: data.name.toLowerCase().replace(/\s+/g, '-'),
createdAt: now,
updatedAt: now,
} as ApiProject
}
const MOCK_GENRES: ApiGenre[] = [
{ id: '1', name: 'Documentary', slug: 'documentary' },
{ id: '2', name: 'Drama', slug: 'drama' },
{ id: '3', name: 'Thriller', slug: 'thriller' },
{ id: '4', name: 'Comedy', slug: 'comedy' },
{ id: '5', name: 'Sci-Fi', slug: 'sci-fi' },
{ id: '6', name: 'Animation', slug: 'animation' },
{ id: '7', name: 'Horror', slug: 'horror' },
{ id: '8', name: 'Action', slug: 'action' },
{ id: '9', name: 'Romance', slug: 'romance' },
{ id: '10', name: 'Music', slug: 'music' },
]
// ── Composable ───────────────────────────────────────────────────────────────────
export function useFilmmaker() {
/**
* Sorted and filtered projects based on active filters
*/
const filteredProjects = computed(() => {
let result = [...projects.value]
// Filter by type
if (typeFilter.value) {
result = result.filter((p) => p.type === typeFilter.value)
}
// Filter by search
if (searchQuery.value.trim()) {
const q = searchQuery.value.toLowerCase()
result = result.filter(
(p) =>
p.name?.toLowerCase().includes(q) ||
p.title?.toLowerCase().includes(q)
)
}
// Sort
if (sortBy.value === 'a-z') {
result.sort((a, b) => (a.title || a.name || '').localeCompare(b.title || b.name || ''))
} else if (sortBy.value === 'z-a') {
result.sort((a, b) => (b.title || b.name || '').localeCompare(a.title || a.name || ''))
} else {
result.sort(
(a, b) =>
new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime()
)
}
return result
})
// ── Project Actions ─────────────────────────────────────────────────────────
async function fetchProjects() {
isLoading.value = true
error.value = null
try {
if (USE_MOCK) {
// Mock mode: projects are already in local state
projectsCount.value = projects.value.length
return
}
const [list, count] = await Promise.all([
filmmakerService.getPrivateProjects({
type: typeFilter.value || undefined,
search: searchQuery.value || undefined,
}),
filmmakerService.getPrivateProjectsCount({
type: typeFilter.value || undefined,
search: searchQuery.value || undefined,
}),
])
projects.value = list
projectsCount.value = count
} catch (err: any) {
error.value = err.message || 'Failed to load projects'
console.debug('Failed to load projects:', err)
} finally {
isLoading.value = false
}
}
async function createNewProject(data: CreateProjectData): Promise<ApiProject | null> {
isLoading.value = true
error.value = null
try {
let project: ApiProject
if (USE_MOCK) {
// Simulate brief delay
await new Promise(resolve => setTimeout(resolve, 300))
project = createMockProject(data)
} else {
project = await filmmakerService.createProject(data)
}
// Add to local list
projects.value = [project, ...projects.value]
projectsCount.value++
return project
} catch (err: any) {
error.value = err.message || 'Failed to create project'
console.error('Failed to create project:', err)
return null
} finally {
isLoading.value = false
}
}
async function saveProject(id: string, data: UpdateProjectData): Promise<ApiProject | null> {
error.value = null
try {
let updated: ApiProject
if (USE_MOCK) {
await new Promise(resolve => setTimeout(resolve, 200))
const idx = projects.value.findIndex((p) => p.id === id)
if (idx === -1) throw new Error('Project not found')
updated = { ...projects.value[idx], ...data, updatedAt: new Date().toISOString() } as ApiProject
projects.value[idx] = updated
} else {
updated = await filmmakerService.updateProject(id, data)
const idx = projects.value.findIndex((p) => p.id === id)
if (idx !== -1) projects.value[idx] = updated
}
return updated
} catch (err: any) {
error.value = err.message || 'Failed to save project'
console.error('Failed to save project:', err)
return null
}
}
async function removeProject(id: string): Promise<boolean> {
error.value = null
try {
if (USE_MOCK) {
await new Promise(resolve => setTimeout(resolve, 200))
} else {
await filmmakerService.deleteProject(id)
}
projects.value = projects.value.filter((p) => p.id !== id)
projectsCount.value--
return true
} catch (err: any) {
error.value = err.message || 'Failed to delete project'
console.error('Failed to delete project:', err)
return false
}
}
// ── Analytics Actions ───────────────────────────────────────────────────────
async function fetchAnalytics(projectIds?: string[]) {
isLoading.value = true
error.value = null
try {
if (USE_MOCK) {
analytics.value = {
balance: 125000,
totalEarnings: 450000,
myTotalEarnings: 360000,
averageSharePercentage: 80,
}
watchAnalytics.value = {
viewsByDate: {},
trailerViews: 3891,
averageWatchTime: 2340,
streamingRevenueSats: 280000,
rentalRevenueSats: 170000,
purchasesCount: 89,
purchasesByContent: [],
revenueByDate: {},
}
return
}
const [analyticData, watchData] = await Promise.all([
filmmakerService.getFilmmakerAnalytics(),
filmmakerService.getWatchAnalytics(projectIds),
])
analytics.value = analyticData
watchAnalytics.value = watchData
} catch (err: any) {
error.value = err.message || 'Failed to load analytics'
console.error('Failed to load analytics:', err)
} finally {
isLoading.value = false
}
}
// ── Payment Actions ─────────────────────────────────────────────────────────
async function fetchPaymentMethods() {
try {
if (USE_MOCK) {
paymentMethods.value = []
return
}
paymentMethods.value = await filmmakerService.getPaymentMethods()
} catch (err: any) {
console.error('Failed to load payment methods:', err)
}
}
async function fetchPayments(filmId?: string) {
try {
if (USE_MOCK) {
payments.value = []
return
}
payments.value = await filmmakerService.getPayments(filmId)
} catch (err: any) {
console.error('Failed to load payments:', err)
}
}
// ── Single Project Fetch ────────────────────────────────────────────────────
async function getProject(id: string): Promise<ApiProject | null> {
error.value = null
try {
if (USE_MOCK) {
// Look up from local projects list
const found = projects.value.find((p) => p.id === id)
return found || null
}
return await filmmakerService.getPrivateProject(id)
} catch (err: any) {
error.value = err.message || 'Failed to load project'
console.error('Failed to load project:', err)
return null
}
}
// ── Genres ──────────────────────────────────────────────────────────────────
async function fetchGenres() {
try {
if (USE_MOCK) {
genres.value = MOCK_GENRES
return
}
genres.value = await filmmakerService.getGenres()
} catch (err: any) {
console.error('Failed to load genres:', err)
}
}
return {
// State
projects,
projectsCount,
filteredProjects,
isLoading,
error,
genres,
analytics,
watchAnalytics,
paymentMethods,
payments,
// Filters
activeTab,
typeFilter,
sortBy,
searchQuery,
// Actions
fetchProjects,
createNewProject,
getProject,
saveProject,
removeProject,
fetchAnalytics,
fetchPaymentMethods,
fetchPayments,
fetchGenres,
}
}

View File

@@ -0,0 +1,186 @@
/**
* useUpload composable
*
* Chunked multipart upload with progress tracking.
* Ported from the original indeehub-frontend uploader library.
* Works with both the original API and our self-hosted MinIO backend.
*/
import { ref, computed } from 'vue'
import { filmmakerService } from '../services/filmmaker.service'
import axios from 'axios'
const CHUNK_SIZE = 20 * 1024 * 1024 // 20 MB
const MAX_PARALLEL_UPLOADS = 6
const MAX_RETRIES = 3
export interface UploadItem {
id: string
file: File
key: string
bucket: string
progress: number
status: 'pending' | 'uploading' | 'completed' | 'failed'
error?: string
}
// Shared upload queue (singleton across components)
const uploadQueue = ref<UploadItem[]>([])
const isUploading = ref(false)
export function useUpload() {
const totalProgress = computed(() => {
if (uploadQueue.value.length === 0) return 0
const total = uploadQueue.value.reduce((sum, item) => sum + item.progress, 0)
return Math.round(total / uploadQueue.value.length)
})
const activeUploads = computed(() =>
uploadQueue.value.filter((u) => u.status === 'uploading')
)
const completedUploads = computed(() =>
uploadQueue.value.filter((u) => u.status === 'completed')
)
/**
* Add a file to the upload queue and start uploading
*/
async function addUpload(file: File, key: string, bucket: string = 'indeedhub-private'): Promise<string | null> {
const item: UploadItem = {
id: `upload-${Date.now()}-${Math.random().toString(36).slice(2)}`,
file,
key,
bucket,
progress: 0,
status: 'pending',
}
uploadQueue.value.push(item)
return processUpload(item)
}
/**
* Process a single upload: initialize, chunk, upload, finalize
*/
async function processUpload(item: UploadItem): Promise<string | null> {
try {
item.status = 'uploading'
isUploading.value = true
// Step 1: Initialize multipart upload
const { UploadId, Key } = await filmmakerService.initializeUpload(
item.key,
item.bucket,
item.file.type
)
// Step 2: Calculate chunks
const totalChunks = Math.ceil(item.file.size / CHUNK_SIZE)
// Step 3: Get presigned URLs for all chunks
const { parts: presignedParts } = await filmmakerService.getPresignedUrls(
UploadId,
Key,
item.bucket,
totalChunks
)
// Step 4: Upload chunks in parallel with progress tracking
const completedParts: Array<{ PartNumber: number; ETag: string }> = []
let uploadedChunks = 0
// Process chunks in batches of MAX_PARALLEL_UPLOADS
for (let batchStart = 0; batchStart < presignedParts.length; batchStart += MAX_PARALLEL_UPLOADS) {
const batch = presignedParts.slice(batchStart, batchStart + MAX_PARALLEL_UPLOADS)
const batchResults = await Promise.all(
batch.map(async (part) => {
const start = (part.PartNumber - 1) * CHUNK_SIZE
const end = Math.min(start + CHUNK_SIZE, item.file.size)
const chunk = item.file.slice(start, end)
// Upload with retries
let lastError: Error | null = null
for (let attempt = 0; attempt < MAX_RETRIES; attempt++) {
try {
const response = await axios.put(part.signedUrl, chunk, {
headers: { 'Content-Type': item.file.type },
onUploadProgress: () => {
// Progress is tracked at the chunk level
},
})
uploadedChunks++
item.progress = Math.round((uploadedChunks / totalChunks) * 100)
const etag = response.headers.etag || response.headers.ETag
return {
PartNumber: part.PartNumber,
ETag: etag?.replace(/"/g, '') || '',
}
} catch (err: any) {
lastError = err
// Exponential backoff
await new Promise((resolve) =>
setTimeout(resolve, Math.pow(2, attempt) * 1000)
)
}
}
throw lastError || new Error(`Failed to upload part ${part.PartNumber}`)
})
)
completedParts.push(...batchResults)
}
// Step 5: Finalize
await filmmakerService.finalizeUpload(UploadId, Key, item.bucket, completedParts)
item.status = 'completed'
item.progress = 100
// Check if all uploads done
if (uploadQueue.value.every((u) => u.status !== 'uploading' && u.status !== 'pending')) {
isUploading.value = false
}
return Key
} catch (err: any) {
item.status = 'failed'
item.error = err.message || 'Upload failed'
console.error('Upload failed:', err)
if (uploadQueue.value.every((u) => u.status !== 'uploading' && u.status !== 'pending')) {
isUploading.value = false
}
return null
}
}
/**
* Remove completed or failed upload from queue
*/
function removeUpload(id: string) {
uploadQueue.value = uploadQueue.value.filter((u) => u.id !== id)
}
/**
* Clear all completed uploads
*/
function clearCompleted() {
uploadQueue.value = uploadQueue.value.filter((u) => u.status !== 'completed')
}
return {
uploadQueue,
isUploading,
totalProgress,
activeUploads,
completedUploads,
addUpload,
removeUpload,
clearCompleted,
}
}

View File

@@ -12,6 +12,14 @@ export const apiConfig = {
retryDelay: 1000,
} as const
// IndeeHub self-hosted backend API
// When deployed via Docker, the nginx proxy serves /api/ -> backend:4000
export const indeehubApiConfig = {
baseURL: import.meta.env.VITE_INDEEHUB_API_URL || '/api',
cdnURL: import.meta.env.VITE_INDEEHUB_CDN_URL || '/storage',
timeout: 30000,
} as const
export const nostrConfig = {
relays: (import.meta.env.VITE_NOSTR_RELAYS || 'ws://localhost:7777,wss://relay.damus.io').split(','),
lookupRelays: (import.meta.env.VITE_NOSTR_LOOKUP_RELAYS || 'wss://purplepag.es').split(','),

View File

@@ -4,22 +4,28 @@ import router from './router'
import App from './App.vue'
import './style.css'
import { registerSW } from 'virtual:pwa-register'
import { initMockMode } from './utils/mock'
const app = createApp(App)
// Detect backend availability before the app renders.
// If the backend is unreachable, USE_MOCK is auto-flipped to true
// so every service/store/composable falls back to mock data.
initMockMode().then(() => {
const app = createApp(App)
app.use(createPinia())
app.use(router)
app.use(createPinia())
app.use(router)
app.mount('#app')
app.mount('#app')
// Register PWA service worker with auto-update
const updateSW = registerSW({
immediate: true,
onNeedRefresh() {
// Auto-reload when new content is available
updateSW(true)
},
onOfflineReady() {
console.log('App ready to work offline')
},
// Register PWA service worker with auto-update
const updateSW = registerSW({
immediate: true,
onNeedRefresh() {
// Auto-reload when new content is available
updateSW(true)
},
onOfflineReady() {
console.log('App ready to work offline')
},
})
})

View File

@@ -1,9 +1,21 @@
import type { Router, RouteLocationNormalized, NavigationGuardNext } from 'vue-router'
import { useAuthStore } from '../stores/auth'
import { accountManager } from '../lib/accounts'
import { USE_MOCK } from '../utils/mock'
/**
* Check if user is authenticated through any method:
* - Auth store (Cognito/Nostr session)
* - Nostr account manager (persona/extension login)
*/
function isUserAuthenticated(): boolean {
const authStore = useAuthStore()
return authStore.isAuthenticated || !!accountManager.active
}
/**
* Authentication guard
* Redirects to login if not authenticated
* Redirects to home if not authenticated (auth modal can be opened there)
*/
export async function authGuard(
to: RouteLocationNormalized,
@@ -17,12 +29,12 @@ export async function authGuard(
await authStore.initialize()
}
if (authStore.isAuthenticated) {
if (isUserAuthenticated()) {
next()
} else {
// Store intended destination for redirect after login
sessionStorage.setItem('redirect_after_login', to.fullPath)
next('/login')
next('/')
}
}
@@ -35,9 +47,7 @@ export function guestGuard(
_from: RouteLocationNormalized,
next: NavigationGuardNext
) {
const authStore = useAuthStore()
if (authStore.isAuthenticated) {
if (isUserAuthenticated()) {
next('/')
} else {
next()
@@ -55,37 +65,55 @@ export function subscriptionGuard(
) {
const authStore = useAuthStore()
if (!authStore.isAuthenticated) {
if (!isUserAuthenticated()) {
sessionStorage.setItem('redirect_after_login', to.fullPath)
next('/login')
next('/')
} else if (authStore.hasActiveSubscription()) {
next()
} else {
// Redirect to subscription page
next('/subscription')
next('/')
}
}
/**
* Filmmaker guard
* Restricts access to filmmaker-only routes
* Restricts access to filmmaker-only routes.
* In development mode with mock data, allows any authenticated user
* (mock Nostr logins include filmmaker profile).
*/
export function filmmakerGuard(
export async function filmmakerGuard(
to: RouteLocationNormalized,
_from: RouteLocationNormalized,
next: NavigationGuardNext
) {
const authStore = useAuthStore()
if (!authStore.isAuthenticated) {
sessionStorage.setItem('redirect_after_login', to.fullPath)
next('/login')
} else if (authStore.isFilmmaker()) {
next()
} else {
// Redirect to home with error message
next('/')
// Initialize auth if not already done
if (!authStore.isAuthenticated && !authStore.isLoading) {
await authStore.initialize()
}
if (!isUserAuthenticated()) {
sessionStorage.setItem('redirect_after_login', to.fullPath)
next('/')
return
}
// Auth store knows about filmmaker status
if (authStore.isFilmmaker()) {
next()
return
}
// In dev/mock mode, Nostr account logins are treated as filmmakers
// since the mock login creates a filmmaker profile
if (USE_MOCK && accountManager.active) {
next()
return
}
// Not a filmmaker — redirect to home
next('/')
}
/**

View File

@@ -1,5 +1,5 @@
import { createRouter, createWebHistory } from 'vue-router'
import { setupGuards, authGuard } from './guards'
import { setupGuards, authGuard, filmmakerGuard } from './guards'
import Browse from '../views/Browse.vue'
const router = createRouter({
@@ -23,7 +23,37 @@ const router = createRouter({
component: () => import('../views/Profile.vue'),
beforeEnter: authGuard,
meta: { requiresAuth: true }
}
},
// ── Creator / Filmmaker Routes ────────────────────────────────────────────
{
path: '/backstage',
name: 'backstage',
component: () => import('../views/backstage/Backstage.vue'),
beforeEnter: filmmakerGuard,
meta: { requiresAuth: true, requiresFilmmaker: true }
},
{
path: '/backstage/project/:id',
name: 'project-editor',
component: () => import('../views/backstage/ProjectEditor.vue'),
beforeEnter: filmmakerGuard,
meta: { requiresAuth: true, requiresFilmmaker: true }
},
{
path: '/backstage/analytics',
name: 'analytics',
component: () => import('../views/backstage/Analytics.vue'),
beforeEnter: filmmakerGuard,
meta: { requiresAuth: true, requiresFilmmaker: true }
},
{
path: '/backstage/settings',
name: 'filmmaker-settings',
component: () => import('../views/backstage/Settings.vue'),
beforeEnter: filmmakerGuard,
meta: { requiresAuth: true, requiresFilmmaker: true }
},
]
})

View File

@@ -55,9 +55,9 @@ class ApiService {
return this.client(originalRequest)
}
} catch (refreshError) {
// Token refresh failed - clear auth and redirect to login
// Token refresh failed - clear auth and redirect to home
this.clearAuth()
window.location.href = '/login'
window.location.href = '/'
return Promise.reject(refreshError)
}
}
@@ -100,6 +100,7 @@ class ApiService {
public clearAuth() {
sessionStorage.removeItem('auth_token')
sessionStorage.removeItem('nostr_token')
sessionStorage.removeItem('nostr_pubkey')
sessionStorage.removeItem('refresh_token')
}
@@ -119,13 +120,13 @@ class ApiService {
throw new Error('No refresh token available')
}
// Call refresh endpoint (implement based on backend)
const response = await axios.post(`${apiConfig.baseURL}/auth/refresh`, {
// Call Nostr refresh endpoint
const response = await axios.post(`${apiConfig.baseURL}/auth/nostr/refresh`, {
refreshToken,
})
const newToken = response.data.accessToken
this.setToken(newToken, 'cognito')
this.setToken(newToken, 'nostr')
if (response.data.refreshToken) {
sessionStorage.setItem('refresh_token', response.data.refreshToken)

View File

@@ -1,4 +1,9 @@
import axios from 'axios'
import { apiService } from './api.service'
import { nip98Service } from './nip98.service'
import { apiConfig } from '../config/api.config'
import { accountManager } from '../lib/accounts'
import type { EventTemplate } from 'nostr-tools'
import type {
LoginCredentials,
RegisterData,
@@ -8,6 +13,48 @@ import type {
ApiUser,
} from '../types/api'
/**
* Create a NIP-98 HTTP Auth event (kind 27235) and return it as
* a base64-encoded Authorization header value: `Nostr <base64>`
*/
async function createNip98AuthHeader(
url: string,
method: string,
body?: string,
): Promise<string> {
const account = accountManager.active
if (!account) throw new Error('No active Nostr account')
const tags: string[][] = [
['u', url],
['method', method.toUpperCase()],
]
// If there's a body, include its SHA-256 hash
if (body && body.length > 0) {
const encoder = new TextEncoder()
const hash = await crypto.subtle.digest('SHA-256', encoder.encode(body))
const hashHex = Array.from(new Uint8Array(hash))
.map(b => b.toString(16).padStart(2, '0'))
.join('')
tags.push(['payload', hashHex])
}
const template: EventTemplate = {
kind: 27235,
created_at: Math.floor(Date.now() / 1000),
tags,
content: '',
}
// Sign with the account's signer
const signed = await account.signer.signEvent(template)
// Base64-encode the signed event JSON
const b64 = btoa(JSON.stringify(signed))
return `Nostr ${b64}`
}
/**
* Authentication Service
* Handles Cognito and Nostr authentication
@@ -68,17 +115,41 @@ class AuthService {
}
/**
* Create Nostr session
* Create Nostr session via NIP-98 HTTP Auth.
* Signs a kind-27235 event and sends it as the Authorization header.
*/
async createNostrSession(request: NostrSessionRequest): Promise<NostrSessionResponse> {
const response = await apiService.post<NostrSessionResponse>('/auth/nostr/session', request)
async createNostrSession(_request: NostrSessionRequest): Promise<NostrSessionResponse> {
const url = `${apiConfig.baseURL}/auth/nostr/session`
const method = 'POST'
// Create NIP-98 auth header — no body is sent
const authHeader = await createNip98AuthHeader(url, method)
// Send the request without a body.
// We use axios({ method }) instead of axios.post(url, data) to
// guarantee no Content-Type or body is serialized.
const response = await axios<NostrSessionResponse>({
method: 'POST',
url,
headers: { Authorization: authHeader },
timeout: apiConfig.timeout,
})
// Store Nostr token
if (response.token) {
apiService.setToken(response.token, 'nostr')
const data = response.data
// Map accessToken to token for convenience
data.token = data.accessToken
// Store Nostr JWT for subsequent authenticated requests
if (data.accessToken) {
apiService.setToken(data.accessToken, 'nostr')
sessionStorage.setItem('nostr_token', data.accessToken)
// Also populate nip98Service storage so IndeehubApiService can find the token
nip98Service.storeTokens(data.accessToken, data.refreshToken, data.expiresIn)
}
return response
return data
}
/**

View File

@@ -0,0 +1,382 @@
/**
* Filmmaker Service
*
* Source-aware API client for all filmmaker/creator endpoints.
* Routes calls to either the original API (api.service) or
* our self-hosted backend (indeehub-api.service) based on the
* active content source.
*/
import { apiService } from './api.service'
import { indeehubApiService } from './indeehub-api.service'
import { useContentSourceStore } from '../stores/contentSource'
import type {
ApiProject,
ApiContent,
ApiSeason,
ApiFilmmakerAnalytics,
ApiWatchAnalytics,
ApiPaymentMethod,
ApiPayment,
ApiGenre,
CreateProjectData,
UpdateProjectData,
UploadInitResponse,
UploadPresignedUrlsResponse,
} from '../types/api'
/**
* Check if we should route requests to our self-hosted backend
*/
function usesSelfHosted(): boolean {
const store = useContentSourceStore()
return store.activeSource === 'indeehub-api'
}
// ── Projects ────────────────────────────────────────────────────────────────────
export async function getPrivateProjects(filters?: {
type?: string
status?: string
search?: string
sort?: string
limit?: number
offset?: number
}): Promise<ApiProject[]> {
if (usesSelfHosted()) {
const params = new URLSearchParams()
if (filters?.type) params.append('type', filters.type)
if (filters?.status) params.append('status', filters.status)
if (filters?.search) params.append('search', filters.search)
if (filters?.sort) params.append('sort', filters.sort)
if (filters?.limit) params.append('limit', String(filters.limit))
if (filters?.offset) params.append('offset', String(filters.offset))
const qs = params.toString()
return indeehubApiService.get<ApiProject[]>(`/projects/private${qs ? `?${qs}` : ''}`)
}
return apiService.get<ApiProject[]>('/projects/private', { params: filters })
}
export async function getPrivateProjectsCount(filters?: {
type?: string
status?: string
search?: string
}): Promise<number> {
if (usesSelfHosted()) {
const params = new URLSearchParams()
if (filters?.type) params.append('type', filters.type)
if (filters?.status) params.append('status', filters.status)
if (filters?.search) params.append('search', filters.search)
const qs = params.toString()
const res = await indeehubApiService.get<{ count: number }>(`/projects/private/count${qs ? `?${qs}` : ''}`)
return res.count
}
const res = await apiService.get<{ count: number }>('/projects/private/count', { params: filters })
return res.count
}
export async function getPrivateProject(id: string): Promise<ApiProject> {
if (usesSelfHosted()) {
return indeehubApiService.get<ApiProject>(`/projects/private/${id}`)
}
return apiService.get<ApiProject>(`/projects/private/${id}`)
}
export async function createProject(data: CreateProjectData): Promise<ApiProject> {
if (usesSelfHosted()) {
return indeehubApiService.post<ApiProject>('/projects', data)
}
return apiService.post<ApiProject>('/projects', data)
}
export async function updateProject(id: string, data: UpdateProjectData): Promise<ApiProject> {
if (usesSelfHosted()) {
return indeehubApiService.patch<ApiProject>(`/projects/${id}`, data)
}
return apiService.patch<ApiProject>(`/projects/${id}`, data)
}
export async function deleteProject(id: string): Promise<void> {
if (usesSelfHosted()) {
await indeehubApiService.delete(`/projects/${id}`)
return
}
await apiService.delete(`/projects/${id}`)
}
export async function checkSlugExists(projectId: string, slug: string): Promise<boolean> {
if (usesSelfHosted()) {
const res = await indeehubApiService.get<{ exists: boolean }>(`/projects/${projectId}/slug/${slug}/exists`)
return res.exists
}
const res = await apiService.get<{ exists: boolean }>(`/projects/${projectId}/slug/${slug}/exists`)
return res.exists
}
// ── Content (episodes/seasons) ──────────────────────────────────────────────────
export async function getContents(projectId: string): Promise<ApiContent[]> {
if (usesSelfHosted()) {
return indeehubApiService.get<ApiContent[]>(`/contents/project/${projectId}`)
}
return apiService.get<ApiContent[]>(`/contents/project/${projectId}`)
}
export async function upsertContent(
projectId: string,
data: Partial<ApiContent>
): Promise<ApiContent> {
if (usesSelfHosted()) {
return indeehubApiService.patch<ApiContent>(`/contents/project/${projectId}`, data)
}
return apiService.patch<ApiContent>(`/contents/project/${projectId}`, data)
}
export async function getSeasons(projectId: string): Promise<ApiSeason[]> {
if (usesSelfHosted()) {
return indeehubApiService.get<ApiSeason[]>(`/seasons/project/${projectId}`)
}
return apiService.get<ApiSeason[]>(`/seasons/project/${projectId}`)
}
// ── Upload ──────────────────────────────────────────────────────────────────────
export async function initializeUpload(
key: string,
bucket: string,
contentType: string
): Promise<UploadInitResponse> {
if (usesSelfHosted()) {
return indeehubApiService.post<UploadInitResponse>('/upload/initialize', {
Key: key,
Bucket: bucket,
ContentType: contentType,
})
}
return apiService.post<UploadInitResponse>('/upload/initialize', {
Key: key,
Bucket: bucket,
ContentType: contentType,
})
}
export async function getPresignedUrls(
uploadId: string,
key: string,
bucket: string,
parts: number
): Promise<UploadPresignedUrlsResponse> {
if (usesSelfHosted()) {
return indeehubApiService.post<UploadPresignedUrlsResponse>('/upload/presigned-urls', {
UploadId: uploadId,
Key: key,
Bucket: bucket,
parts,
})
}
return apiService.post<UploadPresignedUrlsResponse>('/upload/presigned-urls', {
UploadId: uploadId,
Key: key,
Bucket: bucket,
parts,
})
}
export async function finalizeUpload(
uploadId: string,
key: string,
bucket: string,
parts: Array<{ PartNumber: number; ETag: string }>
): Promise<void> {
const payload = { UploadId: uploadId, Key: key, Bucket: bucket, parts }
if (usesSelfHosted()) {
await indeehubApiService.post('/upload/finalize', payload)
return
}
await apiService.post('/upload/finalize', payload)
}
// ── Analytics ───────────────────────────────────────────────────────────────────
export async function getFilmmakerAnalytics(): Promise<ApiFilmmakerAnalytics> {
if (usesSelfHosted()) {
return indeehubApiService.get<ApiFilmmakerAnalytics>('/filmmakers/analytics')
}
return apiService.get<ApiFilmmakerAnalytics>('/filmmakers/analytics')
}
export async function getWatchAnalytics(
projectIds?: string[],
dateRange?: { start: string; end: string }
): Promise<ApiWatchAnalytics> {
const params: Record<string, string> = {}
if (projectIds?.length) params['projectIds'] = projectIds.join(',')
if (dateRange) {
params['start'] = dateRange.start
params['end'] = dateRange.end
}
if (usesSelfHosted()) {
const qs = new URLSearchParams(params).toString()
return indeehubApiService.get<ApiWatchAnalytics>(`/filmmakers/watch-analytics${qs ? `?${qs}` : ''}`)
}
return apiService.get<ApiWatchAnalytics>('/filmmakers/watch-analytics', { params })
}
export async function getProjectRevenue(
projectIds: string[],
dateRange?: { start: string; end: string }
): Promise<Record<string, number>> {
const params: Record<string, string> = { ids: projectIds.join(',') }
if (dateRange) {
params['start'] = dateRange.start
params['end'] = dateRange.end
}
if (usesSelfHosted()) {
const qs = new URLSearchParams(params).toString()
return indeehubApiService.get<Record<string, number>>(`/projects/revenue${qs ? `?${qs}` : ''}`)
}
return apiService.get<Record<string, number>>('/projects/revenue', { params })
}
// ── Payments / Withdrawal ───────────────────────────────────────────────────────
export async function getPaymentMethods(): Promise<ApiPaymentMethod[]> {
if (usesSelfHosted()) {
return indeehubApiService.get<ApiPaymentMethod[]>('/payment-methods')
}
return apiService.get<ApiPaymentMethod[]>('/payment-methods')
}
export async function addPaymentMethod(data: {
type: 'lightning' | 'bank'
lightningAddress?: string
bankName?: string
accountNumber?: string
routingNumber?: string
withdrawalFrequency: 'manual' | 'weekly' | 'monthly'
}): Promise<ApiPaymentMethod> {
if (usesSelfHosted()) {
return indeehubApiService.post<ApiPaymentMethod>('/payment-methods', data)
}
return apiService.post<ApiPaymentMethod>('/payment-methods', data)
}
export async function updatePaymentMethod(
id: string,
data: Partial<ApiPaymentMethod>
): Promise<ApiPaymentMethod> {
if (usesSelfHosted()) {
return indeehubApiService.patch<ApiPaymentMethod>(`/payment-methods/${id}`, data)
}
return apiService.patch<ApiPaymentMethod>(`/payment-methods/${id}`, data)
}
export async function selectPaymentMethod(id: string): Promise<void> {
if (usesSelfHosted()) {
await indeehubApiService.patch(`/payment-methods/${id}/select`, {})
return
}
await apiService.patch(`/payment-methods/${id}/select`, {})
}
export async function removePaymentMethod(id: string): Promise<void> {
if (usesSelfHosted()) {
await indeehubApiService.delete(`/payment-methods/${id}`)
return
}
await apiService.delete(`/payment-methods/${id}`)
}
export async function validateLightningAddress(address: string): Promise<boolean> {
if (usesSelfHosted()) {
const res = await indeehubApiService.post<{ valid: boolean }>('/payment/validate-lightning-address', { address })
return res.valid
}
const res = await apiService.post<{ valid: boolean }>('/payment/validate-lightning-address', { address })
return res.valid
}
export async function getPayments(filmId?: string): Promise<ApiPayment[]> {
const params = filmId ? { filmId } : undefined
if (usesSelfHosted()) {
const qs = filmId ? `?filmId=${filmId}` : ''
return indeehubApiService.get<ApiPayment[]>(`/payment${qs}`)
}
return apiService.get<ApiPayment[]>('/payment', { params })
}
export async function withdraw(amount: number, filmId?: string): Promise<ApiPayment> {
const payload: Record<string, any> = { amount }
if (filmId) payload.filmId = filmId
if (usesSelfHosted()) {
return indeehubApiService.post<ApiPayment>('/payment/bank-payout', payload)
}
return apiService.post<ApiPayment>('/payment/bank-payout', payload)
}
// ── Genres ───────────────────────────────────────────────────────────────────────
export async function getGenres(): Promise<ApiGenre[]> {
if (usesSelfHosted()) {
return indeehubApiService.get<ApiGenre[]>('/genres')
}
return apiService.get<ApiGenre[]>('/genres')
}
// ── SAT Price ───────────────────────────────────────────────────────────────────
export async function getSatPrice(): Promise<number> {
if (usesSelfHosted()) {
const res = await indeehubApiService.get<{ price: number }>('/payment/sat-price')
return res.price
}
const res = await apiService.get<{ price: number }>('/payment/sat-price')
return res.price
}
/**
* Grouped export for convenience
*/
export const filmmakerService = {
// Projects
getPrivateProjects,
getPrivateProjectsCount,
getPrivateProject,
createProject,
updateProject,
deleteProject,
checkSlugExists,
// Content
getContents,
upsertContent,
getSeasons,
// Upload
initializeUpload,
getPresignedUrls,
finalizeUpload,
// Analytics
getFilmmakerAnalytics,
getWatchAnalytics,
getProjectRevenue,
// Payments
getPaymentMethods,
addPaymentMethod,
updatePaymentMethod,
selectPaymentMethod,
removePaymentMethod,
validateLightningAddress,
getPayments,
withdraw,
// Genres
getGenres,
// SAT Price
getSatPrice,
}

View File

@@ -0,0 +1,190 @@
/**
* IndeeHub Self-Hosted API Service
*
* Dedicated API client for our self-hosted NestJS backend.
* Uses the /api/ proxy configured in nginx.
* Auth tokens are managed by nip98.service.ts.
*/
import axios, { type AxiosInstance } from 'axios'
import { indeehubApiConfig } from '../config/api.config'
import { nip98Service } from './nip98.service'
class IndeehubApiService {
private client: AxiosInstance
constructor() {
this.client = axios.create({
baseURL: indeehubApiConfig.baseURL,
timeout: indeehubApiConfig.timeout,
headers: {
'Content-Type': 'application/json',
},
})
// Attach JWT token from NIP-98 session
this.client.interceptors.request.use((config) => {
const token = nip98Service.accessToken
if (token) {
config.headers.Authorization = `Bearer ${token}`
}
return config
})
// Auto-refresh on 401
this.client.interceptors.response.use(
(response) => response,
async (error) => {
const originalRequest = error.config
if (error.response?.status === 401 && !originalRequest._retry) {
originalRequest._retry = true
const newToken = await nip98Service.refresh()
if (newToken) {
originalRequest.headers.Authorization = `Bearer ${newToken}`
return this.client(originalRequest)
}
}
return Promise.reject(error)
}
)
}
/**
* Generic typed GET request
*/
async get<T>(url: string): Promise<T> {
const response = await this.client.get<T>(url)
return response.data
}
/**
* Generic typed POST request
*/
async post<T>(url: string, data?: any): Promise<T> {
const response = await this.client.post<T>(url, data)
return response.data
}
/**
* Generic typed PATCH request
*/
async patch<T>(url: string, data?: any): Promise<T> {
const response = await this.client.patch<T>(url, data)
return response.data
}
/**
* Generic typed DELETE request
*/
async delete<T = void>(url: string): Promise<T> {
const response = await this.client.delete<T>(url)
return response.data
}
/**
* Check if the API is reachable
*/
async healthCheck(): Promise<boolean> {
try {
await this.client.get('/nostr-auth/health', { timeout: 5000 })
return true
} catch {
return false
}
}
/**
* Get all published projects
*/
async getProjects(filters?: {
status?: string
type?: string
genre?: string
limit?: number
offset?: number
}): Promise<any[]> {
const params = new URLSearchParams()
if (filters?.status) params.append('status', filters.status)
if (filters?.type) params.append('type', filters.type)
if (filters?.genre) params.append('genre', filters.genre)
if (filters?.limit) params.append('limit', String(filters.limit))
if (filters?.offset) params.append('offset', String(filters.offset))
const url = `/projects${params.toString() ? `?${params.toString()}` : ''}`
const response = await this.client.get(url)
return response.data
}
/**
* Get a single project by ID
*/
async getProject(id: string): Promise<any> {
const response = await this.client.get(`/projects/${id}`)
return response.data
}
/**
* Get streaming URL for a content item
* Returns different data based on deliveryMode (native vs partner)
*/
async getStreamingUrl(contentId: string): Promise<{
url: string
deliveryMode: 'native' | 'partner'
keyUrl?: string
drmToken?: string
}> {
const response = await this.client.get(`/contents/${contentId}/stream`)
return response.data
}
/**
* Get user's library items
*/
async getLibrary(): Promise<any[]> {
const response = await this.client.get('/library')
return response.data
}
/**
* Add a project to user's library
*/
async addToLibrary(projectId: string): Promise<any> {
const response = await this.client.post('/library', { projectId })
return response.data
}
/**
* Remove a project from user's library
*/
async removeFromLibrary(projectId: string): Promise<void> {
await this.client.delete(`/library/${projectId}`)
}
/**
* Get current user profile (requires auth)
*/
async getMe(): Promise<any> {
const response = await this.client.get('/auth/me')
return response.data
}
/**
* Get genres
*/
async getGenres(): Promise<any[]> {
const response = await this.client.get('/genres')
return response.data
}
/**
* Get the CDN URL for a storage path
*/
getCdnUrl(path: string): string {
if (!path) return ''
if (path.startsWith('http')) return path
if (path.startsWith('/')) return path
return `${indeehubApiConfig.cdnURL}/${path}`
}
}
export const indeehubApiService = new IndeehubApiService()

View File

@@ -1,5 +1,6 @@
import { apiService } from './api.service'
import type { ApiRent, ApiContent } from '../types/api'
import { USE_MOCK } from '../utils/mock'
/**
* Library Service
@@ -14,10 +15,7 @@ class LibraryService {
rented: ApiRent[]
continueWatching: Array<{ content: ApiContent; progress: number }>
}> {
// Check if we're in development mode
const useMockData = import.meta.env.VITE_USE_MOCK_DATA === 'true' || import.meta.env.DEV
if (useMockData) {
if (USE_MOCK) {
// Mock library data for development
console.log('🔧 Development mode: Using mock library data')
@@ -81,15 +79,31 @@ class LibraryService {
}
/**
* Rent content
* Rent content via Lightning invoice.
* Calls POST /rents/lightning which creates a BTCPay invoice.
* Returns the invoice details including BOLT11 for QR code display.
*/
async rentContent(contentId: string, paymentMethodId?: string): Promise<ApiRent> {
return apiService.post<ApiRent>('/rents', {
contentId,
paymentMethodId,
async rentContent(contentId: string, couponCode?: string): Promise<{
id: string
contentId: string
lnInvoice: string
expiration: string
sourceAmount: { amount: string; currency: string }
conversionRate: { amount: string; sourceCurrency: string; targetCurrency: string }
}> {
return apiService.post('/rents/lightning', {
id: contentId,
couponCode,
})
}
/**
* Check if a rent exists for a given content ID (for polling after payment).
*/
async checkRentExists(contentId: string): Promise<{ exists: boolean }> {
return apiService.get(`/rents/content/${contentId}/exists`)
}
/**
* Check if user has access to content
*/

View File

@@ -0,0 +1,172 @@
/**
* NIP-98 Authentication Bridge
*
* Handles Nostr-based authentication with the self-hosted backend:
* 1. Signs NIP-98 HTTP Auth events (kind 27235)
* 2. Exchanges them for JWT session tokens
* 3. Manages token storage and auto-refresh
*/
import axios from 'axios'
import { indeehubApiConfig } from '../config/api.config'
const TOKEN_KEY = 'indeehub_api_token'
const REFRESH_KEY = 'indeehub_api_refresh'
const EXPIRES_KEY = 'indeehub_api_expires'
class Nip98Service {
private refreshPromise: Promise<string> | null = null
/**
* Check if we have a valid (non-expired) API token
*/
get hasValidToken(): boolean {
const token = sessionStorage.getItem(TOKEN_KEY)
const expires = sessionStorage.getItem(EXPIRES_KEY)
if (!token || !expires) return false
return Date.now() < Number(expires)
}
/**
* Get the current access token
*/
get accessToken(): string | null {
if (!this.hasValidToken) return null
return sessionStorage.getItem(TOKEN_KEY)
}
/**
* Create a session with the backend using a NIP-98 auth event.
*
* The signer creates a kind 27235 event targeting POST /auth/nostr/session.
* The backend verifies the event and returns JWT tokens.
*
* @param signer - An applesauce signer instance (or any object with sign() method)
* @param pubkey - The Nostr pubkey (hex)
*/
async createSession(signer: any, pubkey: string): Promise<boolean> {
try {
const url = `${indeehubApiConfig.baseURL}/auth/nostr/session`
const now = Math.floor(Date.now() / 1000)
// Build the NIP-98 event
const event = {
kind: 27235,
created_at: now,
tags: [
['u', url],
['method', 'POST'],
],
content: '',
pubkey,
}
// Sign the event using the Nostr signer
let signedEvent: any
if (typeof signer.sign === 'function') {
signedEvent = await signer.sign(event)
} else if (typeof signer.signEvent === 'function') {
signedEvent = await signer.signEvent(event)
} else {
throw new Error('Signer does not have a sign or signEvent method')
}
// Base64-encode the signed event
const encodedEvent = btoa(JSON.stringify(signedEvent))
// Send to backend — no body to avoid NIP-98 payload mismatch
const response = await axios({
method: 'POST',
url,
headers: {
Authorization: `Nostr ${encodedEvent}`,
},
timeout: 15000,
})
const { accessToken, refreshToken, expiresIn } = response.data
// Store tokens
sessionStorage.setItem(TOKEN_KEY, accessToken)
sessionStorage.setItem(REFRESH_KEY, refreshToken)
sessionStorage.setItem(
EXPIRES_KEY,
String(Date.now() + (expiresIn * 1000) - 30000) // 30s buffer
)
// Also set on the main apiService for backwards compatibility
sessionStorage.setItem('nostr_token', accessToken)
return true
} catch (error) {
console.error('[nip98] Failed to create session:', error)
return false
}
}
/**
* Refresh the session using the stored refresh token
*/
async refresh(): Promise<string | null> {
if (this.refreshPromise) return this.refreshPromise
this.refreshPromise = (async () => {
try {
const refreshToken = sessionStorage.getItem(REFRESH_KEY)
if (!refreshToken) return null
const response = await axios.post(
`${indeehubApiConfig.baseURL}/auth/nostr/refresh`,
{ refreshToken },
{ timeout: 15000 }
)
const { accessToken, refreshToken: newRefresh, expiresIn } = response.data
sessionStorage.setItem(TOKEN_KEY, accessToken)
if (newRefresh) sessionStorage.setItem(REFRESH_KEY, newRefresh)
sessionStorage.setItem(
EXPIRES_KEY,
String(Date.now() + (expiresIn * 1000) - 30000)
)
sessionStorage.setItem('nostr_token', accessToken)
return accessToken
} catch {
this.clearSession()
return null
} finally {
this.refreshPromise = null
}
})()
return this.refreshPromise
}
/**
* Store tokens from an external auth flow (e.g. auth.service.ts).
* Keeps nip98Service in sync so IndeehubApiService can read the token.
*/
storeTokens(accessToken: string, refreshToken?: string, expiresIn?: number) {
sessionStorage.setItem(TOKEN_KEY, accessToken)
if (refreshToken) {
sessionStorage.setItem(REFRESH_KEY, refreshToken)
}
// Default to 1 hour if expiresIn is not provided
const ttlMs = (expiresIn ?? 3600) * 1000
sessionStorage.setItem(EXPIRES_KEY, String(Date.now() + ttlMs - 30000))
// Backwards compatibility
sessionStorage.setItem('nostr_token', accessToken)
}
/**
* Clear stored session data
*/
clearSession() {
sessionStorage.removeItem(TOKEN_KEY)
sessionStorage.removeItem(REFRESH_KEY)
sessionStorage.removeItem(EXPIRES_KEY)
}
}
export const nip98Service = new Nip98Service()

View File

@@ -46,6 +46,22 @@ class SubscriptionService {
return apiService.post<ApiSubscription>(`/subscriptions/${subscriptionId}/resume`)
}
/**
* Create a Lightning subscription invoice via BTCPay.
* Returns invoice details including BOLT11 for QR code display.
*/
async createLightningSubscription(data: {
type: 'enthusiast' | 'film-buff' | 'cinephile'
period: 'monthly' | 'annual'
}): Promise<{
lnInvoice: string
expiration: string
sourceAmount: { amount: string; currency: string }
id: string
}> {
return apiService.post('/subscriptions/lightning', data)
}
/**
* Update payment method
*/

View File

@@ -1,7 +1,73 @@
import { defineStore } from 'pinia'
import { ref } from 'vue'
import { authService } from '../services/auth.service'
import { nip98Service } from '../services/nip98.service'
import type { ApiUser } from '../types/api'
import { USE_MOCK } from '../utils/mock'
/** Returns true when the error looks like a network / connection failure */
function isConnectionError(error: any): boolean {
const msg = error?.message?.toLowerCase() || ''
return (
msg.includes('unable to connect') ||
msg.includes('network error') ||
msg.includes('failed to fetch') ||
msg.includes('econnrefused')
)
}
/** Build a mock Nostr user with filmmaker profile + subscription */
function buildMockNostrUser(pubkey: string) {
const mockUserId = 'mock-nostr-user-' + pubkey.slice(0, 8)
return {
id: mockUserId,
email: `${pubkey.slice(0, 8)}@nostr.local`,
legalName: 'Nostr User',
nostrPubkey: pubkey,
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
filmmaker: {
id: 'mock-filmmaker-' + pubkey.slice(0, 8),
userId: mockUserId,
professionalName: 'Nostr Filmmaker',
bio: 'Independent filmmaker and content creator.',
},
subscriptions: [{
id: 'mock-sub-cinephile',
userId: mockUserId,
tier: 'cinephile' as const,
status: 'active' as const,
currentPeriodStart: new Date().toISOString(),
currentPeriodEnd: new Date(Date.now() + 30 * 24 * 60 * 60 * 1000).toISOString(),
cancelAtPeriodEnd: false,
stripePriceId: 'mock-price-cinephile',
stripeCustomerId: 'mock-customer-' + pubkey.slice(0, 8),
}],
}
}
/** Build a mock Cognito user with subscription */
function buildMockCognitoUser(email: string, legalName?: string) {
const username = email.split('@')[0]
return {
id: 'mock-user-' + username,
email,
legalName: legalName || username.charAt(0).toUpperCase() + username.slice(1),
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
subscriptions: [{
id: 'mock-sub-cinephile',
userId: 'mock-user-' + username,
tier: 'cinephile' as const,
status: 'active' as const,
currentPeriodStart: new Date().toISOString(),
currentPeriodEnd: new Date(Date.now() + 30 * 24 * 60 * 60 * 1000).toISOString(),
cancelAtPeriodEnd: false,
stripePriceId: 'mock-price-cinephile',
stripeCustomerId: 'mock-customer-' + username,
}],
}
}
export type AuthType = 'cognito' | 'nostr' | null
@@ -28,35 +94,73 @@ export const useAuthStore = defineStore('auth', () => {
const isLoading = ref(false)
/**
* Initialize auth state from stored tokens
* Initialize auth state from stored tokens.
* In dev/mock mode, reconstructs the mock user directly from
* sessionStorage rather than calling the (non-existent) backend API.
*/
async function initialize() {
// Bail out early if already authenticated — prevents
// re-initialization from wiping state on subsequent navigations
if (isAuthenticated.value && user.value) {
return
}
isLoading.value = true
try {
// Check for existing tokens
const storedCognitoToken = sessionStorage.getItem('auth_token')
const storedNostrToken = sessionStorage.getItem('nostr_token')
const storedPubkey = sessionStorage.getItem('nostr_pubkey')
if (storedCognitoToken || storedNostrToken) {
// Validate session and fetch user
if (!storedCognitoToken && !storedNostrToken) {
return // Nothing stored — not logged in
}
// Helper: restore mock session from sessionStorage
const restoreAsMock = () => {
if (storedNostrToken && storedPubkey) {
user.value = buildMockNostrUser(storedPubkey)
nostrPubkey.value = storedPubkey
authType.value = 'nostr'
isAuthenticated.value = true
} else if (storedCognitoToken) {
user.value = buildMockCognitoUser('dev@local', 'Dev User')
cognitoToken.value = storedCognitoToken
authType.value = 'cognito'
isAuthenticated.value = true
}
}
if (USE_MOCK) {
restoreAsMock()
return
}
// Real mode: validate session with backend API
try {
const isValid = await authService.validateSession()
if (isValid) {
await fetchCurrentUser()
if (storedCognitoToken) {
authType.value = 'cognito'
cognitoToken.value = storedCognitoToken
} else {
authType.value = 'nostr'
}
isAuthenticated.value = true
} else {
// Session invalid - clear auth
await logout()
}
} catch (apiError: any) {
if (isConnectionError(apiError)) {
console.warn('Backend not reachable — falling back to mock session.')
restoreAsMock()
} else {
throw apiError
}
}
} catch (error) {
console.error('Failed to initialize auth:', error)
@@ -71,74 +175,49 @@ export const useAuthStore = defineStore('auth', () => {
*/
async function loginWithCognito(email: string, password: string) {
isLoading.value = true
try {
// Check if we're in development mode without backend
const useMockData = import.meta.env.VITE_USE_MOCK_DATA === 'true' || import.meta.env.DEV
if (useMockData) {
// Mock Cognito login for development
console.log('🔧 Development mode: Using mock Cognito authentication')
// Simulate API delay
await new Promise(resolve => setTimeout(resolve, 500))
// Create a mock user with active subscription
const mockUser = {
id: 'mock-user-' + email.split('@')[0],
email: email,
legalName: email.split('@')[0].charAt(0).toUpperCase() + email.split('@')[0].slice(1),
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
subscriptions: [{
id: 'mock-sub-cinephile',
userId: 'mock-user-' + email.split('@')[0],
tier: 'cinephile' as const,
status: 'active' as const,
currentPeriodStart: new Date().toISOString(),
currentPeriodEnd: new Date(Date.now() + 30 * 24 * 60 * 60 * 1000).toISOString(), // 30 days from now
cancelAtPeriodEnd: false,
stripePriceId: 'mock-price-cinephile',
stripeCustomerId: 'mock-customer-' + email.split('@')[0],
}],
}
console.log('✅ Mock user created with Cinephile subscription (full access)')
cognitoToken.value = 'mock-jwt-token-' + Date.now()
authType.value = 'cognito'
user.value = mockUser
isAuthenticated.value = true
// Store mock tokens
sessionStorage.setItem('auth_token', cognitoToken.value)
sessionStorage.setItem('refresh_token', 'mock-refresh-token')
return {
accessToken: cognitoToken.value,
idToken: 'mock-id-token',
refreshToken: 'mock-refresh-token',
expiresIn: 3600,
}
// Mock Cognito login helper
const mockLogin = () => {
const mockUser = buildMockCognitoUser(email)
console.log('🔧 Using mock Cognito authentication')
cognitoToken.value = 'mock-jwt-token-' + Date.now()
authType.value = 'cognito'
user.value = mockUser
isAuthenticated.value = true
sessionStorage.setItem('auth_token', cognitoToken.value)
sessionStorage.setItem('refresh_token', 'mock-refresh-token')
return {
accessToken: cognitoToken.value,
idToken: 'mock-id-token',
refreshToken: 'mock-refresh-token',
expiresIn: 3600,
}
}
try {
if (USE_MOCK) {
await new Promise(resolve => setTimeout(resolve, 500))
return mockLogin()
}
// Real API call
const response = await authService.login({ email, password })
cognitoToken.value = response.accessToken
authType.value = 'cognito'
await fetchCurrentUser()
isAuthenticated.value = true
return response
} catch (error: any) {
// Provide helpful error message
if (error.message?.includes('Unable to connect')) {
throw new Error(
'Backend API not available. To use real authentication, start the backend server and set VITE_USE_MOCK_DATA=false in .env'
)
if (isConnectionError(error)) {
console.warn('Backend not reachable — falling back to mock Cognito login.')
return mockLogin()
}
throw error
} finally {
@@ -151,75 +230,56 @@ export const useAuthStore = defineStore('auth', () => {
*/
async function loginWithNostr(pubkey: string, signature: string, event: any) {
isLoading.value = true
// Mock Nostr login helper
const mockLogin = () => {
const mockUser = buildMockNostrUser(pubkey)
console.warn('🔧 Using mock Nostr authentication (backend not available)')
nostrPubkey.value = pubkey
authType.value = 'nostr'
user.value = mockUser
isAuthenticated.value = true
sessionStorage.setItem('nostr_token', 'mock-nostr-token-' + pubkey.slice(0, 16))
sessionStorage.setItem('nostr_pubkey', pubkey)
return { token: 'mock-nostr-token', user: mockUser }
}
try {
// Check if we're in development mode without backend
const useMockData = import.meta.env.VITE_USE_MOCK_DATA === 'true' || import.meta.env.DEV
if (useMockData) {
// Mock Nostr login for development
console.log('🔧 Development mode: Using mock Nostr authentication')
// Simulate API delay
if (USE_MOCK) {
await new Promise(resolve => setTimeout(resolve, 500))
// Create a mock Nostr user with active subscription
const mockUser = {
id: 'mock-nostr-user-' + pubkey.slice(0, 8),
email: `${pubkey.slice(0, 8)}@nostr.local`,
legalName: 'Nostr User',
nostrPubkey: pubkey,
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
subscriptions: [{
id: 'mock-sub-cinephile',
userId: 'mock-nostr-user-' + pubkey.slice(0, 8),
tier: 'cinephile' as const,
status: 'active' as const,
currentPeriodStart: new Date().toISOString(),
currentPeriodEnd: new Date(Date.now() + 30 * 24 * 60 * 60 * 1000).toISOString(), // 30 days from now
cancelAtPeriodEnd: false,
stripePriceId: 'mock-price-cinephile',
stripeCustomerId: 'mock-customer-' + pubkey.slice(0, 8),
}],
}
console.log('✅ Mock Nostr user created with Cinephile subscription (full access)')
console.log('📝 Nostr Pubkey:', pubkey)
nostrPubkey.value = pubkey
authType.value = 'nostr'
user.value = mockUser
isAuthenticated.value = true
// Store mock session
sessionStorage.setItem('nostr_token', 'mock-nostr-token-' + pubkey.slice(0, 16))
return {
token: 'mock-nostr-token',
user: mockUser,
}
return mockLogin()
}
// Real API call
// Real API call — creates NIP-98 signed session via the active
// Nostr account in accountManager (set by useAccounts before this call)
const response = await authService.createNostrSession({
pubkey,
signature,
event,
})
nostrPubkey.value = pubkey
authType.value = 'nostr'
user.value = response.user
sessionStorage.setItem('nostr_pubkey', pubkey)
isAuthenticated.value = true
// Backend returns JWT tokens but no user object.
// Fetch the user profile with the new access token.
try {
await fetchCurrentUser()
} catch {
// User may not exist in DB yet — create a minimal local representation
user.value = buildMockNostrUser(pubkey)
}
return response
} catch (error: any) {
// Provide helpful error message
if (error.message?.includes('Unable to connect')) {
throw new Error(
'Backend API not available. To use real Nostr authentication, start the backend server and set VITE_USE_MOCK_DATA=false in .env'
)
if (isConnectionError(error)) {
console.warn('Backend not reachable — falling back to mock Nostr login.')
return mockLogin()
}
throw error
} finally {
@@ -232,78 +292,53 @@ export const useAuthStore = defineStore('auth', () => {
*/
async function register(email: string, password: string, legalName: string) {
isLoading.value = true
try {
// Check if we're in development mode without backend
const useMockData = import.meta.env.VITE_USE_MOCK_DATA === 'true' || import.meta.env.DEV
if (useMockData) {
// Mock registration for development
console.log('🔧 Development mode: Using mock registration')
// Simulate API delay
await new Promise(resolve => setTimeout(resolve, 500))
// Create a mock user with active subscription
const mockUser = {
id: 'mock-user-' + email.split('@')[0],
email: email,
legalName: legalName,
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
subscriptions: [{
id: 'mock-sub-cinephile',
userId: 'mock-user-' + email.split('@')[0],
tier: 'cinephile' as const,
status: 'active' as const,
currentPeriodStart: new Date().toISOString(),
currentPeriodEnd: new Date(Date.now() + 30 * 24 * 60 * 60 * 1000).toISOString(), // 30 days from now
cancelAtPeriodEnd: false,
stripePriceId: 'mock-price-cinephile',
stripeCustomerId: 'mock-customer-' + email.split('@')[0],
}],
}
console.log('✅ Mock user registered with Cinephile subscription (full access)')
cognitoToken.value = 'mock-jwt-token-' + Date.now()
authType.value = 'cognito'
user.value = mockUser
isAuthenticated.value = true
// Store mock tokens
sessionStorage.setItem('auth_token', cognitoToken.value)
sessionStorage.setItem('refresh_token', 'mock-refresh-token')
return {
accessToken: cognitoToken.value,
idToken: 'mock-id-token',
refreshToken: 'mock-refresh-token',
expiresIn: 3600,
}
// Mock registration helper
const mockRegister = () => {
const mockUser = buildMockCognitoUser(email, legalName)
console.warn('🔧 Using mock registration (backend not available)')
cognitoToken.value = 'mock-jwt-token-' + Date.now()
authType.value = 'cognito'
user.value = mockUser
isAuthenticated.value = true
sessionStorage.setItem('auth_token', cognitoToken.value)
sessionStorage.setItem('refresh_token', 'mock-refresh-token')
return {
accessToken: cognitoToken.value,
idToken: 'mock-id-token',
refreshToken: 'mock-refresh-token',
expiresIn: 3600,
}
}
try {
if (USE_MOCK) {
await new Promise(resolve => setTimeout(resolve, 500))
return mockRegister()
}
// Real API call
const response = await authService.register({
email,
password,
legalName,
})
cognitoToken.value = response.accessToken
authType.value = 'cognito'
await fetchCurrentUser()
isAuthenticated.value = true
return response
} catch (error: any) {
// Provide helpful error message
if (error.message?.includes('Unable to connect')) {
throw new Error(
'Backend API not available. To use real authentication, start the backend server and set VITE_USE_MOCK_DATA=false in .env'
)
if (isConnectionError(error)) {
console.warn('Backend not reachable — falling back to mock registration.')
return mockRegister()
}
throw error
} finally {
@@ -335,6 +370,7 @@ export const useAuthStore = defineStore('auth', () => {
*/
async function logout() {
await authService.logout()
nip98Service.clearSession()
user.value = null
authType.value = null

View File

@@ -6,8 +6,9 @@ import { topDocFilms, topDocBitcoin, topDocCrypto, topDocMoney, topDocEconomics
import { contentService } from '../services/content.service'
import { mapApiProjectsToContents } from '../utils/mappers'
import { useContentSourceStore } from './contentSource'
const USE_MOCK_DATA = import.meta.env.VITE_USE_MOCK_DATA === 'true' || import.meta.env.DEV
import { indeehubApiService } from '../services/indeehub-api.service'
import { useFilmmaker } from '../composables/useFilmmaker'
import { USE_MOCK as USE_MOCK_DATA } from '../utils/mock'
export const useContentStore = defineStore('content', () => {
const featuredContent = ref<Content | null>(null)
@@ -24,25 +25,21 @@ export const useContentStore = defineStore('content', () => {
const error = ref<string | null>(null)
/**
* Fetch content from API
* Fetch content from the original API (external IndeeHub)
*/
async function fetchContentFromApi() {
try {
// Fetch all published projects
const projects = await contentService.getProjects({ status: 'published' })
if (projects.length === 0) {
throw new Error('No content available')
}
// Map API data to content format
const allContent = mapApiProjectsToContents(projects)
// Set featured content (first film project or first project)
const featuredFilm = allContent.find(c => c.type === 'film') || allContent[0]
featuredContent.value = featuredFilm
// Organize into rows
const films = allContent.filter(c => c.type === 'film')
const bitcoinContent = allContent.filter(c =>
c.categories?.some(cat => cat.toLowerCase().includes('bitcoin'))
@@ -68,6 +65,64 @@ export const useContentStore = defineStore('content', () => {
}
}
/**
* Fetch content from our self-hosted IndeeHub API
*/
async function fetchContentFromIndeehubApi() {
try {
const response = await indeehubApiService.getProjects()
// Handle both array responses and wrapped responses like { data: [...] }
const projects = Array.isArray(response) ? response : (response as any)?.data ?? []
if (!Array.isArray(projects) || projects.length === 0) {
throw new Error('No content available from IndeeHub API')
}
// Map API projects to frontend Content format
const allContent: Content[] = projects.map((p: any) => ({
id: p.id,
title: p.title,
description: p.synopsis || '',
thumbnail: p.poster || '',
backdrop: p.poster || '',
type: p.type || 'film',
slug: p.slug,
rentalPrice: p.rentalPrice,
status: p.status,
categories: p.genre ? [p.genre.name] : [],
streamingUrl: p.streamingUrl || p.partnerStreamUrl || undefined,
apiData: {
deliveryMode: p.deliveryMode,
partnerStreamUrl: p.partnerStreamUrl,
partnerDashUrl: p.partnerDashUrl,
partnerFairplayUrl: p.partnerFairplayUrl,
partnerDrmToken: p.partnerDrmToken,
},
}))
const featuredFilm = allContent.find(c => c.type === 'film') || allContent[0]
featuredContent.value = featuredFilm
const films = allContent.filter(c => c.type === 'film')
const bitcoinContent = allContent.filter(c =>
c.categories?.some(cat => cat.toLowerCase().includes('bitcoin') || cat.toLowerCase().includes('documentary'))
)
contentRows.value = {
featured: allContent.slice(0, 10),
newReleases: films.slice(0, 8),
bitcoin: bitcoinContent.length > 0 ? bitcoinContent : films.slice(0, 6),
documentaries: allContent.slice(0, 10),
dramas: films.slice(0, 6),
independent: films.slice(0, 10)
}
} catch (err) {
console.error('IndeeHub API fetch failed:', err)
throw err
}
}
/**
* Fetch IndeeHub mock content (original catalog)
*/
@@ -111,7 +166,52 @@ export const useContentStore = defineStore('content', () => {
}
/**
* Route to the correct mock loader based on the active content source
* Convert published filmmaker projects to Content format and merge
* them into the existing content rows so they appear on the browse page.
*/
function mergePublishedFilmmakerProjects() {
try {
const { projects } = useFilmmaker()
const published = projects.value.filter(p => p.status === 'published')
if (published.length === 0) return
const publishedContent: Content[] = published.map(p => ({
id: p.id,
title: p.title || p.name,
description: p.synopsis || '',
thumbnail: p.poster || '/images/placeholder-poster.jpg',
backdrop: p.poster || '/images/placeholder-poster.jpg',
type: p.type === 'episodic' ? 'series' as const : 'film' as const,
rating: p.format || undefined,
releaseYear: p.releaseDate ? new Date(p.releaseDate).getFullYear() : new Date().getFullYear(),
categories: p.genres?.map(g => g.name) || [],
slug: p.slug,
rentalPrice: p.rentalPrice,
status: p.status,
apiData: p,
}))
// Merge into each content row (prepend so they appear first)
for (const key of Object.keys(contentRows.value)) {
// Avoid duplicates by filtering out any already-present IDs
const existingIds = new Set(contentRows.value[key].map(c => c.id))
const newItems = publishedContent.filter(c => !existingIds.has(c.id))
if (newItems.length > 0) {
contentRows.value[key] = [...newItems, ...contentRows.value[key]]
}
}
// If no featured content yet, use the first published project
if (!featuredContent.value && publishedContent.length > 0) {
featuredContent.value = publishedContent[0]
}
} catch {
// Filmmaker composable may not be initialized yet — safe to ignore
}
}
/**
* Route to the correct loader based on the active content source
*/
function fetchContentFromMock() {
const sourceStore = useContentSourceStore()
@@ -120,6 +220,9 @@ export const useContentStore = defineStore('content', () => {
} else {
fetchIndeeHubMock()
}
// In mock mode, also include any projects published through the backstage
mergePublishedFilmmakerProjects()
}
/**
@@ -130,12 +233,17 @@ export const useContentStore = defineStore('content', () => {
error.value = null
try {
if (USE_MOCK_DATA) {
const sourceStore = useContentSourceStore()
if (sourceStore.activeSource === 'indeehub-api' && !USE_MOCK_DATA) {
// Fetch from our self-hosted backend (only when backend is actually running)
await fetchContentFromIndeehubApi()
} else if (USE_MOCK_DATA) {
// Use mock data in development or when flag is set
await new Promise(resolve => setTimeout(resolve, 100))
fetchContentFromMock()
} else {
// Fetch from API
// Fetch from original API
await fetchContentFromApi()
}
} catch (e: any) {

View File

@@ -1,13 +1,35 @@
import { defineStore } from 'pinia'
import { ref, watch } from 'vue'
import { ref, computed, watch } from 'vue'
export type ContentSourceId = 'indeehub' | 'topdocfilms'
export type ContentSourceId = 'indeehub' | 'topdocfilms' | 'indeehub-api'
const STORAGE_KEY = 'indeedhub:content-source'
export const useContentSourceStore = defineStore('contentSource', () => {
const saved = localStorage.getItem(STORAGE_KEY) as ContentSourceId | null
const activeSource = ref<ContentSourceId>(saved === 'topdocfilms' ? 'topdocfilms' : 'indeehub')
const validSources: ContentSourceId[] = ['indeehub', 'topdocfilms', 'indeehub-api']
const activeSource = ref<ContentSourceId>(
saved && validSources.includes(saved) ? saved : 'indeehub'
)
// API source is only available when the backend URL is configured
const apiUrl = import.meta.env.VITE_INDEEHUB_API_URL || ''
const availableSources = computed(() => {
const sources: { id: ContentSourceId; label: string }[] = [
{ id: 'indeehub', label: 'IndeeHub Films' },
{ id: 'topdocfilms', label: 'TopDoc Films' },
]
// Only show API option if backend URL is configured
if (apiUrl) {
sources.push({ id: 'indeehub-api', label: 'IndeeHub API' })
}
return sources
})
const isApiSource = computed(() => activeSource.value === 'indeehub-api')
// Persist to localStorage on change
watch(activeSource, (v) => {
@@ -19,8 +41,11 @@ export const useContentSourceStore = defineStore('contentSource', () => {
}
function toggle() {
activeSource.value = activeSource.value === 'indeehub' ? 'topdocfilms' : 'indeehub'
const sources = availableSources.value
const currentIndex = sources.findIndex(s => s.id === activeSource.value)
const nextIndex = (currentIndex + 1) % sources.length
activeSource.value = sources[nextIndex].id
}
return { activeSource, setSource, toggle }
return { activeSource, availableSources, isApiSource, setSource, toggle }
})

View File

@@ -96,23 +96,16 @@ body {
object-fit: cover;
}
/* Scrollbar Styles */
/* Hide scrollbars but keep scrolling */
html {
overflow-y: scroll;
scrollbar-width: none; /* Firefox */
}
::-webkit-scrollbar {
width: 8px;
height: 8px;
}
::-webkit-scrollbar-track {
background: rgba(255, 255, 255, 0.05);
}
::-webkit-scrollbar-thumb {
background: rgba(255, 255, 255, 0.2);
border-radius: 4px;
}
::-webkit-scrollbar-thumb:hover {
background: rgba(255, 255, 255, 0.3);
width: 0;
height: 0;
display: none;
}
/* Netflix-style hero gradient */

View File

@@ -143,8 +143,16 @@ export interface NostrSessionRequest {
}
export interface NostrSessionResponse {
token: string
user: ApiUser
/** JWT access token from backend */
accessToken: string
/** JWT refresh token */
refreshToken: string
/** Token TTL in seconds */
expiresIn: number
/** Convenience alias (mapped from accessToken) */
token?: string
/** User object (populated after separate /auth/me call) */
user?: ApiUser
}
// API Response Wrappers
@@ -162,6 +170,118 @@ export interface PaginatedResponse<T> {
hasMore: boolean
}
// Filmmaker / Creator Types
export interface ApiPaymentMethod {
id: string
filmmakerUserId: string
type: 'lightning' | 'bank'
lightningAddress?: string
bankName?: string
accountNumber?: string
routingNumber?: string
withdrawalFrequency: 'manual' | 'weekly' | 'monthly'
isSelected: boolean
createdAt: string
updatedAt: string
}
export interface ApiFilmmakerAnalytics {
balance: number
totalEarnings: number
myTotalEarnings: number
averageSharePercentage: number
}
export interface ApiWatchAnalytics {
viewsByDate: Record<string, number>
trailerViews: number
averageWatchTime: number
streamingRevenueSats: number
rentalRevenueSats: number
purchasesCount: number
purchasesByContent: Array<{ contentId: string; title: string; count: number }>
revenueByDate: Record<string, number>
}
export interface ApiPayment {
id: string
amount: number
currency: string
status: 'pending' | 'completed' | 'failed'
type: 'withdrawal' | 'payout'
createdAt: string
}
export interface ApiCastMember {
id: string
name: string
role: string
profilePictureUrl?: string
type: 'cast' | 'crew'
}
export interface ApiProjectPermission {
id: string
userId: string
projectId: string
role: 'owner' | 'admin' | 'editor' | 'viewer' | 'revenue-manager'
user?: ApiUser
}
export interface ApiRevenueSplit {
id: string
userId: string
projectId: string
percentage: number
user?: ApiUser
}
export interface ApiCoupon {
id: string
code: string
projectId: string
discountType: 'percentage' | 'fixed'
discountValue: number
usageLimit: number
usedCount: number
expiresAt?: string
createdAt: string
}
export type ProjectStatus = 'draft' | 'published' | 'rejected'
export type ProjectType = 'film' | 'episodic' | 'music-video'
export interface CreateProjectData {
name: string
type: ProjectType
}
export interface UpdateProjectData {
name?: string
title?: string
slug?: string
synopsis?: string
status?: ProjectStatus
type?: ProjectType
format?: string
category?: string
poster?: string
trailer?: string
rentalPrice?: number
releaseDate?: string
genres?: string[]
deliveryMode?: 'native' | 'partner'
}
export interface UploadInitResponse {
UploadId: string
Key: string
}
export interface UploadPresignedUrlsResponse {
parts: Array<{ PartNumber: number; signedUrl: string }>
}
// Error Types
export interface ApiError {
message: string

View File

@@ -22,6 +22,10 @@ export interface Content {
drmEnabled?: boolean
streamingUrl?: string
apiData?: any
// Dual-mode content delivery
deliveryMode?: 'native' | 'partner'
keyUrl?: string
}
// Nostr event types

63
src/utils/mock.ts Normal file
View File

@@ -0,0 +1,63 @@
/**
* Centralised mock-mode flag.
*
* Rules:
* 1. VITE_USE_MOCK_DATA="true" → mock ON
* 2. VITE_USE_MOCK_DATA="false" → mock OFF (try real backend)
* 3. Unset / empty → fall back to import.meta.env.DEV
*
* If the user opted for real mode (rule 2) but the backend is
* unreachable, `initMockMode()` automatically flips USE_MOCK to
* true so the app remains usable. A console banner lets the dev
* know what happened.
*/
const explicit = import.meta.env.VITE_USE_MOCK_DATA
// Exported as `let` so initMockMode() can reassign it.
// ES module live-bindings ensure every importer sees the update.
export let USE_MOCK: boolean =
explicit === 'false'
? false
: explicit === 'true'
? true
: import.meta.env.DEV
/**
* Call once at app startup (before mounting).
* Pings the backend — if unreachable and USE_MOCK is false,
* flips USE_MOCK to true so the whole app falls back to mock data.
*/
export async function initMockMode(): Promise<void> {
// Nothing to check if mock is already on
if (USE_MOCK) return
const apiUrl = import.meta.env.VITE_API_URL || 'http://localhost:4000'
try {
const controller = new AbortController()
const timeout = setTimeout(() => controller.abort(), 2000)
await fetch(apiUrl, {
method: 'HEAD',
signal: controller.signal,
})
clearTimeout(timeout)
// Backend is reachable — keep USE_MOCK = false
console.log(
'%c✅ Backend connected at %s — real mode active',
'color: #22c55e; font-weight: bold',
apiUrl,
)
} catch {
// Backend is not reachable — flip to mock
USE_MOCK = true
console.warn(
'%c⚠ Backend not reachable at %s — auto-switching to mock mode.\n' +
' Start the backend (npm run dev:full) for real API/payments.',
'color: #f59e0b; font-weight: bold',
apiUrl,
)
}
}

View File

@@ -101,8 +101,8 @@
<div class="text-white font-medium">{{ user.filmmaker.professionalName }}</div>
</div>
<button @click="$router.push('/dashboard')" class="hero-play-button w-full">
Go to Filmmaker Dashboard
<button @click="$router.push('/backstage')" class="hero-play-button w-full">
Go to Backstage
</button>
</div>
</section>
@@ -164,9 +164,12 @@ const { logout: nostrLogout } = useAccounts()
const contentSourceStore = useContentSourceStore()
const contentStore = useContentStore()
const contentSourceLabel = computed(() =>
contentSourceStore.activeSource === 'indeehub' ? 'IndeeHub Films' : 'TopDoc Films'
)
const contentSourceLabel = computed(() => {
const source = contentSourceStore.availableSources.find(
s => s.id === contentSourceStore.activeSource
)
return source?.label || 'IndeeHub Films'
})
function handleSourceToggle() {
contentSourceStore.toggle()

View File

@@ -0,0 +1,425 @@
<template>
<div class="analytics-view">
<!-- Sticky Top Bar -->
<div class="page-top-bar-wrapper">
<div class="page-top-bar-inner flex items-center gap-3">
<h1 class="text-xl md:text-2xl font-bold text-white">{{ userName }}</h1>
<nav class="flex items-center gap-2 flex-1 ml-4">
<button
@click="activeTab = 'viewership'"
:class="activeTab === 'viewership' ? 'bar-tab-active' : 'bar-tab'"
>
Viewership
</button>
<button
@click="activeTab = 'revenue'"
:class="activeTab === 'revenue' ? 'bar-tab-active' : 'bar-tab'"
>
Revenue
</button>
</nav>
<select v-model="selectedProjectId" class="filter-select flex-shrink-0">
<option value="all">All Films</option>
<option v-for="p in projects" :key="p.id" :value="p.id">{{ p.title || p.name }}</option>
</select>
</div>
</div>
<main class="page-main">
<div class="mx-auto max-w-6xl">
<!-- Viewership Panel -->
<div v-if="activeTab === 'viewership'">
<!-- Stat Cards -->
<div class="grid grid-cols-2 lg:grid-cols-4 gap-4 mb-8">
<div class="stat-card">
<p class="stat-label">Total Views</p>
<p class="stat-value">{{ formatNumber(totalViews) }}</p>
</div>
<div class="stat-card">
<p class="stat-label">Purchases</p>
<p class="stat-value">{{ formatNumber(watchAnalytics?.purchasesCount || 0) }}</p>
</div>
<div class="stat-card">
<p class="stat-label">Trailer Views</p>
<p class="stat-value">{{ formatNumber(watchAnalytics?.trailerViews || 0) }}</p>
</div>
<div class="stat-card">
<p class="stat-label">Avg Watch Time</p>
<p class="stat-value">{{ formatDuration(watchAnalytics?.averageWatchTime || 0) }}</p>
</div>
</div>
<!-- Views Chart -->
<div class="glass-card p-6 mb-8">
<h3 class="text-lg font-semibold text-white mb-4">Views Over Time</h3>
<div class="chart-container">
<div v-if="viewsChartData.length === 0" class="text-center py-12 text-white/30 text-sm">
No viewership data available yet
</div>
<div v-else class="flex items-end gap-1 h-48">
<div
v-for="(point, idx) in viewsChartData"
:key="idx"
class="chart-bar"
:style="{ height: `${point.heightPct}%` }"
:title="`${point.date}: ${point.views} views`"
>
<div class="chart-bar-inner"></div>
</div>
</div>
<div v-if="viewsChartData.length > 0" class="flex justify-between mt-2">
<span class="text-white/20 text-[10px]">{{ viewsChartData[0]?.date }}</span>
<span class="text-white/20 text-[10px]">{{ viewsChartData[viewsChartData.length - 1]?.date }}</span>
</div>
</div>
</div>
</div>
<!-- Revenue Panel -->
<div v-if="activeTab === 'revenue'">
<!-- Revenue Cards -->
<div class="grid grid-cols-1 sm:grid-cols-3 gap-4 mb-8">
<div class="stat-card">
<p class="stat-label">Total Earnings</p>
<p class="stat-value text-[#F7931A]">{{ formatSats(analytics?.totalEarnings || 0) }} <span class="text-sm text-white/40">sats</span></p>
</div>
<div class="stat-card">
<p class="stat-label">My Earnings</p>
<p class="stat-value">{{ formatSats(analytics?.myTotalEarnings || 0) }} <span class="text-sm text-white/40">sats</span></p>
<p class="text-white/30 text-xs mt-1">{{ analytics?.averageSharePercentage || 100 }}% share</p>
</div>
<div class="stat-card">
<p class="stat-label">Balance</p>
<p class="stat-value text-green-400">{{ formatSats(analytics?.balance || 0) }} <span class="text-sm text-white/40">sats</span></p>
<button
@click="showWithdrawModal = true"
:disabled="!analytics?.balance"
class="withdraw-button mt-3 w-full disabled:opacity-30 disabled:cursor-not-allowed"
>
Withdraw
</button>
</div>
</div>
<!-- Revenue Breakdown -->
<div class="grid grid-cols-1 lg:grid-cols-2 gap-6 mb-8">
<!-- Streaming vs Rental -->
<div class="glass-card p-6">
<h3 class="text-lg font-semibold text-white mb-4">Revenue Breakdown</h3>
<div class="space-y-4">
<div>
<div class="flex justify-between text-sm mb-1">
<span class="text-white/60">Streaming</span>
<span class="text-white font-mono">{{ formatSats(watchAnalytics?.streamingRevenueSats || 0) }} sats</span>
</div>
<div class="h-2 rounded-full bg-white/5 overflow-hidden">
<div class="h-full rounded-full bg-[#F7931A]" :style="{ width: streamingPct + '%' }"></div>
</div>
</div>
<div>
<div class="flex justify-between text-sm mb-1">
<span class="text-white/60">Rentals</span>
<span class="text-white font-mono">{{ formatSats(watchAnalytics?.rentalRevenueSats || 0) }} sats</span>
</div>
<div class="h-2 rounded-full bg-white/5 overflow-hidden">
<div class="h-full rounded-full bg-white/40" :style="{ width: rentalPct + '%' }"></div>
</div>
</div>
</div>
</div>
<!-- Purchases by Content -->
<div class="glass-card p-6">
<h3 class="text-lg font-semibold text-white mb-4">Purchases by Content</h3>
<div v-if="(watchAnalytics?.purchasesByContent || []).length === 0" class="text-center py-8 text-white/30 text-sm">
No purchase data yet
</div>
<div v-else class="space-y-3">
<div v-for="item in watchAnalytics?.purchasesByContent" :key="item.contentId" class="flex items-center gap-3">
<span class="text-white/70 text-sm flex-1 truncate">{{ item.title }}</span>
<span class="text-white font-mono text-sm">{{ item.count }}</span>
</div>
</div>
</div>
</div>
<!-- Withdrawal History -->
<div class="glass-card p-6">
<h3 class="text-lg font-semibold text-white mb-4">Withdrawal History</h3>
<div v-if="payments.length === 0" class="text-center py-8 text-white/30 text-sm">
No withdrawals yet
</div>
<div v-else class="space-y-3">
<div v-for="payment in payments" :key="payment.id" class="payment-row">
<div class="flex-1">
<p class="text-white text-sm font-medium">{{ formatSats(payment.amount) }} sats</p>
<p class="text-white/40 text-xs">{{ formatDate(payment.createdAt) }}</p>
</div>
<span
:class="payment.status === 'completed' ? 'status-badge-success' : 'status-badge-pending'"
>
{{ payment.status }}
</span>
</div>
</div>
</div>
</div>
<!-- Withdraw Modal -->
<Transition name="modal">
<div v-if="showWithdrawModal" class="fixed inset-0 z-[100] flex items-center justify-center p-4" @click.self="showWithdrawModal = false">
<div class="fixed inset-0 bg-black/60 backdrop-blur-sm" @click="showWithdrawModal = false"></div>
<div class="modal-card relative z-10 w-full max-w-sm p-6">
<h3 class="text-xl font-bold text-white mb-2">Withdraw Earnings</h3>
<p class="text-white/50 text-sm mb-6">Available balance: {{ formatSats(analytics?.balance || 0) }} sats</p>
<div class="mb-4">
<label class="field-label">Amount (sats)</label>
<input v-model.number="withdrawAmount" type="number" class="field-input" :max="analytics?.balance" min="1" />
</div>
<div class="flex gap-3">
<button @click="showWithdrawModal = false" class="flex-1 cancel-button">Cancel</button>
<button @click="handleWithdraw" :disabled="!withdrawAmount" class="flex-1 create-button disabled:opacity-40">Withdraw</button>
</div>
</div>
</div>
</Transition>
</div>
</main>
</div>
</template>
<script setup lang="ts">
import { ref, computed, onMounted, watch } from 'vue'
import { useAuth } from '../../composables/useAuth'
import { useFilmmaker } from '../../composables/useFilmmaker'
const { user } = useAuth()
const {
projects,
analytics,
watchAnalytics,
payments,
fetchProjects,
fetchAnalytics,
fetchPayments,
} = useFilmmaker()
const activeTab = ref<'viewership' | 'revenue'>('viewership')
const selectedProjectId = ref('all')
const showWithdrawModal = ref(false)
const withdrawAmount = ref(0)
const userName = computed(() => {
if (user.value?.filmmaker?.professionalName) return user.value.filmmaker.professionalName
return user.value?.legalName?.split(' ')[0] || 'Filmmaker'
})
const totalViews = computed(() => {
if (!watchAnalytics.value?.viewsByDate) return 0
return Object.values(watchAnalytics.value.viewsByDate).reduce((sum, v) => sum + v, 0)
})
const viewsChartData = computed(() => {
if (!watchAnalytics.value?.viewsByDate) return []
const entries = Object.entries(watchAnalytics.value.viewsByDate).sort(([a], [b]) => a.localeCompare(b))
const max = Math.max(...entries.map(([, v]) => v), 1)
return entries.map(([date, views]) => ({
date,
views,
heightPct: Math.max((views / max) * 100, 4),
}))
})
const totalRevenue = computed(() =>
(watchAnalytics.value?.streamingRevenueSats || 0) + (watchAnalytics.value?.rentalRevenueSats || 0)
)
const streamingPct = computed(() =>
totalRevenue.value > 0 ? Math.round(((watchAnalytics.value?.streamingRevenueSats || 0) / totalRevenue.value) * 100) : 50
)
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 formatSats(n: number): string {
return n.toLocaleString()
}
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 formatDate(dateString: string): string {
return new Date(dateString).toLocaleDateString('en-US', {
year: 'numeric',
month: 'short',
day: 'numeric',
})
}
function handleWithdraw() {
console.log('Withdraw:', withdrawAmount.value)
showWithdrawModal.value = false
}
// Reload analytics when project filter changes
watch(selectedProjectId, (id) => {
const projectIds = id === 'all' ? projects.value.map((p) => p.id) : [id]
fetchAnalytics(projectIds)
fetchPayments(id === 'all' ? undefined : id)
})
onMounted(async () => {
await fetchProjects()
const projectIds = projects.value.map((p) => p.id)
fetchAnalytics(projectIds)
fetchPayments()
})
</script>
<style scoped>
.analytics-view {
min-height: 100vh;
background: #0a0a0a;
}
.page-top-bar-wrapper {
position: sticky;
top: 0;
z-index: 40;
padding: 112px 16px 8px 16px;
background: linear-gradient(to bottom, #0a0a0a 90%, transparent);
}
.page-top-bar-inner {
padding: 12px 16px;
border-radius: 16px;
background: rgba(10, 10, 10, 0.9);
backdrop-filter: blur(24px);
-webkit-backdrop-filter: blur(24px);
border: 1px solid rgba(255, 255, 255, 0.06);
box-shadow: 0 4px 16px rgba(0, 0, 0, 0.3);
}
.page-main {
padding: 8px 16px 96px 16px;
}
@media (min-width: 768px) {
.page-main {
padding-bottom: 32px;
}
}
.glass-card {
background: rgba(255, 255, 255, 0.05);
backdrop-filter: blur(24px);
-webkit-backdrop-filter: blur(24px);
border-radius: 16px;
border: 1px solid rgba(255, 255, 255, 0.08);
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.3), inset 0 1px 0 rgba(255, 255, 255, 0.1);
}
.modal-card {
background: rgba(20, 20, 20, 0.95);
backdrop-filter: blur(40px);
border-radius: 20px;
border: 1px solid rgba(255, 255, 255, 0.1);
box-shadow: 0 24px 80px rgba(0, 0, 0, 0.6), inset 0 1px 0 rgba(255, 255, 255, 0.08);
}
/* Bar tabs (header-style nav buttons) */
.bar-tab {
padding: 8px 16px;
font-size: 13px;
font-weight: 500;
border-radius: 12px;
background: rgba(0, 0, 0, 0.25);
color: rgba(255, 255, 255, 0.7);
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.3), inset 0 1px 0 rgba(255, 255, 255, 0.1);
border: none;
cursor: pointer;
transition: all 0.2s ease;
white-space: nowrap;
}
.bar-tab:hover {
background: rgba(0, 0, 0, 0.35);
color: rgba(255, 255, 255, 0.9);
transform: translateY(-1px);
}
.bar-tab-active {
padding: 8px 16px;
font-size: 13px;
font-weight: 600;
border-radius: 12px;
background: rgba(0, 0, 0, 0.4);
color: white;
box-shadow: 0 6px 16px rgba(0, 0, 0, 0.4), inset 0 1px 0 rgba(255, 255, 255, 0.15);
border: none;
cursor: default;
white-space: nowrap;
}
/* Stat Cards */
.stat-card {
background: rgba(255, 255, 255, 0.05);
backdrop-filter: blur(24px);
border-radius: 14px;
border: 1px solid rgba(255, 255, 255, 0.08);
padding: 20px;
box-shadow: 0 4px 16px rgba(0, 0, 0, 0.2), inset 0 1px 0 rgba(255, 255, 255, 0.08);
}
.stat-label {
font-size: 13px;
color: rgba(255, 255, 255, 0.4);
font-weight: 500;
margin-bottom: 4px;
}
.stat-value {
font-size: 28px;
font-weight: 700;
color: white;
line-height: 1.2;
}
/* Chart */
.chart-container { position: relative; }
.chart-bar { flex: 1; min-width: 4px; display: flex; align-items: flex-end; }
.chart-bar-inner { width: 100%; height: 100%; border-radius: 3px 3px 0 0; background: linear-gradient(to top, rgba(247, 147, 26, 0.3), rgba(247, 147, 26, 0.6)); transition: all 0.3s ease; }
.chart-bar:hover .chart-bar-inner { background: linear-gradient(to top, rgba(247, 147, 26, 0.5), rgba(247, 147, 26, 0.9)); }
/* Payment row */
.payment-row { display: flex; align-items: center; gap: 12px; padding: 12px 16px; border-radius: 12px; background: rgba(255, 255, 255, 0.04); border: 1px solid rgba(255, 255, 255, 0.06); }
.status-badge-success { padding: 4px 10px; font-size: 11px; font-weight: 600; border-radius: 8px; background: rgba(34, 197, 94, 0.15); color: #22c55e; text-transform: capitalize; }
.status-badge-pending { padding: 4px 10px; font-size: 11px; font-weight: 600; border-radius: 8px; background: rgba(255, 255, 255, 0.08); color: rgba(255, 255, 255, 0.5); text-transform: capitalize; }
/* Withdraw button */
.withdraw-button { padding: 8px 16px; font-size: 13px; font-weight: 600; border-radius: 10px; background: rgba(247, 147, 26, 0.15); color: #F7931A; border: 1px solid rgba(247, 147, 26, 0.3); cursor: pointer; transition: all 0.2s; }
.withdraw-button:hover { background: rgba(247, 147, 26, 0.25); }
/* Filter */
.filter-select { padding: 10px 32px 10px 12px; border-radius: 12px; border: 1px solid rgba(255, 255, 255, 0.1); background: rgba(255, 255, 255, 0.06); color: rgba(255, 255, 255, 0.8); font-size: 14px; outline: none; appearance: none; cursor: pointer; background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='12' height='12' viewBox='0 0 24 24' fill='none' stroke='rgba(255,255,255,0.4)' stroke-width='2'%3E%3Cpath d='M6 9l6 6 6-6'/%3E%3C/svg%3E"); background-repeat: no-repeat; background-position: right 12px center; }
.filter-select option { background: #1a1a1a; color: white; }
/* Form fields */
.field-label { display: block; color: rgba(255, 255, 255, 0.6); font-size: 14px; font-weight: 500; margin-bottom: 6px; }
.field-input { width: 100%; padding: 10px 14px; border-radius: 12px; border: 1px solid rgba(255, 255, 255, 0.1); background: rgba(255, 255, 255, 0.06); color: white; font-size: 14px; outline: none; }
.field-input:focus { border-color: rgba(255, 255, 255, 0.2); background: rgba(255, 255, 255, 0.08); }
.cancel-button { padding: 10px 24px; font-size: 14px; border-radius: 14px; background: rgba(255, 255, 255, 0.08); color: rgba(255, 255, 255, 0.7); border: 1px solid rgba(255, 255, 255, 0.1); cursor: pointer; }
.create-button { padding: 10px 24px; font-size: 14px; font-weight: 600; border-radius: 14px; background: rgba(255, 255, 255, 0.85); color: rgba(0, 0, 0, 0.9); border: none; cursor: pointer; }
.modal-enter-active { transition: all 0.3s ease-out; }
.modal-leave-active { transition: all 0.2s ease-in; }
.modal-enter-from, .modal-leave-to { opacity: 0; }
</style>

View File

@@ -0,0 +1,558 @@
<template>
<div class="backstage-view">
<!-- Sticky Top Bar -->
<div class="page-top-bar-wrapper">
<div class="page-top-bar-inner flex items-center gap-3">
<h1 class="text-xl md:text-2xl font-bold text-white">Backstage</h1>
<span class="text-white/40 text-sm flex-shrink-0">{{ projectsCount }} project{{ projectsCount === 1 ? '' : 's' }}</span>
<nav class="flex items-center gap-2 flex-1 ml-4 overflow-x-auto">
<button
v-for="tab in tabs"
:key="tab.id"
@click="activeTab = tab.id"
:class="activeTab === tab.id ? 'bar-tab-active' : 'bar-tab'"
>
{{ tab.label }}
</button>
</nav>
<button @click="showCreateModal = true" class="create-button flex items-center gap-2 text-sm flex-shrink-0">
<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="M12 4v16m8-8H4" />
</svg>
Create Project
</button>
</div>
</div>
<main class="page-main">
<div class="mx-auto max-w-7xl">
<!-- Filters Card -->
<div class="glass-card p-4 md:p-6 mb-6">
<!-- Filters Row -->
<div class="flex flex-col sm:flex-row items-stretch sm:items-center gap-3">
<!-- Search -->
<div class="relative flex-1 max-w-sm">
<svg class="w-4 h-4 text-white/30 absolute left-3 top-1/2 -translate-y-1/2 pointer-events-none" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z" />
</svg>
<input
v-model="searchQuery"
type="text"
placeholder="Search projects..."
class="filter-input"
style="padding-left: 40px"
/>
</div>
<!-- Type filter -->
<div class="flex gap-2">
<button
v-for="typeOpt in typeOptions"
:key="typeOpt.value"
@click="typeFilter = typeFilter === typeOpt.value ? null : typeOpt.value"
:class="typeFilter === typeOpt.value ? 'type-btn-active' : 'type-btn'"
>
{{ typeOpt.label }}
</button>
</div>
<!-- Sort -->
<select v-model="sortBy" class="filter-select">
<option value="recent">Recently Added</option>
<option value="a-z">A Z</option>
<option value="z-a">Z A</option>
</select>
</div>
</div>
<!-- Project Grid -->
<div v-if="isLoading" class="project-grid">
<div
v-for="i in 6"
:key="'skeleton-' + i"
class="skeleton-card animate-pulse"
>
<div class="aspect-[2/3] rounded-xl bg-white/5"></div>
<div class="mt-3 space-y-2">
<div class="h-4 w-3/4 rounded bg-white/5"></div>
<div class="h-3 w-1/2 rounded bg-white/5"></div>
</div>
</div>
</div>
<div v-else-if="filteredProjects.length === 0" class="empty-state glass-card p-8 md:p-12 text-center">
<svg class="w-16 h-16 mx-auto text-white/20 mb-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" d="M7 4V20M17 4V20M3 8H7M17 8H21M3 12H21M3 16H7M17 16H21M4 20H20C20.5523 20 21 19.5523 21 19V5C21 4.44772 20.5523 4 20 4H4C3.44772 4 3 4.44772 3 5V19C3 19.5523 3.44772 20 4 20Z" />
</svg>
<h3 class="text-xl font-bold text-white mb-2">No projects yet</h3>
<p class="text-white/50 mb-6 max-w-sm mx-auto">
Get started by creating your first project. Upload your film, add details, and share it with the world.
</p>
<button @click="showCreateModal = true" class="create-button inline-flex items-center gap-2">
<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 4v16m8-8H4" />
</svg>
Create Project
</button>
</div>
<div v-else class="project-grid">
<button
v-for="project in filteredProjects"
:key="project.id"
@click="openProject(project)"
class="project-card group text-left"
>
<!-- Poster -->
<div class="aspect-[2/3] rounded-xl overflow-hidden bg-white/5 relative">
<img
v-if="project.poster"
:src="project.poster"
:alt="project.title || project.name"
class="w-full h-full object-cover transition-transform duration-300 group-hover:scale-105"
/>
<div v-else class="w-full h-full flex items-center justify-center">
<svg class="w-12 h-12 text-white/10" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" d="M7 4V20M17 4V20M3 8H7M17 8H21M3 12H21M3 16H7M17 16H21M4 20H20C20.5523 20 21 19.5523 21 19V5C21 4.44772 20.5523 4 20 4H4C3.44772 4 3 4.44772 3 5V19C3 19.5523 3.44772 20 4 20Z" />
</svg>
</div>
<!-- Status Badge -->
<div class="absolute top-2 right-2">
<span :class="statusClass(project.status)">
{{ project.status || 'draft' }}
</span>
</div>
<!-- Hover overlay -->
<div class="absolute inset-0 bg-black/40 opacity-0 group-hover:opacity-100 transition-opacity duration-300 flex items-center justify-center">
<span class="text-white font-medium text-sm">Edit Project</span>
</div>
</div>
<!-- Info -->
<div class="mt-3">
<h3 class="text-sm font-semibold text-white truncate">{{ project.title || project.name }}</h3>
<p class="text-xs text-white/40 mt-0.5 capitalize">{{ project.type || 'Film' }}</p>
</div>
</button>
</div>
</div>
</main>
<!-- Create Project Modal -->
<Transition name="modal">
<div v-if="showCreateModal" class="fixed inset-0 z-[100] flex items-center justify-center p-4" @click.self="showCreateModal = false">
<div class="fixed inset-0 bg-black/60 backdrop-blur-sm" @click="showCreateModal = false"></div>
<div class="modal-card relative z-10 w-full max-w-md p-6">
<h2 class="text-xl font-bold text-white mb-6">Create New Project</h2>
<!-- Project Name -->
<div class="mb-5">
<label class="block text-white/60 text-sm mb-2">Project Name</label>
<input
v-model="newProjectName"
type="text"
placeholder="e.g. My First Film"
class="filter-input w-full"
@keydown.enter="handleCreateProject"
/>
</div>
<!-- Project Type -->
<div class="mb-6">
<label class="block text-white/60 text-sm mb-2">Type</label>
<div class="flex gap-2">
<button
v-for="typeOpt in createTypeOptions"
:key="typeOpt.value"
@click="newProjectType = typeOpt.value"
:class="newProjectType === typeOpt.value ? 'type-btn-active' : 'type-btn'"
>
{{ typeOpt.label }}
</button>
</div>
</div>
<!-- Actions -->
<div class="flex gap-3">
<button @click="showCreateModal = false" class="flex-1 cancel-button">Cancel</button>
<button
@click="handleCreateProject"
:disabled="!newProjectName.trim() || isCreating"
class="flex-1 create-button disabled:opacity-40 disabled:cursor-not-allowed"
>
{{ isCreating ? 'Creating...' : 'Create' }}
</button>
</div>
</div>
</div>
</Transition>
</div>
</template>
<script setup lang="ts">
import { ref, onMounted } from 'vue'
import { useRouter } from 'vue-router'
import { useFilmmaker } from '../../composables/useFilmmaker'
import type { ApiProject, ProjectType } from '../../types/api'
const router = useRouter()
const {
filteredProjects,
projectsCount,
isLoading,
activeTab,
typeFilter,
sortBy,
searchQuery,
fetchProjects,
createNewProject,
} = useFilmmaker()
const showCreateModal = ref(false)
const newProjectName = ref('')
const newProjectType = ref<ProjectType>('film')
const isCreating = ref(false)
const tabs = [
{ id: 'projects' as const, label: 'Projects' },
{ id: 'resume' as const, label: 'Resumé' },
{ id: 'stakeholder' as const, label: 'Stakeholder' },
]
const typeOptions = [
{ value: 'film' as ProjectType, label: 'Film' },
{ value: 'episodic' as ProjectType, label: 'Episodic' },
{ value: 'music-video' as ProjectType, label: 'Music Video' },
]
const createTypeOptions = [
{ value: 'film' as ProjectType, label: 'Film' },
{ value: 'episodic' as ProjectType, label: 'Episodic' },
{ value: 'music-video' as ProjectType, label: 'Music Video' },
]
function statusClass(status?: string): string {
const base = 'px-2 py-0.5 text-[10px] uppercase tracking-wider font-bold rounded-full'
switch (status) {
case 'published':
return `${base} bg-green-500/20 text-green-400 border border-green-500/30`
case 'rejected':
return `${base} bg-red-500/20 text-red-400 border border-red-500/30`
default:
return `${base} bg-white/10 text-white/60 border border-white/10`
}
}
function openProject(project: ApiProject) {
router.push(`/backstage/project/${project.id}`)
}
async function handleCreateProject() {
if (!newProjectName.value.trim()) return
isCreating.value = true
const project = await createNewProject({
name: newProjectName.value.trim(),
type: newProjectType.value,
})
isCreating.value = false
if (project) {
showCreateModal.value = false
newProjectName.value = ''
router.push(`/backstage/project/${project.id}`)
}
}
onMounted(() => {
fetchProjects()
})
</script>
<style scoped>
.backstage-view {
min-height: 100vh;
background: #0a0a0a;
}
.page-top-bar-wrapper {
position: sticky;
top: 0;
z-index: 40;
padding: 112px 16px 8px 16px;
background: linear-gradient(to bottom, #0a0a0a 90%, transparent);
}
.page-top-bar-inner {
padding: 12px 16px;
border-radius: 16px;
background: rgba(10, 10, 10, 0.9);
backdrop-filter: blur(24px);
-webkit-backdrop-filter: blur(24px);
border: 1px solid rgba(255, 255, 255, 0.06);
box-shadow: 0 4px 16px rgba(0, 0, 0, 0.3);
}
.page-main {
padding: 8px 16px 96px 16px;
}
@media (min-width: 768px) {
.page-main {
padding-bottom: 32px;
}
}
.glass-card {
background: rgba(255, 255, 255, 0.05);
backdrop-filter: blur(24px);
-webkit-backdrop-filter: blur(24px);
border-radius: 16px;
border: 1px solid rgba(255, 255, 255, 0.08);
box-shadow:
0 8px 24px rgba(0, 0, 0, 0.3),
inset 0 1px 0 rgba(255, 255, 255, 0.1);
}
.modal-card {
background: rgba(20, 20, 20, 0.95);
backdrop-filter: blur(40px);
-webkit-backdrop-filter: blur(40px);
border-radius: 20px;
border: 1px solid rgba(255, 255, 255, 0.1);
box-shadow:
0 24px 80px rgba(0, 0, 0, 0.6),
inset 0 1px 0 rgba(255, 255, 255, 0.08);
}
/* Bar tabs (header-style nav buttons) */
.bar-tab {
padding: 8px 16px;
font-size: 13px;
font-weight: 500;
border-radius: 12px;
background: rgba(0, 0, 0, 0.25);
color: rgba(255, 255, 255, 0.7);
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.3), inset 0 1px 0 rgba(255, 255, 255, 0.1);
border: none;
cursor: pointer;
transition: all 0.2s ease;
white-space: nowrap;
}
.bar-tab:hover {
background: rgba(0, 0, 0, 0.35);
color: rgba(255, 255, 255, 0.9);
transform: translateY(-1px);
}
.bar-tab-active {
padding: 8px 16px;
font-size: 13px;
font-weight: 600;
border-radius: 12px;
background: rgba(0, 0, 0, 0.4);
color: white;
box-shadow: 0 6px 16px rgba(0, 0, 0, 0.4), inset 0 1px 0 rgba(255, 255, 255, 0.15);
border: none;
cursor: default;
white-space: nowrap;
}
/* Filter inputs */
.filter-input {
width: 100%;
padding: 8px 12px;
border-radius: 12px;
border: 1px solid rgba(255, 255, 255, 0.1);
background: rgba(255, 255, 255, 0.06);
color: rgba(255, 255, 255, 0.95);
font-size: 14px;
outline: none;
transition: all 0.2s ease;
}
.filter-input::placeholder {
color: rgba(255, 255, 255, 0.3);
}
.filter-input:focus {
border-color: rgba(255, 255, 255, 0.2);
background: rgba(255, 255, 255, 0.08);
box-shadow: 0 0 0 3px rgba(255, 255, 255, 0.04);
}
.filter-select {
padding: 8px 32px 8px 12px;
border-radius: 12px;
border: 1px solid rgba(255, 255, 255, 0.1);
background: rgba(255, 255, 255, 0.06);
color: rgba(255, 255, 255, 0.8);
font-size: 14px;
outline: none;
appearance: none;
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='12' height='12' viewBox='0 0 24 24' fill='none' stroke='rgba(255,255,255,0.4)' stroke-width='2'%3E%3Cpath d='M6 9l6 6 6-6'/%3E%3C/svg%3E");
background-repeat: no-repeat;
background-position: right 12px center;
cursor: pointer;
transition: all 0.2s ease;
}
.filter-select:focus {
border-color: rgba(255, 255, 255, 0.2);
}
.filter-select option {
background: #1a1a1a;
color: white;
}
/* Type filter buttons */
.type-btn {
padding: 8px 16px;
font-size: 13px;
font-weight: 500;
line-height: 1.4;
border-radius: 10px;
background: rgba(255, 255, 255, 0.04);
color: rgba(255, 255, 255, 0.5);
border: 1px solid rgba(255, 255, 255, 0.08);
cursor: pointer;
transition: all 0.2s ease;
white-space: nowrap;
}
.type-btn:hover {
color: rgba(255, 255, 255, 0.8);
background: rgba(255, 255, 255, 0.08);
}
.type-btn-active {
padding: 8px 16px;
font-size: 13px;
font-weight: 600;
line-height: 1.4;
border-radius: 10px;
background: rgba(247, 147, 26, 0.15);
color: #F7931A;
border: 1px solid rgba(247, 147, 26, 0.3);
cursor: pointer;
transition: all 0.2s ease;
white-space: nowrap;
}
/* Create button (primary CTA) */
.create-button {
padding: 10px 24px;
font-size: 14px;
font-weight: 600;
border-radius: 14px;
background: rgba(255, 255, 255, 0.85);
color: rgba(0, 0, 0, 0.9);
border: none;
cursor: pointer;
transition: all 0.3s ease;
white-space: nowrap;
box-shadow:
0 8px 24px rgba(0, 0, 0, 0.3),
inset 0 1px 0 rgba(255, 255, 255, 1);
}
.create-button:hover {
background: rgba(255, 255, 255, 0.95);
transform: translateY(-1px);
box-shadow:
0 12px 32px rgba(0, 0, 0, 0.4),
inset 0 1px 0 rgba(255, 255, 255, 1);
}
.cancel-button {
padding: 10px 24px;
font-size: 14px;
font-weight: 500;
border-radius: 14px;
background: rgba(255, 255, 255, 0.08);
color: rgba(255, 255, 255, 0.7);
border: 1px solid rgba(255, 255, 255, 0.1);
cursor: pointer;
transition: all 0.2s ease;
}
.cancel-button:hover {
background: rgba(255, 255, 255, 0.12);
color: white;
}
/* Project Grid */
.project-grid {
display: grid;
grid-template-columns: repeat(2, 1fr);
gap: 16px;
}
@media (min-width: 640px) {
.project-grid {
grid-template-columns: repeat(3, 1fr);
gap: 20px;
}
}
@media (min-width: 768px) {
.project-grid {
grid-template-columns: repeat(4, 1fr);
}
}
@media (min-width: 1024px) {
.project-grid {
grid-template-columns: repeat(5, 1fr);
}
}
@media (min-width: 1280px) {
.project-grid {
grid-template-columns: repeat(6, 1fr);
}
}
/* Project card hover */
.project-card {
cursor: pointer;
transition: transform 0.3s ease;
}
.project-card:hover {
transform: translateY(-4px);
}
/* Empty state */
.empty-state {
width: 100%;
}
/* Modal transition */
.modal-enter-active {
transition: all 0.3s ease-out;
}
.modal-leave-active {
transition: all 0.2s ease-in;
}
.modal-enter-from {
opacity: 0;
}
.modal-enter-from .modal-card {
transform: scale(0.95) translateY(10px);
}
.modal-leave-to {
opacity: 0;
}
.modal-leave-to .modal-card {
transform: scale(0.95) translateY(10px);
}
</style>

View File

@@ -0,0 +1,535 @@
<template>
<div class="editor-view">
<!-- Sticky Top Bar (same px-4 as header = identical width) -->
<div class="top-bar-wrapper">
<div class="top-bar-inner flex items-center gap-3">
<!-- Back to Backstage -->
<button @click="$router.push('/backstage')" class="back-button flex-shrink-0" title="Back to Backstage">
<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="M15 19l-7-7 7-7" />
</svg>
</button>
<!-- Project Name + Status -->
<div class="flex items-center gap-3 flex-1 min-w-0">
<h1 class="text-xl md:text-2xl font-bold text-white truncate">{{ project?.title || project?.name || 'Loading...' }}</h1>
<button @click="showNameModal = true" class="text-white/30 hover:text-white/60 transition-colors flex-shrink-0">
<svg class="w-3.5 h-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M11 5H6a2 2 0 00-2 2v11a2 2 0 002 2h11a2 2 0 002-2v-5m-1.414-9.414a2 2 0 112.828 2.828L11.828 15H9v-2.828l8.586-8.586z" />
</svg>
</button>
<span :class="statusBadgeClass">{{ project?.status || 'draft' }}</span>
</div>
<!-- Actions -->
<div class="flex gap-3 flex-shrink-0">
<template v-if="project?.status === 'draft'">
<button @click="handleSave('draft')" :disabled="isSaving" class="cancel-button text-sm">
{{ isSaving ? 'Saving...' : 'Save Draft' }}
</button>
<button @click="handlePublish" :disabled="isSaving" class="create-button text-sm">
Publish
</button>
</template>
<template v-else>
<button @click="handleUnpublish" :disabled="isSaving" class="cancel-button text-sm">
Unpublish
</button>
<button @click="handleSave()" :disabled="isSaving" class="create-button text-sm">
{{ isSaving ? 'Saving...' : 'Save Changes' }}
</button>
</template>
</div>
</div>
</div>
<!-- Page content -->
<main class="editor-main">
<!-- Rejection Banner -->
<div v-if="project?.status === 'rejected'" class="glass-card border-red-500/20 bg-red-500/5 p-4 mb-6 flex items-start gap-3">
<svg class="w-5 h-5 text-red-400 flex-shrink-0 mt-0.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 8v4m0 4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
<div>
<p class="text-red-400 font-medium text-sm">Project Rejected</p>
<p class="text-red-400/60 text-sm mt-1">Please review and fix the issues below, then resubmit for approval.</p>
</div>
</div>
<!-- Two-column grid (fixed column widths) -->
<div class="editor-grid">
<!-- Sidebar (tab navigation only) -->
<aside class="sidebar-col">
<nav class="glass-card p-3 sidebar-nav">
<div class="space-y-1">
<button
v-for="tab in visibleTabs"
:key="tab.id"
@click="activeTabId = tab.id"
class="sidebar-tab w-full text-left"
:class="activeTabId === tab.id ? 'sidebar-tab-active' : ''"
>
<span class="flex-1">{{ tab.label }}</span>
<span v-if="tab.state === 'completed'" class="w-4 h-4 text-green-400">
<svg fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 13l4 4L19 7" /></svg>
</span>
<span v-else-if="tab.state === 'invalid'" class="w-4 h-4 text-red-400">
<svg fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 8v4m0 4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z" /></svg>
</span>
</button>
</div>
<!-- Delete Project -->
<button
@click="showDeleteConfirm = true"
class="mt-4 w-full text-sm text-red-400/60 hover:text-red-400 transition-colors text-left px-3 py-2"
>
Delete Project
</button>
</nav>
</aside>
<!-- Content Area (stable width via grid) -->
<div class="content-col">
<!-- Tab Content -->
<div class="glass-card p-5 md:p-8">
<div v-if="activeTabId === 'assets'">
<AssetsTab :project="project" @update="handleFieldUpdate" />
</div>
<div v-else-if="activeTabId === 'details'">
<DetailsTab :project="project" :genres="genres" @update="handleFieldUpdate" />
</div>
<div v-else-if="activeTabId === 'content'">
<ContentTab :project="project" />
</div>
<div v-else-if="activeTabId === 'cast-and-crew'">
<CastCrewTab :project="project" />
</div>
<div v-else-if="activeTabId === 'revenue'">
<RevenueTab :project="project" @update="handleFieldUpdate" />
</div>
<div v-else-if="activeTabId === 'permissions'">
<PermissionsTab :project="project" />
</div>
<div v-else-if="activeTabId === 'documentation'">
<DocumentationTab :project="project" />
</div>
<div v-else-if="activeTabId === 'coupons'">
<CouponsTab :project="project" />
</div>
</div>
</div>
</div>
</main>
<!-- Delete Confirmation Modal -->
<Transition name="modal">
<div v-if="showDeleteConfirm" class="fixed inset-0 z-[100] flex items-center justify-center p-4" @click.self="showDeleteConfirm = false">
<div class="fixed inset-0 bg-black/60 backdrop-blur-sm" @click="showDeleteConfirm = false"></div>
<div class="modal-card relative z-10 w-full max-w-sm p-6 text-center">
<svg class="w-12 h-12 mx-auto text-red-400 mb-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16" />
</svg>
<h3 class="text-xl font-bold text-white mb-2">Delete Project?</h3>
<p class="text-white/50 text-sm mb-6">This action cannot be undone. All project data, uploads, and analytics will be permanently removed.</p>
<div class="flex gap-3">
<button @click="showDeleteConfirm = false" class="flex-1 cancel-button">Cancel</button>
<button @click="handleDelete" class="flex-1 delete-button">Delete</button>
</div>
</div>
</div>
</Transition>
<!-- Rename Modal -->
<Transition name="modal">
<div v-if="showNameModal" class="fixed inset-0 z-[100] flex items-center justify-center p-4" @click.self="showNameModal = false">
<div class="fixed inset-0 bg-black/60 backdrop-blur-sm" @click="showNameModal = false"></div>
<div class="modal-card relative z-10 w-full max-w-sm p-6">
<h3 class="text-xl font-bold text-white mb-4">Rename Project</h3>
<input
v-model="editName"
class="filter-input w-full mb-4"
placeholder="Project name"
@keydown.enter="handleRename"
/>
<div class="flex gap-3">
<button @click="showNameModal = false" class="flex-1 cancel-button">Cancel</button>
<button @click="handleRename" class="flex-1 create-button">Save</button>
</div>
</div>
</div>
</Transition>
</div>
</template>
<script setup lang="ts">
import { ref, computed, onMounted, watch } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { useFilmmaker } from '../../composables/useFilmmaker'
import type { ApiProject } from '../../types/api'
// Tab components
import AssetsTab from '../../components/backstage/AssetsTab.vue'
import DetailsTab from '../../components/backstage/DetailsTab.vue'
import ContentTab from '../../components/backstage/ContentTab.vue'
import CastCrewTab from '../../components/backstage/CastCrewTab.vue'
import RevenueTab from '../../components/backstage/RevenueTab.vue'
import PermissionsTab from '../../components/backstage/PermissionsTab.vue'
import DocumentationTab from '../../components/backstage/DocumentationTab.vue'
import CouponsTab from '../../components/backstage/CouponsTab.vue'
const route = useRoute()
const router = useRouter()
const { getProject: fetchProject, saveProject, removeProject, fetchGenres, genres } = useFilmmaker()
const project = ref<ApiProject | null>(null)
const isLoading = ref(true)
const isSaving = ref(false)
const pendingChanges = ref<Record<string, any>>({})
const activeTabId = ref('assets')
const showDeleteConfirm = ref(false)
const showNameModal = ref(false)
const editName = ref('')
interface TabDef {
id: string
label: string
state: 'new' | 'completed' | 'invalid'
showFor?: string[]
}
const allTabs = computed<TabDef[]>(() => [
{ id: 'assets', label: 'Assets', state: project.value?.poster ? 'completed' : 'new' },
{ id: 'details', label: 'Details', state: project.value?.title && project.value?.synopsis ? 'completed' : 'new' },
{ id: 'content', label: 'Content', state: 'new', showFor: ['episodic'] },
{ id: 'cast-and-crew', label: 'Cast & Crew', state: 'new' },
{ id: 'revenue', label: 'Revenue', state: project.value?.rentalPrice ? 'completed' : 'new' },
{ id: 'permissions', label: 'Permissions', state: 'new' },
{ id: 'documentation', label: 'Documentation', state: 'new' },
{ id: 'coupons', label: 'Coupons', state: 'new' },
])
const visibleTabs = computed(() =>
allTabs.value.filter(
(t) => !t.showFor || t.showFor.includes(project.value?.type || 'film')
)
)
const currentTabLabel = computed(() =>
visibleTabs.value.find((t) => t.id === activeTabId.value)?.label || ''
)
const statusBadgeClass = computed(() => {
const base = 'inline-block px-2 py-0.5 text-[10px] uppercase tracking-wider font-bold rounded-full flex-shrink-0'
switch (project.value?.status) {
case 'published':
return `${base} bg-green-500/20 text-green-400 border border-green-500/30`
case 'rejected':
return `${base} bg-red-500/20 text-red-400 border border-red-500/30`
default:
return `${base} bg-white/10 text-white/50 border border-white/10`
}
})
function handleFieldUpdate(field: string, value: any) {
pendingChanges.value[field] = value
}
async function handleSave(status?: string) {
if (!project.value) return
isSaving.value = true
const data = { ...pendingChanges.value }
if (status) data.status = status
const updated = await saveProject(project.value.id, data)
if (updated) {
project.value = updated
pendingChanges.value = {}
}
isSaving.value = false
}
async function handlePublish() {
await handleSave('published')
}
async function handleUnpublish() {
await handleSave('draft')
}
async function handleDelete() {
if (!project.value) return
const success = await removeProject(project.value.id)
if (success) {
router.push('/backstage')
}
showDeleteConfirm.value = false
}
async function handleRename() {
if (!project.value || !editName.value.trim()) return
const updated = await saveProject(project.value.id, { name: editName.value.trim() })
if (updated) project.value = updated
showNameModal.value = false
}
async function loadProject() {
isLoading.value = true
try {
const id = route.params.id as string
const result = await fetchProject(id)
if (!result) {
console.warn('Project not found:', id)
router.push('/backstage')
return
}
project.value = result
editName.value = result.title || result.name || ''
} catch (err: any) {
console.error('Failed to load project:', err)
router.push('/backstage')
} finally {
isLoading.value = false
}
}
onMounted(() => {
loadProject()
fetchGenres()
})
watch(() => route.params.id, () => {
loadProject()
})
</script>
<style scoped>
.editor-view {
min-height: 100vh;
background: #0a0a0a;
}
.glass-card {
background: rgba(255, 255, 255, 0.05);
backdrop-filter: blur(24px);
-webkit-backdrop-filter: blur(24px);
border-radius: 16px;
border: 1px solid rgba(255, 255, 255, 0.08);
box-shadow:
0 8px 24px rgba(0, 0, 0, 0.3),
inset 0 1px 0 rgba(255, 255, 255, 0.1);
}
.modal-card {
background: rgba(20, 20, 20, 0.95);
backdrop-filter: blur(40px);
-webkit-backdrop-filter: blur(40px);
border-radius: 20px;
border: 1px solid rgba(255, 255, 255, 0.1);
box-shadow:
0 24px 80px rgba(0, 0, 0, 0.6),
inset 0 1px 0 rgba(255, 255, 255, 0.08);
}
/*
* Top bar — exactly matches header positioning.
* Header: <header class="fixed top-0 left-0 right-0 z-50 pt-4 px-4">
* inner: <div class="floating-glass-header ... rounded-2xl" style="max-width: 100%;">
*
* So the header glass pill is inset 16px from each viewport edge.
* We mirror that exactly: px-4 = 16px each side, always.
*/
.top-bar-wrapper {
position: sticky;
top: 0;
z-index: 40;
/* pt matches the header height + header's pt-4 offset + 32px gap between header and top bar */
padding: 112px 16px 8px 16px;
background: linear-gradient(to bottom, #0a0a0a 90%, transparent);
}
.top-bar-inner {
padding: 12px 16px;
border-radius: 16px;
background: rgba(10, 10, 10, 0.9);
backdrop-filter: blur(24px);
-webkit-backdrop-filter: blur(24px);
border: 1px solid rgba(255, 255, 255, 0.06);
box-shadow: 0 4px 16px rgba(0, 0, 0, 0.3);
}
/* Main content area */
.editor-main {
padding: 8px 16px 96px 16px;
}
@media (min-width: 768px) {
.editor-main {
padding-bottom: 32px;
}
}
/*
* Two-column grid — columns have fixed pixel widths so the content
* area never changes width when switching between tabs.
* On mobile: single column (stacked).
*/
.editor-grid {
display: grid;
grid-template-columns: 1fr;
gap: 24px;
}
@media (min-width: 1024px) {
.editor-grid {
grid-template-columns: 210px 1fr;
}
}
.content-col {
min-width: 0; /* prevents grid blowout from long content */
}
/* Sidebar nav — fixed on desktop so it never scrolls with the page */
@media (min-width: 1024px) {
.sidebar-nav {
position: fixed;
top: 196px; /* aligned with content area top */
width: 210px;
}
}
/* Back button */
.back-button {
display: flex;
align-items: center;
justify-content: center;
width: 36px;
height: 36px;
border-radius: 10px;
background: rgba(255, 255, 255, 0.08);
border: 1px solid rgba(255, 255, 255, 0.1);
color: rgba(255, 255, 255, 0.6);
cursor: pointer;
transition: all 0.2s ease;
}
.back-button:hover {
background: rgba(255, 255, 255, 0.12);
color: white;
transform: translateX(-1px);
}
/* Sidebar tabs */
.sidebar-tab {
display: flex;
align-items: center;
gap: 8px;
padding: 10px 14px;
border-radius: 10px;
font-size: 14px;
font-weight: 500;
color: rgba(255, 255, 255, 0.5);
transition: all 0.2s ease;
cursor: pointer;
background: transparent;
border: 1px solid transparent;
}
.sidebar-tab:hover {
color: rgba(255, 255, 255, 0.8);
background: rgba(255, 255, 255, 0.05);
}
.sidebar-tab-active {
color: white;
background: rgba(255, 255, 255, 0.1);
border-color: rgba(255, 255, 255, 0.12);
}
/* Buttons */
.filter-input {
width: 100%;
padding: 10px 12px;
border-radius: 12px;
border: 1px solid rgba(255, 255, 255, 0.1);
background: rgba(255, 255, 255, 0.06);
color: rgba(255, 255, 255, 0.95);
font-size: 14px;
outline: none;
transition: all 0.2s ease;
}
.filter-input::placeholder {
color: rgba(255, 255, 255, 0.3);
}
.filter-input:focus {
border-color: rgba(255, 255, 255, 0.2);
background: rgba(255, 255, 255, 0.08);
box-shadow: 0 0 0 3px rgba(255, 255, 255, 0.04);
}
.create-button {
padding: 10px 24px;
font-size: 14px;
font-weight: 600;
border-radius: 14px;
background: rgba(255, 255, 255, 0.85);
color: rgba(0, 0, 0, 0.9);
border: none;
cursor: pointer;
transition: all 0.3s ease;
white-space: nowrap;
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.3), inset 0 1px 0 rgba(255, 255, 255, 1);
}
.create-button:hover {
background: rgba(255, 255, 255, 0.95);
transform: translateY(-1px);
}
.cancel-button {
padding: 10px 24px;
font-size: 14px;
font-weight: 500;
border-radius: 14px;
background: rgba(255, 255, 255, 0.08);
color: rgba(255, 255, 255, 0.7);
border: 1px solid rgba(255, 255, 255, 0.1);
cursor: pointer;
transition: all 0.2s ease;
}
.cancel-button:hover {
background: rgba(255, 255, 255, 0.12);
color: white;
}
.delete-button {
padding: 10px 24px;
font-size: 14px;
font-weight: 600;
border-radius: 14px;
background: rgba(239, 68, 68, 0.2);
color: #ef4444;
border: 1px solid rgba(239, 68, 68, 0.3);
cursor: pointer;
transition: all 0.2s ease;
}
.delete-button:hover {
background: rgba(239, 68, 68, 0.3);
}
/* Modal transition */
.modal-enter-active { transition: all 0.3s ease-out; }
.modal-leave-active { transition: all 0.2s ease-in; }
.modal-enter-from, .modal-leave-to { opacity: 0; }
</style>

View File

@@ -0,0 +1,537 @@
<template>
<div class="settings-view">
<!-- Sticky Top Bar -->
<div class="page-top-bar-wrapper">
<div class="page-top-bar-inner flex items-center gap-3">
<h1 class="text-xl md:text-2xl font-bold text-white">Settings</h1>
<nav class="flex items-center gap-2 flex-1 ml-4 overflow-x-auto">
<button
v-for="tab in settingsTabs"
:key="tab.id"
@click="activeTab = tab.id"
:class="activeTab === tab.id ? 'bar-tab-active' : 'bar-tab'"
>
{{ tab.label }}
</button>
</nav>
</div>
</div>
<main class="page-main">
<div class="mx-auto max-w-3xl">
<!-- Personal -->
<div v-if="activeTab === 'personal'" class="grid grid-cols-1 lg:grid-cols-5 gap-6">
<!-- Profile Details (left, wider) -->
<div class="glass-card p-6 md:p-8 lg:col-span-3">
<h2 class="text-xl font-bold text-white mb-6">Profile Details</h2>
<div class="space-y-5">
<div class="grid grid-cols-1 sm:grid-cols-2 gap-5">
<div>
<label class="field-label">First Name</label>
<input v-model="firstName" class="field-input" placeholder="First name" />
</div>
<div>
<label class="field-label">Last Name</label>
<input v-model="lastName" class="field-input" placeholder="Last name" />
</div>
</div>
<div>
<label class="field-label">Professional Name</label>
<input v-model="professionalName" class="field-input" placeholder="Display name for your creator profile" />
</div>
<div>
<label class="field-label">Bio</label>
<textarea v-model="bio" class="field-input min-h-[100px] resize-y" placeholder="Tell viewers about yourself..." rows="3"></textarea>
</div>
</div>
<div class="mt-8 flex justify-end">
<button @click="handleSaveProfile" :disabled="isSaving" class="create-button">
{{ isSaving ? 'Saving...' : 'Save Changes' }}
</button>
</div>
</div>
<!-- Account Information (right, narrower) -->
<div class="glass-card p-6 md:p-8 lg:col-span-2">
<h2 class="text-xl font-bold text-white mb-6">Account Information</h2>
<div class="space-y-5">
<div class="flex items-center justify-between">
<div>
<label class="field-label mb-0">Email</label>
<p class="text-white font-medium text-sm">{{ user?.email || 'Not set' }}</p>
</div>
<button class="text-sm text-white/40 hover:text-white transition-colors">Change</button>
</div>
<div class="flex items-center justify-between">
<div>
<label class="field-label mb-0">Password</label>
<p class="text-white/50 text-sm"></p>
</div>
<button class="text-sm text-white/40 hover:text-white transition-colors">Change</button>
</div>
</div>
</div>
</div>
<!-- Security -->
<div v-if="activeTab === 'security'" class="glass-card p-6 md:p-8">
<h2 class="text-xl font-bold text-white mb-6">Security</h2>
<!-- Nostr Key -->
<div class="mb-8">
<h3 class="text-lg font-semibold text-white mb-2">Nostr Key</h3>
<p class="text-white/40 text-sm mb-4">Link your Nostr key for decentralized authentication.</p>
<div v-if="user?.nostrPubkey" class="space-y-3">
<div class="info-row">
<span class="text-white/50 text-sm">Public Key</span>
<span class="text-white font-mono text-xs truncate max-w-[200px]">{{ user.nostrPubkey }}</span>
</div>
<div class="flex items-center gap-2">
<span class="status-dot bg-green-400"></span>
<span class="text-green-400 text-sm font-medium">Linked</span>
</div>
<button @click="handleUnlinkNostr" class="text-sm text-red-400/60 hover:text-red-400 transition-colors">
Unlink Nostr Account
</button>
</div>
<div v-else>
<button @click="handleLinkNostr" class="create-button text-sm">
Link Nostr Key
</button>
</div>
</div>
<!-- MFA -->
<div>
<h3 class="text-lg font-semibold text-white mb-2">Multi-Factor Authentication</h3>
<p class="text-white/40 text-sm mb-4">Add an extra layer of security to your account.</p>
<div class="flex items-center gap-3">
<span class="status-dot bg-white/20"></span>
<span class="text-white/50 text-sm">Not configured</span>
</div>
<button class="cancel-button text-sm mt-3">Set Up MFA</button>
</div>
</div>
<!-- Withdrawal Methods -->
<div v-if="activeTab === 'withdrawal'" class="glass-card p-6 md:p-8">
<div class="flex items-center justify-between mb-6">
<h2 class="text-xl font-bold text-white">Withdrawal Methods</h2>
<button @click="showAddMethodModal = true" class="add-button flex items-center gap-2">
<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="M12 4v16m8-8H4" />
</svg>
Add Method
</button>
</div>
<!-- Methods list -->
<div v-if="paymentMethods.length > 0" class="space-y-3">
<div v-for="method in paymentMethods" :key="method.id" class="method-row">
<div class="flex items-center gap-3 flex-1 min-w-0">
<div class="w-10 h-10 rounded-xl flex items-center justify-center flex-shrink-0" :class="method.type === 'lightning' ? 'bg-[#F7931A]/20 text-[#F7931A]' : 'bg-white/10 text-white/60'">
<svg v-if="method.type === 'lightning'" 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="M13 10V3L4 14h7v7l9-11h-7z" />
</svg>
<svg v-else 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="M3 10h18M7 15h1m4 0h1m-7 4h12a3 3 0 003-3V8a3 3 0 00-3-3H6a3 3 0 00-3 3v8a3 3 0 003 3z" />
</svg>
</div>
<div class="min-w-0">
<p class="text-white font-medium text-sm truncate">
{{ method.type === 'lightning' ? method.lightningAddress : method.bankName }}
</p>
<p class="text-white/40 text-xs capitalize">{{ method.type }} · {{ method.withdrawalFrequency }}</p>
</div>
</div>
<div class="flex items-center gap-2">
<span v-if="method.isSelected" class="selected-badge">Active</span>
<button v-else @click="selectMethod(method.id)" class="text-xs text-white/30 hover:text-white transition-colors">
Set Active
</button>
<button @click="removeMethod(method.id)" class="text-white/20 hover:text-red-400 transition-colors ml-2">
<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="M6 18L18 6M6 6l12 12" />
</svg>
</button>
</div>
</div>
</div>
<div v-else class="text-center py-12">
<svg class="w-12 h-12 mx-auto text-white/15 mb-3" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" d="M13 10V3L4 14h7v7l9-11h-7z" />
</svg>
<p class="text-white/40 text-sm mb-4">No withdrawal methods configured</p>
<p class="text-white/30 text-xs">Add a Lightning address to withdraw your earnings.</p>
</div>
</div>
<!-- Add-ons -->
<div v-if="activeTab === 'addons'" class="space-y-4">
<div v-for="addon in addons" :key="addon.id" class="glass-card p-6">
<div class="flex items-start justify-between">
<div class="flex-1">
<h3 class="text-lg font-semibold text-white mb-1">{{ addon.title }}</h3>
<p class="text-white/40 text-sm">{{ addon.description }}</p>
</div>
<button
:disabled="addon.disabled"
:class="addon.enabled ? 'toggle-active' : addon.disabled ? 'toggle-disabled' : 'toggle-inactive'"
@click="addon.enabled = !addon.enabled"
>
{{ addon.enabled ? 'Enabled' : addon.disabled ? 'Coming Soon' : 'Enable' }}
</button>
</div>
</div>
</div>
</div>
</main>
<!-- Add Payment Method Modal -->
<Transition name="modal">
<div v-if="showAddMethodModal" class="fixed inset-0 z-[100] flex items-center justify-center p-4" @click.self="showAddMethodModal = false">
<div class="fixed inset-0 bg-black/60 backdrop-blur-sm" @click="showAddMethodModal = false"></div>
<div class="modal-card relative z-10 w-full max-w-md p-6">
<h3 class="text-xl font-bold text-white mb-2">Add Lightning Address</h3>
<p class="text-white/50 text-sm mb-6">Receive your creator earnings directly via the Lightning Network.</p>
<!-- Lightning Address -->
<div class="space-y-4">
<div>
<label class="field-label">Lightning Address</label>
<input v-model="newLightningAddress" class="field-input" placeholder="you@wallet.example.com" />
<p v-if="addMethodError" class="text-red-400 text-xs mt-1">{{ addMethodError }}</p>
</div>
</div>
<!-- Frequency -->
<div class="mt-5">
<label class="field-label">Withdrawal Frequency</label>
<select v-model="newFrequency" class="field-select">
<option value="automatic">Automatic</option>
<option value="daily">Daily</option>
<option value="weekly">Weekly</option>
<option value="monthly">Monthly</option>
</select>
</div>
<div class="flex gap-3 mt-6">
<button @click="showAddMethodModal = false" class="flex-1 cancel-button">Cancel</button>
<button @click="handleAddMethod" :disabled="isAddingMethod" class="flex-1 create-button">
{{ isAddingMethod ? 'Validating...' : 'Add Method' }}
</button>
</div>
</div>
</div>
</Transition>
</div>
</template>
<script setup lang="ts">
import { ref, reactive, onMounted, computed } from 'vue'
import { useAuth } from '../../composables/useAuth'
import { useFilmmaker } from '../../composables/useFilmmaker'
import * as filmmakerService from '../../services/filmmaker.service'
import { USE_MOCK } from '../../utils/mock'
const { user, linkNostr, unlinkNostr } = useAuth()
const { paymentMethods, fetchPaymentMethods } = useFilmmaker()
const activeTab = ref('personal')
const isSaving = ref(false)
// Personal fields
const firstName = ref('')
const lastName = ref('')
const professionalName = ref('')
const bio = ref('')
// Settings tabs
const settingsTabs = computed(() => {
const tabs = [
{ id: 'personal', label: 'Personal' },
{ id: 'security', label: 'Security' },
{ id: 'withdrawal', label: 'Withdrawal Methods' },
{ id: 'addons', label: 'Add-ons' },
]
return tabs
})
// Add-ons
const addons = reactive([
{ id: 'rss', title: 'RSS Exhibition', description: 'Distribute your content via RSS feed for podcast apps and other platforms.', enabled: false, disabled: false },
{ id: 'screening', title: 'Screening Room', description: 'Create private screening rooms for festivals and industry professionals.', enabled: false, disabled: false },
{ id: 'badge', title: 'ID Verification & Badge', description: 'Verify your identity to get a verified badge on your profile.', enabled: false, disabled: true },
])
// Withdrawal method modal
const showAddMethodModal = ref(false)
const newMethodType = ref<'lightning'>('lightning')
const newLightningAddress = ref('')
const newFrequency = ref<string>('automatic')
function handleSaveProfile() {
isSaving.value = true
console.log('Save profile:', { firstName: firstName.value, lastName: lastName.value, professionalName: professionalName.value, bio: bio.value })
setTimeout(() => { isSaving.value = false }, 500)
}
async function handleLinkNostr() {
if (!window.nostr) {
alert('Nostr extension not found. Please install a Nostr browser extension like Alby or nos2x.')
return
}
try {
const pubkey = await window.nostr.getPublicKey()
const authEvent = {
kind: 27235,
created_at: Math.floor(Date.now() / 1000),
tags: [['u', window.location.origin], ['method', 'POST']],
content: '',
}
const signedEvent = await window.nostr.signEvent(authEvent)
await linkNostr(pubkey, signedEvent.sig)
} catch (error: any) {
alert(error.message || 'Failed to link Nostr account')
}
}
async function handleUnlinkNostr() {
if (!confirm('Are you sure you want to unlink your Nostr account?')) return
try {
await unlinkNostr()
} catch (error: any) {
alert(error.message || 'Failed to unlink')
}
}
const isAddingMethod = ref(false)
const addMethodError = ref<string | null>(null)
async function handleAddMethod() {
if (!newLightningAddress.value || !newLightningAddress.value.includes('@')) {
addMethodError.value = 'Please enter a valid Lightning address (e.g. you@wallet.com)'
return
}
isAddingMethod.value = true
addMethodError.value = null
try {
if (USE_MOCK) {
// In dev/mock mode, simulate validation and save locally
await new Promise(resolve => setTimeout(resolve, 400))
console.log('Development mode: Mock Lightning address added:', newLightningAddress.value)
paymentMethods.value = [{
id: 'mock-ln-' + Date.now(),
type: 'lightning',
lightningAddress: newLightningAddress.value,
withdrawalFrequency: newFrequency.value,
isSelected: true,
} as any, ...paymentMethods.value]
showAddMethodModal.value = false
newLightningAddress.value = ''
return
}
// Validate the address first
const isValid = await filmmakerService.validateLightningAddress(newLightningAddress.value)
if (!isValid) {
addMethodError.value = 'This Lightning address could not be verified. Please check and try again.'
return
}
// Add the payment method
await filmmakerService.addPaymentMethod({
type: 'lightning',
lightningAddress: newLightningAddress.value,
withdrawalFrequency: newFrequency.value,
})
// Refresh the list
await fetchPaymentMethods()
showAddMethodModal.value = false
newLightningAddress.value = ''
} catch (error: any) {
addMethodError.value = error.message || 'Failed to add payment method'
} finally {
isAddingMethod.value = false
}
}
async function selectMethod(id: string) {
try {
if (USE_MOCK) {
paymentMethods.value = paymentMethods.value.map(m => ({
...m,
isSelected: m.id === id,
}))
return
}
await filmmakerService.selectPaymentMethod(id)
await fetchPaymentMethods()
} catch (error: any) {
console.error('Failed to select method:', error)
}
}
async function removeMethod(id: string) {
if (!confirm('Remove this withdrawal method?')) return
try {
if (USE_MOCK) {
paymentMethods.value = paymentMethods.value.filter(m => m.id !== id)
return
}
await filmmakerService.removePaymentMethod(id)
await fetchPaymentMethods()
} catch (error: any) {
console.error('Failed to remove method:', error)
}
}
onMounted(() => {
// Populate fields from user
if (user.value) {
const names = (user.value.legalName || '').split(' ')
firstName.value = names[0] || ''
lastName.value = names.slice(1).join(' ') || ''
professionalName.value = user.value.filmmaker?.professionalName || ''
bio.value = user.value.filmmaker?.bio || ''
}
fetchPaymentMethods()
})
declare global {
interface Window {
nostr?: {
getPublicKey: () => Promise<string>
signEvent: (event: any) => Promise<any>
}
}
}
</script>
<style scoped>
.settings-view { min-height: 100vh; background: #0a0a0a; }
.page-top-bar-wrapper {
position: sticky;
top: 0;
z-index: 40;
padding: 112px 16px 8px 16px;
background: linear-gradient(to bottom, #0a0a0a 90%, transparent);
}
.page-top-bar-inner {
padding: 12px 16px;
border-radius: 16px;
background: rgba(10, 10, 10, 0.9);
backdrop-filter: blur(24px);
-webkit-backdrop-filter: blur(24px);
border: 1px solid rgba(255, 255, 255, 0.06);
box-shadow: 0 4px 16px rgba(0, 0, 0, 0.3);
}
.page-main {
padding: 8px 16px 96px 16px;
}
@media (min-width: 768px) {
.page-main {
padding-bottom: 32px;
}
}
.glass-card {
background: rgba(255, 255, 255, 0.05);
backdrop-filter: blur(24px);
-webkit-backdrop-filter: blur(24px);
border-radius: 16px;
border: 1px solid rgba(255, 255, 255, 0.08);
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.3), inset 0 1px 0 rgba(255, 255, 255, 0.1);
}
.modal-card {
background: rgba(20, 20, 20, 0.95);
backdrop-filter: blur(40px);
border-radius: 20px;
border: 1px solid rgba(255, 255, 255, 0.1);
box-shadow: 0 24px 80px rgba(0, 0, 0, 0.6), inset 0 1px 0 rgba(255, 255, 255, 0.08);
}
/* Bar tabs (header-style nav buttons) */
.bar-tab {
padding: 8px 16px;
font-size: 13px;
font-weight: 500;
border-radius: 12px;
background: rgba(0, 0, 0, 0.25);
color: rgba(255, 255, 255, 0.7);
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.3), inset 0 1px 0 rgba(255, 255, 255, 0.1);
border: none;
cursor: pointer;
transition: all 0.2s ease;
white-space: nowrap;
}
.bar-tab:hover {
background: rgba(0, 0, 0, 0.35);
color: rgba(255, 255, 255, 0.9);
transform: translateY(-1px);
}
.bar-tab-active {
padding: 8px 16px;
font-size: 13px;
font-weight: 600;
border-radius: 12px;
background: rgba(0, 0, 0, 0.4);
color: white;
box-shadow: 0 6px 16px rgba(0, 0, 0, 0.4), inset 0 1px 0 rgba(255, 255, 255, 0.15);
border: none;
cursor: default;
white-space: nowrap;
}
/* Form fields */
.field-label { display: block; color: rgba(255, 255, 255, 0.6); font-size: 14px; font-weight: 500; margin-bottom: 6px; }
.field-input { width: 100%; padding: 10px 14px; border-radius: 12px; border: 1px solid rgba(255, 255, 255, 0.1); background: rgba(255, 255, 255, 0.06); color: rgba(255, 255, 255, 0.95); font-size: 14px; outline: none; transition: all 0.2s; }
.field-input::placeholder { color: rgba(255, 255, 255, 0.25); }
.field-input:focus { border-color: rgba(255, 255, 255, 0.2); background: rgba(255, 255, 255, 0.08); box-shadow: 0 0 0 3px rgba(255, 255, 255, 0.04); }
.field-select { width: 100%; padding: 10px 14px; border-radius: 12px; border: 1px solid rgba(255, 255, 255, 0.1); background: rgba(255, 255, 255, 0.06); color: rgba(255, 255, 255, 0.8); font-size: 14px; outline: none; appearance: none; cursor: pointer; }
.field-select option { background: #1a1a1a; color: white; }
/* Buttons */
.create-button { padding: 10px 24px; font-size: 14px; font-weight: 600; border-radius: 14px; background: rgba(255, 255, 255, 0.85); color: rgba(0, 0, 0, 0.9); border: none; cursor: pointer; transition: all 0.3s; box-shadow: 0 8px 24px rgba(0, 0, 0, 0.3); }
.create-button:hover { background: rgba(255, 255, 255, 0.95); transform: translateY(-1px); }
.cancel-button { padding: 10px 24px; font-size: 14px; font-weight: 500; border-radius: 14px; background: rgba(255, 255, 255, 0.08); color: rgba(255, 255, 255, 0.7); border: 1px solid rgba(255, 255, 255, 0.1); cursor: pointer; transition: all 0.2s; }
.cancel-button:hover { background: rgba(255, 255, 255, 0.12); color: white; }
.add-button { padding: 8px 16px; font-size: 13px; font-weight: 600; border-radius: 10px; background: rgba(255, 255, 255, 0.08); color: rgba(255, 255, 255, 0.8); border: 1px solid rgba(255, 255, 255, 0.1); cursor: pointer; transition: all 0.2s; }
.add-button:hover { background: rgba(255, 255, 255, 0.12); color: white; }
/* Type toggle */
.type-btn { padding: 10px 20px; font-size: 13px; font-weight: 500; border-radius: 12px; background: rgba(255, 255, 255, 0.04); color: rgba(255, 255, 255, 0.5); border: 1px solid rgba(255, 255, 255, 0.08); cursor: pointer; transition: all 0.2s; }
.type-btn:hover { color: rgba(255, 255, 255, 0.8); background: rgba(255, 255, 255, 0.08); }
.type-btn-active { padding: 10px 20px; font-size: 13px; font-weight: 600; border-radius: 12px; background: rgba(247, 147, 26, 0.15); color: #F7931A; border: 1px solid rgba(247, 147, 26, 0.3); cursor: pointer; }
/* Toggle buttons */
.toggle-active { padding: 6px 14px; font-size: 12px; font-weight: 600; border-radius: 8px; background: rgba(34, 197, 94, 0.15); color: #22c55e; border: 1px solid rgba(34, 197, 94, 0.3); cursor: pointer; }
.toggle-inactive { padding: 6px 14px; font-size: 12px; font-weight: 600; border-radius: 8px; background: rgba(255, 255, 255, 0.08); color: rgba(255, 255, 255, 0.6); border: 1px solid rgba(255, 255, 255, 0.1); cursor: pointer; }
.toggle-disabled { padding: 6px 14px; font-size: 12px; font-weight: 500; border-radius: 8px; background: rgba(255, 255, 255, 0.04); color: rgba(255, 255, 255, 0.3); border: 1px solid rgba(255, 255, 255, 0.06); cursor: not-allowed; }
/* Method row */
.method-row { display: flex; align-items: center; gap: 12px; padding: 14px 16px; border-radius: 12px; background: rgba(255, 255, 255, 0.04); border: 1px solid rgba(255, 255, 255, 0.06); }
.selected-badge { padding: 4px 10px; font-size: 11px; font-weight: 600; border-radius: 8px; background: rgba(247, 147, 26, 0.15); color: #F7931A; text-transform: uppercase; letter-spacing: 0.05em; }
.info-row { display: flex; justify-content: space-between; align-items: center; padding: 8px 0; }
.status-dot { width: 8px; height: 8px; border-radius: 50%; flex-shrink: 0; }
.modal-enter-active { transition: all 0.3s ease-out; }
.modal-leave-active { transition: all 0.2s ease-in; }
.modal-enter-from, .modal-leave-to { opacity: 0; }
</style>