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:
29
src/App.vue
29
src/App.vue
@@ -1,7 +1,11 @@
|
||||
<template>
|
||||
<div id="app" class="min-h-screen">
|
||||
<!-- Shared Header (hidden on mobile when search overlay is open) -->
|
||||
<!-- Backstage Header (for creator routes) -->
|
||||
<BackstageHeader v-if="isBackstageRoute" />
|
||||
|
||||
<!-- Audience Header (hidden on mobile when search overlay is open, hidden on backstage) -->
|
||||
<AppHeader
|
||||
v-else
|
||||
:class="{ 'header-hidden-mobile': showMobileSearch }"
|
||||
@openAuth="handleOpenAuth"
|
||||
@selectContent="handleSearchSelect"
|
||||
@@ -11,16 +15,19 @@
|
||||
<!-- Route Content -->
|
||||
<RouterView @openAuth="handleOpenAuth" />
|
||||
|
||||
<!-- Mobile Navigation (hidden on desktop) -->
|
||||
<!-- Mobile Navigation: Backstage tab bar on creator routes, audience tab bar otherwise -->
|
||||
<BackstageMobileNav v-if="isBackstageRoute" />
|
||||
<MobileNav
|
||||
v-else
|
||||
:searchActive="showMobileSearch"
|
||||
@openAuth="handleOpenAuth"
|
||||
@openSearch="showMobileSearch = true"
|
||||
@closeSearch="showMobileSearch = false"
|
||||
/>
|
||||
|
||||
<!-- Mobile Search Overlay -->
|
||||
<!-- Mobile Search Overlay (audience only) -->
|
||||
<MobileSearch
|
||||
v-if="!isBackstageRoute"
|
||||
:isOpen="showMobileSearch"
|
||||
@close="showMobileSearch = false"
|
||||
@select="handleSearchSelect"
|
||||
@@ -39,21 +46,33 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { ref, computed, onMounted } from 'vue'
|
||||
import { useRouter, useRoute } from 'vue-router'
|
||||
import { useAuthStore } from './stores/auth'
|
||||
import { useSearchSelectionStore } from './stores/searchSelection'
|
||||
import AppHeader from './components/AppHeader.vue'
|
||||
import BackstageHeader from './components/BackstageHeader.vue'
|
||||
import AuthModal from './components/AuthModal.vue'
|
||||
import BackstageMobileNav from './components/BackstageMobileNav.vue'
|
||||
import MobileNav from './components/MobileNav.vue'
|
||||
import MobileSearch from './components/MobileSearch.vue'
|
||||
import ToastContainer from './components/ToastContainer.vue'
|
||||
import type { Content } from './types/content'
|
||||
|
||||
const router = useRouter()
|
||||
const route = useRoute()
|
||||
const authStore = useAuthStore()
|
||||
const searchSelection = useSearchSelectionStore()
|
||||
|
||||
/**
|
||||
* True when current route is any /backstage/* path.
|
||||
* Checks both Vue Router's resolved path and the browser URL
|
||||
* to handle the initial render before async guards resolve.
|
||||
*/
|
||||
const isBackstageRoute = computed(() =>
|
||||
route.path.startsWith('/backstage') || window.location.pathname.startsWith('/backstage')
|
||||
)
|
||||
|
||||
const showAuthModal = ref(false)
|
||||
const showMobileSearch = ref(false)
|
||||
const pendingRedirect = ref<string | null>(null)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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()
|
||||
|
||||
320
src/components/BackstageHeader.vue
Normal file
320
src/components/BackstageHeader.vue
Normal 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>
|
||||
136
src/components/BackstageMobileNav.vue
Normal file
136
src/components/BackstageMobileNav.vue
Normal 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>
|
||||
@@ -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 -->
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
159
src/components/backstage/AssetsTab.vue
Normal file
159
src/components/backstage/AssetsTab.vue
Normal 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>
|
||||
160
src/components/backstage/CastCrewTab.vue
Normal file
160
src/components/backstage/CastCrewTab.vue
Normal 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>
|
||||
118
src/components/backstage/ContentTab.vue
Normal file
118
src/components/backstage/ContentTab.vue
Normal 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>
|
||||
136
src/components/backstage/CouponsTab.vue
Normal file
136
src/components/backstage/CouponsTab.vue
Normal 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>
|
||||
216
src/components/backstage/DetailsTab.vue
Normal file
216
src/components/backstage/DetailsTab.vue
Normal 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>
|
||||
106
src/components/backstage/DocumentationTab.vue
Normal file
106
src/components/backstage/DocumentationTab.vue
Normal 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>
|
||||
101
src/components/backstage/PermissionsTab.vue
Normal file
101
src/components/backstage/PermissionsTab.vue
Normal 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>
|
||||
140
src/components/backstage/RevenueTab.vue
Normal file
140
src/components/backstage/RevenueTab.vue
Normal 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>
|
||||
188
src/components/backstage/UploadZone.vue
Normal file
188
src/components/backstage/UploadZone.vue
Normal 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>
|
||||
@@ -2,6 +2,7 @@ import { computed } from 'vue'
|
||||
import { libraryService } from '../services/library.service'
|
||||
import { subscriptionService } from '../services/subscription.service'
|
||||
import { useAuthStore } from '../stores/auth'
|
||||
import { USE_MOCK } from '../utils/mock'
|
||||
|
||||
/**
|
||||
* Access Control Composable
|
||||
@@ -27,10 +28,7 @@ export function useAccess() {
|
||||
return { hasAccess: true, method: 'subscription' }
|
||||
}
|
||||
|
||||
// Check if we're in development mode
|
||||
const useMockData = import.meta.env.VITE_USE_MOCK_DATA === 'true' || import.meta.env.DEV
|
||||
|
||||
if (useMockData) {
|
||||
if (USE_MOCK) {
|
||||
// In dev mode without subscription, no access (prompt rental)
|
||||
return { hasAccess: false }
|
||||
}
|
||||
|
||||
@@ -131,14 +131,18 @@ export function useAccounts() {
|
||||
}
|
||||
|
||||
/**
|
||||
* Logout current account
|
||||
* Logout current account.
|
||||
* removeAccount already clears the active account if it's the one being removed.
|
||||
*/
|
||||
function logout() {
|
||||
const current = accountManager.active
|
||||
if (current) {
|
||||
accountManager.removeAccount(current)
|
||||
try {
|
||||
accountManager.removeAccount(current)
|
||||
} catch (err) {
|
||||
console.debug('Account removal cleanup:', err)
|
||||
}
|
||||
}
|
||||
accountManager.setActive(null as any)
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
340
src/composables/useFilmmaker.ts
Normal file
340
src/composables/useFilmmaker.ts
Normal file
@@ -0,0 +1,340 @@
|
||||
/**
|
||||
* useFilmmaker composable
|
||||
*
|
||||
* Reactive state and actions for the filmmaker/creator dashboard.
|
||||
* In dev mode without a backend, uses local mock data so the
|
||||
* backstage UI is fully functional for prototyping.
|
||||
*/
|
||||
|
||||
import { ref, computed } from 'vue'
|
||||
import { filmmakerService } from '../services/filmmaker.service'
|
||||
import type {
|
||||
ApiProject,
|
||||
ApiFilmmakerAnalytics,
|
||||
ApiWatchAnalytics,
|
||||
ApiPaymentMethod,
|
||||
ApiPayment,
|
||||
ApiGenre,
|
||||
CreateProjectData,
|
||||
UpdateProjectData,
|
||||
ProjectType,
|
||||
} from '../types/api'
|
||||
import { USE_MOCK } from '../utils/mock'
|
||||
|
||||
// ── Shared reactive state (singleton across components) ─────────────────────────
|
||||
const projects = ref<ApiProject[]>([])
|
||||
const projectsCount = ref(0)
|
||||
const isLoading = ref(false)
|
||||
const error = ref<string | null>(null)
|
||||
const genres = ref<ApiGenre[]>([])
|
||||
|
||||
// Analytics state
|
||||
const analytics = ref<ApiFilmmakerAnalytics | null>(null)
|
||||
const watchAnalytics = ref<ApiWatchAnalytics | null>(null)
|
||||
|
||||
// Payment state
|
||||
const paymentMethods = ref<ApiPaymentMethod[]>([])
|
||||
const payments = ref<ApiPayment[]>([])
|
||||
|
||||
// Filters
|
||||
const activeTab = ref<'projects' | 'resume' | 'stakeholder'>('projects')
|
||||
const typeFilter = ref<ProjectType | null>(null)
|
||||
const sortBy = ref<'a-z' | 'z-a' | 'recent'>('recent')
|
||||
const searchQuery = ref('')
|
||||
|
||||
// ── Mock data helpers ────────────────────────────────────────────────────────────
|
||||
|
||||
let mockIdCounter = 1
|
||||
|
||||
function createMockProject(data: CreateProjectData): ApiProject {
|
||||
const id = `mock-project-${mockIdCounter++}`
|
||||
const now = new Date().toISOString()
|
||||
return {
|
||||
id,
|
||||
name: data.name,
|
||||
title: data.name,
|
||||
type: data.type,
|
||||
status: 'draft',
|
||||
slug: data.name.toLowerCase().replace(/\s+/g, '-'),
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
} as ApiProject
|
||||
}
|
||||
|
||||
const MOCK_GENRES: ApiGenre[] = [
|
||||
{ id: '1', name: 'Documentary', slug: 'documentary' },
|
||||
{ id: '2', name: 'Drama', slug: 'drama' },
|
||||
{ id: '3', name: 'Thriller', slug: 'thriller' },
|
||||
{ id: '4', name: 'Comedy', slug: 'comedy' },
|
||||
{ id: '5', name: 'Sci-Fi', slug: 'sci-fi' },
|
||||
{ id: '6', name: 'Animation', slug: 'animation' },
|
||||
{ id: '7', name: 'Horror', slug: 'horror' },
|
||||
{ id: '8', name: 'Action', slug: 'action' },
|
||||
{ id: '9', name: 'Romance', slug: 'romance' },
|
||||
{ id: '10', name: 'Music', slug: 'music' },
|
||||
]
|
||||
|
||||
// ── Composable ───────────────────────────────────────────────────────────────────
|
||||
|
||||
export function useFilmmaker() {
|
||||
/**
|
||||
* Sorted and filtered projects based on active filters
|
||||
*/
|
||||
const filteredProjects = computed(() => {
|
||||
let result = [...projects.value]
|
||||
|
||||
// Filter by type
|
||||
if (typeFilter.value) {
|
||||
result = result.filter((p) => p.type === typeFilter.value)
|
||||
}
|
||||
|
||||
// Filter by search
|
||||
if (searchQuery.value.trim()) {
|
||||
const q = searchQuery.value.toLowerCase()
|
||||
result = result.filter(
|
||||
(p) =>
|
||||
p.name?.toLowerCase().includes(q) ||
|
||||
p.title?.toLowerCase().includes(q)
|
||||
)
|
||||
}
|
||||
|
||||
// Sort
|
||||
if (sortBy.value === 'a-z') {
|
||||
result.sort((a, b) => (a.title || a.name || '').localeCompare(b.title || b.name || ''))
|
||||
} else if (sortBy.value === 'z-a') {
|
||||
result.sort((a, b) => (b.title || b.name || '').localeCompare(a.title || a.name || ''))
|
||||
} else {
|
||||
result.sort(
|
||||
(a, b) =>
|
||||
new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime()
|
||||
)
|
||||
}
|
||||
|
||||
return result
|
||||
})
|
||||
|
||||
// ── Project Actions ─────────────────────────────────────────────────────────
|
||||
|
||||
async function fetchProjects() {
|
||||
isLoading.value = true
|
||||
error.value = null
|
||||
try {
|
||||
if (USE_MOCK) {
|
||||
// Mock mode: projects are already in local state
|
||||
projectsCount.value = projects.value.length
|
||||
return
|
||||
}
|
||||
const [list, count] = await Promise.all([
|
||||
filmmakerService.getPrivateProjects({
|
||||
type: typeFilter.value || undefined,
|
||||
search: searchQuery.value || undefined,
|
||||
}),
|
||||
filmmakerService.getPrivateProjectsCount({
|
||||
type: typeFilter.value || undefined,
|
||||
search: searchQuery.value || undefined,
|
||||
}),
|
||||
])
|
||||
projects.value = list
|
||||
projectsCount.value = count
|
||||
} catch (err: any) {
|
||||
error.value = err.message || 'Failed to load projects'
|
||||
console.debug('Failed to load projects:', err)
|
||||
} finally {
|
||||
isLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function createNewProject(data: CreateProjectData): Promise<ApiProject | null> {
|
||||
isLoading.value = true
|
||||
error.value = null
|
||||
try {
|
||||
let project: ApiProject
|
||||
if (USE_MOCK) {
|
||||
// Simulate brief delay
|
||||
await new Promise(resolve => setTimeout(resolve, 300))
|
||||
project = createMockProject(data)
|
||||
} else {
|
||||
project = await filmmakerService.createProject(data)
|
||||
}
|
||||
// Add to local list
|
||||
projects.value = [project, ...projects.value]
|
||||
projectsCount.value++
|
||||
return project
|
||||
} catch (err: any) {
|
||||
error.value = err.message || 'Failed to create project'
|
||||
console.error('Failed to create project:', err)
|
||||
return null
|
||||
} finally {
|
||||
isLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function saveProject(id: string, data: UpdateProjectData): Promise<ApiProject | null> {
|
||||
error.value = null
|
||||
try {
|
||||
let updated: ApiProject
|
||||
if (USE_MOCK) {
|
||||
await new Promise(resolve => setTimeout(resolve, 200))
|
||||
const idx = projects.value.findIndex((p) => p.id === id)
|
||||
if (idx === -1) throw new Error('Project not found')
|
||||
updated = { ...projects.value[idx], ...data, updatedAt: new Date().toISOString() } as ApiProject
|
||||
projects.value[idx] = updated
|
||||
} else {
|
||||
updated = await filmmakerService.updateProject(id, data)
|
||||
const idx = projects.value.findIndex((p) => p.id === id)
|
||||
if (idx !== -1) projects.value[idx] = updated
|
||||
}
|
||||
return updated
|
||||
} catch (err: any) {
|
||||
error.value = err.message || 'Failed to save project'
|
||||
console.error('Failed to save project:', err)
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
async function removeProject(id: string): Promise<boolean> {
|
||||
error.value = null
|
||||
try {
|
||||
if (USE_MOCK) {
|
||||
await new Promise(resolve => setTimeout(resolve, 200))
|
||||
} else {
|
||||
await filmmakerService.deleteProject(id)
|
||||
}
|
||||
projects.value = projects.value.filter((p) => p.id !== id)
|
||||
projectsCount.value--
|
||||
return true
|
||||
} catch (err: any) {
|
||||
error.value = err.message || 'Failed to delete project'
|
||||
console.error('Failed to delete project:', err)
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
// ── Analytics Actions ───────────────────────────────────────────────────────
|
||||
|
||||
async function fetchAnalytics(projectIds?: string[]) {
|
||||
isLoading.value = true
|
||||
error.value = null
|
||||
try {
|
||||
if (USE_MOCK) {
|
||||
analytics.value = {
|
||||
balance: 125000,
|
||||
totalEarnings: 450000,
|
||||
myTotalEarnings: 360000,
|
||||
averageSharePercentage: 80,
|
||||
}
|
||||
watchAnalytics.value = {
|
||||
viewsByDate: {},
|
||||
trailerViews: 3891,
|
||||
averageWatchTime: 2340,
|
||||
streamingRevenueSats: 280000,
|
||||
rentalRevenueSats: 170000,
|
||||
purchasesCount: 89,
|
||||
purchasesByContent: [],
|
||||
revenueByDate: {},
|
||||
}
|
||||
return
|
||||
}
|
||||
const [analyticData, watchData] = await Promise.all([
|
||||
filmmakerService.getFilmmakerAnalytics(),
|
||||
filmmakerService.getWatchAnalytics(projectIds),
|
||||
])
|
||||
analytics.value = analyticData
|
||||
watchAnalytics.value = watchData
|
||||
} catch (err: any) {
|
||||
error.value = err.message || 'Failed to load analytics'
|
||||
console.error('Failed to load analytics:', err)
|
||||
} finally {
|
||||
isLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// ── Payment Actions ─────────────────────────────────────────────────────────
|
||||
|
||||
async function fetchPaymentMethods() {
|
||||
try {
|
||||
if (USE_MOCK) {
|
||||
paymentMethods.value = []
|
||||
return
|
||||
}
|
||||
paymentMethods.value = await filmmakerService.getPaymentMethods()
|
||||
} catch (err: any) {
|
||||
console.error('Failed to load payment methods:', err)
|
||||
}
|
||||
}
|
||||
|
||||
async function fetchPayments(filmId?: string) {
|
||||
try {
|
||||
if (USE_MOCK) {
|
||||
payments.value = []
|
||||
return
|
||||
}
|
||||
payments.value = await filmmakerService.getPayments(filmId)
|
||||
} catch (err: any) {
|
||||
console.error('Failed to load payments:', err)
|
||||
}
|
||||
}
|
||||
|
||||
// ── Single Project Fetch ────────────────────────────────────────────────────
|
||||
|
||||
async function getProject(id: string): Promise<ApiProject | null> {
|
||||
error.value = null
|
||||
try {
|
||||
if (USE_MOCK) {
|
||||
// Look up from local projects list
|
||||
const found = projects.value.find((p) => p.id === id)
|
||||
return found || null
|
||||
}
|
||||
return await filmmakerService.getPrivateProject(id)
|
||||
} catch (err: any) {
|
||||
error.value = err.message || 'Failed to load project'
|
||||
console.error('Failed to load project:', err)
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
// ── Genres ──────────────────────────────────────────────────────────────────
|
||||
|
||||
async function fetchGenres() {
|
||||
try {
|
||||
if (USE_MOCK) {
|
||||
genres.value = MOCK_GENRES
|
||||
return
|
||||
}
|
||||
genres.value = await filmmakerService.getGenres()
|
||||
} catch (err: any) {
|
||||
console.error('Failed to load genres:', err)
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
// State
|
||||
projects,
|
||||
projectsCount,
|
||||
filteredProjects,
|
||||
isLoading,
|
||||
error,
|
||||
genres,
|
||||
analytics,
|
||||
watchAnalytics,
|
||||
paymentMethods,
|
||||
payments,
|
||||
|
||||
// Filters
|
||||
activeTab,
|
||||
typeFilter,
|
||||
sortBy,
|
||||
searchQuery,
|
||||
|
||||
// Actions
|
||||
fetchProjects,
|
||||
createNewProject,
|
||||
getProject,
|
||||
saveProject,
|
||||
removeProject,
|
||||
fetchAnalytics,
|
||||
fetchPaymentMethods,
|
||||
fetchPayments,
|
||||
fetchGenres,
|
||||
}
|
||||
}
|
||||
186
src/composables/useUpload.ts
Normal file
186
src/composables/useUpload.ts
Normal file
@@ -0,0 +1,186 @@
|
||||
/**
|
||||
* useUpload composable
|
||||
*
|
||||
* Chunked multipart upload with progress tracking.
|
||||
* Ported from the original indeehub-frontend uploader library.
|
||||
* Works with both the original API and our self-hosted MinIO backend.
|
||||
*/
|
||||
|
||||
import { ref, computed } from 'vue'
|
||||
import { filmmakerService } from '../services/filmmaker.service'
|
||||
import axios from 'axios'
|
||||
|
||||
const CHUNK_SIZE = 20 * 1024 * 1024 // 20 MB
|
||||
const MAX_PARALLEL_UPLOADS = 6
|
||||
const MAX_RETRIES = 3
|
||||
|
||||
export interface UploadItem {
|
||||
id: string
|
||||
file: File
|
||||
key: string
|
||||
bucket: string
|
||||
progress: number
|
||||
status: 'pending' | 'uploading' | 'completed' | 'failed'
|
||||
error?: string
|
||||
}
|
||||
|
||||
// Shared upload queue (singleton across components)
|
||||
const uploadQueue = ref<UploadItem[]>([])
|
||||
const isUploading = ref(false)
|
||||
|
||||
export function useUpload() {
|
||||
const totalProgress = computed(() => {
|
||||
if (uploadQueue.value.length === 0) return 0
|
||||
const total = uploadQueue.value.reduce((sum, item) => sum + item.progress, 0)
|
||||
return Math.round(total / uploadQueue.value.length)
|
||||
})
|
||||
|
||||
const activeUploads = computed(() =>
|
||||
uploadQueue.value.filter((u) => u.status === 'uploading')
|
||||
)
|
||||
|
||||
const completedUploads = computed(() =>
|
||||
uploadQueue.value.filter((u) => u.status === 'completed')
|
||||
)
|
||||
|
||||
/**
|
||||
* Add a file to the upload queue and start uploading
|
||||
*/
|
||||
async function addUpload(file: File, key: string, bucket: string = 'indeedhub-private'): Promise<string | null> {
|
||||
const item: UploadItem = {
|
||||
id: `upload-${Date.now()}-${Math.random().toString(36).slice(2)}`,
|
||||
file,
|
||||
key,
|
||||
bucket,
|
||||
progress: 0,
|
||||
status: 'pending',
|
||||
}
|
||||
|
||||
uploadQueue.value.push(item)
|
||||
return processUpload(item)
|
||||
}
|
||||
|
||||
/**
|
||||
* Process a single upload: initialize, chunk, upload, finalize
|
||||
*/
|
||||
async function processUpload(item: UploadItem): Promise<string | null> {
|
||||
try {
|
||||
item.status = 'uploading'
|
||||
isUploading.value = true
|
||||
|
||||
// Step 1: Initialize multipart upload
|
||||
const { UploadId, Key } = await filmmakerService.initializeUpload(
|
||||
item.key,
|
||||
item.bucket,
|
||||
item.file.type
|
||||
)
|
||||
|
||||
// Step 2: Calculate chunks
|
||||
const totalChunks = Math.ceil(item.file.size / CHUNK_SIZE)
|
||||
|
||||
// Step 3: Get presigned URLs for all chunks
|
||||
const { parts: presignedParts } = await filmmakerService.getPresignedUrls(
|
||||
UploadId,
|
||||
Key,
|
||||
item.bucket,
|
||||
totalChunks
|
||||
)
|
||||
|
||||
// Step 4: Upload chunks in parallel with progress tracking
|
||||
const completedParts: Array<{ PartNumber: number; ETag: string }> = []
|
||||
let uploadedChunks = 0
|
||||
|
||||
// Process chunks in batches of MAX_PARALLEL_UPLOADS
|
||||
for (let batchStart = 0; batchStart < presignedParts.length; batchStart += MAX_PARALLEL_UPLOADS) {
|
||||
const batch = presignedParts.slice(batchStart, batchStart + MAX_PARALLEL_UPLOADS)
|
||||
|
||||
const batchResults = await Promise.all(
|
||||
batch.map(async (part) => {
|
||||
const start = (part.PartNumber - 1) * CHUNK_SIZE
|
||||
const end = Math.min(start + CHUNK_SIZE, item.file.size)
|
||||
const chunk = item.file.slice(start, end)
|
||||
|
||||
// Upload with retries
|
||||
let lastError: Error | null = null
|
||||
for (let attempt = 0; attempt < MAX_RETRIES; attempt++) {
|
||||
try {
|
||||
const response = await axios.put(part.signedUrl, chunk, {
|
||||
headers: { 'Content-Type': item.file.type },
|
||||
onUploadProgress: () => {
|
||||
// Progress is tracked at the chunk level
|
||||
},
|
||||
})
|
||||
|
||||
uploadedChunks++
|
||||
item.progress = Math.round((uploadedChunks / totalChunks) * 100)
|
||||
|
||||
const etag = response.headers.etag || response.headers.ETag
|
||||
return {
|
||||
PartNumber: part.PartNumber,
|
||||
ETag: etag?.replace(/"/g, '') || '',
|
||||
}
|
||||
} catch (err: any) {
|
||||
lastError = err
|
||||
// Exponential backoff
|
||||
await new Promise((resolve) =>
|
||||
setTimeout(resolve, Math.pow(2, attempt) * 1000)
|
||||
)
|
||||
}
|
||||
}
|
||||
throw lastError || new Error(`Failed to upload part ${part.PartNumber}`)
|
||||
})
|
||||
)
|
||||
|
||||
completedParts.push(...batchResults)
|
||||
}
|
||||
|
||||
// Step 5: Finalize
|
||||
await filmmakerService.finalizeUpload(UploadId, Key, item.bucket, completedParts)
|
||||
|
||||
item.status = 'completed'
|
||||
item.progress = 100
|
||||
|
||||
// Check if all uploads done
|
||||
if (uploadQueue.value.every((u) => u.status !== 'uploading' && u.status !== 'pending')) {
|
||||
isUploading.value = false
|
||||
}
|
||||
|
||||
return Key
|
||||
} catch (err: any) {
|
||||
item.status = 'failed'
|
||||
item.error = err.message || 'Upload failed'
|
||||
console.error('Upload failed:', err)
|
||||
|
||||
if (uploadQueue.value.every((u) => u.status !== 'uploading' && u.status !== 'pending')) {
|
||||
isUploading.value = false
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove completed or failed upload from queue
|
||||
*/
|
||||
function removeUpload(id: string) {
|
||||
uploadQueue.value = uploadQueue.value.filter((u) => u.id !== id)
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear all completed uploads
|
||||
*/
|
||||
function clearCompleted() {
|
||||
uploadQueue.value = uploadQueue.value.filter((u) => u.status !== 'completed')
|
||||
}
|
||||
|
||||
return {
|
||||
uploadQueue,
|
||||
isUploading,
|
||||
totalProgress,
|
||||
activeUploads,
|
||||
completedUploads,
|
||||
addUpload,
|
||||
removeUpload,
|
||||
clearCompleted,
|
||||
}
|
||||
}
|
||||
@@ -12,6 +12,14 @@ export const apiConfig = {
|
||||
retryDelay: 1000,
|
||||
} as const
|
||||
|
||||
// IndeeHub self-hosted backend API
|
||||
// When deployed via Docker, the nginx proxy serves /api/ -> backend:4000
|
||||
export const indeehubApiConfig = {
|
||||
baseURL: import.meta.env.VITE_INDEEHUB_API_URL || '/api',
|
||||
cdnURL: import.meta.env.VITE_INDEEHUB_CDN_URL || '/storage',
|
||||
timeout: 30000,
|
||||
} as const
|
||||
|
||||
export const nostrConfig = {
|
||||
relays: (import.meta.env.VITE_NOSTR_RELAYS || 'ws://localhost:7777,wss://relay.damus.io').split(','),
|
||||
lookupRelays: (import.meta.env.VITE_NOSTR_LOOKUP_RELAYS || 'wss://purplepag.es').split(','),
|
||||
|
||||
34
src/main.ts
34
src/main.ts
@@ -4,22 +4,28 @@ import router from './router'
|
||||
import App from './App.vue'
|
||||
import './style.css'
|
||||
import { registerSW } from 'virtual:pwa-register'
|
||||
import { initMockMode } from './utils/mock'
|
||||
|
||||
const app = createApp(App)
|
||||
// Detect backend availability before the app renders.
|
||||
// If the backend is unreachable, USE_MOCK is auto-flipped to true
|
||||
// so every service/store/composable falls back to mock data.
|
||||
initMockMode().then(() => {
|
||||
const app = createApp(App)
|
||||
|
||||
app.use(createPinia())
|
||||
app.use(router)
|
||||
app.use(createPinia())
|
||||
app.use(router)
|
||||
|
||||
app.mount('#app')
|
||||
app.mount('#app')
|
||||
|
||||
// Register PWA service worker with auto-update
|
||||
const updateSW = registerSW({
|
||||
immediate: true,
|
||||
onNeedRefresh() {
|
||||
// Auto-reload when new content is available
|
||||
updateSW(true)
|
||||
},
|
||||
onOfflineReady() {
|
||||
console.log('App ready to work offline')
|
||||
},
|
||||
// Register PWA service worker with auto-update
|
||||
const updateSW = registerSW({
|
||||
immediate: true,
|
||||
onNeedRefresh() {
|
||||
// Auto-reload when new content is available
|
||||
updateSW(true)
|
||||
},
|
||||
onOfflineReady() {
|
||||
console.log('App ready to work offline')
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,9 +1,21 @@
|
||||
import type { Router, RouteLocationNormalized, NavigationGuardNext } from 'vue-router'
|
||||
import { useAuthStore } from '../stores/auth'
|
||||
import { accountManager } from '../lib/accounts'
|
||||
import { USE_MOCK } from '../utils/mock'
|
||||
|
||||
/**
|
||||
* Check if user is authenticated through any method:
|
||||
* - Auth store (Cognito/Nostr session)
|
||||
* - Nostr account manager (persona/extension login)
|
||||
*/
|
||||
function isUserAuthenticated(): boolean {
|
||||
const authStore = useAuthStore()
|
||||
return authStore.isAuthenticated || !!accountManager.active
|
||||
}
|
||||
|
||||
/**
|
||||
* Authentication guard
|
||||
* Redirects to login if not authenticated
|
||||
* Redirects to home if not authenticated (auth modal can be opened there)
|
||||
*/
|
||||
export async function authGuard(
|
||||
to: RouteLocationNormalized,
|
||||
@@ -17,12 +29,12 @@ export async function authGuard(
|
||||
await authStore.initialize()
|
||||
}
|
||||
|
||||
if (authStore.isAuthenticated) {
|
||||
if (isUserAuthenticated()) {
|
||||
next()
|
||||
} else {
|
||||
// Store intended destination for redirect after login
|
||||
sessionStorage.setItem('redirect_after_login', to.fullPath)
|
||||
next('/login')
|
||||
next('/')
|
||||
}
|
||||
}
|
||||
|
||||
@@ -35,9 +47,7 @@ export function guestGuard(
|
||||
_from: RouteLocationNormalized,
|
||||
next: NavigationGuardNext
|
||||
) {
|
||||
const authStore = useAuthStore()
|
||||
|
||||
if (authStore.isAuthenticated) {
|
||||
if (isUserAuthenticated()) {
|
||||
next('/')
|
||||
} else {
|
||||
next()
|
||||
@@ -55,37 +65,55 @@ export function subscriptionGuard(
|
||||
) {
|
||||
const authStore = useAuthStore()
|
||||
|
||||
if (!authStore.isAuthenticated) {
|
||||
if (!isUserAuthenticated()) {
|
||||
sessionStorage.setItem('redirect_after_login', to.fullPath)
|
||||
next('/login')
|
||||
next('/')
|
||||
} else if (authStore.hasActiveSubscription()) {
|
||||
next()
|
||||
} else {
|
||||
// Redirect to subscription page
|
||||
next('/subscription')
|
||||
next('/')
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Filmmaker guard
|
||||
* Restricts access to filmmaker-only routes
|
||||
* Restricts access to filmmaker-only routes.
|
||||
* In development mode with mock data, allows any authenticated user
|
||||
* (mock Nostr logins include filmmaker profile).
|
||||
*/
|
||||
export function filmmakerGuard(
|
||||
export async function filmmakerGuard(
|
||||
to: RouteLocationNormalized,
|
||||
_from: RouteLocationNormalized,
|
||||
next: NavigationGuardNext
|
||||
) {
|
||||
const authStore = useAuthStore()
|
||||
|
||||
if (!authStore.isAuthenticated) {
|
||||
sessionStorage.setItem('redirect_after_login', to.fullPath)
|
||||
next('/login')
|
||||
} else if (authStore.isFilmmaker()) {
|
||||
next()
|
||||
} else {
|
||||
// Redirect to home with error message
|
||||
next('/')
|
||||
// Initialize auth if not already done
|
||||
if (!authStore.isAuthenticated && !authStore.isLoading) {
|
||||
await authStore.initialize()
|
||||
}
|
||||
|
||||
if (!isUserAuthenticated()) {
|
||||
sessionStorage.setItem('redirect_after_login', to.fullPath)
|
||||
next('/')
|
||||
return
|
||||
}
|
||||
|
||||
// Auth store knows about filmmaker status
|
||||
if (authStore.isFilmmaker()) {
|
||||
next()
|
||||
return
|
||||
}
|
||||
|
||||
// In dev/mock mode, Nostr account logins are treated as filmmakers
|
||||
// since the mock login creates a filmmaker profile
|
||||
if (USE_MOCK && accountManager.active) {
|
||||
next()
|
||||
return
|
||||
}
|
||||
|
||||
// Not a filmmaker — redirect to home
|
||||
next('/')
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { createRouter, createWebHistory } from 'vue-router'
|
||||
import { setupGuards, authGuard } from './guards'
|
||||
import { setupGuards, authGuard, filmmakerGuard } from './guards'
|
||||
import Browse from '../views/Browse.vue'
|
||||
|
||||
const router = createRouter({
|
||||
@@ -23,7 +23,37 @@ const router = createRouter({
|
||||
component: () => import('../views/Profile.vue'),
|
||||
beforeEnter: authGuard,
|
||||
meta: { requiresAuth: true }
|
||||
}
|
||||
},
|
||||
|
||||
// ── Creator / Filmmaker Routes ────────────────────────────────────────────
|
||||
{
|
||||
path: '/backstage',
|
||||
name: 'backstage',
|
||||
component: () => import('../views/backstage/Backstage.vue'),
|
||||
beforeEnter: filmmakerGuard,
|
||||
meta: { requiresAuth: true, requiresFilmmaker: true }
|
||||
},
|
||||
{
|
||||
path: '/backstage/project/:id',
|
||||
name: 'project-editor',
|
||||
component: () => import('../views/backstage/ProjectEditor.vue'),
|
||||
beforeEnter: filmmakerGuard,
|
||||
meta: { requiresAuth: true, requiresFilmmaker: true }
|
||||
},
|
||||
{
|
||||
path: '/backstage/analytics',
|
||||
name: 'analytics',
|
||||
component: () => import('../views/backstage/Analytics.vue'),
|
||||
beforeEnter: filmmakerGuard,
|
||||
meta: { requiresAuth: true, requiresFilmmaker: true }
|
||||
},
|
||||
{
|
||||
path: '/backstage/settings',
|
||||
name: 'filmmaker-settings',
|
||||
component: () => import('../views/backstage/Settings.vue'),
|
||||
beforeEnter: filmmakerGuard,
|
||||
meta: { requiresAuth: true, requiresFilmmaker: true }
|
||||
},
|
||||
]
|
||||
})
|
||||
|
||||
|
||||
@@ -55,9 +55,9 @@ class ApiService {
|
||||
return this.client(originalRequest)
|
||||
}
|
||||
} catch (refreshError) {
|
||||
// Token refresh failed - clear auth and redirect to login
|
||||
// Token refresh failed - clear auth and redirect to home
|
||||
this.clearAuth()
|
||||
window.location.href = '/login'
|
||||
window.location.href = '/'
|
||||
return Promise.reject(refreshError)
|
||||
}
|
||||
}
|
||||
@@ -100,6 +100,7 @@ class ApiService {
|
||||
public clearAuth() {
|
||||
sessionStorage.removeItem('auth_token')
|
||||
sessionStorage.removeItem('nostr_token')
|
||||
sessionStorage.removeItem('nostr_pubkey')
|
||||
sessionStorage.removeItem('refresh_token')
|
||||
}
|
||||
|
||||
@@ -119,13 +120,13 @@ class ApiService {
|
||||
throw new Error('No refresh token available')
|
||||
}
|
||||
|
||||
// Call refresh endpoint (implement based on backend)
|
||||
const response = await axios.post(`${apiConfig.baseURL}/auth/refresh`, {
|
||||
// Call Nostr refresh endpoint
|
||||
const response = await axios.post(`${apiConfig.baseURL}/auth/nostr/refresh`, {
|
||||
refreshToken,
|
||||
})
|
||||
|
||||
const newToken = response.data.accessToken
|
||||
this.setToken(newToken, 'cognito')
|
||||
this.setToken(newToken, 'nostr')
|
||||
|
||||
if (response.data.refreshToken) {
|
||||
sessionStorage.setItem('refresh_token', response.data.refreshToken)
|
||||
|
||||
@@ -1,4 +1,9 @@
|
||||
import axios from 'axios'
|
||||
import { apiService } from './api.service'
|
||||
import { nip98Service } from './nip98.service'
|
||||
import { apiConfig } from '../config/api.config'
|
||||
import { accountManager } from '../lib/accounts'
|
||||
import type { EventTemplate } from 'nostr-tools'
|
||||
import type {
|
||||
LoginCredentials,
|
||||
RegisterData,
|
||||
@@ -8,6 +13,48 @@ import type {
|
||||
ApiUser,
|
||||
} from '../types/api'
|
||||
|
||||
/**
|
||||
* Create a NIP-98 HTTP Auth event (kind 27235) and return it as
|
||||
* a base64-encoded Authorization header value: `Nostr <base64>`
|
||||
*/
|
||||
async function createNip98AuthHeader(
|
||||
url: string,
|
||||
method: string,
|
||||
body?: string,
|
||||
): Promise<string> {
|
||||
const account = accountManager.active
|
||||
if (!account) throw new Error('No active Nostr account')
|
||||
|
||||
const tags: string[][] = [
|
||||
['u', url],
|
||||
['method', method.toUpperCase()],
|
||||
]
|
||||
|
||||
// If there's a body, include its SHA-256 hash
|
||||
if (body && body.length > 0) {
|
||||
const encoder = new TextEncoder()
|
||||
const hash = await crypto.subtle.digest('SHA-256', encoder.encode(body))
|
||||
const hashHex = Array.from(new Uint8Array(hash))
|
||||
.map(b => b.toString(16).padStart(2, '0'))
|
||||
.join('')
|
||||
tags.push(['payload', hashHex])
|
||||
}
|
||||
|
||||
const template: EventTemplate = {
|
||||
kind: 27235,
|
||||
created_at: Math.floor(Date.now() / 1000),
|
||||
tags,
|
||||
content: '',
|
||||
}
|
||||
|
||||
// Sign with the account's signer
|
||||
const signed = await account.signer.signEvent(template)
|
||||
|
||||
// Base64-encode the signed event JSON
|
||||
const b64 = btoa(JSON.stringify(signed))
|
||||
return `Nostr ${b64}`
|
||||
}
|
||||
|
||||
/**
|
||||
* Authentication Service
|
||||
* Handles Cognito and Nostr authentication
|
||||
@@ -68,17 +115,41 @@ class AuthService {
|
||||
}
|
||||
|
||||
/**
|
||||
* Create Nostr session
|
||||
* Create Nostr session via NIP-98 HTTP Auth.
|
||||
* Signs a kind-27235 event and sends it as the Authorization header.
|
||||
*/
|
||||
async createNostrSession(request: NostrSessionRequest): Promise<NostrSessionResponse> {
|
||||
const response = await apiService.post<NostrSessionResponse>('/auth/nostr/session', request)
|
||||
async createNostrSession(_request: NostrSessionRequest): Promise<NostrSessionResponse> {
|
||||
const url = `${apiConfig.baseURL}/auth/nostr/session`
|
||||
const method = 'POST'
|
||||
|
||||
// Create NIP-98 auth header — no body is sent
|
||||
const authHeader = await createNip98AuthHeader(url, method)
|
||||
|
||||
// Send the request without a body.
|
||||
// We use axios({ method }) instead of axios.post(url, data) to
|
||||
// guarantee no Content-Type or body is serialized.
|
||||
const response = await axios<NostrSessionResponse>({
|
||||
method: 'POST',
|
||||
url,
|
||||
headers: { Authorization: authHeader },
|
||||
timeout: apiConfig.timeout,
|
||||
})
|
||||
|
||||
// Store Nostr token
|
||||
if (response.token) {
|
||||
apiService.setToken(response.token, 'nostr')
|
||||
const data = response.data
|
||||
|
||||
// Map accessToken to token for convenience
|
||||
data.token = data.accessToken
|
||||
|
||||
// Store Nostr JWT for subsequent authenticated requests
|
||||
if (data.accessToken) {
|
||||
apiService.setToken(data.accessToken, 'nostr')
|
||||
sessionStorage.setItem('nostr_token', data.accessToken)
|
||||
|
||||
// Also populate nip98Service storage so IndeehubApiService can find the token
|
||||
nip98Service.storeTokens(data.accessToken, data.refreshToken, data.expiresIn)
|
||||
}
|
||||
|
||||
return response
|
||||
return data
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
382
src/services/filmmaker.service.ts
Normal file
382
src/services/filmmaker.service.ts
Normal file
@@ -0,0 +1,382 @@
|
||||
/**
|
||||
* Filmmaker Service
|
||||
*
|
||||
* Source-aware API client for all filmmaker/creator endpoints.
|
||||
* Routes calls to either the original API (api.service) or
|
||||
* our self-hosted backend (indeehub-api.service) based on the
|
||||
* active content source.
|
||||
*/
|
||||
|
||||
import { apiService } from './api.service'
|
||||
import { indeehubApiService } from './indeehub-api.service'
|
||||
import { useContentSourceStore } from '../stores/contentSource'
|
||||
import type {
|
||||
ApiProject,
|
||||
ApiContent,
|
||||
ApiSeason,
|
||||
ApiFilmmakerAnalytics,
|
||||
ApiWatchAnalytics,
|
||||
ApiPaymentMethod,
|
||||
ApiPayment,
|
||||
ApiGenre,
|
||||
CreateProjectData,
|
||||
UpdateProjectData,
|
||||
UploadInitResponse,
|
||||
UploadPresignedUrlsResponse,
|
||||
} from '../types/api'
|
||||
|
||||
/**
|
||||
* Check if we should route requests to our self-hosted backend
|
||||
*/
|
||||
function usesSelfHosted(): boolean {
|
||||
const store = useContentSourceStore()
|
||||
return store.activeSource === 'indeehub-api'
|
||||
}
|
||||
|
||||
// ── Projects ────────────────────────────────────────────────────────────────────
|
||||
|
||||
export async function getPrivateProjects(filters?: {
|
||||
type?: string
|
||||
status?: string
|
||||
search?: string
|
||||
sort?: string
|
||||
limit?: number
|
||||
offset?: number
|
||||
}): Promise<ApiProject[]> {
|
||||
if (usesSelfHosted()) {
|
||||
const params = new URLSearchParams()
|
||||
if (filters?.type) params.append('type', filters.type)
|
||||
if (filters?.status) params.append('status', filters.status)
|
||||
if (filters?.search) params.append('search', filters.search)
|
||||
if (filters?.sort) params.append('sort', filters.sort)
|
||||
if (filters?.limit) params.append('limit', String(filters.limit))
|
||||
if (filters?.offset) params.append('offset', String(filters.offset))
|
||||
const qs = params.toString()
|
||||
return indeehubApiService.get<ApiProject[]>(`/projects/private${qs ? `?${qs}` : ''}`)
|
||||
}
|
||||
return apiService.get<ApiProject[]>('/projects/private', { params: filters })
|
||||
}
|
||||
|
||||
export async function getPrivateProjectsCount(filters?: {
|
||||
type?: string
|
||||
status?: string
|
||||
search?: string
|
||||
}): Promise<number> {
|
||||
if (usesSelfHosted()) {
|
||||
const params = new URLSearchParams()
|
||||
if (filters?.type) params.append('type', filters.type)
|
||||
if (filters?.status) params.append('status', filters.status)
|
||||
if (filters?.search) params.append('search', filters.search)
|
||||
const qs = params.toString()
|
||||
const res = await indeehubApiService.get<{ count: number }>(`/projects/private/count${qs ? `?${qs}` : ''}`)
|
||||
return res.count
|
||||
}
|
||||
const res = await apiService.get<{ count: number }>('/projects/private/count', { params: filters })
|
||||
return res.count
|
||||
}
|
||||
|
||||
export async function getPrivateProject(id: string): Promise<ApiProject> {
|
||||
if (usesSelfHosted()) {
|
||||
return indeehubApiService.get<ApiProject>(`/projects/private/${id}`)
|
||||
}
|
||||
return apiService.get<ApiProject>(`/projects/private/${id}`)
|
||||
}
|
||||
|
||||
export async function createProject(data: CreateProjectData): Promise<ApiProject> {
|
||||
if (usesSelfHosted()) {
|
||||
return indeehubApiService.post<ApiProject>('/projects', data)
|
||||
}
|
||||
return apiService.post<ApiProject>('/projects', data)
|
||||
}
|
||||
|
||||
export async function updateProject(id: string, data: UpdateProjectData): Promise<ApiProject> {
|
||||
if (usesSelfHosted()) {
|
||||
return indeehubApiService.patch<ApiProject>(`/projects/${id}`, data)
|
||||
}
|
||||
return apiService.patch<ApiProject>(`/projects/${id}`, data)
|
||||
}
|
||||
|
||||
export async function deleteProject(id: string): Promise<void> {
|
||||
if (usesSelfHosted()) {
|
||||
await indeehubApiService.delete(`/projects/${id}`)
|
||||
return
|
||||
}
|
||||
await apiService.delete(`/projects/${id}`)
|
||||
}
|
||||
|
||||
export async function checkSlugExists(projectId: string, slug: string): Promise<boolean> {
|
||||
if (usesSelfHosted()) {
|
||||
const res = await indeehubApiService.get<{ exists: boolean }>(`/projects/${projectId}/slug/${slug}/exists`)
|
||||
return res.exists
|
||||
}
|
||||
const res = await apiService.get<{ exists: boolean }>(`/projects/${projectId}/slug/${slug}/exists`)
|
||||
return res.exists
|
||||
}
|
||||
|
||||
// ── Content (episodes/seasons) ──────────────────────────────────────────────────
|
||||
|
||||
export async function getContents(projectId: string): Promise<ApiContent[]> {
|
||||
if (usesSelfHosted()) {
|
||||
return indeehubApiService.get<ApiContent[]>(`/contents/project/${projectId}`)
|
||||
}
|
||||
return apiService.get<ApiContent[]>(`/contents/project/${projectId}`)
|
||||
}
|
||||
|
||||
export async function upsertContent(
|
||||
projectId: string,
|
||||
data: Partial<ApiContent>
|
||||
): Promise<ApiContent> {
|
||||
if (usesSelfHosted()) {
|
||||
return indeehubApiService.patch<ApiContent>(`/contents/project/${projectId}`, data)
|
||||
}
|
||||
return apiService.patch<ApiContent>(`/contents/project/${projectId}`, data)
|
||||
}
|
||||
|
||||
export async function getSeasons(projectId: string): Promise<ApiSeason[]> {
|
||||
if (usesSelfHosted()) {
|
||||
return indeehubApiService.get<ApiSeason[]>(`/seasons/project/${projectId}`)
|
||||
}
|
||||
return apiService.get<ApiSeason[]>(`/seasons/project/${projectId}`)
|
||||
}
|
||||
|
||||
// ── Upload ──────────────────────────────────────────────────────────────────────
|
||||
|
||||
export async function initializeUpload(
|
||||
key: string,
|
||||
bucket: string,
|
||||
contentType: string
|
||||
): Promise<UploadInitResponse> {
|
||||
if (usesSelfHosted()) {
|
||||
return indeehubApiService.post<UploadInitResponse>('/upload/initialize', {
|
||||
Key: key,
|
||||
Bucket: bucket,
|
||||
ContentType: contentType,
|
||||
})
|
||||
}
|
||||
return apiService.post<UploadInitResponse>('/upload/initialize', {
|
||||
Key: key,
|
||||
Bucket: bucket,
|
||||
ContentType: contentType,
|
||||
})
|
||||
}
|
||||
|
||||
export async function getPresignedUrls(
|
||||
uploadId: string,
|
||||
key: string,
|
||||
bucket: string,
|
||||
parts: number
|
||||
): Promise<UploadPresignedUrlsResponse> {
|
||||
if (usesSelfHosted()) {
|
||||
return indeehubApiService.post<UploadPresignedUrlsResponse>('/upload/presigned-urls', {
|
||||
UploadId: uploadId,
|
||||
Key: key,
|
||||
Bucket: bucket,
|
||||
parts,
|
||||
})
|
||||
}
|
||||
return apiService.post<UploadPresignedUrlsResponse>('/upload/presigned-urls', {
|
||||
UploadId: uploadId,
|
||||
Key: key,
|
||||
Bucket: bucket,
|
||||
parts,
|
||||
})
|
||||
}
|
||||
|
||||
export async function finalizeUpload(
|
||||
uploadId: string,
|
||||
key: string,
|
||||
bucket: string,
|
||||
parts: Array<{ PartNumber: number; ETag: string }>
|
||||
): Promise<void> {
|
||||
const payload = { UploadId: uploadId, Key: key, Bucket: bucket, parts }
|
||||
if (usesSelfHosted()) {
|
||||
await indeehubApiService.post('/upload/finalize', payload)
|
||||
return
|
||||
}
|
||||
await apiService.post('/upload/finalize', payload)
|
||||
}
|
||||
|
||||
// ── Analytics ───────────────────────────────────────────────────────────────────
|
||||
|
||||
export async function getFilmmakerAnalytics(): Promise<ApiFilmmakerAnalytics> {
|
||||
if (usesSelfHosted()) {
|
||||
return indeehubApiService.get<ApiFilmmakerAnalytics>('/filmmakers/analytics')
|
||||
}
|
||||
return apiService.get<ApiFilmmakerAnalytics>('/filmmakers/analytics')
|
||||
}
|
||||
|
||||
export async function getWatchAnalytics(
|
||||
projectIds?: string[],
|
||||
dateRange?: { start: string; end: string }
|
||||
): Promise<ApiWatchAnalytics> {
|
||||
const params: Record<string, string> = {}
|
||||
if (projectIds?.length) params['projectIds'] = projectIds.join(',')
|
||||
if (dateRange) {
|
||||
params['start'] = dateRange.start
|
||||
params['end'] = dateRange.end
|
||||
}
|
||||
|
||||
if (usesSelfHosted()) {
|
||||
const qs = new URLSearchParams(params).toString()
|
||||
return indeehubApiService.get<ApiWatchAnalytics>(`/filmmakers/watch-analytics${qs ? `?${qs}` : ''}`)
|
||||
}
|
||||
return apiService.get<ApiWatchAnalytics>('/filmmakers/watch-analytics', { params })
|
||||
}
|
||||
|
||||
export async function getProjectRevenue(
|
||||
projectIds: string[],
|
||||
dateRange?: { start: string; end: string }
|
||||
): Promise<Record<string, number>> {
|
||||
const params: Record<string, string> = { ids: projectIds.join(',') }
|
||||
if (dateRange) {
|
||||
params['start'] = dateRange.start
|
||||
params['end'] = dateRange.end
|
||||
}
|
||||
|
||||
if (usesSelfHosted()) {
|
||||
const qs = new URLSearchParams(params).toString()
|
||||
return indeehubApiService.get<Record<string, number>>(`/projects/revenue${qs ? `?${qs}` : ''}`)
|
||||
}
|
||||
return apiService.get<Record<string, number>>('/projects/revenue', { params })
|
||||
}
|
||||
|
||||
// ── Payments / Withdrawal ───────────────────────────────────────────────────────
|
||||
|
||||
export async function getPaymentMethods(): Promise<ApiPaymentMethod[]> {
|
||||
if (usesSelfHosted()) {
|
||||
return indeehubApiService.get<ApiPaymentMethod[]>('/payment-methods')
|
||||
}
|
||||
return apiService.get<ApiPaymentMethod[]>('/payment-methods')
|
||||
}
|
||||
|
||||
export async function addPaymentMethod(data: {
|
||||
type: 'lightning' | 'bank'
|
||||
lightningAddress?: string
|
||||
bankName?: string
|
||||
accountNumber?: string
|
||||
routingNumber?: string
|
||||
withdrawalFrequency: 'manual' | 'weekly' | 'monthly'
|
||||
}): Promise<ApiPaymentMethod> {
|
||||
if (usesSelfHosted()) {
|
||||
return indeehubApiService.post<ApiPaymentMethod>('/payment-methods', data)
|
||||
}
|
||||
return apiService.post<ApiPaymentMethod>('/payment-methods', data)
|
||||
}
|
||||
|
||||
export async function updatePaymentMethod(
|
||||
id: string,
|
||||
data: Partial<ApiPaymentMethod>
|
||||
): Promise<ApiPaymentMethod> {
|
||||
if (usesSelfHosted()) {
|
||||
return indeehubApiService.patch<ApiPaymentMethod>(`/payment-methods/${id}`, data)
|
||||
}
|
||||
return apiService.patch<ApiPaymentMethod>(`/payment-methods/${id}`, data)
|
||||
}
|
||||
|
||||
export async function selectPaymentMethod(id: string): Promise<void> {
|
||||
if (usesSelfHosted()) {
|
||||
await indeehubApiService.patch(`/payment-methods/${id}/select`, {})
|
||||
return
|
||||
}
|
||||
await apiService.patch(`/payment-methods/${id}/select`, {})
|
||||
}
|
||||
|
||||
export async function removePaymentMethod(id: string): Promise<void> {
|
||||
if (usesSelfHosted()) {
|
||||
await indeehubApiService.delete(`/payment-methods/${id}`)
|
||||
return
|
||||
}
|
||||
await apiService.delete(`/payment-methods/${id}`)
|
||||
}
|
||||
|
||||
export async function validateLightningAddress(address: string): Promise<boolean> {
|
||||
if (usesSelfHosted()) {
|
||||
const res = await indeehubApiService.post<{ valid: boolean }>('/payment/validate-lightning-address', { address })
|
||||
return res.valid
|
||||
}
|
||||
const res = await apiService.post<{ valid: boolean }>('/payment/validate-lightning-address', { address })
|
||||
return res.valid
|
||||
}
|
||||
|
||||
export async function getPayments(filmId?: string): Promise<ApiPayment[]> {
|
||||
const params = filmId ? { filmId } : undefined
|
||||
if (usesSelfHosted()) {
|
||||
const qs = filmId ? `?filmId=${filmId}` : ''
|
||||
return indeehubApiService.get<ApiPayment[]>(`/payment${qs}`)
|
||||
}
|
||||
return apiService.get<ApiPayment[]>('/payment', { params })
|
||||
}
|
||||
|
||||
export async function withdraw(amount: number, filmId?: string): Promise<ApiPayment> {
|
||||
const payload: Record<string, any> = { amount }
|
||||
if (filmId) payload.filmId = filmId
|
||||
if (usesSelfHosted()) {
|
||||
return indeehubApiService.post<ApiPayment>('/payment/bank-payout', payload)
|
||||
}
|
||||
return apiService.post<ApiPayment>('/payment/bank-payout', payload)
|
||||
}
|
||||
|
||||
// ── Genres ───────────────────────────────────────────────────────────────────────
|
||||
|
||||
export async function getGenres(): Promise<ApiGenre[]> {
|
||||
if (usesSelfHosted()) {
|
||||
return indeehubApiService.get<ApiGenre[]>('/genres')
|
||||
}
|
||||
return apiService.get<ApiGenre[]>('/genres')
|
||||
}
|
||||
|
||||
// ── SAT Price ───────────────────────────────────────────────────────────────────
|
||||
|
||||
export async function getSatPrice(): Promise<number> {
|
||||
if (usesSelfHosted()) {
|
||||
const res = await indeehubApiService.get<{ price: number }>('/payment/sat-price')
|
||||
return res.price
|
||||
}
|
||||
const res = await apiService.get<{ price: number }>('/payment/sat-price')
|
||||
return res.price
|
||||
}
|
||||
|
||||
/**
|
||||
* Grouped export for convenience
|
||||
*/
|
||||
export const filmmakerService = {
|
||||
// Projects
|
||||
getPrivateProjects,
|
||||
getPrivateProjectsCount,
|
||||
getPrivateProject,
|
||||
createProject,
|
||||
updateProject,
|
||||
deleteProject,
|
||||
checkSlugExists,
|
||||
|
||||
// Content
|
||||
getContents,
|
||||
upsertContent,
|
||||
getSeasons,
|
||||
|
||||
// Upload
|
||||
initializeUpload,
|
||||
getPresignedUrls,
|
||||
finalizeUpload,
|
||||
|
||||
// Analytics
|
||||
getFilmmakerAnalytics,
|
||||
getWatchAnalytics,
|
||||
getProjectRevenue,
|
||||
|
||||
// Payments
|
||||
getPaymentMethods,
|
||||
addPaymentMethod,
|
||||
updatePaymentMethod,
|
||||
selectPaymentMethod,
|
||||
removePaymentMethod,
|
||||
validateLightningAddress,
|
||||
getPayments,
|
||||
withdraw,
|
||||
|
||||
// Genres
|
||||
getGenres,
|
||||
|
||||
// SAT Price
|
||||
getSatPrice,
|
||||
}
|
||||
190
src/services/indeehub-api.service.ts
Normal file
190
src/services/indeehub-api.service.ts
Normal file
@@ -0,0 +1,190 @@
|
||||
/**
|
||||
* IndeeHub Self-Hosted API Service
|
||||
*
|
||||
* Dedicated API client for our self-hosted NestJS backend.
|
||||
* Uses the /api/ proxy configured in nginx.
|
||||
* Auth tokens are managed by nip98.service.ts.
|
||||
*/
|
||||
|
||||
import axios, { type AxiosInstance } from 'axios'
|
||||
import { indeehubApiConfig } from '../config/api.config'
|
||||
import { nip98Service } from './nip98.service'
|
||||
|
||||
class IndeehubApiService {
|
||||
private client: AxiosInstance
|
||||
|
||||
constructor() {
|
||||
this.client = axios.create({
|
||||
baseURL: indeehubApiConfig.baseURL,
|
||||
timeout: indeehubApiConfig.timeout,
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
})
|
||||
|
||||
// Attach JWT token from NIP-98 session
|
||||
this.client.interceptors.request.use((config) => {
|
||||
const token = nip98Service.accessToken
|
||||
if (token) {
|
||||
config.headers.Authorization = `Bearer ${token}`
|
||||
}
|
||||
return config
|
||||
})
|
||||
|
||||
// Auto-refresh on 401
|
||||
this.client.interceptors.response.use(
|
||||
(response) => response,
|
||||
async (error) => {
|
||||
const originalRequest = error.config
|
||||
if (error.response?.status === 401 && !originalRequest._retry) {
|
||||
originalRequest._retry = true
|
||||
const newToken = await nip98Service.refresh()
|
||||
if (newToken) {
|
||||
originalRequest.headers.Authorization = `Bearer ${newToken}`
|
||||
return this.client(originalRequest)
|
||||
}
|
||||
}
|
||||
return Promise.reject(error)
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Generic typed GET request
|
||||
*/
|
||||
async get<T>(url: string): Promise<T> {
|
||||
const response = await this.client.get<T>(url)
|
||||
return response.data
|
||||
}
|
||||
|
||||
/**
|
||||
* Generic typed POST request
|
||||
*/
|
||||
async post<T>(url: string, data?: any): Promise<T> {
|
||||
const response = await this.client.post<T>(url, data)
|
||||
return response.data
|
||||
}
|
||||
|
||||
/**
|
||||
* Generic typed PATCH request
|
||||
*/
|
||||
async patch<T>(url: string, data?: any): Promise<T> {
|
||||
const response = await this.client.patch<T>(url, data)
|
||||
return response.data
|
||||
}
|
||||
|
||||
/**
|
||||
* Generic typed DELETE request
|
||||
*/
|
||||
async delete<T = void>(url: string): Promise<T> {
|
||||
const response = await this.client.delete<T>(url)
|
||||
return response.data
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if the API is reachable
|
||||
*/
|
||||
async healthCheck(): Promise<boolean> {
|
||||
try {
|
||||
await this.client.get('/nostr-auth/health', { timeout: 5000 })
|
||||
return true
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all published projects
|
||||
*/
|
||||
async getProjects(filters?: {
|
||||
status?: string
|
||||
type?: string
|
||||
genre?: string
|
||||
limit?: number
|
||||
offset?: number
|
||||
}): Promise<any[]> {
|
||||
const params = new URLSearchParams()
|
||||
if (filters?.status) params.append('status', filters.status)
|
||||
if (filters?.type) params.append('type', filters.type)
|
||||
if (filters?.genre) params.append('genre', filters.genre)
|
||||
if (filters?.limit) params.append('limit', String(filters.limit))
|
||||
if (filters?.offset) params.append('offset', String(filters.offset))
|
||||
|
||||
const url = `/projects${params.toString() ? `?${params.toString()}` : ''}`
|
||||
const response = await this.client.get(url)
|
||||
return response.data
|
||||
}
|
||||
|
||||
/**
|
||||
* Get a single project by ID
|
||||
*/
|
||||
async getProject(id: string): Promise<any> {
|
||||
const response = await this.client.get(`/projects/${id}`)
|
||||
return response.data
|
||||
}
|
||||
|
||||
/**
|
||||
* Get streaming URL for a content item
|
||||
* Returns different data based on deliveryMode (native vs partner)
|
||||
*/
|
||||
async getStreamingUrl(contentId: string): Promise<{
|
||||
url: string
|
||||
deliveryMode: 'native' | 'partner'
|
||||
keyUrl?: string
|
||||
drmToken?: string
|
||||
}> {
|
||||
const response = await this.client.get(`/contents/${contentId}/stream`)
|
||||
return response.data
|
||||
}
|
||||
|
||||
/**
|
||||
* Get user's library items
|
||||
*/
|
||||
async getLibrary(): Promise<any[]> {
|
||||
const response = await this.client.get('/library')
|
||||
return response.data
|
||||
}
|
||||
|
||||
/**
|
||||
* Add a project to user's library
|
||||
*/
|
||||
async addToLibrary(projectId: string): Promise<any> {
|
||||
const response = await this.client.post('/library', { projectId })
|
||||
return response.data
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove a project from user's library
|
||||
*/
|
||||
async removeFromLibrary(projectId: string): Promise<void> {
|
||||
await this.client.delete(`/library/${projectId}`)
|
||||
}
|
||||
|
||||
/**
|
||||
* Get current user profile (requires auth)
|
||||
*/
|
||||
async getMe(): Promise<any> {
|
||||
const response = await this.client.get('/auth/me')
|
||||
return response.data
|
||||
}
|
||||
|
||||
/**
|
||||
* Get genres
|
||||
*/
|
||||
async getGenres(): Promise<any[]> {
|
||||
const response = await this.client.get('/genres')
|
||||
return response.data
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the CDN URL for a storage path
|
||||
*/
|
||||
getCdnUrl(path: string): string {
|
||||
if (!path) return ''
|
||||
if (path.startsWith('http')) return path
|
||||
if (path.startsWith('/')) return path
|
||||
return `${indeehubApiConfig.cdnURL}/${path}`
|
||||
}
|
||||
}
|
||||
|
||||
export const indeehubApiService = new IndeehubApiService()
|
||||
@@ -1,5 +1,6 @@
|
||||
import { apiService } from './api.service'
|
||||
import type { ApiRent, ApiContent } from '../types/api'
|
||||
import { USE_MOCK } from '../utils/mock'
|
||||
|
||||
/**
|
||||
* Library Service
|
||||
@@ -14,10 +15,7 @@ class LibraryService {
|
||||
rented: ApiRent[]
|
||||
continueWatching: Array<{ content: ApiContent; progress: number }>
|
||||
}> {
|
||||
// Check if we're in development mode
|
||||
const useMockData = import.meta.env.VITE_USE_MOCK_DATA === 'true' || import.meta.env.DEV
|
||||
|
||||
if (useMockData) {
|
||||
if (USE_MOCK) {
|
||||
// Mock library data for development
|
||||
console.log('🔧 Development mode: Using mock library data')
|
||||
|
||||
@@ -81,15 +79,31 @@ class LibraryService {
|
||||
}
|
||||
|
||||
/**
|
||||
* Rent content
|
||||
* Rent content via Lightning invoice.
|
||||
* Calls POST /rents/lightning which creates a BTCPay invoice.
|
||||
* Returns the invoice details including BOLT11 for QR code display.
|
||||
*/
|
||||
async rentContent(contentId: string, paymentMethodId?: string): Promise<ApiRent> {
|
||||
return apiService.post<ApiRent>('/rents', {
|
||||
contentId,
|
||||
paymentMethodId,
|
||||
async rentContent(contentId: string, couponCode?: string): Promise<{
|
||||
id: string
|
||||
contentId: string
|
||||
lnInvoice: string
|
||||
expiration: string
|
||||
sourceAmount: { amount: string; currency: string }
|
||||
conversionRate: { amount: string; sourceCurrency: string; targetCurrency: string }
|
||||
}> {
|
||||
return apiService.post('/rents/lightning', {
|
||||
id: contentId,
|
||||
couponCode,
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a rent exists for a given content ID (for polling after payment).
|
||||
*/
|
||||
async checkRentExists(contentId: string): Promise<{ exists: boolean }> {
|
||||
return apiService.get(`/rents/content/${contentId}/exists`)
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if user has access to content
|
||||
*/
|
||||
|
||||
172
src/services/nip98.service.ts
Normal file
172
src/services/nip98.service.ts
Normal file
@@ -0,0 +1,172 @@
|
||||
/**
|
||||
* NIP-98 Authentication Bridge
|
||||
*
|
||||
* Handles Nostr-based authentication with the self-hosted backend:
|
||||
* 1. Signs NIP-98 HTTP Auth events (kind 27235)
|
||||
* 2. Exchanges them for JWT session tokens
|
||||
* 3. Manages token storage and auto-refresh
|
||||
*/
|
||||
|
||||
import axios from 'axios'
|
||||
import { indeehubApiConfig } from '../config/api.config'
|
||||
|
||||
const TOKEN_KEY = 'indeehub_api_token'
|
||||
const REFRESH_KEY = 'indeehub_api_refresh'
|
||||
const EXPIRES_KEY = 'indeehub_api_expires'
|
||||
|
||||
class Nip98Service {
|
||||
private refreshPromise: Promise<string> | null = null
|
||||
|
||||
/**
|
||||
* Check if we have a valid (non-expired) API token
|
||||
*/
|
||||
get hasValidToken(): boolean {
|
||||
const token = sessionStorage.getItem(TOKEN_KEY)
|
||||
const expires = sessionStorage.getItem(EXPIRES_KEY)
|
||||
if (!token || !expires) return false
|
||||
return Date.now() < Number(expires)
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the current access token
|
||||
*/
|
||||
get accessToken(): string | null {
|
||||
if (!this.hasValidToken) return null
|
||||
return sessionStorage.getItem(TOKEN_KEY)
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a session with the backend using a NIP-98 auth event.
|
||||
*
|
||||
* The signer creates a kind 27235 event targeting POST /auth/nostr/session.
|
||||
* The backend verifies the event and returns JWT tokens.
|
||||
*
|
||||
* @param signer - An applesauce signer instance (or any object with sign() method)
|
||||
* @param pubkey - The Nostr pubkey (hex)
|
||||
*/
|
||||
async createSession(signer: any, pubkey: string): Promise<boolean> {
|
||||
try {
|
||||
const url = `${indeehubApiConfig.baseURL}/auth/nostr/session`
|
||||
const now = Math.floor(Date.now() / 1000)
|
||||
|
||||
// Build the NIP-98 event
|
||||
const event = {
|
||||
kind: 27235,
|
||||
created_at: now,
|
||||
tags: [
|
||||
['u', url],
|
||||
['method', 'POST'],
|
||||
],
|
||||
content: '',
|
||||
pubkey,
|
||||
}
|
||||
|
||||
// Sign the event using the Nostr signer
|
||||
let signedEvent: any
|
||||
if (typeof signer.sign === 'function') {
|
||||
signedEvent = await signer.sign(event)
|
||||
} else if (typeof signer.signEvent === 'function') {
|
||||
signedEvent = await signer.signEvent(event)
|
||||
} else {
|
||||
throw new Error('Signer does not have a sign or signEvent method')
|
||||
}
|
||||
|
||||
// Base64-encode the signed event
|
||||
const encodedEvent = btoa(JSON.stringify(signedEvent))
|
||||
|
||||
// Send to backend — no body to avoid NIP-98 payload mismatch
|
||||
const response = await axios({
|
||||
method: 'POST',
|
||||
url,
|
||||
headers: {
|
||||
Authorization: `Nostr ${encodedEvent}`,
|
||||
},
|
||||
timeout: 15000,
|
||||
})
|
||||
|
||||
const { accessToken, refreshToken, expiresIn } = response.data
|
||||
|
||||
// Store tokens
|
||||
sessionStorage.setItem(TOKEN_KEY, accessToken)
|
||||
sessionStorage.setItem(REFRESH_KEY, refreshToken)
|
||||
sessionStorage.setItem(
|
||||
EXPIRES_KEY,
|
||||
String(Date.now() + (expiresIn * 1000) - 30000) // 30s buffer
|
||||
)
|
||||
|
||||
// Also set on the main apiService for backwards compatibility
|
||||
sessionStorage.setItem('nostr_token', accessToken)
|
||||
|
||||
return true
|
||||
} catch (error) {
|
||||
console.error('[nip98] Failed to create session:', error)
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Refresh the session using the stored refresh token
|
||||
*/
|
||||
async refresh(): Promise<string | null> {
|
||||
if (this.refreshPromise) return this.refreshPromise
|
||||
|
||||
this.refreshPromise = (async () => {
|
||||
try {
|
||||
const refreshToken = sessionStorage.getItem(REFRESH_KEY)
|
||||
if (!refreshToken) return null
|
||||
|
||||
const response = await axios.post(
|
||||
`${indeehubApiConfig.baseURL}/auth/nostr/refresh`,
|
||||
{ refreshToken },
|
||||
{ timeout: 15000 }
|
||||
)
|
||||
|
||||
const { accessToken, refreshToken: newRefresh, expiresIn } = response.data
|
||||
|
||||
sessionStorage.setItem(TOKEN_KEY, accessToken)
|
||||
if (newRefresh) sessionStorage.setItem(REFRESH_KEY, newRefresh)
|
||||
sessionStorage.setItem(
|
||||
EXPIRES_KEY,
|
||||
String(Date.now() + (expiresIn * 1000) - 30000)
|
||||
)
|
||||
sessionStorage.setItem('nostr_token', accessToken)
|
||||
|
||||
return accessToken
|
||||
} catch {
|
||||
this.clearSession()
|
||||
return null
|
||||
} finally {
|
||||
this.refreshPromise = null
|
||||
}
|
||||
})()
|
||||
|
||||
return this.refreshPromise
|
||||
}
|
||||
|
||||
/**
|
||||
* Store tokens from an external auth flow (e.g. auth.service.ts).
|
||||
* Keeps nip98Service in sync so IndeehubApiService can read the token.
|
||||
*/
|
||||
storeTokens(accessToken: string, refreshToken?: string, expiresIn?: number) {
|
||||
sessionStorage.setItem(TOKEN_KEY, accessToken)
|
||||
if (refreshToken) {
|
||||
sessionStorage.setItem(REFRESH_KEY, refreshToken)
|
||||
}
|
||||
// Default to 1 hour if expiresIn is not provided
|
||||
const ttlMs = (expiresIn ?? 3600) * 1000
|
||||
sessionStorage.setItem(EXPIRES_KEY, String(Date.now() + ttlMs - 30000))
|
||||
// Backwards compatibility
|
||||
sessionStorage.setItem('nostr_token', accessToken)
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear stored session data
|
||||
*/
|
||||
clearSession() {
|
||||
sessionStorage.removeItem(TOKEN_KEY)
|
||||
sessionStorage.removeItem(REFRESH_KEY)
|
||||
sessionStorage.removeItem(EXPIRES_KEY)
|
||||
}
|
||||
}
|
||||
|
||||
export const nip98Service = new Nip98Service()
|
||||
@@ -46,6 +46,22 @@ class SubscriptionService {
|
||||
return apiService.post<ApiSubscription>(`/subscriptions/${subscriptionId}/resume`)
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a Lightning subscription invoice via BTCPay.
|
||||
* Returns invoice details including BOLT11 for QR code display.
|
||||
*/
|
||||
async createLightningSubscription(data: {
|
||||
type: 'enthusiast' | 'film-buff' | 'cinephile'
|
||||
period: 'monthly' | 'annual'
|
||||
}): Promise<{
|
||||
lnInvoice: string
|
||||
expiration: string
|
||||
sourceAmount: { amount: string; currency: string }
|
||||
id: string
|
||||
}> {
|
||||
return apiService.post('/subscriptions/lightning', data)
|
||||
}
|
||||
|
||||
/**
|
||||
* Update payment method
|
||||
*/
|
||||
|
||||
@@ -1,7 +1,73 @@
|
||||
import { defineStore } from 'pinia'
|
||||
import { ref } from 'vue'
|
||||
import { authService } from '../services/auth.service'
|
||||
import { nip98Service } from '../services/nip98.service'
|
||||
import type { ApiUser } from '../types/api'
|
||||
import { USE_MOCK } from '../utils/mock'
|
||||
|
||||
/** Returns true when the error looks like a network / connection failure */
|
||||
function isConnectionError(error: any): boolean {
|
||||
const msg = error?.message?.toLowerCase() || ''
|
||||
return (
|
||||
msg.includes('unable to connect') ||
|
||||
msg.includes('network error') ||
|
||||
msg.includes('failed to fetch') ||
|
||||
msg.includes('econnrefused')
|
||||
)
|
||||
}
|
||||
|
||||
/** Build a mock Nostr user with filmmaker profile + subscription */
|
||||
function buildMockNostrUser(pubkey: string) {
|
||||
const mockUserId = 'mock-nostr-user-' + pubkey.slice(0, 8)
|
||||
return {
|
||||
id: mockUserId,
|
||||
email: `${pubkey.slice(0, 8)}@nostr.local`,
|
||||
legalName: 'Nostr User',
|
||||
nostrPubkey: pubkey,
|
||||
createdAt: new Date().toISOString(),
|
||||
updatedAt: new Date().toISOString(),
|
||||
filmmaker: {
|
||||
id: 'mock-filmmaker-' + pubkey.slice(0, 8),
|
||||
userId: mockUserId,
|
||||
professionalName: 'Nostr Filmmaker',
|
||||
bio: 'Independent filmmaker and content creator.',
|
||||
},
|
||||
subscriptions: [{
|
||||
id: 'mock-sub-cinephile',
|
||||
userId: mockUserId,
|
||||
tier: 'cinephile' as const,
|
||||
status: 'active' as const,
|
||||
currentPeriodStart: new Date().toISOString(),
|
||||
currentPeriodEnd: new Date(Date.now() + 30 * 24 * 60 * 60 * 1000).toISOString(),
|
||||
cancelAtPeriodEnd: false,
|
||||
stripePriceId: 'mock-price-cinephile',
|
||||
stripeCustomerId: 'mock-customer-' + pubkey.slice(0, 8),
|
||||
}],
|
||||
}
|
||||
}
|
||||
|
||||
/** Build a mock Cognito user with subscription */
|
||||
function buildMockCognitoUser(email: string, legalName?: string) {
|
||||
const username = email.split('@')[0]
|
||||
return {
|
||||
id: 'mock-user-' + username,
|
||||
email,
|
||||
legalName: legalName || username.charAt(0).toUpperCase() + username.slice(1),
|
||||
createdAt: new Date().toISOString(),
|
||||
updatedAt: new Date().toISOString(),
|
||||
subscriptions: [{
|
||||
id: 'mock-sub-cinephile',
|
||||
userId: 'mock-user-' + username,
|
||||
tier: 'cinephile' as const,
|
||||
status: 'active' as const,
|
||||
currentPeriodStart: new Date().toISOString(),
|
||||
currentPeriodEnd: new Date(Date.now() + 30 * 24 * 60 * 60 * 1000).toISOString(),
|
||||
cancelAtPeriodEnd: false,
|
||||
stripePriceId: 'mock-price-cinephile',
|
||||
stripeCustomerId: 'mock-customer-' + username,
|
||||
}],
|
||||
}
|
||||
}
|
||||
|
||||
export type AuthType = 'cognito' | 'nostr' | null
|
||||
|
||||
@@ -28,35 +94,73 @@ export const useAuthStore = defineStore('auth', () => {
|
||||
const isLoading = ref(false)
|
||||
|
||||
/**
|
||||
* Initialize auth state from stored tokens
|
||||
* Initialize auth state from stored tokens.
|
||||
* In dev/mock mode, reconstructs the mock user directly from
|
||||
* sessionStorage rather than calling the (non-existent) backend API.
|
||||
*/
|
||||
async function initialize() {
|
||||
// Bail out early if already authenticated — prevents
|
||||
// re-initialization from wiping state on subsequent navigations
|
||||
if (isAuthenticated.value && user.value) {
|
||||
return
|
||||
}
|
||||
|
||||
isLoading.value = true
|
||||
|
||||
try {
|
||||
// Check for existing tokens
|
||||
const storedCognitoToken = sessionStorage.getItem('auth_token')
|
||||
const storedNostrToken = sessionStorage.getItem('nostr_token')
|
||||
const storedPubkey = sessionStorage.getItem('nostr_pubkey')
|
||||
|
||||
if (storedCognitoToken || storedNostrToken) {
|
||||
// Validate session and fetch user
|
||||
if (!storedCognitoToken && !storedNostrToken) {
|
||||
return // Nothing stored — not logged in
|
||||
}
|
||||
|
||||
// Helper: restore mock session from sessionStorage
|
||||
const restoreAsMock = () => {
|
||||
if (storedNostrToken && storedPubkey) {
|
||||
user.value = buildMockNostrUser(storedPubkey)
|
||||
nostrPubkey.value = storedPubkey
|
||||
authType.value = 'nostr'
|
||||
isAuthenticated.value = true
|
||||
} else if (storedCognitoToken) {
|
||||
user.value = buildMockCognitoUser('dev@local', 'Dev User')
|
||||
cognitoToken.value = storedCognitoToken
|
||||
authType.value = 'cognito'
|
||||
isAuthenticated.value = true
|
||||
}
|
||||
}
|
||||
|
||||
if (USE_MOCK) {
|
||||
restoreAsMock()
|
||||
return
|
||||
}
|
||||
|
||||
// Real mode: validate session with backend API
|
||||
try {
|
||||
const isValid = await authService.validateSession()
|
||||
|
||||
|
||||
if (isValid) {
|
||||
await fetchCurrentUser()
|
||||
|
||||
|
||||
if (storedCognitoToken) {
|
||||
authType.value = 'cognito'
|
||||
cognitoToken.value = storedCognitoToken
|
||||
} else {
|
||||
authType.value = 'nostr'
|
||||
}
|
||||
|
||||
|
||||
isAuthenticated.value = true
|
||||
} else {
|
||||
// Session invalid - clear auth
|
||||
await logout()
|
||||
}
|
||||
} catch (apiError: any) {
|
||||
if (isConnectionError(apiError)) {
|
||||
console.warn('Backend not reachable — falling back to mock session.')
|
||||
restoreAsMock()
|
||||
} else {
|
||||
throw apiError
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to initialize auth:', error)
|
||||
@@ -71,74 +175,49 @@ export const useAuthStore = defineStore('auth', () => {
|
||||
*/
|
||||
async function loginWithCognito(email: string, password: string) {
|
||||
isLoading.value = true
|
||||
|
||||
try {
|
||||
// Check if we're in development mode without backend
|
||||
const useMockData = import.meta.env.VITE_USE_MOCK_DATA === 'true' || import.meta.env.DEV
|
||||
|
||||
if (useMockData) {
|
||||
// Mock Cognito login for development
|
||||
console.log('🔧 Development mode: Using mock Cognito authentication')
|
||||
|
||||
// Simulate API delay
|
||||
await new Promise(resolve => setTimeout(resolve, 500))
|
||||
|
||||
// Create a mock user with active subscription
|
||||
const mockUser = {
|
||||
id: 'mock-user-' + email.split('@')[0],
|
||||
email: email,
|
||||
legalName: email.split('@')[0].charAt(0).toUpperCase() + email.split('@')[0].slice(1),
|
||||
createdAt: new Date().toISOString(),
|
||||
updatedAt: new Date().toISOString(),
|
||||
subscriptions: [{
|
||||
id: 'mock-sub-cinephile',
|
||||
userId: 'mock-user-' + email.split('@')[0],
|
||||
tier: 'cinephile' as const,
|
||||
status: 'active' as const,
|
||||
currentPeriodStart: new Date().toISOString(),
|
||||
currentPeriodEnd: new Date(Date.now() + 30 * 24 * 60 * 60 * 1000).toISOString(), // 30 days from now
|
||||
cancelAtPeriodEnd: false,
|
||||
stripePriceId: 'mock-price-cinephile',
|
||||
stripeCustomerId: 'mock-customer-' + email.split('@')[0],
|
||||
}],
|
||||
}
|
||||
|
||||
console.log('✅ Mock user created with Cinephile subscription (full access)')
|
||||
|
||||
cognitoToken.value = 'mock-jwt-token-' + Date.now()
|
||||
authType.value = 'cognito'
|
||||
user.value = mockUser
|
||||
isAuthenticated.value = true
|
||||
|
||||
// Store mock tokens
|
||||
sessionStorage.setItem('auth_token', cognitoToken.value)
|
||||
sessionStorage.setItem('refresh_token', 'mock-refresh-token')
|
||||
|
||||
return {
|
||||
accessToken: cognitoToken.value,
|
||||
idToken: 'mock-id-token',
|
||||
refreshToken: 'mock-refresh-token',
|
||||
expiresIn: 3600,
|
||||
}
|
||||
|
||||
// Mock Cognito login helper
|
||||
const mockLogin = () => {
|
||||
const mockUser = buildMockCognitoUser(email)
|
||||
console.log('🔧 Using mock Cognito authentication')
|
||||
|
||||
cognitoToken.value = 'mock-jwt-token-' + Date.now()
|
||||
authType.value = 'cognito'
|
||||
user.value = mockUser
|
||||
isAuthenticated.value = true
|
||||
|
||||
sessionStorage.setItem('auth_token', cognitoToken.value)
|
||||
sessionStorage.setItem('refresh_token', 'mock-refresh-token')
|
||||
|
||||
return {
|
||||
accessToken: cognitoToken.value,
|
||||
idToken: 'mock-id-token',
|
||||
refreshToken: 'mock-refresh-token',
|
||||
expiresIn: 3600,
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
try {
|
||||
if (USE_MOCK) {
|
||||
await new Promise(resolve => setTimeout(resolve, 500))
|
||||
return mockLogin()
|
||||
}
|
||||
|
||||
// Real API call
|
||||
const response = await authService.login({ email, password })
|
||||
|
||||
|
||||
cognitoToken.value = response.accessToken
|
||||
authType.value = 'cognito'
|
||||
|
||||
|
||||
await fetchCurrentUser()
|
||||
|
||||
|
||||
isAuthenticated.value = true
|
||||
|
||||
|
||||
return response
|
||||
} catch (error: any) {
|
||||
// Provide helpful error message
|
||||
if (error.message?.includes('Unable to connect')) {
|
||||
throw new Error(
|
||||
'Backend API not available. To use real authentication, start the backend server and set VITE_USE_MOCK_DATA=false in .env'
|
||||
)
|
||||
if (isConnectionError(error)) {
|
||||
console.warn('Backend not reachable — falling back to mock Cognito login.')
|
||||
return mockLogin()
|
||||
}
|
||||
throw error
|
||||
} finally {
|
||||
@@ -151,75 +230,56 @@ export const useAuthStore = defineStore('auth', () => {
|
||||
*/
|
||||
async function loginWithNostr(pubkey: string, signature: string, event: any) {
|
||||
isLoading.value = true
|
||||
|
||||
|
||||
// Mock Nostr login helper
|
||||
const mockLogin = () => {
|
||||
const mockUser = buildMockNostrUser(pubkey)
|
||||
console.warn('🔧 Using mock Nostr authentication (backend not available)')
|
||||
|
||||
nostrPubkey.value = pubkey
|
||||
authType.value = 'nostr'
|
||||
user.value = mockUser
|
||||
isAuthenticated.value = true
|
||||
|
||||
sessionStorage.setItem('nostr_token', 'mock-nostr-token-' + pubkey.slice(0, 16))
|
||||
sessionStorage.setItem('nostr_pubkey', pubkey)
|
||||
|
||||
return { token: 'mock-nostr-token', user: mockUser }
|
||||
}
|
||||
|
||||
try {
|
||||
// Check if we're in development mode without backend
|
||||
const useMockData = import.meta.env.VITE_USE_MOCK_DATA === 'true' || import.meta.env.DEV
|
||||
|
||||
if (useMockData) {
|
||||
// Mock Nostr login for development
|
||||
console.log('🔧 Development mode: Using mock Nostr authentication')
|
||||
|
||||
// Simulate API delay
|
||||
if (USE_MOCK) {
|
||||
await new Promise(resolve => setTimeout(resolve, 500))
|
||||
|
||||
// Create a mock Nostr user with active subscription
|
||||
const mockUser = {
|
||||
id: 'mock-nostr-user-' + pubkey.slice(0, 8),
|
||||
email: `${pubkey.slice(0, 8)}@nostr.local`,
|
||||
legalName: 'Nostr User',
|
||||
nostrPubkey: pubkey,
|
||||
createdAt: new Date().toISOString(),
|
||||
updatedAt: new Date().toISOString(),
|
||||
subscriptions: [{
|
||||
id: 'mock-sub-cinephile',
|
||||
userId: 'mock-nostr-user-' + pubkey.slice(0, 8),
|
||||
tier: 'cinephile' as const,
|
||||
status: 'active' as const,
|
||||
currentPeriodStart: new Date().toISOString(),
|
||||
currentPeriodEnd: new Date(Date.now() + 30 * 24 * 60 * 60 * 1000).toISOString(), // 30 days from now
|
||||
cancelAtPeriodEnd: false,
|
||||
stripePriceId: 'mock-price-cinephile',
|
||||
stripeCustomerId: 'mock-customer-' + pubkey.slice(0, 8),
|
||||
}],
|
||||
}
|
||||
|
||||
console.log('✅ Mock Nostr user created with Cinephile subscription (full access)')
|
||||
console.log('📝 Nostr Pubkey:', pubkey)
|
||||
|
||||
nostrPubkey.value = pubkey
|
||||
authType.value = 'nostr'
|
||||
user.value = mockUser
|
||||
isAuthenticated.value = true
|
||||
|
||||
// Store mock session
|
||||
sessionStorage.setItem('nostr_token', 'mock-nostr-token-' + pubkey.slice(0, 16))
|
||||
|
||||
return {
|
||||
token: 'mock-nostr-token',
|
||||
user: mockUser,
|
||||
}
|
||||
return mockLogin()
|
||||
}
|
||||
|
||||
// Real API call
|
||||
|
||||
// Real API call — creates NIP-98 signed session via the active
|
||||
// Nostr account in accountManager (set by useAccounts before this call)
|
||||
const response = await authService.createNostrSession({
|
||||
pubkey,
|
||||
signature,
|
||||
event,
|
||||
})
|
||||
|
||||
|
||||
nostrPubkey.value = pubkey
|
||||
authType.value = 'nostr'
|
||||
user.value = response.user
|
||||
sessionStorage.setItem('nostr_pubkey', pubkey)
|
||||
isAuthenticated.value = true
|
||||
|
||||
|
||||
// Backend returns JWT tokens but no user object.
|
||||
// Fetch the user profile with the new access token.
|
||||
try {
|
||||
await fetchCurrentUser()
|
||||
} catch {
|
||||
// User may not exist in DB yet — create a minimal local representation
|
||||
user.value = buildMockNostrUser(pubkey)
|
||||
}
|
||||
|
||||
return response
|
||||
} catch (error: any) {
|
||||
// Provide helpful error message
|
||||
if (error.message?.includes('Unable to connect')) {
|
||||
throw new Error(
|
||||
'Backend API not available. To use real Nostr authentication, start the backend server and set VITE_USE_MOCK_DATA=false in .env'
|
||||
)
|
||||
if (isConnectionError(error)) {
|
||||
console.warn('Backend not reachable — falling back to mock Nostr login.')
|
||||
return mockLogin()
|
||||
}
|
||||
throw error
|
||||
} finally {
|
||||
@@ -232,78 +292,53 @@ export const useAuthStore = defineStore('auth', () => {
|
||||
*/
|
||||
async function register(email: string, password: string, legalName: string) {
|
||||
isLoading.value = true
|
||||
|
||||
try {
|
||||
// Check if we're in development mode without backend
|
||||
const useMockData = import.meta.env.VITE_USE_MOCK_DATA === 'true' || import.meta.env.DEV
|
||||
|
||||
if (useMockData) {
|
||||
// Mock registration for development
|
||||
console.log('🔧 Development mode: Using mock registration')
|
||||
|
||||
// Simulate API delay
|
||||
await new Promise(resolve => setTimeout(resolve, 500))
|
||||
|
||||
// Create a mock user with active subscription
|
||||
const mockUser = {
|
||||
id: 'mock-user-' + email.split('@')[0],
|
||||
email: email,
|
||||
legalName: legalName,
|
||||
createdAt: new Date().toISOString(),
|
||||
updatedAt: new Date().toISOString(),
|
||||
subscriptions: [{
|
||||
id: 'mock-sub-cinephile',
|
||||
userId: 'mock-user-' + email.split('@')[0],
|
||||
tier: 'cinephile' as const,
|
||||
status: 'active' as const,
|
||||
currentPeriodStart: new Date().toISOString(),
|
||||
currentPeriodEnd: new Date(Date.now() + 30 * 24 * 60 * 60 * 1000).toISOString(), // 30 days from now
|
||||
cancelAtPeriodEnd: false,
|
||||
stripePriceId: 'mock-price-cinephile',
|
||||
stripeCustomerId: 'mock-customer-' + email.split('@')[0],
|
||||
}],
|
||||
}
|
||||
|
||||
console.log('✅ Mock user registered with Cinephile subscription (full access)')
|
||||
|
||||
cognitoToken.value = 'mock-jwt-token-' + Date.now()
|
||||
authType.value = 'cognito'
|
||||
user.value = mockUser
|
||||
isAuthenticated.value = true
|
||||
|
||||
// Store mock tokens
|
||||
sessionStorage.setItem('auth_token', cognitoToken.value)
|
||||
sessionStorage.setItem('refresh_token', 'mock-refresh-token')
|
||||
|
||||
return {
|
||||
accessToken: cognitoToken.value,
|
||||
idToken: 'mock-id-token',
|
||||
refreshToken: 'mock-refresh-token',
|
||||
expiresIn: 3600,
|
||||
}
|
||||
|
||||
// Mock registration helper
|
||||
const mockRegister = () => {
|
||||
const mockUser = buildMockCognitoUser(email, legalName)
|
||||
console.warn('🔧 Using mock registration (backend not available)')
|
||||
|
||||
cognitoToken.value = 'mock-jwt-token-' + Date.now()
|
||||
authType.value = 'cognito'
|
||||
user.value = mockUser
|
||||
isAuthenticated.value = true
|
||||
|
||||
sessionStorage.setItem('auth_token', cognitoToken.value)
|
||||
sessionStorage.setItem('refresh_token', 'mock-refresh-token')
|
||||
|
||||
return {
|
||||
accessToken: cognitoToken.value,
|
||||
idToken: 'mock-id-token',
|
||||
refreshToken: 'mock-refresh-token',
|
||||
expiresIn: 3600,
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
try {
|
||||
if (USE_MOCK) {
|
||||
await new Promise(resolve => setTimeout(resolve, 500))
|
||||
return mockRegister()
|
||||
}
|
||||
|
||||
// Real API call
|
||||
const response = await authService.register({
|
||||
email,
|
||||
password,
|
||||
legalName,
|
||||
})
|
||||
|
||||
|
||||
cognitoToken.value = response.accessToken
|
||||
authType.value = 'cognito'
|
||||
|
||||
|
||||
await fetchCurrentUser()
|
||||
|
||||
|
||||
isAuthenticated.value = true
|
||||
|
||||
|
||||
return response
|
||||
} catch (error: any) {
|
||||
// Provide helpful error message
|
||||
if (error.message?.includes('Unable to connect')) {
|
||||
throw new Error(
|
||||
'Backend API not available. To use real authentication, start the backend server and set VITE_USE_MOCK_DATA=false in .env'
|
||||
)
|
||||
if (isConnectionError(error)) {
|
||||
console.warn('Backend not reachable — falling back to mock registration.')
|
||||
return mockRegister()
|
||||
}
|
||||
throw error
|
||||
} finally {
|
||||
@@ -335,6 +370,7 @@ export const useAuthStore = defineStore('auth', () => {
|
||||
*/
|
||||
async function logout() {
|
||||
await authService.logout()
|
||||
nip98Service.clearSession()
|
||||
|
||||
user.value = null
|
||||
authType.value = null
|
||||
|
||||
@@ -6,8 +6,9 @@ import { topDocFilms, topDocBitcoin, topDocCrypto, topDocMoney, topDocEconomics
|
||||
import { contentService } from '../services/content.service'
|
||||
import { mapApiProjectsToContents } from '../utils/mappers'
|
||||
import { useContentSourceStore } from './contentSource'
|
||||
|
||||
const USE_MOCK_DATA = import.meta.env.VITE_USE_MOCK_DATA === 'true' || import.meta.env.DEV
|
||||
import { indeehubApiService } from '../services/indeehub-api.service'
|
||||
import { useFilmmaker } from '../composables/useFilmmaker'
|
||||
import { USE_MOCK as USE_MOCK_DATA } from '../utils/mock'
|
||||
|
||||
export const useContentStore = defineStore('content', () => {
|
||||
const featuredContent = ref<Content | null>(null)
|
||||
@@ -24,25 +25,21 @@ export const useContentStore = defineStore('content', () => {
|
||||
const error = ref<string | null>(null)
|
||||
|
||||
/**
|
||||
* Fetch content from API
|
||||
* Fetch content from the original API (external IndeeHub)
|
||||
*/
|
||||
async function fetchContentFromApi() {
|
||||
try {
|
||||
// Fetch all published projects
|
||||
const projects = await contentService.getProjects({ status: 'published' })
|
||||
|
||||
if (projects.length === 0) {
|
||||
throw new Error('No content available')
|
||||
}
|
||||
|
||||
// Map API data to content format
|
||||
const allContent = mapApiProjectsToContents(projects)
|
||||
|
||||
// Set featured content (first film project or first project)
|
||||
const featuredFilm = allContent.find(c => c.type === 'film') || allContent[0]
|
||||
featuredContent.value = featuredFilm
|
||||
|
||||
// Organize into rows
|
||||
const films = allContent.filter(c => c.type === 'film')
|
||||
const bitcoinContent = allContent.filter(c =>
|
||||
c.categories?.some(cat => cat.toLowerCase().includes('bitcoin'))
|
||||
@@ -68,6 +65,64 @@ export const useContentStore = defineStore('content', () => {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch content from our self-hosted IndeeHub API
|
||||
*/
|
||||
async function fetchContentFromIndeehubApi() {
|
||||
try {
|
||||
const response = await indeehubApiService.getProjects()
|
||||
|
||||
// Handle both array responses and wrapped responses like { data: [...] }
|
||||
const projects = Array.isArray(response) ? response : (response as any)?.data ?? []
|
||||
|
||||
if (!Array.isArray(projects) || projects.length === 0) {
|
||||
throw new Error('No content available from IndeeHub API')
|
||||
}
|
||||
|
||||
// Map API projects to frontend Content format
|
||||
const allContent: Content[] = projects.map((p: any) => ({
|
||||
id: p.id,
|
||||
title: p.title,
|
||||
description: p.synopsis || '',
|
||||
thumbnail: p.poster || '',
|
||||
backdrop: p.poster || '',
|
||||
type: p.type || 'film',
|
||||
slug: p.slug,
|
||||
rentalPrice: p.rentalPrice,
|
||||
status: p.status,
|
||||
categories: p.genre ? [p.genre.name] : [],
|
||||
streamingUrl: p.streamingUrl || p.partnerStreamUrl || undefined,
|
||||
apiData: {
|
||||
deliveryMode: p.deliveryMode,
|
||||
partnerStreamUrl: p.partnerStreamUrl,
|
||||
partnerDashUrl: p.partnerDashUrl,
|
||||
partnerFairplayUrl: p.partnerFairplayUrl,
|
||||
partnerDrmToken: p.partnerDrmToken,
|
||||
},
|
||||
}))
|
||||
|
||||
const featuredFilm = allContent.find(c => c.type === 'film') || allContent[0]
|
||||
featuredContent.value = featuredFilm
|
||||
|
||||
const films = allContent.filter(c => c.type === 'film')
|
||||
const bitcoinContent = allContent.filter(c =>
|
||||
c.categories?.some(cat => cat.toLowerCase().includes('bitcoin') || cat.toLowerCase().includes('documentary'))
|
||||
)
|
||||
|
||||
contentRows.value = {
|
||||
featured: allContent.slice(0, 10),
|
||||
newReleases: films.slice(0, 8),
|
||||
bitcoin: bitcoinContent.length > 0 ? bitcoinContent : films.slice(0, 6),
|
||||
documentaries: allContent.slice(0, 10),
|
||||
dramas: films.slice(0, 6),
|
||||
independent: films.slice(0, 10)
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('IndeeHub API fetch failed:', err)
|
||||
throw err
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch IndeeHub mock content (original catalog)
|
||||
*/
|
||||
@@ -111,7 +166,52 @@ export const useContentStore = defineStore('content', () => {
|
||||
}
|
||||
|
||||
/**
|
||||
* Route to the correct mock loader based on the active content source
|
||||
* Convert published filmmaker projects to Content format and merge
|
||||
* them into the existing content rows so they appear on the browse page.
|
||||
*/
|
||||
function mergePublishedFilmmakerProjects() {
|
||||
try {
|
||||
const { projects } = useFilmmaker()
|
||||
const published = projects.value.filter(p => p.status === 'published')
|
||||
if (published.length === 0) return
|
||||
|
||||
const publishedContent: Content[] = published.map(p => ({
|
||||
id: p.id,
|
||||
title: p.title || p.name,
|
||||
description: p.synopsis || '',
|
||||
thumbnail: p.poster || '/images/placeholder-poster.jpg',
|
||||
backdrop: p.poster || '/images/placeholder-poster.jpg',
|
||||
type: p.type === 'episodic' ? 'series' as const : 'film' as const,
|
||||
rating: p.format || undefined,
|
||||
releaseYear: p.releaseDate ? new Date(p.releaseDate).getFullYear() : new Date().getFullYear(),
|
||||
categories: p.genres?.map(g => g.name) || [],
|
||||
slug: p.slug,
|
||||
rentalPrice: p.rentalPrice,
|
||||
status: p.status,
|
||||
apiData: p,
|
||||
}))
|
||||
|
||||
// Merge into each content row (prepend so they appear first)
|
||||
for (const key of Object.keys(contentRows.value)) {
|
||||
// Avoid duplicates by filtering out any already-present IDs
|
||||
const existingIds = new Set(contentRows.value[key].map(c => c.id))
|
||||
const newItems = publishedContent.filter(c => !existingIds.has(c.id))
|
||||
if (newItems.length > 0) {
|
||||
contentRows.value[key] = [...newItems, ...contentRows.value[key]]
|
||||
}
|
||||
}
|
||||
|
||||
// If no featured content yet, use the first published project
|
||||
if (!featuredContent.value && publishedContent.length > 0) {
|
||||
featuredContent.value = publishedContent[0]
|
||||
}
|
||||
} catch {
|
||||
// Filmmaker composable may not be initialized yet — safe to ignore
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Route to the correct loader based on the active content source
|
||||
*/
|
||||
function fetchContentFromMock() {
|
||||
const sourceStore = useContentSourceStore()
|
||||
@@ -120,6 +220,9 @@ export const useContentStore = defineStore('content', () => {
|
||||
} else {
|
||||
fetchIndeeHubMock()
|
||||
}
|
||||
|
||||
// In mock mode, also include any projects published through the backstage
|
||||
mergePublishedFilmmakerProjects()
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -130,12 +233,17 @@ export const useContentStore = defineStore('content', () => {
|
||||
error.value = null
|
||||
|
||||
try {
|
||||
if (USE_MOCK_DATA) {
|
||||
const sourceStore = useContentSourceStore()
|
||||
|
||||
if (sourceStore.activeSource === 'indeehub-api' && !USE_MOCK_DATA) {
|
||||
// Fetch from our self-hosted backend (only when backend is actually running)
|
||||
await fetchContentFromIndeehubApi()
|
||||
} else if (USE_MOCK_DATA) {
|
||||
// Use mock data in development or when flag is set
|
||||
await new Promise(resolve => setTimeout(resolve, 100))
|
||||
fetchContentFromMock()
|
||||
} else {
|
||||
// Fetch from API
|
||||
// Fetch from original API
|
||||
await fetchContentFromApi()
|
||||
}
|
||||
} catch (e: any) {
|
||||
|
||||
@@ -1,13 +1,35 @@
|
||||
import { defineStore } from 'pinia'
|
||||
import { ref, watch } from 'vue'
|
||||
import { ref, computed, watch } from 'vue'
|
||||
|
||||
export type ContentSourceId = 'indeehub' | 'topdocfilms'
|
||||
export type ContentSourceId = 'indeehub' | 'topdocfilms' | 'indeehub-api'
|
||||
|
||||
const STORAGE_KEY = 'indeedhub:content-source'
|
||||
|
||||
export const useContentSourceStore = defineStore('contentSource', () => {
|
||||
const saved = localStorage.getItem(STORAGE_KEY) as ContentSourceId | null
|
||||
const activeSource = ref<ContentSourceId>(saved === 'topdocfilms' ? 'topdocfilms' : 'indeehub')
|
||||
const validSources: ContentSourceId[] = ['indeehub', 'topdocfilms', 'indeehub-api']
|
||||
const activeSource = ref<ContentSourceId>(
|
||||
saved && validSources.includes(saved) ? saved : 'indeehub'
|
||||
)
|
||||
|
||||
// API source is only available when the backend URL is configured
|
||||
const apiUrl = import.meta.env.VITE_INDEEHUB_API_URL || ''
|
||||
|
||||
const availableSources = computed(() => {
|
||||
const sources: { id: ContentSourceId; label: string }[] = [
|
||||
{ id: 'indeehub', label: 'IndeeHub Films' },
|
||||
{ id: 'topdocfilms', label: 'TopDoc Films' },
|
||||
]
|
||||
|
||||
// Only show API option if backend URL is configured
|
||||
if (apiUrl) {
|
||||
sources.push({ id: 'indeehub-api', label: 'IndeeHub API' })
|
||||
}
|
||||
|
||||
return sources
|
||||
})
|
||||
|
||||
const isApiSource = computed(() => activeSource.value === 'indeehub-api')
|
||||
|
||||
// Persist to localStorage on change
|
||||
watch(activeSource, (v) => {
|
||||
@@ -19,8 +41,11 @@ export const useContentSourceStore = defineStore('contentSource', () => {
|
||||
}
|
||||
|
||||
function toggle() {
|
||||
activeSource.value = activeSource.value === 'indeehub' ? 'topdocfilms' : 'indeehub'
|
||||
const sources = availableSources.value
|
||||
const currentIndex = sources.findIndex(s => s.id === activeSource.value)
|
||||
const nextIndex = (currentIndex + 1) % sources.length
|
||||
activeSource.value = sources[nextIndex].id
|
||||
}
|
||||
|
||||
return { activeSource, setSource, toggle }
|
||||
return { activeSource, availableSources, isApiSource, setSource, toggle }
|
||||
})
|
||||
|
||||
@@ -96,23 +96,16 @@ body {
|
||||
object-fit: cover;
|
||||
}
|
||||
|
||||
/* Scrollbar Styles */
|
||||
/* Hide scrollbars but keep scrolling */
|
||||
html {
|
||||
overflow-y: scroll;
|
||||
scrollbar-width: none; /* Firefox */
|
||||
}
|
||||
|
||||
::-webkit-scrollbar {
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-track {
|
||||
background: rgba(255, 255, 255, 0.05);
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-thumb {
|
||||
background: rgba(255, 255, 255, 0.2);
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-thumb:hover {
|
||||
background: rgba(255, 255, 255, 0.3);
|
||||
width: 0;
|
||||
height: 0;
|
||||
display: none;
|
||||
}
|
||||
|
||||
/* Netflix-style hero gradient */
|
||||
|
||||
124
src/types/api.ts
124
src/types/api.ts
@@ -143,8 +143,16 @@ export interface NostrSessionRequest {
|
||||
}
|
||||
|
||||
export interface NostrSessionResponse {
|
||||
token: string
|
||||
user: ApiUser
|
||||
/** JWT access token from backend */
|
||||
accessToken: string
|
||||
/** JWT refresh token */
|
||||
refreshToken: string
|
||||
/** Token TTL in seconds */
|
||||
expiresIn: number
|
||||
/** Convenience alias (mapped from accessToken) */
|
||||
token?: string
|
||||
/** User object (populated after separate /auth/me call) */
|
||||
user?: ApiUser
|
||||
}
|
||||
|
||||
// API Response Wrappers
|
||||
@@ -162,6 +170,118 @@ export interface PaginatedResponse<T> {
|
||||
hasMore: boolean
|
||||
}
|
||||
|
||||
// Filmmaker / Creator Types
|
||||
export interface ApiPaymentMethod {
|
||||
id: string
|
||||
filmmakerUserId: string
|
||||
type: 'lightning' | 'bank'
|
||||
lightningAddress?: string
|
||||
bankName?: string
|
||||
accountNumber?: string
|
||||
routingNumber?: string
|
||||
withdrawalFrequency: 'manual' | 'weekly' | 'monthly'
|
||||
isSelected: boolean
|
||||
createdAt: string
|
||||
updatedAt: string
|
||||
}
|
||||
|
||||
export interface ApiFilmmakerAnalytics {
|
||||
balance: number
|
||||
totalEarnings: number
|
||||
myTotalEarnings: number
|
||||
averageSharePercentage: number
|
||||
}
|
||||
|
||||
export interface ApiWatchAnalytics {
|
||||
viewsByDate: Record<string, number>
|
||||
trailerViews: number
|
||||
averageWatchTime: number
|
||||
streamingRevenueSats: number
|
||||
rentalRevenueSats: number
|
||||
purchasesCount: number
|
||||
purchasesByContent: Array<{ contentId: string; title: string; count: number }>
|
||||
revenueByDate: Record<string, number>
|
||||
}
|
||||
|
||||
export interface ApiPayment {
|
||||
id: string
|
||||
amount: number
|
||||
currency: string
|
||||
status: 'pending' | 'completed' | 'failed'
|
||||
type: 'withdrawal' | 'payout'
|
||||
createdAt: string
|
||||
}
|
||||
|
||||
export interface ApiCastMember {
|
||||
id: string
|
||||
name: string
|
||||
role: string
|
||||
profilePictureUrl?: string
|
||||
type: 'cast' | 'crew'
|
||||
}
|
||||
|
||||
export interface ApiProjectPermission {
|
||||
id: string
|
||||
userId: string
|
||||
projectId: string
|
||||
role: 'owner' | 'admin' | 'editor' | 'viewer' | 'revenue-manager'
|
||||
user?: ApiUser
|
||||
}
|
||||
|
||||
export interface ApiRevenueSplit {
|
||||
id: string
|
||||
userId: string
|
||||
projectId: string
|
||||
percentage: number
|
||||
user?: ApiUser
|
||||
}
|
||||
|
||||
export interface ApiCoupon {
|
||||
id: string
|
||||
code: string
|
||||
projectId: string
|
||||
discountType: 'percentage' | 'fixed'
|
||||
discountValue: number
|
||||
usageLimit: number
|
||||
usedCount: number
|
||||
expiresAt?: string
|
||||
createdAt: string
|
||||
}
|
||||
|
||||
export type ProjectStatus = 'draft' | 'published' | 'rejected'
|
||||
export type ProjectType = 'film' | 'episodic' | 'music-video'
|
||||
|
||||
export interface CreateProjectData {
|
||||
name: string
|
||||
type: ProjectType
|
||||
}
|
||||
|
||||
export interface UpdateProjectData {
|
||||
name?: string
|
||||
title?: string
|
||||
slug?: string
|
||||
synopsis?: string
|
||||
status?: ProjectStatus
|
||||
type?: ProjectType
|
||||
format?: string
|
||||
category?: string
|
||||
poster?: string
|
||||
trailer?: string
|
||||
rentalPrice?: number
|
||||
releaseDate?: string
|
||||
genres?: string[]
|
||||
deliveryMode?: 'native' | 'partner'
|
||||
}
|
||||
|
||||
export interface UploadInitResponse {
|
||||
UploadId: string
|
||||
Key: string
|
||||
}
|
||||
|
||||
export interface UploadPresignedUrlsResponse {
|
||||
parts: Array<{ PartNumber: number; signedUrl: string }>
|
||||
}
|
||||
|
||||
// Error Types
|
||||
export interface ApiError {
|
||||
message: string
|
||||
|
||||
@@ -22,6 +22,10 @@ export interface Content {
|
||||
drmEnabled?: boolean
|
||||
streamingUrl?: string
|
||||
apiData?: any
|
||||
|
||||
// Dual-mode content delivery
|
||||
deliveryMode?: 'native' | 'partner'
|
||||
keyUrl?: string
|
||||
}
|
||||
|
||||
// Nostr event types
|
||||
|
||||
63
src/utils/mock.ts
Normal file
63
src/utils/mock.ts
Normal file
@@ -0,0 +1,63 @@
|
||||
/**
|
||||
* Centralised mock-mode flag.
|
||||
*
|
||||
* Rules:
|
||||
* 1. VITE_USE_MOCK_DATA="true" → mock ON
|
||||
* 2. VITE_USE_MOCK_DATA="false" → mock OFF (try real backend)
|
||||
* 3. Unset / empty → fall back to import.meta.env.DEV
|
||||
*
|
||||
* If the user opted for real mode (rule 2) but the backend is
|
||||
* unreachable, `initMockMode()` automatically flips USE_MOCK to
|
||||
* true so the app remains usable. A console banner lets the dev
|
||||
* know what happened.
|
||||
*/
|
||||
|
||||
const explicit = import.meta.env.VITE_USE_MOCK_DATA
|
||||
|
||||
// Exported as `let` so initMockMode() can reassign it.
|
||||
// ES module live-bindings ensure every importer sees the update.
|
||||
export let USE_MOCK: boolean =
|
||||
explicit === 'false'
|
||||
? false
|
||||
: explicit === 'true'
|
||||
? true
|
||||
: import.meta.env.DEV
|
||||
|
||||
/**
|
||||
* Call once at app startup (before mounting).
|
||||
* Pings the backend — if unreachable and USE_MOCK is false,
|
||||
* flips USE_MOCK to true so the whole app falls back to mock data.
|
||||
*/
|
||||
export async function initMockMode(): Promise<void> {
|
||||
// Nothing to check if mock is already on
|
||||
if (USE_MOCK) return
|
||||
|
||||
const apiUrl = import.meta.env.VITE_API_URL || 'http://localhost:4000'
|
||||
|
||||
try {
|
||||
const controller = new AbortController()
|
||||
const timeout = setTimeout(() => controller.abort(), 2000)
|
||||
|
||||
await fetch(apiUrl, {
|
||||
method: 'HEAD',
|
||||
signal: controller.signal,
|
||||
})
|
||||
|
||||
clearTimeout(timeout)
|
||||
// Backend is reachable — keep USE_MOCK = false
|
||||
console.log(
|
||||
'%c✅ Backend connected at %s — real mode active',
|
||||
'color: #22c55e; font-weight: bold',
|
||||
apiUrl,
|
||||
)
|
||||
} catch {
|
||||
// Backend is not reachable — flip to mock
|
||||
USE_MOCK = true
|
||||
console.warn(
|
||||
'%c⚠️ Backend not reachable at %s — auto-switching to mock mode.\n' +
|
||||
' Start the backend (npm run dev:full) for real API/payments.',
|
||||
'color: #f59e0b; font-weight: bold',
|
||||
apiUrl,
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -101,8 +101,8 @@
|
||||
<div class="text-white font-medium">{{ user.filmmaker.professionalName }}</div>
|
||||
</div>
|
||||
|
||||
<button @click="$router.push('/dashboard')" class="hero-play-button w-full">
|
||||
Go to Filmmaker Dashboard
|
||||
<button @click="$router.push('/backstage')" class="hero-play-button w-full">
|
||||
Go to Backstage
|
||||
</button>
|
||||
</div>
|
||||
</section>
|
||||
@@ -164,9 +164,12 @@ const { logout: nostrLogout } = useAccounts()
|
||||
const contentSourceStore = useContentSourceStore()
|
||||
const contentStore = useContentStore()
|
||||
|
||||
const contentSourceLabel = computed(() =>
|
||||
contentSourceStore.activeSource === 'indeehub' ? 'IndeeHub Films' : 'TopDoc Films'
|
||||
)
|
||||
const contentSourceLabel = computed(() => {
|
||||
const source = contentSourceStore.availableSources.find(
|
||||
s => s.id === contentSourceStore.activeSource
|
||||
)
|
||||
return source?.label || 'IndeeHub Films'
|
||||
})
|
||||
|
||||
function handleSourceToggle() {
|
||||
contentSourceStore.toggle()
|
||||
|
||||
425
src/views/backstage/Analytics.vue
Normal file
425
src/views/backstage/Analytics.vue
Normal file
@@ -0,0 +1,425 @@
|
||||
<template>
|
||||
<div class="analytics-view">
|
||||
<!-- Sticky Top Bar -->
|
||||
<div class="page-top-bar-wrapper">
|
||||
<div class="page-top-bar-inner flex items-center gap-3">
|
||||
<h1 class="text-xl md:text-2xl font-bold text-white">{{ userName }}</h1>
|
||||
<nav class="flex items-center gap-2 flex-1 ml-4">
|
||||
<button
|
||||
@click="activeTab = 'viewership'"
|
||||
:class="activeTab === 'viewership' ? 'bar-tab-active' : 'bar-tab'"
|
||||
>
|
||||
Viewership
|
||||
</button>
|
||||
<button
|
||||
@click="activeTab = 'revenue'"
|
||||
:class="activeTab === 'revenue' ? 'bar-tab-active' : 'bar-tab'"
|
||||
>
|
||||
Revenue
|
||||
</button>
|
||||
</nav>
|
||||
<select v-model="selectedProjectId" class="filter-select flex-shrink-0">
|
||||
<option value="all">All Films</option>
|
||||
<option v-for="p in projects" :key="p.id" :value="p.id">{{ p.title || p.name }}</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<main class="page-main">
|
||||
<div class="mx-auto max-w-6xl">
|
||||
|
||||
<!-- ── Viewership Panel ────────────────────────────────────────── -->
|
||||
<div v-if="activeTab === 'viewership'">
|
||||
<!-- Stat Cards -->
|
||||
<div class="grid grid-cols-2 lg:grid-cols-4 gap-4 mb-8">
|
||||
<div class="stat-card">
|
||||
<p class="stat-label">Total Views</p>
|
||||
<p class="stat-value">{{ formatNumber(totalViews) }}</p>
|
||||
</div>
|
||||
<div class="stat-card">
|
||||
<p class="stat-label">Purchases</p>
|
||||
<p class="stat-value">{{ formatNumber(watchAnalytics?.purchasesCount || 0) }}</p>
|
||||
</div>
|
||||
<div class="stat-card">
|
||||
<p class="stat-label">Trailer Views</p>
|
||||
<p class="stat-value">{{ formatNumber(watchAnalytics?.trailerViews || 0) }}</p>
|
||||
</div>
|
||||
<div class="stat-card">
|
||||
<p class="stat-label">Avg Watch Time</p>
|
||||
<p class="stat-value">{{ formatDuration(watchAnalytics?.averageWatchTime || 0) }}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Views Chart -->
|
||||
<div class="glass-card p-6 mb-8">
|
||||
<h3 class="text-lg font-semibold text-white mb-4">Views Over Time</h3>
|
||||
<div class="chart-container">
|
||||
<div v-if="viewsChartData.length === 0" class="text-center py-12 text-white/30 text-sm">
|
||||
No viewership data available yet
|
||||
</div>
|
||||
<div v-else class="flex items-end gap-1 h-48">
|
||||
<div
|
||||
v-for="(point, idx) in viewsChartData"
|
||||
:key="idx"
|
||||
class="chart-bar"
|
||||
:style="{ height: `${point.heightPct}%` }"
|
||||
:title="`${point.date}: ${point.views} views`"
|
||||
>
|
||||
<div class="chart-bar-inner"></div>
|
||||
</div>
|
||||
</div>
|
||||
<div v-if="viewsChartData.length > 0" class="flex justify-between mt-2">
|
||||
<span class="text-white/20 text-[10px]">{{ viewsChartData[0]?.date }}</span>
|
||||
<span class="text-white/20 text-[10px]">{{ viewsChartData[viewsChartData.length - 1]?.date }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ── Revenue Panel ───────────────────────────────────────────── -->
|
||||
<div v-if="activeTab === 'revenue'">
|
||||
<!-- Revenue Cards -->
|
||||
<div class="grid grid-cols-1 sm:grid-cols-3 gap-4 mb-8">
|
||||
<div class="stat-card">
|
||||
<p class="stat-label">Total Earnings</p>
|
||||
<p class="stat-value text-[#F7931A]">{{ formatSats(analytics?.totalEarnings || 0) }} <span class="text-sm text-white/40">sats</span></p>
|
||||
</div>
|
||||
<div class="stat-card">
|
||||
<p class="stat-label">My Earnings</p>
|
||||
<p class="stat-value">{{ formatSats(analytics?.myTotalEarnings || 0) }} <span class="text-sm text-white/40">sats</span></p>
|
||||
<p class="text-white/30 text-xs mt-1">{{ analytics?.averageSharePercentage || 100 }}% share</p>
|
||||
</div>
|
||||
<div class="stat-card">
|
||||
<p class="stat-label">Balance</p>
|
||||
<p class="stat-value text-green-400">{{ formatSats(analytics?.balance || 0) }} <span class="text-sm text-white/40">sats</span></p>
|
||||
<button
|
||||
@click="showWithdrawModal = true"
|
||||
:disabled="!analytics?.balance"
|
||||
class="withdraw-button mt-3 w-full disabled:opacity-30 disabled:cursor-not-allowed"
|
||||
>
|
||||
Withdraw
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Revenue Breakdown -->
|
||||
<div class="grid grid-cols-1 lg:grid-cols-2 gap-6 mb-8">
|
||||
<!-- Streaming vs Rental -->
|
||||
<div class="glass-card p-6">
|
||||
<h3 class="text-lg font-semibold text-white mb-4">Revenue Breakdown</h3>
|
||||
<div class="space-y-4">
|
||||
<div>
|
||||
<div class="flex justify-between text-sm mb-1">
|
||||
<span class="text-white/60">Streaming</span>
|
||||
<span class="text-white font-mono">{{ formatSats(watchAnalytics?.streamingRevenueSats || 0) }} sats</span>
|
||||
</div>
|
||||
<div class="h-2 rounded-full bg-white/5 overflow-hidden">
|
||||
<div class="h-full rounded-full bg-[#F7931A]" :style="{ width: streamingPct + '%' }"></div>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<div class="flex justify-between text-sm mb-1">
|
||||
<span class="text-white/60">Rentals</span>
|
||||
<span class="text-white font-mono">{{ formatSats(watchAnalytics?.rentalRevenueSats || 0) }} sats</span>
|
||||
</div>
|
||||
<div class="h-2 rounded-full bg-white/5 overflow-hidden">
|
||||
<div class="h-full rounded-full bg-white/40" :style="{ width: rentalPct + '%' }"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Purchases by Content -->
|
||||
<div class="glass-card p-6">
|
||||
<h3 class="text-lg font-semibold text-white mb-4">Purchases by Content</h3>
|
||||
<div v-if="(watchAnalytics?.purchasesByContent || []).length === 0" class="text-center py-8 text-white/30 text-sm">
|
||||
No purchase data yet
|
||||
</div>
|
||||
<div v-else class="space-y-3">
|
||||
<div v-for="item in watchAnalytics?.purchasesByContent" :key="item.contentId" class="flex items-center gap-3">
|
||||
<span class="text-white/70 text-sm flex-1 truncate">{{ item.title }}</span>
|
||||
<span class="text-white font-mono text-sm">{{ item.count }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Withdrawal History -->
|
||||
<div class="glass-card p-6">
|
||||
<h3 class="text-lg font-semibold text-white mb-4">Withdrawal History</h3>
|
||||
<div v-if="payments.length === 0" class="text-center py-8 text-white/30 text-sm">
|
||||
No withdrawals yet
|
||||
</div>
|
||||
<div v-else class="space-y-3">
|
||||
<div v-for="payment in payments" :key="payment.id" class="payment-row">
|
||||
<div class="flex-1">
|
||||
<p class="text-white text-sm font-medium">{{ formatSats(payment.amount) }} sats</p>
|
||||
<p class="text-white/40 text-xs">{{ formatDate(payment.createdAt) }}</p>
|
||||
</div>
|
||||
<span
|
||||
:class="payment.status === 'completed' ? 'status-badge-success' : 'status-badge-pending'"
|
||||
>
|
||||
{{ payment.status }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Withdraw Modal -->
|
||||
<Transition name="modal">
|
||||
<div v-if="showWithdrawModal" class="fixed inset-0 z-[100] flex items-center justify-center p-4" @click.self="showWithdrawModal = false">
|
||||
<div class="fixed inset-0 bg-black/60 backdrop-blur-sm" @click="showWithdrawModal = false"></div>
|
||||
<div class="modal-card relative z-10 w-full max-w-sm p-6">
|
||||
<h3 class="text-xl font-bold text-white mb-2">Withdraw Earnings</h3>
|
||||
<p class="text-white/50 text-sm mb-6">Available balance: {{ formatSats(analytics?.balance || 0) }} sats</p>
|
||||
<div class="mb-4">
|
||||
<label class="field-label">Amount (sats)</label>
|
||||
<input v-model.number="withdrawAmount" type="number" class="field-input" :max="analytics?.balance" min="1" />
|
||||
</div>
|
||||
<div class="flex gap-3">
|
||||
<button @click="showWithdrawModal = false" class="flex-1 cancel-button">Cancel</button>
|
||||
<button @click="handleWithdraw" :disabled="!withdrawAmount" class="flex-1 create-button disabled:opacity-40">Withdraw</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Transition>
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, onMounted, watch } from 'vue'
|
||||
import { useAuth } from '../../composables/useAuth'
|
||||
import { useFilmmaker } from '../../composables/useFilmmaker'
|
||||
|
||||
const { user } = useAuth()
|
||||
const {
|
||||
projects,
|
||||
analytics,
|
||||
watchAnalytics,
|
||||
payments,
|
||||
fetchProjects,
|
||||
fetchAnalytics,
|
||||
fetchPayments,
|
||||
} = useFilmmaker()
|
||||
|
||||
const activeTab = ref<'viewership' | 'revenue'>('viewership')
|
||||
const selectedProjectId = ref('all')
|
||||
const showWithdrawModal = ref(false)
|
||||
const withdrawAmount = ref(0)
|
||||
|
||||
const userName = computed(() => {
|
||||
if (user.value?.filmmaker?.professionalName) return user.value.filmmaker.professionalName
|
||||
return user.value?.legalName?.split(' ')[0] || 'Filmmaker'
|
||||
})
|
||||
|
||||
const totalViews = computed(() => {
|
||||
if (!watchAnalytics.value?.viewsByDate) return 0
|
||||
return Object.values(watchAnalytics.value.viewsByDate).reduce((sum, v) => sum + v, 0)
|
||||
})
|
||||
|
||||
const viewsChartData = computed(() => {
|
||||
if (!watchAnalytics.value?.viewsByDate) return []
|
||||
const entries = Object.entries(watchAnalytics.value.viewsByDate).sort(([a], [b]) => a.localeCompare(b))
|
||||
const max = Math.max(...entries.map(([, v]) => v), 1)
|
||||
return entries.map(([date, views]) => ({
|
||||
date,
|
||||
views,
|
||||
heightPct: Math.max((views / max) * 100, 4),
|
||||
}))
|
||||
})
|
||||
|
||||
const totalRevenue = computed(() =>
|
||||
(watchAnalytics.value?.streamingRevenueSats || 0) + (watchAnalytics.value?.rentalRevenueSats || 0)
|
||||
)
|
||||
|
||||
const streamingPct = computed(() =>
|
||||
totalRevenue.value > 0 ? Math.round(((watchAnalytics.value?.streamingRevenueSats || 0) / totalRevenue.value) * 100) : 50
|
||||
)
|
||||
|
||||
const rentalPct = computed(() =>
|
||||
totalRevenue.value > 0 ? Math.round(((watchAnalytics.value?.rentalRevenueSats || 0) / totalRevenue.value) * 100) : 50
|
||||
)
|
||||
|
||||
function formatNumber(n: number): string {
|
||||
return n.toLocaleString()
|
||||
}
|
||||
|
||||
function formatSats(n: number): string {
|
||||
return n.toLocaleString()
|
||||
}
|
||||
|
||||
function formatDuration(seconds: number): string {
|
||||
if (!seconds) return '0m'
|
||||
const m = Math.floor(seconds / 60)
|
||||
const s = Math.round(seconds % 60)
|
||||
return m > 0 ? `${m}m ${s}s` : `${s}s`
|
||||
}
|
||||
|
||||
function formatDate(dateString: string): string {
|
||||
return new Date(dateString).toLocaleDateString('en-US', {
|
||||
year: 'numeric',
|
||||
month: 'short',
|
||||
day: 'numeric',
|
||||
})
|
||||
}
|
||||
|
||||
function handleWithdraw() {
|
||||
console.log('Withdraw:', withdrawAmount.value)
|
||||
showWithdrawModal.value = false
|
||||
}
|
||||
|
||||
// Reload analytics when project filter changes
|
||||
watch(selectedProjectId, (id) => {
|
||||
const projectIds = id === 'all' ? projects.value.map((p) => p.id) : [id]
|
||||
fetchAnalytics(projectIds)
|
||||
fetchPayments(id === 'all' ? undefined : id)
|
||||
})
|
||||
|
||||
onMounted(async () => {
|
||||
await fetchProjects()
|
||||
const projectIds = projects.value.map((p) => p.id)
|
||||
fetchAnalytics(projectIds)
|
||||
fetchPayments()
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.analytics-view {
|
||||
min-height: 100vh;
|
||||
background: #0a0a0a;
|
||||
}
|
||||
|
||||
.page-top-bar-wrapper {
|
||||
position: sticky;
|
||||
top: 0;
|
||||
z-index: 40;
|
||||
padding: 112px 16px 8px 16px;
|
||||
background: linear-gradient(to bottom, #0a0a0a 90%, transparent);
|
||||
}
|
||||
|
||||
.page-top-bar-inner {
|
||||
padding: 12px 16px;
|
||||
border-radius: 16px;
|
||||
background: rgba(10, 10, 10, 0.9);
|
||||
backdrop-filter: blur(24px);
|
||||
-webkit-backdrop-filter: blur(24px);
|
||||
border: 1px solid rgba(255, 255, 255, 0.06);
|
||||
box-shadow: 0 4px 16px rgba(0, 0, 0, 0.3);
|
||||
}
|
||||
|
||||
.page-main {
|
||||
padding: 8px 16px 96px 16px;
|
||||
}
|
||||
|
||||
@media (min-width: 768px) {
|
||||
.page-main {
|
||||
padding-bottom: 32px;
|
||||
}
|
||||
}
|
||||
|
||||
.glass-card {
|
||||
background: rgba(255, 255, 255, 0.05);
|
||||
backdrop-filter: blur(24px);
|
||||
-webkit-backdrop-filter: blur(24px);
|
||||
border-radius: 16px;
|
||||
border: 1px solid rgba(255, 255, 255, 0.08);
|
||||
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.3), inset 0 1px 0 rgba(255, 255, 255, 0.1);
|
||||
}
|
||||
|
||||
.modal-card {
|
||||
background: rgba(20, 20, 20, 0.95);
|
||||
backdrop-filter: blur(40px);
|
||||
border-radius: 20px;
|
||||
border: 1px solid rgba(255, 255, 255, 0.1);
|
||||
box-shadow: 0 24px 80px rgba(0, 0, 0, 0.6), inset 0 1px 0 rgba(255, 255, 255, 0.08);
|
||||
}
|
||||
|
||||
/* Bar tabs (header-style nav buttons) */
|
||||
.bar-tab {
|
||||
padding: 8px 16px;
|
||||
font-size: 13px;
|
||||
font-weight: 500;
|
||||
border-radius: 12px;
|
||||
background: rgba(0, 0, 0, 0.25);
|
||||
color: rgba(255, 255, 255, 0.7);
|
||||
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.3), inset 0 1px 0 rgba(255, 255, 255, 0.1);
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s ease;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.bar-tab:hover {
|
||||
background: rgba(0, 0, 0, 0.35);
|
||||
color: rgba(255, 255, 255, 0.9);
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
.bar-tab-active {
|
||||
padding: 8px 16px;
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
border-radius: 12px;
|
||||
background: rgba(0, 0, 0, 0.4);
|
||||
color: white;
|
||||
box-shadow: 0 6px 16px rgba(0, 0, 0, 0.4), inset 0 1px 0 rgba(255, 255, 255, 0.15);
|
||||
border: none;
|
||||
cursor: default;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
/* Stat Cards */
|
||||
.stat-card {
|
||||
background: rgba(255, 255, 255, 0.05);
|
||||
backdrop-filter: blur(24px);
|
||||
border-radius: 14px;
|
||||
border: 1px solid rgba(255, 255, 255, 0.08);
|
||||
padding: 20px;
|
||||
box-shadow: 0 4px 16px rgba(0, 0, 0, 0.2), inset 0 1px 0 rgba(255, 255, 255, 0.08);
|
||||
}
|
||||
|
||||
.stat-label {
|
||||
font-size: 13px;
|
||||
color: rgba(255, 255, 255, 0.4);
|
||||
font-weight: 500;
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
.stat-value {
|
||||
font-size: 28px;
|
||||
font-weight: 700;
|
||||
color: white;
|
||||
line-height: 1.2;
|
||||
}
|
||||
|
||||
/* Chart */
|
||||
.chart-container { position: relative; }
|
||||
.chart-bar { flex: 1; min-width: 4px; display: flex; align-items: flex-end; }
|
||||
.chart-bar-inner { width: 100%; height: 100%; border-radius: 3px 3px 0 0; background: linear-gradient(to top, rgba(247, 147, 26, 0.3), rgba(247, 147, 26, 0.6)); transition: all 0.3s ease; }
|
||||
.chart-bar:hover .chart-bar-inner { background: linear-gradient(to top, rgba(247, 147, 26, 0.5), rgba(247, 147, 26, 0.9)); }
|
||||
|
||||
/* Payment row */
|
||||
.payment-row { display: flex; align-items: center; gap: 12px; padding: 12px 16px; border-radius: 12px; background: rgba(255, 255, 255, 0.04); border: 1px solid rgba(255, 255, 255, 0.06); }
|
||||
.status-badge-success { padding: 4px 10px; font-size: 11px; font-weight: 600; border-radius: 8px; background: rgba(34, 197, 94, 0.15); color: #22c55e; text-transform: capitalize; }
|
||||
.status-badge-pending { padding: 4px 10px; font-size: 11px; font-weight: 600; border-radius: 8px; background: rgba(255, 255, 255, 0.08); color: rgba(255, 255, 255, 0.5); text-transform: capitalize; }
|
||||
|
||||
/* Withdraw button */
|
||||
.withdraw-button { padding: 8px 16px; font-size: 13px; font-weight: 600; border-radius: 10px; background: rgba(247, 147, 26, 0.15); color: #F7931A; border: 1px solid rgba(247, 147, 26, 0.3); cursor: pointer; transition: all 0.2s; }
|
||||
.withdraw-button:hover { background: rgba(247, 147, 26, 0.25); }
|
||||
|
||||
/* Filter */
|
||||
.filter-select { padding: 10px 32px 10px 12px; border-radius: 12px; border: 1px solid rgba(255, 255, 255, 0.1); background: rgba(255, 255, 255, 0.06); color: rgba(255, 255, 255, 0.8); font-size: 14px; outline: none; appearance: none; cursor: pointer; background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='12' height='12' viewBox='0 0 24 24' fill='none' stroke='rgba(255,255,255,0.4)' stroke-width='2'%3E%3Cpath d='M6 9l6 6 6-6'/%3E%3C/svg%3E"); background-repeat: no-repeat; background-position: right 12px center; }
|
||||
.filter-select option { background: #1a1a1a; color: white; }
|
||||
|
||||
/* Form fields */
|
||||
.field-label { display: block; color: rgba(255, 255, 255, 0.6); font-size: 14px; font-weight: 500; margin-bottom: 6px; }
|
||||
.field-input { width: 100%; padding: 10px 14px; border-radius: 12px; border: 1px solid rgba(255, 255, 255, 0.1); background: rgba(255, 255, 255, 0.06); color: white; font-size: 14px; outline: none; }
|
||||
.field-input:focus { border-color: rgba(255, 255, 255, 0.2); background: rgba(255, 255, 255, 0.08); }
|
||||
.cancel-button { padding: 10px 24px; font-size: 14px; border-radius: 14px; background: rgba(255, 255, 255, 0.08); color: rgba(255, 255, 255, 0.7); border: 1px solid rgba(255, 255, 255, 0.1); cursor: pointer; }
|
||||
.create-button { padding: 10px 24px; font-size: 14px; font-weight: 600; border-radius: 14px; background: rgba(255, 255, 255, 0.85); color: rgba(0, 0, 0, 0.9); border: none; cursor: pointer; }
|
||||
|
||||
.modal-enter-active { transition: all 0.3s ease-out; }
|
||||
.modal-leave-active { transition: all 0.2s ease-in; }
|
||||
.modal-enter-from, .modal-leave-to { opacity: 0; }
|
||||
</style>
|
||||
558
src/views/backstage/Backstage.vue
Normal file
558
src/views/backstage/Backstage.vue
Normal file
@@ -0,0 +1,558 @@
|
||||
<template>
|
||||
<div class="backstage-view">
|
||||
<!-- Sticky Top Bar -->
|
||||
<div class="page-top-bar-wrapper">
|
||||
<div class="page-top-bar-inner flex items-center gap-3">
|
||||
<h1 class="text-xl md:text-2xl font-bold text-white">Backstage</h1>
|
||||
<span class="text-white/40 text-sm flex-shrink-0">{{ projectsCount }} project{{ projectsCount === 1 ? '' : 's' }}</span>
|
||||
<nav class="flex items-center gap-2 flex-1 ml-4 overflow-x-auto">
|
||||
<button
|
||||
v-for="tab in tabs"
|
||||
:key="tab.id"
|
||||
@click="activeTab = tab.id"
|
||||
:class="activeTab === tab.id ? 'bar-tab-active' : 'bar-tab'"
|
||||
>
|
||||
{{ tab.label }}
|
||||
</button>
|
||||
</nav>
|
||||
<button @click="showCreateModal = true" class="create-button flex items-center gap-2 text-sm flex-shrink-0">
|
||||
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 4v16m8-8H4" />
|
||||
</svg>
|
||||
Create Project
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<main class="page-main">
|
||||
<div class="mx-auto max-w-7xl">
|
||||
<!-- Filters Card -->
|
||||
<div class="glass-card p-4 md:p-6 mb-6">
|
||||
<!-- Filters Row -->
|
||||
<div class="flex flex-col sm:flex-row items-stretch sm:items-center gap-3">
|
||||
<!-- Search -->
|
||||
<div class="relative flex-1 max-w-sm">
|
||||
<svg class="w-4 h-4 text-white/30 absolute left-3 top-1/2 -translate-y-1/2 pointer-events-none" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z" />
|
||||
</svg>
|
||||
<input
|
||||
v-model="searchQuery"
|
||||
type="text"
|
||||
placeholder="Search projects..."
|
||||
class="filter-input"
|
||||
style="padding-left: 40px"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- Type filter -->
|
||||
<div class="flex gap-2">
|
||||
<button
|
||||
v-for="typeOpt in typeOptions"
|
||||
:key="typeOpt.value"
|
||||
@click="typeFilter = typeFilter === typeOpt.value ? null : typeOpt.value"
|
||||
:class="typeFilter === typeOpt.value ? 'type-btn-active' : 'type-btn'"
|
||||
>
|
||||
{{ typeOpt.label }}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Sort -->
|
||||
<select v-model="sortBy" class="filter-select">
|
||||
<option value="recent">Recently Added</option>
|
||||
<option value="a-z">A → Z</option>
|
||||
<option value="z-a">Z → A</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Project Grid -->
|
||||
<div v-if="isLoading" class="project-grid">
|
||||
<div
|
||||
v-for="i in 6"
|
||||
:key="'skeleton-' + i"
|
||||
class="skeleton-card animate-pulse"
|
||||
>
|
||||
<div class="aspect-[2/3] rounded-xl bg-white/5"></div>
|
||||
<div class="mt-3 space-y-2">
|
||||
<div class="h-4 w-3/4 rounded bg-white/5"></div>
|
||||
<div class="h-3 w-1/2 rounded bg-white/5"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-else-if="filteredProjects.length === 0" class="empty-state glass-card p-8 md:p-12 text-center">
|
||||
<svg class="w-16 h-16 mx-auto text-white/20 mb-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" d="M7 4V20M17 4V20M3 8H7M17 8H21M3 12H21M3 16H7M17 16H21M4 20H20C20.5523 20 21 19.5523 21 19V5C21 4.44772 20.5523 4 20 4H4C3.44772 4 3 4.44772 3 5V19C3 19.5523 3.44772 20 4 20Z" />
|
||||
</svg>
|
||||
<h3 class="text-xl font-bold text-white mb-2">No projects yet</h3>
|
||||
<p class="text-white/50 mb-6 max-w-sm mx-auto">
|
||||
Get started by creating your first project. Upload your film, add details, and share it with the world.
|
||||
</p>
|
||||
<button @click="showCreateModal = true" class="create-button inline-flex items-center gap-2">
|
||||
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 4v16m8-8H4" />
|
||||
</svg>
|
||||
Create Project
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div v-else class="project-grid">
|
||||
<button
|
||||
v-for="project in filteredProjects"
|
||||
:key="project.id"
|
||||
@click="openProject(project)"
|
||||
class="project-card group text-left"
|
||||
>
|
||||
<!-- Poster -->
|
||||
<div class="aspect-[2/3] rounded-xl overflow-hidden bg-white/5 relative">
|
||||
<img
|
||||
v-if="project.poster"
|
||||
:src="project.poster"
|
||||
:alt="project.title || project.name"
|
||||
class="w-full h-full object-cover transition-transform duration-300 group-hover:scale-105"
|
||||
/>
|
||||
<div v-else class="w-full h-full flex items-center justify-center">
|
||||
<svg class="w-12 h-12 text-white/10" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" d="M7 4V20M17 4V20M3 8H7M17 8H21M3 12H21M3 16H7M17 16H21M4 20H20C20.5523 20 21 19.5523 21 19V5C21 4.44772 20.5523 4 20 4H4C3.44772 4 3 4.44772 3 5V19C3 19.5523 3.44772 20 4 20Z" />
|
||||
</svg>
|
||||
</div>
|
||||
|
||||
<!-- Status Badge -->
|
||||
<div class="absolute top-2 right-2">
|
||||
<span :class="statusClass(project.status)">
|
||||
{{ project.status || 'draft' }}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<!-- Hover overlay -->
|
||||
<div class="absolute inset-0 bg-black/40 opacity-0 group-hover:opacity-100 transition-opacity duration-300 flex items-center justify-center">
|
||||
<span class="text-white font-medium text-sm">Edit Project</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Info -->
|
||||
<div class="mt-3">
|
||||
<h3 class="text-sm font-semibold text-white truncate">{{ project.title || project.name }}</h3>
|
||||
<p class="text-xs text-white/40 mt-0.5 capitalize">{{ project.type || 'Film' }}</p>
|
||||
</div>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
|
||||
<!-- Create Project Modal -->
|
||||
<Transition name="modal">
|
||||
<div v-if="showCreateModal" class="fixed inset-0 z-[100] flex items-center justify-center p-4" @click.self="showCreateModal = false">
|
||||
<div class="fixed inset-0 bg-black/60 backdrop-blur-sm" @click="showCreateModal = false"></div>
|
||||
<div class="modal-card relative z-10 w-full max-w-md p-6">
|
||||
<h2 class="text-xl font-bold text-white mb-6">Create New Project</h2>
|
||||
|
||||
<!-- Project Name -->
|
||||
<div class="mb-5">
|
||||
<label class="block text-white/60 text-sm mb-2">Project Name</label>
|
||||
<input
|
||||
v-model="newProjectName"
|
||||
type="text"
|
||||
placeholder="e.g. My First Film"
|
||||
class="filter-input w-full"
|
||||
@keydown.enter="handleCreateProject"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- Project Type -->
|
||||
<div class="mb-6">
|
||||
<label class="block text-white/60 text-sm mb-2">Type</label>
|
||||
<div class="flex gap-2">
|
||||
<button
|
||||
v-for="typeOpt in createTypeOptions"
|
||||
:key="typeOpt.value"
|
||||
@click="newProjectType = typeOpt.value"
|
||||
:class="newProjectType === typeOpt.value ? 'type-btn-active' : 'type-btn'"
|
||||
>
|
||||
{{ typeOpt.label }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Actions -->
|
||||
<div class="flex gap-3">
|
||||
<button @click="showCreateModal = false" class="flex-1 cancel-button">Cancel</button>
|
||||
<button
|
||||
@click="handleCreateProject"
|
||||
:disabled="!newProjectName.trim() || isCreating"
|
||||
class="flex-1 create-button disabled:opacity-40 disabled:cursor-not-allowed"
|
||||
>
|
||||
{{ isCreating ? 'Creating...' : 'Create' }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Transition>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { useFilmmaker } from '../../composables/useFilmmaker'
|
||||
import type { ApiProject, ProjectType } from '../../types/api'
|
||||
|
||||
const router = useRouter()
|
||||
const {
|
||||
filteredProjects,
|
||||
projectsCount,
|
||||
isLoading,
|
||||
activeTab,
|
||||
typeFilter,
|
||||
sortBy,
|
||||
searchQuery,
|
||||
fetchProjects,
|
||||
createNewProject,
|
||||
} = useFilmmaker()
|
||||
|
||||
const showCreateModal = ref(false)
|
||||
const newProjectName = ref('')
|
||||
const newProjectType = ref<ProjectType>('film')
|
||||
const isCreating = ref(false)
|
||||
|
||||
const tabs = [
|
||||
{ id: 'projects' as const, label: 'Projects' },
|
||||
{ id: 'resume' as const, label: 'Resumé' },
|
||||
{ id: 'stakeholder' as const, label: 'Stakeholder' },
|
||||
]
|
||||
|
||||
const typeOptions = [
|
||||
{ value: 'film' as ProjectType, label: 'Film' },
|
||||
{ value: 'episodic' as ProjectType, label: 'Episodic' },
|
||||
{ value: 'music-video' as ProjectType, label: 'Music Video' },
|
||||
]
|
||||
|
||||
const createTypeOptions = [
|
||||
{ value: 'film' as ProjectType, label: 'Film' },
|
||||
{ value: 'episodic' as ProjectType, label: 'Episodic' },
|
||||
{ value: 'music-video' as ProjectType, label: 'Music Video' },
|
||||
]
|
||||
|
||||
function statusClass(status?: string): string {
|
||||
const base = 'px-2 py-0.5 text-[10px] uppercase tracking-wider font-bold rounded-full'
|
||||
switch (status) {
|
||||
case 'published':
|
||||
return `${base} bg-green-500/20 text-green-400 border border-green-500/30`
|
||||
case 'rejected':
|
||||
return `${base} bg-red-500/20 text-red-400 border border-red-500/30`
|
||||
default:
|
||||
return `${base} bg-white/10 text-white/60 border border-white/10`
|
||||
}
|
||||
}
|
||||
|
||||
function openProject(project: ApiProject) {
|
||||
router.push(`/backstage/project/${project.id}`)
|
||||
}
|
||||
|
||||
async function handleCreateProject() {
|
||||
if (!newProjectName.value.trim()) return
|
||||
isCreating.value = true
|
||||
|
||||
const project = await createNewProject({
|
||||
name: newProjectName.value.trim(),
|
||||
type: newProjectType.value,
|
||||
})
|
||||
|
||||
isCreating.value = false
|
||||
|
||||
if (project) {
|
||||
showCreateModal.value = false
|
||||
newProjectName.value = ''
|
||||
router.push(`/backstage/project/${project.id}`)
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
fetchProjects()
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.backstage-view {
|
||||
min-height: 100vh;
|
||||
background: #0a0a0a;
|
||||
}
|
||||
|
||||
.page-top-bar-wrapper {
|
||||
position: sticky;
|
||||
top: 0;
|
||||
z-index: 40;
|
||||
padding: 112px 16px 8px 16px;
|
||||
background: linear-gradient(to bottom, #0a0a0a 90%, transparent);
|
||||
}
|
||||
|
||||
.page-top-bar-inner {
|
||||
padding: 12px 16px;
|
||||
border-radius: 16px;
|
||||
background: rgba(10, 10, 10, 0.9);
|
||||
backdrop-filter: blur(24px);
|
||||
-webkit-backdrop-filter: blur(24px);
|
||||
border: 1px solid rgba(255, 255, 255, 0.06);
|
||||
box-shadow: 0 4px 16px rgba(0, 0, 0, 0.3);
|
||||
}
|
||||
|
||||
.page-main {
|
||||
padding: 8px 16px 96px 16px;
|
||||
}
|
||||
|
||||
@media (min-width: 768px) {
|
||||
.page-main {
|
||||
padding-bottom: 32px;
|
||||
}
|
||||
}
|
||||
|
||||
.glass-card {
|
||||
background: rgba(255, 255, 255, 0.05);
|
||||
backdrop-filter: blur(24px);
|
||||
-webkit-backdrop-filter: blur(24px);
|
||||
border-radius: 16px;
|
||||
border: 1px solid rgba(255, 255, 255, 0.08);
|
||||
box-shadow:
|
||||
0 8px 24px rgba(0, 0, 0, 0.3),
|
||||
inset 0 1px 0 rgba(255, 255, 255, 0.1);
|
||||
}
|
||||
|
||||
.modal-card {
|
||||
background: rgba(20, 20, 20, 0.95);
|
||||
backdrop-filter: blur(40px);
|
||||
-webkit-backdrop-filter: blur(40px);
|
||||
border-radius: 20px;
|
||||
border: 1px solid rgba(255, 255, 255, 0.1);
|
||||
box-shadow:
|
||||
0 24px 80px rgba(0, 0, 0, 0.6),
|
||||
inset 0 1px 0 rgba(255, 255, 255, 0.08);
|
||||
}
|
||||
|
||||
/* Bar tabs (header-style nav buttons) */
|
||||
.bar-tab {
|
||||
padding: 8px 16px;
|
||||
font-size: 13px;
|
||||
font-weight: 500;
|
||||
border-radius: 12px;
|
||||
background: rgba(0, 0, 0, 0.25);
|
||||
color: rgba(255, 255, 255, 0.7);
|
||||
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.3), inset 0 1px 0 rgba(255, 255, 255, 0.1);
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s ease;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.bar-tab:hover {
|
||||
background: rgba(0, 0, 0, 0.35);
|
||||
color: rgba(255, 255, 255, 0.9);
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
.bar-tab-active {
|
||||
padding: 8px 16px;
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
border-radius: 12px;
|
||||
background: rgba(0, 0, 0, 0.4);
|
||||
color: white;
|
||||
box-shadow: 0 6px 16px rgba(0, 0, 0, 0.4), inset 0 1px 0 rgba(255, 255, 255, 0.15);
|
||||
border: none;
|
||||
cursor: default;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
/* Filter inputs */
|
||||
.filter-input {
|
||||
width: 100%;
|
||||
padding: 8px 12px;
|
||||
border-radius: 12px;
|
||||
border: 1px solid rgba(255, 255, 255, 0.1);
|
||||
background: rgba(255, 255, 255, 0.06);
|
||||
color: rgba(255, 255, 255, 0.95);
|
||||
font-size: 14px;
|
||||
outline: none;
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
|
||||
.filter-input::placeholder {
|
||||
color: rgba(255, 255, 255, 0.3);
|
||||
}
|
||||
|
||||
.filter-input:focus {
|
||||
border-color: rgba(255, 255, 255, 0.2);
|
||||
background: rgba(255, 255, 255, 0.08);
|
||||
box-shadow: 0 0 0 3px rgba(255, 255, 255, 0.04);
|
||||
}
|
||||
|
||||
.filter-select {
|
||||
padding: 8px 32px 8px 12px;
|
||||
border-radius: 12px;
|
||||
border: 1px solid rgba(255, 255, 255, 0.1);
|
||||
background: rgba(255, 255, 255, 0.06);
|
||||
color: rgba(255, 255, 255, 0.8);
|
||||
font-size: 14px;
|
||||
outline: none;
|
||||
appearance: none;
|
||||
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='12' height='12' viewBox='0 0 24 24' fill='none' stroke='rgba(255,255,255,0.4)' stroke-width='2'%3E%3Cpath d='M6 9l6 6 6-6'/%3E%3C/svg%3E");
|
||||
background-repeat: no-repeat;
|
||||
background-position: right 12px center;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
|
||||
.filter-select:focus {
|
||||
border-color: rgba(255, 255, 255, 0.2);
|
||||
}
|
||||
|
||||
.filter-select option {
|
||||
background: #1a1a1a;
|
||||
color: white;
|
||||
}
|
||||
|
||||
/* Type filter buttons */
|
||||
.type-btn {
|
||||
padding: 8px 16px;
|
||||
font-size: 13px;
|
||||
font-weight: 500;
|
||||
line-height: 1.4;
|
||||
border-radius: 10px;
|
||||
background: rgba(255, 255, 255, 0.04);
|
||||
color: rgba(255, 255, 255, 0.5);
|
||||
border: 1px solid rgba(255, 255, 255, 0.08);
|
||||
cursor: pointer;
|
||||
transition: all 0.2s ease;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.type-btn:hover {
|
||||
color: rgba(255, 255, 255, 0.8);
|
||||
background: rgba(255, 255, 255, 0.08);
|
||||
}
|
||||
|
||||
.type-btn-active {
|
||||
padding: 8px 16px;
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
line-height: 1.4;
|
||||
border-radius: 10px;
|
||||
background: rgba(247, 147, 26, 0.15);
|
||||
color: #F7931A;
|
||||
border: 1px solid rgba(247, 147, 26, 0.3);
|
||||
cursor: pointer;
|
||||
transition: all 0.2s ease;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
/* Create button (primary CTA) */
|
||||
.create-button {
|
||||
padding: 10px 24px;
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
border-radius: 14px;
|
||||
background: rgba(255, 255, 255, 0.85);
|
||||
color: rgba(0, 0, 0, 0.9);
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
transition: all 0.3s ease;
|
||||
white-space: nowrap;
|
||||
box-shadow:
|
||||
0 8px 24px rgba(0, 0, 0, 0.3),
|
||||
inset 0 1px 0 rgba(255, 255, 255, 1);
|
||||
}
|
||||
|
||||
.create-button:hover {
|
||||
background: rgba(255, 255, 255, 0.95);
|
||||
transform: translateY(-1px);
|
||||
box-shadow:
|
||||
0 12px 32px rgba(0, 0, 0, 0.4),
|
||||
inset 0 1px 0 rgba(255, 255, 255, 1);
|
||||
}
|
||||
|
||||
.cancel-button {
|
||||
padding: 10px 24px;
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
border-radius: 14px;
|
||||
background: rgba(255, 255, 255, 0.08);
|
||||
color: rgba(255, 255, 255, 0.7);
|
||||
border: 1px solid rgba(255, 255, 255, 0.1);
|
||||
cursor: pointer;
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
|
||||
.cancel-button:hover {
|
||||
background: rgba(255, 255, 255, 0.12);
|
||||
color: white;
|
||||
}
|
||||
|
||||
/* Project Grid */
|
||||
.project-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, 1fr);
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
@media (min-width: 640px) {
|
||||
.project-grid {
|
||||
grid-template-columns: repeat(3, 1fr);
|
||||
gap: 20px;
|
||||
}
|
||||
}
|
||||
|
||||
@media (min-width: 768px) {
|
||||
.project-grid {
|
||||
grid-template-columns: repeat(4, 1fr);
|
||||
}
|
||||
}
|
||||
|
||||
@media (min-width: 1024px) {
|
||||
.project-grid {
|
||||
grid-template-columns: repeat(5, 1fr);
|
||||
}
|
||||
}
|
||||
|
||||
@media (min-width: 1280px) {
|
||||
.project-grid {
|
||||
grid-template-columns: repeat(6, 1fr);
|
||||
}
|
||||
}
|
||||
|
||||
/* Project card hover */
|
||||
.project-card {
|
||||
cursor: pointer;
|
||||
transition: transform 0.3s ease;
|
||||
}
|
||||
|
||||
.project-card:hover {
|
||||
transform: translateY(-4px);
|
||||
}
|
||||
|
||||
/* Empty state */
|
||||
.empty-state {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
/* Modal transition */
|
||||
.modal-enter-active {
|
||||
transition: all 0.3s ease-out;
|
||||
}
|
||||
|
||||
.modal-leave-active {
|
||||
transition: all 0.2s ease-in;
|
||||
}
|
||||
|
||||
.modal-enter-from {
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
.modal-enter-from .modal-card {
|
||||
transform: scale(0.95) translateY(10px);
|
||||
}
|
||||
|
||||
.modal-leave-to {
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
.modal-leave-to .modal-card {
|
||||
transform: scale(0.95) translateY(10px);
|
||||
}
|
||||
</style>
|
||||
535
src/views/backstage/ProjectEditor.vue
Normal file
535
src/views/backstage/ProjectEditor.vue
Normal file
@@ -0,0 +1,535 @@
|
||||
<template>
|
||||
<div class="editor-view">
|
||||
<!-- ── Sticky Top Bar (same px-4 as header = identical width) ──── -->
|
||||
<div class="top-bar-wrapper">
|
||||
<div class="top-bar-inner flex items-center gap-3">
|
||||
<!-- Back to Backstage -->
|
||||
<button @click="$router.push('/backstage')" class="back-button flex-shrink-0" title="Back to Backstage">
|
||||
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 19l-7-7 7-7" />
|
||||
</svg>
|
||||
</button>
|
||||
|
||||
<!-- Project Name + Status -->
|
||||
<div class="flex items-center gap-3 flex-1 min-w-0">
|
||||
<h1 class="text-xl md:text-2xl font-bold text-white truncate">{{ project?.title || project?.name || 'Loading...' }}</h1>
|
||||
<button @click="showNameModal = true" class="text-white/30 hover:text-white/60 transition-colors flex-shrink-0">
|
||||
<svg class="w-3.5 h-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M11 5H6a2 2 0 00-2 2v11a2 2 0 002 2h11a2 2 0 002-2v-5m-1.414-9.414a2 2 0 112.828 2.828L11.828 15H9v-2.828l8.586-8.586z" />
|
||||
</svg>
|
||||
</button>
|
||||
<span :class="statusBadgeClass">{{ project?.status || 'draft' }}</span>
|
||||
</div>
|
||||
|
||||
<!-- Actions -->
|
||||
<div class="flex gap-3 flex-shrink-0">
|
||||
<template v-if="project?.status === 'draft'">
|
||||
<button @click="handleSave('draft')" :disabled="isSaving" class="cancel-button text-sm">
|
||||
{{ isSaving ? 'Saving...' : 'Save Draft' }}
|
||||
</button>
|
||||
<button @click="handlePublish" :disabled="isSaving" class="create-button text-sm">
|
||||
Publish
|
||||
</button>
|
||||
</template>
|
||||
<template v-else>
|
||||
<button @click="handleUnpublish" :disabled="isSaving" class="cancel-button text-sm">
|
||||
Unpublish
|
||||
</button>
|
||||
<button @click="handleSave()" :disabled="isSaving" class="create-button text-sm">
|
||||
{{ isSaving ? 'Saving...' : 'Save Changes' }}
|
||||
</button>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ── Page content ────────────────────────────────────────────── -->
|
||||
<main class="editor-main">
|
||||
|
||||
<!-- Rejection Banner -->
|
||||
<div v-if="project?.status === 'rejected'" class="glass-card border-red-500/20 bg-red-500/5 p-4 mb-6 flex items-start gap-3">
|
||||
<svg class="w-5 h-5 text-red-400 flex-shrink-0 mt-0.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 8v4m0 4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
|
||||
</svg>
|
||||
<div>
|
||||
<p class="text-red-400 font-medium text-sm">Project Rejected</p>
|
||||
<p class="text-red-400/60 text-sm mt-1">Please review and fix the issues below, then resubmit for approval.</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ── Two-column grid (fixed column widths) ─────────────────── -->
|
||||
<div class="editor-grid">
|
||||
|
||||
<!-- ── Sidebar (tab navigation only) ─────────────────────── -->
|
||||
<aside class="sidebar-col">
|
||||
<nav class="glass-card p-3 sidebar-nav">
|
||||
<div class="space-y-1">
|
||||
<button
|
||||
v-for="tab in visibleTabs"
|
||||
:key="tab.id"
|
||||
@click="activeTabId = tab.id"
|
||||
class="sidebar-tab w-full text-left"
|
||||
:class="activeTabId === tab.id ? 'sidebar-tab-active' : ''"
|
||||
>
|
||||
<span class="flex-1">{{ tab.label }}</span>
|
||||
<span v-if="tab.state === 'completed'" class="w-4 h-4 text-green-400">
|
||||
<svg fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 13l4 4L19 7" /></svg>
|
||||
</span>
|
||||
<span v-else-if="tab.state === 'invalid'" class="w-4 h-4 text-red-400">
|
||||
<svg fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 8v4m0 4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z" /></svg>
|
||||
</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Delete Project -->
|
||||
<button
|
||||
@click="showDeleteConfirm = true"
|
||||
class="mt-4 w-full text-sm text-red-400/60 hover:text-red-400 transition-colors text-left px-3 py-2"
|
||||
>
|
||||
Delete Project
|
||||
</button>
|
||||
</nav>
|
||||
</aside>
|
||||
|
||||
<!-- ── Content Area (stable width via grid) ──────────────── -->
|
||||
<div class="content-col">
|
||||
<!-- Tab Content -->
|
||||
<div class="glass-card p-5 md:p-8">
|
||||
<div v-if="activeTabId === 'assets'">
|
||||
<AssetsTab :project="project" @update="handleFieldUpdate" />
|
||||
</div>
|
||||
<div v-else-if="activeTabId === 'details'">
|
||||
<DetailsTab :project="project" :genres="genres" @update="handleFieldUpdate" />
|
||||
</div>
|
||||
<div v-else-if="activeTabId === 'content'">
|
||||
<ContentTab :project="project" />
|
||||
</div>
|
||||
<div v-else-if="activeTabId === 'cast-and-crew'">
|
||||
<CastCrewTab :project="project" />
|
||||
</div>
|
||||
<div v-else-if="activeTabId === 'revenue'">
|
||||
<RevenueTab :project="project" @update="handleFieldUpdate" />
|
||||
</div>
|
||||
<div v-else-if="activeTabId === 'permissions'">
|
||||
<PermissionsTab :project="project" />
|
||||
</div>
|
||||
<div v-else-if="activeTabId === 'documentation'">
|
||||
<DocumentationTab :project="project" />
|
||||
</div>
|
||||
<div v-else-if="activeTabId === 'coupons'">
|
||||
<CouponsTab :project="project" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
|
||||
<!-- Delete Confirmation Modal -->
|
||||
<Transition name="modal">
|
||||
<div v-if="showDeleteConfirm" class="fixed inset-0 z-[100] flex items-center justify-center p-4" @click.self="showDeleteConfirm = false">
|
||||
<div class="fixed inset-0 bg-black/60 backdrop-blur-sm" @click="showDeleteConfirm = false"></div>
|
||||
<div class="modal-card relative z-10 w-full max-w-sm p-6 text-center">
|
||||
<svg class="w-12 h-12 mx-auto text-red-400 mb-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16" />
|
||||
</svg>
|
||||
<h3 class="text-xl font-bold text-white mb-2">Delete Project?</h3>
|
||||
<p class="text-white/50 text-sm mb-6">This action cannot be undone. All project data, uploads, and analytics will be permanently removed.</p>
|
||||
<div class="flex gap-3">
|
||||
<button @click="showDeleteConfirm = false" class="flex-1 cancel-button">Cancel</button>
|
||||
<button @click="handleDelete" class="flex-1 delete-button">Delete</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Transition>
|
||||
|
||||
<!-- Rename Modal -->
|
||||
<Transition name="modal">
|
||||
<div v-if="showNameModal" class="fixed inset-0 z-[100] flex items-center justify-center p-4" @click.self="showNameModal = false">
|
||||
<div class="fixed inset-0 bg-black/60 backdrop-blur-sm" @click="showNameModal = false"></div>
|
||||
<div class="modal-card relative z-10 w-full max-w-sm p-6">
|
||||
<h3 class="text-xl font-bold text-white mb-4">Rename Project</h3>
|
||||
<input
|
||||
v-model="editName"
|
||||
class="filter-input w-full mb-4"
|
||||
placeholder="Project name"
|
||||
@keydown.enter="handleRename"
|
||||
/>
|
||||
<div class="flex gap-3">
|
||||
<button @click="showNameModal = false" class="flex-1 cancel-button">Cancel</button>
|
||||
<button @click="handleRename" class="flex-1 create-button">Save</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Transition>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, onMounted, watch } from 'vue'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
import { useFilmmaker } from '../../composables/useFilmmaker'
|
||||
import type { ApiProject } from '../../types/api'
|
||||
|
||||
// Tab components
|
||||
import AssetsTab from '../../components/backstage/AssetsTab.vue'
|
||||
import DetailsTab from '../../components/backstage/DetailsTab.vue'
|
||||
import ContentTab from '../../components/backstage/ContentTab.vue'
|
||||
import CastCrewTab from '../../components/backstage/CastCrewTab.vue'
|
||||
import RevenueTab from '../../components/backstage/RevenueTab.vue'
|
||||
import PermissionsTab from '../../components/backstage/PermissionsTab.vue'
|
||||
import DocumentationTab from '../../components/backstage/DocumentationTab.vue'
|
||||
import CouponsTab from '../../components/backstage/CouponsTab.vue'
|
||||
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
const { getProject: fetchProject, saveProject, removeProject, fetchGenres, genres } = useFilmmaker()
|
||||
|
||||
const project = ref<ApiProject | null>(null)
|
||||
const isLoading = ref(true)
|
||||
const isSaving = ref(false)
|
||||
const pendingChanges = ref<Record<string, any>>({})
|
||||
|
||||
const activeTabId = ref('assets')
|
||||
const showDeleteConfirm = ref(false)
|
||||
const showNameModal = ref(false)
|
||||
const editName = ref('')
|
||||
|
||||
interface TabDef {
|
||||
id: string
|
||||
label: string
|
||||
state: 'new' | 'completed' | 'invalid'
|
||||
showFor?: string[]
|
||||
}
|
||||
|
||||
const allTabs = computed<TabDef[]>(() => [
|
||||
{ id: 'assets', label: 'Assets', state: project.value?.poster ? 'completed' : 'new' },
|
||||
{ id: 'details', label: 'Details', state: project.value?.title && project.value?.synopsis ? 'completed' : 'new' },
|
||||
{ id: 'content', label: 'Content', state: 'new', showFor: ['episodic'] },
|
||||
{ id: 'cast-and-crew', label: 'Cast & Crew', state: 'new' },
|
||||
{ id: 'revenue', label: 'Revenue', state: project.value?.rentalPrice ? 'completed' : 'new' },
|
||||
{ id: 'permissions', label: 'Permissions', state: 'new' },
|
||||
{ id: 'documentation', label: 'Documentation', state: 'new' },
|
||||
{ id: 'coupons', label: 'Coupons', state: 'new' },
|
||||
])
|
||||
|
||||
const visibleTabs = computed(() =>
|
||||
allTabs.value.filter(
|
||||
(t) => !t.showFor || t.showFor.includes(project.value?.type || 'film')
|
||||
)
|
||||
)
|
||||
|
||||
const currentTabLabel = computed(() =>
|
||||
visibleTabs.value.find((t) => t.id === activeTabId.value)?.label || ''
|
||||
)
|
||||
|
||||
|
||||
const statusBadgeClass = computed(() => {
|
||||
const base = 'inline-block px-2 py-0.5 text-[10px] uppercase tracking-wider font-bold rounded-full flex-shrink-0'
|
||||
switch (project.value?.status) {
|
||||
case 'published':
|
||||
return `${base} bg-green-500/20 text-green-400 border border-green-500/30`
|
||||
case 'rejected':
|
||||
return `${base} bg-red-500/20 text-red-400 border border-red-500/30`
|
||||
default:
|
||||
return `${base} bg-white/10 text-white/50 border border-white/10`
|
||||
}
|
||||
})
|
||||
|
||||
function handleFieldUpdate(field: string, value: any) {
|
||||
pendingChanges.value[field] = value
|
||||
}
|
||||
|
||||
async function handleSave(status?: string) {
|
||||
if (!project.value) return
|
||||
isSaving.value = true
|
||||
|
||||
const data = { ...pendingChanges.value }
|
||||
if (status) data.status = status
|
||||
|
||||
const updated = await saveProject(project.value.id, data)
|
||||
if (updated) {
|
||||
project.value = updated
|
||||
pendingChanges.value = {}
|
||||
}
|
||||
isSaving.value = false
|
||||
}
|
||||
|
||||
async function handlePublish() {
|
||||
await handleSave('published')
|
||||
}
|
||||
|
||||
async function handleUnpublish() {
|
||||
await handleSave('draft')
|
||||
}
|
||||
|
||||
async function handleDelete() {
|
||||
if (!project.value) return
|
||||
const success = await removeProject(project.value.id)
|
||||
if (success) {
|
||||
router.push('/backstage')
|
||||
}
|
||||
showDeleteConfirm.value = false
|
||||
}
|
||||
|
||||
async function handleRename() {
|
||||
if (!project.value || !editName.value.trim()) return
|
||||
const updated = await saveProject(project.value.id, { name: editName.value.trim() })
|
||||
if (updated) project.value = updated
|
||||
showNameModal.value = false
|
||||
}
|
||||
|
||||
async function loadProject() {
|
||||
isLoading.value = true
|
||||
try {
|
||||
const id = route.params.id as string
|
||||
const result = await fetchProject(id)
|
||||
if (!result) {
|
||||
console.warn('Project not found:', id)
|
||||
router.push('/backstage')
|
||||
return
|
||||
}
|
||||
project.value = result
|
||||
editName.value = result.title || result.name || ''
|
||||
} catch (err: any) {
|
||||
console.error('Failed to load project:', err)
|
||||
router.push('/backstage')
|
||||
} finally {
|
||||
isLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
loadProject()
|
||||
fetchGenres()
|
||||
})
|
||||
|
||||
watch(() => route.params.id, () => {
|
||||
loadProject()
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.editor-view {
|
||||
min-height: 100vh;
|
||||
background: #0a0a0a;
|
||||
}
|
||||
|
||||
.glass-card {
|
||||
background: rgba(255, 255, 255, 0.05);
|
||||
backdrop-filter: blur(24px);
|
||||
-webkit-backdrop-filter: blur(24px);
|
||||
border-radius: 16px;
|
||||
border: 1px solid rgba(255, 255, 255, 0.08);
|
||||
box-shadow:
|
||||
0 8px 24px rgba(0, 0, 0, 0.3),
|
||||
inset 0 1px 0 rgba(255, 255, 255, 0.1);
|
||||
}
|
||||
|
||||
.modal-card {
|
||||
background: rgba(20, 20, 20, 0.95);
|
||||
backdrop-filter: blur(40px);
|
||||
-webkit-backdrop-filter: blur(40px);
|
||||
border-radius: 20px;
|
||||
border: 1px solid rgba(255, 255, 255, 0.1);
|
||||
box-shadow:
|
||||
0 24px 80px rgba(0, 0, 0, 0.6),
|
||||
inset 0 1px 0 rgba(255, 255, 255, 0.08);
|
||||
}
|
||||
|
||||
/*
|
||||
* Top bar — exactly matches header positioning.
|
||||
* Header: <header class="fixed top-0 left-0 right-0 z-50 pt-4 px-4">
|
||||
* inner: <div class="floating-glass-header ... rounded-2xl" style="max-width: 100%;">
|
||||
*
|
||||
* So the header glass pill is inset 16px from each viewport edge.
|
||||
* We mirror that exactly: px-4 = 16px each side, always.
|
||||
*/
|
||||
.top-bar-wrapper {
|
||||
position: sticky;
|
||||
top: 0;
|
||||
z-index: 40;
|
||||
/* pt matches the header height + header's pt-4 offset + 32px gap between header and top bar */
|
||||
padding: 112px 16px 8px 16px;
|
||||
background: linear-gradient(to bottom, #0a0a0a 90%, transparent);
|
||||
}
|
||||
|
||||
.top-bar-inner {
|
||||
padding: 12px 16px;
|
||||
border-radius: 16px;
|
||||
background: rgba(10, 10, 10, 0.9);
|
||||
backdrop-filter: blur(24px);
|
||||
-webkit-backdrop-filter: blur(24px);
|
||||
border: 1px solid rgba(255, 255, 255, 0.06);
|
||||
box-shadow: 0 4px 16px rgba(0, 0, 0, 0.3);
|
||||
}
|
||||
|
||||
/* Main content area */
|
||||
.editor-main {
|
||||
padding: 8px 16px 96px 16px;
|
||||
}
|
||||
|
||||
@media (min-width: 768px) {
|
||||
.editor-main {
|
||||
padding-bottom: 32px;
|
||||
}
|
||||
}
|
||||
|
||||
/*
|
||||
* Two-column grid — columns have fixed pixel widths so the content
|
||||
* area never changes width when switching between tabs.
|
||||
* On mobile: single column (stacked).
|
||||
*/
|
||||
.editor-grid {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr;
|
||||
gap: 24px;
|
||||
}
|
||||
|
||||
@media (min-width: 1024px) {
|
||||
.editor-grid {
|
||||
grid-template-columns: 210px 1fr;
|
||||
}
|
||||
}
|
||||
|
||||
.content-col {
|
||||
min-width: 0; /* prevents grid blowout from long content */
|
||||
}
|
||||
|
||||
/* Sidebar nav — fixed on desktop so it never scrolls with the page */
|
||||
@media (min-width: 1024px) {
|
||||
.sidebar-nav {
|
||||
position: fixed;
|
||||
top: 196px; /* aligned with content area top */
|
||||
width: 210px;
|
||||
}
|
||||
}
|
||||
|
||||
/* Back button */
|
||||
.back-button {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 36px;
|
||||
height: 36px;
|
||||
border-radius: 10px;
|
||||
background: rgba(255, 255, 255, 0.08);
|
||||
border: 1px solid rgba(255, 255, 255, 0.1);
|
||||
color: rgba(255, 255, 255, 0.6);
|
||||
cursor: pointer;
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
|
||||
.back-button:hover {
|
||||
background: rgba(255, 255, 255, 0.12);
|
||||
color: white;
|
||||
transform: translateX(-1px);
|
||||
}
|
||||
|
||||
/* Sidebar tabs */
|
||||
.sidebar-tab {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 10px 14px;
|
||||
border-radius: 10px;
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
color: rgba(255, 255, 255, 0.5);
|
||||
transition: all 0.2s ease;
|
||||
cursor: pointer;
|
||||
background: transparent;
|
||||
border: 1px solid transparent;
|
||||
}
|
||||
|
||||
.sidebar-tab:hover {
|
||||
color: rgba(255, 255, 255, 0.8);
|
||||
background: rgba(255, 255, 255, 0.05);
|
||||
}
|
||||
|
||||
.sidebar-tab-active {
|
||||
color: white;
|
||||
background: rgba(255, 255, 255, 0.1);
|
||||
border-color: rgba(255, 255, 255, 0.12);
|
||||
}
|
||||
|
||||
/* Buttons */
|
||||
.filter-input {
|
||||
width: 100%;
|
||||
padding: 10px 12px;
|
||||
border-radius: 12px;
|
||||
border: 1px solid rgba(255, 255, 255, 0.1);
|
||||
background: rgba(255, 255, 255, 0.06);
|
||||
color: rgba(255, 255, 255, 0.95);
|
||||
font-size: 14px;
|
||||
outline: none;
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
|
||||
.filter-input::placeholder {
|
||||
color: rgba(255, 255, 255, 0.3);
|
||||
}
|
||||
|
||||
.filter-input:focus {
|
||||
border-color: rgba(255, 255, 255, 0.2);
|
||||
background: rgba(255, 255, 255, 0.08);
|
||||
box-shadow: 0 0 0 3px rgba(255, 255, 255, 0.04);
|
||||
}
|
||||
|
||||
.create-button {
|
||||
padding: 10px 24px;
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
border-radius: 14px;
|
||||
background: rgba(255, 255, 255, 0.85);
|
||||
color: rgba(0, 0, 0, 0.9);
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
transition: all 0.3s ease;
|
||||
white-space: nowrap;
|
||||
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.3), inset 0 1px 0 rgba(255, 255, 255, 1);
|
||||
}
|
||||
|
||||
.create-button:hover {
|
||||
background: rgba(255, 255, 255, 0.95);
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
|
||||
.cancel-button {
|
||||
padding: 10px 24px;
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
border-radius: 14px;
|
||||
background: rgba(255, 255, 255, 0.08);
|
||||
color: rgba(255, 255, 255, 0.7);
|
||||
border: 1px solid rgba(255, 255, 255, 0.1);
|
||||
cursor: pointer;
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
|
||||
.cancel-button:hover {
|
||||
background: rgba(255, 255, 255, 0.12);
|
||||
color: white;
|
||||
}
|
||||
|
||||
.delete-button {
|
||||
padding: 10px 24px;
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
border-radius: 14px;
|
||||
background: rgba(239, 68, 68, 0.2);
|
||||
color: #ef4444;
|
||||
border: 1px solid rgba(239, 68, 68, 0.3);
|
||||
cursor: pointer;
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
|
||||
.delete-button:hover {
|
||||
background: rgba(239, 68, 68, 0.3);
|
||||
}
|
||||
|
||||
/* Modal transition */
|
||||
.modal-enter-active { transition: all 0.3s ease-out; }
|
||||
.modal-leave-active { transition: all 0.2s ease-in; }
|
||||
.modal-enter-from, .modal-leave-to { opacity: 0; }
|
||||
</style>
|
||||
537
src/views/backstage/Settings.vue
Normal file
537
src/views/backstage/Settings.vue
Normal file
@@ -0,0 +1,537 @@
|
||||
<template>
|
||||
<div class="settings-view">
|
||||
<!-- Sticky Top Bar -->
|
||||
<div class="page-top-bar-wrapper">
|
||||
<div class="page-top-bar-inner flex items-center gap-3">
|
||||
<h1 class="text-xl md:text-2xl font-bold text-white">Settings</h1>
|
||||
<nav class="flex items-center gap-2 flex-1 ml-4 overflow-x-auto">
|
||||
<button
|
||||
v-for="tab in settingsTabs"
|
||||
:key="tab.id"
|
||||
@click="activeTab = tab.id"
|
||||
:class="activeTab === tab.id ? 'bar-tab-active' : 'bar-tab'"
|
||||
>
|
||||
{{ tab.label }}
|
||||
</button>
|
||||
</nav>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<main class="page-main">
|
||||
<div class="mx-auto max-w-3xl">
|
||||
|
||||
<!-- ── Personal ────────────────────────────────────────────────── -->
|
||||
<div v-if="activeTab === 'personal'" class="grid grid-cols-1 lg:grid-cols-5 gap-6">
|
||||
<!-- Profile Details (left, wider) -->
|
||||
<div class="glass-card p-6 md:p-8 lg:col-span-3">
|
||||
<h2 class="text-xl font-bold text-white mb-6">Profile Details</h2>
|
||||
|
||||
<div class="space-y-5">
|
||||
<div class="grid grid-cols-1 sm:grid-cols-2 gap-5">
|
||||
<div>
|
||||
<label class="field-label">First Name</label>
|
||||
<input v-model="firstName" class="field-input" placeholder="First name" />
|
||||
</div>
|
||||
<div>
|
||||
<label class="field-label">Last Name</label>
|
||||
<input v-model="lastName" class="field-input" placeholder="Last name" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label class="field-label">Professional Name</label>
|
||||
<input v-model="professionalName" class="field-input" placeholder="Display name for your creator profile" />
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label class="field-label">Bio</label>
|
||||
<textarea v-model="bio" class="field-input min-h-[100px] resize-y" placeholder="Tell viewers about yourself..." rows="3"></textarea>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mt-8 flex justify-end">
|
||||
<button @click="handleSaveProfile" :disabled="isSaving" class="create-button">
|
||||
{{ isSaving ? 'Saving...' : 'Save Changes' }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Account Information (right, narrower) -->
|
||||
<div class="glass-card p-6 md:p-8 lg:col-span-2">
|
||||
<h2 class="text-xl font-bold text-white mb-6">Account Information</h2>
|
||||
<div class="space-y-5">
|
||||
<div class="flex items-center justify-between">
|
||||
<div>
|
||||
<label class="field-label mb-0">Email</label>
|
||||
<p class="text-white font-medium text-sm">{{ user?.email || 'Not set' }}</p>
|
||||
</div>
|
||||
<button class="text-sm text-white/40 hover:text-white transition-colors">Change</button>
|
||||
</div>
|
||||
<div class="flex items-center justify-between">
|
||||
<div>
|
||||
<label class="field-label mb-0">Password</label>
|
||||
<p class="text-white/50 text-sm">••••••••</p>
|
||||
</div>
|
||||
<button class="text-sm text-white/40 hover:text-white transition-colors">Change</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ── Security ────────────────────────────────────────────────── -->
|
||||
<div v-if="activeTab === 'security'" class="glass-card p-6 md:p-8">
|
||||
<h2 class="text-xl font-bold text-white mb-6">Security</h2>
|
||||
|
||||
<!-- Nostr Key -->
|
||||
<div class="mb-8">
|
||||
<h3 class="text-lg font-semibold text-white mb-2">Nostr Key</h3>
|
||||
<p class="text-white/40 text-sm mb-4">Link your Nostr key for decentralized authentication.</p>
|
||||
|
||||
<div v-if="user?.nostrPubkey" class="space-y-3">
|
||||
<div class="info-row">
|
||||
<span class="text-white/50 text-sm">Public Key</span>
|
||||
<span class="text-white font-mono text-xs truncate max-w-[200px]">{{ user.nostrPubkey }}</span>
|
||||
</div>
|
||||
<div class="flex items-center gap-2">
|
||||
<span class="status-dot bg-green-400"></span>
|
||||
<span class="text-green-400 text-sm font-medium">Linked</span>
|
||||
</div>
|
||||
<button @click="handleUnlinkNostr" class="text-sm text-red-400/60 hover:text-red-400 transition-colors">
|
||||
Unlink Nostr Account
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div v-else>
|
||||
<button @click="handleLinkNostr" class="create-button text-sm">
|
||||
Link Nostr Key
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- MFA -->
|
||||
<div>
|
||||
<h3 class="text-lg font-semibold text-white mb-2">Multi-Factor Authentication</h3>
|
||||
<p class="text-white/40 text-sm mb-4">Add an extra layer of security to your account.</p>
|
||||
<div class="flex items-center gap-3">
|
||||
<span class="status-dot bg-white/20"></span>
|
||||
<span class="text-white/50 text-sm">Not configured</span>
|
||||
</div>
|
||||
<button class="cancel-button text-sm mt-3">Set Up MFA</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ── Withdrawal Methods ──────────────────────────────────────── -->
|
||||
<div v-if="activeTab === 'withdrawal'" class="glass-card p-6 md:p-8">
|
||||
<div class="flex items-center justify-between mb-6">
|
||||
<h2 class="text-xl font-bold text-white">Withdrawal Methods</h2>
|
||||
<button @click="showAddMethodModal = true" class="add-button flex items-center gap-2">
|
||||
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 4v16m8-8H4" />
|
||||
</svg>
|
||||
Add Method
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Methods list -->
|
||||
<div v-if="paymentMethods.length > 0" class="space-y-3">
|
||||
<div v-for="method in paymentMethods" :key="method.id" class="method-row">
|
||||
<div class="flex items-center gap-3 flex-1 min-w-0">
|
||||
<div class="w-10 h-10 rounded-xl flex items-center justify-center flex-shrink-0" :class="method.type === 'lightning' ? 'bg-[#F7931A]/20 text-[#F7931A]' : 'bg-white/10 text-white/60'">
|
||||
<svg v-if="method.type === 'lightning'" class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13 10V3L4 14h7v7l9-11h-7z" />
|
||||
</svg>
|
||||
<svg v-else class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M3 10h18M7 15h1m4 0h1m-7 4h12a3 3 0 003-3V8a3 3 0 00-3-3H6a3 3 0 00-3 3v8a3 3 0 003 3z" />
|
||||
</svg>
|
||||
</div>
|
||||
<div class="min-w-0">
|
||||
<p class="text-white font-medium text-sm truncate">
|
||||
{{ method.type === 'lightning' ? method.lightningAddress : method.bankName }}
|
||||
</p>
|
||||
<p class="text-white/40 text-xs capitalize">{{ method.type }} · {{ method.withdrawalFrequency }}</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex items-center gap-2">
|
||||
<span v-if="method.isSelected" class="selected-badge">Active</span>
|
||||
<button v-else @click="selectMethod(method.id)" class="text-xs text-white/30 hover:text-white transition-colors">
|
||||
Set Active
|
||||
</button>
|
||||
<button @click="removeMethod(method.id)" class="text-white/20 hover:text-red-400 transition-colors ml-2">
|
||||
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-else class="text-center py-12">
|
||||
<svg class="w-12 h-12 mx-auto text-white/15 mb-3" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" d="M13 10V3L4 14h7v7l9-11h-7z" />
|
||||
</svg>
|
||||
<p class="text-white/40 text-sm mb-4">No withdrawal methods configured</p>
|
||||
<p class="text-white/30 text-xs">Add a Lightning address to withdraw your earnings.</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ── Add-ons ─────────────────────────────────────────────────── -->
|
||||
<div v-if="activeTab === 'addons'" class="space-y-4">
|
||||
<div v-for="addon in addons" :key="addon.id" class="glass-card p-6">
|
||||
<div class="flex items-start justify-between">
|
||||
<div class="flex-1">
|
||||
<h3 class="text-lg font-semibold text-white mb-1">{{ addon.title }}</h3>
|
||||
<p class="text-white/40 text-sm">{{ addon.description }}</p>
|
||||
</div>
|
||||
<button
|
||||
:disabled="addon.disabled"
|
||||
:class="addon.enabled ? 'toggle-active' : addon.disabled ? 'toggle-disabled' : 'toggle-inactive'"
|
||||
@click="addon.enabled = !addon.enabled"
|
||||
>
|
||||
{{ addon.enabled ? 'Enabled' : addon.disabled ? 'Coming Soon' : 'Enable' }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
|
||||
<!-- Add Payment Method Modal -->
|
||||
<Transition name="modal">
|
||||
<div v-if="showAddMethodModal" class="fixed inset-0 z-[100] flex items-center justify-center p-4" @click.self="showAddMethodModal = false">
|
||||
<div class="fixed inset-0 bg-black/60 backdrop-blur-sm" @click="showAddMethodModal = false"></div>
|
||||
<div class="modal-card relative z-10 w-full max-w-md p-6">
|
||||
<h3 class="text-xl font-bold text-white mb-2">Add Lightning Address</h3>
|
||||
<p class="text-white/50 text-sm mb-6">Receive your creator earnings directly via the Lightning Network.</p>
|
||||
|
||||
<!-- Lightning Address -->
|
||||
<div class="space-y-4">
|
||||
<div>
|
||||
<label class="field-label">Lightning Address</label>
|
||||
<input v-model="newLightningAddress" class="field-input" placeholder="you@wallet.example.com" />
|
||||
<p v-if="addMethodError" class="text-red-400 text-xs mt-1">{{ addMethodError }}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Frequency -->
|
||||
<div class="mt-5">
|
||||
<label class="field-label">Withdrawal Frequency</label>
|
||||
<select v-model="newFrequency" class="field-select">
|
||||
<option value="automatic">Automatic</option>
|
||||
<option value="daily">Daily</option>
|
||||
<option value="weekly">Weekly</option>
|
||||
<option value="monthly">Monthly</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div class="flex gap-3 mt-6">
|
||||
<button @click="showAddMethodModal = false" class="flex-1 cancel-button">Cancel</button>
|
||||
<button @click="handleAddMethod" :disabled="isAddingMethod" class="flex-1 create-button">
|
||||
{{ isAddingMethod ? 'Validating...' : 'Add Method' }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Transition>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, reactive, onMounted, computed } from 'vue'
|
||||
import { useAuth } from '../../composables/useAuth'
|
||||
import { useFilmmaker } from '../../composables/useFilmmaker'
|
||||
import * as filmmakerService from '../../services/filmmaker.service'
|
||||
import { USE_MOCK } from '../../utils/mock'
|
||||
|
||||
const { user, linkNostr, unlinkNostr } = useAuth()
|
||||
const { paymentMethods, fetchPaymentMethods } = useFilmmaker()
|
||||
|
||||
const activeTab = ref('personal')
|
||||
const isSaving = ref(false)
|
||||
|
||||
// Personal fields
|
||||
const firstName = ref('')
|
||||
const lastName = ref('')
|
||||
const professionalName = ref('')
|
||||
const bio = ref('')
|
||||
|
||||
// Settings tabs
|
||||
const settingsTabs = computed(() => {
|
||||
const tabs = [
|
||||
{ id: 'personal', label: 'Personal' },
|
||||
{ id: 'security', label: 'Security' },
|
||||
{ id: 'withdrawal', label: 'Withdrawal Methods' },
|
||||
{ id: 'addons', label: 'Add-ons' },
|
||||
]
|
||||
return tabs
|
||||
})
|
||||
|
||||
// Add-ons
|
||||
const addons = reactive([
|
||||
{ id: 'rss', title: 'RSS Exhibition', description: 'Distribute your content via RSS feed for podcast apps and other platforms.', enabled: false, disabled: false },
|
||||
{ id: 'screening', title: 'Screening Room', description: 'Create private screening rooms for festivals and industry professionals.', enabled: false, disabled: false },
|
||||
{ id: 'badge', title: 'ID Verification & Badge', description: 'Verify your identity to get a verified badge on your profile.', enabled: false, disabled: true },
|
||||
])
|
||||
|
||||
// Withdrawal method modal
|
||||
const showAddMethodModal = ref(false)
|
||||
const newMethodType = ref<'lightning'>('lightning')
|
||||
const newLightningAddress = ref('')
|
||||
const newFrequency = ref<string>('automatic')
|
||||
|
||||
function handleSaveProfile() {
|
||||
isSaving.value = true
|
||||
console.log('Save profile:', { firstName: firstName.value, lastName: lastName.value, professionalName: professionalName.value, bio: bio.value })
|
||||
setTimeout(() => { isSaving.value = false }, 500)
|
||||
}
|
||||
|
||||
async function handleLinkNostr() {
|
||||
if (!window.nostr) {
|
||||
alert('Nostr extension not found. Please install a Nostr browser extension like Alby or nos2x.')
|
||||
return
|
||||
}
|
||||
try {
|
||||
const pubkey = await window.nostr.getPublicKey()
|
||||
const authEvent = {
|
||||
kind: 27235,
|
||||
created_at: Math.floor(Date.now() / 1000),
|
||||
tags: [['u', window.location.origin], ['method', 'POST']],
|
||||
content: '',
|
||||
}
|
||||
const signedEvent = await window.nostr.signEvent(authEvent)
|
||||
await linkNostr(pubkey, signedEvent.sig)
|
||||
} catch (error: any) {
|
||||
alert(error.message || 'Failed to link Nostr account')
|
||||
}
|
||||
}
|
||||
|
||||
async function handleUnlinkNostr() {
|
||||
if (!confirm('Are you sure you want to unlink your Nostr account?')) return
|
||||
try {
|
||||
await unlinkNostr()
|
||||
} catch (error: any) {
|
||||
alert(error.message || 'Failed to unlink')
|
||||
}
|
||||
}
|
||||
|
||||
const isAddingMethod = ref(false)
|
||||
const addMethodError = ref<string | null>(null)
|
||||
|
||||
async function handleAddMethod() {
|
||||
if (!newLightningAddress.value || !newLightningAddress.value.includes('@')) {
|
||||
addMethodError.value = 'Please enter a valid Lightning address (e.g. you@wallet.com)'
|
||||
return
|
||||
}
|
||||
|
||||
isAddingMethod.value = true
|
||||
addMethodError.value = null
|
||||
|
||||
try {
|
||||
if (USE_MOCK) {
|
||||
// In dev/mock mode, simulate validation and save locally
|
||||
await new Promise(resolve => setTimeout(resolve, 400))
|
||||
console.log('Development mode: Mock Lightning address added:', newLightningAddress.value)
|
||||
paymentMethods.value = [{
|
||||
id: 'mock-ln-' + Date.now(),
|
||||
type: 'lightning',
|
||||
lightningAddress: newLightningAddress.value,
|
||||
withdrawalFrequency: newFrequency.value,
|
||||
isSelected: true,
|
||||
} as any, ...paymentMethods.value]
|
||||
showAddMethodModal.value = false
|
||||
newLightningAddress.value = ''
|
||||
return
|
||||
}
|
||||
|
||||
// Validate the address first
|
||||
const isValid = await filmmakerService.validateLightningAddress(newLightningAddress.value)
|
||||
if (!isValid) {
|
||||
addMethodError.value = 'This Lightning address could not be verified. Please check and try again.'
|
||||
return
|
||||
}
|
||||
|
||||
// Add the payment method
|
||||
await filmmakerService.addPaymentMethod({
|
||||
type: 'lightning',
|
||||
lightningAddress: newLightningAddress.value,
|
||||
withdrawalFrequency: newFrequency.value,
|
||||
})
|
||||
|
||||
// Refresh the list
|
||||
await fetchPaymentMethods()
|
||||
showAddMethodModal.value = false
|
||||
newLightningAddress.value = ''
|
||||
} catch (error: any) {
|
||||
addMethodError.value = error.message || 'Failed to add payment method'
|
||||
} finally {
|
||||
isAddingMethod.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function selectMethod(id: string) {
|
||||
try {
|
||||
if (USE_MOCK) {
|
||||
paymentMethods.value = paymentMethods.value.map(m => ({
|
||||
...m,
|
||||
isSelected: m.id === id,
|
||||
}))
|
||||
return
|
||||
}
|
||||
await filmmakerService.selectPaymentMethod(id)
|
||||
await fetchPaymentMethods()
|
||||
} catch (error: any) {
|
||||
console.error('Failed to select method:', error)
|
||||
}
|
||||
}
|
||||
|
||||
async function removeMethod(id: string) {
|
||||
if (!confirm('Remove this withdrawal method?')) return
|
||||
try {
|
||||
if (USE_MOCK) {
|
||||
paymentMethods.value = paymentMethods.value.filter(m => m.id !== id)
|
||||
return
|
||||
}
|
||||
await filmmakerService.removePaymentMethod(id)
|
||||
await fetchPaymentMethods()
|
||||
} catch (error: any) {
|
||||
console.error('Failed to remove method:', error)
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
// Populate fields from user
|
||||
if (user.value) {
|
||||
const names = (user.value.legalName || '').split(' ')
|
||||
firstName.value = names[0] || ''
|
||||
lastName.value = names.slice(1).join(' ') || ''
|
||||
professionalName.value = user.value.filmmaker?.professionalName || ''
|
||||
bio.value = user.value.filmmaker?.bio || ''
|
||||
}
|
||||
fetchPaymentMethods()
|
||||
})
|
||||
|
||||
declare global {
|
||||
interface Window {
|
||||
nostr?: {
|
||||
getPublicKey: () => Promise<string>
|
||||
signEvent: (event: any) => Promise<any>
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.settings-view { min-height: 100vh; background: #0a0a0a; }
|
||||
|
||||
.page-top-bar-wrapper {
|
||||
position: sticky;
|
||||
top: 0;
|
||||
z-index: 40;
|
||||
padding: 112px 16px 8px 16px;
|
||||
background: linear-gradient(to bottom, #0a0a0a 90%, transparent);
|
||||
}
|
||||
|
||||
.page-top-bar-inner {
|
||||
padding: 12px 16px;
|
||||
border-radius: 16px;
|
||||
background: rgba(10, 10, 10, 0.9);
|
||||
backdrop-filter: blur(24px);
|
||||
-webkit-backdrop-filter: blur(24px);
|
||||
border: 1px solid rgba(255, 255, 255, 0.06);
|
||||
box-shadow: 0 4px 16px rgba(0, 0, 0, 0.3);
|
||||
}
|
||||
|
||||
.page-main {
|
||||
padding: 8px 16px 96px 16px;
|
||||
}
|
||||
|
||||
@media (min-width: 768px) {
|
||||
.page-main {
|
||||
padding-bottom: 32px;
|
||||
}
|
||||
}
|
||||
|
||||
.glass-card {
|
||||
background: rgba(255, 255, 255, 0.05);
|
||||
backdrop-filter: blur(24px);
|
||||
-webkit-backdrop-filter: blur(24px);
|
||||
border-radius: 16px;
|
||||
border: 1px solid rgba(255, 255, 255, 0.08);
|
||||
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.3), inset 0 1px 0 rgba(255, 255, 255, 0.1);
|
||||
}
|
||||
|
||||
.modal-card {
|
||||
background: rgba(20, 20, 20, 0.95);
|
||||
backdrop-filter: blur(40px);
|
||||
border-radius: 20px;
|
||||
border: 1px solid rgba(255, 255, 255, 0.1);
|
||||
box-shadow: 0 24px 80px rgba(0, 0, 0, 0.6), inset 0 1px 0 rgba(255, 255, 255, 0.08);
|
||||
}
|
||||
|
||||
/* Bar tabs (header-style nav buttons) */
|
||||
.bar-tab {
|
||||
padding: 8px 16px;
|
||||
font-size: 13px;
|
||||
font-weight: 500;
|
||||
border-radius: 12px;
|
||||
background: rgba(0, 0, 0, 0.25);
|
||||
color: rgba(255, 255, 255, 0.7);
|
||||
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.3), inset 0 1px 0 rgba(255, 255, 255, 0.1);
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s ease;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.bar-tab:hover {
|
||||
background: rgba(0, 0, 0, 0.35);
|
||||
color: rgba(255, 255, 255, 0.9);
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
.bar-tab-active {
|
||||
padding: 8px 16px;
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
border-radius: 12px;
|
||||
background: rgba(0, 0, 0, 0.4);
|
||||
color: white;
|
||||
box-shadow: 0 6px 16px rgba(0, 0, 0, 0.4), inset 0 1px 0 rgba(255, 255, 255, 0.15);
|
||||
border: none;
|
||||
cursor: default;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
/* Form fields */
|
||||
.field-label { display: block; color: rgba(255, 255, 255, 0.6); font-size: 14px; font-weight: 500; margin-bottom: 6px; }
|
||||
.field-input { width: 100%; padding: 10px 14px; border-radius: 12px; border: 1px solid rgba(255, 255, 255, 0.1); background: rgba(255, 255, 255, 0.06); color: rgba(255, 255, 255, 0.95); font-size: 14px; outline: none; transition: all 0.2s; }
|
||||
.field-input::placeholder { color: rgba(255, 255, 255, 0.25); }
|
||||
.field-input:focus { border-color: rgba(255, 255, 255, 0.2); background: rgba(255, 255, 255, 0.08); box-shadow: 0 0 0 3px rgba(255, 255, 255, 0.04); }
|
||||
.field-select { width: 100%; padding: 10px 14px; border-radius: 12px; border: 1px solid rgba(255, 255, 255, 0.1); background: rgba(255, 255, 255, 0.06); color: rgba(255, 255, 255, 0.8); font-size: 14px; outline: none; appearance: none; cursor: pointer; }
|
||||
.field-select option { background: #1a1a1a; color: white; }
|
||||
|
||||
/* Buttons */
|
||||
.create-button { padding: 10px 24px; font-size: 14px; font-weight: 600; border-radius: 14px; background: rgba(255, 255, 255, 0.85); color: rgba(0, 0, 0, 0.9); border: none; cursor: pointer; transition: all 0.3s; box-shadow: 0 8px 24px rgba(0, 0, 0, 0.3); }
|
||||
.create-button:hover { background: rgba(255, 255, 255, 0.95); transform: translateY(-1px); }
|
||||
.cancel-button { padding: 10px 24px; font-size: 14px; font-weight: 500; border-radius: 14px; background: rgba(255, 255, 255, 0.08); color: rgba(255, 255, 255, 0.7); border: 1px solid rgba(255, 255, 255, 0.1); cursor: pointer; transition: all 0.2s; }
|
||||
.cancel-button:hover { background: rgba(255, 255, 255, 0.12); color: white; }
|
||||
.add-button { padding: 8px 16px; font-size: 13px; font-weight: 600; border-radius: 10px; background: rgba(255, 255, 255, 0.08); color: rgba(255, 255, 255, 0.8); border: 1px solid rgba(255, 255, 255, 0.1); cursor: pointer; transition: all 0.2s; }
|
||||
.add-button:hover { background: rgba(255, 255, 255, 0.12); color: white; }
|
||||
|
||||
/* Type toggle */
|
||||
.type-btn { padding: 10px 20px; font-size: 13px; font-weight: 500; border-radius: 12px; background: rgba(255, 255, 255, 0.04); color: rgba(255, 255, 255, 0.5); border: 1px solid rgba(255, 255, 255, 0.08); cursor: pointer; transition: all 0.2s; }
|
||||
.type-btn:hover { color: rgba(255, 255, 255, 0.8); background: rgba(255, 255, 255, 0.08); }
|
||||
.type-btn-active { padding: 10px 20px; font-size: 13px; font-weight: 600; border-radius: 12px; background: rgba(247, 147, 26, 0.15); color: #F7931A; border: 1px solid rgba(247, 147, 26, 0.3); cursor: pointer; }
|
||||
|
||||
/* Toggle buttons */
|
||||
.toggle-active { padding: 6px 14px; font-size: 12px; font-weight: 600; border-radius: 8px; background: rgba(34, 197, 94, 0.15); color: #22c55e; border: 1px solid rgba(34, 197, 94, 0.3); cursor: pointer; }
|
||||
.toggle-inactive { padding: 6px 14px; font-size: 12px; font-weight: 600; border-radius: 8px; background: rgba(255, 255, 255, 0.08); color: rgba(255, 255, 255, 0.6); border: 1px solid rgba(255, 255, 255, 0.1); cursor: pointer; }
|
||||
.toggle-disabled { padding: 6px 14px; font-size: 12px; font-weight: 500; border-radius: 8px; background: rgba(255, 255, 255, 0.04); color: rgba(255, 255, 255, 0.3); border: 1px solid rgba(255, 255, 255, 0.06); cursor: not-allowed; }
|
||||
|
||||
/* Method row */
|
||||
.method-row { display: flex; align-items: center; gap: 12px; padding: 14px 16px; border-radius: 12px; background: rgba(255, 255, 255, 0.04); border: 1px solid rgba(255, 255, 255, 0.06); }
|
||||
.selected-badge { padding: 4px 10px; font-size: 11px; font-weight: 600; border-radius: 8px; background: rgba(247, 147, 26, 0.15); color: #F7931A; text-transform: uppercase; letter-spacing: 0.05em; }
|
||||
.info-row { display: flex; justify-content: space-between; align-items: center; padding: 8px 0; }
|
||||
.status-dot { width: 8px; height: 8px; border-radius: 50%; flex-shrink: 0; }
|
||||
|
||||
.modal-enter-active { transition: all 0.3s ease-out; }
|
||||
.modal-leave-active { transition: all 0.2s ease-in; }
|
||||
.modal-enter-from, .modal-leave-to { opacity: 0; }
|
||||
</style>
|
||||
Reference in New Issue
Block a user