Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
106 changes: 106 additions & 0 deletions PanTS-Demo/src/helpers/pendingUploads.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,106 @@
// Resumable-upload store backed by IndexedDB.
//
// A chunked upload can be interrupted by a tab close/reload. The backend keeps
// every received chunk on disk (`/tmp/uploads/<session_id>/chunk-N`) and a
// re-sent chunk just overwrites, so the *server* side is already resumable -
// the only thing lost on reload is the browser's `File` object (in-memory refs
// don't survive a page unload). IndexedDB *does* persist Blobs across reloads,
// so we stash the picked file plus a chunk cursor here and resume on reopen.
//
// Everything is best-effort: if IndexedDB is unavailable or over quota, callers
// fall back to a normal (non-resumable) upload rather than failing the run.

export type PendingUpload = {
sessionId: string;
file: Blob; // the picked File - File extends Blob, survives in IDB
filename: string;
model: string;
bdmapId: string;
totalChunks: number;
nextChunk: number; // first chunk not yet confirmed uploaded
};

const DB_NAME = "bodymaps-uploads";
const STORE = "pending";

function openDb(): Promise<IDBDatabase> {
return new Promise((resolve, reject) => {
const req = indexedDB.open(DB_NAME, 1);
req.onupgradeneeded = () => {
const db = req.result;
if (!db.objectStoreNames.contains(STORE)) {
db.createObjectStore(STORE, { keyPath: "sessionId" });
}
};
req.onsuccess = () => resolve(req.result);
req.onerror = () => reject(req.error);
});
}

function withStore<T>(
mode: IDBTransactionMode,
fn: (store: IDBObjectStore) => IDBRequest | void,
onResult?: (req: IDBRequest) => T,
): Promise<T | void> {
return openDb().then(db =>
new Promise<T | void>((resolve, reject) => {
const tx = db.transaction(STORE, mode);
const store = tx.objectStore(STORE);
const req = fn(store);
tx.oncomplete = () => { db.close(); resolve(req && onResult ? onResult(req) : undefined); };
tx.onerror = () => { db.close(); reject(tx.error); };
tx.onabort = () => { db.close(); reject(tx.error); };
})
);
}

export async function savePendingUpload(p: PendingUpload): Promise<boolean> {
try {
await withStore("readwrite", store => store.put(p));
return true;
} catch (e) {
console.warn("savePendingUpload failed - upload won't be resumable", e);
return false;
}
}

export async function setPendingNextChunk(sessionId: string, nextChunk: number): Promise<void> {
try {
const db = await openDb();
await new Promise<void>((resolve, reject) => {
const tx = db.transaction(STORE, "readwrite");
const store = tx.objectStore(STORE);
const getReq = store.get(sessionId);
getReq.onsuccess = () => {
const rec = getReq.result as PendingUpload | undefined;
if (rec) { rec.nextChunk = nextChunk; store.put(rec); }
};
tx.oncomplete = () => { db.close(); resolve(); };
tx.onerror = () => { db.close(); reject(tx.error); };
});
} catch (e) {
console.warn("setPendingNextChunk failed", e);
}
}

export async function deletePendingUpload(sessionId: string): Promise<void> {
try {
await withStore("readwrite", store => store.delete(sessionId));
} catch (e) {
console.warn("deletePendingUpload failed", e);
}
}

export async function loadPendingUploads(): Promise<PendingUpload[]> {
try {
const result = await withStore<PendingUpload[]>(
"readonly",
store => store.getAll(),
req => (req.result as PendingUpload[]) || [],
);
return (result as PendingUpload[]) || [];
} catch (e) {
console.warn("loadPendingUploads failed", e);
return [];
}
}
16 changes: 14 additions & 2 deletions PanTS-Demo/src/helpers/recentUploads.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
// Recent uploads persisted in the user's localStorage (mirrors JHU's recentIds
// pattern). Extracted from UploadPage so the logic can be unit-tested.

export type RecentUploadStatus = "Processing" | "Completed" | "Failed";
export type RecentUploadStatus = "Processing" | "Completed" | "Failed" | "Cancelled";

