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