const API_BASE = process.env.NEXT_PUBLIC_API_URL || ''; interface FetchOptions extends RequestInit { token?: string; } async function apiFetch( endpoint: string, options: FetchOptions = {} ): Promise { const { token, ...fetchOptions } = options; const headers: Record = { 'Content-Type': 'application/json', ...(options.headers as Record || {}), }; const authToken = token || (typeof window !== 'undefined' ? localStorage.getItem('vidreview_token') : null); if (authToken) { headers['Authorization'] = `Bearer ${authToken}`; } const res = await fetch(`${API_BASE}${endpoint}`, { ...fetchOptions, headers, credentials: 'include', }); if (!res.ok) { const error = await res.json().catch(() => ({ error: res.statusText })); throw new Error(error.error || `HTTP ${res.status}`); } return res.json(); } // ── Auth ───────────────────────────────────────────────────────────────────── export const authApi = { register: (data: { email: string; name: string; password: string; inviteToken?: string }) => apiFetch<{ user: User; token: string; acceptedProjects: { projectId: string; projectName: string }[]; userName: string }>('/api/auth/register', { method: 'POST', body: JSON.stringify(data), }), login: (data: { email: string; password: string }) => apiFetch<{ user: User; token: string; acceptedProjects: { projectId: string; projectName: string }[] }>('/api/auth/login', { method: 'POST', body: JSON.stringify(data), }), logout: () => apiFetch('/api/auth/logout', { method: 'POST' }), me: (token: string) => apiFetch<{ user: User }>('/api/auth/me', { token }), }; // ── Projects ───────────────────────────────────────────────────────────────── export const projectsApi = { list: (token: string, params?: { page?: number; limit?: number; search?: string }) => { const q = new URLSearchParams(); if (params?.page) q.set('page', String(params.page)); if (params?.limit) q.set('limit', String(params.limit)); if (params?.search) q.set('search', params.search); return apiFetch<{ projects: Project[]; total: number; page: number; limit: number; totalPages: number }>( `/api/projects${q.toString() ? `?${q}` : ''}`, { token } ); }, create: (token: string, data: { name: string; description?: string }) => apiFetch<{ project: Project }>('/api/projects', { method: 'POST', body: JSON.stringify(data), token, }), get: (token: string, id: string) => apiFetch<{ project: Project }>(`/api/projects/${id}`, { token }), update: (token: string, id: string, data: { name?: string; description?: string }) => apiFetch<{ project: Project }>(`/api/projects/${id}`, { method: 'PUT', body: JSON.stringify(data), token, }), delete: (token: string, id: string) => apiFetch(`/api/projects/${id}`, { method: 'DELETE', token }), inviteMember: (token: string, projectId: string, email: string, role: string) => apiFetch(`/api/projects/${projectId}/members`, { method: 'POST', body: JSON.stringify({ email, role }), token, }), removeMember: (token: string, projectId: string, userId: string) => apiFetch(`/api/projects/${projectId}/members/${userId}`, { method: 'DELETE', token }), updateMember: (token: string, projectId: string, userId: string, role: string) => apiFetch(`/api/projects/${projectId}/members/${userId}`, { method: 'PUT', body: JSON.stringify({ role }), token, }), }; // ── Assets ─────────────────────────────────────────────────────────────────── export const assetsApi = { list: (token: string, projectId: string, params?: { page?: number; limit?: number; search?: string; status?: string; }) => { const q = new URLSearchParams({ projectId }); if (params?.page) q.set('page', String(params.page)); if (params?.limit) q.set('limit', String(params.limit)); if (params?.search) q.set('search', params.search); if (params?.status) q.set('status', params.status); return apiFetch<{ assets: Asset[]; total: number; page: number; limit: number; totalPages: number }>( `/api/assets?${q}`, { token } ); }, get: (token: string, id: string) => apiFetch<{ asset: AssetWithComments }>(`/api/assets/${id}`, { token }), getStatus: (token: string, id: string) => apiFetch<{ asset: AssetStatusInfo }>(`/api/assets/${id}/status`, { token }), upload: (token: string, formData: FormData) => fetch(`${API_BASE}/api/assets/upload`, { method: 'POST', headers: { Authorization: `Bearer ${token}` }, body: formData, }).then(r => r.json()), updateStatus: (token: string, id: string, status: string) => apiFetch<{ asset: Asset }>(`/api/assets/${id}/status`, { method: 'PUT', body: JSON.stringify({ status }), token, }), delete: (token: string, id: string) => apiFetch(`/api/assets/${id}`, { method: 'DELETE', token }), cancelTranscode: (token: string, id: string) => apiFetch<{ asset: Asset }>(`/api/assets/${id}/transcode/cancel`, { method: 'POST', token }), pauseTranscode: (token: string, id: string) => apiFetch<{ asset: Asset }>(`/api/assets/${id}/transcode/pause`, { method: 'POST', token }), resumeTranscode: (token: string, id: string) => apiFetch<{ asset: Asset }>(`/api/assets/${id}/transcode/resume`, { method: 'POST', token }), }; // ── Comments ───────────────────────────────────────────────────────────────── export const commentsApi = { list: (token: string, assetId: string, params?: { resolved?: boolean; includeDeleted?: boolean; page?: number; limit?: number; }) => { const q = new URLSearchParams(); if (params?.resolved !== undefined) q.set('resolved', String(params.resolved)); if (params?.includeDeleted) q.set('includeDeleted', 'true'); if (params?.page) q.set('page', String(params.page)); if (params?.limit) q.set('limit', String(params.limit)); return apiFetch<{ comments: Comment[]; total: number; page: number; limit: number; totalPages: number }>( `/api/assets/${assetId}/comments${q.toString() ? `?${q}` : ''}`, { token } ); }, create: (token: string, assetId: string, data: { content: string; timestamp?: number; annotations?: AnnotationData[]; parentId?: string; }) => apiFetch<{ comment: Comment }>(`/api/assets/${assetId}/comments`, { method: 'POST', body: JSON.stringify(data), token, }), requestResolve: (token: string, id: string) => apiFetch<{ comment: Comment }>(`/api/comments/${id}/resolve/request`, { method: 'POST', token }), resolve: (token: string, id: string, action?: 'approve' | 'reject') => apiFetch<{ comment: Comment }>(`/api/comments/${id}/resolve`, { method: 'PUT', body: JSON.stringify({ action }), token, }), updateAnnotations: (token: string, id: string, annotations: AnnotationData[]) => apiFetch<{ comment: Comment }>(`/api/comments/${id}/annotations`, { method: 'PUT', body: JSON.stringify({ annotations }), token, }), delete: (token: string, id: string) => apiFetch(`/api/comments/${id}`, { method: 'DELETE', token }), restoreComment: (token: string, commentId: string) => apiFetch<{ comment: Comment }>(`/api/comments/${commentId}/restore`, { method: 'POST', token }), }; // ── Users ──────────────────────────────────────────────────────────────────── export const usersApi = { list: (token: string, search?: string) => apiFetch<{ users: AdminUser[] }>(`/api/users${search ? `?search=${encodeURIComponent(search)}` : ''}`, { token }), getMe: (token: string) => apiFetch<{ user: AdminUser }>('/api/users/me', { token }), updateMe: (token: string, data: { name?: string; avatarUrl?: string; currentPassword?: string; newPassword?: string; }) => apiFetch<{ user: AdminUser }>('/api/users/me', { method: 'PUT', body: JSON.stringify(data), token, }), uploadAvatar: (token: string, file: File): Promise<{ user: AdminUser; avatarUrl: string }> => { const formData = new FormData(); formData.append('avatar', file); return fetch(`${API_BASE}/api/users/me/avatar`, { method: 'POST', headers: { Authorization: `Bearer ${token}` }, body: formData, }).then(async r => { if (!r.ok) { const errBody = await r.json().catch(() => ({} as Record)); const msg = typeof errBody.error === 'string' ? errBody.error : `HTTP ${r.status}`; throw new Error(msg); } return r.json() as Promise<{ user: AdminUser; avatarUrl: string }>; }); }, updateRole: (token: string, id: string, role: string) => apiFetch<{ user: AdminUser }>(`/api/users/${id}/role`, { method: 'PUT', body: JSON.stringify({ role }), token, }), updateActive: (token: string, id: string, active: boolean) => apiFetch<{ user: AdminUser }>(`/api/users/${id}/active`, { method: 'PUT', body: JSON.stringify({ active }), token, }), updateQuota: (token: string, id: string, storageQuota: number) => apiFetch<{ user: AdminUser }>(`/api/users/${id}/quota`, { method: 'PUT', body: JSON.stringify({ storageQuota }), token, }), deleteUser: (token: string, id: string) => apiFetch(`/api/users/${id}`, { method: 'DELETE', token }), }; // ── Invitations ──────────────────────────────────────────────────────────────── export const invitationsApi = { // Public: verify an invitation token verify: (token: string) => apiFetch<{ invitation: InvitationInfo }>(`/api/invitations/${token}`), // Public: accept invitation (must be logged in with matching email) accept: (token: string) => apiFetch<{ member: { projectId: string } }>(`/api/invitations/${token}/accept`, { method: 'POST', }), // Project-scoped: list pending invitations list: (token: string, projectId: string) => apiFetch<{ invitations: Invitation[] }>(`/api/invitations/project/${projectId}`, { token }), // Project-scoped: create invitation create: (token: string, projectId: string, email: string, role: string) => apiFetch<{ invitation: Invitation; inviteUrl: string }>(`/api/invitations/project/${projectId}`, { method: 'POST', body: JSON.stringify({ email, role }), token, }), // Admin: invite MEMBER to workspace (email only, no project) inviteMember: (token: string, email: string) => apiFetch<{ invitation: Invitation; inviteUrl: string }>('/api/invitations/workspace', { method: 'POST', body: JSON.stringify({ email }), token, }), // Admin: invite PROJECT_USER to a specific project (requires projectId) adminInvite: (token: string, email: string, projectId: string, role: string) => apiFetch<{ invitation: Invitation; inviteUrl: string }>('/api/invitations', { method: 'POST', body: JSON.stringify({ email, projectId, role }), token, }), // Admin: list all pending workspace invitations listAll: (token: string) => apiFetch<{ invitations: AdminInvitation[] }>('/api/invitations', { token }), // Revoke an invitation revoke: (token: string, invitationId: string) => apiFetch(`/api/invitations/${invitationId}`, { method: 'DELETE', token }), // Resend invitation (new token) resend: (token: string, projectId: string, invitationId: string) => apiFetch<{ invitation: Invitation; inviteUrl: string }>(`/api/invitations/project/${projectId}/resend`, { method: 'POST', body: JSON.stringify({ invitationId }), token, }), }; // ── Site Settings ────────────────────────────────────────────────────────────── export const settingsApi = { getRegistration: (token: string) => apiFetch<{ enabled: boolean }>('/api/settings/registration', { token }), setRegistration: (token: string, enabled: boolean) => apiFetch<{ enabled: boolean }>('/api/settings/registration', { method: 'PUT', body: JSON.stringify({ enabled }), token, }), }; // ── Types ───────────────────────────────────────────────────────────────────── export interface User { id: string; email: string; name: string; globalRole: string; avatarUrl?: string | null; storageQuota?: number; // bytes storageUsed?: number; // bytes } export interface AdminUser extends User { active: boolean; createdAt: string; storageQuota: number; // bytes storageUsed: number; // bytes — sum of assets in owned projects /** Number of projects this user owns (their storage is counted from these) */ ownedProjects: number; _count?: { memberships: number; // projects they're a member of (including owned) comments: number; }; } export interface Project { id: string; name: string; description?: string | null; ownerId: string; createdAt: string; updatedAt: string; /** Current user's role in this project: 'OWNER' | 'ADMIN' | 'EDITOR' | 'REVIEWER' | 'VIEWER' | null */ myRole: string | null; members: Array<{ id: string; role: string; joinedAt: string; invitedBy?: string | null; user: User }>; _count?: { assets: number }; } export interface Invitation { id: string; email: string; projectId: string; role: string; token: string; status: 'PENDING' | 'ACCEPTED' | 'EXPIRED' | 'REVOKED'; invitedBy?: string | null; expiresAt: string; createdAt: string; } export interface AdminInvitation { id: string; email: string; projectId: string; role: string; token: string; status: 'PENDING' | 'ACCEPTED' | 'EXPIRED' | 'REVOKED'; invitedBy?: string | null; expiresAt: string; createdAt: string; project: { id: string; name: string }; type?: 'WORKSPACE' | 'PROJECT'; } export interface InvitationInfo { id: string; email: string; role: string; projectName?: string | null; projectId?: string | null; /** 'WORKSPACE' = invite MEMBER to workspace (no project); 'PROJECT' = invite PROJECT_USER to a project */ type?: 'WORKSPACE' | 'PROJECT'; expiresAt: string; status: 'PENDING' | 'ACCEPTED' | 'EXPIRED' | 'REVOKED'; isExpired: boolean; isOwnInvitation: boolean; alreadyMember: boolean; isLoggedIn: boolean; /** Whether the invite email already has an account — determines sign-in vs register button */ inviteeExists: boolean; } export type TranscodeStatus = | 'PENDING' | 'UPLOADING' | 'PROCESSING' | 'COMPLETED' | 'FAILED' | 'UNSUPPORTED_CODEC'; export interface Asset { id: string; projectId: string; title: string; filename: string; originalFilename?: string | null; filePath: string; thumbnail?: string | null; hlsPath?: string | null; duration?: number | null; fps?: number; codec?: string | null; /** Width of the video in pixels (px) */ videoWidth?: number | null; /** Height of the video in pixels (px) */ videoHeight?: number | null; /** File size in bytes */ fileSize?: number | null; /** Video bitrate in bits/s (original file) */ bitrate?: number | null; mimeType: string; status: string; transcodeStatus: TranscodeStatus; transcodeProgress: number; transcodeError?: string | null; transcodePaused?: boolean; createdAt: string; uploader?: Pick; _count?: { comments: number }; /** True if this asset has at least one public share link */ isShared?: boolean; } export interface AssetStatusInfo { id: string; title: string; hlsPath?: string | null; transcodeStatus: TranscodeStatus; transcodeProgress: number; transcodeError?: string | null; transcodePaused?: boolean; thumbnail?: string | null; duration?: number | null; codec?: string | null; status: string; } export interface AssetWithComments extends Asset { project: Project; comments: Comment[]; } export interface Comment { id: string; assetId: string; userId: string; content: string; timestamp?: number | null; annotations?: AnnotationData[] | null; resolved: boolean; resolveStatus: 'UNRESOLVED' | 'PENDING_APPROVAL' | 'RESOLVED'; resolvedById?: string | null; resolvedByAt?: string | null; requestedById?: string | null; requestedByAt?: string | null; parentId?: string | null; deleted?: boolean; deletedAt?: string | null; deletedById?: string | null; createdAt: string; user: User; replies?: Comment[]; resolvedBy?: Pick; requestedBy?: Pick; deletedBy?: Pick; } export interface AnnotationData { type: 'pen' | 'arrow' | 'rect' | 'ellipse' | 'text'; color: string; points?: [number, number][]; text?: string; boundingBox?: { x: number; y: number; width: number; height: number }; } // ── Share Links ──────────────────────────────────────────────────────────────── export interface ShareLink { id: string; assetId: string; token: string; shareUrl?: string; hasPassword: boolean; allowDownload: boolean; maxViews: number; // -1 or 0 = unlimited viewCount: number; assetTitle?: string; createdAt?: string; } export interface ShareLinkVerify { id: string; token: string; hasPassword: boolean; allowDownload: boolean; maxViews: number; viewCount: number; asset: { id: string; title: string; thumbnail?: string | null; mimeType: string; duration?: number | null; fps?: number; videoReady: boolean; }; } export const shareLinksApi = { // Get share link for an asset (by assetId — admin/owner only) getForAsset: (assetId: string) => apiFetch<{ shareLink: ShareLink | null }>(`/api/share?assetId=${assetId}`), // Create a share link for an asset create: (assetId: string, data: { password?: string; allowDownload?: boolean; maxViews?: number; }) => apiFetch<{ shareLink: ShareLink }>('/api/share', { method: 'POST', body: JSON.stringify({ assetId, ...data }), }), // Update share link settings update: (id: string, data: { password?: string; allowDownload?: boolean; maxViews?: number; }) => apiFetch<{ shareLink: ShareLink }>(`/api/share/${id}`, { method: 'PUT', body: JSON.stringify(data), }), // Revoke/delete share link revoke: (id: string) => apiFetch(`/api/share/${id}`, { method: 'DELETE' }), // Public: verify share link token verify: (token: string) => apiFetch(`/api/share/${token}`), // Public: submit password and get stream URL access: (token: string, password?: string) => apiFetch<{ streamUrl: string; mimeType: string; allowDownload: boolean }>(`/api/share/${token}/access`, { method: 'POST', body: JSON.stringify({ password }), }), }; // ── Folders ──────────────────────────────────────────────────────────────────── export interface FolderNode { id: string; name: string; parentId: string | null; order: number; assetCount: number; assetIds: string[]; children: FolderNode[]; } export const foldersApi = { // List all folders for a project list: (token: string, projectId: string) => apiFetch<{ folders: FolderNode[]; allFolders: FolderNode[] }>(`/api/folders/project/${projectId}`, { token }), // Create folder create: (token: string, data: { name: string; projectId: string; parentId?: string }) => apiFetch<{ folder: FolderNode }>('/api/folders', { method: 'POST', body: JSON.stringify(data), token }), // Rename folder rename: (token: string, id: string, name: string) => apiFetch<{ folder: FolderNode }>(`/api/folders/${id}`, { method: 'PUT', body: JSON.stringify({ name }), token }), // Delete folder delete: (token: string, id: string) => apiFetch(`/api/folders/${id}`, { method: 'DELETE', token }), // Add assets to folder (append) addAssets: (token: string, id: string, assetIds: string[]) => apiFetch<{ success: boolean; assetCount: number }>(`/api/folders/${id}/assets`, { method: 'POST', body: JSON.stringify({ assetIds }), token }), // Replace all assets in folder setAssets: (token: string, id: string, assetIds: string[]) => apiFetch<{ success: boolean; assetCount: number }>(`/api/folders/${id}/assets`, { method: 'PUT', body: JSON.stringify({ assetIds }), token }), // Remove asset from folder removeAsset: (token: string, id: string, assetId: string) => apiFetch(`/api/folders/${id}/assets/${assetId}`, { method: 'DELETE', token }), };