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:
@@ -2,6 +2,7 @@ import { computed } from 'vue'
|
||||
import { libraryService } from '../services/library.service'
|
||||
import { subscriptionService } from '../services/subscription.service'
|
||||
import { useAuthStore } from '../stores/auth'
|
||||
import { USE_MOCK } from '../utils/mock'
|
||||
|
||||
/**
|
||||
* Access Control Composable
|
||||
@@ -27,10 +28,7 @@ export function useAccess() {
|
||||
return { hasAccess: true, method: 'subscription' }
|
||||
}
|
||||
|
||||
// Check if we're in development mode
|
||||
const useMockData = import.meta.env.VITE_USE_MOCK_DATA === 'true' || import.meta.env.DEV
|
||||
|
||||
if (useMockData) {
|
||||
if (USE_MOCK) {
|
||||
// In dev mode without subscription, no access (prompt rental)
|
||||
return { hasAccess: false }
|
||||
}
|
||||
|
||||
@@ -131,14 +131,18 @@ export function useAccounts() {
|
||||
}
|
||||
|
||||
/**
|
||||
* Logout current account
|
||||
* Logout current account.
|
||||
* removeAccount already clears the active account if it's the one being removed.
|
||||
*/
|
||||
function logout() {
|
||||
const current = accountManager.active
|
||||
if (current) {
|
||||
accountManager.removeAccount(current)
|
||||
try {
|
||||
accountManager.removeAccount(current)
|
||||
} catch (err) {
|
||||
console.debug('Account removal cleanup:', err)
|
||||
}
|
||||
}
|
||||
accountManager.setActive(null as any)
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
340
src/composables/useFilmmaker.ts
Normal file
340
src/composables/useFilmmaker.ts
Normal file
@@ -0,0 +1,340 @@
|
||||
/**
|
||||
* useFilmmaker composable
|
||||
*
|
||||
* Reactive state and actions for the filmmaker/creator dashboard.
|
||||
* In dev mode without a backend, uses local mock data so the
|
||||
* backstage UI is fully functional for prototyping.
|
||||
*/
|
||||
|
||||
import { ref, computed } from 'vue'
|
||||
import { filmmakerService } from '../services/filmmaker.service'
|
||||
import type {
|
||||
ApiProject,
|
||||
ApiFilmmakerAnalytics,
|
||||
ApiWatchAnalytics,
|
||||
ApiPaymentMethod,
|
||||
ApiPayment,
|
||||
ApiGenre,
|
||||
CreateProjectData,
|
||||
UpdateProjectData,
|
||||
ProjectType,
|
||||
} from '../types/api'
|
||||
import { USE_MOCK } from '../utils/mock'
|
||||
|
||||
// ── Shared reactive state (singleton across components) ─────────────────────────
|
||||
const projects = ref<ApiProject[]>([])
|
||||
const projectsCount = ref(0)
|
||||
const isLoading = ref(false)
|
||||
const error = ref<string | null>(null)
|
||||
const genres = ref<ApiGenre[]>([])
|
||||
|
||||
// Analytics state
|
||||
const analytics = ref<ApiFilmmakerAnalytics | null>(null)
|
||||
const watchAnalytics = ref<ApiWatchAnalytics | null>(null)
|
||||
|
||||
// Payment state
|
||||
const paymentMethods = ref<ApiPaymentMethod[]>([])
|
||||
const payments = ref<ApiPayment[]>([])
|
||||
|
||||
// Filters
|
||||
const activeTab = ref<'projects' | 'resume' | 'stakeholder'>('projects')
|
||||
const typeFilter = ref<ProjectType | null>(null)
|
||||
const sortBy = ref<'a-z' | 'z-a' | 'recent'>('recent')
|
||||
const searchQuery = ref('')
|
||||
|
||||
// ── Mock data helpers ────────────────────────────────────────────────────────────
|
||||
|
||||
let mockIdCounter = 1
|
||||
|
||||
function createMockProject(data: CreateProjectData): ApiProject {
|
||||
const id = `mock-project-${mockIdCounter++}`
|
||||
const now = new Date().toISOString()
|
||||
return {
|
||||
id,
|
||||
name: data.name,
|
||||
title: data.name,
|
||||
type: data.type,
|
||||
status: 'draft',
|
||||
slug: data.name.toLowerCase().replace(/\s+/g, '-'),
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
} as ApiProject
|
||||
}
|
||||
|
||||
const MOCK_GENRES: ApiGenre[] = [
|
||||
{ id: '1', name: 'Documentary', slug: 'documentary' },
|
||||
{ id: '2', name: 'Drama', slug: 'drama' },
|
||||
{ id: '3', name: 'Thriller', slug: 'thriller' },
|
||||
{ id: '4', name: 'Comedy', slug: 'comedy' },
|
||||
{ id: '5', name: 'Sci-Fi', slug: 'sci-fi' },
|
||||
{ id: '6', name: 'Animation', slug: 'animation' },
|
||||
{ id: '7', name: 'Horror', slug: 'horror' },
|
||||
{ id: '8', name: 'Action', slug: 'action' },
|
||||
{ id: '9', name: 'Romance', slug: 'romance' },
|
||||
{ id: '10', name: 'Music', slug: 'music' },
|
||||
]
|
||||
|
||||
// ── Composable ───────────────────────────────────────────────────────────────────
|
||||
|
||||
export function useFilmmaker() {
|
||||
/**
|
||||
* Sorted and filtered projects based on active filters
|
||||
*/
|
||||
const filteredProjects = computed(() => {
|
||||
let result = [...projects.value]
|
||||
|
||||
// Filter by type
|
||||
if (typeFilter.value) {
|
||||
result = result.filter((p) => p.type === typeFilter.value)
|
||||
}
|
||||
|
||||
// Filter by search
|
||||
if (searchQuery.value.trim()) {
|
||||
const q = searchQuery.value.toLowerCase()
|
||||
result = result.filter(
|
||||
(p) =>
|
||||
p.name?.toLowerCase().includes(q) ||
|
||||
p.title?.toLowerCase().includes(q)
|
||||
)
|
||||
}
|
||||
|
||||
// Sort
|
||||
if (sortBy.value === 'a-z') {
|
||||
result.sort((a, b) => (a.title || a.name || '').localeCompare(b.title || b.name || ''))
|
||||
} else if (sortBy.value === 'z-a') {
|
||||
result.sort((a, b) => (b.title || b.name || '').localeCompare(a.title || a.name || ''))
|
||||
} else {
|
||||
result.sort(
|
||||
(a, b) =>
|
||||
new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime()
|
||||
)
|
||||
}
|
||||
|
||||
return result
|
||||
})
|
||||
|
||||
// ── Project Actions ─────────────────────────────────────────────────────────
|
||||
|
||||
async function fetchProjects() {
|
||||
isLoading.value = true
|
||||
error.value = null
|
||||
try {
|
||||
if (USE_MOCK) {
|
||||
// Mock mode: projects are already in local state
|
||||
projectsCount.value = projects.value.length
|
||||
return
|
||||
}
|
||||
const [list, count] = await Promise.all([
|
||||
filmmakerService.getPrivateProjects({
|
||||
type: typeFilter.value || undefined,
|
||||
search: searchQuery.value || undefined,
|
||||
}),
|
||||
filmmakerService.getPrivateProjectsCount({
|
||||
type: typeFilter.value || undefined,
|
||||
search: searchQuery.value || undefined,
|
||||
}),
|
||||
])
|
||||
projects.value = list
|
||||
projectsCount.value = count
|
||||
} catch (err: any) {
|
||||
error.value = err.message || 'Failed to load projects'
|
||||
console.debug('Failed to load projects:', err)
|
||||
} finally {
|
||||
isLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function createNewProject(data: CreateProjectData): Promise<ApiProject | null> {
|
||||
isLoading.value = true
|
||||
error.value = null
|
||||
try {
|
||||
let project: ApiProject
|
||||
if (USE_MOCK) {
|
||||
// Simulate brief delay
|
||||
await new Promise(resolve => setTimeout(resolve, 300))
|
||||
project = createMockProject(data)
|
||||
} else {
|
||||
project = await filmmakerService.createProject(data)
|
||||
}
|
||||
// Add to local list
|
||||
projects.value = [project, ...projects.value]
|
||||
projectsCount.value++
|
||||
return project
|
||||
} catch (err: any) {
|
||||
error.value = err.message || 'Failed to create project'
|
||||
console.error('Failed to create project:', err)
|
||||
return null
|
||||
} finally {
|
||||
isLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function saveProject(id: string, data: UpdateProjectData): Promise<ApiProject | null> {
|
||||
error.value = null
|
||||
try {
|
||||
let updated: ApiProject
|
||||
if (USE_MOCK) {
|
||||
await new Promise(resolve => setTimeout(resolve, 200))
|
||||
const idx = projects.value.findIndex((p) => p.id === id)
|
||||
if (idx === -1) throw new Error('Project not found')
|
||||
updated = { ...projects.value[idx], ...data, updatedAt: new Date().toISOString() } as ApiProject
|
||||
projects.value[idx] = updated
|
||||
} else {
|
||||
updated = await filmmakerService.updateProject(id, data)
|
||||
const idx = projects.value.findIndex((p) => p.id === id)
|
||||
if (idx !== -1) projects.value[idx] = updated
|
||||
}
|
||||
return updated
|
||||
} catch (err: any) {
|
||||
error.value = err.message || 'Failed to save project'
|
||||
console.error('Failed to save project:', err)
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
async function removeProject(id: string): Promise<boolean> {
|
||||
error.value = null
|
||||
try {
|
||||
if (USE_MOCK) {
|
||||
await new Promise(resolve => setTimeout(resolve, 200))
|
||||
} else {
|
||||
await filmmakerService.deleteProject(id)
|
||||
}
|
||||
projects.value = projects.value.filter((p) => p.id !== id)
|
||||
projectsCount.value--
|
||||
return true
|
||||
} catch (err: any) {
|
||||
error.value = err.message || 'Failed to delete project'
|
||||
console.error('Failed to delete project:', err)
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
// ── Analytics Actions ───────────────────────────────────────────────────────
|
||||
|
||||
async function fetchAnalytics(projectIds?: string[]) {
|
||||
isLoading.value = true
|
||||
error.value = null
|
||||
try {
|
||||
if (USE_MOCK) {
|
||||
analytics.value = {
|
||||
balance: 125000,
|
||||
totalEarnings: 450000,
|
||||
myTotalEarnings: 360000,
|
||||
averageSharePercentage: 80,
|
||||
}
|
||||
watchAnalytics.value = {
|
||||
viewsByDate: {},
|
||||
trailerViews: 3891,
|
||||
averageWatchTime: 2340,
|
||||
streamingRevenueSats: 280000,
|
||||
rentalRevenueSats: 170000,
|
||||
purchasesCount: 89,
|
||||
purchasesByContent: [],
|
||||
revenueByDate: {},
|
||||
}
|
||||
return
|
||||
}
|
||||
const [analyticData, watchData] = await Promise.all([
|
||||
filmmakerService.getFilmmakerAnalytics(),
|
||||
filmmakerService.getWatchAnalytics(projectIds),
|
||||
])
|
||||
analytics.value = analyticData
|
||||
watchAnalytics.value = watchData
|
||||
} catch (err: any) {
|
||||
error.value = err.message || 'Failed to load analytics'
|
||||
console.error('Failed to load analytics:', err)
|
||||
} finally {
|
||||
isLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// ── Payment Actions ─────────────────────────────────────────────────────────
|
||||
|
||||
async function fetchPaymentMethods() {
|
||||
try {
|
||||
if (USE_MOCK) {
|
||||
paymentMethods.value = []
|
||||
return
|
||||
}
|
||||
paymentMethods.value = await filmmakerService.getPaymentMethods()
|
||||
} catch (err: any) {
|
||||
console.error('Failed to load payment methods:', err)
|
||||
}
|
||||
}
|
||||
|
||||
async function fetchPayments(filmId?: string) {
|
||||
try {
|
||||
if (USE_MOCK) {
|
||||
payments.value = []
|
||||
return
|
||||
}
|
||||
payments.value = await filmmakerService.getPayments(filmId)
|
||||
} catch (err: any) {
|
||||
console.error('Failed to load payments:', err)
|
||||
}
|
||||
}
|
||||
|
||||
// ── Single Project Fetch ────────────────────────────────────────────────────
|
||||
|
||||
async function getProject(id: string): Promise<ApiProject | null> {
|
||||
error.value = null
|
||||
try {
|
||||
if (USE_MOCK) {
|
||||
// Look up from local projects list
|
||||
const found = projects.value.find((p) => p.id === id)
|
||||
return found || null
|
||||
}
|
||||
return await filmmakerService.getPrivateProject(id)
|
||||
} catch (err: any) {
|
||||
error.value = err.message || 'Failed to load project'
|
||||
console.error('Failed to load project:', err)
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
// ── Genres ──────────────────────────────────────────────────────────────────
|
||||
|
||||
async function fetchGenres() {
|
||||
try {
|
||||
if (USE_MOCK) {
|
||||
genres.value = MOCK_GENRES
|
||||
return
|
||||
}
|
||||
genres.value = await filmmakerService.getGenres()
|
||||
} catch (err: any) {
|
||||
console.error('Failed to load genres:', err)
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
// State
|
||||
projects,
|
||||
projectsCount,
|
||||
filteredProjects,
|
||||
isLoading,
|
||||
error,
|
||||
genres,
|
||||
analytics,
|
||||
watchAnalytics,
|
||||
paymentMethods,
|
||||
payments,
|
||||
|
||||
// Filters
|
||||
activeTab,
|
||||
typeFilter,
|
||||
sortBy,
|
||||
searchQuery,
|
||||
|
||||
// Actions
|
||||
fetchProjects,
|
||||
createNewProject,
|
||||
getProject,
|
||||
saveProject,
|
||||
removeProject,
|
||||
fetchAnalytics,
|
||||
fetchPaymentMethods,
|
||||
fetchPayments,
|
||||
fetchGenres,
|
||||
}
|
||||
}
|
||||
186
src/composables/useUpload.ts
Normal file
186
src/composables/useUpload.ts
Normal file
@@ -0,0 +1,186 @@
|
||||
/**
|
||||
* useUpload composable
|
||||
*
|
||||
* Chunked multipart upload with progress tracking.
|
||||
* Ported from the original indeehub-frontend uploader library.
|
||||
* Works with both the original API and our self-hosted MinIO backend.
|
||||
*/
|
||||
|
||||
import { ref, computed } from 'vue'
|
||||
import { filmmakerService } from '../services/filmmaker.service'
|
||||
import axios from 'axios'
|
||||
|
||||
const CHUNK_SIZE = 20 * 1024 * 1024 // 20 MB
|
||||
const MAX_PARALLEL_UPLOADS = 6
|
||||
const MAX_RETRIES = 3
|
||||
|
||||
export interface UploadItem {
|
||||
id: string
|
||||
file: File
|
||||
key: string
|
||||
bucket: string
|
||||
progress: number
|
||||
status: 'pending' | 'uploading' | 'completed' | 'failed'
|
||||
error?: string
|
||||
}
|
||||
|
||||
// Shared upload queue (singleton across components)
|
||||
const uploadQueue = ref<UploadItem[]>([])
|
||||
const isUploading = ref(false)
|
||||
|
||||
export function useUpload() {
|
||||
const totalProgress = computed(() => {
|
||||
if (uploadQueue.value.length === 0) return 0
|
||||
const total = uploadQueue.value.reduce((sum, item) => sum + item.progress, 0)
|
||||
return Math.round(total / uploadQueue.value.length)
|
||||
})
|
||||
|
||||
const activeUploads = computed(() =>
|
||||
uploadQueue.value.filter((u) => u.status === 'uploading')
|
||||
)
|
||||
|
||||
const completedUploads = computed(() =>
|
||||
uploadQueue.value.filter((u) => u.status === 'completed')
|
||||
)
|
||||
|
||||
/**
|
||||
* Add a file to the upload queue and start uploading
|
||||
*/
|
||||
async function addUpload(file: File, key: string, bucket: string = 'indeedhub-private'): Promise<string | null> {
|
||||
const item: UploadItem = {
|
||||
id: `upload-${Date.now()}-${Math.random().toString(36).slice(2)}`,
|
||||
file,
|
||||
key,
|
||||
bucket,
|
||||
progress: 0,
|
||||
status: 'pending',
|
||||
}
|
||||
|
||||
uploadQueue.value.push(item)
|
||||
return processUpload(item)
|
||||
}
|
||||
|
||||
/**
|
||||
* Process a single upload: initialize, chunk, upload, finalize
|
||||
*/
|
||||
async function processUpload(item: UploadItem): Promise<string | null> {
|
||||
try {
|
||||
item.status = 'uploading'
|
||||
isUploading.value = true
|
||||
|
||||
// Step 1: Initialize multipart upload
|
||||
const { UploadId, Key } = await filmmakerService.initializeUpload(
|
||||
item.key,
|
||||
item.bucket,
|
||||
item.file.type
|
||||
)
|
||||
|
||||
// Step 2: Calculate chunks
|
||||
const totalChunks = Math.ceil(item.file.size / CHUNK_SIZE)
|
||||
|
||||
// Step 3: Get presigned URLs for all chunks
|
||||
const { parts: presignedParts } = await filmmakerService.getPresignedUrls(
|
||||
UploadId,
|
||||
Key,
|
||||
item.bucket,
|
||||
totalChunks
|
||||
)
|
||||
|
||||
// Step 4: Upload chunks in parallel with progress tracking
|
||||
const completedParts: Array<{ PartNumber: number; ETag: string }> = []
|
||||
let uploadedChunks = 0
|
||||
|
||||
// Process chunks in batches of MAX_PARALLEL_UPLOADS
|
||||
for (let batchStart = 0; batchStart < presignedParts.length; batchStart += MAX_PARALLEL_UPLOADS) {
|
||||
const batch = presignedParts.slice(batchStart, batchStart + MAX_PARALLEL_UPLOADS)
|
||||
|
||||
const batchResults = await Promise.all(
|
||||
batch.map(async (part) => {
|
||||
const start = (part.PartNumber - 1) * CHUNK_SIZE
|
||||
const end = Math.min(start + CHUNK_SIZE, item.file.size)
|
||||
const chunk = item.file.slice(start, end)
|
||||
|
||||
// Upload with retries
|
||||
let lastError: Error | null = null
|
||||
for (let attempt = 0; attempt < MAX_RETRIES; attempt++) {
|
||||
try {
|
||||
const response = await axios.put(part.signedUrl, chunk, {
|
||||
headers: { 'Content-Type': item.file.type },
|
||||
onUploadProgress: () => {
|
||||
// Progress is tracked at the chunk level
|
||||
},
|
||||
})
|
||||
|
||||
uploadedChunks++
|
||||
item.progress = Math.round((uploadedChunks / totalChunks) * 100)
|
||||
|
||||
const etag = response.headers.etag || response.headers.ETag
|
||||
return {
|
||||
PartNumber: part.PartNumber,
|
||||
ETag: etag?.replace(/"/g, '') || '',
|
||||
}
|
||||
} catch (err: any) {
|
||||
lastError = err
|
||||
// Exponential backoff
|
||||
await new Promise((resolve) =>
|
||||
setTimeout(resolve, Math.pow(2, attempt) * 1000)
|
||||
)
|
||||
}
|
||||
}
|
||||
throw lastError || new Error(`Failed to upload part ${part.PartNumber}`)
|
||||
})
|
||||
)
|
||||
|
||||
completedParts.push(...batchResults)
|
||||
}
|
||||
|
||||
// Step 5: Finalize
|
||||
await filmmakerService.finalizeUpload(UploadId, Key, item.bucket, completedParts)
|
||||
|
||||
item.status = 'completed'
|
||||
item.progress = 100
|
||||
|
||||
// Check if all uploads done
|
||||
if (uploadQueue.value.every((u) => u.status !== 'uploading' && u.status !== 'pending')) {
|
||||
isUploading.value = false
|
||||
}
|
||||
|
||||
return Key
|
||||
} catch (err: any) {
|
||||
item.status = 'failed'
|
||||
item.error = err.message || 'Upload failed'
|
||||
console.error('Upload failed:', err)
|
||||
|
||||
if (uploadQueue.value.every((u) => u.status !== 'uploading' && u.status !== 'pending')) {
|
||||
isUploading.value = false
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove completed or failed upload from queue
|
||||
*/
|
||||
function removeUpload(id: string) {
|
||||
uploadQueue.value = uploadQueue.value.filter((u) => u.id !== id)
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear all completed uploads
|
||||
*/
|
||||
function clearCompleted() {
|
||||
uploadQueue.value = uploadQueue.value.filter((u) => u.status !== 'completed')
|
||||
}
|
||||
|
||||
return {
|
||||
uploadQueue,
|
||||
isUploading,
|
||||
totalProgress,
|
||||
activeUploads,
|
||||
completedUploads,
|
||||
addUpload,
|
||||
removeUpload,
|
||||
clearCompleted,
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user