Implement backend API and database services in Docker setup

- Added a new `api` service for the NestJS backend, including health checks and dependencies on PostgreSQL, Redis, and MinIO.
- Introduced PostgreSQL and Redis services with health checks and configurations for data persistence.
- Added MinIO for S3-compatible object storage and a one-shot service to initialize required buckets.
- Updated the Nginx configuration to proxy requests to the new backend API and MinIO storage.
- Enhanced the Dockerfile to support the new API environment variables and configurations.
- Updated the `package.json` and `package-lock.json` to include new dependencies for QR code generation and other utilities.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Dorian
2026-02-12 20:14:39 +00:00
parent f19fd6feef
commit cdd24a5def
478 changed files with 55355 additions and 529 deletions

View File

@@ -0,0 +1,159 @@
<template>
<div class="space-y-8">
<!-- Main Film File -->
<div>
<h3 class="text-lg font-semibold text-white mb-1">Main File</h3>
<p class="text-white/40 text-sm mb-4">Upload the main video file for your project. Supported formats: MP4, MOV, MKV.</p>
<UploadZone
label="Drag & drop your video file, or click to browse"
accept="video/*"
:current-file="uploads.file.fileName || (project as any)?.file"
:status="uploads.file.status"
:progress="uploads.file.progress"
:file-name="uploads.file.fileName"
@file-selected="(f: File) => handleFileUpload('file', f)"
/>
</div>
<!-- Poster -->
<div>
<h3 class="text-lg font-semibold text-white mb-1">Poster</h3>
<p class="text-white/40 text-sm mb-4">Poster image used as the thumbnail across the platform. 2:3 aspect ratio recommended.</p>
<UploadZone
label="Upload poster image"
accept="image/*"
:preview="uploads.poster.previewUrl || project?.poster"
:status="uploads.poster.status"
:progress="uploads.poster.progress"
:file-name="uploads.poster.fileName"
@file-selected="(f: File) => handleFileUpload('poster', f)"
/>
</div>
<!-- Trailer -->
<div>
<h3 class="text-lg font-semibold text-white mb-1">Trailer</h3>
<p class="text-white/40 text-sm mb-4">Optional trailer to showcase your project.</p>
<UploadZone
label="Upload trailer video"
accept="video/*"
:current-file="uploads.trailer.fileName || project?.trailer"
:status="uploads.trailer.status"
:progress="uploads.trailer.progress"
:file-name="uploads.trailer.fileName"
@file-selected="(f: File) => handleFileUpload('trailer', f)"
/>
</div>
<!-- Subtitles -->
<div>
<h3 class="text-lg font-semibold text-white mb-1">Subtitles</h3>
<p class="text-white/40 text-sm mb-4">Upload subtitle files (.srt, .vtt) for accessibility.</p>
<UploadZone
label="Upload subtitle file"
accept=".srt,.vtt"
:current-file="uploads.subtitles.fileName"
:status="uploads.subtitles.status"
:progress="uploads.subtitles.progress"
:file-name="uploads.subtitles.fileName"
@file-selected="(f: File) => handleFileUpload('subtitles', f)"
/>
</div>
</div>
</template>
<script setup lang="ts">
import { reactive } from 'vue'
import type { ApiProject } from '../../types/api'
import UploadZone from './UploadZone.vue'
import { USE_MOCK } from '../../utils/mock'
defineProps<{
project: ApiProject | null
}>()
const emit = defineEmits<{
(e: 'update', field: string, value: any): void
}>()
interface UploadState {
status: 'idle' | 'uploading' | 'completed' | 'error'
progress: number
fileName: string
previewUrl: string
}
const uploads = reactive<Record<string, UploadState>>({
file: { status: 'idle', progress: 0, fileName: '', previewUrl: '' },
poster: { status: 'idle', progress: 0, fileName: '', previewUrl: '' },
trailer: { status: 'idle', progress: 0, fileName: '', previewUrl: '' },
subtitles: { status: 'idle', progress: 0, fileName: '', previewUrl: '' },
})
/**
* Simulate upload progress for development mode
* Mimics realistic chunked upload behavior with variable speed
*/
function simulateMockUpload(field: string, file: File): Promise<void> {
return new Promise((resolve) => {
const state = uploads[field]
state.status = 'uploading'
state.progress = 0
state.fileName = file.name
// Simulate faster for small files, slower for large
const sizeMB = file.size / (1024 * 1024)
const totalDuration = Math.min(800 + sizeMB * 20, 4000) // 0.8s to 4s
const steps = 20
const interval = totalDuration / steps
let step = 0
const timer = setInterval(() => {
step++
// Simulate non-linear progress (fast start, slow middle, fast end)
const rawProgress = step / steps
const easedProgress = rawProgress < 0.5
? 2 * rawProgress * rawProgress
: 1 - Math.pow(-2 * rawProgress + 2, 2) / 2
state.progress = Math.round(easedProgress * 100)
if (step >= steps) {
clearInterval(timer)
state.progress = 100
state.status = 'completed'
resolve()
}
}, interval)
})
}
async function handleFileUpload(field: string, file: File) {
const state = uploads[field]
// Generate a preview URL for images
if (file.type.startsWith('image/')) {
state.previewUrl = URL.createObjectURL(file)
}
if (USE_MOCK) {
// Mock mode: simulate upload with progress
await simulateMockUpload(field, file)
// Emit the blob preview URL for images so the poster is displayable,
// or the filename for non-image files (video, subtitles)
const value = state.previewUrl || file.name
emit('update', field, value)
} else {
// Real mode: trigger actual upload (handled by parent/service)
state.status = 'uploading'
state.fileName = file.name
state.progress = 0
try {
// Emit to parent, which would handle the real upload
emit('update', field, file)
} catch {
state.status = 'error'
}
}
}
</script>

View File

