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:
+122
-14
@@ -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,
|
||||
}),
|
||||
|
||||
Reference in New Issue
Block a user