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:
Krao Hasanee
2026-05-30 22:44:16 -04:00
parent 13ef1f4ded
commit 0b4705311b
28 changed files with 1491 additions and 1552 deletions
+122 -14
View File
@@ -49,6 +49,8 @@ function getConfig() {
return {
url,
token: process.env.FILEBROWSER_TOKEN || '',
adminUser: process.env.FILEBROWSER_ADMIN_USER || '',
adminPass: process.env.FILEBROWSER_ADMIN_PASS || '',
teamRoot: normalizePath(process.env.FILEBROWSER_TEAM_ROOT || '/'),
clientRoot: normalizePath(process.env.FILEBROWSER_CLIENT_ROOT || '/fourgebranding/Clients'),
externalSubsRoot: normalizePath(process.env.FILEBROWSER_SUBS_ROOT || '/fourgebranding/Subcontractors'),
@@ -57,21 +59,121 @@ function getConfig() {
};
}
function getToken(config) {
if (!config.token) throw new Error('FILEBROWSER_TOKEN not configured');
return config.token;
let runtimeToken = '';
let runtimeTokenTs = 0;
const RUNTIME_TOKEN_TTL_MS = 30 * 60 * 1000;
function extractLoginToken(payload) {
if (typeof payload === 'string') {
const raw = payload.trim();
if (raw.split('.').length === 3) return raw;
try {
const parsed = JSON.parse(raw);
return extractLoginToken(parsed);
} catch {
return '';
}
}
if (!payload || typeof payload !== 'object') return '';
return String(
payload.token
|| payload.auth
|| payload.access_token
|| payload.jwt
|| payload?.data?.token
|| ''
).trim();
}
async function loginForToken(config) {
if (!config.url || !config.adminUser || !config.adminPass) return '';
const endpoints = ['/api/auth/login', '/api/login'];
let lastError = '';
for (const endpoint of endpoints) {
try {
const response = await fetch(`${config.url}${endpoint}`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ username: config.adminUser, password: config.adminPass }),
});
if (!response.ok) {
const text = await response.text().catch(() => '');
// Some FileBrowser deployments only expose /api/auth/login.
// Ignore /api/login 404 and keep trying/using prior success signal.
if (endpoint === '/api/login' && response.status === 404) continue;
lastError = `${endpoint} -> ${response.status} ${text?.slice(0, 180) || ''}`.trim();
continue;
}
const bodyText = await response.text();
const payload = (() => {
try {
return JSON.parse(bodyText);
} catch {
return bodyText;
}
})();
const token = extractLoginToken(payload);
if (token) return token;
lastError = `${endpoint} -> 200 but token missing`;
} catch {
lastError = `${endpoint} -> request failed`;
}
}
if (lastError) {
const err = new Error(`FileBrowser login failed: ${lastError}`);
err.status = 401;
throw err;
}
return '';
}
async function getToken(config, forceRefresh = false) {
const now = Date.now();
if (!forceRefresh && config.token && String(config.token).trim()) {
return String(config.token).trim();
}
if (!forceRefresh && runtimeToken && (now - runtimeTokenTs) < RUNTIME_TOKEN_TTL_MS) {
return runtimeToken;
}
if (!forceRefresh) {
const freshPreferred = await loginForToken(config);
if (freshPreferred) {
runtimeToken = freshPreferred;
runtimeTokenTs = now;
return runtimeToken;
}
}
const fresh = await loginForToken(config);
if (!fresh) throw new Error('FileBrowser login returned no token.');
runtimeToken = fresh;
runtimeTokenTs = now;
return runtimeToken;
}
async function fbFetch(config, method, endpoint, { params = {}, headers = {}, body } = {}) {
const token = getToken(config);
const qs = new URLSearchParams({ source: FB_SOURCE, ...params }).toString();
const url = `${config.url}${endpoint}?${qs}`;
const res = await fetch(url, {
method,
headers: { Authorization: `Bearer ${token}`, ...headers },
body,
});
async function callWith(token) {
return fetch(url, {
method,
headers: { Authorization: `Bearer ${token}`, ...headers },
body,
});
}
let token = await getToken(config);
let res = await callWith(token);
if (res.status === 401) {
// Always force-refresh and retry once on Unauthorized.
token = await getToken(config, true);
res = await callWith(token);
}
if (!res.ok) {
const text = await res.text();
@@ -329,7 +431,7 @@ export default async function handler(req, res) {
if (req.method === 'GET' && action === 'download') {
const resolved = toFbPath();
if (resolved.virtual) return json(res, 400, { error: 'Cannot download virtual directory' });
const token = getToken(config);
const token = await getToken(config);
const downloadUrl = `${config.url}/api/resources/download?source=${FB_SOURCE}&file=${encodeURIComponent(resolved.fbPath)}`;
return json(res, 200, { url: downloadUrl, token });
}
@@ -337,9 +439,14 @@ export default async function handler(req, res) {
if (req.method === 'GET' && action === 'download-blob') {
const resolved = toFbPath();
if (resolved.virtual) return json(res, 400, { error: 'Cannot download virtual directory' });
const token = getToken(config);
const token = await getToken(config);
const downloadUrl = `${config.url}/api/resources/download?source=${FB_SOURCE}&file=${encodeURIComponent(resolved.fbPath)}&auth=${encodeURIComponent(token)}`;
const upstream = await fetch(downloadUrl);
let upstream = await fetch(downloadUrl);
if (upstream.status === 401) {
const fresh = await getToken(config, true);
const retryUrl = `${config.url}/api/resources/download?source=${FB_SOURCE}&file=${encodeURIComponent(resolved.fbPath)}&auth=${encodeURIComponent(fresh)}`;
upstream = await fetch(retryUrl);
}
if (!upstream.ok) {
const text = await upstream.text();
return json(res, upstream.status, { error: text || `Download failed (${upstream.status})` });
@@ -356,7 +463,7 @@ export default async function handler(req, res) {
if (req.method === 'POST' && action === 'upload-token') {
const resolved = toFbPath();
if (resolved.virtual) return json(res, 400, { error: 'Cannot upload to virtual directory' });
const token = getToken(config);
const token = await getToken(config);
return json(res, 200, { token, url: config.url, fbPath: resolved.fbPath });
}
@@ -453,6 +560,7 @@ export default async function handler(req, res) {
if (req.method === 'POST' && action === 'move') {
const srcPath = req.body?.srcPath;
const dstPath = req.body?.dstPath;
const mode = req.body?.mode === 'copy' ? 'copy' : 'move';
if (!srcPath || !dstPath) return json(res, 400, { error: 'srcPath and dstPath required' });
const resolvedSrc = toFbPath(srcPath);
@@ -466,7 +574,7 @@ export default async function handler(req, res) {
params: {},
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
action: 'move',
action: mode,
items: [{ fromSource: FB_SOURCE, fromPath: resolvedSrc.fbPath, toSource: FB_SOURCE, toPath: newFbPath }],
overwrite: false,
}),