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

@@ -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>