export type RecentUpload = {
sessionId: string;
Expand Down Expand Up @@ -39,6 +39,12 @@ export const addRecentUpload = (entry: RecentUpload): RecentUpload[] => {
return trimmed;
};

export const removeRecentUpload = (sessionId: string): RecentUpload[] => {
const list = loadRecentUploads().filter((u) => u.sessionId !== sessionId);
persistRecentUploads(list);
return list;
};

export const updateRecentUploadStatus = (
sessionId: string,
status: RecentUploadStatus
Expand All @@ -59,4 +65,10 @@ export const formatRelativeTime = (ts: number): string => {
};

export const recentStatusColor = (status: RecentUploadStatus): string =>
status === "Failed" ? "#ef4444" : status === "Processing" ? "#6a6a6a" : "#8f8f8f";
status === "Failed"
? "#ef4444"
: status === "Cancelled"
? "#d97706"
: status === "Processing"
? "#6a6a6a"
: "#8f8f8f";
129 changes: 129 additions & 0 deletions PanTS-Demo/src/routes/UploadPage.css
Original file line number Diff line number Diff line change
Expand Up @@ -450,6 +450,122 @@
color: #111111;
}

/* Custom model dropdown */
.model-dropdown {
position: relative;
width: 100%;
}
.model-dropdown-btn {
width: 100%;
padding: 10px 14px;
background: rgba(0,0,0,.04);
border: 1px solid rgba(0,0,0,.08);
border-radius: 8px;
color: #8f8f8f;
font-family: 'Space Grotesk', sans-serif;
font-size: 13px;
cursor: pointer;
outline: none;
transition: all 0.15s;
display: flex;
align-items: center;
justify-content: space-between;
text-align: left;
}
.model-dropdown-btn:hover {
border-color: rgba(0,0,0,.15);
background-color: rgba(0,0,0,.06);
}
.model-dropdown-btn.open {
border-color: rgba(0,0,0,.25);
box-shadow: 0 0 0 2px rgba(0,0,0,.06);
}
.model-dropdown-btn.has-value {
color: #111111;
}
.model-dropdown-chevron {
color: #6a6a6a;
flex-shrink: 0;
transition: transform 0.18s;
}
.model-dropdown-chevron.rotated {
transform: rotate(180deg);
}
.model-dropdown-menu {
position: absolute;
top: calc(100% + 4px);
left: 0;
right: 0;
background: #ffffff;
border: 1px solid rgba(0,0,0,.10);
border-radius: 10px;
box-shadow: 0 8px 24px rgba(0,0,0,.12);
z-index: 100;
overflow: hidden;
}
.model-dropdown-item {
display: flex;
align-items: center;
justify-content: space-between;
padding: 10px 14px;
cursor: pointer;
transition: background 0.1s;
}
.model-dropdown-item:hover {
background: rgba(0,0,0,.04);
}
.model-dropdown-item.selected {
background: rgba(0,45,114,.05);
}
.model-dropdown-item-content {
display: flex;
flex-direction: column;
gap: 1px;
}
.model-dropdown-item-name {
font-family: 'Space Grotesk', sans-serif;
font-size: 13px;
font-weight: 500;
color: #111111;
}
.model-dropdown-item.selected .model-dropdown-item-name {
color: #002D72;
}
.model-dropdown-item-desc {
font-family: 'Space Grotesk', sans-serif;
font-size: 11px;
color: #8f8f8f;
}
.model-dropdown-check {
color: #002D72;
flex-shrink: 0;
}
.model-dropdown-item-side {
display: flex;
align-items: center;
gap: 8px;
flex-shrink: 0;
margin-left: 10px;
}

/* ── Cancel button on Active cards ── */
.active-cancel-btn {
background: transparent;
border: 1px solid rgba(0, 0, 0, 0.1);
border-radius: 6px;
padding: 6px 12px;
color: #6a6a6a;
font-family: 'Space Grotesk', sans-serif;
font-size: 11px;
cursor: pointer;
transition: all 0.15s;
}
.active-cancel-btn:hover {
border-color: rgba(239, 68, 68, 0.4);
color: #ef4444;
background: rgba(239, 68, 68, 0.05);
}

/* Arrow connector */
.pipeline-arrow {
display: flex;
Expand Down Expand Up @@ -981,3 +1097,16 @@
min-width: 54px;
text-align: right;
}

/* ── Active-upload spinner ── */
@keyframes upload-spin {
to { transform: rotate(360deg); }
}
.upload-spinner {
width: 18px;
height: 18px;
border-radius: 50%;
border: 2px solid rgba(0, 45, 114, 0.12);
border-top-color: #002D72;
animation: upload-spin 0.75s linear infinite;
}
Loading
Loading