|
|
@@ -3,22 +3,68 @@
|
|
|
import { useState, useEffect, useCallback } from 'react';
|
|
|
import { useParams, useRouter } from 'next/navigation';
|
|
|
import { useAuth } from '@/lib/auth-context';
|
|
|
-import { projectsApi, assetsApi, Project, Asset } from '@/lib/api';
|
|
|
-import { Modal } from '@/components/ui/modal';
|
|
|
+import { projectsApi, assetsApi, invitationsApi, Project, Asset, Invitation } from '@/lib/api';
|
|
|
import { useDropzone } from 'react-dropzone';
|
|
|
|
|
|
+const ROLE_COLORS: Record<string, string> = {
|
|
|
+ ADMIN: 'badge-danger',
|
|
|
+ EDITOR: 'badge-brand',
|
|
|
+ REVIEWER:'badge-muted',
|
|
|
+ VIEWER: 'badge-subtle',
|
|
|
+};
|
|
|
+
|
|
|
+const ROLE_LABELS: Record<string, string> = {
|
|
|
+ ADMIN: 'Admin',
|
|
|
+ EDITOR: 'Editor',
|
|
|
+ REVIEWER:'Reviewer',
|
|
|
+ VIEWER: 'Viewer',
|
|
|
+};
|
|
|
+
|
|
|
export default function ProjectDetailPage() {
|
|
|
const params = useParams();
|
|
|
const projectId = params.projectId as string;
|
|
|
- const { token } = useAuth();
|
|
|
+ const { user, token } = useAuth();
|
|
|
const router = useRouter();
|
|
|
|
|
|
const [project, setProject] = useState<Project | null>(null);
|
|
|
+ const [members, setMembers] = useState<any[]>([]);
|
|
|
+ const [pendingInvites, setPendingInvites] = useState<Invitation[]>([]);
|
|
|
const [assets, setAssets] = useState<Asset[]>([]);
|
|
|
const [loading, setLoading] = useState(true);
|
|
|
const [uploading, setUploading] = useState(false);
|
|
|
+ const [activeTab, setActiveTab] = useState<'videos' | 'members'>('videos');
|
|
|
+
|
|
|
+ // Invite form state
|
|
|
+ const [inviteEmail, setInviteEmail] = useState('');
|
|
|
+ const [inviteRole, setInviteRole] = useState('REVIEWER');
|
|
|
+ const [inviting, setInviting] = useState(false);
|
|
|
+ const [inviteError, setInviteError] = useState('');
|
|
|
+ const [inviteSuccess, setInviteSuccess] = useState('');
|
|
|
+
|
|
|
+ // Edit member role
|
|
|
+ const [editingRoleId, setEditingRoleId] = useState<string | null>(null);
|
|
|
+ const [editingRole, setEditingRole] = useState('');
|
|
|
+ const [updatingRole, setUpdatingRole] = useState(false);
|
|
|
+
|
|
|
+ // Remove member
|
|
|
+ const [confirmRemove, setConfirmRemove] = useState<{ id: string; name: string } | null>(null);
|
|
|
+ const [removing, setRemoving] = useState(false);
|
|
|
+
|
|
|
+ // Revoke invite
|
|
|
+ const [revokingId, setRevokingId] = useState<string | null>(null);
|
|
|
|
|
|
- const loadData = useCallback(async () => {
|
|
|
+ // Copy link
|
|
|
+ const [copiedInviteId, setCopiedInviteId] = useState<string | null>(null);
|
|
|
+ const [inviteUrlMap, setInviteUrlMap] = useState<Record<string, string>>({});
|
|
|
+
|
|
|
+ const canManage = members.some(m =>
|
|
|
+ m.user.id === user?.id && ['ADMIN', 'EDITOR'].includes(m.role)
|
|
|
+ );
|
|
|
+ const isAdmin = members.some(m =>
|
|
|
+ m.user.id === user?.id && m.role === 'ADMIN'
|
|
|
+ );
|
|
|
+
|
|
|
+ const loadAll = useCallback(async () => {
|
|
|
if (!token) return;
|
|
|
try {
|
|
|
const [{ project: p }, { assets: a }] = await Promise.all([
|
|
|
@@ -26,15 +72,96 @@ export default function ProjectDetailPage() {
|
|
|
assetsApi.list(token, projectId),
|
|
|
]);
|
|
|
setProject(p);
|
|
|
+ setMembers(p.members ?? []);
|
|
|
setAssets(a);
|
|
|
+
|
|
|
+ if (canManage) {
|
|
|
+ const { invitations } = await invitationsApi.list(token, projectId);
|
|
|
+ setPendingInvites(invitations.filter((i: Invitation) => i.status === 'PENDING'));
|
|
|
+ }
|
|
|
} catch {
|
|
|
router.push('/projects');
|
|
|
} finally {
|
|
|
setLoading(false);
|
|
|
}
|
|
|
- }, [token, projectId, router]);
|
|
|
+ }, [token, projectId, router, canManage]);
|
|
|
+
|
|
|
+ useEffect(() => { loadAll(); }, [loadAll]);
|
|
|
|
|
|
- useEffect(() => { loadData(); }, [loadData]);
|
|
|
+ // ── Invite member ──────────────────────────────────────────────────────────
|
|
|
+ const handleInvite = async (e: React.FormEvent) => {
|
|
|
+ e.preventDefault();
|
|
|
+ if (!token || !inviteEmail.trim()) return;
|
|
|
+ setInviting(true);
|
|
|
+ setInviteError('');
|
|
|
+ setInviteSuccess('');
|
|
|
+ try {
|
|
|
+ const { inviteUrl } = await invitationsApi.create(token, projectId, inviteEmail.trim(), inviteRole);
|
|
|
+ const { invitations } = await invitationsApi.list(token, projectId);
|
|
|
+ setPendingInvites(invitations.filter((i: Invitation) => i.status === 'PENDING'));
|
|
|
+ setInviteUrlMap(prev => ({ ...prev, [inviteUrl.split('/').pop()!]: inviteUrl }));
|
|
|
+ setInviteEmail('');
|
|
|
+ setInviteSuccess(`Invitation sent to ${inviteEmail.trim()}`);
|
|
|
+ setTimeout(() => setInviteSuccess(''), 3000);
|
|
|
+ } catch (err) {
|
|
|
+ setInviteError(err instanceof Error ? err.message : 'Failed to send invitation');
|
|
|
+ } finally {
|
|
|
+ setInviting(false);
|
|
|
+ }
|
|
|
+ };
|
|
|
+
|
|
|
+ // ── Change role ────────────────────────────────────────────────────────────
|
|
|
+ const handleChangeRole = async (memberId: string) => {
|
|
|
+ if (!token || !editingRole) return;
|
|
|
+ setUpdatingRole(true);
|
|
|
+ try {
|
|
|
+ await projectsApi.updateMember(token, projectId, memberId, editingRole);
|
|
|
+ setMembers(prev => prev.map(m => m.id === memberId ? { ...m, role: editingRole } : m));
|
|
|
+ setEditingRoleId(null);
|
|
|
+ } catch (err) {
|
|
|
+ alert(err instanceof Error ? err.message : 'Failed to update role');
|
|
|
+ } finally {
|
|
|
+ setUpdatingRole(false);
|
|
|
+ }
|
|
|
+ };
|
|
|
+
|
|
|
+ // ── Remove member ─────────────────────────────────────────────────────────
|
|
|
+ const handleRemoveMember = async () => {
|
|
|
+ if (!token || !confirmRemove) return;
|
|
|
+ setRemoving(true);
|
|
|
+ try {
|
|
|
+ await projectsApi.removeMember(token, projectId, confirmRemove.id);
|
|
|
+ setMembers(prev => prev.filter(m => m.id !== confirmRemove!.id));
|
|
|
+ setConfirmRemove(null);
|
|
|
+ } catch (err) {
|
|
|
+ alert(err instanceof Error ? err.message : 'Failed to remove member');
|
|
|
+ } finally {
|
|
|
+ setRemoving(false);
|
|
|
+ }
|
|
|
+ };
|
|
|
+
|
|
|
+ // ── Revoke invite ──────────────────────────────────────────────────────────
|
|
|
+ const handleRevoke = async (invitationId: string) => {
|
|
|
+ if (!token) return;
|
|
|
+ setRevokingId(invitationId);
|
|
|
+ try {
|
|
|
+ await invitationsApi.revoke(token, invitationId);
|
|
|
+ setPendingInvites(prev => prev.filter(i => i.id !== invitationId));
|
|
|
+ } catch (err) {
|
|
|
+ alert(err instanceof Error ? err.message : 'Failed to revoke invitation');
|
|
|
+ } finally {
|
|
|
+ setRevokingId(null);
|
|
|
+ }
|
|
|
+ };
|
|
|
+
|
|
|
+ // ── Copy invite link ──────────────────────────────────────────────────────
|
|
|
+ const handleCopyLink = async (invite: Invitation) => {
|
|
|
+ const base = window.location.origin;
|
|
|
+ const url = inviteUrlMap[invite.token] ?? `${base}/invite/${invite.token}`;
|
|
|
+ await navigator.clipboard.writeText(url);
|
|
|
+ setCopiedInviteId(invite.id);
|
|
|
+ setTimeout(() => setCopiedInviteId(null), 2000);
|
|
|
+ };
|
|
|
|
|
|
const handleDrop = async (acceptedFiles: File[]) => {
|
|
|
if (!token || acceptedFiles.length === 0) return;
|
|
|
@@ -89,7 +216,7 @@ export default function ProjectDetailPage() {
|
|
|
<div className="flex items-center gap-3" style={{ color: 'var(--text-muted)' }}>
|
|
|
<div className="w-5 h-5 rounded-full animate-spin"
|
|
|
style={{ borderColor: '#6366F1', borderTopColor: 'transparent' }} />
|
|
|
- <span className="text-sm">Loading assets…</span>
|
|
|
+ <span className="text-sm">Loading…</span>
|
|
|
</div>
|
|
|
</div>
|
|
|
);
|
|
|
@@ -129,6 +256,28 @@ export default function ProjectDetailPage() {
|
|
|
)}
|
|
|
</div>
|
|
|
|
|
|
+ {/* Tabs */}
|
|
|
+ <div className="flex items-center gap-1 p-1 rounded-lg"
|
|
|
+ style={{ background: 'rgba(255,255,255,0.04)' }}>
|
|
|
+ {[['videos', 'Videos'], ['members', 'Members']].map(([tab, label]) => (
|
|
|
+ <button key={tab}
|
|
|
+ onClick={() => setActiveTab(tab as any)}
|
|
|
+ className="px-3 py-1.5 rounded-md text-xs font-medium transition-all"
|
|
|
+ style={{
|
|
|
+ background: activeTab === tab ? 'rgba(99,102,241,0.20)' : 'transparent',
|
|
|
+ color: activeTab === tab ? '#A5B4FC' : 'var(--text-muted)',
|
|
|
+ }}>
|
|
|
+ {label}
|
|
|
+ {tab === 'members' && (
|
|
|
+ <span className="ml-1.5 text-[10px] px-1.5 py-0.5 rounded-full"
|
|
|
+ style={{ background: 'rgba(255,255,255,0.06)', color: 'inherit' }}>
|
|
|
+ {members.length}
|
|
|
+ </span>
|
|
|
+ )}
|
|
|
+ </button>
|
|
|
+ ))}
|
|
|
+ </div>
|
|
|
+
|
|
|
<div className="text-xs px-2.5 py-1 rounded-full"
|
|
|
style={{ background: 'rgba(255,255,255,0.05)', color: 'var(--text-muted)' }}>
|
|
|
{assets.length} video{assets.length !== 1 ? 's' : ''}
|
|
|
@@ -137,125 +286,414 @@ export default function ProjectDetailPage() {
|
|
|
|
|
|
<div className="px-8 py-6">
|
|
|
|
|
|
- {/* Upload zone */}
|
|
|
- <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()} />
|
|
|
+ {/* ── Videos Tab ───────────────────────────────────────────────────── */}
|
|
|
+ {activeTab === 'videos' && (
|
|
|
+ <>
|
|
|
+ {/* Upload zone */}
|
|
|
+ <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()} />
|
|
|
|
|
|
- {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>
|
|
|
+ {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}>
|
|
|
+ <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-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)' }}>
|
|
|
+ MP4, MOV, WebM — up to 500MB each
|
|
|
+ </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}>
|
|
|
- <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>
|
|
|
+
|
|
|
+ {/* Asset grid */}
|
|
|
+ {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>
|
|
|
- <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)' }}>
|
|
|
- MP4, MOV, WebM — up to 500MB each
|
|
|
- </p>
|
|
|
- </>
|
|
|
- )}
|
|
|
- </div>
|
|
|
+ ) : (
|
|
|
+ <div className="grid grid-cols-1 md:grid-cols-2 xl:grid-cols-3 gap-4">
|
|
|
+ {assets.map((asset, i) => (
|
|
|
+ <a key={asset.id}
|
|
|
+ href={`/review/${asset.id}`}
|
|
|
+ className="card overflow-hidden group cursor-pointer"
|
|
|
+ style={{ animation: `slideUp 0.25s ease-out ${i * 40}ms both` }}>
|
|
|
|
|
|
- {/* Asset grid */}
|
|
|
- {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="grid grid-cols-1 md:grid-cols-2 xl:grid-cols-3 gap-4">
|
|
|
- {assets.map((asset, i) => (
|
|
|
- <a key={asset.id}
|
|
|
- href={`/review/${asset.id}`}
|
|
|
- className="card overflow-hidden group cursor-pointer"
|
|
|
- style={{ animation: `slideUp 0.25s ease-out ${i * 40}ms both` }}>
|
|
|
-
|
|
|
- {/* Thumbnail */}
|
|
|
- <div className="relative aspect-video" style={{ background: '#080810' }}>
|
|
|
- {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>
|
|
|
+ {/* Thumbnail */}
|
|
|
+ <div className="relative aspect-video" style={{ background: '#080810' }}>
|
|
|
+ {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>
|
|
|
+ )}
|
|
|
+
|
|
|
+ {asset.duration && (
|
|
|
+ <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' }}>
|
|
|
+ {formatDuration(asset.duration)}
|
|
|
+ </span>
|
|
|
+ )}
|
|
|
+
|
|
|
+ <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>
|
|
|
</div>
|
|
|
- )}
|
|
|
-
|
|
|
- {/* Duration */}
|
|
|
- {asset.duration && (
|
|
|
- <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' }}>
|
|
|
- {formatDuration(asset.duration)}
|
|
|
- </span>
|
|
|
- )}
|
|
|
-
|
|
|
- {/* Play overlay */}
|
|
|
- <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>
|
|
|
+
|
|
|
+ {/* Info */}
|
|
|
+ <div className="p-4">
|
|
|
+ <div className="flex items-start justify-between gap-2 mb-2">
|
|
|
+ <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>
|
|
|
+ <div className="flex items-center gap-3 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>
|
|
|
</div>
|
|
|
+ </a>
|
|
|
+ ))}
|
|
|
+ </div>
|
|
|
+ )}
|
|
|
+ </>
|
|
|
+ )}
|
|
|
+
|
|
|
+ {/* ── Members Tab ─────────────────────────────────────────────────── */}
|
|
|
+ {activeTab === 'members' && (
|
|
|
+ <div className="max-w-3xl animate-fade-in">
|
|
|
+
|
|
|
+ {/* Invite form */}
|
|
|
+ {canManage && (
|
|
|
+ <div className="card p-5 mb-6">
|
|
|
+ <h2 className="text-sm font-semibold mb-4" style={{ color: 'var(--text)' }}>
|
|
|
+ Invite someone
|
|
|
+ </h2>
|
|
|
+
|
|
|
+ <form onSubmit={handleInvite} className="flex items-end gap-3 flex-wrap">
|
|
|
+ <div className="flex-1 min-w-[180px]">
|
|
|
+ <label className="block text-xs mb-1.5" style={{ color: 'var(--text-muted)' }}>Email address</label>
|
|
|
+ <input
|
|
|
+ type="email"
|
|
|
+ className="input"
|
|
|
+ value={inviteEmail}
|
|
|
+ onChange={e => setInviteEmail(e.target.value)}
|
|
|
+ placeholder="colleague@company.com"
|
|
|
+ required
|
|
|
+ />
|
|
|
+ </div>
|
|
|
+ <div className="w-36">
|
|
|
+ <label className="block text-xs mb-1.5" style={{ color: 'var(--text-muted)' }}>Role</label>
|
|
|
+ <select
|
|
|
+ className="input"
|
|
|
+ value={inviteRole}
|
|
|
+ onChange={e => setInviteRole(e.target.value)}
|
|
|
+ >
|
|
|
+ {Object.entries(ROLE_LABELS).map(([value, label]) => (
|
|
|
+ <option key={value} value={value}>{label}</option>
|
|
|
+ ))}
|
|
|
+ </select>
|
|
|
</div>
|
|
|
+ <button
|
|
|
+ type="submit"
|
|
|
+ disabled={inviting}
|
|
|
+ className="btn btn-primary btn-md"
|
|
|
+ >
|
|
|
+ {inviting ? 'Sending…' : 'Send invite'}
|
|
|
+ </button>
|
|
|
+ </form>
|
|
|
+
|
|
|
+ {inviteError && (
|
|
|
+ <p className="text-xs mt-2" style={{ color: '#F87171' }}>{inviteError}</p>
|
|
|
+ )}
|
|
|
+ {inviteSuccess && (
|
|
|
+ <p className="text-xs mt-2" style={{ color: '#86EFAC' }}>{inviteSuccess}</p>
|
|
|
+ )}
|
|
|
+ </div>
|
|
|
+ )}
|
|
|
+
|
|
|
+ {/* Members list */}
|
|
|
+ <div className="card overflow-hidden mb-6">
|
|
|
+ <div className="px-5 py-4 border-b" style={{ borderColor: 'rgba(255,255,255,0.06)' }}>
|
|
|
+ <h2 className="text-sm font-semibold" style={{ color: 'var(--text)' }}>
|
|
|
+ Members ({members.length})
|
|
|
+ </h2>
|
|
|
+ </div>
|
|
|
+
|
|
|
+ {members.length === 0 ? (
|
|
|
+ <div className="p-8 text-center">
|
|
|
+ <p className="text-sm" style={{ color: 'var(--text-muted)' }}>No members yet</p>
|
|
|
</div>
|
|
|
+ ) : (
|
|
|
+ <div className="divide-y" style={{ borderColor: 'rgba(255,255,255,0.04)' }}>
|
|
|
+ {members.map(m => {
|
|
|
+ const isMe = m.user.id === user?.id;
|
|
|
+ const canEdit = isAdmin && !isMe;
|
|
|
|
|
|
- {/* Info */}
|
|
|
- <div className="p-4">
|
|
|
- <div className="flex items-start justify-between gap-2 mb-2">
|
|
|
- <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>
|
|
|
+ return (
|
|
|
+ <div key={m.id}
|
|
|
+ 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"
|
|
|
+ style={{ background: 'rgba(99,102,241,0.15)', color: '#A5B4FC' }}>
|
|
|
+ {m.user.name.split(' ').map((n: string) => n[0]).slice(0, 2).join('').toUpperCase()}
|
|
|
+ </div>
|
|
|
+
|
|
|
+ {/* Info */}
|
|
|
+ <div className="flex-1 min-w-0">
|
|
|
+ <div className="flex items-center gap-2">
|
|
|
+ <span className="text-sm font-medium" style={{ color: 'var(--text)' }}>
|
|
|
+ {m.user.name}
|
|
|
+ {isMe && <span className="ml-1.5 text-[10px]" style={{ color: 'var(--text-subtle)' }}>(you)</span>}
|
|
|
+ </span>
|
|
|
+ </div>
|
|
|
+ <p className="text-xs truncate" style={{ color: 'var(--text-muted)' }}>{m.user.email}</p>
|
|
|
+ </div>
|
|
|
+
|
|
|
+ {/* Joined date */}
|
|
|
+ <span className="text-xs hidden sm:block" style={{ color: 'var(--text-subtle)' }}>
|
|
|
+ {new Date(m.joinedAt).toLocaleDateString('en-US', { month: 'short', year: 'numeric' })}
|
|
|
+ </span>
|
|
|
+
|
|
|
+ {/* Role */}
|
|
|
+ {editingRoleId === m.id ? (
|
|
|
+ <div className="flex items-center gap-2 shrink-0">
|
|
|
+ <select
|
|
|
+ className="input text-xs py-1.5"
|
|
|
+ value={editingRole}
|
|
|
+ onChange={e => setEditingRole(e.target.value)}
|
|
|
+ autoFocus
|
|
|
+ >
|
|
|
+ {Object.entries(ROLE_LABELS).map(([v, l]) => (
|
|
|
+ <option key={v} value={v}>{l}</option>
|
|
|
+ ))}
|
|
|
+ </select>
|
|
|
+ <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}>
|
|
|
+ <path strokeLinecap="round" strokeLinejoin="round" d="M5 13l4 4L19 7" />
|
|
|
+ </svg>
|
|
|
+ </button>
|
|
|
+ <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}>
|
|
|
+ <path strokeLinecap="round" strokeLinejoin="round" d="M6 18L18 6M6 6l12 12" />
|
|
|
+ </svg>
|
|
|
+ </button>
|
|
|
+ </div>
|
|
|
+ ) : (
|
|
|
+ <div className="flex items-center gap-2 shrink-0">
|
|
|
+ <span className={`badge ${ROLE_COLORS[m.role] ?? 'badge-muted'}`}>
|
|
|
+ {ROLE_LABELS[m.role] ?? m.role}
|
|
|
+ </span>
|
|
|
+ {canEdit && (
|
|
|
+ <button
|
|
|
+ onClick={() => { setEditingRoleId(m.id); setEditingRole(m.role); }}
|
|
|
+ className="btn btn-secondary btn-sm"
|
|
|
+ title="Change role"
|
|
|
+ >
|
|
|
+ <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="M16.862 4.487l1.687-1.688a1.875 1.875 0 112.652 2.652L10.582 16.07a4.5 4.5 0 01-1.897 1.13L6 18l.8-2.685a4.5 4.5 0 011.13-1.897l8.932-8.931zm0 0L19.5 7.125" />
|
|
|
+ </svg>
|
|
|
+ </button>
|
|
|
+ )}
|
|
|
+ {isAdmin && !isMe && (
|
|
|
+ <button
|
|
|
+ onClick={() => setConfirmRemove({ id: m.user.id, name: m.user.name })}
|
|
|
+ className="btn btn-danger btn-sm"
|
|
|
+ title="Remove from 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="M22 12h-4l-3 9L9 3l-3 9H2" />
|
|
|
+ </svg>
|
|
|
+ </button>
|
|
|
+ )}
|
|
|
+ </div>
|
|
|
+ )}
|
|
|
+ </div>
|
|
|
+ );
|
|
|
+ })}
|
|
|
+ </div>
|
|
|
+ )}
|
|
|
+ </div>
|
|
|
+
|
|
|
+ {/* Pending invitations */}
|
|
|
+ {canManage && (
|
|
|
+ <div className="card overflow-hidden">
|
|
|
+ <div className="px-5 py-4 border-b flex items-center justify-between" style={{ borderColor: 'rgba(255,255,255,0.06)' }}>
|
|
|
+ <h2 className="text-sm font-semibold" style={{ color: 'var(--text)' }}>
|
|
|
+ Pending invitations
|
|
|
+ </h2>
|
|
|
+ <span className="text-xs px-2 py-0.5 rounded-full"
|
|
|
+ style={{ background: 'rgba(255,255,255,0.05)', color: 'var(--text-muted)' }}>
|
|
|
+ {pendingInvites.length}
|
|
|
+ </span>
|
|
|
+ </div>
|
|
|
+
|
|
|
+ {pendingInvites.length === 0 ? (
|
|
|
+ <div className="p-8 text-center">
|
|
|
+ <p className="text-xs" style={{ color: 'var(--text-subtle)' }}>No pending invitations</p>
|
|
|
</div>
|
|
|
- <div className="flex items-center gap-3 text-xs" style={{ color: 'var(--text-muted)' }}>
|
|
|
- <span>{asset._count?.comments ?? 0} comment{(asset._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="divide-y" style={{ borderColor: 'rgba(255,255,255,0.04)' }}>
|
|
|
+ {pendingInvites.map(inv => (
|
|
|
+ <div key={inv.id}
|
|
|
+ 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"
|
|
|
+ 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}>
|
|
|
+ <path strokeLinecap="round" strokeLinejoin="round" d="M21.75 6.75v10.5a2.25 2.25 0 01-2.25 2.25h-15a2.25 2.25 0 01-2.25-2.25V6.75m19.5 0A2.25 2.25 0 0019.5 4.5h-15a2.25 2.25 0 00-2.25 2.25m19.5 0v.243a2.25 2.25 0 01-1.07 1.916l-7.5 4.615a2.25 2.25 0 01-2.36 0L3.32 8.91a2.25 2.25 0 01-1.07-1.916V6.75" />
|
|
|
+ </svg>
|
|
|
+ </div>
|
|
|
+
|
|
|
+ {/* Info */}
|
|
|
+ <div className="flex-1 min-w-0">
|
|
|
+ <div className="flex items-center gap-2">
|
|
|
+ <span className="text-sm font-medium" style={{ color: 'var(--text)' }}>{inv.email}</span>
|
|
|
+ <span className={`badge ${ROLE_COLORS[inv.role] ?? 'badge-muted'}`}>
|
|
|
+ {ROLE_LABELS[inv.role] ?? inv.role}
|
|
|
+ </span>
|
|
|
+ </div>
|
|
|
+ <div className="flex items-center gap-3 mt-0.5 text-xs" style={{ color: 'var(--text-subtle)' }}>
|
|
|
+ <span>Sent {new Date(inv.createdAt).toLocaleDateString()}</span>
|
|
|
+ <span>·</span>
|
|
|
+ <span>Expires {new Date(inv.expiresAt).toLocaleDateString()}</span>
|
|
|
+ </div>
|
|
|
+ </div>
|
|
|
+
|
|
|
+ {/* Actions */}
|
|
|
+ <div className="flex items-center gap-1.5 shrink-0">
|
|
|
+ <button
|
|
|
+ onClick={() => handleCopyLink(inv)}
|
|
|
+ className="btn btn-secondary btn-sm"
|
|
|
+ title="Copy invite link"
|
|
|
+ >
|
|
|
+ {copiedInviteId === inv.id ? (
|
|
|
+ <svg className="w-3.5 h-3.5" style={{ color: '#86EFAC' }} fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
|
|
+ <path strokeLinecap="round" strokeLinejoin="round" d="M5 13l4 4L19 7" />
|
|
|
+ </svg>
|
|
|
+ ) : (
|
|
|
+ <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="M13.19 8.688a4.5 4.5 0 011.242 7.244l-4.5 4.5a4.5 4.5 0 01-6.364-6.364l1.757-1.757m13.35-.622l1.757-1.757a4.5 4.5 0 00-6.364-6.364l-4.5 4.5a4.5 4.5 0 001.242 7.244" />
|
|
|
+ </svg>
|
|
|
+ )}
|
|
|
+ </button>
|
|
|
+ <button
|
|
|
+ onClick={() => handleRevoke(inv.id)}
|
|
|
+ disabled={revokingId === inv.id}
|
|
|
+ className="btn btn-danger btn-sm"
|
|
|
+ title="Revoke invitation"
|
|
|
+ >
|
|
|
+ {revokingId === inv.id ? '…' : (
|
|
|
+ <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" />
|
|
|
+ </svg>
|
|
|
+ )}
|
|
|
+ </button>
|
|
|
+ </div>
|
|
|
+ </div>
|
|
|
+ ))}
|
|
|
</div>
|
|
|
- </div>
|
|
|
- </a>
|
|
|
- ))}
|
|
|
+ )}
|
|
|
+
|
|
|
+ {pendingInvites.length > 0 && (
|
|
|
+ <div className="px-5 py-3 border-t" style={{ borderColor: 'rgba(255,255,255,0.04)' }}>
|
|
|
+ <p className="text-xs" style={{ color: 'var(--text-subtle)' }}>
|
|
|
+ Invitation links expire after 7 days. Copy the link and send it manually, or ask the recipient to check their email.
|
|
|
+ </p>
|
|
|
+ </div>
|
|
|
+ )}
|
|
|
+ </div>
|
|
|
+ )}
|
|
|
</div>
|
|
|
)}
|
|
|
</div>
|
|
|
+
|
|
|
+ {/* Remove member confirm modal */}
|
|
|
+ {confirmRemove && (
|
|
|
+ <div className="fixed inset-0 z-50 flex items-center justify-center"
|
|
|
+ style={{ background: 'rgba(0,0,0,0.7)' }}>
|
|
|
+ <div className="card p-6 max-w-sm w-full mx-4 animate-scale-in">
|
|
|
+ <h3 className="text-base font-semibold mb-2" style={{ color: 'var(--text)' }}>
|
|
|
+ Remove {confirmRemove.name}?
|
|
|
+ </h3>
|
|
|
+ <p className="text-sm mb-5" style={{ color: 'var(--text-muted)' }}>
|
|
|
+ They'll lose access to this project and all its videos. They can rejoin if invited again.
|
|
|
+ </p>
|
|
|
+ <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"
|
|
|
+ >
|
|
|
+ {removing ? 'Removing…' : 'Remove'}
|
|
|
+ </button>
|
|
|
+ </div>
|
|
|
+ </div>
|
|
|
+ </div>
|
|
|
+ )}
|
|
|
</div>
|
|
|
);
|
|
|
}
|