| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637 |
- const API_BASE = process.env.NEXT_PUBLIC_API_URL || '';
- interface FetchOptions extends RequestInit {
- token?: string;
- }
- async function apiFetch<T = unknown>(
- endpoint: string,
- options: FetchOptions = {}
- ): Promise<T> {
- const { token, ...fetchOptions } = options;
- const headers: Record<string, string> = {
- 'Content-Type': 'application/json',
- ...(options.headers as Record<string, string> || {}),
- };
- 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<string, unknown>));
- 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<User, 'id' | 'name' | 'email' | 'avatarUrl'>;
- _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<User, 'id' | 'name' | 'email' | 'avatarUrl'>;
- requestedBy?: Pick<User, 'id' | 'name' | 'email' | 'avatarUrl'>;
- deletedBy?: Pick<User, 'id' | 'name' | 'email' | 'avatarUrl'>;
- }
- 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<ShareLinkVerify>(`/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 }),
- };
|