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:
@@ -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>
|
||||
Reference in New Issue
Block a user