|
|
@@ -4,6 +4,7 @@ import { useState, useEffect, useCallback, useRef } from 'react';
|
|
|
import { useParams, useRouter } from 'next/navigation';
|
|
|
import { useAuth } from '@/lib/auth-context';
|
|
|
import { projectsApi, assetsApi, invitationsApi, Project, Asset, Invitation, TranscodeStatus } from '@/lib/api';
|
|
|
+import { AssetCard } from '@/components/ui/AssetCard';
|
|
|
import { useDropzone } from 'react-dropzone';
|
|
|
import { TranscodeTasksPanel } from '@/components/transcode/TranscodeTasksPanel';
|
|
|
|
|
|
@@ -37,6 +38,30 @@ const ROLE_LABELS: Record<string, string> = {
|
|
|
VIEWER: 'Viewer',
|
|
|
};
|
|
|
|
|
|
+function formatGroupDate(d: Date): string {
|
|
|
+ const now = new Date();
|
|
|
+ const today = new Date(now.getFullYear(), now.getMonth(), now.getDate());
|
|
|
+ const yesterday = new Date(today.getTime() - 86400000);
|
|
|
+ const videoDay = new Date(d.getFullYear(), d.getMonth(), d.getDate());
|
|
|
+ if (videoDay.getTime() === today.getTime()) return 'Today';
|
|
|
+ if (videoDay.getTime() === yesterday.getTime()) return 'Yesterday';
|
|
|
+ return d.toLocaleDateString('en-US', { weekday: 'long', month: 'long', day: 'numeric' });
|
|
|
+}
|
|
|
+
|
|
|
+function groupByDay(assets: Asset[]): [string, Asset[]][] {
|
|
|
+ const sorted = [...assets].sort(
|
|
|
+ (a, b) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime()
|
|
|
+ );
|
|
|
+ const groups: Record<string, Asset[]> = {};
|
|
|
+ for (const a of sorted) {
|
|
|
+ const d = new Date(a.createdAt);
|
|
|
+ const day = new Date(d.getFullYear(), d.getMonth(), d.getDate()).toISOString();
|
|
|
+ if (!groups[day]) groups[day] = [];
|
|
|
+ groups[day].push(a);
|
|
|
+ }
|
|
|
+ return Object.entries(groups);
|
|
|
+}
|
|
|
+
|
|
|
export default function ProjectDetailPage() {
|
|
|
const params = useParams();
|
|
|
const projectId = params.projectId as string;
|
|
|
@@ -352,7 +377,7 @@ export default function ProjectDetailPage() {
|
|
|
<div className="min-h-screen" style={{ background: 'var(--bg)' }}>
|
|
|
|
|
|
{/* Header */}
|
|
|
- <header className="sticky top-0 z-10 px-4 md:px-8 py-3 md:py-4 flex items-center gap-3 md:gap-5 shrink-0"
|
|
|
+ <header className="sticky top-0 z-10 px-4 md:px-8 py-3 md:py-4 flex items-center gap-2 md:gap-5 shrink-0 flex-wrap"
|
|
|
style={{
|
|
|
background: 'rgba(10,11,20,0.80)',
|
|
|
backdropFilter: 'blur(12px)',
|
|
|
@@ -360,16 +385,16 @@ export default function ProjectDetailPage() {
|
|
|
}}>
|
|
|
<button
|
|
|
onClick={() => router.push('/projects')}
|
|
|
- className="flex items-center gap-1.5 text-sm transition-colors"
|
|
|
+ className="flex items-center gap-1.5 text-sm transition-colors shrink-0"
|
|
|
style={{ color: 'var(--text-muted)' }}
|
|
|
>
|
|
|
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
|
|
<path strokeLinecap="round" strokeLinejoin="round" d="M15 19l-7-7 7-7" />
|
|
|
</svg>
|
|
|
- Projects
|
|
|
+ <span className="hidden sm:inline">Projects</span>
|
|
|
</button>
|
|
|
|
|
|
- <div className="w-px h-4" style={{ background: 'rgba(255,255,255,0.10)' }} />
|
|
|
+ <div className="w-px h-4 hidden sm:block shrink-0" style={{ background: 'rgba(255,255,255,0.10)' }} />
|
|
|
|
|
|
<div className="flex-1 min-w-0">
|
|
|
<div className="flex items-center gap-2">
|
|
|
@@ -390,62 +415,74 @@ export default function ProjectDetailPage() {
|
|
|
)}
|
|
|
</div>
|
|
|
{project?.description && (
|
|
|
- <p className="text-xs truncate mt-0.5" style={{ color: 'var(--text-muted)' }}>
|
|
|
+ <p className="text-xs truncate mt-0.5 hidden sm:block" style={{ color: 'var(--text-muted)' }}>
|
|
|
{project.description}
|
|
|
</p>
|
|
|
)}
|
|
|
</div>
|
|
|
|
|
|
- {/* Tabs */}
|
|
|
- <div className="flex items-center gap-1 p-1 rounded-lg"
|
|
|
+ {/* Tabs — icon only on mobile, icon+label on sm+ */}
|
|
|
+ <div className="flex items-center gap-1 p-1 rounded-lg shrink-0"
|
|
|
style={{ background: 'rgba(255,255,255,0.04)' }}>
|
|
|
- {[['videos', 'Videos', assets.length], ['transcode', 'Transcode Tasks', assets.filter(a => a.transcodeStatus !== 'COMPLETED').length], ['members', 'Members', members.length]].map(([tab, label, count]) => (
|
|
|
+ {[
|
|
|
+ { tab: 'videos', label: 'Videos', count: assets.length },
|
|
|
+ { tab: 'transcode', label: 'Transcode Tasks', count: assets.filter(a => a.transcodeStatus !== 'COMPLETED').length },
|
|
|
+ { tab: 'members', label: 'Members', count: members.length },
|
|
|
+ ].map(({ tab, label, count }) => (
|
|
|
<button key={tab}
|
|
|
onClick={() => setActiveTab(tab as any)}
|
|
|
- className="px-3 py-1.5 rounded-md text-xs font-medium transition-all flex items-center gap-1.5"
|
|
|
+ className="relative px-2 sm:px-3 py-1.5 rounded-md text-xs font-medium transition-all flex items-center gap-1.5 shrink-0"
|
|
|
style={{
|
|
|
background: activeTab === tab ? 'rgba(99,102,241,0.20)' : 'transparent',
|
|
|
color: activeTab === tab ? '#A5B4FC' : 'var(--text-muted)',
|
|
|
- }}>
|
|
|
- {label}
|
|
|
- {tab !== 'members' && (count as number) > 0 && (
|
|
|
- <span className="text-[10px] px-1.5 py-0.5 rounded-full"
|
|
|
- style={{
|
|
|
- background: tab === 'transcode'
|
|
|
- ? 'rgba(167,139,250,0.25)'
|
|
|
- : 'rgba(255,255,255,0.06)',
|
|
|
- color: tab === 'transcode' ? '#A78BFA' : 'inherit',
|
|
|
- }}>
|
|
|
- {count}
|
|
|
- </span>
|
|
|
+ }}
|
|
|
+ title={label}
|
|
|
+ >
|
|
|
+ {tab === 'videos' && (
|
|
|
+ <svg className="w-4 h-4 shrink-0" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
|
|
|
+ <path strokeLinecap="round" strokeLinejoin="round" d="M15.75 10.5l4.72-4.72a.75.75 0 011.28.53v11.38a.75.75 0 01-1.28.53l-4.72-4.72M4.5 18.75h9a2.25 2.25 0 002.25-2.25v-9a2.25 2.25 0 00-2.25-2.25h-9A2.25 2.25 0 002.25 7.5v9a2.25 2.25 0 002.25 2.25z" />
|
|
|
+ </svg>
|
|
|
+ )}
|
|
|
+ {tab === 'transcode' && (
|
|
|
+ <svg className="w-4 h-4 shrink-0" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
|
|
|
+ <path strokeLinecap="round" strokeLinejoin="round" d="M19.5 12c0-1.232-.046-2.453-.138-3.662a4.006 4.006 0 00-3.7-3.7 48.678 48.678 0 00-7.324 0 4.006 4.006 0 00-3.7 3.7c-.017.22-.032.441-.046.662M19.5 12l3-3m-3 3l-3-3m-12 3c0 1.232.046 2.453.138 3.662a4.006 4.006 0 003.7 3.7 48.656 48.656 0 007.324 0 4.006 4.006 0 003.7-3.7c.017-.22.032-.441.046-.662M4.5 12l3 3m-3-3l-3 3" />
|
|
|
+ </svg>
|
|
|
)}
|
|
|
{tab === 'members' && (
|
|
|
- <span className="ml-0.5 text-[10px] px-1.5 py-0.5 rounded-full"
|
|
|
- style={{ background: 'rgba(255,255,255,0.06)', color: 'inherit' }}>
|
|
|
- {members.length}
|
|
|
- </span>
|
|
|
+ <svg className="w-4 h-4 shrink-0" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
|
|
|
+ <path strokeLinecap="round" strokeLinejoin="round" d="M15 19.128a9.38 9.38 0 002.625.372 9.337 9.337 0 004.121-.952 4.125 4.125 0 00-7.533-2.493M15 19.128v-.003c0-1.113-.285-2.16-.786-3.07M15 19.128v.106A12.318 12.318 0 018.624 21c-2.331 0-4.512-.645-6.374-1.766l-.001-.109a6.375 6.375 0 0111.964-3.07M12 6.375a3.375 3.375 0 11-6.75 0 3.375 3.375 0 016.75 0zm8.25 2.25a2.625 2.625 0 11-5.25 0 2.625 2.625 0 015.25 0z" />
|
|
|
+ </svg>
|
|
|
)}
|
|
|
+ <span className="hidden sm:inline">{label}</span>
|
|
|
+ <span className="text-[10px] px-1 py-0.5 rounded-full"
|
|
|
+ style={{
|
|
|
+ background: tab === 'transcode'
|
|
|
+ ? 'rgba(167,139,250,0.25)'
|
|
|
+ : 'rgba(255,255,255,0.06)',
|
|
|
+ color: tab === 'transcode' ? '#A78BFA' : 'inherit',
|
|
|
+ }}>
|
|
|
+ {count}
|
|
|
+ </span>
|
|
|
</button>
|
|
|
))}
|
|
|
</div>
|
|
|
|
|
|
- <div className="text-xs px-2 py-1.5 md:px-2.5 md:py-1 rounded-full shrink-0"
|
|
|
+ <div className="text-xs px-2 py-1.5 rounded-full shrink-0"
|
|
|
style={{ background: 'rgba(255,255,255,0.05)', color: 'var(--text-muted)' }}>
|
|
|
- {assets.length} video{assets.length !== 1 ? 's' : ''}
|
|
|
+ {assets.length}
|
|
|
</div>
|
|
|
|
|
|
{/* Delete project — owner only */}
|
|
|
{isOwner && (
|
|
|
<button
|
|
|
onClick={() => setConfirmDeleteProject(true)}
|
|
|
- className="flex items-center gap-1.5 text-xs px-2.5 py-1.5 rounded-lg transition-all shrink-0"
|
|
|
+ className="flex items-center justify-center p-1.5 rounded-lg transition-all shrink-0"
|
|
|
style={{ color: '#F87171', background: 'rgba(248,113,113,0.08)' }}
|
|
|
title="Delete project"
|
|
|
>
|
|
|
<svg className="w-3.5 h-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
|
|
<path strokeLinecap="round" strokeLinejoin="round" d="M14.74 9l-.346 9m-4.788 0L9.26 9m9.968-3.21c.342.052.682.107 1.022.166m-1.022-.165L18.16 19.673a2.25 2.25 0 01-2.244 2.077H8.084a2.25 2.25 0 01-2.244-2.077L4.772 5.79m14.456 0a48.108 48.108 0 00-3.478-.397m-12 .562c.34-.059.68-.114 1.022-.165m0 0a48.11 48.11 0 013.478-.397m7.5 0v-.916c0-1.18-.91-2.164-2.09-2.201a51.964 51.964 0 00-3.32 0c-1.18.037-2.09 1.022-2.09 2.201v.916m7.5 0a48.667 48.667 0 00-7.5 0" />
|
|
|
</svg>
|
|
|
- <span className="hidden sm:inline">Delete</span>
|
|
|
</button>
|
|
|
)}
|
|
|
</header>
|
|
|
@@ -508,7 +545,7 @@ export default function ProjectDetailPage() {
|
|
|
</div>
|
|
|
)}
|
|
|
|
|
|
- {/* Asset grid */}
|
|
|
+ {/* Asset grid — grouped by date */}
|
|
|
{assets.length === 0 ? (
|
|
|
<div className="text-center py-20 rounded-2xl animate-fade-in"
|
|
|
style={{ background: 'rgba(255,255,255,0.02)', border: '1px solid rgba(255,255,255,0.06)' }}>
|
|
|
@@ -522,194 +559,72 @@ export default function ProjectDetailPage() {
|
|
|
<p className="text-xs" style={{ color: 'var(--text-muted)' }}>Upload your first video using the dropzone above</p>
|
|
|
</div>
|
|
|
) : (
|
|
|
- <div className="grid grid-cols-1 md:grid-cols-2 xl:grid-cols-3 gap-4">
|
|
|
- {assets.map((asset, i) => (
|
|
|
- <div key={asset.id}
|
|
|
- className="card overflow-hidden group"
|
|
|
- style={{ animation: `slideUp 0.25s ease-out ${i * 40}ms both` }}>
|
|
|
-
|
|
|
- {/* Thumbnail */}
|
|
|
- <div className="relative aspect-video" style={{ background: '#080810' }} onClick={() => router.push(`/review/${asset.id}`)}>
|
|
|
- {/* Play overlay — only show when ready */}
|
|
|
- {asset.transcodeStatus === 'COMPLETED' && (
|
|
|
- <>
|
|
|
- {asset.thumbnail ? (
|
|
|
- <img
|
|
|
- src={`/uploads/${asset.thumbnail}`}
|
|
|
- alt={asset.title}
|
|
|
- className="w-full h-full object-cover"
|
|
|
- style={{ opacity: 0.85 }}
|
|
|
- />
|
|
|
- ) : (
|
|
|
- <div className="w-full h-full flex items-center justify-center">
|
|
|
- <svg className="w-10 h-10" style={{ color: 'rgba(255,255,255,0.15)' }} fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1}>
|
|
|
- <path strokeLinecap="round" strokeLinejoin="round" d="M14.752 11.168l-3.197-2.132A1 1 0 0010 9.87v4.263a1 1 0 001.555.832l3.197-2.132a1 1 0 000-1.664z" />
|
|
|
- <path strokeLinecap="round" strokeLinejoin="round" d="M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
|
|
|
- </svg>
|
|
|
- </div>
|
|
|
- )}
|
|
|
- <div className="absolute inset-0 flex items-center justify-center opacity-0 group-hover:opacity-100 transition-opacity duration-200"
|
|
|
- style={{ background: 'rgba(0,0,0,0.35)' }}>
|
|
|
- <div className="w-12 h-12 rounded-full flex items-center justify-center"
|
|
|
- style={{ background: 'rgba(99,102,241,0.80)', boxShadow: '0 0 24px rgba(99,102,241,0.4)' }}>
|
|
|
- <svg className="w-5 h-5 text-white ml-0.5" fill="currentColor" viewBox="0 0 24 24">
|
|
|
- <path d="M8 5v14l11-7z" />
|
|
|
- </svg>
|
|
|
- </div>
|
|
|
- </div>
|
|
|
- </>
|
|
|
- )}
|
|
|
-
|
|
|
- {/* Not ready — show transcode status overlay */}
|
|
|
- {asset.transcodeStatus !== 'COMPLETED' && (
|
|
|
- <div className="absolute inset-0 flex flex-col items-center justify-center gap-3">
|
|
|
- {/* Animated spinner */}
|
|
|
- {['UPLOADING', 'PROCESSING', 'PENDING'].includes(asset.transcodeStatus) && (
|
|
|
- <div className="w-10 h-10 rounded-full animate-spin"
|
|
|
- style={{ borderColor: transcodeColors[asset.transcodeStatus]?.dot.replace('bg-','#').replace('-400','' ) || '#6366F1', borderTopColor: 'transparent', borderWidth: '3px' }} />
|
|
|
- )}
|
|
|
- {/* Error icon */}
|
|
|
- {asset.transcodeStatus === 'FAILED' && (
|
|
|
- <div className="w-10 h-10 rounded-full flex items-center justify-center"
|
|
|
- style={{ background: 'rgba(248,113,113,0.15)' }}>
|
|
|
- <svg className="w-5 h-5" style={{ color: '#F87171' }} fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
|
|
- <path strokeLinecap="round" strokeLinejoin="round" d="M12 9v3.75m-9.303 3.376c-.866 1.5.217 3.374 1.948 3.374h14.71c1.73 0 2.813-1.874 1.948-3.374L13.949 3.378c-.866-1.5-3.032-1.5-3.898 0L2.697 16.126zM12 15.75h.007v.008H12v-.008z" />
|
|
|
- </svg>
|
|
|
- </div>
|
|
|
- )}
|
|
|
- {asset.transcodeStatus === 'UNSUPPORTED_CODEC' && (
|
|
|
- <div className="w-10 h-10 rounded-full flex items-center justify-center"
|
|
|
- style={{ background: 'rgba(251,191,36,0.15)' }}>
|
|
|
- <svg className="w-5 h-5" style={{ color: '#FBBF24' }} fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
|
|
- <path strokeLinecap="round" strokeLinejoin="round" d="M12 9v3.75m-9.303 3.376c-.866 1.5.217 3.374 1.948 3.374h14.71c1.73 0 2.813-1.874 1.948-3.374L13.949 3.378c-.866-1.5-3.032-1.5-3.898 0L2.697 16.126zM12 15.75h.007v.008H12v-.008z" />
|
|
|
- </svg>
|
|
|
- </div>
|
|
|
- )}
|
|
|
-
|
|
|
- {/* Status label */}
|
|
|
- <span className="text-xs font-medium px-2.5 py-1 rounded-full"
|
|
|
- style={{ background: transcodeColors[asset.transcodeStatus]?.bg, color: transcodeColors[asset.transcodeStatus]?.text }}>
|
|
|
- {transcodeLabels[asset.transcodeStatus]}
|
|
|
- </span>
|
|
|
- </div>
|
|
|
- )}
|
|
|
-
|
|
|
- {/* Progress bar */}
|
|
|
- {['UPLOADING', 'PROCESSING'].includes(asset.transcodeStatus) && (
|
|
|
- <div className="absolute bottom-0 left-0 right-0 h-1 overflow-hidden"
|
|
|
- style={{ background: 'rgba(0,0,0,0.3)' }}>
|
|
|
- <div
|
|
|
- className="h-full transition-all duration-500"
|
|
|
- style={{
|
|
|
- width: `${asset.transcodeProgress}%`,
|
|
|
- background: 'linear-gradient(90deg, #818CF8, #A78BFA)',
|
|
|
- }}
|
|
|
- />
|
|
|
- </div>
|
|
|
- )}
|
|
|
-
|
|
|
- {/* Duration badge */}
|
|
|
- {asset.duration && asset.transcodeStatus === 'COMPLETED' && (
|
|
|
- <span className="absolute bottom-2 right-2 text-xs px-1.5 py-0.5 rounded-md font-mono"
|
|
|
- style={{ background: 'rgba(0,0,0,0.70)', color: '#E2E8F0' }}>
|
|
|
- {(() => { const m = Math.floor(asset.duration! / 60); const s = Math.floor(asset.duration! % 60); return `${m}:${s.toString().padStart(2,'0')}`; })()}
|
|
|
+ <div className="space-y-8">
|
|
|
+ {groupByDay(assets).map(([dayKey, dayAssets]) => {
|
|
|
+ const groupDate = new Date(dayKey);
|
|
|
+ const showHour = dayAssets.length > 1;
|
|
|
+ return (
|
|
|
+ <div key={dayKey}>
|
|
|
+ {/* Date group header */}
|
|
|
+ <div className="flex items-center gap-3 mb-4">
|
|
|
+ <span className="text-xs font-semibold shrink-0" style={{ color: 'var(--text-muted)' }}>
|
|
|
+ {formatGroupDate(groupDate)}
|
|
|
</span>
|
|
|
- )}
|
|
|
-
|
|
|
- {/* Codec badge */}
|
|
|
- {asset.codec && asset.transcodeStatus !== 'COMPLETED' && (
|
|
|
- <span className="absolute top-2 left-2 text-[10px] px-1.5 py-0.5 rounded font-mono"
|
|
|
- style={{ background: 'rgba(0,0,0,0.6)', color: '#94A3B8' }}>
|
|
|
- {asset.codec}
|
|
|
+ <div className="flex-1 h-px" style={{ background: 'rgba(255,255,255,0.06)' }} />
|
|
|
+ <span className="text-[10px] shrink-0" style={{ color: 'var(--text-subtle)' }}>
|
|
|
+ {dayAssets.length} video{dayAssets.length !== 1 ? 's' : ''}
|
|
|
</span>
|
|
|
- )}
|
|
|
- </div>
|
|
|
-
|
|
|
- {/* Info */}
|
|
|
- <div className="p-4">
|
|
|
- <div className="flex items-start justify-between gap-2 mb-1.5">
|
|
|
- <h3 className="text-sm font-medium truncate flex-1 transition-colors"
|
|
|
- style={{ color: 'var(--text)' }}>
|
|
|
- {asset.title}
|
|
|
- </h3>
|
|
|
- <span className={`badge shrink-0 ${statusColors[asset.status]}`}>
|
|
|
- {statusLabels[asset.status]}
|
|
|
- </span>
|
|
|
- </div>
|
|
|
-
|
|
|
- {/* Uploader + date */}
|
|
|
- <div className="flex items-center gap-1.5 mb-2 text-[11px]" style={{ color: 'var(--text-muted)' }}>
|
|
|
- <svg className="w-3 h-3 shrink-0" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
|
|
|
- <path strokeLinecap="round" strokeLinejoin="round" d="M15.75 6a3.75 3.75 0 11-7.5 0 3.75 3.75 0 017.5 0zM4.501 20.118a7.5 7.5 0 0114.998 0A17.933 17.933 0 0112 21.75c-2.676 0-5.216-.584-7.499-1.632z" />
|
|
|
- </svg>
|
|
|
- <span className="truncate">{asset.uploader?.name ?? 'Unknown'}</span>
|
|
|
- <span className="shrink-0 text-[10px]" style={{ color: 'var(--text-subtle)' }}>·</span>
|
|
|
- <span className="shrink-0 text-[10px]">
|
|
|
- {new Date(asset.createdAt).toLocaleDateString('en-US', { month: 'short', day: 'numeric', year: 'numeric' })}
|
|
|
- </span>
|
|
|
- <span className="shrink-0 text-[10px]" style={{ color: 'var(--text-subtle)' }}>·</span>
|
|
|
- <span className="shrink-0 text-[10px] truncate">{asset.filename}</span>
|
|
|
</div>
|
|
|
-
|
|
|
- {/* Transcode status row */}
|
|
|
- {asset.transcodeStatus !== 'COMPLETED' && (
|
|
|
- <div className="mb-2 flex items-center gap-1.5">
|
|
|
- <div
|
|
|
- className={`w-1.5 h-1.5 rounded-full shrink-0 ${['UPLOADING','PROCESSING'].includes(asset.transcodeStatus) ? 'animate-pulse' : ''} ${transcodeColors[asset.transcodeStatus]?.dot}`}
|
|
|
+ <div className="grid grid-cols-1 md:grid-cols-2 xl:grid-cols-3 gap-4">
|
|
|
+ {dayAssets.map((asset, i) => (
|
|
|
+ <AssetCard
|
|
|
+ key={asset.id}
|
|
|
+ asset={asset}
|
|
|
+ canManage={canManage}
|
|
|
+ showHour={showHour}
|
|
|
+ onPlay={() => router.push(`/review/${asset.id}`)}
|
|
|
+ onDelete={() => handleDeleteAsset(asset.id, asset.title)}
|
|
|
+ onCancel={async (id) => {
|
|
|
+ if (!token) return;
|
|
|
+ try {
|
|
|
+ await assetsApi.cancelTranscode(token, id);
|
|
|
+ setAssets(prev => prev.map(a => a.id === id ? {
|
|
|
+ ...a,
|
|
|
+ transcodeStatus: 'PENDING',
|
|
|
+ transcodeProgress: 0,
|
|
|
+ transcodeError: null,
|
|
|
+ hlsPath: null,
|
|
|
+ transcodePaused: false,
|
|
|
+ } : a));
|
|
|
+ } catch (err) {
|
|
|
+ alert(err instanceof Error ? err.message : 'Failed to cancel transcode');
|
|
|
+ }
|
|
|
+ }}
|
|
|
+ onPause={async (id) => {
|
|
|
+ if (!token) return;
|
|
|
+ try {
|
|
|
+ await assetsApi.pauseTranscode(token, id);
|
|
|
+ setAssets(prev => prev.map(a => a.id === id ? { ...a, transcodePaused: true } : a));
|
|
|
+ } catch (err) {
|
|
|
+ alert(err instanceof Error ? err.message : 'Failed to pause transcode');
|
|
|
+ }
|
|
|
+ }}
|
|
|
+ onResume={async (id) => {
|
|
|
+ if (!token) return;
|
|
|
+ try {
|
|
|
+ await assetsApi.resumeTranscode(token, id);
|
|
|
+ setAssets(prev => prev.map(a => a.id === id ? { ...a, transcodePaused: false } : a));
|
|
|
+ } catch (err) {
|
|
|
+ alert(err instanceof Error ? err.message : 'Failed to resume transcode');
|
|
|
+ }
|
|
|
+ }}
|
|
|
+ animationDelay={i * 40}
|
|
|
/>
|
|
|
- <span className="text-[11px] truncate" style={{ color: transcodeColors[asset.transcodeStatus]?.text }}>
|
|
|
- {transcodeLabels[asset.transcodeStatus]}
|
|
|
- {['UPLOADING', 'PROCESSING'].includes(asset.transcodeStatus) && asset.transcodeProgress > 0
|
|
|
- ? ` — ${asset.transcodeProgress}%`
|
|
|
- : ''}
|
|
|
- </span>
|
|
|
- {asset.transcodeStatus === 'FAILED' && asset.transcodeError && (
|
|
|
- <span className="text-[10px] truncate" style={{ color: '#F87171' }}>
|
|
|
- : {asset.transcodeError}
|
|
|
- </span>
|
|
|
- )}
|
|
|
- {asset.transcodeStatus === 'UNSUPPORTED_CODEC' && (
|
|
|
- <span className="text-[10px] truncate" style={{ color: '#FB923C' }}>
|
|
|
- — will re-encode to H.264
|
|
|
- </span>
|
|
|
- )}
|
|
|
- </div>
|
|
|
- )}
|
|
|
-
|
|
|
- <div className="flex items-center gap-2 text-xs" style={{ color: 'var(--text-muted)' }}>
|
|
|
- <span>{(asset as any)._count?.comments ?? 0} comment{((asset as any)._count?.comments ?? 0) !== 1 ? 's' : ''}</span>
|
|
|
- <span className="w-1 h-1 rounded-full" style={{ background: 'rgba(255,255,255,0.12)' }} />
|
|
|
- <span>{new Date(asset.createdAt).toLocaleDateString('en-US', { month: 'short', day: 'numeric' })}</span>
|
|
|
- <div className="flex-1" />
|
|
|
-
|
|
|
- {/* Download */}
|
|
|
- <a
|
|
|
- href={`/uploads/${asset.filePath}`}
|
|
|
- download={asset.filename}
|
|
|
- onClick={e => e.stopPropagation()}
|
|
|
- className="p-1 rounded transition-colors hover:bg-blue-500/20 flex-shrink-0"
|
|
|
- title="Download original"
|
|
|
- >
|
|
|
- <svg className="w-3.5 h-3.5" style={{ color: '#60A5FA' }} fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
|
|
- <path strokeLinecap="round" strokeLinejoin="round" d="M3 16.5v2.25A2.25 2.25 0 005.25 21h13.5A2.25 2.25 0 0021 18.75V16.5M16.5 12L12 16.5m0 0L7.5 12m4.5 4.5V3" />
|
|
|
- </svg>
|
|
|
- </a>
|
|
|
-
|
|
|
- {canManage && (
|
|
|
- <button
|
|
|
- onClick={(e) => { e.stopPropagation(); handleDeleteAsset(asset.id, asset.title); }}
|
|
|
- className="p-1 rounded transition-colors hover:bg-red-500/20 flex-shrink-0"
|
|
|
- title="Delete video"
|
|
|
- >
|
|
|
- <svg className="w-3.5 h-3.5" style={{ color: '#F87171' }} fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
|
|
- <path strokeLinecap="round" strokeLinejoin="round" d="M14.74 9l-.346 9m-4.788 0L9.26 9m9.968-3.21c.342.052.682.107 1.022.166m-1.022-.165L18.16 19.673a2.25 2.25 0 01-2.244 2.077H8.084a2.25 2.25 0 01-2.244-2.077L4.772 5.79m14.456 0a48.108 48.108 0 00-3.478-.397m-12 .562c.34-.059.68-.114 1.022-.165m0 0a48.11 48.11 0 013.478-.397m7.5 0v-.916c0-1.18-.91-2.164-2.09-2.201a51.964 51.964 0 00-3.32 0c-1.18.037-2.09 1.022-2.09 2.201v.916m7.5 0a48.667 48.667 0 00-7.5 0" />
|
|
|
- </svg>
|
|
|
- </button>
|
|
|
- )}
|
|
|
+ ))}
|
|
|
</div>
|
|
|
</div>
|
|
|
- </div>
|
|
|
- ))}
|
|
|
+ );
|
|
|
+ })}
|
|
|
</div>
|
|
|
)}
|
|
|
</>
|
|
|
@@ -733,11 +648,30 @@ export default function ProjectDetailPage() {
|
|
|
transcodeProgress: 0,
|
|
|
transcodeError: null,
|
|
|
hlsPath: null,
|
|
|
+ transcodePaused: false,
|
|
|
} : a));
|
|
|
} catch (err) {
|
|
|
alert(err instanceof Error ? err.message : 'Failed to cancel transcode');
|
|
|
}
|
|
|
}}
|
|
|
+ onPause={async (id) => {
|
|
|
+ if (!token) return;
|
|
|
+ try {
|
|
|
+ await assetsApi.pauseTranscode(token, id);
|
|
|
+ setAssets(prev => prev.map(a => a.id === id ? { ...a, transcodePaused: true } : a));
|
|
|
+ } catch (err) {
|
|
|
+ alert(err instanceof Error ? err.message : 'Failed to pause transcode');
|
|
|
+ }
|
|
|
+ }}
|
|
|
+ onResume={async (id) => {
|
|
|
+ if (!token) return;
|
|
|
+ try {
|
|
|
+ await assetsApi.resumeTranscode(token, id);
|
|
|
+ setAssets(prev => prev.map(a => a.id === id ? { ...a, transcodePaused: false } : a));
|
|
|
+ } catch (err) {
|
|
|
+ alert(err instanceof Error ? err.message : 'Failed to resume transcode');
|
|
|
+ }
|
|
|
+ }}
|
|
|
/>
|
|
|
</div>
|
|
|
)}
|