Enhance payment processing and rental features
- Updated the BTCPay service to support internal Lightning invoices with private route hints, improving payment routing for users with private channels. - Added reconciliation methods for pending rents and subscriptions to ensure missed payments are processed on startup. - Enhanced the rental and subscription services to handle payments in satoshis, aligning with Lightning Network standards. - Improved the rental modal and content detail components to display rental status and pricing more clearly, including a countdown for rental expiration. - Refactored various components to streamline user experience and ensure accurate rental access checks.
This commit is contained in:
@@ -7,10 +7,10 @@
|
||||
<UploadZone
|
||||
label="Drag & drop your video file, or click to browse"
|
||||
accept="video/*"
|
||||
:current-file="uploads.file.fileName || (project as any)?.file"
|
||||
:current-file="uploads.file.fileName || existingFile"
|
||||
:status="uploads.file.status"
|
||||
:progress="uploads.file.progress"
|
||||
:file-name="uploads.file.fileName"
|
||||
:file-name="uploads.file.fileName || existingFileLabel"
|
||||
@file-selected="(f: File) => handleFileUpload('file', f)"
|
||||
/>
|
||||
</div>
|
||||
@@ -37,10 +37,10 @@
|
||||
<UploadZone
|
||||
label="Upload trailer video"
|
||||
accept="video/*"
|
||||
:current-file="uploads.trailer.fileName || project?.trailer"
|
||||
:current-file="uploads.trailer.fileName || existingTrailer"
|
||||
:status="uploads.trailer.status"
|
||||
:progress="uploads.trailer.progress"
|
||||
:file-name="uploads.trailer.fileName"
|
||||
:file-name="uploads.trailer.fileName || existingTrailerLabel"
|
||||
@file-selected="(f: File) => handleFileUpload('trailer', f)"
|
||||
/>
|
||||
</div>
|
||||
@@ -63,12 +63,13 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { reactive } from 'vue'
|
||||
import { reactive, computed } from 'vue'
|
||||
import type { ApiProject } from '../../types/api'
|
||||
import UploadZone from './UploadZone.vue'
|
||||
import { useUpload } from '../../composables/useUpload'
|
||||
import { USE_MOCK } from '../../utils/mock'
|
||||
|
||||
defineProps<{
|
||||
const props = defineProps<{
|
||||
project: ApiProject | null
|
||||
}>()
|
||||
|
||||
@@ -76,6 +77,31 @@ const emit = defineEmits<{
|
||||
(e: 'update', field: string, value: any): void
|
||||
}>()
|
||||
|
||||
const { addUpload } = useUpload()
|
||||
|
||||
/**
|
||||
* Extract the human-readable file name from an S3 key.
|
||||
* e.g. "projects/abc/file/uuid.mp4" → "uuid.mp4"
|
||||
*/
|
||||
function labelFromKey(key: string | undefined | null): string {
|
||||
if (!key) return ''
|
||||
const parts = key.split('/')
|
||||
return parts[parts.length - 1] || key
|
||||
}
|
||||
|
||||
// The actual video file lives in the nested content entity (project.film.file)
|
||||
const existingFile = computed(() => props.project?.film?.file || '')
|
||||
const existingFileLabel = computed(() => labelFromKey(existingFile.value))
|
||||
|
||||
// Trailer can be on the content entity's trailer object or the top-level project
|
||||
const existingTrailer = computed(() => {
|
||||
const filmTrailer = (props.project?.film as any)?.trailer
|
||||
// trailer might be an object with a `file` property or a string
|
||||
if (filmTrailer && typeof filmTrailer === 'object') return filmTrailer.file || ''
|
||||
return filmTrailer || props.project?.trailer || ''
|
||||
})
|
||||
const existingTrailerLabel = computed(() => labelFromKey(existingTrailer.value))
|
||||
|
||||
interface UploadState {
|
||||
status: 'idle' | 'uploading' | 'completed' | 'error'
|
||||
progress: number
|
||||
@@ -90,6 +116,17 @@ const uploads = reactive<Record<string, UploadState>>({
|
||||
subtitles: { status: 'idle', progress: 0, fileName: '', previewUrl: '' },
|
||||
})
|
||||
|
||||
/**
|
||||
* Build an S3 key for a given field and file.
|
||||
* Pattern: projects/{projectId}/{field}/{uuid}.{ext}
|
||||
*/
|
||||
function buildS3Key(field: string, file: File): string {
|
||||
const projectId = props.project?.id ?? 'unknown'
|
||||
const ext = file.name.split('.').pop() || 'bin'
|
||||
const uuid = crypto.randomUUID()
|
||||
return `projects/${projectId}/${field}/${uuid}.${ext}`
|
||||
}
|
||||
|
||||
/**
|
||||
* Simulate upload progress for development mode
|
||||
* Mimics realistic chunked upload behavior with variable speed
|
||||
@@ -143,16 +180,36 @@ async function handleFileUpload(field: string, file: File) {
|
||||
const value = state.previewUrl || file.name
|
||||
emit('update', field, value)
|
||||
} else {
|
||||
// Real mode: trigger actual upload (handled by parent/service)
|
||||
// Real mode: upload to MinIO via multipart upload
|
||||
state.status = 'uploading'
|
||||
state.fileName = file.name
|
||||
state.progress = 0
|
||||
|
||||
const s3Key = buildS3Key(field, file)
|
||||
// Posters go to the public bucket so they're directly accessible via URL
|
||||
const isPublicAsset = field === 'poster'
|
||||
const bucket = isPublicAsset
|
||||
? (import.meta.env.VITE_S3_PUBLIC_BUCKET || 'indeedhub-public')
|
||||
: (import.meta.env.VITE_S3_PRIVATE_BUCKET || 'indeedhub-private')
|
||||
|
||||
try {
|
||||
// Emit to parent, which would handle the real upload
|
||||
emit('update', field, file)
|
||||
} catch {
|
||||
const resultKey = await addUpload(file, s3Key, bucket, (progress, status) => {
|
||||
// Real-time progress callback from the chunked uploader
|
||||
state.progress = progress
|
||||
if (status === 'uploading') state.status = 'uploading'
|
||||
})
|
||||
|
||||
if (resultKey) {
|
||||
state.status = 'completed'
|
||||
state.progress = 100
|
||||
// Emit the S3 key so the parent can save it to the project
|
||||
emit('update', field, resultKey)
|
||||
} else {
|
||||
state.status = 'error'
|
||||
}
|
||||
} catch (err: any) {
|
||||
state.status = 'error'
|
||||
console.error(`Upload failed for ${field}:`, err.message)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -69,15 +69,15 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Genres -->
|
||||
<!-- Genre -->
|
||||
<div>
|
||||
<label class="field-label">Genres</label>
|
||||
<label class="field-label">Genre</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'"
|
||||
@click="selectGenre(genre.id)"
|
||||
:class="selectedGenreId === genre.id ? 'genre-tag-active' : 'genre-tag'"
|
||||
>
|
||||
{{ genre.name }}
|
||||
</button>
|
||||
@@ -90,7 +90,7 @@
|
||||
<label class="field-label">Release Date</label>
|
||||
<input
|
||||
type="date"
|
||||
:value="project?.releaseDate?.split('T')[0]"
|
||||
:value="(project?.film?.releaseDate ?? project?.releaseDate)?.split('T')[0]"
|
||||
@input="emit('update', 'releaseDate', ($event.target as HTMLInputElement).value)"
|
||||
class="field-input"
|
||||
/>
|
||||
@@ -111,27 +111,28 @@ const emit = defineEmits<{
|
||||
(e: 'update', field: string, value: any): void
|
||||
}>()
|
||||
|
||||
const selectedGenres = ref<string[]>([])
|
||||
const selectedGenreId = ref<string | null>(null)
|
||||
|
||||
// Sync genres from project
|
||||
// Sync genre from project (backend returns `genre` as a single object)
|
||||
watch(
|
||||
() => props.project?.genres,
|
||||
(genres) => {
|
||||
if (genres) {
|
||||
selectedGenres.value = genres.map((g) => g.slug)
|
||||
() => (props.project as any)?.genre,
|
||||
(genre) => {
|
||||
if (genre?.id) {
|
||||
selectedGenreId.value = genre.id
|
||||
}
|
||||
},
|
||||
{ immediate: true }
|
||||
)
|
||||
|
||||
function toggleGenre(slug: string) {
|
||||
const idx = selectedGenres.value.indexOf(slug)
|
||||
if (idx === -1) {
|
||||
selectedGenres.value.push(slug)
|
||||
function selectGenre(id: string) {
|
||||
// Toggle: clicking the already-selected genre deselects it
|
||||
if (selectedGenreId.value === id) {
|
||||
selectedGenreId.value = null
|
||||
emit('update', 'genreId', null)
|
||||
} else {
|
||||
selectedGenres.value.splice(idx, 1)
|
||||
selectedGenreId.value = id
|
||||
emit('update', 'genreId', id)
|
||||
}
|
||||
emit('update', 'genres', [...selectedGenres.value])
|
||||
}
|
||||
</script>
|
||||
|
||||
|
||||
@@ -8,7 +8,7 @@
|
||||
<div class="relative flex-1 max-w-xs">
|
||||
<input
|
||||
type="number"
|
||||
:value="project?.rentalPrice"
|
||||
:value="project?.film?.rentalPrice ?? project?.rentalPrice"
|
||||
@input="emit('update', 'rentalPrice', Number(($event.target as HTMLInputElement).value))"
|
||||
class="field-input pr-12"
|
||||
placeholder="0"
|
||||
|
||||
Reference in New Issue
Block a user