|
@@ -3,17 +3,18 @@
|
|
|
import { useState, useEffect, useCallback, useRef } from 'react';
|
|
import { useState, useEffect, useCallback, useRef } from 'react';
|
|
|
import { useParams, useRouter } from 'next/navigation';
|
|
import { useParams, useRouter } from 'next/navigation';
|
|
|
import { useAuth } from '@/lib/auth-context';
|
|
import { useAuth } from '@/lib/auth-context';
|
|
|
-import { projectsApi, assetsApi, invitationsApi, Project, Asset, Invitation, TranscodeStatus } from '@/lib/api';
|
|
|
|
|
|
|
+import { projectsApi, assetsApi, invitationsApi, foldersApi, Project, Asset, Invitation, TranscodeStatus, FolderNode } from '@/lib/api';
|
|
|
import { AssetCard } from '@/components/ui/AssetCard';
|
|
import { AssetCard } from '@/components/ui/AssetCard';
|
|
|
-import { useDropzone } from 'react-dropzone';
|
|
|
|
|
|
|
+import { FolderTree } from '@/components/folders/FolderTree';
|
|
|
|
|
+import { ShareModal } from '@/components/share/ShareModal';
|
|
|
import { TranscodeTasksPanel } from '@/components/transcode/TranscodeTasksPanel';
|
|
import { TranscodeTasksPanel } from '@/components/transcode/TranscodeTasksPanel';
|
|
|
|
|
+import { useDropzone } from 'react-dropzone';
|
|
|
|
|
|
|
|
async function safeCopy(text: string): Promise<void> {
|
|
async function safeCopy(text: string): Promise<void> {
|
|
|
if (typeof window === 'undefined') return;
|
|
if (typeof window === 'undefined') return;
|
|
|
if (navigator.clipboard?.writeText) {
|
|
if (navigator.clipboard?.writeText) {
|
|
|
try { await navigator.clipboard.writeText(text); } catch { /* ignore */ }
|
|
try { await navigator.clipboard.writeText(text); } catch { /* ignore */ }
|
|
|
} else {
|
|
} else {
|
|
|
- // Fallback: create a temp input so we can use execCommand on insecure contexts
|
|
|
|
|
const el = document.createElement('textarea');
|
|
const el = document.createElement('textarea');
|
|
|
el.value = text;
|
|
el.value = text;
|
|
|
el.style.cssText = 'position:fixed;top:-999px;left:-999px;opacity:0';
|
|
el.style.cssText = 'position:fixed;top:-999px;left:-999px;opacity:0';
|
|
@@ -62,6 +63,75 @@ function groupByDay(assets: Asset[]): [string, Asset[]][] {
|
|
|
return Object.entries(groups);
|
|
return Object.entries(groups);
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
|
|
+/** Collect asset IDs DIRECTLY in a folder (not from subfolders) */
|
|
|
|
|
+function collectAssetIds(folders: FolderNode[], targetId: string | null): Set<string> {
|
|
|
|
|
+ const ids = new Set<string>();
|
|
|
|
|
+ if (targetId === null) return ids; // "All Videos" — no filter
|
|
|
|
|
+
|
|
|
|
|
+ function findTarget(f: FolderNode): FolderNode | null {
|
|
|
|
|
+ if (f.id === targetId) return f;
|
|
|
|
|
+ for (const c of f.children) { const r = findTarget(c); if (r) return r; }
|
|
|
|
|
+ return null;
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ for (const f of folders) {
|
|
|
|
|
+ const target = findTarget(f);
|
|
|
|
|
+ if (target) { for (const id of target.assetIds) ids.add(id); break; }
|
|
|
|
|
+ }
|
|
|
|
|
+ return ids;
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+/** Get direct subfolders of a folder */
|
|
|
|
|
+function getSubfolders(folders: FolderNode[], targetId: string | null): FolderNode[] {
|
|
|
|
|
+ if (targetId === null) return folders; // root: show top-level folders
|
|
|
|
|
+
|
|
|
|
|
+ function findTarget(f: FolderNode): FolderNode | null {
|
|
|
|
|
+ if (f.id === targetId) return f;
|
|
|
|
|
+ for (const c of f.children) { const r = findTarget(c); if (r) return r; }
|
|
|
|
|
+ return null;
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ for (const f of folders) {
|
|
|
|
|
+ const target = findTarget(f);
|
|
|
|
|
+ if (target) return [...target.children].sort((a, b) => a.order - b.order || a.name.localeCompare(b.name));
|
|
|
|
|
+ }
|
|
|
|
|
+ return [];
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+/** Build a map of assetId -> folder names it belongs to */
|
|
|
|
|
+function buildAssetFolders(allFolders: FolderNode[]): Map<string, string[]> {
|
|
|
|
|
+ const map = new Map<string, string[]>();
|
|
|
|
|
+ function addFolders(f: FolderNode, trail: string[]): void {
|
|
|
|
|
+ for (const id of f.assetIds) {
|
|
|
|
|
+ if (!map.has(id)) map.set(id, []);
|
|
|
|
|
+ map.get(id)!.push(f.name);
|
|
|
|
|
+ }
|
|
|
|
|
+ for (const child of f.children) addFolders(child, [...trail, f.name]);
|
|
|
|
|
+ }
|
|
|
|
|
+ for (const f of allFolders) addFolders(f, []);
|
|
|
|
|
+ return map;
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+/** Get the folder names an asset belongs to */
|
|
|
|
|
+function getAssetFolderNames(assetFolders: Map<string, string[]>, assetId: string): string[] {
|
|
|
|
|
+ return assetFolders.get(assetId) ?? [];
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+/** Returns a breadcrumb path of folder names for the selected folder */
|
|
|
|
|
+function getBreadcrumb(folders: FolderNode[], targetId: string | null): string[] {
|
|
|
|
|
+ if (targetId === null) return [];
|
|
|
|
|
+ const path: string[] = [];
|
|
|
|
|
+ function search(f: FolderNode, trail: string[]): boolean {
|
|
|
|
|
+ if (f.id === targetId) { path.push(...trail, f.name); return true; }
|
|
|
|
|
+ for (const child of f.children) {
|
|
|
|
|
+ if (search(child, [...trail, f.name])) return true;
|
|
|
|
|
+ }
|
|
|
|
|
+ return false;
|
|
|
|
|
+ }
|
|
|
|
|
+ for (const f of folders) if (search(f, [])) break;
|
|
|
|
|
+ return path;
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
export default function ProjectDetailPage() {
|
|
export default function ProjectDetailPage() {
|
|
|
const params = useParams();
|
|
const params = useParams();
|
|
|
const projectId = params.projectId as string;
|
|
const projectId = params.projectId as string;
|
|
@@ -72,8 +142,13 @@ export default function ProjectDetailPage() {
|
|
|
const [members, setMembers] = useState<any[]>([]);
|
|
const [members, setMembers] = useState<any[]>([]);
|
|
|
const [pendingInvites, setPendingInvites] = useState<Invitation[]>([]);
|
|
const [pendingInvites, setPendingInvites] = useState<Invitation[]>([]);
|
|
|
const [assets, setAssets] = useState<Asset[]>([]);
|
|
const [assets, setAssets] = useState<Asset[]>([]);
|
|
|
|
|
+ const [folders, setFolders] = useState<FolderNode[]>([]);
|
|
|
|
|
+ const [allFolders, setAllFolders] = useState<FolderNode[]>([]);
|
|
|
|
|
+ const [selectedFolderId, setSelectedFolderId] = useState<string | null>(null);
|
|
|
|
|
+ const [viewMode, setViewMode] = useState<'file' | 'timeline'>('file');
|
|
|
const [loading, setLoading] = useState(true);
|
|
const [loading, setLoading] = useState(true);
|
|
|
const [uploading, setUploading] = useState(false);
|
|
const [uploading, setUploading] = useState(false);
|
|
|
|
|
+ const [sharingAssetId, setSharingAssetId] = useState<string | null>(null);
|
|
|
const [activeTab, setActiveTab] = useState<'videos' | 'members' | 'transcode'>('videos');
|
|
const [activeTab, setActiveTab] = useState<'videos' | 'members' | 'transcode'>('videos');
|
|
|
|
|
|
|
|
// Invite form state (single shared form)
|
|
// Invite form state (single shared form)
|
|
@@ -108,6 +183,41 @@ export default function ProjectDetailPage() {
|
|
|
m.user.id === user?.id && m.role === 'ADMIN'
|
|
m.user.id === user?.id && m.role === 'ADMIN'
|
|
|
);
|
|
);
|
|
|
|
|
|
|
|
|
|
+ // ── Folder data derived from state ──────────────────────────────────────────
|
|
|
|
|
+ // For file mode: only assets directly in the selected folder
|
|
|
|
|
+ const folderAssetIds = assets.length > 0
|
|
|
|
|
+ ? collectAssetIds(folders, selectedFolderId)
|
|
|
|
|
+ : new Set<string>();
|
|
|
|
|
+ // For timeline mode: assets in selected folder AND all its subfolders
|
|
|
|
|
+ const timelineAssetIds = (() => {
|
|
|
|
|
+ const ids = new Set<string>();
|
|
|
|
|
+ if (selectedFolderId === null) return ids;
|
|
|
|
|
+ function findTarget(f: FolderNode): FolderNode | null {
|
|
|
|
|
+ if (f.id === selectedFolderId) return f;
|
|
|
|
|
+ for (const c of f.children) { const r = findTarget(c); if (r) return r; }
|
|
|
|
|
+ return null;
|
|
|
|
|
+ }
|
|
|
|
|
+ function collectAll(f: FolderNode): void {
|
|
|
|
|
+ for (const id of f.assetIds) ids.add(id);
|
|
|
|
|
+ for (const c of f.children) collectAll(c);
|
|
|
|
|
+ }
|
|
|
|
|
+ for (const f of folders) {
|
|
|
|
|
+ const target = findTarget(f);
|
|
|
|
|
+ if (target) { collectAll(target); break; }
|
|
|
|
|
+ }
|
|
|
|
|
+ return ids;
|
|
|
|
|
+ })();
|
|
|
|
|
+ const filteredAssets = selectedFolderId === null
|
|
|
|
|
+ ? assets
|
|
|
|
|
+ : (folderAssetIds.size > 0 ? assets.filter(a => folderAssetIds.has(a.id)) : []);
|
|
|
|
|
+ // Timeline uses all assets in the selected folder AND its subfolders
|
|
|
|
|
+ const timelineAssets = selectedFolderId === null
|
|
|
|
|
+ ? assets
|
|
|
|
|
+ : (timelineAssetIds.size > 0 ? assets.filter(a => timelineAssetIds.has(a.id)) : []);
|
|
|
|
|
+ const subfolders = getSubfolders(folders, selectedFolderId);
|
|
|
|
|
+ const breadcrumb = getBreadcrumb(folders, selectedFolderId);
|
|
|
|
|
+ const assetFolders = buildAssetFolders(allFolders);
|
|
|
|
|
+
|
|
|
// ── Delete project ──────────────────────────────────────────────────────────
|
|
// ── Delete project ──────────────────────────────────────────────────────────
|
|
|
const [confirmDeleteProject, setConfirmDeleteProject] = useState(false);
|
|
const [confirmDeleteProject, setConfirmDeleteProject] = useState(false);
|
|
|
const [deletingProject, setDeletingProject] = useState(false);
|
|
const [deletingProject, setDeletingProject] = useState(false);
|
|
@@ -126,6 +236,17 @@ export default function ProjectDetailPage() {
|
|
|
}
|
|
}
|
|
|
};
|
|
};
|
|
|
|
|
|
|
|
|
|
+ const loadFolders = useCallback(async () => {
|
|
|
|
|
+ if (!token) return;
|
|
|
|
|
+ try {
|
|
|
|
|
+ const data = await foldersApi.list(token, projectId);
|
|
|
|
|
+ setFolders(data.folders);
|
|
|
|
|
+ setAllFolders(data.allFolders);
|
|
|
|
|
+ } catch (e) {
|
|
|
|
|
+ console.error('Failed to load folders:', e);
|
|
|
|
|
+ }
|
|
|
|
|
+ }, [token, projectId]);
|
|
|
|
|
+
|
|
|
const loadAll = useCallback(async () => {
|
|
const loadAll = useCallback(async () => {
|
|
|
if (!token) return;
|
|
if (!token) return;
|
|
|
try {
|
|
try {
|
|
@@ -149,6 +270,7 @@ export default function ProjectDetailPage() {
|
|
|
}, [token, projectId, router, canManage]);
|
|
}, [token, projectId, router, canManage]);
|
|
|
|
|
|
|
|
useEffect(() => { loadAll(); }, [loadAll]);
|
|
useEffect(() => { loadAll(); }, [loadAll]);
|
|
|
|
|
+ useEffect(() => { if (!loading && token) loadFolders(); }, [loading, token, loadFolders]);
|
|
|
|
|
|
|
|
// ── Invite member ──────────────────────────────────────────────────────────
|
|
// ── Invite member ──────────────────────────────────────────────────────────
|
|
|
const handleInvite = async (e: React.FormEvent) => {
|
|
const handleInvite = async (e: React.FormEvent) => {
|
|
@@ -188,7 +310,6 @@ export default function ProjectDetailPage() {
|
|
|
const { inviteUrl } = await invitationsApi.create(token, projectId, inviteEmail.trim(), inviteRole);
|
|
const { inviteUrl } = await invitationsApi.create(token, projectId, inviteEmail.trim(), inviteRole);
|
|
|
const { invitations } = await invitationsApi.list(token, projectId);
|
|
const { invitations } = await invitationsApi.list(token, projectId);
|
|
|
setPendingInvites(invitations.filter((i: Invitation) => i.status === 'PENDING'));
|
|
setPendingInvites(invitations.filter((i: Invitation) => i.status === 'PENDING'));
|
|
|
- // API returns full URL now (e.g. http://localhost:3000/invite/xxx)
|
|
|
|
|
await safeCopy(inviteUrl);
|
|
await safeCopy(inviteUrl);
|
|
|
setCreatedLink(inviteUrl);
|
|
setCreatedLink(inviteUrl);
|
|
|
setInviteEmail('');
|
|
setInviteEmail('');
|
|
@@ -257,6 +378,7 @@ export default function ProjectDetailPage() {
|
|
|
setTimeout(() => setCopiedInviteId(null), 2000);
|
|
setTimeout(() => setCopiedInviteId(null), 2000);
|
|
|
};
|
|
};
|
|
|
|
|
|
|
|
|
|
+ // ── Upload ─────────────────────────────────────────────────────────────────
|
|
|
const handleDrop = async (acceptedFiles: File[]) => {
|
|
const handleDrop = async (acceptedFiles: File[]) => {
|
|
|
if (!token || acceptedFiles.length === 0) return;
|
|
if (!token || acceptedFiles.length === 0) return;
|
|
|
setUploading(true);
|
|
setUploading(true);
|
|
@@ -276,46 +398,13 @@ export default function ProjectDetailPage() {
|
|
|
setUploading(false);
|
|
setUploading(false);
|
|
|
};
|
|
};
|
|
|
|
|
|
|
|
- const { getRootProps, getInputProps, isDragActive } = useDropzone({
|
|
|
|
|
|
|
+ const { getRootProps: getUploadRootProps, getInputProps: getUploadInputProps, isDragActive: isUploadDragActive } = useDropzone({
|
|
|
onDrop: handleDrop,
|
|
onDrop: handleDrop,
|
|
|
accept: { 'video/*': ['.mp4', '.mov', '.webm', '.avi', '.mpeg'] },
|
|
accept: { 'video/*': ['.mp4', '.mov', '.webm', '.avi', '.mpeg'] },
|
|
|
multiple: true,
|
|
multiple: true,
|
|
|
disabled: uploading,
|
|
disabled: uploading,
|
|
|
});
|
|
});
|
|
|
|
|
|
|
|
- const statusColors: Record<string, string> = {
|
|
|
|
|
- PENDING_REVIEW: 'status-pending',
|
|
|
|
|
- CHANGES_REQUESTED: 'status-changes',
|
|
|
|
|
- APPROVED: 'status-approved',
|
|
|
|
|
- REJECTED: 'status-rejected',
|
|
|
|
|
- };
|
|
|
|
|
-
|
|
|
|
|
- const statusLabels: Record<string, string> = {
|
|
|
|
|
- PENDING_REVIEW: 'Pending',
|
|
|
|
|
- CHANGES_REQUESTED: 'Changes',
|
|
|
|
|
- APPROVED: 'Approved',
|
|
|
|
|
- REJECTED: 'Rejected',
|
|
|
|
|
- };
|
|
|
|
|
-
|
|
|
|
|
- // ── Transcode status helpers ────────────────────────────────────────────────
|
|
|
|
|
- const transcodeColors: Record<TranscodeStatus, { text: string; dot: string; bg: string }> = {
|
|
|
|
|
- PENDING: { text: '#94A3B8', dot: 'bg-slate-400', bg: 'rgba(148,163,184,0.10)' },
|
|
|
|
|
- UPLOADING: { text: '#60A5FA', dot: 'bg-blue-400', bg: 'rgba(96,165,250,0.10)' },
|
|
|
|
|
- PROCESSING: { text: '#A78BFA', dot: 'bg-violet-400', bg: 'rgba(167,139,250,0.10)' },
|
|
|
|
|
- COMPLETED: { text: '#34D399', dot: 'bg-emerald-400', bg: 'rgba(52,211,153,0.10)' },
|
|
|
|
|
- FAILED: { text: '#F87171', dot: 'bg-red-400', bg: 'rgba(248,113,113,0.10)' },
|
|
|
|
|
- UNSUPPORTED_CODEC: { text: '#FBBF24', dot: 'bg-amber-400', bg: 'rgba(251,191,36,0.10)' },
|
|
|
|
|
- };
|
|
|
|
|
-
|
|
|
|
|
- const transcodeLabels: Record<TranscodeStatus, string> = {
|
|
|
|
|
- PENDING: 'Queued',
|
|
|
|
|
- UPLOADING: 'Uploading',
|
|
|
|
|
- PROCESSING: 'Processing',
|
|
|
|
|
- COMPLETED: 'Ready',
|
|
|
|
|
- FAILED: 'Failed',
|
|
|
|
|
- UNSUPPORTED_CODEC: 'Unsupported codec',
|
|
|
|
|
- };
|
|
|
|
|
-
|
|
|
|
|
// Poll for assets that are still processing
|
|
// Poll for assets that are still processing
|
|
|
const pollingRef = useRef<ReturnType<typeof setInterval> | null>(null);
|
|
const pollingRef = useRef<ReturnType<typeof setInterval> | null>(null);
|
|
|
|
|
|
|
@@ -327,6 +416,21 @@ export default function ProjectDetailPage() {
|
|
|
setConfirmDelete({ id, title });
|
|
setConfirmDelete({ id, title });
|
|
|
};
|
|
};
|
|
|
|
|
|
|
|
|
|
+ // ── Remove asset from a folder ──────────────────────────────────────────
|
|
|
|
|
+ const handleRemoveFromFolder = useCallback(async (assetId: string, folderName: string) => {
|
|
|
|
|
+ if (!token) return;
|
|
|
|
|
+ // Find the folder by name within the project
|
|
|
|
|
+ const folder = allFolders.find(f => f.name === folderName);
|
|
|
|
|
+ if (!folder) return;
|
|
|
|
|
+ try {
|
|
|
|
|
+ await foldersApi.removeAsset(token, folder.id, assetId);
|
|
|
|
|
+ // Refresh folder data so asset disappears from the folder
|
|
|
|
|
+ loadFolders();
|
|
|
|
|
+ } catch (err) {
|
|
|
|
|
+ console.error('Failed to remove from folder:', err);
|
|
|
|
|
+ }
|
|
|
|
|
+ }, [token, allFolders, loadFolders]);
|
|
|
|
|
+
|
|
|
const confirmDeleteAsset = async () => {
|
|
const confirmDeleteAsset = async () => {
|
|
|
if (!token || !confirmDelete) return;
|
|
if (!token || !confirmDelete) return;
|
|
|
setDeletingId(confirmDelete.id);
|
|
setDeletingId(confirmDelete.id);
|
|
@@ -340,6 +444,7 @@ export default function ProjectDetailPage() {
|
|
|
setDeletingId(null);
|
|
setDeletingId(null);
|
|
|
}
|
|
}
|
|
|
};
|
|
};
|
|
|
|
|
+
|
|
|
useEffect(() => {
|
|
useEffect(() => {
|
|
|
const processingAssets = assets.filter(a =>
|
|
const processingAssets = assets.filter(a =>
|
|
|
['UPLOADING', 'PROCESSING', 'PENDING'].includes(a.transcodeStatus)
|
|
['UPLOADING', 'PROCESSING', 'PENDING'].includes(a.transcodeStatus)
|
|
@@ -348,7 +453,7 @@ export default function ProjectDetailPage() {
|
|
|
if (pollingRef.current) { clearInterval(pollingRef.current); pollingRef.current = null; }
|
|
if (pollingRef.current) { clearInterval(pollingRef.current); pollingRef.current = null; }
|
|
|
return;
|
|
return;
|
|
|
}
|
|
}
|
|
|
- if (pollingRef.current) return; // already polling
|
|
|
|
|
|
|
+ if (pollingRef.current) return;
|
|
|
|
|
|
|
|
pollingRef.current = setInterval(async () => {
|
|
pollingRef.current = setInterval(async () => {
|
|
|
if (!token) return;
|
|
if (!token) return;
|
|
@@ -376,8 +481,25 @@ export default function ProjectDetailPage() {
|
|
|
return (
|
|
return (
|
|
|
<div className="min-h-screen" style={{ background: 'var(--bg)' }}>
|
|
<div className="min-h-screen" style={{ background: 'var(--bg)' }}>
|
|
|
|
|
|
|
|
|
|
+ {/* Full-page upload overlay when dragging files */}
|
|
|
|
|
+ {isUploadDragActive && (
|
|
|
|
|
+ <div {...getUploadRootProps()} className="upload-drop-overlay">
|
|
|
|
|
+ <input {...getUploadInputProps()} />
|
|
|
|
|
+ <div className="text-center">
|
|
|
|
|
+ <div className="w-16 h-16 rounded-2xl mx-auto mb-4 flex items-center justify-center"
|
|
|
|
|
+ style={{ background: 'rgba(99,102,241,0.15)', border: '2px solid rgba(99,102,241,0.4)' }}>
|
|
|
|
|
+ <svg className="w-8 h-8" style={{ color: '#818CF8' }} fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
|
|
|
|
|
+ <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.5m-13.5-9L12 3m0 0l4.5 4.5M12 3v13.5" />
|
|
|
|
|
+ </svg>
|
|
|
|
|
+ </div>
|
|
|
|
|
+ <p className="text-lg font-medium" style={{ color: 'var(--text)' }}>Drop videos to upload</p>
|
|
|
|
|
+ <p className="text-sm mt-1" style={{ color: 'var(--text-muted)' }}>MP4, MOV, WebM — up to 500MB each</p>
|
|
|
|
|
+ </div>
|
|
|
|
|
+ </div>
|
|
|
|
|
+ )}
|
|
|
|
|
+
|
|
|
{/* Header */}
|
|
{/* Header */}
|
|
|
- <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"
|
|
|
|
|
|
|
+ <header className="sticky top-0 z-10 px-4 md:px-8 py-3 md:py-4 flex items-center gap-2 md:gap-4 shrink-0 flex-wrap"
|
|
|
style={{
|
|
style={{
|
|
|
background: 'rgba(10,11,20,0.80)',
|
|
background: 'rgba(10,11,20,0.80)',
|
|
|
backdropFilter: 'blur(12px)',
|
|
backdropFilter: 'blur(12px)',
|
|
@@ -421,12 +543,32 @@ export default function ProjectDetailPage() {
|
|
|
)}
|
|
)}
|
|
|
</div>
|
|
</div>
|
|
|
|
|
|
|
|
- {/* Tabs — icon only on mobile, icon+label on sm+ */}
|
|
|
|
|
|
|
+ {/* Upload button — compact, in header */}
|
|
|
|
|
+ {canManage && (
|
|
|
|
|
+ <button
|
|
|
|
|
+ {...getUploadRootProps()}
|
|
|
|
|
+ className="flex items-center gap-1.5 text-xs px-3 py-1.5 rounded-lg shrink-0 transition-all"
|
|
|
|
|
+ style={{ background: 'rgba(99,102,241,0.12)', color: '#A5B4FC' }}
|
|
|
|
|
+ title="Upload video"
|
|
|
|
|
+ >
|
|
|
|
|
+ <input {...getUploadInputProps()} />
|
|
|
|
|
+ {uploading ? (
|
|
|
|
|
+ <div className="w-3.5 h-3.5 rounded-full animate-spin" style={{ borderColor: '#A5B4FC', borderTopColor: 'transparent', borderWidth: '2px' }} />
|
|
|
|
|
+ ) : (
|
|
|
|
|
+ <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="M3 16.5v2.25A2.25 2.25 0 005.25 21h13.5A2.25 2.25 0 0021 18.75V16.5m-13.5-9L12 3m0 0l4.5 4.5M12 3v13.5" />
|
|
|
|
|
+ </svg>
|
|
|
|
|
+ )}
|
|
|
|
|
+ <span className="hidden sm:inline">Upload</span>
|
|
|
|
|
+ </button>
|
|
|
|
|
+ )}
|
|
|
|
|
+
|
|
|
|
|
+ {/* Tabs */}
|
|
|
<div className="flex items-center gap-1 p-1 rounded-lg shrink-0"
|
|
<div className="flex items-center gap-1 p-1 rounded-lg shrink-0"
|
|
|
style={{ background: 'rgba(255,255,255,0.04)' }}>
|
|
style={{ background: 'rgba(255,255,255,0.04)' }}>
|
|
|
{[
|
|
{[
|
|
|
{ tab: 'videos', label: 'Videos', count: assets.length },
|
|
{ tab: 'videos', label: 'Videos', count: assets.length },
|
|
|
- { tab: 'transcode', label: 'Transcode Tasks', count: assets.filter(a => a.transcodeStatus !== 'COMPLETED').length },
|
|
|
|
|
|
|
+ { tab: 'transcode', label: 'Tasks', count: assets.filter(a => a.transcodeStatus !== 'COMPLETED').length },
|
|
|
{ tab: 'members', label: 'Members', count: members.length },
|
|
{ tab: 'members', label: 'Members', count: members.length },
|
|
|
].map(({ tab, label, count }) => (
|
|
].map(({ tab, label, count }) => (
|
|
|
<button key={tab}
|
|
<button key={tab}
|
|
@@ -467,11 +609,6 @@ export default function ProjectDetailPage() {
|
|
|
))}
|
|
))}
|
|
|
</div>
|
|
</div>
|
|
|
|
|
|
|
|
- <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}
|
|
|
|
|
- </div>
|
|
|
|
|
-
|
|
|
|
|
{/* Delete project — owner only */}
|
|
{/* Delete project — owner only */}
|
|
|
{isOwner && (
|
|
{isOwner && (
|
|
|
<button
|
|
<button
|
|
@@ -492,141 +629,307 @@ export default function ProjectDetailPage() {
|
|
|
{/* ── Videos Tab ───────────────────────────────────────────────────── */}
|
|
{/* ── Videos Tab ───────────────────────────────────────────────────── */}
|
|
|
{activeTab === 'videos' && (
|
|
{activeTab === 'videos' && (
|
|
|
<>
|
|
<>
|
|
|
- {/* Upload zone — only shown to EDITOR and ADMIN */}
|
|
|
|
|
- {canManage ? (
|
|
|
|
|
- <div
|
|
|
|
|
- {...getRootProps()}
|
|
|
|
|
- className="mb-8 rounded-2xl p-10 text-center cursor-pointer transition-all animate-fade-in"
|
|
|
|
|
- style={{
|
|
|
|
|
- background: isDragActive ? 'rgba(99,102,241,0.08)' : 'rgba(255,255,255,0.02)',
|
|
|
|
|
- border: isDragActive
|
|
|
|
|
- ? '1px solid rgba(99,102,241,0.40)'
|
|
|
|
|
- : '1px dashed rgba(255,255,255,0.10)',
|
|
|
|
|
- borderRadius: '16px',
|
|
|
|
|
- }}
|
|
|
|
|
- >
|
|
|
|
|
- <input {...getInputProps()} />
|
|
|
|
|
|
|
+ {/* File/Timeline mode toggle + breadcrumb bar */}
|
|
|
|
|
+ {activeTab === 'videos' && (
|
|
|
|
|
+ <div className="flex items-center gap-3 mb-5 flex-wrap">
|
|
|
|
|
+ {/* Breadcrumb */}
|
|
|
|
|
+ <nav className="flex items-center gap-1 text-xs shrink min-w-0" style={{ color: 'var(--text-muted)' }}>
|
|
|
|
|
+ <span className="truncate">{project?.name}</span>
|
|
|
|
|
+ {breadcrumb.map((name, i) => (
|
|
|
|
|
+ <span key={i} className="flex items-center gap-1 shrink-0">
|
|
|
|
|
+ <svg className="w-3 h-3 shrink-0" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
|
|
|
|
+ <path strokeLinecap="round" strokeLinejoin="round" d="M8.25 4.5l7.5 7.5-7.5 7.5" />
|
|
|
|
|
+ </svg>
|
|
|
|
|
+ <span className={i === breadcrumb.length - 1 ? '' : 'opacity-60'}>{name}</span>
|
|
|
|
|
+ </span>
|
|
|
|
|
+ ))}
|
|
|
|
|
+ </nav>
|
|
|
|
|
+
|
|
|
|
|
+ <div className="flex-1" />
|
|
|
|
|
+
|
|
|
|
|
+ {/* Asset count */}
|
|
|
|
|
+ <span className="text-xs px-2 py-1 rounded-full shrink-0"
|
|
|
|
|
+ style={{ background: 'rgba(255,255,255,0.05)', color: 'var(--text-muted)' }}>
|
|
|
|
|
+ {filteredAssets.length} video{filteredAssets.length !== 1 ? 's' : ''}
|
|
|
|
|
+ </span>
|
|
|
|
|
+
|
|
|
|
|
+ {/* Mode toggle */}
|
|
|
|
|
+ <div className="flex items-center gap-0.5 p-0.5 rounded-lg shrink-0"
|
|
|
|
|
+ style={{ background: 'rgba(255,255,255,0.05)' }}>
|
|
|
|
|
+ {[
|
|
|
|
|
+ { mode: 'file' as const, label: 'File', icon: (
|
|
|
|
|
+ <svg className="w-3.5 h-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
|
|
|
|
|
+ <path strokeLinecap="round" strokeLinejoin="round" d="M2.25 12.75V12A2.25 2.25 0 014.5 9.75h15A2.25 2.25 0 0121.75 12v.75m-8.69-6.44l-2.12-2.12a1.5 1.5 0 00-1.061-.44H4.5A2.25 2.25 0 002.25 6v12a2.25 2.25 0 002.25 2.25h15A2.25 2.25 0 0021.75 18V9a2.25 2.25 0 00-2.25-2.25h-1.5A2.25 2.25 0 0115 9v.75m-8.69-6.44H5.5a2.25 2.25 0 00-2.25 2.25v.75h13.5v-.75a2.25 2.25 0 00-2.25-2.25H12" />
|
|
|
|
|
+ </svg>
|
|
|
|
|
+ )},
|
|
|
|
|
+ { mode: 'timeline' as const, label: 'Timeline', icon: (
|
|
|
|
|
+ <svg className="w-3.5 h-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
|
|
|
|
|
+ <path strokeLinecap="round" strokeLinejoin="round" d="M12 6v6h4.5m4.5 0a9 9 0 11-18 0 9 9 0 0118 0z" />
|
|
|
|
|
+ </svg>
|
|
|
|
|
+ )},
|
|
|
|
|
+ ].map(({ mode, label, icon }) => (
|
|
|
|
|
+ <button key={mode}
|
|
|
|
|
+ onClick={() => setViewMode(mode)}
|
|
|
|
|
+ className="flex items-center gap-1.5 px-2.5 py-1.5 rounded-md text-xs font-medium transition-all whitespace-nowrap"
|
|
|
|
|
+ style={{
|
|
|
|
|
+ background: viewMode === mode ? 'rgba(99,102,241,0.20)' : 'transparent',
|
|
|
|
|
+ color: viewMode === mode ? '#A5B4FC' : 'var(--text-muted)',
|
|
|
|
|
+ }}
|
|
|
|
|
+ >
|
|
|
|
|
+ {icon}
|
|
|
|
|
+ <span className="hidden sm:inline">{label}</span>
|
|
|
|
|
+ </button>
|
|
|
|
|
+ ))}
|
|
|
|
|
+ </div>
|
|
|
|
|
+ </div>
|
|
|
|
|
+ )}
|
|
|
|
|
|
|
|
- {uploading ? (
|
|
|
|
|
- <div className="space-y-3">
|
|
|
|
|
- <div className="w-9 h-9 rounded-full mx-auto animate-spin"
|
|
|
|
|
- style={{ borderColor: '#6366F1', borderTopColor: 'transparent' }} />
|
|
|
|
|
- <p className="text-sm" style={{ color: 'var(--text-muted)' }}>Uploading…</p>
|
|
|
|
|
- </div>
|
|
|
|
|
- ) : (
|
|
|
|
|
- <>
|
|
|
|
|
- <div className="w-12 h-12 rounded-2xl mx-auto mb-4 flex items-center justify-center"
|
|
|
|
|
- style={{ background: 'rgba(99,102,241,0.10)', border: '1px solid rgba(99,102,241,0.15)' }}>
|
|
|
|
|
- <svg className="w-6 h-6" style={{ color: '#6366F1' }} fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
|
|
|
|
|
|
|
+ <div className="flex gap-5">
|
|
|
|
|
+ {/* Left panel: FolderTree (both file and timeline modes) */}
|
|
|
|
|
+ <aside className="w-52 shrink-0 hidden md:block">
|
|
|
|
|
+ <FolderTree
|
|
|
|
|
+ folders={folders}
|
|
|
|
|
+ allFolders={allFolders}
|
|
|
|
|
+ selectedFolderId={selectedFolderId}
|
|
|
|
|
+ onSelectFolder={setSelectedFolderId}
|
|
|
|
|
+ canManage={canManage}
|
|
|
|
|
+ token={token ?? ''}
|
|
|
|
|
+ projectId={projectId}
|
|
|
|
|
+ onRefresh={loadFolders}
|
|
|
|
|
+ totalAssetCount={assets.length}
|
|
|
|
|
+ />
|
|
|
|
|
+ </aside>
|
|
|
|
|
+
|
|
|
|
|
+ {/* Main content */}
|
|
|
|
|
+ <div className="flex-1 min-w-0">
|
|
|
|
|
+
|
|
|
|
|
+ {/* Upload zone for non-managers */}
|
|
|
|
|
+ {!canManage && (
|
|
|
|
|
+ <div className="mb-6 rounded-2xl p-6 text-center animate-fade-in"
|
|
|
|
|
+ style={{ background: 'rgba(255,255,255,0.01)', border: '1px solid rgba(255,255,255,0.05)', borderRadius: '16px' }}>
|
|
|
|
|
+ <div className="w-10 h-10 rounded-2xl mx-auto mb-3 flex items-center justify-center"
|
|
|
|
|
+ style={{ background: 'rgba(255,255,255,0.03)', border: '1px solid rgba(255,255,255,0.06)' }}>
|
|
|
|
|
+ <svg className="w-5 h-5" style={{ color: 'var(--text-subtle)' }} fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
|
|
|
<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.5m-13.5-9L12 3m0 0l4.5 4.5M12 3v13.5" />
|
|
<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.5m-13.5-9L12 3m0 0l4.5 4.5M12 3v13.5" />
|
|
|
</svg>
|
|
</svg>
|
|
|
</div>
|
|
</div>
|
|
|
- <p className="text-sm font-medium mb-1" style={{ color: 'var(--text)' }}>
|
|
|
|
|
- {isDragActive ? 'Drop videos here' : 'Drag & drop videos, or click to browse'}
|
|
|
|
|
- </p>
|
|
|
|
|
<p className="text-xs" style={{ color: 'var(--text-muted)' }}>
|
|
<p className="text-xs" style={{ color: 'var(--text-muted)' }}>
|
|
|
- MP4, MOV, WebM — up to 500MB each
|
|
|
|
|
|
|
+ Your role ({members.find(m => m.user.id === user?.id)?.role ?? 'Member'}) does not allow uploading.
|
|
|
</p>
|
|
</p>
|
|
|
- </>
|
|
|
|
|
|
|
+ </div>
|
|
|
)}
|
|
)}
|
|
|
- </div>
|
|
|
|
|
- ) : (
|
|
|
|
|
- <div className="mb-8 rounded-2xl p-6 text-center animate-fade-in"
|
|
|
|
|
- style={{ background: 'rgba(255,255,255,0.01)', border: '1px solid rgba(255,255,255,0.05)', borderRadius: '16px' }}>
|
|
|
|
|
- <div className="w-10 h-10 rounded-2xl mx-auto mb-3 flex items-center justify-center"
|
|
|
|
|
- style={{ background: 'rgba(255,255,255,0.03)', border: '1px solid rgba(255,255,255,0.06)' }}>
|
|
|
|
|
- <svg className="w-5 h-5" style={{ color: 'var(--text-subtle)' }} fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
|
|
|
|
|
- <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.5m-13.5-9L12 3m0 0l4.5 4.5M12 3v13.5" />
|
|
|
|
|
- </svg>
|
|
|
|
|
- </div>
|
|
|
|
|
- <p className="text-xs" style={{ color: 'var(--text-muted)' }}>
|
|
|
|
|
- Your role ({members.find(m => m.user.id === user?.id)?.role ?? 'Member'}) does not allow uploading videos.
|
|
|
|
|
- </p>
|
|
|
|
|
- </div>
|
|
|
|
|
- )}
|
|
|
|
|
|
|
|
|
|
- {/* 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)' }}>
|
|
|
|
|
- <div className="w-14 h-14 rounded-2xl mx-auto mb-4 flex items-center justify-center"
|
|
|
|
|
- style={{ background: 'rgba(99,102,241,0.08)', border: '1px solid rgba(99,102,241,0.12)' }}>
|
|
|
|
|
- <svg className="w-7 h-7" style={{ color: '#6366F1' }} fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.2}>
|
|
|
|
|
- <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>
|
|
|
|
|
- </div>
|
|
|
|
|
- <p className="text-sm font-medium mb-1" style={{ color: 'var(--text)' }}>No videos yet</p>
|
|
|
|
|
- <p className="text-xs" style={{ color: 'var(--text-muted)' }}>Upload your first video using the dropzone above</p>
|
|
|
|
|
- </div>
|
|
|
|
|
- ) : (
|
|
|
|
|
- <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>
|
|
|
|
|
- <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>
|
|
|
|
|
|
|
+ {/* File mode content */}
|
|
|
|
|
+ {viewMode === 'file' && (filteredAssets.length === 0 && subfolders.length === 0) ? (
|
|
|
|
|
+ <div className="text-center py-16 rounded-2xl animate-fade-in"
|
|
|
|
|
+ style={{ background: 'rgba(255,255,255,0.02)', border: '1px solid rgba(255,255,255,0.06)' }}>
|
|
|
|
|
+ <div className="w-14 h-14 rounded-2xl mx-auto mb-4 flex items-center justify-center"
|
|
|
|
|
+ style={{ background: 'rgba(99,102,241,0.08)', border: '1px solid rgba(99,102,241,0.12)' }}>
|
|
|
|
|
+ <svg className="w-7 h-7" style={{ color: '#6366F1' }} fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.2}>
|
|
|
|
|
+ <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>
|
|
|
|
|
+ </div>
|
|
|
|
|
+ <p className="text-sm font-medium mb-1" style={{ color: 'var(--text)' }}>
|
|
|
|
|
+ {selectedFolderId ? 'No videos in this folder' : 'No videos yet'}
|
|
|
|
|
+ </p>
|
|
|
|
|
+ <p className="text-xs" style={{ color: 'var(--text-muted)' }}>
|
|
|
|
|
+ {selectedFolderId
|
|
|
|
|
+ ? 'Drag videos here or move them from other folders'
|
|
|
|
|
+ : (canManage ? 'Upload your first video using the Upload button above' : 'Videos will appear here once uploaded')}
|
|
|
|
|
+ </p>
|
|
|
|
|
+ </div>
|
|
|
|
|
+ ) : viewMode === 'file' ? (
|
|
|
|
|
+ // File mode: subfolders + videos
|
|
|
|
|
+ <div>
|
|
|
|
|
+ {/* Subfolders */}
|
|
|
|
|
+ {subfolders.length > 0 && (
|
|
|
|
|
+ <div className="mb-6">
|
|
|
|
|
+ <div className="flex items-center gap-3 mb-3">
|
|
|
|
|
+ <span className="text-xs font-medium" style={{ color: 'var(--text-subtle)' }}>Folders</span>
|
|
|
|
|
+ <div className="flex-1 h-px" style={{ background: 'rgba(255,255,255,0.05)' }} />
|
|
|
|
|
+ </div>
|
|
|
|
|
+ <div className="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 xl:grid-cols-6 gap-3">
|
|
|
|
|
+ {subfolders.map(folder => (
|
|
|
|
|
+ <button
|
|
|
|
|
+ key={folder.id}
|
|
|
|
|
+ onClick={() => setSelectedFolderId(folder.id)}
|
|
|
|
|
+ className="flex items-center gap-2.5 px-3 py-2.5 rounded-xl text-left transition-all hover:brightness-110 group"
|
|
|
|
|
+ style={{ background: 'rgba(255,255,255,0.03)', border: '1px solid rgba(255,255,255,0.06)' }}
|
|
|
|
|
+ >
|
|
|
|
|
+ <svg className="w-5 h-5 shrink-0" style={{ color: '#A78BFA' }} fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
|
|
|
|
|
+ <path strokeLinecap="round" strokeLinejoin="round" d="M2.25 12.75V12A2.25 2.25 0 014.5 9.75h15A2.25 2.25 0 0121.75 12v.75m-8.69-6.44l-2.12-2.12a1.5 1.5 0 00-1.061-.44H4.5A2.25 2.25 0 002.25 6v12a2.25 2.25 0 002.25 2.25h15A2.25 2.25 0 0021.75 18V9A2.25 2.25 0 0019.5 6.75h-1.5A2.25 2.25 0 0115.75 9v.75m-8.69-6.44H5.5a2.25 2.25 0 00-2.25 2.25v.75h13.5v-.75a2.25 2.25 0 00-2.25-2.25H12" />
|
|
|
|
|
+ </svg>
|
|
|
|
|
+ <div className="flex-1 min-w-0">
|
|
|
|
|
+ <div className="text-xs font-medium truncate" style={{ color: 'var(--text)' }}>{folder.name}</div>
|
|
|
|
|
+ {folder.assetCount > 0 && (
|
|
|
|
|
+ <div className="text-[10px]" style={{ color: 'var(--text-subtle)' }}>{folder.assetCount} video{folder.assetCount !== 1 ? 's' : ''}</div>
|
|
|
|
|
+ )}
|
|
|
|
|
+ </div>
|
|
|
|
|
+ <svg className="w-3 h-3 shrink-0 opacity-0 group-hover:opacity-100 transition-opacity" style={{ color: 'var(--text-subtle)' }} fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
|
|
|
|
+ <path strokeLinecap="round" strokeLinejoin="round" d="M8.25 4.5l7.5 7.5-7.5 7.5" />
|
|
|
|
|
+ </svg>
|
|
|
|
|
+ </button>
|
|
|
|
|
+ ))}
|
|
|
|
|
+ </div>
|
|
|
</div>
|
|
</div>
|
|
|
|
|
+ )}
|
|
|
|
|
+
|
|
|
|
|
+ {/* Videos in this folder */}
|
|
|
|
|
+ {filteredAssets.length > 0 && (
|
|
|
<div className="grid grid-cols-1 md:grid-cols-2 xl:grid-cols-3 gap-4">
|
|
<div className="grid grid-cols-1 md:grid-cols-2 xl:grid-cols-3 gap-4">
|
|
|
- {dayAssets.map((asset, i) => (
|
|
|
|
|
|
|
+ {filteredAssets.map((asset, i) => (
|
|
|
<AssetCard
|
|
<AssetCard
|
|
|
key={asset.id}
|
|
key={asset.id}
|
|
|
asset={asset}
|
|
asset={asset}
|
|
|
canManage={canManage}
|
|
canManage={canManage}
|
|
|
- showHour={showHour}
|
|
|
|
|
|
|
+ showHour={false}
|
|
|
onPlay={() => router.push(`/review/${asset.id}`)}
|
|
onPlay={() => router.push(`/review/${asset.id}`)}
|
|
|
onDelete={() => handleDeleteAsset(asset.id, asset.title)}
|
|
onDelete={() => handleDeleteAsset(asset.id, asset.title)}
|
|
|
onCancel={async (id) => {
|
|
onCancel={async (id) => {
|
|
|
if (!token) return;
|
|
if (!token) return;
|
|
|
try {
|
|
try {
|
|
|
await assetsApi.cancelTranscode(token, id);
|
|
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');
|
|
|
|
|
- }
|
|
|
|
|
|
|
+ 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) => {
|
|
onPause={async (id) => {
|
|
|
if (!token) return;
|
|
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');
|
|
|
|
|
- }
|
|
|
|
|
|
|
+ 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) => {
|
|
onResume={async (id) => {
|
|
|
if (!token) return;
|
|
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');
|
|
|
|
|
- }
|
|
|
|
|
|
|
+ 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}
|
|
animationDelay={i * 40}
|
|
|
|
|
+ folderNames={getAssetFolderNames(assetFolders, asset.id)}
|
|
|
|
|
+ onShare={setSharingAssetId}
|
|
|
|
|
+ isShared={!!asset.isShared}
|
|
|
|
|
+ onRemoveFromFolder={handleRemoveFromFolder}
|
|
|
/>
|
|
/>
|
|
|
))}
|
|
))}
|
|
|
</div>
|
|
</div>
|
|
|
- </div>
|
|
|
|
|
- );
|
|
|
|
|
- })}
|
|
|
|
|
|
|
+ )}
|
|
|
|
|
+ </div>
|
|
|
|
|
+ ) : (
|
|
|
|
|
+ // Timeline mode: grouped by date
|
|
|
|
|
+ <div className="space-y-8">
|
|
|
|
|
+ {groupByDay(timelineAssets).map(([dayKey, dayAssets]) => {
|
|
|
|
|
+ const groupDate = new Date(dayKey);
|
|
|
|
|
+ const showHour = dayAssets.length > 1;
|
|
|
|
|
+ return (
|
|
|
|
|
+ <div key={dayKey}>
|
|
|
|
|
+ <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>
|
|
|
|
|
+ <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>
|
|
|
|
|
+ <div className="space-y-3">
|
|
|
|
|
+ {dayAssets.map((asset, i) => {
|
|
|
|
|
+ const createdAt = new Date(asset.createdAt);
|
|
|
|
|
+ return (
|
|
|
|
|
+ <div key={asset.id}
|
|
|
|
|
+ className="flex items-center gap-4 p-3 rounded-xl cursor-pointer group transition-colors animate-fade-in"
|
|
|
|
|
+ style={{ background: 'rgba(255,255,255,0.03)', border: '1px solid rgba(255,255,255,0.05)' }}
|
|
|
|
|
+ onClick={() => router.push(`/review/${asset.id}`)}
|
|
|
|
|
+ draggable={canManage}
|
|
|
|
|
+ onDragStart={canManage ? (e) => {
|
|
|
|
|
+ e.dataTransfer.setData('assetId', asset.id);
|
|
|
|
|
+ e.dataTransfer.setData('text/plain', asset.title);
|
|
|
|
|
+ e.dataTransfer.effectAllowed = 'move';
|
|
|
|
|
+ if (asset.thumbnail && asset.transcodeStatus === 'COMPLETED') {
|
|
|
|
|
+ const ghost = document.createElement('div');
|
|
|
|
|
+ ghost.style.cssText = 'position:fixed;top:-9999px;left:-9999px;display:flex;align-items:center;gap:8px;padding:6px 10px;background:rgba(15,15,25,0.95);border:1px solid rgba(99,102,241,0.4);border-radius:8px;backdrop-filter:blur(8px);font-family:system-ui,sans-serif;z-index:99999;';
|
|
|
|
|
+ const img = document.createElement('img');
|
|
|
|
|
+ img.src = `/uploads/${asset.thumbnail}`;
|
|
|
|
|
+ img.style.cssText = 'height:48px;border-radius:5px;object-fit:cover;';
|
|
|
|
|
+ const label = document.createElement('span');
|
|
|
|
|
+ label.textContent = asset.title;
|
|
|
|
|
+ label.style.cssText = 'color:#e2e8f0;font-size:12px;font-weight:500;max-width:160px;overflow:hidden;white-space:nowrap;text-overflow:ellipsis;';
|
|
|
|
|
+ ghost.appendChild(img);
|
|
|
|
|
+ ghost.appendChild(label);
|
|
|
|
|
+ document.body.appendChild(ghost);
|
|
|
|
|
+ e.dataTransfer.setDragImage(ghost, 30, 28);
|
|
|
|
|
+ setTimeout(() => document.body.removeChild(ghost), 0);
|
|
|
|
|
+ }
|
|
|
|
|
+ } : undefined}
|
|
|
|
|
+ >
|
|
|
|
|
+ {/* Thumbnail */}
|
|
|
|
|
+ <div className="w-24 sm:w-32 shrink-0 rounded-lg overflow-hidden aspect-video"
|
|
|
|
|
+ style={{ background: '#080810' }}>
|
|
|
|
|
+ {asset.thumbnail && asset.transcodeStatus === 'COMPLETED' ? (
|
|
|
|
|
+ <img src={`/uploads/${asset.thumbnail}`} alt={asset.title} className="w-full h-full object-cover" style={{ opacity: 0.8 }} />
|
|
|
|
|
+ ) : (
|
|
|
|
|
+ <div className="w-full h-full flex items-center justify-center">
|
|
|
|
|
+ <svg className="w-6 h-6" 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>
|
|
|
|
|
+
|
|
|
|
|
+ {/* Info */}
|
|
|
|
|
+ <div className="flex-1 min-w-0">
|
|
|
|
|
+ <div className="flex items-start justify-between gap-2 mb-1">
|
|
|
|
|
+ <h3 className="text-sm font-medium truncate" style={{ color: 'var(--text)' }}>{asset.title}</h3>
|
|
|
|
|
+ {asset.duration && (
|
|
|
|
|
+ <span className="text-xs shrink-0 px-1.5 py-0.5 rounded font-mono"
|
|
|
|
|
+ style={{ background: 'rgba(0,0,0,0.5)', color: '#E2E8F0' }}>
|
|
|
|
|
+ {`${Math.floor(asset.duration / 60)}:${Math.floor(asset.duration % 60).toString().padStart(2, '0')}`}
|
|
|
|
|
+ </span>
|
|
|
|
|
+ )}
|
|
|
|
|
+ </div>
|
|
|
|
|
+ {/* Folder tags */}
|
|
|
|
|
+ {(() => {
|
|
|
|
|
+ const tags = getAssetFolderNames(assetFolders, asset.id);
|
|
|
|
|
+ return tags.length > 0 ? (
|
|
|
|
|
+ <div className="flex flex-wrap gap-1 mb-1">
|
|
|
|
|
+ {tags.map((name, i) => (
|
|
|
|
|
+ <span key={i} className="text-[10px] px-1.5 py-0.5 rounded"
|
|
|
|
|
+ style={{ background: 'rgba(99,102,241,0.12)', color: '#A5B4FC' }}>
|
|
|
|
|
+ {name}
|
|
|
|
|
+ </span>
|
|
|
|
|
+ ))}
|
|
|
|
|
+ </div>
|
|
|
|
|
+ ) : null;
|
|
|
|
|
+ })()}
|
|
|
|
|
+ <div className="flex items-center gap-2 text-xs" style={{ color: 'var(--text-muted)' }}>
|
|
|
|
|
+ <span className="truncate">{asset.uploader?.name ?? 'Unknown'}</span>
|
|
|
|
|
+ <span>·</span>
|
|
|
|
|
+ <span className="shrink-0 text-[10px]" style={{ color: 'var(--text-subtle)' }}>
|
|
|
|
|
+ {showHour
|
|
|
|
|
+ ? createdAt.toLocaleTimeString('en-US', { hour: '2-digit', minute: '2-digit', hour12: true })
|
|
|
|
|
+ : createdAt.toLocaleDateString('en-US', { month: 'short', day: 'numeric' })}
|
|
|
|
|
+ </span>
|
|
|
|
|
+ <span>·</span>
|
|
|
|
|
+ <span>{(asset as any)._count?.comments ?? 0} comments</span>
|
|
|
|
|
+ </div>
|
|
|
|
|
+ </div>
|
|
|
|
|
+
|
|
|
|
|
+ {/* Play button */}
|
|
|
|
|
+ <div className="w-8 h-8 rounded-full flex items-center justify-center shrink-0 opacity-0 group-hover:opacity-100 transition-opacity"
|
|
|
|
|
+ style={{ background: 'rgba(99,102,241,0.20)', color: '#A5B4FC' }}>
|
|
|
|
|
+ <svg className="w-4 h-4 ml-0.5" fill="currentColor" viewBox="0 0 24 24">
|
|
|
|
|
+ <path d="M8 5v14l11-7z" />
|
|
|
|
|
+ </svg>
|
|
|
|
|
+ </div>
|
|
|
|
|
+ </div>
|
|
|
|
|
+ );
|
|
|
|
|
+ })}
|
|
|
|
|
+ </div>
|
|
|
|
|
+ </div>
|
|
|
|
|
+ );
|
|
|
|
|
+ })}
|
|
|
|
|
+ </div>
|
|
|
|
|
+ )}
|
|
|
</div>
|
|
</div>
|
|
|
- )}
|
|
|
|
|
|
|
+ </div>
|
|
|
</>
|
|
</>
|
|
|
)}
|
|
)}
|
|
|
|
|
|
|
@@ -642,35 +945,25 @@ export default function ProjectDetailPage() {
|
|
|
if (!token) return;
|
|
if (!token) return;
|
|
|
try {
|
|
try {
|
|
|
await assetsApi.cancelTranscode(token, id);
|
|
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');
|
|
|
|
|
- }
|
|
|
|
|
|
|
+ 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) => {
|
|
onPause={async (id) => {
|
|
|
if (!token) return;
|
|
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');
|
|
|
|
|
- }
|
|
|
|
|
|
|
+ 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) => {
|
|
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'); }
|
|
|
|
|
+ }}
|
|
|
|
|
+ onReprocess={async (id) => {
|
|
|
if (!token) return;
|
|
if (!token) return;
|
|
|
try {
|
|
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');
|
|
|
|
|
- }
|
|
|
|
|
|
|
+ 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 reprocess transcode'); }
|
|
|
}}
|
|
}}
|
|
|
/>
|
|
/>
|
|
|
</div>
|
|
</div>
|
|
@@ -680,7 +973,7 @@ export default function ProjectDetailPage() {
|
|
|
{activeTab === 'members' && (
|
|
{activeTab === 'members' && (
|
|
|
<div className="max-w-3xl animate-fade-in">
|
|
<div className="max-w-3xl animate-fade-in">
|
|
|
|
|
|
|
|
- {/* Invite form — single form, shared email + role */}
|
|
|
|
|
|
|
+ {/* Invite form */}
|
|
|
{canManage && (
|
|
{canManage && (
|
|
|
<div className="card p-5 mb-6">
|
|
<div className="card p-5 mb-6">
|
|
|
<h2 className="text-sm font-semibold mb-4" style={{ color: 'var(--text)' }}>
|
|
<h2 className="text-sm font-semibold mb-4" style={{ color: 'var(--text)' }}>
|
|
@@ -716,7 +1009,6 @@ export default function ProjectDetailPage() {
|
|
|
))}
|
|
))}
|
|
|
</select>
|
|
</select>
|
|
|
</div>
|
|
</div>
|
|
|
- {/* Both buttons share the same email + role from this single form */}
|
|
|
|
|
<button
|
|
<button
|
|
|
type="button"
|
|
type="button"
|
|
|
disabled={inviting || !inviteEmail.trim()}
|
|
disabled={inviting || !inviteEmail.trim()}
|
|
@@ -737,13 +1029,12 @@ export default function ProjectDetailPage() {
|
|
|
type="submit"
|
|
type="submit"
|
|
|
disabled={inviting || !inviteEmail.trim()}
|
|
disabled={inviting || !inviteEmail.trim()}
|
|
|
className="btn btn-primary btn-md"
|
|
className="btn btn-primary btn-md"
|
|
|
- title="Send invite — link is included automatically"
|
|
|
|
|
|
|
+ title="Send invite"
|
|
|
>
|
|
>
|
|
|
{inviting ? 'Sending…' : 'Send Invite'}
|
|
{inviting ? 'Sending…' : 'Send Invite'}
|
|
|
</button>
|
|
</button>
|
|
|
</form>
|
|
</form>
|
|
|
|
|
|
|
|
- {/* Created link feedback */}
|
|
|
|
|
{createdLink && (
|
|
{createdLink && (
|
|
|
<div className="rounded-lg p-3 animate-scale-in"
|
|
<div className="rounded-lg p-3 animate-scale-in"
|
|
|
style={{ background: 'rgba(34,197,94,0.08)', border: '1px solid rgba(34,197,94,0.20)' }}>
|
|
style={{ background: 'rgba(34,197,94,0.08)', border: '1px solid rgba(34,197,94,0.20)' }}>
|
|
@@ -759,12 +1050,8 @@ export default function ProjectDetailPage() {
|
|
|
</div>
|
|
</div>
|
|
|
)}
|
|
)}
|
|
|
|
|
|
|
|
- {inviteError && (
|
|
|
|
|
- <p className="text-xs" style={{ color: '#F87171' }}>{inviteError}</p>
|
|
|
|
|
- )}
|
|
|
|
|
- {inviteSuccess && (
|
|
|
|
|
- <p className="text-xs" style={{ color: '#86EFAC' }}>{inviteSuccess}</p>
|
|
|
|
|
- )}
|
|
|
|
|
|
|
+ {inviteError && <p className="text-xs" style={{ color: '#F87171' }}>{inviteError}</p>}
|
|
|
|
|
+ {inviteSuccess && <p className="text-xs" style={{ color: '#86EFAC' }}>{inviteSuccess}</p>}
|
|
|
</div>
|
|
</div>
|
|
|
</div>
|
|
</div>
|
|
|
)}
|
|
)}
|
|
@@ -791,13 +1078,11 @@ export default function ProjectDetailPage() {
|
|
|
<div key={m.id}
|
|
<div key={m.id}
|
|
|
className="flex items-center gap-4 px-5 py-4 hover:bg-white/[0.02] transition-colors">
|
|
className="flex items-center gap-4 px-5 py-4 hover:bg-white/[0.02] transition-colors">
|
|
|
|
|
|
|
|
- {/* Avatar */}
|
|
|
|
|
<div className="w-9 h-9 rounded-full flex items-center justify-center text-xs font-semibold shrink-0"
|
|
<div className="w-9 h-9 rounded-full flex items-center justify-center text-xs font-semibold shrink-0"
|
|
|
style={{ background: 'rgba(99,102,241,0.15)', color: '#A5B4FC' }}>
|
|
style={{ background: 'rgba(99,102,241,0.15)', color: '#A5B4FC' }}>
|
|
|
{m.user.name.split(' ').map((n: string) => n[0]).slice(0, 2).join('').toUpperCase()}
|
|
{m.user.name.split(' ').map((n: string) => n[0]).slice(0, 2).join('').toUpperCase()}
|
|
|
</div>
|
|
</div>
|
|
|
|
|
|
|
|
- {/* Info */}
|
|
|
|
|
<div className="flex-1 min-w-0">
|
|
<div className="flex-1 min-w-0">
|
|
|
<div className="flex items-center gap-2">
|
|
<div className="flex items-center gap-2">
|
|
|
<span className="text-sm font-medium" style={{ color: 'var(--text)' }}>
|
|
<span className="text-sm font-medium" style={{ color: 'var(--text)' }}>
|
|
@@ -808,12 +1093,10 @@ export default function ProjectDetailPage() {
|
|
|
<p className="text-xs truncate" style={{ color: 'var(--text-muted)' }}>{m.user.email}</p>
|
|
<p className="text-xs truncate" style={{ color: 'var(--text-muted)' }}>{m.user.email}</p>
|
|
|
</div>
|
|
</div>
|
|
|
|
|
|
|
|
- {/* Joined date */}
|
|
|
|
|
<span className="text-xs hidden sm:block" style={{ color: 'var(--text-subtle)' }}>
|
|
<span className="text-xs hidden sm:block" style={{ color: 'var(--text-subtle)' }}>
|
|
|
{new Date(m.joinedAt).toLocaleDateString('en-US', { month: 'short', year: 'numeric' })}
|
|
{new Date(m.joinedAt).toLocaleDateString('en-US', { month: 'short', year: 'numeric' })}
|
|
|
</span>
|
|
</span>
|
|
|
|
|
|
|
|
- {/* Role */}
|
|
|
|
|
{editingRoleId === m.id ? (
|
|
{editingRoleId === m.id ? (
|
|
|
<div className="flex items-center gap-2 shrink-0">
|
|
<div className="flex items-center gap-2 shrink-0">
|
|
|
<select
|
|
<select
|
|
@@ -826,21 +1109,12 @@ export default function ProjectDetailPage() {
|
|
|
<option key={v} value={v}>{l}</option>
|
|
<option key={v} value={v}>{l}</option>
|
|
|
))}
|
|
))}
|
|
|
</select>
|
|
</select>
|
|
|
- <button
|
|
|
|
|
- onClick={() => handleChangeRole(m.id)}
|
|
|
|
|
- disabled={updatingRole}
|
|
|
|
|
- className="btn btn-primary btn-sm px-2"
|
|
|
|
|
- title="Save"
|
|
|
|
|
- >
|
|
|
|
|
|
|
+ <button onClick={() => handleChangeRole(m.id)} disabled={updatingRole} className="btn btn-primary btn-sm px-2" title="Save">
|
|
|
<svg className="w-3.5 h-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
|
<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="M5 13l4 4L19 7" />
|
|
<path strokeLinecap="round" strokeLinejoin="round" d="M5 13l4 4L19 7" />
|
|
|
</svg>
|
|
</svg>
|
|
|
</button>
|
|
</button>
|
|
|
- <button
|
|
|
|
|
- onClick={() => setEditingRoleId(null)}
|
|
|
|
|
- className="btn btn-secondary btn-sm px-2"
|
|
|
|
|
- title="Cancel"
|
|
|
|
|
- >
|
|
|
|
|
|
|
+ <button onClick={() => setEditingRoleId(null)} className="btn btn-secondary btn-sm px-2" title="Cancel">
|
|
|
<svg className="w-3.5 h-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
|
<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="M6 18L18 6M6 6l12 12" />
|
|
<path strokeLinecap="round" strokeLinejoin="round" d="M6 18L18 6M6 6l12 12" />
|
|
|
</svg>
|
|
</svg>
|
|
@@ -904,8 +1178,6 @@ export default function ProjectDetailPage() {
|
|
|
{pendingInvites.map(inv => (
|
|
{pendingInvites.map(inv => (
|
|
|
<div key={inv.id}
|
|
<div key={inv.id}
|
|
|
className="flex items-center gap-4 px-5 py-4">
|
|
className="flex items-center gap-4 px-5 py-4">
|
|
|
-
|
|
|
|
|
- {/* Icon */}
|
|
|
|
|
<div className="w-9 h-9 rounded-full flex items-center justify-center shrink-0"
|
|
<div className="w-9 h-9 rounded-full flex items-center justify-center shrink-0"
|
|
|
style={{ background: 'rgba(99,102,241,0.08)' }}>
|
|
style={{ background: 'rgba(99,102,241,0.08)' }}>
|
|
|
<svg className="w-4 h-4" style={{ color: '#818CF8' }} fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
|
|
<svg className="w-4 h-4" style={{ color: '#818CF8' }} fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
|
|
@@ -913,7 +1185,6 @@ export default function ProjectDetailPage() {
|
|
|
</svg>
|
|
</svg>
|
|
|
</div>
|
|
</div>
|
|
|
|
|
|
|
|
- {/* Info */}
|
|
|
|
|
<div className="flex-1 min-w-0">
|
|
<div className="flex-1 min-w-0">
|
|
|
<div className="flex items-center gap-2">
|
|
<div className="flex items-center gap-2">
|
|
|
<span className="text-sm font-medium" style={{ color: 'var(--text)' }}>{inv.email}</span>
|
|
<span className="text-sm font-medium" style={{ color: 'var(--text)' }}>{inv.email}</span>
|
|
@@ -928,7 +1199,6 @@ export default function ProjectDetailPage() {
|
|
|
</div>
|
|
</div>
|
|
|
</div>
|
|
</div>
|
|
|
|
|
|
|
|
- {/* Actions */}
|
|
|
|
|
<div className="flex items-center gap-1.5 shrink-0">
|
|
<div className="flex items-center gap-1.5 shrink-0">
|
|
|
<button
|
|
<button
|
|
|
onClick={() => handleCopyLink(inv)}
|
|
onClick={() => handleCopyLink(inv)}
|
|
@@ -976,6 +1246,14 @@ export default function ProjectDetailPage() {
|
|
|
)}
|
|
)}
|
|
|
</div>
|
|
</div>
|
|
|
|
|
|
|
|
|
|
+ {/* Share modal */}
|
|
|
|
|
+ {sharingAssetId && (
|
|
|
|
|
+ <ShareModal
|
|
|
|
|
+ assetId={sharingAssetId}
|
|
|
|
|
+ onClose={() => setSharingAssetId(null)}
|
|
|
|
|
+ />
|
|
|
|
|
+ )}
|
|
|
|
|
+
|
|
|
{/* Delete asset confirm modal */}
|
|
{/* Delete asset confirm modal */}
|
|
|
{confirmDelete && (
|
|
{confirmDelete && (
|
|
|
<div className="fixed inset-0 z-50 flex items-center justify-center"
|
|
<div className="fixed inset-0 z-50 flex items-center justify-center"
|
|
@@ -999,18 +1277,8 @@ export default function ProjectDetailPage() {
|
|
|
This will permanently delete the video file, thumbnail, and all HLS segments. This action cannot be undone.
|
|
This will permanently delete the video file, thumbnail, and all HLS segments. This action cannot be undone.
|
|
|
</p>
|
|
</p>
|
|
|
<div className="flex gap-3 justify-end">
|
|
<div className="flex gap-3 justify-end">
|
|
|
- <button
|
|
|
|
|
- onClick={() => setConfirmDelete(null)}
|
|
|
|
|
- disabled={!!deletingId}
|
|
|
|
|
- className="btn btn-secondary btn-md"
|
|
|
|
|
- >
|
|
|
|
|
- Cancel
|
|
|
|
|
- </button>
|
|
|
|
|
- <button
|
|
|
|
|
- onClick={confirmDeleteAsset}
|
|
|
|
|
- disabled={!!deletingId}
|
|
|
|
|
- className="btn btn-danger btn-md"
|
|
|
|
|
- >
|
|
|
|
|
|
|
+ <button onClick={() => setConfirmDelete(null)} disabled={!!deletingId} className="btn btn-secondary btn-md">Cancel</button>
|
|
|
|
|
+ <button onClick={confirmDeleteAsset} disabled={!!deletingId} className="btn btn-danger btn-md">
|
|
|
{deletingId === confirmDelete.id ? 'Deleting…' : 'Delete video'}
|
|
{deletingId === confirmDelete.id ? 'Deleting…' : 'Delete video'}
|
|
|
</button>
|
|
</button>
|
|
|
</div>
|
|
</div>
|
|
@@ -1030,14 +1298,8 @@ export default function ProjectDetailPage() {
|
|
|
They'll lose access to this project and all its videos. They can rejoin if invited again.
|
|
They'll lose access to this project and all its videos. They can rejoin if invited again.
|
|
|
</p>
|
|
</p>
|
|
|
<div className="flex gap-3 justify-end">
|
|
<div className="flex gap-3 justify-end">
|
|
|
- <button onClick={() => setConfirmRemove(null)} className="btn btn-secondary btn-md">
|
|
|
|
|
- Cancel
|
|
|
|
|
- </button>
|
|
|
|
|
- <button
|
|
|
|
|
- onClick={handleRemoveMember}
|
|
|
|
|
- disabled={removing}
|
|
|
|
|
- className="btn btn-danger btn-md"
|
|
|
|
|
- >
|
|
|
|
|
|
|
+ <button onClick={() => setConfirmRemove(null)} className="btn btn-secondary btn-md">Cancel</button>
|
|
|
|
|
+ <button onClick={handleRemoveMember} disabled={removing} className="btn btn-danger btn-md">
|
|
|
{removing ? 'Removing…' : 'Remove'}
|
|
{removing ? 'Removing…' : 'Remove'}
|
|
|
</button>
|
|
</button>
|
|
|
</div>
|
|
</div>
|