@@ -0,0 +1,160 @@
<template>
<div>
<div class="flex items-center justify-between mb-6">
<div>
<h3 class="text-lg font-semibold text-white">Cast & Crew</h3>
<p class="text-white/40 text-sm mt-1">Add the people involved in your project.</p>
</div>
<button @click="showAddModal = true" class="add-button flex items-center gap-2">
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 4v16m8-8H4" />
</svg>
Add Member
</button>
</div>
<!-- Cast list -->
<div v-if="members.length > 0" class="space-y-3">
<div v-for="member in members" :key="member.id" class="member-row">
<div class="w-10 h-10 rounded-full bg-white/10 flex items-center justify-center text-white/40 text-sm font-bold flex-shrink-0">
{{ member.name[0]?.toUpperCase() }}
</div>
<div class="flex-1 min-w-0">
<p class="text-white font-medium text-sm truncate">{{ member.name }}</p>
<p class="text-white/40 text-xs capitalize">{{ member.role }} · {{ member.type }}</p>
</div>
<button @click="removeMember(member.id)" class="text-white/20 hover:text-red-400 transition-colors">
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
</div>
</div>
<!-- Empty state -->
<div v-else class="text-center py-12">
<svg class="w-12 h-12 mx-auto text-white/15 mb-3" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" d="M17 20h5v-2a3 3 0 00-5.356-1.857M17 20H7m10 0v-2c0-.656-.126-1.283-.356-1.857M7 20H2v-2a3 3 0 015.356-1.857M7 20v-2c0-.656.126-1.283.356-1.857m0 0a5.002 5.002 0 019.288 0M15 7a3 3 0 11-6 0 3 3 0 016 0z" />
</svg>
<p class="text-white/40 text-sm">No cast or crew added yet</p>
</div>
<!-- Add member modal -->
<Transition name="modal">
<div v-if="showAddModal" class="fixed inset-0 z-[100] flex items-center justify-center p-4" @click.self="showAddModal = false">
<div class="fixed inset-0 bg-black/60 backdrop-blur-sm" @click="showAddModal = false"></div>
<div class="modal-card relative z-10 w-full max-w-sm p-6">
<h3 class="text-xl font-bold text-white mb-4">Add Member</h3>
<div class="space-y-4">
<div>
<label class="field-label">Name</label>
<input v-model="newMember.name" class="field-input" placeholder="Full name" />
</div>
<div>
<label class="field-label">Role</label>
<input v-model="newMember.role" class="field-input" placeholder="e.g. Director, Actor" />
</div>
<div>
<label class="field-label">Type</label>
<div class="flex gap-2">
<button
@click="newMember.type = 'cast'"
:class="newMember.type === 'cast' ? 'type-btn-active' : 'type-btn'"
>Cast</button>
<button
@click="newMember.type = 'crew'"
:class="newMember.type === 'crew' ? 'type-btn-active' : 'type-btn'"
>Crew</button>
</div>
</div>
</div>
<div class="flex gap-3 mt-6">
<button @click="showAddModal = false" class="flex-1 cancel-button">Cancel</button>
<button @click="addMember" :disabled="!newMember.name" class="flex-1 create-button disabled:opacity-40">Add</button>
</div>
</div>
</div>
</Transition>
</div>
</template>
<script setup lang="ts">
import { ref, reactive } from 'vue'
import type { ApiProject, ApiCastMember } from '../../types/api'
defineProps<{
project: ApiProject | null
}>()
const showAddModal = ref(false)
const members = ref<ApiCastMember[]>([])
const newMember = reactive({ name: '', role: '', type: 'cast' as 'cast' | 'crew' })
function addMember() {
if (!newMember.name) return
members.value.push({
id: Date.now().toString(),
name: newMember.name,
role: newMember.role,
type: newMember.type,
})
newMember.name = ''
newMember.role = ''
newMember.type = 'cast'
showAddModal.value = false
}
function removeMember(id: string) {
members.value = members.value.filter((m) => m.id !== id)
}
</script>
<style scoped>
.member-row {
display: flex;
align-items: center;
gap: 12px;
padding: 12px 14px;
border-radius: 12px;
background: rgba(255, 255, 255, 0.04);
border: 1px solid rgba(255, 255, 255, 0.06);
}
.add-button {
padding: 8px 16px;
font-size: 13px;
font-weight: 600;
border-radius: 10px;
background: rgba(255, 255, 255, 0.08);
color: rgba(255, 255, 255, 0.8);
border: 1px solid rgba(255, 255, 255, 0.1);
cursor: pointer;
transition: all 0.2s ease;
}
.add-button:hover { background: rgba(255, 255, 255, 0.12); color: white; }
.modal-card {
background: rgba(20, 20, 20, 0.95);
backdrop-filter: blur(40px);
border-radius: 20px;
border: 1px solid rgba(255, 255, 255, 0.1);
box-shadow: 0 24px 80px rgba(0, 0, 0, 0.6), inset 0 1px 0 rgba(255, 255, 255, 0.08);
}
.field-label { display: block; color: rgba(255, 255, 255, 0.6); font-size: 14px; font-weight: 500; margin-bottom: 6px; }
.field-input { width: 100%; padding: 10px 14px; border-radius: 12px; border: 1px solid rgba(255, 255, 255, 0.1); background: rgba(255, 255, 255, 0.06); color: white; font-size: 14px; outline: none; }
.field-input::placeholder { color: rgba(255, 255, 255, 0.25); }
.field-input:focus { border-color: rgba(255, 255, 255, 0.2); background: rgba(255, 255, 255, 0.08); }
.type-btn { padding: 6px 14px; font-size: 13px; border-radius: 10px; background: rgba(255, 255, 255, 0.04); color: rgba(255, 255, 255, 0.5); border: 1px solid rgba(255, 255, 255, 0.08); cursor: pointer; transition: all 0.2s; }
.type-btn:hover { color: rgba(255, 255, 255, 0.8); background: rgba(255, 255, 255, 0.08); }
.type-btn-active { padding: 6px 14px; font-size: 13px; border-radius: 10px; background: rgba(247, 147, 26, 0.15); color: #F7931A; border: 1px solid rgba(247, 147, 26, 0.3); cursor: pointer; }
.cancel-button { padding: 10px 24px; font-size: 14px; border-radius: 14px; background: rgba(255, 255, 255, 0.08); color: rgba(255, 255, 255, 0.7); border: 1px solid rgba(255, 255, 255, 0.1); cursor: pointer; }
.create-button { padding: 10px 24px; font-size: 14px; font-weight: 600; border-radius: 14px; background: rgba(255, 255, 255, 0.85); color: rgba(0, 0, 0, 0.9); border: none; cursor: pointer; }
.modal-enter-active { transition: all 0.3s ease-out; }
.modal-leave-active { transition: all 0.2s ease-in; }
.modal-enter-from, .modal-leave-to { opacity: 0; }
</style>

View File

@@ -0,0 +1,118 @@
<template>
<div>
<div class="flex items-center justify-between mb-6">
<div>
<h3 class="text-lg font-semibold text-white">Episodes & Seasons</h3>
<p class="text-white/40 text-sm mt-1">Manage episodes and seasons for your episodic project.</p>
</div>
<button @click="showAddEpisode = true" class="add-button flex items-center gap-2">
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 4v16m8-8H4" />
</svg>
Add Episode
</button>
</div>
<!-- Empty state -->
<div v-if="!(project as any)?.contents?.length" class="text-center py-12">
<svg class="w-12 h-12 mx-auto text-white/15 mb-3" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" d="M19 11H5m14 0a2 2 0 012 2v6a2 2 0 01-2 2H5a2 2 0 01-2-2v-6a2 2 0 012-2m14 0V9a2 2 0 00-2-2M5 11V9a2 2 0 012-2m0 0V5a2 2 0 012-2h6a2 2 0 012 2v2M7 7h10" />
</svg>
<p class="text-white/40 text-sm">No episodes added yet</p>
</div>
<!-- Episode list placeholder -->
<div v-else class="space-y-3">
<div
v-for="(episode, idx) in (project as any)?.contents || []"
:key="episode.id || idx"
class="episode-row"
>
<span class="text-white/30 text-sm font-mono w-8">{{ String(Number(idx) + 1).padStart(2, '0') }}</span>
<span class="text-white font-medium flex-1 truncate">{{ episode.title || `Episode ${Number(idx) + 1}` }}</span>
<span class="text-white/30 text-xs capitalize">{{ episode.status || 'draft' }}</span>
</div>
</div>
<!-- Add episode modal placeholder -->
<Transition name="modal">
<div v-if="showAddEpisode" class="fixed inset-0 z-[100] flex items-center justify-center p-4" @click.self="showAddEpisode = false">
<div class="fixed inset-0 bg-black/60 backdrop-blur-sm" @click="showAddEpisode = false"></div>
<div class="modal-card relative z-10 w-full max-w-md p-6">
<h3 class="text-xl font-bold text-white mb-4">Add Episode</h3>
<p class="text-white/50 text-sm mb-6">Episode management will use the content upload pipeline. This feature connects to the backend API for content creation.</p>
<button @click="showAddEpisode = false" class="cancel-button w-full">Close</button>
</div>
</div>
</Transition>
</div>
</template>
<script setup lang="ts">
import { ref } from 'vue'
import type { ApiProject } from '../../types/api'
defineProps<{
project: ApiProject | null
}>()
const showAddEpisode = ref(false)
</script>
<style scoped>
.add-button {
padding: 8px 16px;
font-size: 13px;
font-weight: 600;
border-radius: 10px;
background: rgba(255, 255, 255, 0.08);
color: rgba(255, 255, 255, 0.8);
border: 1px solid rgba(255, 255, 255, 0.1);
cursor: pointer;
transition: all 0.2s ease;
}
.add-button:hover {
background: rgba(255, 255, 255, 0.12);
color: white;
}
.episode-row {
display: flex;
align-items: center;
gap: 12px;
padding: 14px 16px;
border-radius: 12px;
background: rgba(255, 255, 255, 0.04);
border: 1px solid rgba(255, 255, 255, 0.06);
transition: background 0.2s ease;
}
.episode-row:hover {
background: rgba(255, 255, 255, 0.07);
}
.modal-card {
background: rgba(20, 20, 20, 0.95);
backdrop-filter: blur(40px);
border-radius: 20px;
border: 1px solid rgba(255, 255, 255, 0.1);
box-shadow: 0 24px 80px rgba(0, 0, 0, 0.6), inset 0 1px 0 rgba(255, 255, 255, 0.08);
}
.cancel-button {
padding: 10px 24px;
font-size: 14px;
font-weight: 500;
border-radius: 14px;
background: rgba(255, 255, 255, 0.08);
color: rgba(255, 255, 255, 0.7);
border: 1px solid rgba(255, 255, 255, 0.1);
cursor: pointer;
transition: all 0.2s ease;
}
.modal-enter-active { transition: all 0.3s ease-out; }
.modal-leave-active { transition: all 0.2s ease-in; }
.modal-enter-from, .modal-leave-to { opacity: 0; }
</style>

View File

@@ -0,0 +1,136 @@
<template>
<div>
<div class="flex items-center justify-between mb-6">
<div>
<h3 class="text-lg font-semibold text-white">Coupons</h3>
<p class="text-white/40 text-sm mt-1">Create discount codes for your project.</p>
</div>
<button @click="showCreateModal = true" class="add-button flex items-center gap-2">
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 4v16m8-8H4" />
</svg>
Create Coupon
</button>
</div>
<!-- Coupon list -->
<div v-if="coupons.length > 0" class="space-y-3">
<div v-for="coupon in coupons" :key="coupon.id" class="coupon-row">
<div class="flex-1 min-w-0">
<p class="text-white font-mono font-bold text-sm">{{ coupon.code }}</p>
<p class="text-white/40 text-xs mt-0.5">
{{ coupon.discountType === 'percentage' ? `${coupon.discountValue}% off` : `${coupon.discountValue} sats off` }}
· Used {{ coupon.usedCount }}/{{ coupon.usageLimit }}
</p>
</div>
<button @click="removeCoupon(coupon.id)" class="text-white/20 hover:text-red-400 transition-colors">
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16" />
</svg>
</button>
</div>
</div>
<!-- Empty state -->
<div v-else class="text-center py-12">
<svg class="w-12 h-12 mx-auto text-white/15 mb-3" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" d="M15 5v2m0 4v2m0 4v2M5 5a2 2 0 00-2 2v3a2 2 0 110 4v3a2 2 0 002 2h14a2 2 0 002-2v-3a2 2 0 110-4V7a2 2 0 00-2-2H5z" />
</svg>
<p class="text-white/40 text-sm">No coupons created yet</p>
</div>
<!-- Create coupon modal -->
<Transition name="modal">
<div v-if="showCreateModal" class="fixed inset-0 z-[100] flex items-center justify-center p-4" @click.self="showCreateModal = false">
<div class="fixed inset-0 bg-black/60 backdrop-blur-sm" @click="showCreateModal = false"></div>
<div class="modal-card relative z-10 w-full max-w-sm p-6">
<h3 class="text-xl font-bold text-white mb-4">Create Coupon</h3>
<div class="space-y-4">
<div>
<label class="field-label">Code</label>
<input v-model="newCoupon.code" class="field-input font-mono" placeholder="SUMMER2026" />
</div>
<div class="grid grid-cols-2 gap-3">
<div>
<label class="field-label">Discount</label>
<input v-model.number="newCoupon.discountValue" type="number" class="field-input" placeholder="20" min="1" />
</div>
<div>
<label class="field-label">Type</label>
<select v-model="newCoupon.discountType" class="field-select">
<option value="percentage">Percentage</option>
<option value="fixed">Fixed (sats)</option>
</select>
</div>
</div>
<div>
<label class="field-label">Usage Limit</label>
<input v-model.number="newCoupon.usageLimit" type="number" class="field-input" placeholder="100" min="1" />
</div>
</div>
<div class="flex gap-3 mt-6">
<button @click="showCreateModal = false" class="flex-1 cancel-button">Cancel</button>
<button @click="handleCreate" :disabled="!newCoupon.code" class="flex-1 create-button disabled:opacity-40">Create</button>
</div>
</div>
</div>
</Transition>
</div>
</template>
<script setup lang="ts">
import { ref, reactive } from 'vue'
import type { ApiProject, ApiCoupon } from '../../types/api'
defineProps<{
project: ApiProject | null
}>()
const showCreateModal = ref(false)
const coupons = ref<ApiCoupon[]>([])
const newCoupon = reactive({
code: '',
discountType: 'percentage' as 'percentage' | 'fixed',
discountValue: 20,
usageLimit: 100,
})
function handleCreate() {
if (!newCoupon.code) return
coupons.value.push({
id: Date.now().toString(),
code: newCoupon.code.toUpperCase(),
projectId: '',
discountType: newCoupon.discountType,
discountValue: newCoupon.discountValue,
usageLimit: newCoupon.usageLimit,
usedCount: 0,
createdAt: new Date().toISOString(),
})
newCoupon.code = ''
showCreateModal.value = false
}
function removeCoupon(id: string) {
coupons.value = coupons.value.filter((c) => c.id !== id)
}
</script>
<style scoped>
.coupon-row { display: flex; align-items: center; gap: 12px; padding: 14px 16px; border-radius: 12px; background: rgba(255, 255, 255, 0.04); border: 1px solid rgba(255, 255, 255, 0.06); }
.add-button { padding: 8px 16px; font-size: 13px; font-weight: 600; border-radius: 10px; background: rgba(255, 255, 255, 0.08); color: rgba(255, 255, 255, 0.8); border: 1px solid rgba(255, 255, 255, 0.1); cursor: pointer; transition: all 0.2s; }
.add-button:hover { background: rgba(255, 255, 255, 0.12); color: white; }
.modal-card { background: rgba(20, 20, 20, 0.95); backdrop-filter: blur(40px); border-radius: 20px; border: 1px solid rgba(255, 255, 255, 0.1); box-shadow: 0 24px 80px rgba(0, 0, 0, 0.6), inset 0 1px 0 rgba(255, 255, 255, 0.08); }
.field-label { display: block; color: rgba(255, 255, 255, 0.6); font-size: 14px; font-weight: 500; margin-bottom: 6px; }
.field-input { width: 100%; padding: 10px 14px; border-radius: 12px; border: 1px solid rgba(255, 255, 255, 0.1); background: rgba(255, 255, 255, 0.06); color: white; font-size: 14px; outline: none; }
.field-input::placeholder { color: rgba(255, 255, 255, 0.25); }
.field-input:focus { border-color: rgba(255, 255, 255, 0.2); background: rgba(255, 255, 255, 0.08); }
.field-select { width: 100%; padding: 10px 14px; border-radius: 12px; border: 1px solid rgba(255, 255, 255, 0.1); background: rgba(255, 255, 255, 0.06); color: rgba(255, 255, 255, 0.8); font-size: 14px; outline: none; cursor: pointer; }
.field-select option { background: #1a1a1a; color: white; }
.cancel-button { padding: 10px 24px; font-size: 14px; border-radius: 14px; background: rgba(255, 255, 255, 0.08); color: rgba(255, 255, 255, 0.7); border: 1px solid rgba(255, 255, 255, 0.1); cursor: pointer; }
.create-button { padding: 10px 24px; font-size: 14px; font-weight: 600; border-radius: 14px; background: rgba(255, 255, 255, 0.85); color: rgba(0, 0, 0, 0.9); border: none; cursor: pointer; }
.modal-enter-active { transition: all 0.3s ease-out; }
.modal-leave-active { transition: all 0.2s ease-in; }
.modal-enter-from, .modal-leave-to { opacity: 0; }
</style>

View File

@@ -0,0 +1,216 @@
<template>
<div class="space-y-6">
<!-- Title -->
<div>
<label class="field-label">Title</label>
<input
:value="project?.title"
@input="emit('update', 'title', ($event.target as HTMLInputElement).value)"
class="field-input"
placeholder="Give your project a title"
/>
</div>
<!-- Synopsis -->
<div>
<label class="field-label">Synopsis</label>
<textarea
:value="project?.synopsis"
@input="emit('update', 'synopsis', ($event.target as HTMLTextAreaElement).value)"
class="field-input min-h-[120px] resize-y"
placeholder="Write a compelling synopsis..."
rows="4"
></textarea>
</div>
<!-- Slug -->
<div>
<label class="field-label">URL Slug</label>
<div class="flex items-center gap-2">
<span class="text-white/30 text-sm">indeedhub.com/watch/</span>
<input
:value="project?.slug"
@input="emit('update', 'slug', ($event.target as HTMLInputElement).value)"
class="field-input flex-1"
placeholder="my-awesome-film"
/>
</div>
</div>
<!-- Two columns: Category + Format -->
<div class="grid grid-cols-1 sm:grid-cols-2 gap-6">
<div>
<label class="field-label">Category</label>
<select
:value="project?.category"
@change="emit('update', 'category', ($event.target as HTMLSelectElement).value)"
class="field-select"
>
<option value="">Select category</option>
<option value="narrative">Narrative</option>
<option value="documentary">Documentary</option>
<option value="experimental">Experimental</option>
<option value="animation">Animation</option>
</select>
</div>
<div>
<label class="field-label">Format</label>
<select
:value="project?.format"
@change="emit('update', 'format', ($event.target as HTMLSelectElement).value)"
class="field-select"
>
<option value="">Select format</option>
<option value="feature">Feature</option>
<option value="short">Short</option>
<option value="medium">Medium Length</option>
</select>
</div>
</div>
<!-- Genres -->
<div>
<label class="field-label">Genres</label>
<div class="flex flex-wrap gap-2">
<button
v-for="genre in genres"
:key="genre.id"
@click="toggleGenre(genre.slug)"
:class="selectedGenres.includes(genre.slug) ? 'genre-tag-active' : 'genre-tag'"
>
{{ genre.name }}
</button>
</div>
<p v-if="genres.length === 0" class="text-white/30 text-sm mt-2">Loading genres...</p>
</div>
<!-- Release Date -->
<div>
<label class="field-label">Release Date</label>
<input
type="date"
:value="project?.releaseDate?.split('T')[0]"
@input="emit('update', 'releaseDate', ($event.target as HTMLInputElement).value)"
class="field-input"
/>
</div>
</div>
</template>
<script setup lang="ts">
import { ref, watch } from 'vue'
import type { ApiProject, ApiGenre } from '../../types/api'
const props = defineProps<{
project: ApiProject | null
genres: ApiGenre[]
}>()
const emit = defineEmits<{
(e: 'update', field: string, value: any): void
}>()
const selectedGenres = ref<string[]>([])
// Sync genres from project
watch(
() => props.project?.genres,
(genres) => {
if (genres) {
selectedGenres.value = genres.map((g) => g.slug)
}
},
{ immediate: true }
)
function toggleGenre(slug: string) {
const idx = selectedGenres.value.indexOf(slug)
if (idx === -1) {
selectedGenres.value.push(slug)
} else {
selectedGenres.value.splice(idx, 1)
}
emit('update', 'genres', [...selectedGenres.value])
}
</script>
<style scoped>
.field-label {
display: block;
color: rgba(255, 255, 255, 0.6);
font-size: 14px;
font-weight: 500;
margin-bottom: 8px;
}
.field-input {
width: 100%;
padding: 10px 14px;
border-radius: 12px;
border: 1px solid rgba(255, 255, 255, 0.1);
background: rgba(255, 255, 255, 0.06);
color: rgba(255, 255, 255, 0.95);
font-size: 14px;
outline: none;
transition: all 0.2s ease;
}
.field-input::placeholder {
color: rgba(255, 255, 255, 0.25);
}
.field-input:focus {
border-color: rgba(255, 255, 255, 0.2);
background: rgba(255, 255, 255, 0.08);
box-shadow: 0 0 0 3px rgba(255, 255, 255, 0.04);
}
.field-select {
width: 100%;
padding: 10px 14px;
border-radius: 12px;
border: 1px solid rgba(255, 255, 255, 0.1);
background: rgba(255, 255, 255, 0.06);
color: rgba(255, 255, 255, 0.8);
font-size: 14px;
outline: none;
appearance: none;
cursor: pointer;
transition: all 0.2s ease;
}
.field-select option {
background: #1a1a1a;
color: white;
}
.genre-tag {
padding: 6px 14px;
font-size: 13px;
font-weight: 500;
border-radius: 10px;
background: rgba(255, 255, 255, 0.04);
color: rgba(255, 255, 255, 0.5);
border: 1px solid rgba(255, 255, 255, 0.08);
cursor: pointer;
transition: all 0.2s ease;
}
.genre-tag:hover {
color: rgba(255, 255, 255, 0.8);
background: rgba(255, 255, 255, 0.08);
}
.genre-tag-active {
padding: 6px 14px;
font-size: 13px;
font-weight: 600;
border-radius: 10px;
background: rgba(247, 147, 26, 0.15);
color: #F7931A;
border: 1px solid rgba(247, 147, 26, 0.3);
cursor: pointer;
transition: all 0.2s ease;
}
</style>

View File

@@ -0,0 +1,106 @@
<template>
<div>
<h3 class="text-lg font-semibold text-white mb-1">Project Documentation</h3>
<p class="text-white/40 text-sm mb-6">Upload legal documents, contracts, and press kits related to your project.</p>
<!-- Upload zone -->
<div
class="upload-zone"
@dragover.prevent
@drop.prevent="handleDrop"
@click="triggerInput"
>
<div class="flex flex-col items-center gap-3 py-8">
<svg class="w-10 h-10 text-white/20" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z" />
</svg>
<span class="text-white/40 text-sm text-center px-4">Drop files here or click to upload documents</span>
<span class="text-white/20 text-xs">PDF, DOC, DOCX, TXT</span>
</div>
<input ref="fileInput" type="file" class="hidden" accept=".pdf,.doc,.docx,.txt" multiple @change="handleInput" />
</div>
<!-- Documents list -->
<div v-if="documents.length > 0" class="mt-4 space-y-2">
<div v-for="doc in documents" :key="doc.name" class="doc-row">
<svg class="w-4 h-4 text-white/30 flex-shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z" />
</svg>
<span class="text-white/70 text-sm flex-1 truncate">{{ doc.name }}</span>
<span class="text-white/30 text-xs">{{ formatSize(doc.size) }}</span>
<button @click="removeDoc(doc.name)" class="text-white/20 hover:text-red-400 transition-colors ml-2">
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { ref } from 'vue'
import type { ApiProject } from '../../types/api'
defineProps<{
project: ApiProject | null
}>()
const fileInput = ref<HTMLInputElement | null>(null)
const documents = ref<Array<{ name: string; size: number }>>([])
function triggerInput() {
fileInput.value?.click()
}
function handleInput(event: Event) {
const target = event.target as HTMLInputElement
if (target.files) {
Array.from(target.files).forEach((f) => {
documents.value.push({ name: f.name, size: f.size })
})
}
}
function handleDrop(event: DragEvent) {
if (event.dataTransfer?.files) {
Array.from(event.dataTransfer.files).forEach((f) => {
documents.value.push({ name: f.name, size: f.size })
})
}
}
function removeDoc(name: string) {
documents.value = documents.value.filter((d) => d.name !== name)
}
function formatSize(bytes: number): string {
if (bytes < 1024) return bytes + ' B'
if (bytes < 1024 * 1024) return (bytes / 1024).toFixed(1) + ' KB'
return (bytes / (1024 * 1024)).toFixed(1) + ' MB'
}
</script>
<style scoped>
.upload-zone {
border: 2px dashed rgba(255, 255, 255, 0.1);
border-radius: 14px;
cursor: pointer;
transition: all 0.2s ease;
}
.upload-zone:hover {
border-color: rgba(255, 255, 255, 0.2);
background: rgba(255, 255, 255, 0.03);
}
.doc-row {
display: flex;
align-items: center;
gap: 10px;
padding: 10px 14px;
border-radius: 10px;
background: rgba(255, 255, 255, 0.04);
border: 1px solid rgba(255, 255, 255, 0.06);
}
</style>

View File

@@ -0,0 +1,101 @@
<template>
<div>
<div class="flex items-center justify-between mb-6">
<div>
<h3 class="text-lg font-semibold text-white">Team Permissions</h3>
<p class="text-white/40 text-sm mt-1">Manage who can access and edit this project.</p>
</div>
<button @click="showInviteModal = true" class="add-button flex items-center gap-2">
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M18 9v3m0 0v3m0-3h3m-3 0h-3m-2-5a4 4 0 11-8 0 4 4 0 018 0zM3 20a6 6 0 0112 0v1H3v-1z" />
</svg>
Invite
</button>
</div>
<!-- Permissions list -->
<div class="space-y-3">
<div class="member-row">
<div class="w-10 h-10 rounded-full bg-[#F7931A]/20 flex items-center justify-center text-[#F7931A] text-sm font-bold flex-shrink-0">
O
</div>
<div class="flex-1 min-w-0">
<p class="text-white font-medium text-sm">You</p>
<p class="text-white/40 text-xs">Owner</p>
</div>
<span class="role-badge">Owner</span>
</div>
</div>
<p class="text-white/30 text-xs mt-6">
Team collaboration with multiple roles (admin, editor, viewer, revenue-manager) is managed through the backend API.
</p>
<!-- Invite modal -->
<Transition name="modal">
<div v-if="showInviteModal" class="fixed inset-0 z-[100] flex items-center justify-center p-4" @click.self="showInviteModal = false">
<div class="fixed inset-0 bg-black/60 backdrop-blur-sm" @click="showInviteModal = false"></div>
<div class="modal-card relative z-10 w-full max-w-sm p-6">
<h3 class="text-xl font-bold text-white mb-4">Invite Team Member</h3>
<div class="space-y-4">
<div>
<label class="field-label">Email</label>
<input v-model="inviteEmail" class="field-input" placeholder="team@example.com" type="email" />
</div>
<div>
<label class="field-label">Role</label>
<select v-model="inviteRole" class="field-select">
<option value="editor">Editor</option>
<option value="viewer">Viewer</option>
<option value="revenue-manager">Revenue Manager</option>
<option value="admin">Admin</option>
</select>
</div>
</div>
<div class="flex gap-3 mt-6">
<button @click="showInviteModal = false" class="flex-1 cancel-button">Cancel</button>
<button @click="handleInvite" :disabled="!inviteEmail" class="flex-1 create-button disabled:opacity-40">Send Invite</button>
</div>
</div>
</div>
</Transition>
</div>
</template>
<script setup lang="ts">
import { ref } from 'vue'
import type { ApiProject } from '../../types/api'
defineProps<{
project: ApiProject | null
}>()
const showInviteModal = ref(false)
const inviteEmail = ref('')
const inviteRole = ref('editor')
function handleInvite() {
console.log('Invite:', inviteEmail.value, inviteRole.value)
showInviteModal.value = false
inviteEmail.value = ''
}
</script>
<style scoped>
.member-row { display: flex; align-items: center; gap: 12px; padding: 14px 16px; border-radius: 12px; background: rgba(255, 255, 255, 0.04); border: 1px solid rgba(255, 255, 255, 0.06); }
.add-button { padding: 8px 16px; font-size: 13px; font-weight: 600; border-radius: 10px; background: rgba(255, 255, 255, 0.08); color: rgba(255, 255, 255, 0.8); border: 1px solid rgba(255, 255, 255, 0.1); cursor: pointer; transition: all 0.2s; }
.add-button:hover { background: rgba(255, 255, 255, 0.12); color: white; }
.role-badge { padding: 4px 10px; font-size: 11px; font-weight: 600; border-radius: 8px; background: rgba(247, 147, 26, 0.15); color: #F7931A; text-transform: uppercase; letter-spacing: 0.05em; }
.modal-card { background: rgba(20, 20, 20, 0.95); backdrop-filter: blur(40px); border-radius: 20px; border: 1px solid rgba(255, 255, 255, 0.1); box-shadow: 0 24px 80px rgba(0, 0, 0, 0.6), inset 0 1px 0 rgba(255, 255, 255, 0.08); }
.field-label { display: block; color: rgba(255, 255, 255, 0.6); font-size: 14px; font-weight: 500; margin-bottom: 6px; }
.field-input { width: 100%; padding: 10px 14px; border-radius: 12px; border: 1px solid rgba(255, 255, 255, 0.1); background: rgba(255, 255, 255, 0.06); color: white; font-size: 14px; outline: none; }
.field-input::placeholder { color: rgba(255, 255, 255, 0.25); }
.field-input:focus { border-color: rgba(255, 255, 255, 0.2); background: rgba(255, 255, 255, 0.08); }
.field-select { width: 100%; padding: 10px 14px; border-radius: 12px; border: 1px solid rgba(255, 255, 255, 0.1); background: rgba(255, 255, 255, 0.06); color: rgba(255, 255, 255, 0.8); font-size: 14px; outline: none; appearance: none; cursor: pointer; }
.field-select option { background: #1a1a1a; color: white; }
.cancel-button { padding: 10px 24px; font-size: 14px; border-radius: 14px; background: rgba(255, 255, 255, 0.08); color: rgba(255, 255, 255, 0.7); border: 1px solid rgba(255, 255, 255, 0.1); cursor: pointer; }
.create-button { padding: 10px 24px; font-size: 14px; font-weight: 600; border-radius: 14px; background: rgba(255, 255, 255, 0.85); color: rgba(0, 0, 0, 0.9); border: none; cursor: pointer; }
.modal-enter-active { transition: all 0.3s ease-out; }
.modal-leave-active { transition: all 0.2s ease-in; }
.modal-enter-from, .modal-leave-to { opacity: 0; }
</style>

View File

@@ -0,0 +1,140 @@
<template>
<div class="space-y-8">
<!-- Rental Price -->
<div>
<h3 class="text-lg font-semibold text-white mb-1">Rental Price</h3>
<p class="text-white/40 text-sm mb-4">Set the price for renting your project. Prices are in satoshis.</p>
<div class="flex items-center gap-3">
<div class="relative flex-1 max-w-xs">
<input
type="number"
:value="project?.rentalPrice"
@input="emit('update', 'rentalPrice', Number(($event.target as HTMLInputElement).value))"
class="field-input pr-12"
placeholder="0"
min="0"
/>
<span class="absolute right-3 top-1/2 -translate-y-1/2 text-white/30 text-sm">sats</span>
</div>
</div>
</div>
<!-- Delivery Mode -->
<div>
<h3 class="text-lg font-semibold text-white mb-1">Delivery Mode</h3>
<p class="text-white/40 text-sm mb-4">Choose how your content is delivered to viewers.</p>
<div class="flex gap-3">
<button
@click="emit('update', 'deliveryMode', 'native')"
:class="((project as any)?.deliveryMode || 'native') === 'native' ? 'mode-active' : 'mode-inactive'"
>
<svg class="w-5 h-5 mb-1" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 12h14M5 12a2 2 0 01-2-2V6a2 2 0 012-2h14a2 2 0 012 2v4a2 2 0 01-2 2M5 12a2 2 0 00-2 2v4a2 2 0 002 2h14a2 2 0 002-2v-4a2 2 0 00-2-2" />
</svg>
<span class="text-sm font-medium">Self-Hosted</span>
<span class="text-xs opacity-60">AES-128 HLS</span>
</button>
<button
@click="emit('update', 'deliveryMode', 'partner')"
:class="(project as any)?.deliveryMode === 'partner' ? 'mode-active' : 'mode-inactive'"
>
<svg class="w-5 h-5 mb-1" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M3 15a4 4 0 004 4h9a5 5 0 10-.1-9.999 5.002 5.002 0 10-9.78 2.096A4.001 4.001 0 003 15z" />
</svg>
<span class="text-sm font-medium">Partner CDN</span>
<span class="text-xs opacity-60">DRM Protected</span>
</button>
</div>
</div>
<!-- Revenue Split (placeholder) -->
<div>
<h3 class="text-lg font-semibold text-white mb-1">Revenue Split</h3>
<p class="text-white/40 text-sm mb-4">Define how revenue is shared among stakeholders.</p>
<div class="split-row">
<div class="w-10 h-10 rounded-full bg-[#F7931A]/20 flex items-center justify-center text-[#F7931A] text-sm font-bold flex-shrink-0">
You
</div>
<span class="text-white font-medium flex-1">Owner</span>
<span class="text-white/70 text-sm font-mono">100%</span>
</div>
<p class="text-white/30 text-xs mt-3">Revenue split management with multiple stakeholders is available through the backend API.</p>
</div>
</div>
</template>
<script setup lang="ts">
import type { ApiProject } from '../../types/api'
defineProps<{
project: ApiProject | null
}>()
const emit = defineEmits<{
(e: 'update', field: string, value: any): void
}>()
</script>
<style scoped>
.field-input {
width: 100%;
padding: 10px 14px;
border-radius: 12px;
border: 1px solid rgba(255, 255, 255, 0.1);
background: rgba(255, 255, 255, 0.06);
color: rgba(255, 255, 255, 0.95);
font-size: 14px;
outline: none;
transition: all 0.2s ease;
}
.field-input::placeholder { color: rgba(255, 255, 255, 0.25); }
.field-input:focus { border-color: rgba(255, 255, 255, 0.2); background: rgba(255, 255, 255, 0.08); }
.mode-inactive {
display: flex;
flex-direction: column;
align-items: center;
gap: 4px;
padding: 16px 24px;
border-radius: 14px;
background: rgba(255, 255, 255, 0.04);
color: rgba(255, 255, 255, 0.5);
border: 1px solid rgba(255, 255, 255, 0.08);
cursor: pointer;
transition: all 0.2s ease;
min-width: 140px;
}
.mode-inactive:hover {
color: rgba(255, 255, 255, 0.8);
background: rgba(255, 255, 255, 0.08);
}
.mode-active {
display: flex;
flex-direction: column;
align-items: center;
gap: 4px;
padding: 16px 24px;
border-radius: 14px;
background: rgba(247, 147, 26, 0.12);
color: #F7931A;
border: 1px solid rgba(247, 147, 26, 0.3);
cursor: pointer;
transition: all 0.2s ease;
min-width: 140px;
}
.split-row {
display: flex;
align-items: center;
gap: 12px;
padding: 14px 16px;
border-radius: 12px;
background: rgba(255, 255, 255, 0.04);
border: 1px solid rgba(255, 255, 255, 0.06);
}
</style>

View File

@@ -0,0 +1,188 @@
<template>
<div
class="upload-zone"
:class="{
'upload-zone-active': isDragging,
'upload-zone-has-content': preview || currentFile || status === 'completed',
'upload-zone-uploading': status === 'uploading',
'upload-zone-error': status === 'error',
}"
@dragover.prevent="isDragging = true"
@dragleave.prevent="isDragging = false"
@drop.prevent="handleDrop"
@click="triggerInput"
>
<!-- Uploading state -->
<div v-if="status === 'uploading'" class="flex flex-col items-center gap-3 py-8 px-4">
<div class="upload-spinner"></div>
<div class="w-full max-w-xs">
<div class="flex justify-between text-xs mb-1.5">
<span class="text-white/60 truncate max-w-[200px]">{{ fileName }}</span>
<span class="text-white/40 font-mono">{{ progress }}%</span>
</div>
<div class="h-1.5 rounded-full bg-white/10 overflow-hidden">
<div
class="h-full rounded-full bg-[#F7931A] transition-all duration-300 ease-out"
:style="{ width: `${progress}%` }"
></div>
</div>
</div>
<span class="text-white/30 text-xs">Uploading...</span>
</div>
<!-- Completed / success state -->
<div v-else-if="status === 'completed' || (currentFile && status !== 'error')" class="flex flex-col items-center gap-2 py-6 px-4">
<svg class="w-8 h-8 text-green-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 13l4 4L19 7" />
</svg>
<span class="text-white/70 text-sm text-center truncate max-w-full px-2">{{ fileName || currentFile }}</span>
<span class="text-white/30 text-xs">Click to replace</span>
</div>
<!-- Error state -->
<div v-else-if="status === 'error'" class="flex flex-col items-center gap-2 py-6 px-4">
<svg class="w-8 h-8 text-red-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 8v4m0 4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
<span class="text-red-400/70 text-sm">Upload failed</span>
<span class="text-white/30 text-xs">Click to retry</span>
</div>
<!-- Preview image -->
<div v-else-if="preview" class="upload-preview">
<img :src="preview" alt="Preview" class="w-full h-full object-cover rounded-xl" />
<div class="upload-preview-overlay">
<span class="text-white text-sm font-medium">Click to replace</span>
</div>
</div>
<!-- Upload prompt (idle) -->
<div v-else class="flex flex-col items-center gap-3 py-8">
<svg class="w-10 h-10 text-white/20" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" d="M7 16a4 4 0 01-.88-7.903A5 5 0 1115.9 6L16 6a5 5 0 011 9.9M15 13l-3-3m0 0l-3 3m3-3v12" />
</svg>
<span class="text-white/40 text-sm text-center px-4">{{ label }}</span>
</div>
<input
ref="fileInput"
type="file"
:accept="accept"
class="hidden"
@change="handleInput"
/>
</div>
</template>
<script setup lang="ts">
import { ref } from 'vue'
defineProps<{
label?: string
accept?: string
preview?: string
currentFile?: string
status?: 'idle' | 'uploading' | 'completed' | 'error'
progress?: number
fileName?: string
}>()
const emit = defineEmits<{
(e: 'file-selected', file: File): void
}>()
const fileInput = ref<HTMLInputElement | null>(null)
const isDragging = ref(false)
function triggerInput() {
fileInput.value?.click()
}
function handleInput(event: Event) {
const target = event.target as HTMLInputElement
const file = target.files?.[0]
if (file) emit('file-selected', file)
if (target) target.value = ''
}
function handleDrop(event: DragEvent) {
isDragging.value = false
const file = event.dataTransfer?.files?.[0]
if (file) emit('file-selected', file)
}
</script>
<style scoped>
.upload-zone {
position: relative;
border: 2px dashed rgba(255, 255, 255, 0.1);
border-radius: 14px;
cursor: pointer;
transition: all 0.2s ease;
overflow: hidden;
}
.upload-zone:hover {
border-color: rgba(255, 255, 255, 0.2);
background: rgba(255, 255, 255, 0.03);
}
.upload-zone-active {
border-color: rgba(247, 147, 26, 0.4);
background: rgba(247, 147, 26, 0.05);
}
.upload-zone-uploading {
border-color: rgba(247, 147, 26, 0.25);
border-style: solid;
background: rgba(247, 147, 26, 0.03);
cursor: default;
}
.upload-zone-has-content {
border-style: solid;
border-color: rgba(255, 255, 255, 0.08);
}
.upload-zone-error {
border-color: rgba(239, 68, 68, 0.3);
border-style: dashed;
background: rgba(239, 68, 68, 0.03);
}
.upload-preview {
position: relative;
aspect-ratio: 2 / 3;
max-height: 240px;
}
.upload-preview-overlay {
position: absolute;
inset: 0;
background: rgba(0, 0, 0, 0.5);
display: flex;
align-items: center;
justify-content: center;
opacity: 0;
transition: opacity 0.2s ease;
border-radius: 12px;
}
.upload-zone:hover .upload-preview-overlay {
opacity: 1;
}
/* Spinner animation */
.upload-spinner {
width: 32px;
height: 32px;
border: 3px solid rgba(255, 255, 255, 0.1);
border-top-color: #F7931A;
border-radius: 50%;
animation: spin 0.8s linear infinite;
}
@keyframes spin {
to { transform: rotate(360deg); }
}
</style>