Enhance deployment script and update package dependencies
- Added detailed labels to the deployment script for IndeedHub, including title, version, description, license, icon, and repository URL. - Updated package dependencies in package.json and package-lock.json, including upgrading 'nostr-tools' to version 2.23.0 and adding 'axios' and '@tanstack/vue-query'. - Improved README with a modern description of the platform and updated project structure details. This commit enhances the clarity of the deployment process and ensures the project is using the latest dependencies for better performance and features.
This commit is contained in:
13
src/App.vue
13
src/App.vue
@@ -4,9 +4,22 @@
|
||||
|
||||
<!-- Mobile Navigation (hidden on desktop) -->
|
||||
<MobileNav />
|
||||
|
||||
<!-- Toast Notifications -->
|
||||
<ToastContainer />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { onMounted } from 'vue'
|
||||
import { useAuthStore } from './stores/auth'
|
||||
import MobileNav from './components/MobileNav.vue'
|
||||
import ToastContainer from './components/ToastContainer.vue'
|
||||
|
||||
const authStore = useAuthStore()
|
||||
|
||||
onMounted(async () => {
|
||||
// Initialize authentication on app mount
|
||||
await authStore.initialize()
|
||||
})
|
||||
</script>
|
||||
|
||||
374
src/components/AppHeader.vue
Normal file
374
src/components/AppHeader.vue
Normal file
@@ -0,0 +1,374 @@
|
||||
<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">
|
||||
<!-- Logo + Navigation (Left Side) -->
|
||||
<div class="flex items-center gap-10">
|
||||
<router-link to="/">
|
||||
<img src="/assets/images/logo.svg" alt="IndeedHub" class="h-10 ml-2 md:ml-0" />
|
||||
</router-link>
|
||||
|
||||
<!-- Navigation - Desktop -->
|
||||
<nav v-if="showNav" class="hidden md:flex items-center gap-3">
|
||||
<router-link to="/" :class="isRoute('/') ? 'nav-button-active' : 'nav-button'">Films</router-link>
|
||||
<router-link to="/library" :class="isRoute('/library') ? 'nav-button-active' : 'nav-button'">My List</router-link>
|
||||
</nav>
|
||||
</div>
|
||||
|
||||
<!-- Right Side Actions -->
|
||||
<div class="flex items-center gap-4">
|
||||
<!-- Sign In Button (if not authenticated) -->
|
||||
<button
|
||||
v-if="!isAuthenticated && showAuth"
|
||||
@click="$emit('openAuth')"
|
||||
class="hidden md:block hero-play-button px-4 py-2 text-sm"
|
||||
>
|
||||
Sign In
|
||||
</button>
|
||||
|
||||
<!-- Search -->
|
||||
<button v-if="showSearch" class="hidden md:block p-2 hover:bg-white/10 rounded-lg transition-colors">
|
||||
<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="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z" />
|
||||
</svg>
|
||||
</button>
|
||||
|
||||
<!-- Profile Dropdown (authenticated only) -->
|
||||
<div v-if="isAuthenticated" class="hidden md:block relative profile-dropdown">
|
||||
<button
|
||||
@click="toggleDropdown"
|
||||
class="profile-button flex items-center gap-2"
|
||||
>
|
||||
<div class="profile-avatar">
|
||||
<span>{{ userInitials }}</span>
|
||||
</div>
|
||||
<span class="text-white text-sm font-medium">{{ userName }}</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>
|
||||
|
||||
<!-- Dropdown Menu -->
|
||||
<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('/library')" 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="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>
|
||||
<span>My Library</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>
|
||||
|
||||
<!-- Mobile User Avatar + Name -->
|
||||
<div class="md:hidden flex items-center gap-2 mr-2">
|
||||
<div v-if="isAuthenticated" class="profile-avatar">
|
||||
<span>{{ userInitials }}</span>
|
||||
</div>
|
||||
<span v-if="isAuthenticated" class="text-white text-sm font-medium">{{ userName }}</span>
|
||||
<button v-else-if="showAuth" @click="$emit('openAuth')" class="text-white text-sm font-medium">Sign In</button>
|
||||
</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'
|
||||
|
||||
interface Props {
|
||||
showNav?: boolean
|
||||
showSearch?: boolean
|
||||
showAuth?: boolean
|
||||
}
|
||||
|
||||
interface Emits {
|
||||
(e: 'openAuth'): void
|
||||
}
|
||||
|
||||
withDefaults(defineProps<Props>(), {
|
||||
showNav: true,
|
||||
showSearch: true,
|
||||
showAuth: true,
|
||||
})
|
||||
|
||||
defineEmits<Emits>()
|
||||
|
||||
const router = useRouter()
|
||||
const route = useRoute()
|
||||
const { user, isAuthenticated, logout } = useAuth()
|
||||
|
||||
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]
|
||||
})
|
||||
|
||||
const userName = computed(() => {
|
||||
return user.value?.legalName?.split(' ')[0] || 'Guest'
|
||||
})
|
||||
|
||||
function isRoute(path: string): boolean {
|
||||
return route.path === path
|
||||
}
|
||||
|
||||
function toggleDropdown() {
|
||||
dropdownOpen.value = !dropdownOpen.value
|
||||
}
|
||||
|
||||
function navigateTo(path: string) {
|
||||
dropdownOpen.value = false
|
||||
router.push(path)
|
||||
}
|
||||
|
||||
async function handleLogout() {
|
||||
await logout()
|
||||
dropdownOpen.value = false
|
||||
router.push('/')
|
||||
}
|
||||
|
||||
const 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: 12px 24px;
|
||||
font-size: 16px;
|
||||
font-weight: 500;
|
||||
line-height: 1.4;
|
||||
border-radius: 16px;
|
||||
background: rgba(0, 0, 0, 0.25);
|
||||
color: rgba(255, 255, 255, 0.96);
|
||||
box-shadow:
|
||||
0 8px 24px rgba(0, 0, 0, 0.45),
|
||||
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-block;
|
||||
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 12px 32px rgba(0, 0, 0, 0.6),
|
||||
inset 0 1px 0 rgba(255, 255, 255, 0.25);
|
||||
}
|
||||
|
||||
.nav-button:hover::before {
|
||||
background: linear-gradient(135deg, rgba(255, 255, 255, 0.3), transparent);
|
||||
}
|
||||
|
||||
.nav-button-active {
|
||||
position: relative;
|
||||
padding: 12px 24px;
|
||||
font-size: 16px;
|
||||
font-weight: 600;
|
||||
line-height: 1.4;
|
||||
border-radius: 16px;
|
||||
background: rgba(0, 0, 0, 0.35);
|
||||
color: rgba(255, 255, 255, 1);
|
||||
box-shadow:
|
||||
0 12px 32px rgba(0, 0, 0, 0.6),
|
||||
inset 0 1px 0 rgba(255, 255, 255, 0.25);
|
||||
backdrop-filter: blur(24px);
|
||||
-webkit-backdrop-filter: blur(24px);
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
transition: all 0.3s ease;
|
||||
text-decoration: none;
|
||||
display: inline-block;
|
||||
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;
|
||||
}
|
||||
|
||||
.nav-button-active:hover {
|
||||
transform: translateY(-2px);
|
||||
background: rgba(0, 0, 0, 0.40);
|
||||
box-shadow:
|
||||
0 12px 32px rgba(0, 0, 0, 0.6),
|
||||
inset 0 1px 0 rgba(255, 255, 255, 0.3);
|
||||
}
|
||||
|
||||
/* Sign In Button (reuses hero-play-button pattern) */
|
||||
.hero-play-button {
|
||||
position: relative;
|
||||
padding: 10px 28px;
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
line-height: 1.4;
|
||||
border-radius: 16px;
|
||||
background: rgba(255, 255, 255, 0.85);
|
||||
color: rgba(0, 0, 0, 0.9);
|
||||
box-shadow:
|
||||
0 12px 32px rgba(0, 0, 0, 0.4),
|
||||
inset 0 1px 0 rgba(255, 255, 255, 1);
|
||||
backdrop-filter: blur(24px);
|
||||
-webkit-backdrop-filter: blur(24px);
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
transition: all 0.3s ease;
|
||||
white-space: nowrap;
|
||||
letter-spacing: 0.02em;
|
||||
}
|
||||
|
||||
.hero-play-button::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
border-radius: inherit;
|
||||
padding: 2px;
|
||||
background: linear-gradient(135deg, rgba(100, 100, 100, 0.4), rgba(50, 50, 50, 0.2));
|
||||
-webkit-mask:
|
||||
linear-gradient(#fff 0 0) content-box,
|
||||
linear-gradient(#fff 0 0);
|
||||
-webkit-mask-composite: xor;
|
||||
mask-composite: exclude;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.hero-play-button:hover {
|
||||
transform: translateY(-2px);
|
||||
background: rgba(255, 255, 255, 0.95);
|
||||
box-shadow:
|
||||
0 16px 40px rgba(0, 0, 0, 0.5),
|
||||
inset 0 1px 0 rgba(255, 255, 255, 1);
|
||||
}
|
||||
|
||||
/* Profile Dropdown Styles */
|
||||
.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>
|
||||
306
src/components/AuthModal.vue
Normal file
306
src/components/AuthModal.vue
Normal file
@@ -0,0 +1,306 @@
|
||||
<template>
|
||||
<Transition name="modal-fade">
|
||||
<div v-if="isOpen" class="auth-modal-overlay" @click.self="closeModal">
|
||||
<div class="auth-modal-container">
|
||||
<div class="auth-modal-content">
|
||||
<!-- Close Button -->
|
||||
<button @click="closeModal" class="absolute top-4 right-4 text-white/60 hover:text-white transition-colors">
|
||||
<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">
|
||||
{{ mode === 'login' ? 'Welcome Back' : 'Join IndeedHub' }}
|
||||
</h2>
|
||||
<p class="text-white/60">
|
||||
{{ mode === 'login' ? 'Sign in to continue' : 'Create an account to get started' }}
|
||||
</p>
|
||||
</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>
|
||||
|
||||
<!-- Cognito Auth Form -->
|
||||
<form @submit.prevent="handleSubmit" class="space-y-4">
|
||||
<!-- Legal Name (Register only) -->
|
||||
<div v-if="mode === 'register'" class="form-group">
|
||||
<label class="block text-white/80 text-sm font-medium mb-2">Full Name</label>
|
||||
<input
|
||||
v-model="formData.legalName"
|
||||
type="text"
|
||||
required
|
||||
class="auth-input"
|
||||
placeholder="John Doe"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- Email -->
|
||||
<div class="form-group">
|
||||
<label class="block text-white/80 text-sm font-medium mb-2">Email</label>
|
||||
<input
|
||||
v-model="formData.email"
|
||||
type="email"
|
||||
required
|
||||
class="auth-input"
|
||||
placeholder="you@example.com"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- Password -->
|
||||
<div class="form-group">
|
||||
<label class="block text-white/80 text-sm font-medium mb-2">Password</label>
|
||||
<input
|
||||
v-model="formData.password"
|
||||
type="password"
|
||||
required
|
||||
class="auth-input"
|
||||
:placeholder="mode === 'login' ? 'Enter your password' : 'Create a password'"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- Forgot Password Link (Login only) -->
|
||||
<div v-if="mode === 'login'" class="text-right">
|
||||
<a href="#" @click.prevent="mode = 'forgot'" class="text-sm text-white/60 hover:text-white transition-colors">
|
||||
Forgot password?
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<!-- Submit Button -->
|
||||
<button
|
||||
type="submit"
|
||||
:disabled="isLoading"
|
||||
class="hero-play-button w-full flex items-center justify-center"
|
||||
>
|
||||
<span v-if="!isLoading">{{ mode === 'login' ? 'Sign In' : 'Create Account' }}</span>
|
||||
<span v-else>Loading...</span>
|
||||
</button>
|
||||
</form>
|
||||
|
||||
<!-- Divider -->
|
||||
<div class="relative my-6">
|
||||
<div class="absolute inset-0 flex items-center">
|
||||
<div class="w-full border-t border-white/10"></div>
|
||||
</div>
|
||||
<div class="relative flex justify-center text-sm">
|
||||
<span class="px-4 bg-transparent text-white/40">or</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Nostr Login Button -->
|
||||
<button
|
||||
@click="handleNostrLogin"
|
||||
:disabled="isLoading"
|
||||
class="hero-info-button w-full flex items-center justify-center gap-2"
|
||||
>
|
||||
<svg class="w-5 h-5" fill="currentColor" viewBox="0 0 24 24">
|
||||
<path d="M12 2L2 7v10c0 5.55 3.84 10.74 9 12 5.16-1.26 9-6.45 9-12V7l-10-5z"/>
|
||||
</svg>
|
||||
Sign in with Nostr
|
||||
</button>
|
||||
|
||||
<!-- Toggle Mode -->
|
||||
<div class="mt-6 text-center text-sm text-white/60">
|
||||
{{ mode === 'login' ? "Don't have an account?" : "Already have an account?" }}
|
||||
<button
|
||||
@click="toggleMode"
|
||||
class="ml-1 text-white hover:text-white/80 font-medium transition-colors"
|
||||
>
|
||||
{{ mode === 'login' ? 'Sign up' : 'Sign in' }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Transition>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, computed } from 'vue'
|
||||
import { useAuth } from '../composables/useAuth'
|
||||
|
||||
interface Props {
|
||||
isOpen: boolean
|
||||
defaultMode?: 'login' | 'register'
|
||||
}
|
||||
|
||||
interface Emits {
|
||||
(e: 'close'): void
|
||||
(e: 'success'): void
|
||||
}
|
||||
|
||||
const props = withDefaults(defineProps<Props>(), {
|
||||
defaultMode: 'login',
|
||||
})
|
||||
|
||||
const emit = defineEmits<Emits>()
|
||||
|
||||
const { login, loginWithNostr, register, isLoading: authLoading } = useAuth()
|
||||
|
||||
const mode = ref<'login' | 'register' | 'forgot'>(props.defaultMode)
|
||||
const formData = ref({
|
||||
email: '',
|
||||
password: '',
|
||||
legalName: '',
|
||||
})
|
||||
const errorMessage = ref<string | null>(null)
|
||||
const isLoading = computed(() => authLoading.value)
|
||||
|
||||
function closeModal() {
|
||||
emit('close')
|
||||
// Reset form
|
||||
formData.value = { email: '', password: '', legalName: '' }
|
||||
errorMessage.value = null
|
||||
}
|
||||
|
||||
function toggleMode() {
|
||||
mode.value = mode.value === 'login' ? 'register' : 'login'
|
||||
errorMessage.value = null
|
||||
}
|
||||
|
||||
async function handleSubmit() {
|
||||
errorMessage.value = null
|
||||
|
||||
try {
|
||||
if (mode.value === 'login') {
|
||||
await login(formData.value.email, formData.value.password)
|
||||
} else if (mode.value === 'register') {
|
||||
await register(formData.value.email, formData.value.password, formData.value.legalName)
|
||||
}
|
||||
|
||||
emit('success')
|
||||
closeModal()
|
||||
} catch (error: any) {
|
||||
errorMessage.value = error.message || 'Authentication failed. Please try again.'
|
||||
}
|
||||
}
|
||||
|
||||
async function handleNostrLogin() {
|
||||
errorMessage.value = null
|
||||
|
||||
try {
|
||||
// Check for Nostr extension (NIP-07)
|
||||
if (!window.nostr) {
|
||||
errorMessage.value = 'Nostr extension not found. Please install a Nostr browser extension like Alby or nos2x.'
|
||||
return
|
||||
}
|
||||
|
||||
// Get public key from extension
|
||||
const pubkey = await window.nostr.getPublicKey()
|
||||
|
||||
// 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: '',
|
||||
}
|
||||
|
||||
// Sign event with extension
|
||||
const signedEvent = await window.nostr.signEvent(authEvent)
|
||||
|
||||
// Create session with backend
|
||||
await loginWithNostr(pubkey, signedEvent.sig, signedEvent)
|
||||
|
||||
emit('success')
|
||||
closeModal()
|
||||
} catch (error: any) {
|
||||
console.error('Nostr login failed:', error)
|
||||
errorMessage.value = error.message || 'Nostr authentication failed. Please try again.'
|
||||
}
|
||||
}
|
||||
|
||||
// Declare window.nostr for TypeScript
|
||||
declare global {
|
||||
interface Window {
|
||||
nostr?: {
|
||||
getPublicKey: () => Promise<string>
|
||||
signEvent: (event: any) => Promise<any>
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.auth-modal-overlay {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
z-index: 9998;
|
||||
background: rgba(0, 0, 0, 0.85);
|
||||
backdrop-filter: blur(8px);
|
||||
-webkit-backdrop-filter: blur(8px);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 16px;
|
||||
}
|
||||
|
||||
.auth-modal-container {
|
||||
width: 100%;
|
||||
max-width: 480px;
|
||||
max-height: 90vh;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.auth-modal-content {
|
||||
position: relative;
|
||||
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.08);
|
||||
box-shadow:
|
||||
0 20px 60px rgba(0, 0, 0, 0.5),
|
||||
inset 0 1px 0 rgba(255, 255, 255, 0.1);
|
||||
padding: 32px;
|
||||
}
|
||||
|
||||
.auth-input {
|
||||
width: 100%;
|
||||
padding: 12px 16px;
|
||||
background: rgba(255, 255, 255, 0.05);
|
||||
border: 1px solid rgba(255, 255, 255, 0.1);
|
||||
border-radius: 12px;
|
||||
color: white;
|
||||
font-size: 14px;
|
||||
transition: all 0.3s ease;
|
||||
}
|
||||
|
||||
.auth-input:focus {
|
||||
outline: none;
|
||||
background: rgba(255, 255, 255, 0.08);
|
||||
border-color: rgba(255, 255, 255, 0.2);
|
||||
box-shadow: 0 0 0 3px rgba(255, 255, 255, 0.05);
|
||||
}
|
||||
|
||||
.auth-input::placeholder {
|
||||
color: rgba(255, 255, 255, 0.3);
|
||||
}
|
||||
|
||||
/* Modal Transitions */
|
||||
.modal-fade-enter-active,
|
||||
.modal-fade-leave-active {
|
||||
transition: opacity 0.3s ease;
|
||||
}
|
||||
|
||||
.modal-fade-enter-from,
|
||||
.modal-fade-leave-to {
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
.modal-fade-enter-active .auth-modal-content {
|
||||
transition: transform 0.3s ease, opacity 0.3s ease;
|
||||
}
|
||||
|
||||
.modal-fade-enter-from .auth-modal-content {
|
||||
transform: scale(0.95);
|
||||
opacity: 0;
|
||||
}
|
||||
</style>
|
||||
614
src/components/ContentDetailModal.vue
Normal file
614
src/components/ContentDetailModal.vue
Normal file
@@ -0,0 +1,614 @@
|
||||
<template>
|
||||
<Transition name="modal-fade">
|
||||
<div v-if="isOpen && content" class="detail-overlay" @click.self="$emit('close')">
|
||||
<div class="detail-container">
|
||||
<!-- Scrollable content area -->
|
||||
<div class="detail-scroll" ref="scrollContainer">
|
||||
<!-- Backdrop Hero -->
|
||||
<div class="detail-hero">
|
||||
<img
|
||||
:src="content.backdrop || content.thumbnail"
|
||||
:alt="content.title"
|
||||
class="w-full h-full object-cover object-center"
|
||||
/>
|
||||
<div class="hero-gradient-overlay"></div>
|
||||
|
||||
<!-- Close Button -->
|
||||
<button @click="$emit('close')" class="absolute top-4 right-4 z-10 p-2 bg-black/50 backdrop-blur-md rounded-full text-white/80 hover:text-white transition-colors">
|
||||
<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="M6 18L18 6M6 6l12 12" />
|
||||
</svg>
|
||||
</button>
|
||||
|
||||
<!-- Hero Content Overlay -->
|
||||
<div class="absolute bottom-0 left-0 right-0 p-6 md:p-8">
|
||||
<h1 class="text-3xl md:text-4xl lg:text-5xl font-bold text-white mb-3 drop-shadow-lg" style="font-family: 'Helvetica Neue', Helvetica, Arial, sans-serif;">
|
||||
{{ content.title }}
|
||||
</h1>
|
||||
|
||||
<!-- Meta Row -->
|
||||
<div class="flex flex-wrap items-center gap-2.5 text-sm text-white/80 mb-4">
|
||||
<span v-if="content.rating" class="bg-white/20 backdrop-blur-sm px-2.5 py-0.5 rounded text-white">{{ content.rating }}</span>
|
||||
<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>
|
||||
</div>
|
||||
|
||||
<!-- Action Buttons -->
|
||||
<div class="flex flex-wrap items-center gap-3">
|
||||
<!-- Play Button -->
|
||||
<button @click="handlePlay" class="play-btn flex items-center gap-2">
|
||||
<svg class="w-5 h-5" fill="currentColor" viewBox="0 0 24 24">
|
||||
<path d="M8 5v14l11-7z"/>
|
||||
</svg>
|
||||
Play
|
||||
</button>
|
||||
|
||||
<!-- Add to My List -->
|
||||
<button @click="toggleMyList" class="action-btn" :class="{ 'action-btn-active': isInMyList }">
|
||||
<svg v-if="!isInMyList" class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 4v16m8-8H4" />
|
||||
</svg>
|
||||
<svg v-else class="w-5 h-5" fill="currentColor" viewBox="0 0 24 24">
|
||||
<path d="M9 16.17L4.83 12l-1.42 1.41L9 19 21 7l-1.41-1.41L9 16.17z"/>
|
||||
</svg>
|
||||
<span class="hidden sm:inline">My List</span>
|
||||
</button>
|
||||
|
||||
<!-- Like Button -->
|
||||
<button @click="handleLike" class="action-btn" :class="{ 'action-btn-active': userReaction === '+' }">
|
||||
<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="M14 10h4.764a2 2 0 011.789 2.894l-3.5 7A2 2 0 0115.263 21h-4.017c-.163 0-.326-.02-.485-.06L7 20m7-10V5a2 2 0 00-2-2h-.095c-.5 0-.905.405-.905.905 0 .714-.211 1.412-.608 2.006L7 11v9m7-10h-2M7 20H5a2 2 0 01-2-2v-6a2 2 0 012-2h2.5" />
|
||||
</svg>
|
||||
<span v-if="reactionCounts.positive > 0" class="text-xs">{{ reactionCounts.positive }}</span>
|
||||
</button>
|
||||
|
||||
<!-- Dislike Button -->
|
||||
<button @click="handleDislike" class="action-btn" :class="{ 'action-btn-active': userReaction === '-' }">
|
||||
<svg class="w-5 h-5 rotate-180" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M14 10h4.764a2 2 0 011.789 2.894l-3.5 7A2 2 0 0115.263 21h-4.017c-.163 0-.326-.02-.485-.06L7 20m7-10V5a2 2 0 00-2-2h-.095c-.5 0-.905.405-.905.905 0 .714-.211 1.412-.608 2.006L7 11v9m7-10h-2M7 20H5a2 2 0 01-2-2v-6a2 2 0 012-2h2.5" />
|
||||
</svg>
|
||||
<span v-if="reactionCounts.negative > 0" class="text-xs">{{ reactionCounts.negative }}</span>
|
||||
</button>
|
||||
|
||||
<!-- Share Button -->
|
||||
<button @click="handleShare" class="action-btn">
|
||||
<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.684 13.342C8.886 12.938 9 12.482 9 12c0-.482-.114-.938-.316-1.342m0 2.684a3 3 0 110-2.684m0 2.684l6.632 3.316m-6.632-6l6.632-3.316m0 0a3 3 0 105.367-2.684 3 3 0 00-5.367 2.684zm0 9.316a3 3 0 105.368 2.684 3 3 0 00-5.368-2.684z" />
|
||||
</svg>
|
||||
<span class="hidden sm:inline">Share</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Content Body -->
|
||||
<div class="detail-body">
|
||||
<!-- Description -->
|
||||
<div class="mb-6">
|
||||
<p class="text-white/80 text-base leading-relaxed">{{ content.description }}</p>
|
||||
</div>
|
||||
|
||||
<!-- Categories -->
|
||||
<div v-if="content.categories && content.categories.length > 0" class="flex flex-wrap gap-2 mb-6">
|
||||
<span
|
||||
v-for="category in content.categories"
|
||||
:key="category"
|
||||
class="category-tag"
|
||||
>
|
||||
{{ category }}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<!-- Creator Attribution -->
|
||||
<div v-if="content.creator" class="flex items-center gap-3 mb-8 text-white/60 text-sm">
|
||||
<span>Directed by</span>
|
||||
<span class="text-white font-medium">{{ content.creator }}</span>
|
||||
</div>
|
||||
|
||||
<!-- Divider -->
|
||||
<div class="border-t border-white/10 mb-6"></div>
|
||||
|
||||
<!-- Comments Section -->
|
||||
<div class="comments-section">
|
||||
<h3 class="text-lg font-semibold text-white mb-4 flex items-center gap-2">
|
||||
Comments
|
||||
<span class="text-sm font-normal text-white/50">({{ comments.length }})</span>
|
||||
<span v-if="isDev" class="text-xs bg-white/10 text-white/40 px-2 py-0.5 rounded-full ml-auto">Demo Mode</span>
|
||||
</h3>
|
||||
|
||||
<!-- Comment Input -->
|
||||
<div v-if="isAuthenticated" class="comment-input-wrap mb-6">
|
||||
<div class="flex gap-3">
|
||||
<div class="profile-avatar flex-shrink-0">
|
||||
<span>{{ userInitials }}</span>
|
||||
</div>
|
||||
<div class="flex-1">
|
||||
<textarea
|
||||
v-model="newComment"
|
||||
placeholder="Share your thoughts..."
|
||||
class="comment-textarea"
|
||||
rows="2"
|
||||
@keydown.meta.enter="submitComment"
|
||||
@keydown.ctrl.enter="submitComment"
|
||||
></textarea>
|
||||
<div class="flex justify-end mt-2">
|
||||
<button
|
||||
@click="submitComment"
|
||||
:disabled="!newComment.trim()"
|
||||
class="submit-comment-btn"
|
||||
:class="{ 'opacity-40 cursor-not-allowed': !newComment.trim() }"
|
||||
>
|
||||
Post
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Sign in prompt for comments -->
|
||||
<div v-else class="text-center py-4 mb-6 bg-white/5 rounded-xl">
|
||||
<p class="text-white/50 text-sm">
|
||||
<button @click="$emit('openAuth')" class="text-white underline hover:text-white/80">Sign in</button>
|
||||
to leave a comment
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- Comments List -->
|
||||
<div v-if="isLoadingComments" class="text-center py-8">
|
||||
<div class="text-white/40 text-sm">Loading comments...</div>
|
||||
</div>
|
||||
|
||||
<div v-else-if="comments.length === 0" class="text-center py-8">
|
||||
<div class="text-white/40 text-sm">No comments yet. Be the first!</div>
|
||||
</div>
|
||||
|
||||
<div v-else class="space-y-4">
|
||||
<div
|
||||
v-for="comment in comments"
|
||||
:key="comment.id"
|
||||
class="comment-item"
|
||||
>
|
||||
<div class="flex gap-3">
|
||||
<!-- Author Avatar -->
|
||||
<img
|
||||
v-if="getProfile(comment.pubkey)?.picture"
|
||||
:src="getProfile(comment.pubkey).picture"
|
||||
:alt="getProfile(comment.pubkey)?.name || 'User'"
|
||||
class="w-8 h-8 rounded-full flex-shrink-0 object-cover"
|
||||
/>
|
||||
<div v-else class="w-8 h-8 rounded-full flex-shrink-0 bg-gradient-to-br from-orange-500 to-pink-500 flex items-center justify-center text-xs font-bold text-white">
|
||||
{{ (getProfile(comment.pubkey)?.name || 'A')[0].toUpperCase() }}
|
||||
</div>
|
||||
|
||||
<div class="flex-1 min-w-0">
|
||||
<div class="flex items-center gap-2 mb-1">
|
||||
<span class="text-white text-sm font-medium truncate">
|
||||
{{ getProfile(comment.pubkey)?.name || 'Anonymous' }}
|
||||
</span>
|
||||
<span class="text-white/30 text-xs flex-shrink-0">
|
||||
{{ formatTimeAgo(comment.created_at) }}
|
||||
</span>
|
||||
</div>
|
||||
<p class="text-white/70 text-sm leading-relaxed">{{ comment.content }}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Sub-modals triggered from within this modal -->
|
||||
<VideoPlayer
|
||||
:isOpen="showVideoPlayer"
|
||||
:content="content"
|
||||
@close="showVideoPlayer = false"
|
||||
/>
|
||||
|
||||
<SubscriptionModal
|
||||
:isOpen="showSubscriptionModal"
|
||||
@close="showSubscriptionModal = false"
|
||||
@success="handleSubscriptionSuccess"
|
||||
/>
|
||||
|
||||
<RentalModal
|
||||
:isOpen="showRentalModal"
|
||||
:content="content"
|
||||
@close="showRentalModal = false"
|
||||
@success="handleRentalSuccess"
|
||||
@openSubscription="openSubscriptionFromRental"
|
||||
/>
|
||||
</div>
|
||||
</Transition>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, watch, computed } from 'vue'
|
||||
import { useAuth } from '../composables/useAuth'
|
||||
import { useNostr } from '../composables/useNostr'
|
||||
import type { Content } from '../types/content'
|
||||
import VideoPlayer from './VideoPlayer.vue'
|
||||
import SubscriptionModal from './SubscriptionModal.vue'
|
||||
import RentalModal from './RentalModal.vue'
|
||||
|
||||
interface Props {
|
||||
isOpen: boolean
|
||||
content: Content | null
|
||||
}
|
||||
|
||||
interface Emits {
|
||||
(e: 'close'): void
|
||||
(e: 'openAuth'): void
|
||||
}
|
||||
|
||||
const props = defineProps<Props>()
|
||||
defineEmits<Emits>()
|
||||
|
||||
const { isAuthenticated, hasActiveSubscription, user } = useAuth()
|
||||
|
||||
const isDev = import.meta.env.DEV
|
||||
const newComment = ref('')
|
||||
const isInMyList = ref(false)
|
||||
const userReaction = ref<string | null>(null)
|
||||
const showVideoPlayer = ref(false)
|
||||
const showSubscriptionModal = ref(false)
|
||||
const showRentalModal = ref(false)
|
||||
|
||||
// Nostr social data -- initialized per content
|
||||
const nostr = useNostr()
|
||||
const comments = computed(() => nostr.comments.value)
|
||||
const reactionCounts = computed(() => nostr.reactionCounts.value)
|
||||
const isLoadingComments = computed(() => nostr.isLoading.value)
|
||||
|
||||
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]
|
||||
})
|
||||
|
||||
// Fetch social data when content changes
|
||||
watch(() => props.content?.id, async (newId) => {
|
||||
if (newId && props.isOpen) {
|
||||
await loadSocialData(newId)
|
||||
}
|
||||
})
|
||||
|
||||
watch(() => props.isOpen, async (open) => {
|
||||
if (open && props.content?.id) {
|
||||
await loadSocialData(props.content.id)
|
||||
}
|
||||
})
|
||||
|
||||
async function loadSocialData(contentId: string) {
|
||||
userReaction.value = null
|
||||
await Promise.all([
|
||||
nostr.fetchComments(contentId),
|
||||
nostr.fetchReactions(contentId),
|
||||
])
|
||||
nostr.subscribeToComments(contentId)
|
||||
nostr.subscribeToReactions(contentId)
|
||||
}
|
||||
|
||||
function getProfile(pubkey: string) {
|
||||
return nostr.profiles.value.get(pubkey)
|
||||
}
|
||||
|
||||
function handlePlay() {
|
||||
if (!isAuthenticated.value) {
|
||||
// Will be caught by parent via openAuth emit
|
||||
return
|
||||
}
|
||||
|
||||
if (hasActiveSubscription.value) {
|
||||
showVideoPlayer.value = true
|
||||
return
|
||||
}
|
||||
|
||||
// No subscription -- show rental modal
|
||||
showRentalModal.value = true
|
||||
}
|
||||
|
||||
function toggleMyList() {
|
||||
isInMyList.value = !isInMyList.value
|
||||
}
|
||||
|
||||
async function handleLike() {
|
||||
if (!isAuthenticated.value) return
|
||||
userReaction.value = userReaction.value === '+' ? null : '+'
|
||||
if (props.content?.id) {
|
||||
try {
|
||||
await nostr.postReaction(true, props.content.id)
|
||||
} catch (err) {
|
||||
console.error('Failed to post reaction:', err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function handleDislike() {
|
||||
if (!isAuthenticated.value) return
|
||||
userReaction.value = userReaction.value === '-' ? null : '-'
|
||||
if (props.content?.id) {
|
||||
try {
|
||||
await nostr.postReaction(false, props.content.id)
|
||||
} catch (err) {
|
||||
console.error('Failed to post reaction:', err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function handleShare() {
|
||||
const url = `${window.location.origin}/content/${props.content?.id}`
|
||||
if (navigator.share) {
|
||||
navigator.share({
|
||||
title: props.content?.title,
|
||||
text: props.content?.description,
|
||||
url,
|
||||
}).catch(() => {
|
||||
// User cancelled share
|
||||
})
|
||||
} else {
|
||||
navigator.clipboard.writeText(url)
|
||||
}
|
||||
}
|
||||
|
||||
async function submitComment() {
|
||||
if (!newComment.value.trim() || !props.content?.id) return
|
||||
|
||||
try {
|
||||
await nostr.postComment(newComment.value.trim(), props.content.id)
|
||||
newComment.value = ''
|
||||
} catch (err) {
|
||||
console.error('Failed to post comment:', err)
|
||||
}
|
||||
}
|
||||
|
||||
function handleSubscriptionSuccess() {
|
||||
showSubscriptionModal.value = false
|
||||
}
|
||||
|
||||
function handleRentalSuccess() {
|
||||
showRentalModal.value = false
|
||||
showVideoPlayer.value = true
|
||||
}
|
||||
|
||||
function openSubscriptionFromRental() {
|
||||
showRentalModal.value = false
|
||||
showSubscriptionModal.value = true
|
||||
}
|
||||
|
||||
function formatTimeAgo(timestamp: number): string {
|
||||
const now = Math.floor(Date.now() / 1000)
|
||||
const diff = now - timestamp
|
||||
|
||||
if (diff < 60) return 'just now'
|
||||
if (diff < 3600) return `${Math.floor(diff / 60)}m ago`
|
||||
if (diff < 86400) return `${Math.floor(diff / 3600)}h ago`
|
||||
if (diff < 604800) return `${Math.floor(diff / 86400)}d ago`
|
||||
return new Date(timestamp * 1000).toLocaleDateString()
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.detail-overlay {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
z-index: 1000;
|
||||
background: rgba(0, 0, 0, 0.85);
|
||||
backdrop-filter: blur(8px);
|
||||
-webkit-backdrop-filter: blur(8px);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
@media (min-width: 768px) {
|
||||
.detail-overlay {
|
||||
padding: 2rem;
|
||||
}
|
||||
}
|
||||
|
||||
.detail-container {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
max-width: 900px;
|
||||
max-height: 100vh;
|
||||
background: #141414;
|
||||
border-radius: 0;
|
||||
overflow: hidden;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
@media (min-width: 768px) {
|
||||
.detail-container {
|
||||
max-height: 90vh;
|
||||
border-radius: 16px;
|
||||
}
|
||||
}
|
||||
|
||||
.detail-scroll {
|
||||
height: 100%;
|
||||
overflow-y: auto;
|
||||
overscroll-behavior: contain;
|
||||
}
|
||||
|
||||
.detail-scroll::-webkit-scrollbar {
|
||||
width: 6px;
|
||||
}
|
||||
|
||||
.detail-scroll::-webkit-scrollbar-thumb {
|
||||
background: rgba(255, 255, 255, 0.15);
|
||||
border-radius: 3px;
|
||||
}
|
||||
|
||||
/* Hero */
|
||||
.detail-hero {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
height: 300px;
|
||||
}
|
||||
|
||||
@media (min-width: 768px) {
|
||||
.detail-hero {
|
||||
height: 400px;
|
||||
}
|
||||
}
|
||||
|
||||
.hero-gradient-overlay {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
background: linear-gradient(
|
||||
to top,
|
||||
#141414 0%,
|
||||
rgba(20, 20, 20, 0.85) 30%,
|
||||
rgba(20, 20, 20, 0.3) 60%,
|
||||
rgba(20, 20, 20, 0) 100%
|
||||
);
|
||||
}
|
||||
|
||||
/* Buttons */
|
||||
.play-btn {
|
||||
position: relative;
|
||||
padding: 10px 28px;
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
line-height: 1.4;
|
||||
border-radius: 16px;
|
||||
background: rgba(255, 255, 255, 0.85);
|
||||
color: rgba(0, 0, 0, 0.9);
|
||||
box-shadow:
|
||||
0 12px 32px rgba(0, 0, 0, 0.4),
|
||||
inset 0 1px 0 rgba(255, 255, 255, 1);
|
||||
backdrop-filter: blur(24px);
|
||||
-webkit-backdrop-filter: blur(24px);
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
transition: all 0.3s ease;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.play-btn:hover {
|
||||
transform: translateY(-2px);
|
||||
background: rgba(255, 255, 255, 0.95);
|
||||
box-shadow:
|
||||
0 16px 40px rgba(0, 0, 0, 0.5),
|
||||
inset 0 1px 0 rgba(255, 255, 255, 1);
|
||||
}
|
||||
|
||||
.action-btn {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
padding: 8px 14px;
|
||||
font-size: 13px;
|
||||
font-weight: 500;
|
||||
border-radius: 12px;
|
||||
background: rgba(255, 255, 255, 0.08);
|
||||
color: rgba(255, 255, 255, 0.85);
|
||||
border: 1px solid rgba(255, 255, 255, 0.1);
|
||||
cursor: pointer;
|
||||
transition: all 0.2s ease;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.action-btn:hover {
|
||||
background: rgba(255, 255, 255, 0.15);
|
||||
border-color: rgba(255, 255, 255, 0.2);
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
|
||||
.action-btn-active {
|
||||
background: rgba(255, 255, 255, 0.15);
|
||||
border-color: rgba(255, 255, 255, 0.3);
|
||||
color: white;
|
||||
}
|
||||
|
||||
/* Category Tags */
|
||||
.category-tag {
|
||||
display: inline-block;
|
||||
padding: 4px 12px;
|
||||
font-size: 12px;
|
||||
font-weight: 500;
|
||||
border-radius: 20px;
|
||||
background: rgba(255, 255, 255, 0.06);
|
||||
color: rgba(255, 255, 255, 0.6);
|
||||
border: 1px solid rgba(255, 255, 255, 0.08);
|
||||
}
|
||||
|
||||
/* Content Body */
|
||||
.detail-body {
|
||||
padding: 0 1.5rem 2rem;
|
||||
}
|
||||
|
||||
@media (min-width: 768px) {
|
||||
.detail-body {
|
||||
padding: 0 2rem 2rem;
|
||||
}
|
||||
}
|
||||
|
||||
/* Comments */
|
||||
.comment-textarea {
|
||||
width: 100%;
|
||||
background: rgba(255, 255, 255, 0.05);
|
||||
border: 1px solid rgba(255, 255, 255, 0.1);
|
||||
border-radius: 12px;
|
||||
padding: 12px 14px;
|
||||
color: white;
|
||||
font-size: 14px;
|
||||
resize: none;
|
||||
outline: none;
|
||||
transition: border-color 0.2s ease;
|
||||
}
|
||||
|
||||
.comment-textarea::placeholder {
|
||||
color: rgba(255, 255, 255, 0.3);
|
||||
}
|
||||
|
||||
.comment-textarea:focus {
|
||||
border-color: rgba(255, 255, 255, 0.25);
|
||||
}
|
||||
|
||||
.submit-comment-btn {
|
||||
padding: 6px 20px;
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
border-radius: 10px;
|
||||
background: rgba(255, 255, 255, 0.85);
|
||||
color: rgba(0, 0, 0, 0.9);
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
|
||||
.submit-comment-btn:hover:not(:disabled) {
|
||||
background: rgba(255, 255, 255, 0.95);
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
|
||||
.comment-item {
|
||||
padding: 12px 0;
|
||||
border-bottom: 1px solid rgba(255, 255, 255, 0.05);
|
||||
}
|
||||
|
||||
.comment-item:last-child {
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
/* Transitions */
|
||||
.modal-fade-enter-active {
|
||||
transition: opacity 0.3s ease;
|
||||
}
|
||||
|
||||
.modal-fade-leave-active {
|
||||
transition: opacity 0.25s ease;
|
||||
}
|
||||
|
||||
.modal-fade-enter-from,
|
||||
.modal-fade-leave-to {
|
||||
opacity: 0;
|
||||
}
|
||||
</style>
|
||||
@@ -28,13 +28,24 @@
|
||||
class="content-card flex-shrink-0 w-[200px] md:w-[280px] group/card cursor-pointer"
|
||||
@click="$emit('content-click', content)"
|
||||
>
|
||||
<div class="glass-card rounded-lg p-1.5 transition-all duration-300">
|
||||
<div class="glass-card rounded-lg p-1.5 transition-all duration-300 relative">
|
||||
<img
|
||||
:src="content.thumbnail"
|
||||
:alt="content.title"
|
||||
class="w-full aspect-[2/3] object-contain rounded-md bg-neutral-900"
|
||||
loading="lazy"
|
||||
/>
|
||||
<!-- Social Indicators -->
|
||||
<div class="absolute bottom-3 left-3 flex items-center gap-2">
|
||||
<span class="social-badge" v-if="getReactionCount(content.id) > 0">
|
||||
<svg class="w-3 h-3" fill="currentColor" viewBox="0 0 24 24"><path d="M14 10h4.764a2 2 0 011.789 2.894l-3.5 7A2 2 0 0115.263 21h-4.017c-.163 0-.326-.02-.485-.06L7 20m7-10V5a2 2 0 00-2-2h-.095c-.5 0-.905.405-.905.905 0 .714-.211 1.412-.608 2.006L7 11v9m7-10h-2M7 20H5a2 2 0 01-2-2v-6a2 2 0 012-2h2.5"/></svg>
|
||||
{{ getReactionCount(content.id) }}
|
||||
</span>
|
||||
<span class="social-badge" v-if="getCommentCount(content.id) > 0">
|
||||
<svg class="w-3 h-3" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M8 12h.01M12 12h.01M16 12h.01M21 12c0 4.418-4.03 8-9 8a9.863 9.863 0 01-4.255-.949L3 20l1.395-3.72C3.512 15.042 3 13.574 3 12c0-4.418 4.03-8 9-8s9 3.582 9 8z"/></svg>
|
||||
{{ getCommentCount(content.id) }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="mt-2">
|
||||
<h3 class="text-base md:text-xl font-semibold md:font-bold text-white truncate">{{ content.title }}</h3>
|
||||
@@ -59,6 +70,7 @@
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted, onUnmounted } from 'vue'
|
||||
import { getMockReactionCounts, getMockCommentCount } from '../data/mockSocialData'
|
||||
import type { Content } from '../types/content'
|
||||
|
||||
interface Props {
|
||||
@@ -71,6 +83,14 @@ defineEmits<{
|
||||
'content-click': [content: Content]
|
||||
}>()
|
||||
|
||||
function getReactionCount(contentId: string): number {
|
||||
return getMockReactionCounts(contentId).positive
|
||||
}
|
||||
|
||||
function getCommentCount(contentId: string): number {
|
||||
return getMockCommentCount(contentId)
|
||||
}
|
||||
|
||||
const sliderRef = ref<HTMLElement | null>(null)
|
||||
const canScrollLeft = ref(false)
|
||||
const canScrollRight = ref(true)
|
||||
@@ -162,4 +182,18 @@ onUnmounted(() => {
|
||||
box-shadow: 0 8px 16px rgba(0, 0, 0, 0.2), 0 0 20px rgba(255, 255, 255, 0.05);
|
||||
transform: translateY(-4px);
|
||||
}
|
||||
|
||||
.social-badge {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 3px;
|
||||
padding: 2px 6px;
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
color: rgba(255, 255, 255, 0.85);
|
||||
background: rgba(0, 0, 0, 0.6);
|
||||
backdrop-filter: blur(12px);
|
||||
-webkit-backdrop-filter: blur(12px);
|
||||
border-radius: 6px;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -32,27 +32,13 @@ const navItems = [
|
||||
h('path', { d: 'M10 20v-6h4v6h5v-8h3L12 3 2 12h3v8z' })
|
||||
)
|
||||
},
|
||||
{
|
||||
name: 'Search',
|
||||
path: '/search',
|
||||
icon: () => h('svg', { class: 'w-6 h-6', fill: 'none', stroke: 'currentColor', viewBox: '0 0 24 24' },
|
||||
h('path', { 'stroke-linecap': 'round', 'stroke-linejoin': 'round', 'stroke-width': '2', d: 'M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z' })
|
||||
)
|
||||
},
|
||||
{
|
||||
name: 'My List',
|
||||
path: '/mylist',
|
||||
path: '/library',
|
||||
icon: () => h('svg', { class: 'w-6 h-6', fill: 'none', stroke: 'currentColor', viewBox: '0 0 24 24' },
|
||||
h('path', { 'stroke-linecap': 'round', 'stroke-linejoin': 'round', 'stroke-width': '2', d: 'M5 5a2 2 0 012-2h10a2 2 0 012 2v16l-7-3.5L5 21V5z' })
|
||||
)
|
||||
},
|
||||
{
|
||||
name: 'Creators',
|
||||
path: '/creators',
|
||||
icon: () => h('svg', { class: 'w-6 h-6', fill: 'none', stroke: 'currentColor', viewBox: '0 0 24 24' },
|
||||
h('path', { 'stroke-linecap': 'round', 'stroke-linejoin': 'round', 'stroke-width': '2', d: 'M12 4.354a4 4 0 110 5.292M15 21H3v-1a6 6 0 0112 0v1zm0 0h6v-1a6 6 0 00-9-5.197M13 7a4 4 0 11-8 0 4 4 0 018 0z' })
|
||||
)
|
||||
},
|
||||
{
|
||||
name: 'Profile',
|
||||
path: '/profile',
|
||||
|
||||
221
src/components/RentalModal.vue
Normal file
221
src/components/RentalModal.vue
Normal file
@@ -0,0 +1,221 @@
|
||||
<template>
|
||||
<Transition name="modal-fade">
|
||||
<div v-if="isOpen" class="modal-overlay" @click.self="closeModal">
|
||||
<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">
|
||||
<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>
|
||||
</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>
|
||||
|
||||
<!-- 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>
|
||||
<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>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Transition>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref } from 'vue'
|
||||
import { libraryService } from '../services/library.service'
|
||||
import type { Content } from '../types/content'
|
||||
|
||||
interface Props {
|
||||
isOpen: boolean
|
||||
content: Content | null
|
||||
}
|
||||
|
||||
interface Emits {
|
||||
(e: 'close'): void
|
||||
(e: 'success'): void
|
||||
(e: 'openSubscription'): void
|
||||
}
|
||||
|
||||
const props = defineProps<Props>()
|
||||
const emit = defineEmits<Emits>()
|
||||
|
||||
const isLoading = ref(false)
|
||||
const errorMessage = ref<string | null>(null)
|
||||
|
||||
function closeModal() {
|
||||
emit('close')
|
||||
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`
|
||||
}
|
||||
|
||||
async function handleRent() {
|
||||
if (!props.content) return
|
||||
|
||||
isLoading.value = true
|
||||
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()
|
||||
return
|
||||
}
|
||||
|
||||
// Real API call
|
||||
await libraryService.rentContent(props.content.id)
|
||||
emit('success')
|
||||
closeModal()
|
||||
} catch (error: any) {
|
||||
errorMessage.value = error.message || 'Rental failed. Please try again.'
|
||||
} finally {
|
||||
isLoading.value = false
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.modal-overlay {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
z-index: 9999;
|
||||
background: rgba(0, 0, 0, 0.85);
|
||||
backdrop-filter: blur(8px);
|
||||
-webkit-backdrop-filter: blur(8px);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 16px;
|
||||
}
|
||||
|
||||
.modal-container {
|
||||
width: 100%;
|
||||
max-width: 640px;
|
||||
max-height: 90vh;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.modal-content {
|
||||
position: relative;
|
||||
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.08);
|
||||
box-shadow:
|
||||
0 20px 60px rgba(0, 0, 0, 0.5),
|
||||
inset 0 1px 0 rgba(255, 255, 255, 0.1);
|
||||
padding: 32px;
|
||||
}
|
||||
|
||||
/* Modal Transitions */
|
||||
.modal-fade-enter-active,
|
||||
.modal-fade-leave-active {
|
||||
transition: opacity 0.3s ease;
|
||||
}
|
||||
|
||||
.modal-fade-enter-from,
|
||||
.modal-fade-leave-to {
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
.modal-fade-enter-active .modal-content {
|
||||
transition: transform 0.3s ease, opacity 0.3s ease;
|
||||
}
|
||||
|
||||
.modal-fade-enter-from .modal-content {
|
||||
transform: scale(0.95);
|
||||
opacity: 0;
|
||||
}
|
||||
</style>
|
||||
@@ -242,13 +242,18 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted } from 'vue'
|
||||
|
||||
const showSplash = ref(true)
|
||||
const SPLASH_KEY = 'indeedhub_splash_shown'
|
||||
const alreadyShown = sessionStorage.getItem(SPLASH_KEY) === 'true'
|
||||
const showSplash = ref(!alreadyShown)
|
||||
|
||||
onMounted(() => {
|
||||
// Hide splash after animation completes (5s total animation)
|
||||
setTimeout(() => {
|
||||
showSplash.value = false
|
||||
}, 5000)
|
||||
if (showSplash.value) {
|
||||
sessionStorage.setItem(SPLASH_KEY, 'true')
|
||||
// Hide splash after animation completes (5s total animation)
|
||||
setTimeout(() => {
|
||||
showSplash.value = false
|
||||
}, 5000)
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
|
||||
251
src/components/SubscriptionModal.vue
Normal file
251
src/components/SubscriptionModal.vue
Normal file
@@ -0,0 +1,251 @@
|
||||
<template>
|
||||
<Transition name="modal-fade">
|
||||
<div v-if="isOpen" class="modal-overlay" @click.self="closeModal">
|
||||
<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">
|
||||
<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>
|
||||
</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>
|
||||
</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>
|
||||
|
||||
<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>
|
||||
</Transition>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, onMounted } from 'vue'
|
||||
import { subscriptionService } from '../services/subscription.service'
|
||||
|
||||
interface Props {
|
||||
isOpen: boolean
|
||||
}
|
||||
|
||||
interface Emits {
|
||||
(e: 'close'): void
|
||||
(e: 'success'): void
|
||||
}
|
||||
|
||||
const props = defineProps<Props>()
|
||||
const emit = defineEmits<Emits>()
|
||||
|
||||
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)
|
||||
|
||||
const selectedTierName = computed(() => {
|
||||
const tier = tiers.value.find((t) => t.tier === selectedTier.value)
|
||||
return tier?.name || ''
|
||||
})
|
||||
|
||||
onMounted(async () => {
|
||||
tiers.value = await subscriptionService.getSubscriptionTiers()
|
||||
})
|
||||
|
||||
function closeModal() {
|
||||
emit('close')
|
||||
errorMessage.value = null
|
||||
}
|
||||
|
||||
async function handleSubscribe() {
|
||||
if (!selectedTier.value) return
|
||||
|
||||
isLoading.value = true
|
||||
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()
|
||||
return
|
||||
}
|
||||
|
||||
// Real API call
|
||||
await subscriptionService.subscribe({
|
||||
tier: selectedTier.value as any,
|
||||
period: period.value,
|
||||
})
|
||||
|
||||
emit('success')
|
||||
closeModal()
|
||||
} catch (error: any) {
|
||||
errorMessage.value = error.message || 'Subscription failed. Please try again.'
|
||||
} finally {
|
||||
isLoading.value = false
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.modal-overlay {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
z-index: 9999;
|
||||
background: rgba(0, 0, 0, 0.85);
|
||||
backdrop-filter: blur(8px);
|
||||
-webkit-backdrop-filter: blur(8px);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 16px;
|
||||
}
|
||||
|
||||
.modal-container {
|
||||
width: 100%;
|
||||
max-width: 1024px;
|
||||
max-height: 90vh;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.modal-content {
|
||||
position: relative;
|
||||
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.08);
|
||||
box-shadow:
|
||||
0 20px 60px rgba(0, 0, 0, 0.5),
|
||||
inset 0 1px 0 rgba(255, 255, 255, 0.1);
|
||||
padding: 32px;
|
||||
}
|
||||
|
||||
.tier-card {
|
||||
background: rgba(255, 255, 255, 0.05);
|
||||
border: 2px solid rgba(255, 255, 255, 0.1);
|
||||
border-radius: 16px;
|
||||
padding: 24px;
|
||||
cursor: pointer;
|
||||
transition: all 0.3s ease;
|
||||
}
|
||||
|
||||
.tier-card:hover {
|
||||
background: rgba(255, 255, 255, 0.08);
|
||||
border-color: rgba(255, 255, 255, 0.15);
|
||||
transform: translateY(-2px);
|
||||
}
|
||||
|
||||
.tier-card.selected {
|
||||
background: rgba(247, 147, 26, 0.1);
|
||||
border-color: #F7931A;
|
||||
box-shadow: 0 0 20px rgba(247, 147, 26, 0.3);
|
||||
}
|
||||
|
||||
/* Modal Transitions */
|
||||
.modal-fade-enter-active,
|
||||
.modal-fade-leave-active {
|
||||
transition: opacity 0.3s ease;
|
||||
}
|
||||
|
||||
.modal-fade-enter-from,
|
||||
.modal-fade-leave-to {
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
.modal-fade-enter-active .modal-content {
|
||||
transition: transform 0.3s ease, opacity 0.3s ease;
|
||||
}
|
||||
|
||||
.modal-fade-enter-from .modal-content {
|
||||
transform: scale(0.95);
|
||||
opacity: 0;
|
||||
}
|
||||
</style>
|
||||
159
src/components/ToastContainer.vue
Normal file
159
src/components/ToastContainer.vue
Normal file
@@ -0,0 +1,159 @@
|
||||
<template>
|
||||
<Teleport to="body">
|
||||
<div class="toast-container">
|
||||
<TransitionGroup name="toast">
|
||||
<div
|
||||
v-for="toast in toasts"
|
||||
:key="toast.id"
|
||||
:class="['toast', `toast-${toast.type}`]"
|
||||
>
|
||||
<div class="toast-icon">
|
||||
<!-- Success Icon -->
|
||||
<svg v-if="toast.type === 'success'" class="w-5 h-5" 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>
|
||||
|
||||
<!-- Error Icon -->
|
||||
<svg v-else-if="toast.type === 'error'" class="w-5 h-5" fill="currentColor" viewBox="0 0 20 20">
|
||||
<path fill-rule="evenodd" d="M10 18a8 8 0 100-16 8 8 0 000 16zM8.707 7.293a1 1 0 00-1.414 1.414L8.586 10l-1.293 1.293a1 1 0 101.414 1.414L10 11.414l1.293 1.293a1 1 0 001.414-1.414L11.414 10l1.293-1.293a1 1 0 00-1.414-1.414L10 8.586 8.707 7.293z" clip-rule="evenodd" />
|
||||
</svg>
|
||||
|
||||
<!-- Warning Icon -->
|
||||
<svg v-else-if="toast.type === 'warning'" class="w-5 h-5" fill="currentColor" viewBox="0 0 20 20">
|
||||
<path fill-rule="evenodd" d="M8.257 3.099c.765-1.36 2.722-1.36 3.486 0l5.58 9.92c.75 1.334-.213 2.98-1.742 2.98H4.42c-1.53 0-2.493-1.646-1.743-2.98l5.58-9.92zM11 13a1 1 0 11-2 0 1 1 0 012 0zm-1-8a1 1 0 00-1 1v3a1 1 0 002 0V6a1 1 0 00-1-1z" clip-rule="evenodd" />
|
||||
</svg>
|
||||
|
||||
<!-- Info Icon -->
|
||||
<svg v-else class="w-5 h-5" fill="currentColor" viewBox="0 0 20 20">
|
||||
<path fill-rule="evenodd" d="M18 10a8 8 0 11-16 0 8 8 0 0116 0zm-7-4a1 1 0 11-2 0 1 1 0 012 0zM9 9a1 1 0 000 2v3a1 1 0 001 1h1a1 1 0 100-2v-3a1 1 0 00-1-1H9z" clip-rule="evenodd" />
|
||||
</svg>
|
||||
</div>
|
||||
|
||||
<div class="toast-message">{{ toast.message }}</div>
|
||||
|
||||
<button @click="removeToast(toast.id)" class="toast-close">
|
||||
<svg class="w-4 h-4" fill="currentColor" viewBox="0 0 20 20">
|
||||
<path fill-rule="evenodd" d="M4.293 4.293a1 1 0 011.414 0L10 8.586l4.293-4.293a1 1 0 111.414 1.414L11.414 10l4.293 4.293a1 1 0 01-1.414 1.414L10 11.414l-4.293 4.293a1 1 0 01-1.414-1.414L8.586 10 4.293 5.707a1 1 0 010-1.414z" clip-rule="evenodd" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
</TransitionGroup>
|
||||
</div>
|
||||
</Teleport>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { useToast } from '../composables/useToast'
|
||||
|
||||
const { toasts, removeToast } = useToast()
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.toast-container {
|
||||
position: fixed;
|
||||
top: 80px;
|
||||
right: 16px;
|
||||
z-index: 10000;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
max-width: 400px;
|
||||
}
|
||||
|
||||
.toast {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
padding: 16px;
|
||||
background: rgba(0, 0, 0, 0.75);
|
||||
backdrop-filter: blur(24px);
|
||||
-webkit-backdrop-filter: blur(24px);
|
||||
border-radius: 12px;
|
||||
border: 1px solid rgba(255, 255, 255, 0.1);
|
||||
box-shadow:
|
||||
0 8px 24px rgba(0, 0, 0, 0.4),
|
||||
inset 0 1px 0 rgba(255, 255, 255, 0.1);
|
||||
min-width: 320px;
|
||||
}
|
||||
|
||||
.toast-icon {
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.toast-success {
|
||||
border-color: rgba(34, 197, 94, 0.3);
|
||||
}
|
||||
|
||||
.toast-success .toast-icon {
|
||||
color: #22c55e;
|
||||
}
|
||||
|
||||
.toast-error {
|
||||
border-color: rgba(239, 68, 68, 0.3);
|
||||
}
|
||||
|
||||
.toast-error .toast-icon {
|
||||
color: #ef4444;
|
||||
}
|
||||
|
||||
.toast-warning {
|
||||
border-color: rgba(251, 146, 60, 0.3);
|
||||
}
|
||||
|
||||
.toast-warning .toast-icon {
|
||||
color: #fb923c;
|
||||
}
|
||||
|
||||
.toast-info {
|
||||
border-color: rgba(59, 130, 246, 0.3);
|
||||
}
|
||||
|
||||
.toast-info .toast-icon {
|
||||
color: #3b82f6;
|
||||
}
|
||||
|
||||
.toast-message {
|
||||
flex: 1;
|
||||
color: white;
|
||||
font-size: 14px;
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
.toast-close {
|
||||
flex-shrink: 0;
|
||||
color: rgba(255, 255, 255, 0.6);
|
||||
transition: color 0.2s;
|
||||
}
|
||||
|
||||
.toast-close:hover {
|
||||
color: rgba(255, 255, 255, 1);
|
||||
}
|
||||
|
||||
/* Toast Transitions */
|
||||
.toast-enter-active,
|
||||
.toast-leave-active {
|
||||
transition: all 0.3s ease;
|
||||
}
|
||||
|
||||
.toast-enter-from {
|
||||
transform: translateX(100%);
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
.toast-leave-to {
|
||||
transform: translateX(100%);
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
@media (max-width: 640px) {
|
||||
.toast-container {
|
||||
left: 16px;
|
||||
right: 16px;
|
||||
max-width: none;
|
||||
}
|
||||
|
||||
.toast {
|
||||
min-width: 0;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -1,98 +1,109 @@
|
||||
<template>
|
||||
<div class="video-player-container" :class="{ 'fullscreen': isFullscreen }">
|
||||
<div class="relative w-full h-full bg-black rounded-lg overflow-hidden group">
|
||||
<!-- Video Element -->
|
||||
<video
|
||||
ref="videoRef"
|
||||
class="w-full h-full"
|
||||
:src="src"
|
||||
@click="togglePlay"
|
||||
@timeupdate="handleTimeUpdate"
|
||||
@loadedmetadata="handleMetadata"
|
||||
@ended="handleEnded"
|
||||
/>
|
||||
<Transition name="player-fade">
|
||||
<div v-if="isOpen" class="video-player-overlay">
|
||||
<div class="video-player-container">
|
||||
<!-- Close Button -->
|
||||
<button @click="closePlayer" class="close-button">
|
||||
<svg class="w-8 h-8" 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>
|
||||
|
||||
<!-- Controls Overlay -->
|
||||
<div
|
||||
class="absolute inset-0 bg-gradient-to-t from-black/80 via-transparent to-black/40 opacity-0 group-hover:opacity-100 transition-opacity duration-300"
|
||||
:class="{ 'opacity-100': showControls || !playing }"
|
||||
>
|
||||
<!-- Top Bar -->
|
||||
<div class="absolute top-0 left-0 right-0 p-4 flex items-center justify-between">
|
||||
<button
|
||||
v-if="showBackButton"
|
||||
@click="$emit('close')"
|
||||
class="p-2 hover:bg-white/20 rounded-full transition-colors"
|
||||
>
|
||||
<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="M10 19l-7-7m0 0l7-7m-7 7h18" />
|
||||
</svg>
|
||||
</button>
|
||||
<!-- Video Area (Dummy Player) -->
|
||||
<div class="video-area">
|
||||
<img
|
||||
v-if="content?.backdrop || content?.thumbnail"
|
||||
:src="content?.backdrop || content?.thumbnail"
|
||||
:alt="content?.title"
|
||||
class="w-full h-full object-cover"
|
||||
/>
|
||||
|
||||
<h3 class="text-lg font-semibold">{{ title }}</h3>
|
||||
|
||||
<div class="w-10"></div>
|
||||
<!-- Play Overlay -->
|
||||
<div class="video-overlay">
|
||||
<button class="play-button" @click="togglePlay">
|
||||
<svg v-if="!isPlaying" class="w-20 h-20" fill="currentColor" viewBox="0 0 24 24">
|
||||
<path d="M8 5v14l11-7z" />
|
||||
</svg>
|
||||
<svg v-else class="w-20 h-20" fill="currentColor" viewBox="0 0 24 24">
|
||||
<path d="M6 4h4v16H6V4zm8 0h4v16h-4V4z" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Dummy Notice -->
|
||||
<div class="demo-notice">
|
||||
<div class="demo-badge">
|
||||
<svg class="w-5 h-5" fill="currentColor" viewBox="0 0 20 20">
|
||||
<path fill-rule="evenodd" d="M18 10a8 8 0 11-16 0 8 8 0 0116 0zm-7-4a1 1 0 11-2 0 1 1 0 012 0zM9 9a1 1 0 000 2v3a1 1 0 001 1h1a1 1 0 100-2v-3a1 1 0 00-1-1H9z" clip-rule="evenodd" />
|
||||
</svg>
|
||||
Demo Mode
|
||||
</div>
|
||||
<p class="text-sm">Video player preview - Full streaming coming soon</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Center Play Button -->
|
||||
<div
|
||||
v-if="!playing"
|
||||
class="absolute inset-0 flex items-center justify-center"
|
||||
@click="togglePlay"
|
||||
>
|
||||
<button class="p-6 bg-white/20 hover:bg-white/30 backdrop-blur-md rounded-full transition-all transform hover:scale-110">
|
||||
<svg class="w-16 h-16" fill="currentColor" viewBox="0 0 24 24">
|
||||
<path d="M8 5v14l11-7z"/>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Bottom Controls -->
|
||||
<div class="absolute bottom-0 left-0 right-0 p-4 space-y-2">
|
||||
<!-- Video Controls -->
|
||||
<div class="video-controls">
|
||||
<!-- Progress Bar -->
|
||||
<div
|
||||
class="w-full h-1 bg-white/30 rounded-full cursor-pointer hover:h-2 transition-all"
|
||||
@click="seek"
|
||||
ref="progressRef"
|
||||
>
|
||||
<div
|
||||
class="h-full bg-netflix-red rounded-full transition-all"
|
||||
:style="{ width: `${progress}%` }"
|
||||
></div>
|
||||
<div class="progress-container">
|
||||
<div class="progress-bar">
|
||||
<div class="progress-filled" :style="{ width: `${progress}%` }"></div>
|
||||
<div class="progress-handle" :style="{ left: `${progress}%` }"></div>
|
||||
</div>
|
||||
<div class="time-display">
|
||||
<span>{{ formatTime(currentTime) }}</span>
|
||||
<span class="text-white/60">/</span>
|
||||
<span class="text-white/60">{{ formatTime(duration) }}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Control Buttons -->
|
||||
<div class="flex items-center justify-between">
|
||||
<div class="control-buttons">
|
||||
<!-- Left Side -->
|
||||
<div class="flex items-center gap-4">
|
||||
<!-- Play/Pause -->
|
||||
<button @click="togglePlay" class="p-2 hover:bg-white/20 rounded-full transition-colors">
|
||||
<svg v-if="playing" class="w-6 h-6" fill="currentColor" viewBox="0 0 24 24">
|
||||
<path d="M6 4h4v16H6V4zm8 0h4v16h-4V4z"/>
|
||||
<button @click="togglePlay" class="control-btn">
|
||||
<svg v-if="!isPlaying" class="w-6 h-6" fill="currentColor" viewBox="0 0 24 24">
|
||||
<path d="M8 5v14l11-7z" />
|
||||
</svg>
|
||||
<svg v-else class="w-6 h-6" fill="currentColor" viewBox="0 0 24 24">
|
||||
<path d="M8 5v14l11-7z"/>
|
||||
<path d="M6 4h4v16H6V4zm8 0h4v16h-4V4z" />
|
||||
</svg>
|
||||
</button>
|
||||
|
||||
<!-- Volume -->
|
||||
<button @click="toggleMute" class="p-2 hover:bg-white/20 rounded-full transition-colors">
|
||||
<svg v-if="!muted" class="w-6 h-6" fill="currentColor" viewBox="0 0 24 24">
|
||||
<path d="M3 9v6h4l5 5V4L7 9H3zm13.5 3c0-1.77-1.02-3.29-2.5-4.03v8.05c1.48-.73 2.5-2.25 2.5-4.02z"/>
|
||||
</svg>
|
||||
<svg v-else class="w-6 h-6" fill="currentColor" viewBox="0 0 24 24">
|
||||
<path d="M16.5 12c0-1.77-1.02-3.29-2.5-4.03v2.21l2.45 2.45c.03-.2.05-.41.05-.63zm2.5 0c0 .94-.2 1.82-.54 2.64l1.51 1.51C20.63 14.91 21 13.5 21 12c0-4.28-2.99-7.86-7-8.77v2.06c2.89.86 5 3.54 5 6.71zM4.27 3L3 4.27 7.73 9H3v6h4l5 5v-6.73l4.25 4.25c-.67.52-1.42.93-2.25 1.18v2.06c1.38-.31 2.63-.95 3.69-1.81L19.73 21 21 19.73l-9-9L4.27 3zM12 4L9.91 6.09 12 8.18V4z"/>
|
||||
<button class="control-btn">
|
||||
<svg class="w-6 h-6" fill="currentColor" viewBox="0 0 24 24">
|
||||
<path d="M5 4v16l7-8z M12 4v16l7-8z" />
|
||||
</svg>
|
||||
</button>
|
||||
|
||||
<!-- Time Display -->
|
||||
<span class="text-sm text-white/80">
|
||||
{{ formatTime(currentTime) }} / {{ formatTime(duration) }}
|
||||
</span>
|
||||
<button class="control-btn">
|
||||
<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="M15.536 8.464a5 5 0 010 7.072m2.828-9.9a9 9 0 010 12.728M5.586 15H4a1 1 0 01-1-1v-4a1 1 0 011-1h1.586l4.707-4.707C10.923 3.663 12 4.109 12 5v14c0 .891-1.077 1.337-1.707.707L5.586 15z" />
|
||||
</svg>
|
||||
</button>
|
||||
|
||||
<div class="text-white font-medium text-sm">{{ content?.title }}</div>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center gap-2">
|
||||
<!-- Fullscreen -->
|
||||
<button @click="toggleFullscreen" class="p-2 hover:bg-white/20 rounded-full transition-colors">
|
||||
<!-- Right Side -->
|
||||
<div class="flex items-center gap-4">
|
||||
<button class="control-btn">
|
||||
<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="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.065 2.572c1.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.572 1.065c-.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.065-2.572c-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>
|
||||
</button>
|
||||
|
||||
<div class="quality-selector">
|
||||
<button class="control-btn">
|
||||
{{ quality }}
|
||||
<svg class="w-4 h-4 ml-1" 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>
|
||||
|
||||
<button class="control-btn" @click="toggleFullscreen">
|
||||
<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="M4 8V4m0 0h4M4 4l5 5m11-1V4m0 0h-4m4 0l-5 5M4 16v4m0 0h4m-4 0l5-5m11 5l-5-5m5 5v-4m0 4h-4" />
|
||||
</svg>
|
||||
@@ -100,134 +111,371 @@
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Content Info Panel -->
|
||||
<div class="content-info-panel">
|
||||
<h2 class="text-2xl font-bold text-white mb-2">{{ content?.title }}</h2>
|
||||
<div class="flex items-center gap-4 text-sm text-white/80 mb-4">
|
||||
<span v-if="content?.releaseYear" class="bg-white/10 px-2 py-0.5 rounded">{{ content.releaseYear }}</span>
|
||||
<span v-if="content?.rating">{{ content.rating }}</span>
|
||||
<span v-if="content?.duration">{{ content.duration }}min</span>
|
||||
<span class="text-green-400 flex items-center gap-1">
|
||||
<svg class="w-4 h-4" 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>
|
||||
Cinephile Access
|
||||
</span>
|
||||
</div>
|
||||
<p class="text-white/70 text-sm line-clamp-3">{{ content?.description }}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Transition>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted, onUnmounted } from 'vue'
|
||||
import { ref, watch } from 'vue'
|
||||
import type { Content } from '../types/content'
|
||||
|
||||
interface Props {
|
||||
src: string
|
||||
title?: string
|
||||
showBackButton?: boolean
|
||||
isOpen: boolean
|
||||
content: Content | null
|
||||
}
|
||||
|
||||
interface Emits {
|
||||
(e: 'close'): void
|
||||
}
|
||||
|
||||
const props = defineProps<Props>()
|
||||
const emit = defineEmits<Emits>()
|
||||
|
||||
defineEmits<{
|
||||
'close': []
|
||||
}>()
|
||||
|
||||
const videoRef = ref<HTMLVideoElement | null>(null)
|
||||
const progressRef = ref<HTMLElement | null>(null)
|
||||
const playing = ref(false)
|
||||
const muted = ref(false)
|
||||
const currentTime = ref(0)
|
||||
const duration = ref(0)
|
||||
const isPlaying = ref(false)
|
||||
const progress = ref(0)
|
||||
const showControls = ref(false)
|
||||
const isFullscreen = ref(false)
|
||||
let hideControlsTimeout: NodeJS.Timeout | null = null
|
||||
const currentTime = ref(0)
|
||||
const duration = ref(7200) // 2 hours in seconds
|
||||
const quality = ref('4K')
|
||||
|
||||
const togglePlay = () => {
|
||||
if (!videoRef.value) return
|
||||
|
||||
if (playing.value) {
|
||||
videoRef.value.pause()
|
||||
let playInterval: number | null = null
|
||||
|
||||
watch(() => props.isOpen, (newVal) => {
|
||||
if (newVal) {
|
||||
// Reset player state when opened
|
||||
progress.value = 0
|
||||
currentTime.value = 0
|
||||
isPlaying.value = false
|
||||
} else {
|
||||
videoRef.value.play()
|
||||
// Stop playing when closed
|
||||
stopPlay()
|
||||
}
|
||||
playing.value = !playing.value
|
||||
})
|
||||
|
||||
function closePlayer() {
|
||||
stopPlay()
|
||||
emit('close')
|
||||
}
|
||||
|
||||
const toggleMute = () => {
|
||||
if (!videoRef.value) return
|
||||
videoRef.value.muted = !videoRef.value.muted
|
||||
muted.value = videoRef.value.muted
|
||||
}
|
||||
|
||||
const toggleFullscreen = () => {
|
||||
if (!document.fullscreenElement) {
|
||||
videoRef.value?.requestFullscreen()
|
||||
isFullscreen.value = true
|
||||
function togglePlay() {
|
||||
if (isPlaying.value) {
|
||||
stopPlay()
|
||||
} else {
|
||||
document.exitFullscreen()
|
||||
isFullscreen.value = false
|
||||
startPlay()
|
||||
}
|
||||
}
|
||||
|
||||
const handleTimeUpdate = () => {
|
||||
if (!videoRef.value) return
|
||||
currentTime.value = videoRef.value.currentTime
|
||||
progress.value = (currentTime.value / duration.value) * 100
|
||||
}
|
||||
|
||||
const handleMetadata = () => {
|
||||
if (!videoRef.value) return
|
||||
duration.value = videoRef.value.duration
|
||||
}
|
||||
|
||||
const handleEnded = () => {
|
||||
playing.value = false
|
||||
// TODO: Show related content or next episode
|
||||
}
|
||||
|
||||
const seek = (event: MouseEvent) => {
|
||||
if (!videoRef.value || !progressRef.value) return
|
||||
function startPlay() {
|
||||
isPlaying.value = true
|
||||
console.log('▶️ Video playing (demo mode)')
|
||||
|
||||
const rect = progressRef.value.getBoundingClientRect()
|
||||
const pos = (event.clientX - rect.left) / rect.width
|
||||
videoRef.value.currentTime = pos * duration.value
|
||||
}
|
||||
|
||||
const formatTime = (seconds: number): string => {
|
||||
const mins = Math.floor(seconds / 60)
|
||||
const secs = Math.floor(seconds % 60)
|
||||
return `${mins}:${secs.toString().padStart(2, '0')}`
|
||||
}
|
||||
|
||||
const handleMouseMove = () => {
|
||||
showControls.value = true
|
||||
|
||||
if (hideControlsTimeout) {
|
||||
clearTimeout(hideControlsTimeout)
|
||||
}
|
||||
|
||||
hideControlsTimeout = setTimeout(() => {
|
||||
if (playing.value) {
|
||||
showControls.value = false
|
||||
// Simulate playback progress
|
||||
playInterval = window.setInterval(() => {
|
||||
currentTime.value += 1
|
||||
progress.value = (currentTime.value / duration.value) * 100
|
||||
|
||||
// Loop when finished
|
||||
if (currentTime.value >= duration.value) {
|
||||
currentTime.value = 0
|
||||
progress.value = 0
|
||||
}
|
||||
}, 3000)
|
||||
}, 1000)
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
document.addEventListener('mousemove', handleMouseMove)
|
||||
})
|
||||
|
||||
onUnmounted(() => {
|
||||
document.removeEventListener('mousemove', handleMouseMove)
|
||||
if (hideControlsTimeout) {
|
||||
clearTimeout(hideControlsTimeout)
|
||||
function stopPlay() {
|
||||
isPlaying.value = false
|
||||
if (playInterval) {
|
||||
clearInterval(playInterval)
|
||||
playInterval = null
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
function toggleFullscreen() {
|
||||
console.log('🖥️ Fullscreen toggled (demo)')
|
||||
}
|
||||
|
||||
function formatTime(seconds: number): string {
|
||||
const h = Math.floor(seconds / 3600)
|
||||
const m = Math.floor((seconds % 3600) / 60)
|
||||
const s = Math.floor(seconds % 60)
|
||||
|
||||
if (h > 0) {
|
||||
return `${h}:${m.toString().padStart(2, '0')}:${s.toString().padStart(2, '0')}`
|
||||
}
|
||||
return `${m}:${s.toString().padStart(2, '0')}`
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.video-player-container {
|
||||
width: 100%;
|
||||
aspect-ratio: 16/9;
|
||||
}
|
||||
|
||||
.fullscreen {
|
||||
.video-player-overlay {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
z-index: 9999;
|
||||
aspect-ratio: auto;
|
||||
z-index: 10000;
|
||||
background: #0a0a0a;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
video {
|
||||
.video-player-container {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.close-button {
|
||||
position: absolute;
|
||||
top: 20px;
|
||||
right: 20px;
|
||||
z-index: 100;
|
||||
background: rgba(0, 0, 0, 0.7);
|
||||
backdrop-filter: blur(12px);
|
||||
-webkit-backdrop-filter: blur(12px);
|
||||
border: 1px solid rgba(255, 255, 255, 0.1);
|
||||
border-radius: 50%;
|
||||
width: 48px;
|
||||
height: 48px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
color: white;
|
||||
cursor: pointer;
|
||||
transition: all 0.3s ease;
|
||||
}
|
||||
|
||||
.close-button:hover {
|
||||
background: rgba(0, 0, 0, 0.9);
|
||||
border-color: rgba(255, 255, 255, 0.2);
|
||||
transform: scale(1.05);
|
||||
}
|
||||
|
||||
.video-area {
|
||||
flex: 1;
|
||||
position: relative;
|
||||
background: #000;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.video-overlay {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: rgba(0, 0, 0, 0.3);
|
||||
opacity: 1;
|
||||
transition: opacity 0.3s ease;
|
||||
}
|
||||
|
||||
.video-area:hover .video-overlay {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.play-button {
|
||||
width: 80px;
|
||||
height: 80px;
|
||||
border-radius: 50%;
|
||||
background: rgba(255, 255, 255, 0.2);
|
||||
backdrop-filter: blur(24px);
|
||||
-webkit-backdrop-filter: blur(24px);
|
||||
border: 2px solid rgba(255, 255, 255, 0.3);
|
||||
color: white;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
cursor: pointer;
|
||||
transition: all 0.3s ease;
|
||||
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.4);
|
||||
}
|
||||
|
||||
.play-button:hover {
|
||||
background: rgba(255, 255, 255, 0.3);
|
||||
border-color: rgba(255, 255, 255, 0.5);
|
||||
transform: scale(1.1);
|
||||
}
|
||||
|
||||
.demo-notice {
|
||||
position: absolute;
|
||||
top: 20px;
|
||||
left: 20px;
|
||||
background: rgba(247, 147, 26, 0.15);
|
||||
backdrop-filter: blur(24px);
|
||||
-webkit-backdrop-filter: blur(24px);
|
||||
border: 1px solid rgba(247, 147, 26, 0.3);
|
||||
border-radius: 12px;
|
||||
padding: 12px 16px;
|
||||
color: white;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.demo-badge {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
background: rgba(247, 147, 26, 0.3);
|
||||
padding: 4px 12px;
|
||||
border-radius: 8px;
|
||||
font-weight: 600;
|
||||
font-size: 12px;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.05em;
|
||||
color: #F7931A;
|
||||
}
|
||||
|
||||
.video-controls {
|
||||
background: rgba(0, 0, 0, 0.85);
|
||||
backdrop-filter: blur(24px);
|
||||
-webkit-backdrop-filter: blur(24px);
|
||||
border-top: 1px solid rgba(255, 255, 255, 0.05);
|
||||
padding: 16px 24px;
|
||||
}
|
||||
|
||||
.progress-container {
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.progress-bar {
|
||||
position: relative;
|
||||
height: 6px;
|
||||
background: rgba(255, 255, 255, 0.2);
|
||||
border-radius: 3px;
|
||||
cursor: pointer;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.progress-filled {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
height: 100%;
|
||||
background: #F7931A;
|
||||
border-radius: 3px;
|
||||
transition: width 0.1s linear;
|
||||
}
|
||||
|
||||
.progress-handle {
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
transform: translate(-50%, -50%);
|
||||
width: 14px;
|
||||
height: 14px;
|
||||
background: white;
|
||||
border-radius: 50%;
|
||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.4);
|
||||
opacity: 0;
|
||||
transition: opacity 0.2s;
|
||||
}
|
||||
|
||||
.progress-bar:hover .progress-handle {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.time-display {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
font-size: 13px;
|
||||
color: white;
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
.control-buttons {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
}
|
||||
|
||||
.control-btn {
|
||||
color: white;
|
||||
background: transparent;
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s ease;
|
||||
padding: 8px;
|
||||
border-radius: 8px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.control-btn:hover {
|
||||
background: rgba(255, 255, 255, 0.1);
|
||||
transform: scale(1.05);
|
||||
}
|
||||
|
||||
.quality-selector {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.quality-selector .control-btn {
|
||||
font-weight: 600;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.content-info-panel {
|
||||
position: absolute;
|
||||
bottom: 100px;
|
||||
left: 24px;
|
||||
max-width: 600px;
|
||||
background: rgba(0, 0, 0, 0.7);
|
||||
backdrop-filter: blur(24px);
|
||||
-webkit-backdrop-filter: blur(24px);
|
||||
border: 1px solid rgba(255, 255, 255, 0.08);
|
||||
border-radius: 16px;
|
||||
padding: 20px;
|
||||
opacity: 0;
|
||||
transition: opacity 0.3s ease;
|
||||
}
|
||||
|
||||
.video-player-container:hover .content-info-panel {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
/* Transitions */
|
||||
.player-fade-enter-active,
|
||||
.player-fade-leave-active {
|
||||
transition: opacity 0.3s ease;
|
||||
}
|
||||
|
||||
.player-fade-enter-from,
|
||||
.player-fade-leave-to {
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.demo-notice {
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.content-info-panel {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.control-buttons .text-white {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
90
src/composables/useAccess.ts
Normal file
90
src/composables/useAccess.ts
Normal file
@@ -0,0 +1,90 @@
|
||||
import { computed } from 'vue'
|
||||
import { libraryService } from '../services/library.service'
|
||||
import { subscriptionService } from '../services/subscription.service'
|
||||
import { useAuthStore } from '../stores/auth'
|
||||
|
||||
/**
|
||||
* Access Control Composable
|
||||
* Check user access to content (subscription or rental)
|
||||
*/
|
||||
export function useAccess() {
|
||||
const authStore = useAuthStore()
|
||||
|
||||
/**
|
||||
* Check if user has access to specific content
|
||||
*/
|
||||
async function checkContentAccess(contentId: string): Promise<{
|
||||
hasAccess: boolean
|
||||
method?: 'subscription' | 'rental'
|
||||
expiresAt?: string
|
||||
}> {
|
||||
if (!authStore.isAuthenticated) {
|
||||
return { hasAccess: false }
|
||||
}
|
||||
|
||||
// Check subscription first (instant check)
|
||||
if (authStore.hasActiveSubscription()) {
|
||||
return { hasAccess: true, method: 'subscription' }
|
||||
}
|
||||
|
||||
// Check if we're in development mode
|
||||
const useMockData = import.meta.env.VITE_USE_MOCK_DATA === 'true' || import.meta.env.DEV
|
||||
|
||||
if (useMockData) {
|
||||
// In dev mode without subscription, no access (prompt rental)
|
||||
return { hasAccess: false }
|
||||
}
|
||||
|
||||
// Real API call to check rental
|
||||
try {
|
||||
return await libraryService.checkContentAccess(contentId)
|
||||
} catch (error) {
|
||||
console.error('Failed to check access:', error)
|
||||
return { hasAccess: false }
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if user has active subscription
|
||||
*/
|
||||
const hasActiveSubscription = computed(() => {
|
||||
return authStore.hasActiveSubscription()
|
||||
})
|
||||
|
||||
/**
|
||||
* Get user's subscription tier
|
||||
*/
|
||||
async function getSubscriptionTier() {
|
||||
if (!authStore.isAuthenticated) return null
|
||||
|
||||
try {
|
||||
const subscription = await subscriptionService.getActiveSubscription()
|
||||
return subscription?.tier || null
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if content requires subscription
|
||||
*/
|
||||
function requiresSubscription(_content: any): boolean {
|
||||
// All content requires subscription or rental unless explicitly free
|
||||
return true
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if content can be rented
|
||||
*/
|
||||
function canRent(_content: any): boolean {
|
||||
return !!_content.rentalPrice && _content.rentalPrice > 0
|
||||
}
|
||||
|
||||
return {
|
||||
checkContentAccess,
|
||||
hasActiveSubscription,
|
||||
getSubscriptionTier,
|
||||
requiresSubscription,
|
||||
canRent,
|
||||
}
|
||||
}
|
||||
68
src/composables/useAuth.ts
Normal file
68
src/composables/useAuth.ts
Normal file
@@ -0,0 +1,68 @@
|
||||
import { computed } from 'vue'
|
||||
import { useAuthStore } from '../stores/auth'
|
||||
import type { ApiUser } from '../types/api'
|
||||
|
||||
/**
|
||||
* Auth Composable
|
||||
* Provides reactive authentication state and methods
|
||||
*/
|
||||
export function useAuth() {
|
||||
const authStore = useAuthStore()
|
||||
|
||||
// Reactive state
|
||||
const user = computed<ApiUser | null>(() => authStore.user)
|
||||
const isAuthenticated = computed(() => authStore.isAuthenticated)
|
||||
const isLoading = computed(() => authStore.isLoading)
|
||||
const authType = computed(() => authStore.authType)
|
||||
const nostrPubkey = computed(() => authStore.nostrPubkey)
|
||||
|
||||
// Methods
|
||||
const login = async (email: string, password: string) => {
|
||||
return authStore.loginWithCognito(email, password)
|
||||
}
|
||||
|
||||
const loginWithNostr = async (pubkey: string, signature: string, event: any) => {
|
||||
return authStore.loginWithNostr(pubkey, signature, event)
|
||||
}
|
||||
|
||||
const register = async (email: string, password: string, legalName: string) => {
|
||||
return authStore.register(email, password, legalName)
|
||||
}
|
||||
|
||||
const logout = async () => {
|
||||
return authStore.logout()
|
||||
}
|
||||
|
||||
const linkNostr = async (pubkey: string, signature: string) => {
|
||||
return authStore.linkNostr(pubkey, signature)
|
||||
}
|
||||
|
||||
const unlinkNostr = async () => {
|
||||
return authStore.unlinkNostr()
|
||||
}
|
||||
|
||||
// Computed getters
|
||||
const isFilmmaker = computed(() => authStore.isFilmmaker())
|
||||
const hasActiveSubscription = computed(() => authStore.hasActiveSubscription())
|
||||
|
||||
return {
|
||||
// State
|
||||
user,
|
||||
isAuthenticated,
|
||||
isLoading,
|
||||
authType,
|
||||
nostrPubkey,
|
||||
|
||||
// Methods
|
||||
login,
|
||||
loginWithNostr,
|
||||
register,
|
||||
logout,
|
||||
linkNostr,
|
||||
unlinkNostr,
|
||||
|
||||
// Getters
|
||||
isFilmmaker,
|
||||
hasActiveSubscription,
|
||||
}
|
||||
}
|
||||
350
src/composables/useNostr.ts
Normal file
350
src/composables/useNostr.ts
Normal file
@@ -0,0 +1,350 @@
|
||||
import { ref, computed, onUnmounted } from 'vue'
|
||||
import { nostrClient } from '../lib/nostr'
|
||||
import { getNostrContentIdentifier } from '../utils/mappers'
|
||||
import { getMockComments, getMockReactions, getMockProfile, mockProfiles } from '../data/mockSocialData'
|
||||
import type { Event as NostrEvent } from 'nostr-tools'
|
||||
|
||||
const useMockData = import.meta.env.VITE_USE_MOCK_DATA === 'true' || import.meta.env.DEV
|
||||
|
||||
/**
|
||||
* Nostr Composable
|
||||
* Reactive interface for Nostr features
|
||||
* Uses mock data in development mode
|
||||
*/
|
||||
export function useNostr(contentId?: string) {
|
||||
const comments = ref<NostrEvent[]>([])
|
||||
const reactions = ref<NostrEvent[]>([])
|
||||
const profiles = ref<Map<string, any>>(new Map())
|
||||
const isLoading = ref(false)
|
||||
const error = ref<string | null>(null)
|
||||
|
||||
let commentSub: any = null
|
||||
let reactionSub: any = null
|
||||
|
||||
/**
|
||||
* Fetch comments for content
|
||||
*/
|
||||
async function fetchComments(id: string = contentId!) {
|
||||
if (!id) return
|
||||
|
||||
isLoading.value = true
|
||||
error.value = null
|
||||
|
||||
try {
|
||||
if (useMockData) {
|
||||
// Simulate network delay
|
||||
await new Promise((resolve) => setTimeout(resolve, 200))
|
||||
const mockComments = getMockComments(id)
|
||||
comments.value = mockComments as unknown as NostrEvent[]
|
||||
|
||||
// Populate profiles from mock data
|
||||
mockComments.forEach((comment) => {
|
||||
const profile = getMockProfile(comment.pubkey)
|
||||
if (profile) {
|
||||
profiles.value.set(comment.pubkey, {
|
||||
name: profile.name,
|
||||
picture: profile.picture,
|
||||
about: profile.about,
|
||||
})
|
||||
}
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
const identifier = getNostrContentIdentifier(id)
|
||||
const events = await nostrClient.getComments(identifier)
|
||||
|
||||
// Sort by timestamp (newest first)
|
||||
comments.value = events.sort((a, b) => b.created_at - a.created_at)
|
||||
|
||||
// Fetch profiles for comment authors
|
||||
await fetchProfiles(events.map((e) => e.pubkey))
|
||||
} catch (err: any) {
|
||||
error.value = err.message || 'Failed to fetch comments'
|
||||
console.error('Nostr comments error:', err)
|
||||
} finally {
|
||||
isLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch reactions for content
|
||||
*/
|
||||
async function fetchReactions(id: string = contentId!) {
|
||||
if (!id) return
|
||||
|
||||
try {
|
||||
if (useMockData) {
|
||||
await new Promise((resolve) => setTimeout(resolve, 100))
|
||||
reactions.value = getMockReactions(id) as unknown as NostrEvent[]
|
||||
return
|
||||
}
|
||||
|
||||
const identifier = getNostrContentIdentifier(id)
|
||||
const events = await nostrClient.getReactions(identifier)
|
||||
reactions.value = events
|
||||
} catch (err: any) {
|
||||
console.error('Nostr reactions error:', err)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch user profiles
|
||||
*/
|
||||
async function fetchProfiles(pubkeys: string[]) {
|
||||
const uniquePubkeys = [...new Set(pubkeys)]
|
||||
|
||||
await Promise.all(
|
||||
uniquePubkeys.map(async (pubkey) => {
|
||||
if (profiles.value.has(pubkey)) return
|
||||
|
||||
if (useMockData) {
|
||||
const profile = getMockProfile(pubkey)
|
||||
if (profile) {
|
||||
profiles.value.set(pubkey, {
|
||||
name: profile.name,
|
||||
picture: profile.picture,
|
||||
about: profile.about,
|
||||
})
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
const profileEvent = await nostrClient.getProfile(pubkey)
|
||||
if (profileEvent) {
|
||||
const metadata = JSON.parse(profileEvent.content)
|
||||
profiles.value.set(pubkey, metadata)
|
||||
}
|
||||
} catch (err) {
|
||||
console.error(`Failed to fetch profile for ${pubkey}:`, err)
|
||||
}
|
||||
})
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Subscribe to real-time comments
|
||||
*/
|
||||
function subscribeToComments(id: string = contentId!) {
|
||||
if (!id || commentSub) return
|
||||
|
||||
if (useMockData) {
|
||||
// In mock mode, no real-time subscription needed
|
||||
return
|
||||
}
|
||||
|
||||
const identifier = getNostrContentIdentifier(id)
|
||||
|
||||
commentSub = nostrClient.subscribeToComments(
|
||||
identifier,
|
||||
(event) => {
|
||||
comments.value = [event, ...comments.value]
|
||||
fetchProfiles([event.pubkey])
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Subscribe to real-time reactions
|
||||
*/
|
||||
function subscribeToReactions(id: string = contentId!) {
|
||||
if (!id || reactionSub) return
|
||||
|
||||
if (useMockData) {
|
||||
return
|
||||
}
|
||||
|
||||
const identifier = getNostrContentIdentifier(id)
|
||||
|
||||
reactionSub = nostrClient.subscribeToReactions(
|
||||
identifier,
|
||||
(event) => {
|
||||
reactions.value = [...reactions.value, event]
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Post a comment
|
||||
*/
|
||||
async function postComment(content: string, id: string = contentId!) {
|
||||
if (!id) {
|
||||
throw new Error('Content ID required')
|
||||
}
|
||||
|
||||
if (useMockData) {
|
||||
// In mock mode, add the comment locally
|
||||
const mockProfile = mockProfiles[0]
|
||||
const newComment = {
|
||||
id: Math.random().toString(36).slice(2).padEnd(64, '0'),
|
||||
pubkey: mockProfile.pubkey,
|
||||
content,
|
||||
created_at: Math.floor(Date.now() / 1000),
|
||||
kind: 1 as const,
|
||||
tags: [['i', `https://indeedhub.com/content/${id}`, 'text']],
|
||||
sig: '0'.repeat(128),
|
||||
}
|
||||
comments.value = [newComment as unknown as NostrEvent, ...comments.value]
|
||||
|
||||
if (!profiles.value.has(mockProfile.pubkey)) {
|
||||
profiles.value.set(mockProfile.pubkey, {
|
||||
name: mockProfile.name,
|
||||
picture: mockProfile.picture,
|
||||
about: mockProfile.about,
|
||||
})
|
||||
}
|
||||
return newComment
|
||||
}
|
||||
|
||||
if (!window.nostr) {
|
||||
throw new Error('Nostr extension not available')
|
||||
}
|
||||
|
||||
try {
|
||||
const pubkey = await window.nostr.getPublicKey()
|
||||
const identifier = getNostrContentIdentifier(id)
|
||||
|
||||
const event = {
|
||||
kind: 1,
|
||||
created_at: Math.floor(Date.now() / 1000),
|
||||
tags: [
|
||||
['i', identifier, 'text'],
|
||||
],
|
||||
content,
|
||||
pubkey,
|
||||
}
|
||||
|
||||
const signedEvent = await window.nostr.signEvent(event)
|
||||
await nostrClient.publishEvent(signedEvent)
|
||||
|
||||
return signedEvent
|
||||
} catch (err: any) {
|
||||
throw new Error(err.message || 'Failed to post comment')
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Post a reaction (+1 or -1)
|
||||
*/
|
||||
async function postReaction(positive: boolean, id: string = contentId!) {
|
||||
if (!id) {
|
||||
throw new Error('Content ID required')
|
||||
}
|
||||
|
||||
if (useMockData) {
|
||||
const mockProfile = mockProfiles[0]
|
||||
const newReaction = {
|
||||
id: Math.random().toString(36).slice(2).padEnd(64, '0'),
|
||||
pubkey: mockProfile.pubkey,
|
||||
content: positive ? '+' : '-',
|
||||
created_at: Math.floor(Date.now() / 1000),
|
||||
kind: 17 as const,
|
||||
tags: [['i', `https://indeedhub.com/content/${id}`, 'text']],
|
||||
sig: '0'.repeat(128),
|
||||
}
|
||||
reactions.value = [...reactions.value, newReaction as unknown as NostrEvent]
|
||||
return newReaction
|
||||
}
|
||||
|
||||
if (!window.nostr) {
|
||||
throw new Error('Nostr extension not available')
|
||||
}
|
||||
|
||||
try {
|
||||
const pubkey = await window.nostr.getPublicKey()
|
||||
const identifier = getNostrContentIdentifier(id)
|
||||
|
||||
const event = {
|
||||
kind: 17,
|
||||
created_at: Math.floor(Date.now() / 1000),
|
||||
tags: [
|
||||
['i', identifier, 'text'],
|
||||
],
|
||||
content: positive ? '+' : '-',
|
||||
pubkey,
|
||||
}
|
||||
|
||||
const signedEvent = await window.nostr.signEvent(event)
|
||||
await nostrClient.publishEvent(signedEvent)
|
||||
|
||||
return signedEvent
|
||||
} catch (err: any) {
|
||||
throw new Error(err.message || 'Failed to post reaction')
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get reaction counts
|
||||
*/
|
||||
const reactionCounts = computed(() => {
|
||||
const positive = reactions.value.filter((r) => r.content === '+').length
|
||||
const negative = reactions.value.filter((r) => r.content === '-').length
|
||||
|
||||
return { positive, negative, total: positive - negative }
|
||||
})
|
||||
|
||||
/**
|
||||
* Get user's reaction
|
||||
*/
|
||||
async function getUserReaction(id: string = contentId!) {
|
||||
if (!id) return null
|
||||
|
||||
if (useMockData) {
|
||||
return null // Mock user has no existing reaction
|
||||
}
|
||||
|
||||
if (!window.nostr) return null
|
||||
|
||||
try {
|
||||
const pubkey = await window.nostr.getPublicKey()
|
||||
const userReaction = reactions.value.find((r) => r.pubkey === pubkey)
|
||||
return userReaction?.content || null
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Cleanup subscriptions
|
||||
*/
|
||||
function cleanup() {
|
||||
if (commentSub) commentSub.close()
|
||||
if (reactionSub) reactionSub.close()
|
||||
}
|
||||
|
||||
// Auto-cleanup on unmount
|
||||
onUnmounted(() => {
|
||||
cleanup()
|
||||
})
|
||||
|
||||
return {
|
||||
// State
|
||||
comments,
|
||||
reactions,
|
||||
profiles,
|
||||
isLoading,
|
||||
error,
|
||||
reactionCounts,
|
||||
|
||||
// Methods
|
||||
fetchComments,
|
||||
fetchReactions,
|
||||
subscribeToComments,
|
||||
subscribeToReactions,
|
||||
postComment,
|
||||
postReaction,
|
||||
getUserReaction,
|
||||
cleanup,
|
||||
}
|
||||
}
|
||||
|
||||
// Declare window.nostr for TypeScript
|
||||
declare global {
|
||||
interface Window {
|
||||
nostr?: {
|
||||
getPublicKey: () => Promise<string>
|
||||
signEvent: (event: any) => Promise<any>
|
||||
}
|
||||
}
|
||||
}
|
||||
73
src/composables/useToast.ts
Normal file
73
src/composables/useToast.ts
Normal file
@@ -0,0 +1,73 @@
|
||||
import { ref } from 'vue'
|
||||
|
||||
interface Toast {
|
||||
id: number
|
||||
message: string
|
||||
type: 'success' | 'error' | 'info' | 'warning'
|
||||
duration: number
|
||||
}
|
||||
|
||||
/**
|
||||
* Toast Notification Composable
|
||||
* Displays glassmorphic toast notifications
|
||||
*/
|
||||
export function useToast() {
|
||||
const toasts = ref<Toast[]>([])
|
||||
let nextId = 0
|
||||
|
||||
function showToast(
|
||||
message: string,
|
||||
type: Toast['type'] = 'info',
|
||||
duration: number = 3000
|
||||
) {
|
||||
const toast: Toast = {
|
||||
id: nextId++,
|
||||
message,
|
||||
type,
|
||||
duration,
|
||||
}
|
||||
|
||||
toasts.value.push(toast)
|
||||
|
||||
if (duration > 0) {
|
||||
setTimeout(() => {
|
||||
removeToast(toast.id)
|
||||
}, duration)
|
||||
}
|
||||
|
||||
return toast.id
|
||||
}
|
||||
|
||||
function removeToast(id: number) {
|
||||
const index = toasts.value.findIndex((t) => t.id === id)
|
||||
if (index > -1) {
|
||||
toasts.value.splice(index, 1)
|
||||
}
|
||||
}
|
||||
|
||||
function success(message: string, duration?: number) {
|
||||
return showToast(message, 'success', duration)
|
||||
}
|
||||
|
||||
function error(message: string, duration?: number) {
|
||||
return showToast(message, 'error', duration)
|
||||
}
|
||||
|
||||
function info(message: string, duration?: number) {
|
||||
return showToast(message, 'info', duration)
|
||||
}
|
||||
|
||||
function warning(message: string, duration?: number) {
|
||||
return showToast(message, 'warning', duration)
|
||||
}
|
||||
|
||||
return {
|
||||
toasts,
|
||||
showToast,
|
||||
removeToast,
|
||||
success,
|
||||
error,
|
||||
info,
|
||||
warning,
|
||||
}
|
||||
}
|
||||
24
src/config/api.config.ts
Normal file
24
src/config/api.config.ts
Normal file
@@ -0,0 +1,24 @@
|
||||
/**
|
||||
* API Configuration
|
||||
* Centralized configuration for API client
|
||||
*/
|
||||
|
||||
export const apiConfig = {
|
||||
baseURL: import.meta.env.VITE_API_URL || 'http://localhost:4000',
|
||||
timeout: Number(import.meta.env.VITE_API_TIMEOUT) || 30000,
|
||||
cdnURL: import.meta.env.VITE_CDN_URL || '',
|
||||
enableRetry: true,
|
||||
maxRetries: 3,
|
||||
retryDelay: 1000,
|
||||
} as const
|
||||
|
||||
export const nostrConfig = {
|
||||
relays: (import.meta.env.VITE_NOSTR_RELAYS || 'ws://localhost:7777,wss://relay.damus.io').split(','),
|
||||
lookupRelays: (import.meta.env.VITE_NOSTR_LOOKUP_RELAYS || 'wss://purplepag.es').split(','),
|
||||
} as const
|
||||
|
||||
export const featureFlags = {
|
||||
enableNostr: import.meta.env.VITE_ENABLE_NOSTR === 'true',
|
||||
enableLightning: import.meta.env.VITE_ENABLE_LIGHTNING === 'true',
|
||||
enableRentals: import.meta.env.VITE_ENABLE_RENTALS === 'true',
|
||||
} as const
|
||||
218
src/data/mockSocialData.ts
Normal file
218
src/data/mockSocialData.ts
Normal file
@@ -0,0 +1,218 @@
|
||||
/**
|
||||
* Mock Social Data for Development Mode
|
||||
* Provides realistic comments, reactions, and profiles without Nostr relays
|
||||
*/
|
||||
|
||||
export interface MockProfile {
|
||||
name: string
|
||||
picture: string
|
||||
about: string
|
||||
npub: string
|
||||
pubkey: string
|
||||
}
|
||||
|
||||
export interface MockComment {
|
||||
id: string
|
||||
pubkey: string
|
||||
content: string
|
||||
created_at: number
|
||||
kind: 1
|
||||
tags: string[][]
|
||||
sig: string
|
||||
}
|
||||
|
||||
export interface MockReaction {
|
||||
id: string
|
||||
pubkey: string
|
||||
content: '+' | '-'
|
||||
created_at: number
|
||||
kind: 17
|
||||
tags: string[][]
|
||||
sig: string
|
||||
}
|
||||
|
||||
// Mock Nostr profiles
|
||||
export const mockProfiles: MockProfile[] = [
|
||||
{
|
||||
name: 'BitcoinFilmFan',
|
||||
picture: 'https://api.dicebear.com/7.x/avataaars/svg?seed=BitcoinFilmFan',
|
||||
about: 'Independent film lover and Bitcoin enthusiast.',
|
||||
npub: 'npub1mockuser1xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx',
|
||||
pubkey: 'a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f60001',
|
||||
},
|
||||
{
|
||||
name: 'CinephileMax',
|
||||
picture: 'https://api.dicebear.com/7.x/avataaars/svg?seed=CinephileMax',
|
||||
about: 'Watching everything, one film at a time.',
|
||||
npub: 'npub1mockuser2xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx',
|
||||
pubkey: 'a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f60002',
|
||||
},
|
||||
{
|
||||
name: 'DocuLover',
|
||||
picture: 'https://api.dicebear.com/7.x/avataaars/svg?seed=DocuLover',
|
||||
about: 'Documentaries are the highest form of cinema.',
|
||||
npub: 'npub1mockuser3xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx',
|
||||
pubkey: 'a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f60003',
|
||||
},
|
||||
{
|
||||
name: 'SatoshiScreens',
|
||||
picture: 'https://api.dicebear.com/7.x/avataaars/svg?seed=SatoshiScreens',
|
||||
about: 'Film meets freedom tech. V4V.',
|
||||
npub: 'npub1mockuser4xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx',
|
||||
pubkey: 'a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f60004',
|
||||
},
|
||||
{
|
||||
name: 'IndieFilmNerd',
|
||||
picture: 'https://api.dicebear.com/7.x/avataaars/svg?seed=IndieFilmNerd',
|
||||
about: 'Supporting independent filmmakers everywhere.',
|
||||
npub: 'npub1mockuser5xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx',
|
||||
pubkey: 'a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f60005',
|
||||
},
|
||||
]
|
||||
|
||||
// Comment templates per content ID
|
||||
const commentTemplates: Record<string, string[]> = {
|
||||
'god-bless-bitcoin': [
|
||||
'This documentary completely changed how I think about Bitcoin and faith. Must watch.',
|
||||
'Incredible storytelling. The parallels between monetary sovereignty and spiritual freedom are powerful.',
|
||||
'Shared this with my entire church group. Everyone was blown away.',
|
||||
'Finally a Bitcoin documentary that goes beyond the price charts. Beautiful work.',
|
||||
],
|
||||
'thethingswecarry': [
|
||||
'Such a deeply emotional film. Brought me to tears.',
|
||||
'The cinematography is stunning. Every frame tells a story.',
|
||||
'This is what independent cinema should be. Raw and real.',
|
||||
],
|
||||
'duel': [
|
||||
'Edge-of-your-seat tension from start to finish. Brilliant directing.',
|
||||
'The performances are incredible. You can feel the weight of every decision.',
|
||||
'Rewatched this three times already. Catches something new each time.',
|
||||
],
|
||||
}
|
||||
|
||||
// Generic comments for content without specific templates
|
||||
const genericComments = [
|
||||
'Really enjoyed this one. Great production quality.',
|
||||
'IndeeHub keeps finding amazing content. This platform is the future.',
|
||||
'Watching this made my evening. Highly recommend.',
|
||||
'The filmmakers clearly put their heart into this. It shows.',
|
||||
'More people need to see this. Sharing with everyone I know.',
|
||||
'Just finished watching. Need a moment to process how good that was.',
|
||||
'This is why I subscribe. Quality content that you can not find elsewhere.',
|
||||
'Beautiful film. The score and visuals work perfectly together.',
|
||||
]
|
||||
|
||||
/**
|
||||
* Generate a mock event ID (hex string)
|
||||
*/
|
||||
function mockEventId(seed: number): string {
|
||||
return seed.toString(16).padStart(64, '0')
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate a mock signature (hex string)
|
||||
*/
|
||||
function mockSig(seed: number): string {
|
||||
return seed.toString(16).padStart(128, 'f')
|
||||
}
|
||||
|
||||
/**
|
||||
* Get mock comments for a given content ID
|
||||
*/
|
||||
export function getMockComments(contentId: string): MockComment[] {
|
||||
const templates = commentTemplates[contentId] || genericComments
|
||||
const now = Math.floor(Date.now() / 1000)
|
||||
|
||||
// Pick 3-5 comments
|
||||
const count = 3 + Math.floor(Math.abs(hashCode(contentId)) % 3)
|
||||
const comments: MockComment[] = []
|
||||
|
||||
for (let i = 0; i < count && i < templates.length; i++) {
|
||||
const profile = mockProfiles[i % mockProfiles.length]
|
||||
const hoursAgo = (i + 1) * 3 + Math.floor(Math.abs(hashCode(contentId + i)) % 12)
|
||||
|
||||
comments.push({
|
||||
id: mockEventId(hashCode(contentId + 'comment' + i)),
|
||||
pubkey: profile.pubkey,
|
||||
content: templates[i % templates.length],
|
||||
created_at: now - hoursAgo * 3600,
|
||||
kind: 1,
|
||||
tags: [['i', `https://indeedhub.com/content/${contentId}`, 'text']],
|
||||
sig: mockSig(hashCode(contentId + 'sig' + i)),
|
||||
})
|
||||
}
|
||||
|
||||
return comments.sort((a, b) => b.created_at - a.created_at)
|
||||
}
|
||||
|
||||
/**
|
||||
* Get mock reactions for a given content ID
|
||||
*/
|
||||
export function getMockReactions(contentId: string): MockReaction[] {
|
||||
const reactions: MockReaction[] = []
|
||||
const now = Math.floor(Date.now() / 1000)
|
||||
|
||||
// Generate between 5-15 reactions
|
||||
const count = 5 + Math.floor(Math.abs(hashCode(contentId)) % 11)
|
||||
|
||||
for (let i = 0; i < count; i++) {
|
||||
const profile = mockProfiles[i % mockProfiles.length]
|
||||
// ~80% positive reactions
|
||||
const isPositive = (hashCode(contentId + 'react' + i) % 10) < 8
|
||||
|
||||
reactions.push({
|
||||
id: mockEventId(hashCode(contentId + 'reaction' + i)),
|
||||
pubkey: profile.pubkey + i.toString(16).padStart(4, '0'),
|
||||
content: isPositive ? '+' : '-',
|
||||
created_at: now - i * 1800,
|
||||
kind: 17,
|
||||
tags: [['i', `https://indeedhub.com/content/${contentId}`, 'text']],
|
||||
sig: mockSig(hashCode(contentId + 'reactsig' + i)),
|
||||
})
|
||||
}
|
||||
|
||||
return reactions
|
||||
}
|
||||
|
||||
/**
|
||||
* Get mock reaction counts for a content ID (quick lookup without generating all events)
|
||||
*/
|
||||
export function getMockReactionCounts(contentId: string): { positive: number; negative: number; total: number } {
|
||||
const seed = Math.abs(hashCode(contentId))
|
||||
const total = 5 + (seed % 11)
|
||||
const positive = Math.floor(total * 0.8)
|
||||
const negative = total - positive
|
||||
return { positive, negative, total: positive - negative }
|
||||
}
|
||||
|
||||
/**
|
||||
* Get mock comment count for a content ID (quick lookup)
|
||||
*/
|
||||
export function getMockCommentCount(contentId: string): number {
|
||||
const templates = commentTemplates[contentId]
|
||||
if (templates) {
|
||||
return 3 + Math.floor(Math.abs(hashCode(contentId)) % Math.min(3, templates.length))
|
||||
}
|
||||
return 3 + Math.floor(Math.abs(hashCode(contentId)) % 3)
|
||||
}
|
||||
|
||||
/**
|
||||
* Get a mock profile by pubkey
|
||||
*/
|
||||
export function getMockProfile(pubkey: string): MockProfile | undefined {
|
||||
return mockProfiles.find((p) => pubkey.startsWith(p.pubkey.slice(0, 20)))
|
||||
|| mockProfiles[Math.abs(hashCode(pubkey)) % mockProfiles.length]
|
||||
}
|
||||
|
||||
/**
|
||||
* Simple hash function for deterministic results from strings
|
||||
*/
|
||||
function hashCode(str: string): number {
|
||||
let hash = 0
|
||||
for (let i = 0; i < str.length; i++) {
|
||||
const char = str.charCodeAt(i)
|
||||
hash = ((hash << 5) - hash) + char
|
||||
hash |= 0 // Convert to 32bit integer
|
||||
}
|
||||
return hash
|
||||
}
|
||||
14
src/env.d.ts
vendored
14
src/env.d.ts
vendored
@@ -13,6 +13,20 @@ interface ImportMetaEnv {
|
||||
readonly DEV: boolean
|
||||
readonly PROD: boolean
|
||||
readonly SSR: boolean
|
||||
readonly VITE_INDEEDHUB_API: string
|
||||
readonly VITE_API_URL: string
|
||||
readonly VITE_API_TIMEOUT: string
|
||||
readonly VITE_COGNITO_USER_POOL_ID: string
|
||||
readonly VITE_COGNITO_CLIENT_ID: string
|
||||
readonly VITE_COGNITO_REGION: string
|
||||
readonly VITE_NOSTR_RELAYS: string
|
||||
readonly VITE_NOSTR_LOOKUP_RELAYS: string
|
||||
readonly VITE_CDN_URL: string
|
||||
readonly VITE_APP_URL: string
|
||||
readonly VITE_ENABLE_NOSTR: string
|
||||
readonly VITE_ENABLE_LIGHTNING: string
|
||||
readonly VITE_ENABLE_RENTALS: string
|
||||
readonly VITE_USE_MOCK_DATA: string
|
||||
}
|
||||
|
||||
interface ImportMeta {
|
||||
|
||||
184
src/lib/nostr.ts
Normal file
184
src/lib/nostr.ts
Normal file
@@ -0,0 +1,184 @@
|
||||
import { SimplePool, nip19, type Event as NostrEvent, type Filter } from 'nostr-tools'
|
||||
import { nostrConfig } from '../config/api.config'
|
||||
|
||||
/**
|
||||
* Nostr Client
|
||||
* Handles Nostr relay connections and event management
|
||||
*/
|
||||
class NostrClient {
|
||||
private pool: SimplePool
|
||||
private relays: string[]
|
||||
private lookupRelays: string[]
|
||||
private eventCache: Map<string, NostrEvent>
|
||||
|
||||
constructor() {
|
||||
this.pool = new SimplePool()
|
||||
this.relays = nostrConfig.relays
|
||||
this.lookupRelays = nostrConfig.lookupRelays
|
||||
this.eventCache = new Map()
|
||||
}
|
||||
|
||||
/**
|
||||
* Subscribe to events with filters
|
||||
*/
|
||||
subscribe(
|
||||
filters: Filter | Filter[],
|
||||
onEvent: (event: NostrEvent) => void,
|
||||
onEose?: () => void
|
||||
) {
|
||||
const filterArray = Array.isArray(filters) ? filters : [filters]
|
||||
const sub = this.pool.subscribeMany(
|
||||
this.relays,
|
||||
filterArray as any, // Type workaround for nostr-tools
|
||||
{
|
||||
onevent: (event) => {
|
||||
this.eventCache.set(event.id, event)
|
||||
onEvent(event)
|
||||
},
|
||||
oneose: () => {
|
||||
onEose?.()
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
return sub
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch events (one-time query)
|
||||
*/
|
||||
async fetchEvents(filters: Filter): Promise<NostrEvent[]> {
|
||||
const events = await this.pool.querySync(this.relays, filters)
|
||||
events.forEach((event) => {
|
||||
this.eventCache.set(event.id, event)
|
||||
})
|
||||
return events
|
||||
}
|
||||
|
||||
/**
|
||||
* Publish event to relays
|
||||
*/
|
||||
async publishEvent(event: NostrEvent): Promise<void> {
|
||||
const results = this.pool.publish(this.relays, event)
|
||||
// Wait for at least one successful publish
|
||||
await Promise.race(results)
|
||||
this.eventCache.set(event.id, event)
|
||||
}
|
||||
|
||||
/**
|
||||
* Get profile metadata (kind 0)
|
||||
*/
|
||||
async getProfile(pubkey: string): Promise<NostrEvent | null> {
|
||||
const events = await this.pool.querySync(this.lookupRelays, {
|
||||
kinds: [0],
|
||||
authors: [pubkey],
|
||||
limit: 1,
|
||||
})
|
||||
|
||||
return events[0] || null
|
||||
}
|
||||
|
||||
/**
|
||||
* Get comments for content (kind 1)
|
||||
*/
|
||||
async getComments(contentIdentifier: string): Promise<NostrEvent[]> {
|
||||
const filter: Filter = {
|
||||
kinds: [1],
|
||||
'#i': [contentIdentifier],
|
||||
}
|
||||
|
||||
return this.fetchEvents(filter)
|
||||
}
|
||||
|
||||
/**
|
||||
* Get reactions for content (kind 17)
|
||||
*/
|
||||
async getReactions(contentIdentifier: string): Promise<NostrEvent[]> {
|
||||
const filter: Filter = {
|
||||
kinds: [17],
|
||||
'#i': [contentIdentifier],
|
||||
}
|
||||
|
||||
return this.fetchEvents(filter)
|
||||
}
|
||||
|
||||
/**
|
||||
* Subscribe to comments in real-time
|
||||
*/
|
||||
subscribeToComments(
|
||||
contentIdentifier: string,
|
||||
onComment: (event: NostrEvent) => void,
|
||||
onEose?: () => void
|
||||
) {
|
||||
return this.subscribe(
|
||||
[{
|
||||
kinds: [1],
|
||||
'#i': [contentIdentifier],
|
||||
since: Math.floor(Date.now() / 1000),
|
||||
}],
|
||||
onComment,
|
||||
onEose
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Subscribe to reactions in real-time
|
||||
*/
|
||||
subscribeToReactions(
|
||||
contentIdentifier: string,
|
||||
onReaction: (event: NostrEvent) => void,
|
||||
onEose?: () => void
|
||||
) {
|
||||
return this.subscribe(
|
||||
[{
|
||||
kinds: [17],
|
||||
'#i': [contentIdentifier],
|
||||
since: Math.floor(Date.now() / 1000),
|
||||
}],
|
||||
onReaction,
|
||||
onEose
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Get event from cache or fetch
|
||||
*/
|
||||
async getEvent(eventId: string): Promise<NostrEvent | null> {
|
||||
// Check cache first
|
||||
if (this.eventCache.has(eventId)) {
|
||||
return this.eventCache.get(eventId)!
|
||||
}
|
||||
|
||||
// Fetch from relays
|
||||
const events = await this.fetchEvents({ ids: [eventId] })
|
||||
return events[0] || null
|
||||
}
|
||||
|
||||
/**
|
||||
* Close all connections
|
||||
*/
|
||||
close() {
|
||||
this.pool.close(this.relays)
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert npub to hex pubkey
|
||||
*/
|
||||
npubToHex(npub: string): string {
|
||||
const decoded = nip19.decode(npub)
|
||||
if (decoded.type === 'npub') {
|
||||
return decoded.data
|
||||
}
|
||||
throw new Error('Invalid npub')
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert hex pubkey to npub
|
||||
*/
|
||||
hexToNpub(hex: string): string {
|
||||
return nip19.npubEncode(hex)
|
||||
}
|
||||
}
|
||||
|
||||
// Export singleton instance
|
||||
export const nostrClient = new NostrClient()
|
||||
106
src/router/guards.ts
Normal file
106
src/router/guards.ts
Normal file
@@ -0,0 +1,106 @@
|
||||
import type { Router, RouteLocationNormalized, NavigationGuardNext } from 'vue-router'
|
||||
import { useAuthStore } from '../stores/auth'
|
||||
|
||||
/**
|
||||
* Authentication guard
|
||||
* Redirects to login if not authenticated
|
||||
*/
|
||||
export async function authGuard(
|
||||
to: RouteLocationNormalized,
|
||||
_from: RouteLocationNormalized,
|
||||
next: NavigationGuardNext
|
||||
) {
|
||||
const authStore = useAuthStore()
|
||||
|
||||
// Initialize auth if not already done
|
||||
if (!authStore.isAuthenticated && !authStore.isLoading) {
|
||||
await authStore.initialize()
|
||||
}
|
||||
|
||||
if (authStore.isAuthenticated) {
|
||||
next()
|
||||
} else {
|
||||
// Store intended destination for redirect after login
|
||||
sessionStorage.setItem('redirect_after_login', to.fullPath)
|
||||
next('/login')
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Guest guard
|
||||
* Redirects to home if already authenticated (for login/register pages)
|
||||
*/
|
||||
export function guestGuard(
|
||||
_to: RouteLocationNormalized,
|
||||
_from: RouteLocationNormalized,
|
||||
next: NavigationGuardNext
|
||||
) {
|
||||
const authStore = useAuthStore()
|
||||
|
||||
if (authStore.isAuthenticated) {
|
||||
next('/')
|
||||
} else {
|
||||
next()
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Subscription guard
|
||||
* Checks if user has active subscription
|
||||
*/
|
||||
export function subscriptionGuard(
|
||||
to: RouteLocationNormalized,
|
||||
_from: RouteLocationNormalized,
|
||||
next: NavigationGuardNext
|
||||
) {
|
||||
const authStore = useAuthStore()
|
||||
|
||||
if (!authStore.isAuthenticated) {
|
||||
sessionStorage.setItem('redirect_after_login', to.fullPath)
|
||||
next('/login')
|
||||
} else if (authStore.hasActiveSubscription()) {
|
||||
next()
|
||||
} else {
|
||||
// Redirect to subscription page
|
||||
next('/subscription')
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Filmmaker guard
|
||||
* Restricts access to filmmaker-only routes
|
||||
*/
|
||||
export function filmmakerGuard(
|
||||
to: RouteLocationNormalized,
|
||||
_from: RouteLocationNormalized,
|
||||
next: NavigationGuardNext
|
||||
) {
|
||||
const authStore = useAuthStore()
|
||||
|
||||
if (!authStore.isAuthenticated) {
|
||||
sessionStorage.setItem('redirect_after_login', to.fullPath)
|
||||
next('/login')
|
||||
} else if (authStore.isFilmmaker()) {
|
||||
next()
|
||||
} else {
|
||||
// Redirect to home with error message
|
||||
next('/')
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Setup router guards
|
||||
*/
|
||||
export function setupGuards(router: Router) {
|
||||
// Global before guard for auth initialization
|
||||
router.beforeEach(async (to, _from, next) => {
|
||||
const authStore = useAuthStore()
|
||||
|
||||
// Initialize auth on first navigation
|
||||
if (!authStore.isAuthenticated && !authStore.isLoading && to.meta.requiresAuth) {
|
||||
await authStore.initialize()
|
||||
}
|
||||
|
||||
next()
|
||||
})
|
||||
}
|
||||
@@ -1,4 +1,5 @@
|
||||
import { createRouter, createWebHistory } from 'vue-router'
|
||||
import { setupGuards, authGuard } from './guards'
|
||||
import Browse from '../views/Browse.vue'
|
||||
|
||||
const router = createRouter({
|
||||
@@ -7,9 +8,27 @@ const router = createRouter({
|
||||
{
|
||||
path: '/',
|
||||
name: 'browse',
|
||||
component: Browse
|
||||
component: Browse,
|
||||
meta: { requiresAuth: false }
|
||||
},
|
||||
{
|
||||
path: '/library',
|
||||
name: 'library',
|
||||
component: () => import('../views/Library.vue'),
|
||||
beforeEnter: authGuard,
|
||||
meta: { requiresAuth: true }
|
||||
},
|
||||
{
|
||||
path: '/profile',
|
||||
name: 'profile',
|
||||
component: () => import('../views/Profile.vue'),
|
||||
beforeEnter: authGuard,
|
||||
meta: { requiresAuth: true }
|
||||
}
|
||||
]
|
||||
})
|
||||
|
||||
// Setup authentication guards
|
||||
setupGuards(router)
|
||||
|
||||
export default router
|
||||
|
||||
267
src/services/api.service.ts
Normal file
267
src/services/api.service.ts
Normal file
@@ -0,0 +1,267 @@
|
||||
import axios, { AxiosInstance, AxiosRequestConfig, AxiosError } from 'axios'
|
||||
import { apiConfig } from '../config/api.config'
|
||||
import type { ApiError } from '../types/api'
|
||||
|
||||
/**
|
||||
* Base API Service
|
||||
* Handles HTTP requests, token management, and error handling
|
||||
*/
|
||||
class ApiService {
|
||||
private client: AxiosInstance
|
||||
private tokenRefreshPromise: Promise<string> | null = null
|
||||
|
||||
constructor() {
|
||||
this.client = axios.create({
|
||||
baseURL: apiConfig.baseURL,
|
||||
timeout: apiConfig.timeout,
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
})
|
||||
|
||||
this.setupInterceptors()
|
||||
}
|
||||
|
||||
/**
|
||||
* Setup request and response interceptors
|
||||
*/
|
||||
private setupInterceptors() {
|
||||
// Request interceptor - Add auth token
|
||||
this.client.interceptors.request.use(
|
||||
(config) => {
|
||||
const token = this.getToken()
|
||||
if (token) {
|
||||
config.headers.Authorization = `Bearer ${token}`
|
||||
}
|
||||
return config
|
||||
},
|
||||
(error) => Promise.reject(error)
|
||||
)
|
||||
|
||||
// Response interceptor - Handle errors and token refresh
|
||||
this.client.interceptors.response.use(
|
||||
(response) => response,
|
||||
async (error: AxiosError<ApiError>) => {
|
||||
const originalRequest = error.config as AxiosRequestConfig & { _retry?: boolean }
|
||||
|
||||
// Handle 401 - Token expired
|
||||
if (error.response?.status === 401 && !originalRequest._retry) {
|
||||
originalRequest._retry = true
|
||||
|
||||
try {
|
||||
const newToken = await this.refreshToken()
|
||||
if (newToken && originalRequest.headers) {
|
||||
originalRequest.headers.Authorization = `Bearer ${newToken}`
|
||||
return this.client(originalRequest)
|
||||
}
|
||||
} catch (refreshError) {
|
||||
// Token refresh failed - clear auth and redirect to login
|
||||
this.clearAuth()
|
||||
window.location.href = '/login'
|
||||
return Promise.reject(refreshError)
|
||||
}
|
||||
}
|
||||
|
||||
// Handle other errors
|
||||
return Promise.reject(this.handleError(error))
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Get stored authentication token
|
||||
*/
|
||||
private getToken(): string | null {
|
||||
// Check session storage first (Cognito JWT)
|
||||
const cognitoToken = sessionStorage.getItem('auth_token')
|
||||
if (cognitoToken) return cognitoToken
|
||||
|
||||
// Check for Nostr session token
|
||||
const nostrToken = sessionStorage.getItem('nostr_token')
|
||||
if (nostrToken) return nostrToken
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
/**
|
||||
* Set authentication token
|
||||
*/
|
||||
public setToken(token: string, type: 'cognito' | 'nostr' = 'cognito') {
|
||||
if (type === 'cognito') {
|
||||
sessionStorage.setItem('auth_token', token)
|
||||
} else {
|
||||
sessionStorage.setItem('nostr_token', token)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear authentication
|
||||
*/
|
||||
public clearAuth() {
|
||||
sessionStorage.removeItem('auth_token')
|
||||
sessionStorage.removeItem('nostr_token')
|
||||
sessionStorage.removeItem('refresh_token')
|
||||
}
|
||||
|
||||
/**
|
||||
* Refresh authentication token
|
||||
*/
|
||||
private async refreshToken(): Promise<string> {
|
||||
// Prevent multiple simultaneous refresh requests
|
||||
if (this.tokenRefreshPromise) {
|
||||
return this.tokenRefreshPromise
|
||||
}
|
||||
|
||||
this.tokenRefreshPromise = (async () => {
|
||||
try {
|
||||
const refreshToken = sessionStorage.getItem('refresh_token')
|
||||
if (!refreshToken) {
|
||||
throw new Error('No refresh token available')
|
||||
}
|
||||
|
||||
// Call refresh endpoint (implement based on backend)
|
||||
const response = await axios.post(`${apiConfig.baseURL}/auth/refresh`, {
|
||||
refreshToken,
|
||||
})
|
||||
|
||||
const newToken = response.data.accessToken
|
||||
this.setToken(newToken, 'cognito')
|
||||
|
||||
if (response.data.refreshToken) {
|
||||
sessionStorage.setItem('refresh_token', response.data.refreshToken)
|
||||
}
|
||||
|
||||
return newToken
|
||||
} finally {
|
||||
this.tokenRefreshPromise = null
|
||||
}
|
||||
})()
|
||||
|
||||
return this.tokenRefreshPromise
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle and normalize API errors
|
||||
*/
|
||||
private handleError(error: AxiosError<ApiError>): ApiError {
|
||||
if (error.response) {
|
||||
// Server responded with error
|
||||
return {
|
||||
message: error.response.data?.message || 'An error occurred',
|
||||
statusCode: error.response.status,
|
||||
error: error.response.data?.error,
|
||||
details: error.response.data?.details,
|
||||
}
|
||||
} else if (error.request) {
|
||||
// Request made but no response
|
||||
return {
|
||||
message: 'Unable to connect to server. Please check your internet connection.',
|
||||
statusCode: 0,
|
||||
error: 'NETWORK_ERROR',
|
||||
}
|
||||
} else {
|
||||
// Something else happened
|
||||
return {
|
||||
message: error.message || 'An unexpected error occurred',
|
||||
statusCode: 0,
|
||||
error: 'UNKNOWN_ERROR',
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Retry logic for failed requests
|
||||
*/
|
||||
private async retryRequest<T>(
|
||||
fn: () => Promise<T>,
|
||||
retries: number = apiConfig.maxRetries
|
||||
): Promise<T> {
|
||||
try {
|
||||
return await fn()
|
||||
} catch (error) {
|
||||
if (retries > 0 && this.shouldRetry(error as AxiosError)) {
|
||||
await this.delay(apiConfig.retryDelay)
|
||||
return this.retryRequest(fn, retries - 1)
|
||||
}
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Determine if request should be retried
|
||||
*/
|
||||
private shouldRetry(error: AxiosError): boolean {
|
||||
if (!apiConfig.enableRetry) return false
|
||||
|
||||
// Retry on network errors or 5xx server errors
|
||||
return (
|
||||
!error.response ||
|
||||
(error.response.status >= 500 && error.response.status < 600)
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Delay helper for retry logic
|
||||
*/
|
||||
private delay(ms: number): Promise<void> {
|
||||
return new Promise((resolve) => setTimeout(resolve, ms))
|
||||
}
|
||||
|
||||
/**
|
||||
* GET request
|
||||
*/
|
||||
public async get<T>(url: string, config?: AxiosRequestConfig): Promise<T> {
|
||||
if (apiConfig.enableRetry) {
|
||||
return this.retryRequest(async () => {
|
||||
const response = await this.client.get<T>(url, config)
|
||||
return response.data
|
||||
})
|
||||
}
|
||||
const response = await this.client.get<T>(url, config)
|
||||
return response.data
|
||||
}
|
||||
|
||||
/**
|
||||
* POST request
|
||||
*/
|
||||
public async post<T>(url: string, data?: any, config?: AxiosRequestConfig): Promise<T> {
|
||||
const response = await this.client.post<T>(url, data, config)
|
||||
return response.data
|
||||
}
|
||||
|
||||
/**
|
||||
* PUT request
|
||||
*/
|
||||
public async put<T>(url: string, data?: any, config?: AxiosRequestConfig): Promise<T> {
|
||||
const response = await this.client.put<T>(url, data, config)
|
||||
return response.data
|
||||
}
|
||||
|
||||
/**
|
||||
* PATCH request
|
||||
*/
|
||||
public async patch<T>(url: string, data?: any, config?: AxiosRequestConfig): Promise<T> {
|
||||
const response = await this.client.patch<T>(url, data, config)
|
||||
return response.data
|
||||
}
|
||||
|
||||
/**
|
||||
* DELETE request
|
||||
*/
|
||||
public async delete<T>(url: string, config?: AxiosRequestConfig): Promise<T> {
|
||||
const response = await this.client.delete<T>(url, config)
|
||||
return response.data
|
||||
}
|
||||
|
||||
/**
|
||||
* Get CDN URL for media assets
|
||||
*/
|
||||
public getCdnUrl(path: string): string {
|
||||
if (!path) return ''
|
||||
if (path.startsWith('http')) return path
|
||||
return `${apiConfig.cdnURL}${path}`
|
||||
}
|
||||
}
|
||||
|
||||
// Export singleton instance
|
||||
export const apiService = new ApiService()
|
||||
147
src/services/auth.service.ts
Normal file
147
src/services/auth.service.ts
Normal file
@@ -0,0 +1,147 @@
|
||||
import { apiService } from './api.service'
|
||||
import type {
|
||||
LoginCredentials,
|
||||
RegisterData,
|
||||
AuthResponse,
|
||||
NostrSessionRequest,
|
||||
NostrSessionResponse,
|
||||
ApiUser,
|
||||
} from '../types/api'
|
||||
|
||||
/**
|
||||
* Authentication Service
|
||||
* Handles Cognito and Nostr authentication
|
||||
*/
|
||||
class AuthService {
|
||||
/**
|
||||
* Login with email and password (Cognito)
|
||||
*/
|
||||
async login(credentials: LoginCredentials): Promise<AuthResponse> {
|
||||
try {
|
||||
const response = await apiService.post<AuthResponse>('/auth/login', credentials)
|
||||
|
||||
// Store tokens
|
||||
if (response.accessToken) {
|
||||
apiService.setToken(response.accessToken, 'cognito')
|
||||
if (response.refreshToken) {
|
||||
sessionStorage.setItem('refresh_token', response.refreshToken)
|
||||
}
|
||||
}
|
||||
|
||||
return response
|
||||
} catch (error) {
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Register new user
|
||||
*/
|
||||
async register(data: RegisterData): Promise<AuthResponse> {
|
||||
return apiService.post<AuthResponse>('/auth/register', data)
|
||||
}
|
||||
|
||||
/**
|
||||
* Get current authenticated user
|
||||
*/
|
||||
async getCurrentUser(): Promise<ApiUser> {
|
||||
return apiService.get<ApiUser>('/auth/me')
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate current session
|
||||
*/
|
||||
async validateSession(): Promise<boolean> {
|
||||
try {
|
||||
await apiService.post('/auth/validate-session')
|
||||
return true
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Logout user
|
||||
*/
|
||||
async logout(): Promise<void> {
|
||||
apiService.clearAuth()
|
||||
}
|
||||
|
||||
/**
|
||||
* Create Nostr session
|
||||
*/
|
||||
async createNostrSession(request: NostrSessionRequest): Promise<NostrSessionResponse> {
|
||||
const response = await apiService.post<NostrSessionResponse>('/auth/nostr/session', request)
|
||||
|
||||
// Store Nostr token
|
||||
if (response.token) {
|
||||
apiService.setToken(response.token, 'nostr')
|
||||
}
|
||||
|
||||
return response
|
||||
}
|
||||
|
||||
/**
|
||||
* Refresh Nostr session
|
||||
*/
|
||||
async refreshNostrSession(pubkey: string, signature: string): Promise<NostrSessionResponse> {
|
||||
return apiService.post<NostrSessionResponse>('/auth/nostr/refresh', {
|
||||
pubkey,
|
||||
signature,
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Link Nostr pubkey to existing account
|
||||
*/
|
||||
async linkNostrPubkey(pubkey: string, signature: string): Promise<ApiUser> {
|
||||
return apiService.post<ApiUser>('/auth/nostr/link', {
|
||||
pubkey,
|
||||
signature,
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Unlink Nostr pubkey from account
|
||||
*/
|
||||
async unlinkNostrPubkey(): Promise<ApiUser> {
|
||||
return apiService.post<ApiUser>('/auth/nostr/unlink')
|
||||
}
|
||||
|
||||
/**
|
||||
* Initialize OTP flow
|
||||
*/
|
||||
async initOtp(email: string): Promise<void> {
|
||||
await apiService.post('/auth/otp/init', { email })
|
||||
}
|
||||
|
||||
/**
|
||||
* Request password reset
|
||||
*/
|
||||
async forgotPassword(email: string): Promise<void> {
|
||||
await apiService.post('/auth/forgot-password', { email })
|
||||
}
|
||||
|
||||
/**
|
||||
* Reset password with code
|
||||
*/
|
||||
async resetPassword(email: string, code: string, newPassword: string): Promise<void> {
|
||||
await apiService.post('/auth/reset-password', {
|
||||
email,
|
||||
code,
|
||||
newPassword,
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Confirm email with verification code
|
||||
*/
|
||||
async confirmEmail(email: string, code: string): Promise<void> {
|
||||
await apiService.post('/auth/confirm-email', {
|
||||
email,
|
||||
code,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
export const authService = new AuthService()
|
||||
111
src/services/content.service.ts
Normal file
111
src/services/content.service.ts
Normal file
@@ -0,0 +1,111 @@
|
||||
import { apiService } from './api.service'
|
||||
import type { ApiProject, ApiContent } from '../types/api'
|
||||
|
||||
/**
|
||||
* Content Service
|
||||
* Handles projects and content data
|
||||
*/
|
||||
class ContentService {
|
||||
/**
|
||||
* Get all published projects with optional filters
|
||||
*/
|
||||
async getProjects(filters?: {
|
||||
type?: 'film' | 'episodic' | 'music-video'
|
||||
status?: string
|
||||
genre?: string
|
||||
limit?: number
|
||||
page?: number
|
||||
}): Promise<ApiProject[]> {
|
||||
const params = new URLSearchParams()
|
||||
|
||||
if (filters?.type) params.append('type', filters.type)
|
||||
if (filters?.status) params.append('status', filters.status)
|
||||
if (filters?.genre) params.append('genre', filters.genre)
|
||||
if (filters?.limit) params.append('limit', filters.limit.toString())
|
||||
if (filters?.page) params.append('page', filters.page.toString())
|
||||
|
||||
const url = `/projects${params.toString() ? `?${params.toString()}` : ''}`
|
||||
return apiService.get<ApiProject[]>(url)
|
||||
}
|
||||
|
||||
/**
|
||||
* Get project by ID
|
||||
*/
|
||||
async getProjectById(id: string): Promise<ApiProject> {
|
||||
return apiService.get<ApiProject>(`/projects/${id}`)
|
||||
}
|
||||
|
||||
/**
|
||||
* Get project by slug
|
||||
*/
|
||||
async getProjectBySlug(slug: string): Promise<ApiProject> {
|
||||
return apiService.get<ApiProject>(`/projects/slug/${slug}`)
|
||||
}
|
||||
|
||||
/**
|
||||
* Get content by ID
|
||||
*/
|
||||
async getContentById(id: string): Promise<ApiContent> {
|
||||
return apiService.get<ApiContent>(`/contents/${id}`)
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all contents for a project
|
||||
*/
|
||||
async getContentsByProject(projectId: string): Promise<ApiContent[]> {
|
||||
return apiService.get<ApiContent[]>(`/contents/project/${projectId}`)
|
||||
}
|
||||
|
||||
/**
|
||||
* Get streaming URL for content (requires subscription or rental)
|
||||
*/
|
||||
async getStreamingUrl(contentId: string): Promise<{ url: string; drmToken?: string }> {
|
||||
return apiService.get<{ url: string; drmToken?: string }>(`/contents/${contentId}/stream`)
|
||||
}
|
||||
|
||||
/**
|
||||
* Search projects
|
||||
*/
|
||||
async searchProjects(query: string, filters?: {
|
||||
type?: string
|
||||
genre?: string
|
||||
}): Promise<ApiProject[]> {
|
||||
const params = new URLSearchParams()
|
||||
params.append('q', query)
|
||||
|
||||
if (filters?.type) params.append('type', filters.type)
|
||||
if (filters?.genre) params.append('genre', filters.genre)
|
||||
|
||||
return apiService.get<ApiProject[]>(`/projects/search?${params.toString()}`)
|
||||
}
|
||||
|
||||
/**
|
||||
* Get featured content (top-rated, recent releases)
|
||||
*/
|
||||
async getFeaturedContent(): Promise<ApiProject[]> {
|
||||
return apiService.get<ApiProject[]>('/projects?status=published&featured=true')
|
||||
}
|
||||
|
||||
/**
|
||||
* Get genres
|
||||
*/
|
||||
async getGenres(): Promise<Array<{ id: string; name: string; slug: string }>> {
|
||||
return apiService.get('/genres')
|
||||
}
|
||||
|
||||
/**
|
||||
* Get festivals
|
||||
*/
|
||||
async getFestivals(): Promise<Array<{ id: string; name: string; slug: string }>> {
|
||||
return apiService.get('/festivals')
|
||||
}
|
||||
|
||||
/**
|
||||
* Get awards
|
||||
*/
|
||||
async getAwards(): Promise<Array<{ id: string; name: string; slug: string }>> {
|
||||
return apiService.get('/awards')
|
||||
}
|
||||
}
|
||||
|
||||
export const contentService = new ContentService()
|
||||
145
src/services/library.service.ts
Normal file
145
src/services/library.service.ts
Normal file
@@ -0,0 +1,145 @@
|
||||
import { apiService } from './api.service'
|
||||
import type { ApiRent, ApiContent } from '../types/api'
|
||||
|
||||
/**
|
||||
* Library Service
|
||||
* Handles user library and rentals
|
||||
*/
|
||||
class LibraryService {
|
||||
/**
|
||||
* Get user's library (subscribed + rented content)
|
||||
*/
|
||||
async getUserLibrary(): Promise<{
|
||||
subscribed: ApiContent[]
|
||||
rented: ApiRent[]
|
||||
continueWatching: Array<{ content: ApiContent; progress: number }>
|
||||
}> {
|
||||
// Check if we're in development mode
|
||||
const useMockData = import.meta.env.VITE_USE_MOCK_DATA === 'true' || import.meta.env.DEV
|
||||
|
||||
if (useMockData) {
|
||||
// Mock library data for development
|
||||
console.log('🔧 Development mode: Using mock library data')
|
||||
|
||||
await new Promise(resolve => setTimeout(resolve, 300))
|
||||
|
||||
// Import mock film data
|
||||
const { indeeHubFilms, bitcoinFilms } = await import('../data/indeeHubFilms')
|
||||
const allFilms = [...indeeHubFilms, ...bitcoinFilms]
|
||||
|
||||
// Create mock API content from our film data
|
||||
const mockApiContent = allFilms.slice(0, 20).map((film) => ({
|
||||
id: film.id,
|
||||
projectId: film.id,
|
||||
title: film.title,
|
||||
synopsis: film.description,
|
||||
file: `/content/${film.id}/video.mp4`,
|
||||
status: 'ready' as const,
|
||||
rentalPrice: 4.99,
|
||||
poster: film.thumbnail,
|
||||
metadata: { duration: film.duration },
|
||||
isRssEnabled: false,
|
||||
createdAt: new Date().toISOString(),
|
||||
updatedAt: new Date().toISOString(),
|
||||
}))
|
||||
|
||||
// Mock continue watching (first 3 films with progress)
|
||||
const continueWatching = mockApiContent.slice(0, 3).map((content, index) => ({
|
||||
content,
|
||||
progress: [35, 67, 12][index], // Different progress percentages
|
||||
}))
|
||||
|
||||
// Mock rented content (2 films with expiry)
|
||||
const rented: ApiRent[] = mockApiContent.slice(3, 5).map((content) => ({
|
||||
id: 'rent-' + content.id,
|
||||
userId: 'mock-user',
|
||||
contentId: content.id,
|
||||
expiresAt: new Date(Date.now() + 24 * 60 * 60 * 1000).toISOString(), // 24 hours
|
||||
createdAt: new Date().toISOString(),
|
||||
content,
|
||||
}))
|
||||
|
||||
// All subscribed content (full catalog access)
|
||||
const subscribed = mockApiContent
|
||||
|
||||
return {
|
||||
subscribed,
|
||||
rented,
|
||||
continueWatching,
|
||||
}
|
||||
}
|
||||
|
||||
// Real API call
|
||||
return apiService.get('/library')
|
||||
}
|
||||
|
||||
/**
|
||||
* Get rented content
|
||||
*/
|
||||
async getRentedContent(): Promise<ApiRent[]> {
|
||||
return apiService.get<ApiRent[]>('/rents')
|
||||
}
|
||||
|
||||
/**
|
||||
* Rent content
|
||||
*/
|
||||
async rentContent(contentId: string, paymentMethodId?: string): Promise<ApiRent> {
|
||||
return apiService.post<ApiRent>('/rents', {
|
||||
contentId,
|
||||
paymentMethodId,
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if user has access to content
|
||||
*/
|
||||
async checkContentAccess(contentId: string): Promise<{
|
||||
hasAccess: boolean
|
||||
method?: 'subscription' | 'rental'
|
||||
expiresAt?: string
|
||||
}> {
|
||||
try {
|
||||
return await apiService.get(`/contents/${contentId}/access`)
|
||||
} catch {
|
||||
return { hasAccess: false }
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Add content to watch later list
|
||||
*/
|
||||
async addToWatchLater(contentId: string): Promise<void> {
|
||||
await apiService.post('/library/watch-later', { contentId })
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove content from watch later list
|
||||
*/
|
||||
async removeFromWatchLater(contentId: string): Promise<void> {
|
||||
await apiService.delete(`/library/watch-later/${contentId}`)
|
||||
}
|
||||
|
||||
/**
|
||||
* Update watch progress
|
||||
*/
|
||||
async updateWatchProgress(contentId: string, progress: number, duration: number): Promise<void> {
|
||||
await apiService.post('/library/progress', {
|
||||
contentId,
|
||||
progress,
|
||||
duration,
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Get watch progress for content
|
||||
*/
|
||||
async getWatchProgress(contentId: string): Promise<{ progress: number; duration: number } | null> {
|
||||
try {
|
||||
return await apiService.get(`/library/progress/${contentId}`)
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export const libraryService = new LibraryService()
|
||||
114
src/services/subscription.service.ts
Normal file
114
src/services/subscription.service.ts
Normal file
@@ -0,0 +1,114 @@
|
||||
import { apiService } from './api.service'
|
||||
import type { ApiSubscription } from '../types/api'
|
||||
|
||||
/**
|
||||
* Subscription Service
|
||||
* Handles user subscriptions
|
||||
*/
|
||||
class SubscriptionService {
|
||||
/**
|
||||
* Get user's subscriptions
|
||||
*/
|
||||
async getSubscriptions(): Promise<ApiSubscription[]> {
|
||||
return apiService.get<ApiSubscription[]>('/subscriptions')
|
||||
}
|
||||
|
||||
/**
|
||||
* Get active subscription
|
||||
*/
|
||||
async getActiveSubscription(): Promise<ApiSubscription | null> {
|
||||
const subscriptions = await this.getSubscriptions()
|
||||
return subscriptions.find((sub) => sub.status === 'active') || null
|
||||
}
|
||||
|
||||
/**
|
||||
* Subscribe to a tier
|
||||
*/
|
||||
async subscribe(data: {
|
||||
tier: 'enthusiast' | 'film-buff' | 'cinephile'
|
||||
period: 'monthly' | 'annual'
|
||||
paymentMethodId?: string
|
||||
}): Promise<ApiSubscription> {
|
||||
return apiService.post<ApiSubscription>('/subscriptions', data)
|
||||
}
|
||||
|
||||
/**
|
||||
* Cancel subscription
|
||||
*/
|
||||
async cancelSubscription(subscriptionId: string): Promise<void> {
|
||||
await apiService.delete(`/subscriptions/${subscriptionId}`)
|
||||
}
|
||||
|
||||
/**
|
||||
* Resume cancelled subscription
|
||||
*/
|
||||
async resumeSubscription(subscriptionId: string): Promise<ApiSubscription> {
|
||||
return apiService.post<ApiSubscription>(`/subscriptions/${subscriptionId}/resume`)
|
||||
}
|
||||
|
||||
/**
|
||||
* Update payment method
|
||||
*/
|
||||
async updatePaymentMethod(subscriptionId: string, paymentMethodId: string): Promise<void> {
|
||||
await apiService.patch(`/subscriptions/${subscriptionId}/payment-method`, {
|
||||
paymentMethodId,
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Get subscription tiers with pricing
|
||||
*/
|
||||
async getSubscriptionTiers(): Promise<Array<{
|
||||
tier: string
|
||||
name: string
|
||||
monthlyPrice: number
|
||||
annualPrice: number
|
||||
features: string[]
|
||||
}>> {
|
||||
// This might be a static endpoint or hardcoded
|
||||
// Adjust based on actual API
|
||||
return [
|
||||
{
|
||||
tier: 'enthusiast',
|
||||
name: 'Enthusiast',
|
||||
monthlyPrice: 9.99,
|
||||
annualPrice: 99.99,
|
||||
features: [
|
||||
'Access to all films and series',
|
||||
'HD streaming',
|
||||
'Watch on 2 devices',
|
||||
'Cancel anytime',
|
||||
],
|
||||
},
|
||||
{
|
||||
tier: 'film-buff',
|
||||
name: 'Film Buff',
|
||||
monthlyPrice: 19.99,
|
||||
annualPrice: 199.99,
|
||||
features: [
|
||||
'Everything in Enthusiast',
|
||||
'4K streaming',
|
||||
'Watch on 4 devices',
|
||||
'Exclusive behind-the-scenes content',
|
||||
'Early access to new releases',
|
||||
],
|
||||
},
|
||||
{
|
||||
tier: 'cinephile',
|
||||
name: 'Cinephile',
|
||||
monthlyPrice: 29.99,
|
||||
annualPrice: 299.99,
|
||||
features: [
|
||||
'Everything in Film Buff',
|
||||
'Watch on unlimited devices',
|
||||
'Offline downloads',
|
||||
'Director commentary tracks',
|
||||
'Virtual festival access',
|
||||
'Support independent filmmakers',
|
||||
],
|
||||
},
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
export const subscriptionService = new SubscriptionService()
|
||||
412
src/stores/auth.ts
Normal file
412
src/stores/auth.ts
Normal file
@@ -0,0 +1,412 @@
|
||||
import { defineStore } from 'pinia'
|
||||
import { ref } from 'vue'
|
||||
import { authService } from '../services/auth.service'
|
||||
import type { ApiUser } from '../types/api'
|
||||
|
||||
export type AuthType = 'cognito' | 'nostr' | null
|
||||
|
||||
export interface AuthState {
|
||||
user: ApiUser | null
|
||||
authType: AuthType
|
||||
isAuthenticated: boolean
|
||||
nostrPubkey: string | null
|
||||
cognitoToken: string | null
|
||||
isLoading: boolean
|
||||
}
|
||||
|
||||
/**
|
||||
* Authentication Store
|
||||
* Manages user authentication state with dual Cognito/Nostr support
|
||||
*/
|
||||
export const useAuthStore = defineStore('auth', () => {
|
||||
// State
|
||||
const user = ref<ApiUser | null>(null)
|
||||
const authType = ref<AuthType>(null)
|
||||
const isAuthenticated = ref(false)
|
||||
const nostrPubkey = ref<string | null>(null)
|
||||
const cognitoToken = ref<string | null>(null)
|
||||
const isLoading = ref(false)
|
||||
|
||||
/**
|
||||
* Initialize auth state from stored tokens
|
||||
*/
|
||||
async function initialize() {
|
||||
isLoading.value = true
|
||||
|
||||
try {
|
||||
// Check for existing tokens
|
||||
const storedCognitoToken = sessionStorage.getItem('auth_token')
|
||||
const storedNostrToken = sessionStorage.getItem('nostr_token')
|
||||
|
||||
if (storedCognitoToken || storedNostrToken) {
|
||||
// Validate session and fetch user
|
||||
const isValid = await authService.validateSession()
|
||||
|
||||
if (isValid) {
|
||||
await fetchCurrentUser()
|
||||
|
||||
if (storedCognitoToken) {
|
||||
authType.value = 'cognito'
|
||||
cognitoToken.value = storedCognitoToken
|
||||
} else {
|
||||
authType.value = 'nostr'
|
||||
}
|
||||
|
||||
isAuthenticated.value = true
|
||||
} else {
|
||||
// Session invalid - clear auth
|
||||
await logout()
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to initialize auth:', error)
|
||||
await logout()
|
||||
} finally {
|
||||
isLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Login with email and password (Cognito)
|
||||
*/
|
||||
async function loginWithCognito(email: string, password: string) {
|
||||
isLoading.value = true
|
||||
|
||||
try {
|
||||
// Check if we're in development mode without backend
|
||||
const useMockData = import.meta.env.VITE_USE_MOCK_DATA === 'true' || import.meta.env.DEV
|
||||
|
||||
if (useMockData) {
|
||||
// Mock Cognito login for development
|
||||
console.log('🔧 Development mode: Using mock Cognito authentication')
|
||||
|
||||
// Simulate API delay
|
||||
await new Promise(resolve => setTimeout(resolve, 500))
|
||||
|
||||
// Create a mock user with active subscription
|
||||
const mockUser = {
|
||||
id: 'mock-user-' + email.split('@')[0],
|
||||
email: email,
|
||||
legalName: email.split('@')[0].charAt(0).toUpperCase() + email.split('@')[0].slice(1),
|
||||
createdAt: new Date().toISOString(),
|
||||
updatedAt: new Date().toISOString(),
|
||||
subscriptions: [{
|
||||
id: 'mock-sub-cinephile',
|
||||
userId: 'mock-user-' + email.split('@')[0],
|
||||
tier: 'cinephile' as const,
|
||||
status: 'active' as const,
|
||||
currentPeriodStart: new Date().toISOString(),
|
||||
currentPeriodEnd: new Date(Date.now() + 30 * 24 * 60 * 60 * 1000).toISOString(), // 30 days from now
|
||||
cancelAtPeriodEnd: false,
|
||||
stripePriceId: 'mock-price-cinephile',
|
||||
stripeCustomerId: 'mock-customer-' + email.split('@')[0],
|
||||
}],
|
||||
}
|
||||
|
||||
console.log('✅ Mock user created with Cinephile subscription (full access)')
|
||||
|
||||
cognitoToken.value = 'mock-jwt-token-' + Date.now()
|
||||
authType.value = 'cognito'
|
||||
user.value = mockUser
|
||||
isAuthenticated.value = true
|
||||
|
||||
// Store mock tokens
|
||||
sessionStorage.setItem('auth_token', cognitoToken.value)
|
||||
sessionStorage.setItem('refresh_token', 'mock-refresh-token')
|
||||
|
||||
return {
|
||||
accessToken: cognitoToken.value,
|
||||
idToken: 'mock-id-token',
|
||||
refreshToken: 'mock-refresh-token',
|
||||
expiresIn: 3600,
|
||||
}
|
||||
}
|
||||
|
||||
// Real API call
|
||||
const response = await authService.login({ email, password })
|
||||
|
||||
cognitoToken.value = response.accessToken
|
||||
authType.value = 'cognito'
|
||||
|
||||
await fetchCurrentUser()
|
||||
|
||||
isAuthenticated.value = true
|
||||
|
||||
return response
|
||||
} catch (error: any) {
|
||||
// Provide helpful error message
|
||||
if (error.message?.includes('Unable to connect')) {
|
||||
throw new Error(
|
||||
'Backend API not available. To use real authentication, start the backend server and set VITE_USE_MOCK_DATA=false in .env'
|
||||
)
|
||||
}
|
||||
throw error
|
||||
} finally {
|
||||
isLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Login with Nostr signature
|
||||
*/
|
||||
async function loginWithNostr(pubkey: string, signature: string, event: any) {
|
||||
isLoading.value = true
|
||||
|
||||
try {
|
||||
// Check if we're in development mode without backend
|
||||
const useMockData = import.meta.env.VITE_USE_MOCK_DATA === 'true' || import.meta.env.DEV
|
||||
|
||||
if (useMockData) {
|
||||
// Mock Nostr login for development
|
||||
console.log('🔧 Development mode: Using mock Nostr authentication')
|
||||
|
||||
// Simulate API delay
|
||||
await new Promise(resolve => setTimeout(resolve, 500))
|
||||
|
||||
// Create a mock Nostr user with active subscription
|
||||
const mockUser = {
|
||||
id: 'mock-nostr-user-' + pubkey.slice(0, 8),
|
||||
email: `${pubkey.slice(0, 8)}@nostr.local`,
|
||||
legalName: 'Nostr User',
|
||||
nostrPubkey: pubkey,
|
||||
createdAt: new Date().toISOString(),
|
||||
updatedAt: new Date().toISOString(),
|
||||
subscriptions: [{
|
||||
id: 'mock-sub-cinephile',
|
||||
userId: 'mock-nostr-user-' + pubkey.slice(0, 8),
|
||||
tier: 'cinephile' as const,
|
||||
status: 'active' as const,
|
||||
currentPeriodStart: new Date().toISOString(),
|
||||
currentPeriodEnd: new Date(Date.now() + 30 * 24 * 60 * 60 * 1000).toISOString(), // 30 days from now
|
||||
cancelAtPeriodEnd: false,
|
||||
stripePriceId: 'mock-price-cinephile',
|
||||
stripeCustomerId: 'mock-customer-' + pubkey.slice(0, 8),
|
||||
}],
|
||||
}
|
||||
|
||||
console.log('✅ Mock Nostr user created with Cinephile subscription (full access)')
|
||||
console.log('📝 Nostr Pubkey:', pubkey)
|
||||
|
||||
nostrPubkey.value = pubkey
|
||||
authType.value = 'nostr'
|
||||
user.value = mockUser
|
||||
isAuthenticated.value = true
|
||||
|
||||
// Store mock session
|
||||
sessionStorage.setItem('nostr_token', 'mock-nostr-token-' + pubkey.slice(0, 16))
|
||||
|
||||
return {
|
||||
token: 'mock-nostr-token',
|
||||
user: mockUser,
|
||||
}
|
||||
}
|
||||
|
||||
// Real API call
|
||||
const response = await authService.createNostrSession({
|
||||
pubkey,
|
||||
signature,
|
||||
event,
|
||||
})
|
||||
|
||||
nostrPubkey.value = pubkey
|
||||
authType.value = 'nostr'
|
||||
user.value = response.user
|
||||
isAuthenticated.value = true
|
||||
|
||||
return response
|
||||
} catch (error: any) {
|
||||
// Provide helpful error message
|
||||
if (error.message?.includes('Unable to connect')) {
|
||||
throw new Error(
|
||||
'Backend API not available. To use real Nostr authentication, start the backend server and set VITE_USE_MOCK_DATA=false in .env'
|
||||
)
|
||||
}
|
||||
throw error
|
||||
} finally {
|
||||
isLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Register new user
|
||||
*/
|
||||
async function register(email: string, password: string, legalName: string) {
|
||||
isLoading.value = true
|
||||
|
||||
try {
|
||||
// Check if we're in development mode without backend
|
||||
const useMockData = import.meta.env.VITE_USE_MOCK_DATA === 'true' || import.meta.env.DEV
|
||||
|
||||
if (useMockData) {
|
||||
// Mock registration for development
|
||||
console.log('🔧 Development mode: Using mock registration')
|
||||
|
||||
// Simulate API delay
|
||||
await new Promise(resolve => setTimeout(resolve, 500))
|
||||
|
||||
// Create a mock user with active subscription
|
||||
const mockUser = {
|
||||
id: 'mock-user-' + email.split('@')[0],
|
||||
email: email,
|
||||
legalName: legalName,
|
||||
createdAt: new Date().toISOString(),
|
||||
updatedAt: new Date().toISOString(),
|
||||
subscriptions: [{
|
||||
id: 'mock-sub-cinephile',
|
||||
userId: 'mock-user-' + email.split('@')[0],
|
||||
tier: 'cinephile' as const,
|
||||
status: 'active' as const,
|
||||
currentPeriodStart: new Date().toISOString(),
|
||||
currentPeriodEnd: new Date(Date.now() + 30 * 24 * 60 * 60 * 1000).toISOString(), // 30 days from now
|
||||
cancelAtPeriodEnd: false,
|
||||
stripePriceId: 'mock-price-cinephile',
|
||||
stripeCustomerId: 'mock-customer-' + email.split('@')[0],
|
||||
}],
|
||||
}
|
||||
|
||||
console.log('✅ Mock user registered with Cinephile subscription (full access)')
|
||||
|
||||
cognitoToken.value = 'mock-jwt-token-' + Date.now()
|
||||
authType.value = 'cognito'
|
||||
user.value = mockUser
|
||||
isAuthenticated.value = true
|
||||
|
||||
// Store mock tokens
|
||||
sessionStorage.setItem('auth_token', cognitoToken.value)
|
||||
sessionStorage.setItem('refresh_token', 'mock-refresh-token')
|
||||
|
||||
return {
|
||||
accessToken: cognitoToken.value,
|
||||
idToken: 'mock-id-token',
|
||||
refreshToken: 'mock-refresh-token',
|
||||
expiresIn: 3600,
|
||||
}
|
||||
}
|
||||
|
||||
// Real API call
|
||||
const response = await authService.register({
|
||||
email,
|
||||
password,
|
||||
legalName,
|
||||
})
|
||||
|
||||
cognitoToken.value = response.accessToken
|
||||
authType.value = 'cognito'
|
||||
|
||||
await fetchCurrentUser()
|
||||
|
||||
isAuthenticated.value = true
|
||||
|
||||
return response
|
||||
} catch (error: any) {
|
||||
// Provide helpful error message
|
||||
if (error.message?.includes('Unable to connect')) {
|
||||
throw new Error(
|
||||
'Backend API not available. To use real authentication, start the backend server and set VITE_USE_MOCK_DATA=false in .env'
|
||||
)
|
||||
}
|
||||
throw error
|
||||
} finally {
|
||||
isLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch current user data
|
||||
*/
|
||||
async function fetchCurrentUser() {
|
||||
try {
|
||||
const userData = await authService.getCurrentUser()
|
||||
user.value = userData
|
||||
|
||||
if (userData.nostrPubkey) {
|
||||
nostrPubkey.value = userData.nostrPubkey
|
||||
}
|
||||
|
||||
return userData
|
||||
} catch (error) {
|
||||
console.error('Failed to fetch user:', error)
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Logout user
|
||||
*/
|
||||
async function logout() {
|
||||
await authService.logout()
|
||||
|
||||
user.value = null
|
||||
authType.value = null
|
||||
isAuthenticated.value = false
|
||||
nostrPubkey.value = null
|
||||
cognitoToken.value = null
|
||||
}
|
||||
|
||||
/**
|
||||
* Link Nostr pubkey to account
|
||||
*/
|
||||
async function linkNostr(pubkey: string, signature: string) {
|
||||
try {
|
||||
const updatedUser = await authService.linkNostrPubkey(pubkey, signature)
|
||||
user.value = updatedUser
|
||||
nostrPubkey.value = pubkey
|
||||
return updatedUser
|
||||
} catch (error) {
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Unlink Nostr pubkey from account
|
||||
*/
|
||||
async function unlinkNostr() {
|
||||
try {
|
||||
const updatedUser = await authService.unlinkNostrPubkey()
|
||||
user.value = updatedUser
|
||||
nostrPubkey.value = null
|
||||
return updatedUser
|
||||
} catch (error) {
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if user is filmmaker
|
||||
*/
|
||||
function isFilmmaker(): boolean {
|
||||
return !!user.value?.filmmaker
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if user has active subscription
|
||||
*/
|
||||
function hasActiveSubscription(): boolean {
|
||||
if (!user.value?.subscriptions) return false
|
||||
return user.value.subscriptions.some((sub) => sub.status === 'active')
|
||||
}
|
||||
|
||||
return {
|
||||
// State
|
||||
user,
|
||||
authType,
|
||||
isAuthenticated,
|
||||
nostrPubkey,
|
||||
cognitoToken,
|
||||
isLoading,
|
||||
|
||||
// Actions
|
||||
initialize,
|
||||
loginWithCognito,
|
||||
loginWithNostr,
|
||||
register,
|
||||
fetchCurrentUser,
|
||||
logout,
|
||||
linkNostr,
|
||||
unlinkNostr,
|
||||
|
||||
// Getters
|
||||
isFilmmaker,
|
||||
hasActiveSubscription,
|
||||
}
|
||||
})
|
||||
@@ -2,6 +2,10 @@ import { defineStore } from 'pinia'
|
||||
import { ref } from 'vue'
|
||||
import type { Content } from '../types/content'
|
||||
import { indeeHubFilms, bitcoinFilms, documentaries, dramas } from '../data/indeeHubFilms'
|
||||
import { contentService } from '../services/content.service'
|
||||
import { mapApiProjectsToContents } from '../utils/mappers'
|
||||
|
||||
const USE_MOCK_DATA = import.meta.env.VITE_USE_MOCK_DATA === 'true' || import.meta.env.DEV
|
||||
|
||||
export const useContentStore = defineStore('content', () => {
|
||||
const featuredContent = ref<Content | null>(null)
|
||||
@@ -17,40 +21,102 @@ export const useContentStore = defineStore('content', () => {
|
||||
const loading = ref(false)
|
||||
const error = ref<string | null>(null)
|
||||
|
||||
/**
|
||||
* Fetch content from API
|
||||
*/
|
||||
async function fetchContentFromApi() {
|
||||
try {
|
||||
// Fetch all published projects
|
||||
const projects = await contentService.getProjects({ status: 'published' })
|
||||
|
||||
if (projects.length === 0) {
|
||||
throw new Error('No content available')
|
||||
}
|
||||
|
||||
// Map API data to content format
|
||||
const allContent = mapApiProjectsToContents(projects)
|
||||
|
||||
// Set featured content (first film project or first project)
|
||||
const featuredFilm = allContent.find(c => c.type === 'film') || allContent[0]
|
||||
featuredContent.value = featuredFilm
|
||||
|
||||
// Organize into rows
|
||||
const films = allContent.filter(c => c.type === 'film')
|
||||
const bitcoinContent = allContent.filter(c =>
|
||||
c.categories?.some(cat => cat.toLowerCase().includes('bitcoin'))
|
||||
)
|
||||
const docs = allContent.filter(c =>
|
||||
c.categories?.some(cat => cat.toLowerCase().includes('documentary'))
|
||||
)
|
||||
const dramaContent = allContent.filter(c =>
|
||||
c.categories?.some(cat => cat.toLowerCase().includes('drama'))
|
||||
)
|
||||
|
||||
contentRows.value = {
|
||||
featured: allContent.slice(0, 10),
|
||||
newReleases: films.slice(0, 8),
|
||||
bitcoin: bitcoinContent.length > 0 ? bitcoinContent : films.slice(0, 6),
|
||||
documentaries: docs.length > 0 ? docs : films.slice(0, 6),
|
||||
dramas: dramaContent.length > 0 ? dramaContent : films.slice(0, 6),
|
||||
independent: films.slice(0, 10)
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('API fetch failed:', err)
|
||||
throw err
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch content from mock data
|
||||
*/
|
||||
function fetchContentFromMock() {
|
||||
// Set featured content immediately - God Bless Bitcoin
|
||||
const godBlessBitcoin = bitcoinFilms.find(f => f.title === 'God Bless Bitcoin') || bitcoinFilms[0]
|
||||
if (godBlessBitcoin) {
|
||||
featuredContent.value = {
|
||||
...godBlessBitcoin,
|
||||
backdrop: '/images/god-bless-bitcoin-backdrop.jpg'
|
||||
}
|
||||
} else {
|
||||
featuredContent.value = indeeHubFilms[0]
|
||||
}
|
||||
|
||||
// Organize content into rows
|
||||
contentRows.value = {
|
||||
featured: indeeHubFilms.slice(0, 10),
|
||||
newReleases: indeeHubFilms.slice(0, 8).reverse(),
|
||||
bitcoin: bitcoinFilms,
|
||||
documentaries: documentaries.slice(0, 10),
|
||||
dramas: dramas.slice(0, 10),
|
||||
independent: indeeHubFilms.filter(f =>
|
||||
!f.categories.includes('Bitcoin') && !f.categories.includes('Documentary')
|
||||
).slice(0, 10)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Main fetch content method
|
||||
*/
|
||||
async function fetchContent() {
|
||||
loading.value = true
|
||||
error.value = null
|
||||
|
||||
try {
|
||||
// Set featured content immediately - God Bless Bitcoin
|
||||
const godBlessBitcoin = bitcoinFilms.find(f => f.title === 'God Bless Bitcoin') || bitcoinFilms[0]
|
||||
if (godBlessBitcoin) {
|
||||
// Override backdrop to use the public folder image
|
||||
featuredContent.value = {
|
||||
...godBlessBitcoin,
|
||||
backdrop: '/images/god-bless-bitcoin-backdrop.jpg'
|
||||
}
|
||||
if (USE_MOCK_DATA) {
|
||||
// Use mock data in development or when flag is set
|
||||
await new Promise(resolve => setTimeout(resolve, 100))
|
||||
fetchContentFromMock()
|
||||
} else {
|
||||
featuredContent.value = indeeHubFilms[0]
|
||||
// Fetch from API
|
||||
await fetchContentFromApi()
|
||||
}
|
||||
} catch (e: any) {
|
||||
error.value = e.message || 'Failed to load content'
|
||||
console.error('Content fetch error:', e)
|
||||
|
||||
// Small delay for content rows only
|
||||
await new Promise(resolve => setTimeout(resolve, 100))
|
||||
|
||||
// Organize content into rows
|
||||
contentRows.value = {
|
||||
featured: indeeHubFilms.slice(0, 10),
|
||||
newReleases: indeeHubFilms.slice(0, 8).reverse(),
|
||||
bitcoin: bitcoinFilms,
|
||||
documentaries: documentaries.slice(0, 10),
|
||||
dramas: dramas.slice(0, 10),
|
||||
independent: indeeHubFilms.filter(f =>
|
||||
!f.categories.includes('Bitcoin') && !f.categories.includes('Documentary')
|
||||
).slice(0, 10)
|
||||
}
|
||||
} catch (e) {
|
||||
error.value = 'Failed to load content'
|
||||
console.error(e)
|
||||
// Fallback to mock data on error
|
||||
console.log('Falling back to mock data...')
|
||||
fetchContentFromMock()
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
|
||||
171
src/types/api.ts
Normal file
171
src/types/api.ts
Normal file
@@ -0,0 +1,171 @@
|
||||
/**
|
||||
* API Types
|
||||
* TypeScript interfaces matching the indeehub-api backend
|
||||
*/
|
||||
|
||||
// Core API Models
|
||||
export interface ApiProject {
|
||||
id: string
|
||||
name: string
|
||||
title: string
|
||||
slug: string
|
||||
synopsis: string
|
||||
status: 'draft' | 'published' | 'rejected'
|
||||
type: 'film' | 'episodic' | 'music-video'
|
||||
format: string
|
||||
category: string
|
||||
poster: string
|
||||
trailer: string
|
||||
rentalPrice: number
|
||||
releaseDate: string
|
||||
createdAt: string
|
||||
updatedAt: string
|
||||
genres?: ApiGenre[]
|
||||
filmmaker?: ApiFilmmaker
|
||||
}
|
||||
|
||||
export interface ApiContent {
|
||||
id: string
|
||||
projectId: string
|
||||
seasonId?: string
|
||||
title: string
|
||||
synopsis: string
|
||||
file: string
|
||||
season?: number
|
||||
order?: number
|
||||
status: 'processing' | 'ready' | 'failed'
|
||||
rentalPrice: number
|
||||
poster?: string
|
||||
trailer?: string
|
||||
drmContentId?: string
|
||||
drmMediaId?: string
|
||||
metadata?: Record<string, any>
|
||||
releaseDate?: string
|
||||
isRssEnabled: boolean
|
||||
createdAt: string
|
||||
updatedAt: string
|
||||
}
|
||||
|
||||
export interface ApiSeason {
|
||||
id: string
|
||||
projectId: string
|
||||
seasonNumber: number
|
||||
title: string
|
||||
description: string
|
||||
rentalPrice: number
|
||||
isActive: boolean
|
||||
contents?: ApiContent[]
|
||||
}
|
||||
|
||||
export interface ApiUser {
|
||||
id: string
|
||||
email: string
|
||||
legalName: string
|
||||
profilePictureUrl?: string
|
||||
cognitoId?: string
|
||||
nostrPubkey?: string
|
||||
createdAt: string
|
||||
updatedAt: string
|
||||
filmmaker?: ApiFilmmaker
|
||||
subscriptions?: ApiSubscription[]
|
||||
}
|
||||
|
||||
export interface ApiFilmmaker {
|
||||
id: string
|
||||
userId: string
|
||||
professionalName: string
|
||||
bio?: string
|
||||
profilePictureUrl?: string
|
||||
}
|
||||
|
||||
export interface ApiSubscription {
|
||||
id: string
|
||||
userId: string
|
||||
tier: 'enthusiast' | 'film-buff' | 'cinephile'
|
||||
status: 'active' | 'cancelled' | 'expired'
|
||||
currentPeriodStart: string
|
||||
currentPeriodEnd: string
|
||||
cancelAtPeriodEnd: boolean
|
||||
stripePriceId?: string
|
||||
stripeCustomerId?: string
|
||||
}
|
||||
|
||||
export interface ApiRent {
|
||||
id: string
|
||||
userId: string
|
||||
contentId: string
|
||||
expiresAt: string
|
||||
createdAt: string
|
||||
content?: ApiContent
|
||||
}
|
||||
|
||||
export interface ApiGenre {
|
||||
id: string
|
||||
name: string
|
||||
slug: string
|
||||
}
|
||||
|
||||
export interface ApiFestival {
|
||||
id: string
|
||||
name: string
|
||||
slug: string
|
||||
}
|
||||
|
||||
export interface ApiAward {
|
||||
id: string
|
||||
name: string
|
||||
slug: string
|
||||
}
|
||||
|
||||
// Auth Types
|
||||
export interface LoginCredentials {
|
||||
email: string
|
||||
password: string
|
||||
}
|
||||
|
||||
export interface RegisterData {
|
||||
email: string
|
||||
password: string
|
||||
legalName: string
|
||||
}
|
||||
|
||||
export interface AuthResponse {
|
||||
accessToken: string
|
||||
idToken: string
|
||||
refreshToken: string
|
||||
expiresIn: number
|
||||
}
|
||||
|
||||
export interface NostrSessionRequest {
|
||||
pubkey: string
|
||||
signature: string
|
||||
event: Record<string, any>
|
||||
}
|
||||
|
||||
export interface NostrSessionResponse {
|
||||
token: string
|
||||
user: ApiUser
|
||||
}
|
||||
|
||||
// API Response Wrappers
|
||||
export interface ApiResponse<T> {
|
||||
data: T
|
||||
message?: string
|
||||
success: boolean
|
||||
}
|
||||
|
||||
export interface PaginatedResponse<T> {
|
||||
data: T[]
|
||||
total: number
|
||||
page: number
|
||||
limit: number
|
||||
hasMore: boolean
|
||||
}
|
||||
|
||||
// Error Types
|
||||
export interface ApiError {
|
||||
message: string
|
||||
statusCode: number
|
||||
error?: string
|
||||
details?: any
|
||||
}
|
||||
@@ -14,6 +14,14 @@ export interface Content {
|
||||
nostrEventId?: string
|
||||
views?: number
|
||||
categories: string[]
|
||||
|
||||
// API integration fields
|
||||
slug?: string
|
||||
rentalPrice?: number
|
||||
status?: string
|
||||
drmEnabled?: boolean
|
||||
streamingUrl?: string
|
||||
apiData?: any
|
||||
}
|
||||
|
||||
// Nostr event types
|
||||
|
||||
97
src/utils/mappers.ts
Normal file
97
src/utils/mappers.ts
Normal file
@@ -0,0 +1,97 @@
|
||||
import type { ApiProject, ApiContent } from '../types/api'
|
||||
import type { Content } from '../types/content'
|
||||
import { apiService } from '../services/api.service'
|
||||
|
||||
/**
|
||||
* Data Mappers
|
||||
* Transform API models to frontend models
|
||||
*/
|
||||
|
||||
/**
|
||||
* Extract year from date string
|
||||
*/
|
||||
function extractYear(dateString?: string): number | undefined {
|
||||
if (!dateString) return undefined
|
||||
const year = new Date(dateString).getFullYear()
|
||||
return isNaN(year) ? undefined : year
|
||||
}
|
||||
|
||||
/**
|
||||
* Map API Project to Frontend Content
|
||||
*/
|
||||
export function mapApiProjectToContent(project: ApiProject): Content {
|
||||
return {
|
||||
id: project.id,
|
||||
title: project.title,
|
||||
description: project.synopsis || '',
|
||||
thumbnail: apiService.getCdnUrl(project.poster),
|
||||
backdrop: apiService.getCdnUrl(project.poster),
|
||||
type: project.type === 'episodic' ? 'series' : 'film',
|
||||
rating: project.format || undefined,
|
||||
releaseYear: extractYear(project.releaseDate),
|
||||
duration: undefined, // Requires content metadata
|
||||
categories: project.genres?.map((g) => g.name) || [],
|
||||
|
||||
// Additional API fields
|
||||
slug: project.slug,
|
||||
rentalPrice: project.rentalPrice,
|
||||
status: project.status,
|
||||
apiData: project, // Store full API data for reference
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Map array of API Projects to Frontend Contents
|
||||
*/
|
||||
export function mapApiProjectsToContents(projects: ApiProject[]): Content[] {
|
||||
return projects.map(mapApiProjectToContent)
|
||||
}
|
||||
|
||||
/**
|
||||
* Map API Content to Frontend Content
|
||||
*/
|
||||
export function mapApiContentToContent(content: ApiContent, project?: ApiProject): Content {
|
||||
return {
|
||||
id: content.id,
|
||||
title: content.title || project?.title || '',
|
||||
description: content.synopsis || project?.synopsis || '',
|
||||
thumbnail: apiService.getCdnUrl(content.poster || project?.poster || ''),
|
||||
backdrop: apiService.getCdnUrl(content.poster || project?.poster || ''),
|
||||
type: project?.type === 'episodic' ? 'series' : 'film',
|
||||
rating: project?.format,
|
||||
releaseYear: extractYear(content.releaseDate || project?.releaseDate),
|
||||
duration: content.metadata?.duration as number | undefined,
|
||||
categories: project?.genres?.map((g) => g.name) || [],
|
||||
|
||||
// Additional fields
|
||||
slug: project?.slug || '',
|
||||
rentalPrice: content.rentalPrice,
|
||||
status: content.status,
|
||||
drmEnabled: !!content.drmContentId,
|
||||
apiData: content,
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Map array of API Contents to Frontend Contents
|
||||
*/
|
||||
export function mapApiContentsToContents(contents: ApiContent[], project?: ApiProject): Content[] {
|
||||
return contents.map((content) => mapApiContentToContent(content, project))
|
||||
}
|
||||
|
||||
/**
|
||||
* Get content identifier for Nostr events
|
||||
* Creates a unique identifier for external content references
|
||||
*/
|
||||
export function getNostrContentIdentifier(contentId: string): string {
|
||||
const baseUrl = import.meta.env.VITE_APP_URL || window.location.origin
|
||||
return `${baseUrl}/content/${contentId}`
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse Nostr content identifier to get content ID
|
||||
*/
|
||||
export function parseNostrContentIdentifier(identifier: string): string | null {
|
||||
const match = identifier.match(/\/content\/([^/]+)$/)
|
||||
return match ? match[1] : null
|
||||
}
|
||||
@@ -4,84 +4,7 @@
|
||||
|
||||
<div class="browse-view">
|
||||
<!-- Header / Navigation -->
|
||||
<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">
|
||||
<!-- Logo + Navigation (Left Side) -->
|
||||
<div class="flex items-center gap-10">
|
||||
<img src="/assets/images/logo.svg" alt="IndeedHub" class="h-10 ml-2 md:ml-0" />
|
||||
|
||||
<!-- Navigation - Next to Logo on Desktop -->
|
||||
<nav class="hidden md:flex items-center gap-3">
|
||||
<a href="#" class="nav-button-active">Films</a>
|
||||
<a href="#" class="nav-button">Series</a>
|
||||
<a href="#" class="nav-button">Creators</a>
|
||||
<a href="#" class="nav-button">My List</a>
|
||||
</nav>
|
||||
</div>
|
||||
|
||||
<!-- Right Side Actions -->
|
||||
<div class="flex items-center gap-4">
|
||||
<!-- Search -->
|
||||
<button class="hidden md:block p-2 hover:bg-white/10 rounded-lg transition-colors" @click="toggleSearch">
|
||||
<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="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z" />
|
||||
</svg>
|
||||
</button>
|
||||
|
||||
<!-- Profile Dropdown -->
|
||||
<div class="hidden md:block relative profile-dropdown">
|
||||
<button
|
||||
@click="toggleProfileMenu"
|
||||
class="profile-button flex items-center gap-2"
|
||||
>
|
||||
<div class="profile-avatar">
|
||||
<span>D</span>
|
||||
</div>
|
||||
<span class="text-white text-sm font-medium">Dorian</span>
|
||||
<svg class="w-4 h-4 transition-transform" :class="{ 'rotate-180': profileMenuOpen }" 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>
|
||||
|
||||
<!-- Dropdown Menu -->
|
||||
<div v-if="profileMenuOpen" class="profile-menu absolute right-0 mt-2 w-48">
|
||||
<div class="floating-glass-header py-2 rounded-xl">
|
||||
<a href="#" class="profile-menu-item flex items-center gap-3 px-4 py-2.5">
|
||||
<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>
|
||||
</a>
|
||||
<a href="#" class="profile-menu-item flex items-center gap-3 px-4 py-2.5">
|
||||
<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="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.065 2.572c1.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.572 1.065c-.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.065-2.572c-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>Settings</span>
|
||||
</a>
|
||||
<div class="border-t border-white/10 my-1"></div>
|
||||
<a href="#" class="profile-menu-item flex items-center gap-3 px-4 py-2.5 text-red-400">
|
||||
<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>
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Mobile User Avatar + Name (No Dropdown) -->
|
||||
<div class="md:hidden flex items-center gap-2 mr-2">
|
||||
<div class="profile-avatar">
|
||||
<span>D</span>
|
||||
</div>
|
||||
<span class="text-white text-sm font-medium">Dorian</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
<AppHeader @openAuth="showAuthModal = true" />
|
||||
|
||||
<!-- Hero / Featured Content -->
|
||||
<section class="relative h-[56vh] md:h-[61vh] overflow-hidden">
|
||||
@@ -119,13 +42,13 @@
|
||||
|
||||
<!-- Action Buttons -->
|
||||
<div class="flex items-center gap-2.5 md:gap-3 pt-1.5 md:pt-2">
|
||||
<button class="hero-play-button flex items-center gap-2">
|
||||
<button @click="handlePlayClick" class="hero-play-button flex items-center gap-2">
|
||||
<svg class="w-4 h-4 md:w-5 md:h-5" fill="currentColor" viewBox="0 0 24 24">
|
||||
<path d="M8 5v14l11-7z"/>
|
||||
</svg>
|
||||
Play
|
||||
</button>
|
||||
<button class="hero-info-button flex items-center gap-2">
|
||||
<button @click="handleInfoClick" class="hero-info-button flex items-center gap-2">
|
||||
<svg class="w-4 h-4 md:w-5 md:h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
|
||||
</svg>
|
||||
@@ -183,18 +106,51 @@
|
||||
/>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- Modals -->
|
||||
<AuthModal
|
||||
:isOpen="showAuthModal"
|
||||
@close="showAuthModal = false"
|
||||
@success="handleAuthSuccess"
|
||||
/>
|
||||
|
||||
<ContentDetailModal
|
||||
:isOpen="showDetailModal"
|
||||
:content="selectedContent"
|
||||
@close="showDetailModal = false"
|
||||
@openAuth="showAuthModal = true"
|
||||
/>
|
||||
|
||||
<!-- Hero-only modals (for direct Play button) -->
|
||||
<SubscriptionModal
|
||||
:isOpen="showSubscriptionModal"
|
||||
@close="showSubscriptionModal = false"
|
||||
@success="handleSubscriptionSuccess"
|
||||
/>
|
||||
|
||||
<VideoPlayer
|
||||
:isOpen="showVideoPlayer"
|
||||
:content="selectedContent"
|
||||
@close="showVideoPlayer = false"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted, onUnmounted, computed } from 'vue'
|
||||
import { ref, onMounted, computed } from 'vue'
|
||||
import ContentRow from '../components/ContentRow.vue'
|
||||
import SplashIntro from '../components/SplashIntro.vue'
|
||||
import AppHeader from '../components/AppHeader.vue'
|
||||
import AuthModal from '../components/AuthModal.vue'
|
||||
import ContentDetailModal from '../components/ContentDetailModal.vue'
|
||||
import SubscriptionModal from '../components/SubscriptionModal.vue'
|
||||
import VideoPlayer from '../components/VideoPlayer.vue'
|
||||
import { useContentStore } from '../stores/content'
|
||||
import { useAuth } from '../composables/useAuth'
|
||||
import type { Content } from '../types/content'
|
||||
|
||||
const contentStore = useContentStore()
|
||||
const scrolled = ref(false)
|
||||
const { isAuthenticated, hasActiveSubscription } = useAuth()
|
||||
|
||||
const featuredContent = computed(() => contentStore.featuredContent)
|
||||
const featuredFilms = computed(() => contentStore.contentRows.featured)
|
||||
@@ -204,46 +160,52 @@ const independentCinema = computed(() => contentStore.contentRows.independent)
|
||||
const dramas = computed(() => contentStore.contentRows.dramas)
|
||||
const documentaries = computed(() => contentStore.contentRows.documentaries)
|
||||
|
||||
const profileMenuOpen = ref(false)
|
||||
|
||||
const handleScroll = () => {
|
||||
// Calculate 30% of the page height
|
||||
const scrollThreshold = document.documentElement.scrollHeight * 0.3
|
||||
scrolled.value = window.scrollY > scrollThreshold
|
||||
}
|
||||
|
||||
const toggleSearch = () => {
|
||||
// TODO: Implement search modal
|
||||
console.log('Search clicked')
|
||||
}
|
||||
|
||||
const toggleProfileMenu = () => {
|
||||
profileMenuOpen.value = !profileMenuOpen.value
|
||||
}
|
||||
|
||||
// Close profile menu when clicking outside
|
||||
const handleClickOutside = (event: MouseEvent) => {
|
||||
const dropdown = document.querySelector('.profile-dropdown')
|
||||
if (dropdown && !dropdown.contains(event.target as Node)) {
|
||||
profileMenuOpen.value = false
|
||||
}
|
||||
}
|
||||
const showAuthModal = ref(false)
|
||||
const showDetailModal = ref(false)
|
||||
const showSubscriptionModal = ref(false)
|
||||
const showVideoPlayer = ref(false)
|
||||
const selectedContent = ref<Content | null>(null)
|
||||
|
||||
// Content card click -> always open detail modal
|
||||
const handleContentClick = (content: Content) => {
|
||||
console.log('Content clicked:', content)
|
||||
// TODO: Navigate to content detail page
|
||||
selectedContent.value = content
|
||||
showDetailModal.value = true
|
||||
}
|
||||
|
||||
// Hero Play button -> direct play flow (skips detail modal)
|
||||
const handlePlayClick = () => {
|
||||
if (!isAuthenticated.value) {
|
||||
showAuthModal.value = true
|
||||
return
|
||||
}
|
||||
|
||||
if (hasActiveSubscription.value) {
|
||||
selectedContent.value = featuredContent.value
|
||||
showVideoPlayer.value = true
|
||||
return
|
||||
}
|
||||
|
||||
// No subscription - show subscription modal
|
||||
showSubscriptionModal.value = true
|
||||
}
|
||||
|
||||
// Hero More Info button -> open detail modal for featured content
|
||||
const handleInfoClick = () => {
|
||||
selectedContent.value = featuredContent.value
|
||||
showDetailModal.value = true
|
||||
}
|
||||
|
||||
function handleAuthSuccess() {
|
||||
showAuthModal.value = false
|
||||
}
|
||||
|
||||
function handleSubscriptionSuccess() {
|
||||
showSubscriptionModal.value = false
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
window.addEventListener('scroll', handleScroll)
|
||||
window.addEventListener('click', handleClickOutside)
|
||||
contentStore.fetchContent()
|
||||
})
|
||||
|
||||
onUnmounted(() => {
|
||||
window.removeEventListener('scroll', handleScroll)
|
||||
window.removeEventListener('click', handleClickOutside)
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
@@ -252,143 +214,13 @@ onUnmounted(() => {
|
||||
overflow-x: hidden;
|
||||
}
|
||||
|
||||
.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 - EXACT from Archy Onboarding */
|
||||
.nav-button {
|
||||
position: relative;
|
||||
padding: 12px 24px;
|
||||
font-size: 16px;
|
||||
font-weight: 500;
|
||||
line-height: 1.4;
|
||||
border-radius: 16px;
|
||||
background: rgba(0, 0, 0, 0.25);
|
||||
color: rgba(255, 255, 255, 0.96);
|
||||
box-shadow:
|
||||
0 8px 24px rgba(0, 0, 0, 0.45),
|
||||
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-block;
|
||||
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 12px 32px rgba(0, 0, 0, 0.6),
|
||||
inset 0 1px 0 rgba(255, 255, 255, 0.25);
|
||||
}
|
||||
|
||||
.nav-button:hover::before {
|
||||
background: linear-gradient(135deg, rgba(255, 255, 255, 0.3), transparent);
|
||||
}
|
||||
|
||||
.nav-button-active {
|
||||
position: relative;
|
||||
padding: 12px 24px;
|
||||
font-size: 16px;
|
||||
font-weight: 600;
|
||||
line-height: 1.4;
|
||||
border-radius: 16px;
|
||||
background: rgba(0, 0, 0, 0.35);
|
||||
color: rgba(255, 255, 255, 1);
|
||||
box-shadow:
|
||||
0 12px 32px rgba(0, 0, 0, 0.6),
|
||||
inset 0 1px 0 rgba(255, 255, 255, 0.25);
|
||||
backdrop-filter: blur(24px);
|
||||
-webkit-backdrop-filter: blur(24px);
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
transition: all 0.3s ease;
|
||||
text-decoration: none;
|
||||
display: inline-block;
|
||||
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;
|
||||
}
|
||||
|
||||
.nav-button-active:hover {
|
||||
transform: translateY(-2px);
|
||||
background: rgba(0, 0, 0, 0.40);
|
||||
box-shadow:
|
||||
0 12px 32px rgba(0, 0, 0, 0.6),
|
||||
inset 0 1px 0 rgba(255, 255, 255, 0.3);
|
||||
}
|
||||
|
||||
/* Mobile Tab Bar Styles */
|
||||
.nav-tab {
|
||||
color: rgba(255, 255, 255, 0.7);
|
||||
text-decoration: none;
|
||||
transition: all 0.3s ease;
|
||||
padding: 8px 12px;
|
||||
border-radius: 12px;
|
||||
}
|
||||
|
||||
.nav-tab:active {
|
||||
background: rgba(255, 255, 255, 0.1);
|
||||
}
|
||||
|
||||
.nav-tab-active {
|
||||
color: rgba(255, 255, 255, 1);
|
||||
text-decoration: none;
|
||||
transition: all 0.3s ease;
|
||||
padding: 8px 12px;
|
||||
border-radius: 12px;
|
||||
background: rgba(255, 255, 255, 0.1);
|
||||
}
|
||||
|
||||
/* Hero Title Styles */
|
||||
.hero-title {
|
||||
background: linear-gradient(to right, #fafafa, #9ca3af);
|
||||
-webkit-background-clip: text;
|
||||
background-clip: text;
|
||||
-webkit-text-fill-color: transparent;
|
||||
letter-spacing: 0.05em; /* 5% character spacing */
|
||||
letter-spacing: 0.05em;
|
||||
}
|
||||
|
||||
/* Hero Button Styles */
|
||||
@@ -487,59 +319,4 @@ onUnmounted(() => {
|
||||
font-size: 16px;
|
||||
}
|
||||
}
|
||||
|
||||
/* Profile Dropdown Styles */
|
||||
.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>
|
||||
|
||||
217
src/views/Library.vue
Normal file
217
src/views/Library.vue
Normal file
@@ -0,0 +1,217 @@
|
||||
<template>
|
||||
<div class="library-view">
|
||||
<!-- Header -->
|
||||
<AppHeader @openAuth="showAuthModal = true" />
|
||||
|
||||
<!-- Main Content -->
|
||||
<main class="pt-24 pb-20 px-4">
|
||||
<div class="mx-auto" style="max-width: 75%">
|
||||
<!-- Page Title -->
|
||||
<h1 class="text-4xl md:text-5xl font-bold text-white mb-8">My Library</h1>
|
||||
|
||||
<!-- Loading State -->
|
||||
<div v-if="isLoading" class="text-center py-20">
|
||||
<div class="text-white/60">Loading your library...</div>
|
||||
</div>
|
||||
|
||||
<!-- Error State -->
|
||||
<div v-else-if="error" class="text-center py-20">
|
||||
<div class="text-red-400">{{ error }}</div>
|
||||
</div>
|
||||
|
||||
<!-- Content -->
|
||||
<div v-else class="space-y-12">
|
||||
<!-- Continue Watching -->
|
||||
<section v-if="continueWatching.length > 0">
|
||||
<h2 class="content-row-title text-xl md:text-2xl font-bold text-white mb-4 uppercase">Continue Watching</h2>
|
||||
<div class="grid grid-cols-2 md:grid-cols-4 lg:grid-cols-5 gap-4">
|
||||
<div
|
||||
v-for="item in continueWatching"
|
||||
:key="item.content.id"
|
||||
class="content-card cursor-pointer"
|
||||
@click="openDetail(item.content)"
|
||||
>
|
||||
<div class="relative">
|
||||
<img
|
||||
:src="item.content.thumbnail"
|
||||
:alt="item.content.title"
|
||||
class="w-full aspect-[2/3] object-cover rounded-lg"
|
||||
/>
|
||||
<!-- Progress Bar -->
|
||||
<div class="absolute bottom-0 left-0 right-0 h-1 bg-white/20 rounded-b-lg overflow-hidden">
|
||||
<div class="h-full bg-orange-500" :style="{ width: `${item.progress}%` }"></div>
|
||||
</div>
|
||||
</div>
|
||||
<h3 class="mt-2 text-sm font-medium text-white truncate">{{ item.content.title }}</h3>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- Rented Content -->
|
||||
<section v-if="rentedContent.length > 0">
|
||||
<h2 class="content-row-title text-xl md:text-2xl font-bold text-white mb-4 uppercase">My Rentals</h2>
|
||||
<div class="grid grid-cols-2 md:grid-cols-4 lg:grid-cols-5 gap-4">
|
||||
<div
|
||||
v-for="rental in rentedContent"
|
||||
:key="rental.id"
|
||||
class="content-card cursor-pointer"
|
||||
@click="rental.mappedContent && openDetail(rental.mappedContent)"
|
||||
>
|
||||
<div class="relative">
|
||||
<img
|
||||
:src="rental.mappedContent?.thumbnail"
|
||||
:alt="rental.mappedContent?.title"
|
||||
class="w-full aspect-[2/3] object-cover rounded-lg"
|
||||
/>
|
||||
<!-- Rental Expiry Badge -->
|
||||
<div class="absolute top-2 right-2 bg-black/80 backdrop-blur-md px-2 py-1 rounded text-xs text-white/80">
|
||||
{{ formatTimeRemaining(rental.expiresAt) }}
|
||||
</div>
|
||||
</div>
|
||||
<h3 class="mt-2 text-sm font-medium text-white truncate">{{ rental.mappedContent?.title }}</h3>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- Subscribed Content (if has subscription) -->
|
||||
<section v-if="hasSubscription && subscribedContent.length > 0">
|
||||
<h2 class="content-row-title text-xl md:text-2xl font-bold text-white mb-4 uppercase">All Content</h2>
|
||||
<div class="grid grid-cols-2 md:grid-cols-4 lg:grid-cols-5 gap-4">
|
||||
<div
|
||||
v-for="content in subscribedContent"
|
||||
:key="content.id"
|
||||
class="content-card cursor-pointer"
|
||||
@click="openDetail(content)"
|
||||
>
|
||||
<img
|
||||
:src="content.thumbnail"
|
||||
:alt="content.title"
|
||||
class="w-full aspect-[2/3] object-cover rounded-lg"
|
||||
/>
|
||||
<h3 class="mt-2 text-sm font-medium text-white truncate">{{ content.title }}</h3>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- Empty State -->
|
||||
<section v-if="continueWatching.length === 0 && rentedContent.length === 0 && subscribedContent.length === 0" class="text-center py-20">
|
||||
<div class="text-white/60 mb-6">Your library is empty</div>
|
||||
<router-link to="/" class="hero-play-button inline-block">Browse Content</router-link>
|
||||
</section>
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
|
||||
<!-- Content Detail Modal -->
|
||||
<ContentDetailModal
|
||||
:isOpen="showDetailModal"
|
||||
:content="selectedContent"
|
||||
@close="showDetailModal = false"
|
||||
@openAuth="showAuthModal = true"
|
||||
/>
|
||||
|
||||
<!-- Auth Modal -->
|
||||
<AuthModal
|
||||
:isOpen="showAuthModal"
|
||||
@close="showAuthModal = false"
|
||||
@success="showAuthModal = false"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, onMounted } from 'vue'
|
||||
import { useAuth } from '../composables/useAuth'
|
||||
import { libraryService } from '../services/library.service'
|
||||
import { mapApiContentsToContents } from '../utils/mappers'
|
||||
import type { Content } from '../types/content'
|
||||
import AppHeader from '../components/AppHeader.vue'
|
||||
import ContentDetailModal from '../components/ContentDetailModal.vue'
|
||||
import AuthModal from '../components/AuthModal.vue'
|
||||
|
||||
const { hasActiveSubscription } = useAuth()
|
||||
|
||||
interface MappedRental {
|
||||
id: string
|
||||
expiresAt: string
|
||||
mappedContent?: Content
|
||||
}
|
||||
|
||||
const continueWatching = ref<Array<{ content: Content; progress: number }>>([])
|
||||
const rentedContent = ref<MappedRental[]>([])
|
||||
const subscribedContent = ref<Content[]>([])
|
||||
const isLoading = ref(false)
|
||||
const error = ref<string | null>(null)
|
||||
const showDetailModal = ref(false)
|
||||
const showAuthModal = ref(false)
|
||||
const selectedContent = ref<Content | null>(null)
|
||||
|
||||
const hasSubscription = computed(() => hasActiveSubscription.value)
|
||||
|
||||
onMounted(async () => {
|
||||
await fetchLibrary()
|
||||
})
|
||||
|
||||
async function fetchLibrary() {
|
||||
isLoading.value = true
|
||||
error.value = null
|
||||
|
||||
try {
|
||||
const library = await libraryService.getUserLibrary()
|
||||
|
||||
continueWatching.value = library.continueWatching.map((item) => ({
|
||||
content: mapApiContentsToContents([item.content])[0],
|
||||
progress: item.progress,
|
||||
}))
|
||||
|
||||
rentedContent.value = library.rented.map((rent) => ({
|
||||
id: rent.id,
|
||||
expiresAt: rent.expiresAt,
|
||||
mappedContent: rent.content ? mapApiContentsToContents([rent.content])[0] : undefined,
|
||||
}))
|
||||
|
||||
subscribedContent.value = mapApiContentsToContents(library.subscribed)
|
||||
} catch (err: any) {
|
||||
error.value = err.message || 'Failed to load library'
|
||||
console.error('Library fetch error:', err)
|
||||
} finally {
|
||||
isLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function openDetail(content: Content) {
|
||||
selectedContent.value = content
|
||||
showDetailModal.value = true
|
||||
}
|
||||
|
||||
function formatTimeRemaining(expiresAt: string): string {
|
||||
const now = new Date()
|
||||
const expires = new Date(expiresAt)
|
||||
const diff = expires.getTime() - now.getTime()
|
||||
|
||||
if (diff < 0) return 'Expired'
|
||||
|
||||
const hours = Math.floor(diff / (1000 * 60 * 60))
|
||||
if (hours < 24) {
|
||||
return `${hours}h left`
|
||||
}
|
||||
|
||||
const days = Math.floor(hours / 24)
|
||||
return `${days}d left`
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.library-view {
|
||||
min-height: 100vh;
|
||||
background: #0a0a0a;
|
||||
}
|
||||
|
||||
.content-card {
|
||||
transition: transform 0.3s ease;
|
||||
}
|
||||
|
||||
.content-card:hover {
|
||||
transform: scale(1.05);
|
||||
}
|
||||
</style>
|
||||
246
src/views/Profile.vue
Normal file
246
src/views/Profile.vue
Normal file
@@ -0,0 +1,246 @@
|
||||
<template>
|
||||
<div class="profile-view">
|
||||
<!-- Header -->
|
||||
<AppHeader @openAuth="showAuthModal = true" />
|
||||
|
||||
<!-- Main Content -->
|
||||
<main class="pt-24 pb-20 px-4">
|
||||
<div class="mx-auto max-w-4xl">
|
||||
<!-- Page Title -->
|
||||
<h1 class="text-4xl md:text-5xl font-bold text-white mb-8">Profile</h1>
|
||||
|
||||
<!-- User Info Section -->
|
||||
<section class="glass-card p-6 mb-6">
|
||||
<h2 class="text-2xl font-bold text-white mb-6">Account Information</h2>
|
||||
|
||||
<div class="space-y-4">
|
||||
<div>
|
||||
<label class="block text-white/60 text-sm mb-1">Name</label>
|
||||
<div class="text-white font-medium">{{ user?.legalName }}</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label class="block text-white/60 text-sm mb-1">Email</label>
|
||||
<div class="text-white font-medium">{{ user?.email }}</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label class="block text-white/60 text-sm mb-1">Member Since</label>
|
||||
<div class="text-white font-medium">{{ formatDate(user?.createdAt) }}</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- Subscription Section -->
|
||||
<section class="glass-card p-6 mb-6">
|
||||
<h2 class="text-2xl font-bold text-white mb-6">Subscription</h2>
|
||||
|
||||
<div v-if="subscription" class="space-y-4">
|
||||
<div class="flex justify-between items-center">
|
||||
<div>
|
||||
<div class="text-lg font-bold text-white capitalize">{{ subscription.tier.replace('-', ' ') }}</div>
|
||||
<div class="text-white/60 text-sm">{{ subscription.status === 'active' ? 'Active' : 'Inactive' }}</div>
|
||||
</div>
|
||||
<div class="text-right">
|
||||
<div class="text-white font-medium">Renews on</div>
|
||||
<div class="text-white/60 text-sm">{{ formatDate(subscription.currentPeriodEnd) }}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button
|
||||
v-if="subscription.status === 'active' && !subscription.cancelAtPeriodEnd"
|
||||
@click="handleCancelSubscription"
|
||||
class="hero-info-button w-full"
|
||||
>
|
||||
Cancel Subscription
|
||||
</button>
|
||||
|
||||
<div v-if="subscription.cancelAtPeriodEnd" class="text-orange-400 text-sm">
|
||||
Your subscription will end on {{ formatDate(subscription.currentPeriodEnd) }}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-else class="text-center py-6">
|
||||
<div class="text-white/60 mb-4">No active subscription</div>
|
||||
<button @click="$router.push('/')" class="hero-play-button">
|
||||
Browse Plans
|
||||
</button>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- Nostr Section -->
|
||||
<section class="glass-card p-6 mb-6">
|
||||
<h2 class="text-2xl font-bold text-white mb-6">Nostr Integration</h2>
|
||||
|
||||
<div v-if="user?.nostrPubkey" class="space-y-4">
|
||||
<div>
|
||||
<label class="block text-white/60 text-sm mb-1">Linked Nostr Public Key</label>
|
||||
<div class="text-white font-mono text-xs break-all">{{ user.nostrPubkey }}</div>
|
||||
</div>
|
||||
|
||||
<button
|
||||
@click="handleUnlinkNostr"
|
||||
class="hero-info-button"
|
||||
>
|
||||
Unlink Nostr Account
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div v-else class="text-center py-6">
|
||||
<div class="text-white/60 mb-4">Link your Nostr account to enable social features</div>
|
||||
<button @click="handleLinkNostr" class="hero-play-button">
|
||||
Link Nostr Account
|
||||
</button>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- Filmmaker Section (if applicable) -->
|
||||
<section v-if="user?.filmmaker" class="glass-card p-6">
|
||||
<h2 class="text-2xl font-bold text-white mb-6">Filmmaker Dashboard</h2>
|
||||
|
||||
<div class="space-y-4">
|
||||
<div>
|
||||
<label class="block text-white/60 text-sm mb-1">Professional Name</label>
|
||||
<div class="text-white font-medium">{{ user.filmmaker.professionalName }}</div>
|
||||
</div>
|
||||
|
||||
<button @click="$router.push('/dashboard')" class="hero-play-button w-full">
|
||||
Go to Filmmaker Dashboard
|
||||
</button>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
</main>
|
||||
|
||||
<!-- Auth Modal -->
|
||||
<AuthModal
|
||||
:isOpen="showAuthModal"
|
||||
@close="showAuthModal = false"
|
||||
@success="showAuthModal = false"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted, computed } from 'vue'
|
||||
import { useRouter as _useRouter } from 'vue-router'
|
||||
import { useAuth } from '../composables/useAuth'
|
||||
import { subscriptionService } from '../services/subscription.service'
|
||||
import type { ApiSubscription } from '../types/api'
|
||||
import AppHeader from '../components/AppHeader.vue'
|
||||
import AuthModal from '../components/AuthModal.vue'
|
||||
|
||||
const { user, linkNostr, unlinkNostr } = useAuth()
|
||||
|
||||
const subscription = ref<ApiSubscription | null>(null)
|
||||
const showAuthModal = ref(false)
|
||||
|
||||
// Get subscription from user data directly
|
||||
const subscriptionFromUser = computed(() => {
|
||||
return user.value?.subscriptions?.[0] || null
|
||||
})
|
||||
|
||||
onMounted(async () => {
|
||||
// Use subscription from user data if available (mock mode)
|
||||
if (subscriptionFromUser.value) {
|
||||
subscription.value = subscriptionFromUser.value
|
||||
} else {
|
||||
await fetchSubscription()
|
||||
}
|
||||
})
|
||||
|
||||
async function fetchSubscription() {
|
||||
try {
|
||||
subscription.value = await subscriptionService.getActiveSubscription()
|
||||
} catch (error) {
|
||||
console.error('Failed to fetch subscription:', error)
|
||||
}
|
||||
}
|
||||
|
||||
async function handleCancelSubscription() {
|
||||
if (!subscription.value) return
|
||||
|
||||
const confirmed = confirm('Are you sure you want to cancel your subscription? You will retain access until the end of your billing period.')
|
||||
if (!confirmed) return
|
||||
|
||||
try {
|
||||
await subscriptionService.cancelSubscription(subscription.value.id)
|
||||
await fetchSubscription()
|
||||
} catch (error: any) {
|
||||
alert(error.message || 'Failed to cancel subscription')
|
||||
}
|
||||
}
|
||||
|
||||
async function handleLinkNostr() {
|
||||
if (!window.nostr) {
|
||||
alert('Nostr extension not found. Please install a Nostr browser extension like Alby or nos2x.')
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
const pubkey = await window.nostr.getPublicKey()
|
||||
|
||||
const authEvent = {
|
||||
kind: 27235,
|
||||
created_at: Math.floor(Date.now() / 1000),
|
||||
tags: [
|
||||
['u', window.location.origin],
|
||||
['method', 'POST'],
|
||||
],
|
||||
content: '',
|
||||
}
|
||||
|
||||
const signedEvent = await window.nostr.signEvent(authEvent)
|
||||
await linkNostr(pubkey, signedEvent.sig)
|
||||
} catch (error: any) {
|
||||
alert(error.message || 'Failed to link Nostr account')
|
||||
}
|
||||
}
|
||||
|
||||
async function handleUnlinkNostr() {
|
||||
const confirmed = confirm('Are you sure you want to unlink your Nostr account? You will lose access to social features.')
|
||||
if (!confirmed) return
|
||||
|
||||
try {
|
||||
await unlinkNostr()
|
||||
} catch (error: any) {
|
||||
alert(error.message || 'Failed to unlink Nostr account')
|
||||
}
|
||||
}
|
||||
|
||||
function formatDate(dateString?: string): string {
|
||||
if (!dateString) return 'N/A'
|
||||
return new Date(dateString).toLocaleDateString('en-US', {
|
||||
year: 'numeric',
|
||||
month: 'long',
|
||||
day: 'numeric',
|
||||
})
|
||||
}
|
||||
|
||||
declare global {
|
||||
interface Window {
|
||||
nostr?: {
|
||||
getPublicKey: () => Promise<string>
|
||||
signEvent: (event: any) => Promise<any>
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.profile-view {
|
||||
min-height: 100vh;
|
||||
background: #0a0a0a;
|
||||
}
|
||||
|
||||
.glass-card {
|
||||
background: rgba(255, 255, 255, 0.05);
|
||||
backdrop-filter: blur(24px);
|
||||
-webkit-backdrop-filter: blur(24px);
|
||||
border-radius: 16px;
|
||||
border: 1px solid rgba(255, 255, 255, 0.08);
|
||||
box-shadow:
|
||||
0 8px 24px rgba(0, 0, 0, 0.3),
|
||||
inset 0 1px 0 rgba(255, 255, 255, 0.1);
|
||||
}
|
||||
</style>
|
||||
Reference in New Issue
Block a user