api.ts 21 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637
  1. const API_BASE = process.env.NEXT_PUBLIC_API_URL || '';
  2. interface FetchOptions extends RequestInit {
  3. token?: string;
  4. }
  5. async function apiFetch<T = unknown>(
  6. endpoint: string,
  7. options: FetchOptions = {}
  8. ): Promise<T> {
  9. const { token, ...fetchOptions } = options;
  10. const headers: Record<string, string> = {
  11. 'Content-Type': 'application/json',
  12. ...(options.headers as Record<string, string> || {}),
  13. };
  14. const authToken = token || (typeof window !== 'undefined' ? localStorage.getItem('vidreview_token') : null);
  15. if (authToken) {
  16. headers['Authorization'] = `Bearer ${authToken}`;
  17. }
  18. const res = await fetch(`${API_BASE}${endpoint}`, {
  19. ...fetchOptions,
  20. headers,
  21. credentials: 'include',
  22. });
  23. if (!res.ok) {
  24. const error = await res.json().catch(() => ({ error: res.statusText }));
  25. throw new Error(error.error || `HTTP ${res.status}`);
  26. }
  27. return res.json();
  28. }
  29. // ── Auth ─────────────────────────────────────────────────────────────────────
  30. export const authApi = {
  31. register: (data: { email: string; name: string; password: string; inviteToken?: string }) =>
  32. apiFetch<{ user: User; token: string; acceptedProjects: { projectId: string; projectName: string }[]; userName: string }>('/api/auth/register', {
  33. method: 'POST',
  34. body: JSON.stringify(data),
  35. }),
  36. login: (data: { email: string; password: string }) =>
  37. apiFetch<{ user: User; token: string; acceptedProjects: { projectId: string; projectName: string }[] }>('/api/auth/login', {
  38. method: 'POST',
  39. body: JSON.stringify(data),
  40. }),
  41. logout: () =>
  42. apiFetch('/api/auth/logout', { method: 'POST' }),
  43. me: (token: string) =>
  44. apiFetch<{ user: User }>('/api/auth/me', { token }),
  45. };
  46. // ── Projects ─────────────────────────────────────────────────────────────────
  47. export const projectsApi = {
  48. list: (token: string, params?: { page?: number; limit?: number; search?: string }) => {
  49. const q = new URLSearchParams();
  50. if (params?.page) q.set('page', String(params.page));
  51. if (params?.limit) q.set('limit', String(params.limit));
  52. if (params?.search) q.set('search', params.search);
  53. return apiFetch<{ projects: Project[]; total: number; page: number; limit: number; totalPages: number }>(
  54. `/api/projects${q.toString() ? `?${q}` : ''}`, { token }
  55. );
  56. },
  57. create: (token: string, data: { name: string; description?: string }) =>
  58. apiFetch<{ project: Project }>('/api/projects', {
  59. method: 'POST',
  60. body: JSON.stringify(data),
  61. token,
  62. }),
  63. get: (token: string, id: string) =>
  64. apiFetch<{ project: Project }>(`/api/projects/${id}`, { token }),
  65. update: (token: string, id: string, data: { name?: string; description?: string }) =>
  66. apiFetch<{ project: Project }>(`/api/projects/${id}`, {
  67. method: 'PUT',
  68. body: JSON.stringify(data),
  69. token,
  70. }),
  71. delete: (token: string, id: string) =>
  72. apiFetch(`/api/projects/${id}`, { method: 'DELETE', token }),
  73. inviteMember: (token: string, projectId: string, email: string, role: string) =>
  74. apiFetch(`/api/projects/${projectId}/members`, {
  75. method: 'POST',
  76. body: JSON.stringify({ email, role }),
  77. token,
  78. }),
  79. removeMember: (token: string, projectId: string, userId: string) =>
  80. apiFetch(`/api/projects/${projectId}/members/${userId}`, { method: 'DELETE', token }),
  81. updateMember: (token: string, projectId: string, userId: string, role: string) =>
  82. apiFetch(`/api/projects/${projectId}/members/${userId}`, {
  83. method: 'PUT',
  84. body: JSON.stringify({ role }),
  85. token,
  86. }),
  87. };
  88. // ── Assets ───────────────────────────────────────────────────────────────────
  89. export const assetsApi = {
  90. list: (token: string, projectId: string, params?: {
  91. page?: number; limit?: number; search?: string; status?: string;
  92. }) => {
  93. const q = new URLSearchParams({ projectId });
  94. if (params?.page) q.set('page', String(params.page));
  95. if (params?.limit) q.set('limit', String(params.limit));
  96. if (params?.search) q.set('search', params.search);
  97. if (params?.status) q.set('status', params.status);
  98. return apiFetch<{ assets: Asset[]; total: number; page: number; limit: number; totalPages: number }>(
  99. `/api/assets?${q}`, { token }
  100. );
  101. },
  102. get: (token: string, id: string) =>
  103. apiFetch<{ asset: AssetWithComments }>(`/api/assets/${id}`, { token }),
  104. getStatus: (token: string, id: string) =>
  105. apiFetch<{ asset: AssetStatusInfo }>(`/api/assets/${id}/status`, { token }),
  106. upload: (token: string, formData: FormData) =>
  107. fetch(`${API_BASE}/api/assets/upload`, {
  108. method: 'POST',
  109. headers: { Authorization: `Bearer ${token}` },
  110. body: formData,
  111. }).then(r => r.json()),
  112. updateStatus: (token: string, id: string, status: string) =>
  113. apiFetch<{ asset: Asset }>(`/api/assets/${id}/status`, {
  114. method: 'PUT',
  115. body: JSON.stringify({ status }),
  116. token,
  117. }),
  118. delete: (token: string, id: string) =>
  119. apiFetch(`/api/assets/${id}`, { method: 'DELETE', token }),
  120. cancelTranscode: (token: string, id: string) =>
  121. apiFetch<{ asset: Asset }>(`/api/assets/${id}/transcode/cancel`, { method: 'POST', token }),
  122. pauseTranscode: (token: string, id: string) =>
  123. apiFetch<{ asset: Asset }>(`/api/assets/${id}/transcode/pause`, { method: 'POST', token }),
  124. resumeTranscode: (token: string, id: string) =>
  125. apiFetch<{ asset: Asset }>(`/api/assets/${id}/transcode/resume`, { method: 'POST', token }),
  126. };
  127. // ── Comments ─────────────────────────────────────────────────────────────────
  128. export const commentsApi = {
  129. list: (token: string, assetId: string, params?: {
  130. resolved?: boolean; includeDeleted?: boolean; page?: number; limit?: number;
  131. }) => {
  132. const q = new URLSearchParams();
  133. if (params?.resolved !== undefined) q.set('resolved', String(params.resolved));
  134. if (params?.includeDeleted) q.set('includeDeleted', 'true');
  135. if (params?.page) q.set('page', String(params.page));
  136. if (params?.limit) q.set('limit', String(params.limit));
  137. return apiFetch<{ comments: Comment[]; total: number; page: number; limit: number; totalPages: number }>(
  138. `/api/assets/${assetId}/comments${q.toString() ? `?${q}` : ''}`, { token }
  139. );
  140. },
  141. create: (token: string, assetId: string, data: {
  142. content: string;
  143. timestamp?: number;
  144. annotations?: AnnotationData[];
  145. parentId?: string;
  146. }) =>
  147. apiFetch<{ comment: Comment }>(`/api/assets/${assetId}/comments`, {
  148. method: 'POST',
  149. body: JSON.stringify(data),
  150. token,
  151. }),
  152. requestResolve: (token: string, id: string) =>
  153. apiFetch<{ comment: Comment }>(`/api/comments/${id}/resolve/request`, { method: 'POST', token }),
  154. resolve: (token: string, id: string, action?: 'approve' | 'reject') =>
  155. apiFetch<{ comment: Comment }>(`/api/comments/${id}/resolve`, {
  156. method: 'PUT',
  157. body: JSON.stringify({ action }),
  158. token,
  159. }),
  160. updateAnnotations: (token: string, id: string, annotations: AnnotationData[]) =>
  161. apiFetch<{ comment: Comment }>(`/api/comments/${id}/annotations`, {
  162. method: 'PUT',
  163. body: JSON.stringify({ annotations }),
  164. token,
  165. }),
  166. delete: (token: string, id: string) =>
  167. apiFetch(`/api/comments/${id}`, { method: 'DELETE', token }),
  168. restoreComment: (token: string, commentId: string) =>
  169. apiFetch<{ comment: Comment }>(`/api/comments/${commentId}/restore`, { method: 'POST', token }),
  170. };
  171. // ── Users ────────────────────────────────────────────────────────────────────
  172. export const usersApi = {
  173. list: (token: string, search?: string) =>
  174. apiFetch<{ users: AdminUser[] }>(`/api/users${search ? `?search=${encodeURIComponent(search)}` : ''}`, { token }),
  175. getMe: (token: string) =>
  176. apiFetch<{ user: AdminUser }>('/api/users/me', { token }),
  177. updateMe: (token: string, data: {
  178. name?: string;
  179. avatarUrl?: string;
  180. currentPassword?: string;
  181. newPassword?: string;
  182. }) =>
  183. apiFetch<{ user: AdminUser }>('/api/users/me', {
  184. method: 'PUT',
  185. body: JSON.stringify(data),
  186. token,
  187. }),
  188. uploadAvatar: (token: string, file: File): Promise<{ user: AdminUser; avatarUrl: string }> => {
  189. const formData = new FormData();
  190. formData.append('avatar', file);
  191. return fetch(`${API_BASE}/api/users/me/avatar`, {
  192. method: 'POST',
  193. headers: { Authorization: `Bearer ${token}` },
  194. body: formData,
  195. }).then(async r => {
  196. if (!r.ok) {
  197. const errBody = await r.json().catch(() => ({} as Record<string, unknown>));
  198. const msg = typeof errBody.error === 'string' ? errBody.error : `HTTP ${r.status}`;
  199. throw new Error(msg);
  200. }
  201. return r.json() as Promise<{ user: AdminUser; avatarUrl: string }>;
  202. });
  203. },
  204. updateRole: (token: string, id: string, role: string) =>
  205. apiFetch<{ user: AdminUser }>(`/api/users/${id}/role`, {
  206. method: 'PUT',
  207. body: JSON.stringify({ role }),
  208. token,
  209. }),
  210. updateActive: (token: string, id: string, active: boolean) =>
  211. apiFetch<{ user: AdminUser }>(`/api/users/${id}/active`, {
  212. method: 'PUT',
  213. body: JSON.stringify({ active }),
  214. token,
  215. }),
  216. updateQuota: (token: string, id: string, storageQuota: number) =>
  217. apiFetch<{ user: AdminUser }>(`/api/users/${id}/quota`, {
  218. method: 'PUT',
  219. body: JSON.stringify({ storageQuota }),
  220. token,
  221. }),
  222. deleteUser: (token: string, id: string) =>
  223. apiFetch(`/api/users/${id}`, { method: 'DELETE', token }),
  224. };
  225. // ── Invitations ────────────────────────────────────────────────────────────────
  226. export const invitationsApi = {
  227. // Public: verify an invitation token
  228. verify: (token: string) =>
  229. apiFetch<{ invitation: InvitationInfo }>(`/api/invitations/${token}`),
  230. // Public: accept invitation (must be logged in with matching email)
  231. accept: (token: string) =>
  232. apiFetch<{ member: { projectId: string } }>(`/api/invitations/${token}/accept`, {
  233. method: 'POST',
  234. }),
  235. // Project-scoped: list pending invitations
  236. list: (token: string, projectId: string) =>
  237. apiFetch<{ invitations: Invitation[] }>(`/api/invitations/project/${projectId}`, { token }),
  238. // Project-scoped: create invitation
  239. create: (token: string, projectId: string, email: string, role: string) =>
  240. apiFetch<{ invitation: Invitation; inviteUrl: string }>(`/api/invitations/project/${projectId}`, {
  241. method: 'POST',
  242. body: JSON.stringify({ email, role }),
  243. token,
  244. }),
  245. // Admin: invite MEMBER to workspace (email only, no project)
  246. inviteMember: (token: string, email: string) =>
  247. apiFetch<{ invitation: Invitation; inviteUrl: string }>('/api/invitations/workspace', {
  248. method: 'POST',
  249. body: JSON.stringify({ email }),
  250. token,
  251. }),
  252. // Admin: invite PROJECT_USER to a specific project (requires projectId)
  253. adminInvite: (token: string, email: string, projectId: string, role: string) =>
  254. apiFetch<{ invitation: Invitation; inviteUrl: string }>('/api/invitations', {
  255. method: 'POST',
  256. body: JSON.stringify({ email, projectId, role }),
  257. token,
  258. }),
  259. // Admin: list all pending workspace invitations
  260. listAll: (token: string) =>
  261. apiFetch<{ invitations: AdminInvitation[] }>('/api/invitations', { token }),
  262. // Revoke an invitation
  263. revoke: (token: string, invitationId: string) =>
  264. apiFetch(`/api/invitations/${invitationId}`, { method: 'DELETE', token }),
  265. // Resend invitation (new token)
  266. resend: (token: string, projectId: string, invitationId: string) =>
  267. apiFetch<{ invitation: Invitation; inviteUrl: string }>(`/api/invitations/project/${projectId}/resend`, {
  268. method: 'POST',
  269. body: JSON.stringify({ invitationId }),
  270. token,
  271. }),
  272. };
  273. // ── Site Settings ──────────────────────────────────────────────────────────────
  274. export const settingsApi = {
  275. getRegistration: (token: string) =>
  276. apiFetch<{ enabled: boolean }>('/api/settings/registration', { token }),
  277. setRegistration: (token: string, enabled: boolean) =>
  278. apiFetch<{ enabled: boolean }>('/api/settings/registration', {
  279. method: 'PUT',
  280. body: JSON.stringify({ enabled }),
  281. token,
  282. }),
  283. };
  284. // ── Types ─────────────────────────────────────────────────────────────────────
  285. export interface User {
  286. id: string;
  287. email: string;
  288. name: string;
  289. globalRole: string;
  290. avatarUrl?: string | null;
  291. storageQuota?: number; // bytes
  292. storageUsed?: number; // bytes
  293. }
  294. export interface AdminUser extends User {
  295. active: boolean;
  296. createdAt: string;
  297. storageQuota: number; // bytes
  298. storageUsed: number; // bytes — sum of assets in owned projects
  299. /** Number of projects this user owns (their storage is counted from these) */
  300. ownedProjects: number;
  301. _count?: {
  302. memberships: number; // projects they're a member of (including owned)
  303. comments: number;
  304. };
  305. }
  306. export interface Project {
  307. id: string;
  308. name: string;
  309. description?: string | null;
  310. ownerId: string;
  311. createdAt: string;
  312. updatedAt: string;
  313. /** Current user's role in this project: 'OWNER' | 'ADMIN' | 'EDITOR' | 'REVIEWER' | 'VIEWER' | null */
  314. myRole: string | null;
  315. members: Array<{ id: string; role: string; joinedAt: string; invitedBy?: string | null; user: User }>;
  316. _count?: { assets: number };
  317. }
  318. export interface Invitation {
  319. id: string;
  320. email: string;
  321. projectId: string;
  322. role: string;
  323. token: string;
  324. status: 'PENDING' | 'ACCEPTED' | 'EXPIRED' | 'REVOKED';
  325. invitedBy?: string | null;
  326. expiresAt: string;
  327. createdAt: string;
  328. }
  329. export interface AdminInvitation {
  330. id: string;
  331. email: string;
  332. projectId: string;
  333. role: string;
  334. token: string;
  335. status: 'PENDING' | 'ACCEPTED' | 'EXPIRED' | 'REVOKED';
  336. invitedBy?: string | null;
  337. expiresAt: string;
  338. createdAt: string;
  339. project: { id: string; name: string };
  340. type?: 'WORKSPACE' | 'PROJECT';
  341. }
  342. export interface InvitationInfo {
  343. id: string;
  344. email: string;
  345. role: string;
  346. projectName?: string | null;
  347. projectId?: string | null;
  348. /** 'WORKSPACE' = invite MEMBER to workspace (no project); 'PROJECT' = invite PROJECT_USER to a project */
  349. type?: 'WORKSPACE' | 'PROJECT';
  350. expiresAt: string;
  351. status: 'PENDING' | 'ACCEPTED' | 'EXPIRED' | 'REVOKED';
  352. isExpired: boolean;
  353. isOwnInvitation: boolean;
  354. alreadyMember: boolean;
  355. isLoggedIn: boolean;
  356. /** Whether the invite email already has an account — determines sign-in vs register button */
  357. inviteeExists: boolean;
  358. }
  359. export type TranscodeStatus =
  360. | 'PENDING'
  361. | 'UPLOADING'
  362. | 'PROCESSING'
  363. | 'COMPLETED'
  364. | 'FAILED'
  365. | 'UNSUPPORTED_CODEC';
  366. export interface Asset {
  367. id: string;
  368. projectId: string;
  369. title: string;
  370. filename: string;
  371. originalFilename?: string | null;
  372. filePath: string;
  373. thumbnail?: string | null;
  374. hlsPath?: string | null;
  375. duration?: number | null;
  376. fps?: number;
  377. codec?: string | null;
  378. /** Width of the video in pixels (px) */
  379. videoWidth?: number | null;
  380. /** Height of the video in pixels (px) */
  381. videoHeight?: number | null;
  382. /** File size in bytes */
  383. fileSize?: number | null;
  384. /** Video bitrate in bits/s (original file) */
  385. bitrate?: number | null;
  386. mimeType: string;
  387. status: string;
  388. transcodeStatus: TranscodeStatus;
  389. transcodeProgress: number;
  390. transcodeError?: string | null;
  391. transcodePaused?: boolean;
  392. createdAt: string;
  393. uploader?: Pick<User, 'id' | 'name' | 'email' | 'avatarUrl'>;
  394. _count?: { comments: number };
  395. /** True if this asset has at least one public share link */
  396. isShared?: boolean;
  397. }
  398. export interface AssetStatusInfo {
  399. id: string;
  400. title: string;
  401. hlsPath?: string | null;
  402. transcodeStatus: TranscodeStatus;
  403. transcodeProgress: number;
  404. transcodeError?: string | null;
  405. transcodePaused?: boolean;
  406. thumbnail?: string | null;
  407. duration?: number | null;
  408. codec?: string | null;
  409. status: string;
  410. }
  411. export interface AssetWithComments extends Asset {
  412. project: Project;
  413. comments: Comment[];
  414. }
  415. export interface Comment {
  416. id: string;
  417. assetId: string;
  418. userId: string;
  419. content: string;
  420. timestamp?: number | null;
  421. annotations?: AnnotationData[] | null;
  422. resolved: boolean;
  423. resolveStatus: 'UNRESOLVED' | 'PENDING_APPROVAL' | 'RESOLVED';
  424. resolvedById?: string | null;
  425. resolvedByAt?: string | null;
  426. requestedById?: string | null;
  427. requestedByAt?: string | null;
  428. parentId?: string | null;
  429. deleted?: boolean;
  430. deletedAt?: string | null;
  431. deletedById?: string | null;
  432. createdAt: string;
  433. user: User;
  434. replies?: Comment[];
  435. resolvedBy?: Pick<User, 'id' | 'name' | 'email' | 'avatarUrl'>;
  436. requestedBy?: Pick<User, 'id' | 'name' | 'email' | 'avatarUrl'>;
  437. deletedBy?: Pick<User, 'id' | 'name' | 'email' | 'avatarUrl'>;
  438. }
  439. export interface AnnotationData {
  440. type: 'pen' | 'arrow' | 'rect' | 'ellipse' | 'text';
  441. color: string;
  442. points?: [number, number][];
  443. text?: string;
  444. boundingBox?: { x: number; y: number; width: number; height: number };
  445. }
  446. // ── Share Links ────────────────────────────────────────────────────────────────
  447. export interface ShareLink {
  448. id: string;
  449. assetId: string;
  450. token: string;
  451. shareUrl?: string;
  452. hasPassword: boolean;
  453. allowDownload: boolean;
  454. maxViews: number; // -1 or 0 = unlimited
  455. viewCount: number;
  456. assetTitle?: string;
  457. createdAt?: string;
  458. }
  459. export interface ShareLinkVerify {
  460. id: string;
  461. token: string;
  462. hasPassword: boolean;
  463. allowDownload: boolean;
  464. maxViews: number;
  465. viewCount: number;
  466. asset: {
  467. id: string;
  468. title: string;
  469. thumbnail?: string | null;
  470. mimeType: string;
  471. duration?: number | null;
  472. fps?: number;
  473. videoReady: boolean;
  474. };
  475. }
  476. export const shareLinksApi = {
  477. // Get share link for an asset (by assetId — admin/owner only)
  478. getForAsset: (assetId: string) =>
  479. apiFetch<{ shareLink: ShareLink | null }>(`/api/share?assetId=${assetId}`),
  480. // Create a share link for an asset
  481. create: (assetId: string, data: {
  482. password?: string;
  483. allowDownload?: boolean;
  484. maxViews?: number;
  485. }) =>
  486. apiFetch<{ shareLink: ShareLink }>('/api/share', {
  487. method: 'POST',
  488. body: JSON.stringify({ assetId, ...data }),
  489. }),
  490. // Update share link settings
  491. update: (id: string, data: {
  492. password?: string;
  493. allowDownload?: boolean;
  494. maxViews?: number;
  495. }) =>
  496. apiFetch<{ shareLink: ShareLink }>(`/api/share/${id}`, {
  497. method: 'PUT',
  498. body: JSON.stringify(data),
  499. }),
  500. // Revoke/delete share link
  501. revoke: (id: string) =>
  502. apiFetch(`/api/share/${id}`, { method: 'DELETE' }),
  503. // Public: verify share link token
  504. verify: (token: string) =>
  505. apiFetch<ShareLinkVerify>(`/api/share/${token}`),
  506. // Public: submit password and get stream URL
  507. access: (token: string, password?: string) =>
  508. apiFetch<{ streamUrl: string; mimeType: string; allowDownload: boolean }>(`/api/share/${token}/access`, {
  509. method: 'POST',
  510. body: JSON.stringify({ password }),
  511. }),
  512. };
  513. // ── Folders ────────────────────────────────────────────────────────────────────
  514. export interface FolderNode {
  515. id: string;
  516. name: string;
  517. parentId: string | null;
  518. order: number;
  519. assetCount: number;
  520. assetIds: string[];
  521. children: FolderNode[];
  522. }
  523. export const foldersApi = {
  524. // List all folders for a project
  525. list: (token: string, projectId: string) =>
  526. apiFetch<{ folders: FolderNode[]; allFolders: FolderNode[] }>(`/api/folders/project/${projectId}`, { token }),
  527. // Create folder
  528. create: (token: string, data: { name: string; projectId: string; parentId?: string }) =>
  529. apiFetch<{ folder: FolderNode }>('/api/folders', { method: 'POST', body: JSON.stringify(data), token }),
  530. // Rename folder
  531. rename: (token: string, id: string, name: string) =>
  532. apiFetch<{ folder: FolderNode }>(`/api/folders/${id}`, { method: 'PUT', body: JSON.stringify({ name }), token }),
  533. // Delete folder
  534. delete: (token: string, id: string) =>
  535. apiFetch(`/api/folders/${id}`, { method: 'DELETE', token }),
  536. // Add assets to folder (append)
  537. addAssets: (token: string, id: string, assetIds: string[]) =>
  538. apiFetch<{ success: boolean; assetCount: number }>(`/api/folders/${id}/assets`, { method: 'POST', body: JSON.stringify({ assetIds }), token }),
  539. // Replace all assets in folder
  540. setAssets: (token: string, id: string, assetIds: string[]) =>
  541. apiFetch<{ success: boolean; assetCount: number }>(`/api/folders/${id}/assets`, { method: 'PUT', body: JSON.stringify({ assetIds }), token }),
  542. // Remove asset from folder
  543. removeAsset: (token: string, id: string, assetId: string) =>
  544. apiFetch(`/api/folders/${id}/assets/${assetId}`, { method: 'DELETE', token }),
  545. };