Session 2026-05-30: task detail page overhaul
- New TaskDetail replaces /tasks/:id (was v2 at /requests/:id/v2) - Overview tab: R00 request info, Requested By/Date/Sign Count inline row, Notes, Sign Family, files, amendments - Revisions tab: R01+ from submissions, newest first, same layout as overview, per-entry Amend button - Comments tab: single-line input, post on Enter, delete own comments, Supabase task_comments table - Folder tab: placeholder for file sharing - Tab badges showing revision/comment counts - Amend Request modal: drag-drop file zone, popupOverlayStyle/Surface, Save+Cancel buttons - Request Revision modal for clients on approved/invoiced/paid tasks - JSZip download-all with progress popup for submission files - Upload progress popup for Add Task and amend file uploads - FileAttachment drop zone: 8px radius, transparent bg, label style fix (no all-caps override) - PageLoader on task detail load - task_comments migration with RLS policies Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
+126
-35
@@ -108,12 +108,21 @@ function FileIcon({ name, isDir }) {
|
||||
);
|
||||
}
|
||||
|
||||
const FBQ_FN = `${import.meta.env.VITE_SUPABASE_URL}/functions/v1/fbq-proxy`;
|
||||
async function getAccessTokenOrThrow() {
|
||||
const { data: { session } } = await supabase.auth.getSession();
|
||||
if (session?.access_token) return session.access_token;
|
||||
|
||||
const { data: refreshed, error } = await supabase.auth.refreshSession();
|
||||
if (error) throw new Error('Session expired. Please sign in again.');
|
||||
if (refreshed?.session?.access_token) return refreshed.session.access_token;
|
||||
|
||||
throw new Error('Session expired. Please sign in again.');
|
||||
}
|
||||
|
||||
async function downloadViaLocalFilebrowser(path, session) {
|
||||
if (!session?.access_token) throw new Error('Missing session token for download.');
|
||||
const response = await fetch(`/api/filebrowser?action=download-blob&path=${encodeURIComponent(path)}&sb_access_token=${encodeURIComponent(session.access_token)}`, {
|
||||
headers: { Authorization: `Bearer ${session?.access_token ?? ''}` },
|
||||
const accessToken = session?.access_token || await getAccessTokenOrThrow();
|
||||
const response = await fetch(`/api/filebrowser?action=download-blob&path=${encodeURIComponent(path)}&sb_access_token=${encodeURIComponent(accessToken)}`, {
|
||||
headers: { Authorization: `Bearer ${accessToken}` },
|
||||
});
|
||||
if (!response.ok) {
|
||||
const text = await response.text();
|
||||
@@ -123,10 +132,9 @@ async function downloadViaLocalFilebrowser(path, session) {
|
||||
}
|
||||
|
||||
async function downloadViaApiToken(path, filename) {
|
||||
const { data: { session } } = await supabase.auth.getSession();
|
||||
if (!session?.access_token) throw new Error('Missing session token for download.');
|
||||
const metaResponse = await fetch(`/api/filebrowser?action=download&path=${encodeURIComponent(path)}&sb_access_token=${encodeURIComponent(session.access_token)}`, {
|
||||
headers: { Authorization: `Bearer ${session.access_token}` },
|
||||
const accessToken = await getAccessTokenOrThrow();
|
||||
const metaResponse = await fetch(`/api/filebrowser?action=download&path=${encodeURIComponent(path)}&sb_access_token=${encodeURIComponent(accessToken)}`, {
|
||||
headers: { Authorization: `Bearer ${accessToken}` },
|
||||
});
|
||||
if (!metaResponse.ok) {
|
||||
const text = await metaResponse.text();
|
||||
@@ -147,36 +155,119 @@ async function downloadViaApiToken(path, filename) {
|
||||
document.body.removeChild(link);
|
||||
}
|
||||
|
||||
async function downloadBlobViaLocalFilebrowser(path) {
|
||||
const accessToken = await getAccessTokenOrThrow();
|
||||
const response = await downloadViaLocalFilebrowser(path, { access_token: accessToken });
|
||||
return response.blob();
|
||||
}
|
||||
|
||||
async function fbqProxy(op, path, extra = {}) {
|
||||
const { data: { session } } = await supabase.auth.getSession();
|
||||
const headers = {
|
||||
Authorization: `Bearer ${session?.access_token ?? ''}`,
|
||||
'X-Operation': op,
|
||||
'X-Path': path,
|
||||
...extra.headers,
|
||||
};
|
||||
const response = await fetch(FBQ_FN, {
|
||||
method: 'POST',
|
||||
headers,
|
||||
body: extra.body ?? undefined,
|
||||
});
|
||||
const accessToken = await getAccessTokenOrThrow();
|
||||
const authHeaders = { Authorization: `Bearer ${accessToken}` };
|
||||
|
||||
if (!response.ok && op === 'download') {
|
||||
const text = await response.text();
|
||||
const shouldFallbackToLocalDownload =
|
||||
response.status === 404
|
||||
|| (response.status === 400 && text.toLowerCase().includes('no files specified'));
|
||||
if (shouldFallbackToLocalDownload) {
|
||||
return downloadViaLocalFilebrowser(path, session);
|
||||
if (op === 'list') {
|
||||
const response = await fetch(`/api/filebrowser?action=list&path=${encodeURIComponent(path)}&sb_access_token=${encodeURIComponent(accessToken)}`, {
|
||||
method: 'GET',
|
||||
headers: authHeaders,
|
||||
});
|
||||
if (!response.ok) {
|
||||
const text = await response.text();
|
||||
throw new Error(text || `Error ${response.status}`);
|
||||
}
|
||||
throw new Error(text || `Error ${response.status}`);
|
||||
const data = await response.json();
|
||||
const entries = Array.isArray(data?.entries) ? data.entries : [];
|
||||
const folders = entries
|
||||
.filter((entry) => entry.type === 'dir' || entry.type === 'directory' || entry.isDir)
|
||||
.map((entry) => ({ name: entry.name, filename: entry.name, path: entry.path, type: 'directory', isDir: true, size: 0, modified: entry.mtime || null }));
|
||||
const files = entries
|
||||
.filter((entry) => !(entry.type === 'dir' || entry.type === 'directory' || entry.isDir))
|
||||
.map((entry) => ({ name: entry.name, filename: entry.name, path: entry.path, type: 'file', isDir: false, size: entry.size || 0, modified: entry.mtime || null }));
|
||||
return new Response(JSON.stringify({ folders, files }), { status: 200, headers: { 'Content-Type': 'application/json' } });
|
||||
}
|
||||
|
||||
if (!response.ok) {
|
||||
const text = await response.text();
|
||||
throw new Error(text || `Error ${response.status}`);
|
||||
if (op === 'download') {
|
||||
return downloadViaLocalFilebrowser(path, { access_token: accessToken });
|
||||
}
|
||||
return response;
|
||||
|
||||
if (op === 'mkdir') {
|
||||
const normalized = String(path || '/');
|
||||
const parts = normalized.split('/').filter(Boolean);
|
||||
const name = parts.pop() || '';
|
||||
const parentPath = `/${parts.join('/')}`;
|
||||
const response = await fetch(`/api/filebrowser?action=mkdir&sb_access_token=${encodeURIComponent(accessToken)}`, {
|
||||
method: 'POST',
|
||||
headers: { ...authHeaders, 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ path: parentPath || '/', name }),
|
||||
});
|
||||
if (!response.ok) {
|
||||
const text = await response.text();
|
||||
throw new Error(text || `Error ${response.status}`);
|
||||
}
|
||||
return response;
|
||||
}
|
||||
|
||||
if (op === 'upload') {
|
||||
const normalized = String(path || '/');
|
||||
const parts = normalized.split('/').filter(Boolean);
|
||||
const fileName = parts.pop() || '';
|
||||
const parentPath = `/${parts.join('/')}` || '/';
|
||||
const tokenResponse = await fetch(`/api/filebrowser?action=upload-token&sb_access_token=${encodeURIComponent(accessToken)}`, {
|
||||
method: 'POST',
|
||||
headers: { ...authHeaders, 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ path: parentPath }),
|
||||
});
|
||||
if (!tokenResponse.ok) {
|
||||
const text = await tokenResponse.text();
|
||||
throw new Error(text || `Error ${tokenResponse.status}`);
|
||||
}
|
||||
const { token, url, fbPath } = await tokenResponse.json();
|
||||
if (!token || !url || !fbPath) throw new Error('Upload token unavailable.');
|
||||
const file = extra.body?.get?.('file');
|
||||
if (!file) throw new Error('No file provided.');
|
||||
const uploadResponse = await fetch(`${url}/api/resources?source=srv&path=${encodeURIComponent(`${fbPath}/${fileName}`)}&override=true&auth=${encodeURIComponent(token)}`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/octet-stream' },
|
||||
body: file,
|
||||
});
|
||||
if (!uploadResponse.ok) {
|
||||
const text = await uploadResponse.text();
|
||||
throw new Error(text || `Error ${uploadResponse.status}`);
|
||||
}
|
||||
return uploadResponse;
|
||||
}
|
||||
|
||||
if (op === 'delete') {
|
||||
const files = JSON.parse(extra.headers?.['X-Files'] || '[]');
|
||||
for (const filePath of files) {
|
||||
const response = await fetch(`/api/filebrowser?action=delete&path=${encodeURIComponent(filePath)}&sb_access_token=${encodeURIComponent(accessToken)}`, {
|
||||
method: 'DELETE',
|
||||
headers: authHeaders,
|
||||
});
|
||||
if (!response.ok) {
|
||||
const text = await response.text();
|
||||
throw new Error(text || `Error ${response.status}`);
|
||||
}
|
||||
}
|
||||
return new Response(JSON.stringify({ ok: true }), { status: 200, headers: { 'Content-Type': 'application/json' } });
|
||||
}
|
||||
|
||||
if (op === 'move' || op === 'copy') {
|
||||
const files = JSON.parse(extra.headers?.['X-Files'] || '[]');
|
||||
for (const srcPath of files) {
|
||||
const response = await fetch(`/api/filebrowser?action=move&sb_access_token=${encodeURIComponent(accessToken)}`, {
|
||||
method: 'POST',
|
||||
headers: { ...authHeaders, 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ srcPath, dstPath: path, mode: op }),
|
||||
});
|
||||
if (!response.ok) {
|
||||
const text = await response.text();
|
||||
throw new Error(text || `Error ${response.status}`);
|
||||
}
|
||||
}
|
||||
return new Response(JSON.stringify({ ok: true }), { status: 200, headers: { 'Content-Type': 'application/json' } });
|
||||
}
|
||||
|
||||
throw new Error(`Unsupported operation: ${op}`);
|
||||
}
|
||||
|
||||
function rootPath(user) {
|
||||
@@ -513,7 +604,7 @@ export default function FileSharing() {
|
||||
const fileName = file.name ?? file.filename ?? '';
|
||||
if (!fileName) continue;
|
||||
const filePath = file.path ?? (targetPath === '/' ? `/${fileName}` : `${targetPath}/${fileName}`);
|
||||
const blob = await (await fbqProxy('download', filePath)).blob();
|
||||
const blob = await downloadBlobViaLocalFilebrowser(filePath);
|
||||
zip.file(zipPath ? `${zipPath}/${fileName}` : fileName, blob);
|
||||
}
|
||||
}
|
||||
@@ -535,7 +626,7 @@ export default function FileSharing() {
|
||||
const isDir = entry.type === 'directory' || entry.isDir;
|
||||
|
||||
if (!isDir) {
|
||||
const blob = await (await fbqProxy('download', targetPath)).blob();
|
||||
const blob = await downloadBlobViaLocalFilebrowser(targetPath);
|
||||
setDownloadProgress(100);
|
||||
triggerDownload(URL.createObjectURL(blob), name);
|
||||
return;
|
||||
@@ -559,7 +650,7 @@ export default function FileSharing() {
|
||||
if (isDir) {
|
||||
await addPathToZip(zip, targetPath, name);
|
||||
} else {
|
||||
const blob = await (await fbqProxy('download', targetPath)).blob();
|
||||
const blob = await downloadBlobViaLocalFilebrowser(targetPath);
|
||||
zip.file(name, blob);
|
||||
}
|
||||
setDownloadProgress(Math.round(((i + 1) / selectedEntries.length) * 100));
|
||||
|
||||
Reference in New Issue
Block a user