From 2f4398545b8826da3e577807c469b3457d15ab0a Mon Sep 17 00:00:00 2001 From: elainefan331 Date: Thu, 22 Jan 2026 13:41:48 -0500 Subject: [PATCH 01/65] modify cors setting of zodiac github action file --- .github/workflows/build-deploy-zodiac.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/build-deploy-zodiac.yml b/.github/workflows/build-deploy-zodiac.yml index 67402dc..57a049d 100644 --- a/.github/workflows/build-deploy-zodiac.yml +++ b/.github/workflows/build-deploy-zodiac.yml @@ -36,7 +36,7 @@ jobs: # PUBLIC_URL="/dev/${{ env.BRANCH_NAME }}/" yarn build export PUBLIC_URL="/dev/${{ env.BRANCH_NAME }}/" export REACT_APP_API_URL="/dev/${{ env.BRANCH_NAME }}/api/v1" - export REACT_APP_USE_CORS=true + export REACT_APP_USE_CORS=false yarn build - name: Copy JS libraries (jdata, bjdata etc.) From 5fa944d9acb98de981313582054b91e6bf14b3fc Mon Sep 17 00:00:00 2001 From: elainefan331 Date: Fri, 23 Jan 2026 12:12:49 -0500 Subject: [PATCH 02/65] feat: add user activities interface and service --- .../src/controllers/activity.controller.js | 44 ++++ backend/src/routes/activities.routes.js | 4 + src/redux/activities/activities.action.ts | 0 .../activities/types/activities.interface.ts | 144 ++++++++++++ src/services/activities.service.ts | 221 ++++++++++++++++++ 5 files changed, 413 insertions(+) create mode 100644 src/redux/activities/activities.action.ts create mode 100644 src/redux/activities/types/activities.interface.ts create mode 100644 src/services/activities.service.ts diff --git a/backend/src/controllers/activity.controller.js b/backend/src/controllers/activity.controller.js index 1463a2a..2b76085 100644 --- a/backend/src/controllers/activity.controller.js +++ b/backend/src/controllers/activity.controller.js @@ -383,6 +383,49 @@ const getMostViewedDatasets = async (req, res) => { } }; +// get dataset statistics (views count and likes count) +const getDatasetStats = async (req, res) => { + try { + const { dbName, datasetId } = req.params; + + const dataset = await Dataset.findOne({ + where: { couch_db: dbName, ds_id: datasetId }, + attributes: ["id", "couch_db", "ds_id", "views_count"], + }); + + if (!dataset) { + return res.status(200).json({ + viewsCount: 0, + likesCount: 0, + dataset: null, + }); + } + + // Count how many users liked this dataset + const likesCount = await DatasetLike.count({ + where: { dataset_id: dataset.id }, + }); + + res.status(200).json({ + viewsCount: dataset.views_count, + likesCount: likesCount, + dataset: { + id: dataset.id, + couch_db: dataset.couch_db, + ds_id: dataset.ds_id, + views_count: dataset.views_count, + likes_count: likesCount, + }, + }); + } catch (error) { + console.error("Get dataset stats error:", error); + res.status(500).json({ + message: "Error fetching dataset statistics", + error: error.message, + }); + } +}; + module.exports = { likeDataset, unlikeDataset, @@ -394,4 +437,5 @@ module.exports = { deleteComment, trackView, getMostViewedDatasets, + getDatasetStats, }; diff --git a/backend/src/routes/activities.routes.js b/backend/src/routes/activities.routes.js index 02ca128..6fe859b 100644 --- a/backend/src/routes/activities.routes.js +++ b/backend/src/routes/activities.routes.js @@ -11,6 +11,7 @@ const { deleteComment, trackView, getMostViewedDatasets, + getDatasetStats, } = require("../controllers/activity.controller"); const { restoreUser, requireAuth } = require("../middleware/auth.middleware"); @@ -37,4 +38,7 @@ router.delete("/comments/:commentId", requireAuth, deleteComment); router.post("/datasets/:dbName/:datasetId/views", trackView); // Public router.get("/datasets/most-viewed", getMostViewedDatasets); // Public +// Dataset statistics (views count, likes count) +router.get("/datasets/:dbName/:datasetId/stats", getDatasetStats); // Public + module.exports = router; diff --git a/src/redux/activities/activities.action.ts b/src/redux/activities/activities.action.ts new file mode 100644 index 0000000..e69de29 diff --git a/src/redux/activities/types/activities.interface.ts b/src/redux/activities/types/activities.interface.ts new file mode 100644 index 0000000..c004d22 --- /dev/null +++ b/src/redux/activities/types/activities.interface.ts @@ -0,0 +1,144 @@ +// Comment from the database +export interface Comment { + id: number; + user_id: number; + dataset_id: number; + body: string; + created_at: string; + updated_at: string; + // When comments are fetched with user info included + User?: { + id: number; + username: string; + }; +} + +// Dataset from the database +export interface Dataset { + id: number; + couch_db: string; + ds_id: string; + views_count: number; +} + +// Like relationship +export interface DatasetLike { + id: number; + user_id: number; + dataset_id: number; + created_at: string; +} + +// Saved dataset relationship +export interface SavedDataset { + id: number; + user_id: number; + dataset_id: number; + created_at: string; +} + +// View history +export interface ViewHistory { + id: number; + user_id: number; + dataset_id: number; + viewed_at: string; +} + +// Activity status for a specific dataset (frontend state) +export interface DatasetActivityStatus { + isLiked: boolean; + isSaved: boolean; + comments: Comment[]; + viewsCount: number; + likesCount: number; + isLoadingLike: boolean; + isLoadingSave: boolean; + isLoadingComments: boolean; + isLoadingStats: boolean; +} + +// Redux state +export interface ActivitiesState { + // Key format: "dbName:datasetId" + datasetActivities: Record; + error: string | null; + loading: boolean; +} + +// Action payloads +export interface LikeDatasetPayload { + dbName: string; + datasetId: string; +} + +export interface SaveDatasetPayload { + dbName: string; + datasetId: string; +} + +export interface AddCommentPayload { + dbName: string; + datasetId: string; + body: string; +} + +export interface UpdateCommentPayload { + commentId: number; + body: string; +} + +export interface DeleteCommentPayload { + commentId: number; +} + +export interface GetCommentsPayload { + dbName: string; + datasetId: string; +} + +export interface GetDatasetStatsPayload { + dbName: string; + datasetId: string; +} + +// API Response interfaces +export interface GetCommentsResponse { + comments: Comment[]; +} + +export interface AddCommentResponse { + message: string; + comment: Comment; +} + +export interface UpdateCommentResponse { + message: string; + comment: Comment; +} + +export interface DeleteCommentResponse { + message: string; +} + +export interface LikeResponse { + message: string; +} + +export interface UnlikeResponse { + message: string; +} + +export interface SaveResponse { + message: string; +} + +export interface UnsaveResponse { + message: string; +} + +export interface GetDatasetStatsResponse { + viewsCount: number; + likesCount: number; + dataset: Dataset | null; +} diff --git a/src/services/activities.service.ts b/src/services/activities.service.ts new file mode 100644 index 0000000..f7cafe9 --- /dev/null +++ b/src/services/activities.service.ts @@ -0,0 +1,221 @@ +import { + Comment, + Dataset, + GetCommentsResponse, + AddCommentResponse, + UpdateCommentResponse, + DeleteCommentResponse, + LikeResponse, + UnlikeResponse, + SaveResponse, + UnsaveResponse, + GetDatasetStatsResponse, +} from "../redux/activities/types/activities.interface"; + +const API_URL = process.env.REACT_APP_API_URL || "http://localhost:5000/api/v1"; + +export const ActivitiesService = { + // Like a dataset + likeDataset: async ( + dbName: string, + datasetId: string + ): Promise => { + const response = await fetch( + `${API_URL}/activities/datasets/${dbName}/${datasetId}/like`, + { + method: "POST", + credentials: "include", + } + ); + + const data = await response.json(); + + if (!response.ok) { + throw new Error(data.message || "Failed to like dataset"); + } + + return data; + }, + + // Unlike a dataset + unlikeDataset: async ( + dbName: string, + datasetId: string + ): Promise => { + const response = await fetch( + `${API_URL}/activities/datasets/${dbName}/${datasetId}/like`, + { + method: "DELETE", + credentials: "include", + } + ); + + const data = await response.json(); + + if (!response.ok) { + throw new Error(data.message || "Failed to unlike dataset"); + } + + return data; + }, + + // Save a dataset + saveDataset: async ( + dbName: string, + datasetId: string + ): Promise => { + const response = await fetch( + `${API_URL}/activities/datasets/${dbName}/${datasetId}/save`, + { + method: "POST", + credentials: "include", + } + ); + + const data = await response.json(); + + if (!response.ok) { + throw new Error(data.message || "Failed to save dataset"); + } + + return data; + }, + + // Unsave a dataset + unsaveDataset: async ( + dbName: string, + datasetId: string + ): Promise => { + const response = await fetch( + `${API_URL}/activities/datasets/${dbName}/${datasetId}/save`, + { + method: "DELETE", + credentials: "include", + } + ); + + const data = await response.json(); + + if (!response.ok) { + throw new Error(data.message || "Failed to unsave dataset"); + } + + return data; + }, + + // Get comments for a dataset + getComments: async ( + dbName: string, + datasetId: string + ): Promise => { + const response = await fetch( + `${API_URL}/activities/datasets/${dbName}/${datasetId}/comments`, + { + method: "GET", + credentials: "include", + } + ); + + const data = await response.json(); + + if (!response.ok) { + throw new Error(data.message || "Failed to fetch comments"); + } + + return data; + }, + + // Add a comment + addComment: async ( + dbName: string, + datasetId: string, + body: string + ): Promise => { + const response = await fetch( + `${API_URL}/activities/datasets/${dbName}/${datasetId}/comments`, + { + method: "POST", + headers: { + "Content-Type": "application/json", + }, + credentials: "include", + body: JSON.stringify({ body }), + } + ); + + const data = await response.json(); + + if (!response.ok) { + throw new Error(data.message || "Failed to add comment"); + } + + return data; + }, + + // Update a comment + updateComment: async ( + commentId: number, + body: string + ): Promise => { + const response = await fetch( + `${API_URL}/activities/comments/${commentId}`, + { + method: "PUT", + headers: { + "Content-Type": "application/json", + }, + credentials: "include", + body: JSON.stringify({ body }), + } + ); + + const data = await response.json(); + + if (!response.ok) { + throw new Error(data.message || "Failed to update comment"); + } + + return data; + }, + + // Delete a comment + deleteComment: async (commentId: number): Promise => { + const response = await fetch( + `${API_URL}/activities/comments/${commentId}`, + { + method: "DELETE", + credentials: "include", + } + ); + + const data = await response.json(); + + if (!response.ok) { + throw new Error(data.message || "Failed to delete comment"); + } + + return data; + }, + + // Get dataset statistics (views count and likes count) + getDatasetStats: async ( + dbName: string, + datasetId: string + ): Promise => { + const response = await fetch( + `${API_URL}/activities/datasets/${dbName}/${datasetId}/stats`, + { + method: "GET", + credentials: "include", + } + ); + + const data = await response.json(); + + if (!response.ok) { + throw new Error(data.message || "Failed to fetch dataset statistics"); + } + + return data; + }, +}; From 76ac36b753b084bdb28571b7c80c67e2a20f76f1 Mon Sep 17 00:00:00 2001 From: elainefan331 Date: Mon, 26 Jan 2026 14:53:42 -0500 Subject: [PATCH 03/65] feat: add user activities action, service, selector and slice in redux --- src/redux/activities/activities.action.ts | 177 ++++++++++++ src/redux/activities/activities.selector.ts | 126 ++++++++ src/redux/activities/activities.slice.ts | 269 ++++++++++++++++++ .../activities/types/activities.interface.ts | 17 ++ src/redux/store.ts | 2 + src/services/activities.service.ts | 22 ++ 6 files changed, 613 insertions(+) create mode 100644 src/redux/activities/activities.selector.ts create mode 100644 src/redux/activities/activities.slice.ts diff --git a/src/redux/activities/activities.action.ts b/src/redux/activities/activities.action.ts index e69de29..b352635 100644 --- a/src/redux/activities/activities.action.ts +++ b/src/redux/activities/activities.action.ts @@ -0,0 +1,177 @@ +import { + LikeDatasetPayload, + SaveDatasetPayload, + AddCommentPayload, + UpdateCommentPayload, + DeleteCommentPayload, + GetCommentsPayload, + GetDatasetStatsPayload, + GetMostViewedDatasetsPayload, +} from "./types/activities.interface"; +import { createAsyncThunk } from "@reduxjs/toolkit"; +import { ActivitiesService } from "services/activities.service"; + +// Like a dataset +export const likeDataset = createAsyncThunk( + "activities/likeDataset", + async (payload: LikeDatasetPayload, { rejectWithValue }) => { + try { + await ActivitiesService.likeDataset(payload.dbName, payload.datasetId); + return payload; + } catch (error: any) { + return rejectWithValue(error.message || "Failed to like dataset"); + } + } +); + +// Unlike a dataset +export const unlikeDataset = createAsyncThunk( + "activities/unlikeDataset", + async (payload: LikeDatasetPayload, { rejectWithValue }) => { + try { + await ActivitiesService.unlikeDataset(payload.dbName, payload.datasetId); + return payload; + } catch (error: any) { + return rejectWithValue(error.message || "Failed to unlike dataset"); + } + } +); + +// Save a dataset +export const saveDataset = createAsyncThunk( + "activities/saveDataset", + async (payload: SaveDatasetPayload, { rejectWithValue }) => { + try { + await ActivitiesService.saveDataset(payload.dbName, payload.datasetId); + return payload; + } catch (error: any) { + return rejectWithValue(error.message || "Failed to save dataset"); + } + } +); + +// Unsave a dataset +export const unsaveDataset = createAsyncThunk( + "activities/unsaveDataset", + async (payload: SaveDatasetPayload, { rejectWithValue }) => { + try { + await ActivitiesService.unsaveDataset(payload.dbName, payload.datasetId); + return payload; + } catch (error: any) { + return rejectWithValue(error.message || "Failed to unsave dataset"); + } + } +); + +// Get comments +export const getComments = createAsyncThunk( + "activities/getComments", + async (payload: GetCommentsPayload, { rejectWithValue }) => { + try { + const response = await ActivitiesService.getComments( + payload.dbName, + payload.datasetId + ); + return { + ...payload, + comments: response.comments, + }; + } catch (error: any) { + return rejectWithValue(error.message || "Failed to fetch comments"); + } + } +); + +// Add a comment +export const addComment = createAsyncThunk( + "activities/addComment", + async (payload: AddCommentPayload, { rejectWithValue }) => { + try { + const response = await ActivitiesService.addComment( + payload.dbName, + payload.datasetId, + payload.body + ); + return { + dbName: payload.dbName, + datasetId: payload.datasetId, + comment: response.comment, + }; + } catch (error: any) { + return rejectWithValue(error.message || "Failed to add comment"); + } + } +); + +// Update a comment +export const updateComment = createAsyncThunk( + "activities/updateComment", + async (payload: UpdateCommentPayload, { rejectWithValue }) => { + try { + const response = await ActivitiesService.updateComment( + payload.commentId, + payload.body + ); + return { + commentId: payload.commentId, + comment: response.comment, + }; + } catch (error: any) { + return rejectWithValue(error.message || "Failed to update comment"); + } + } +); + +// Delete a comment +export const deleteComment = createAsyncThunk( + "activities/deleteComment", + async (payload: DeleteCommentPayload, { rejectWithValue }) => { + try { + await ActivitiesService.deleteComment(payload.commentId); + return payload; + } catch (error: any) { + return rejectWithValue(error.message || "Failed to delete comment"); + } + } +); + +// Get dataset statistics (views and likes count) +export const getDatasetStats = createAsyncThunk( + "activities/getDatasetStats", + async (payload: GetDatasetStatsPayload, { rejectWithValue }) => { + try { + const response = await ActivitiesService.getDatasetStats( + payload.dbName, + payload.datasetId + ); + return { + dbName: payload.dbName, + datasetId: payload.datasetId, + viewsCount: response.viewsCount, + likesCount: response.likesCount, + }; + } catch (error: any) { + return rejectWithValue(error.message || "Failed to fetch dataset stats"); + } + } +); + +// Get most viewed datasets +export const getMostViewedDatasets = createAsyncThunk( + "activities/getMostViewedDatasets", + async (payload: GetMostViewedDatasetsPayload = {}, { rejectWithValue }) => { + try { + const response = await ActivitiesService.getMostViewedDatasets( + payload.limit || 10 + ); + return { + mostViewed: response.mostViewed, + datasetsCount: response.datasetsCount, + }; + } catch (error: any) { + return rejectWithValue( + error.message || "Failed to fetch most viewed datasets" + ); + } + } +); diff --git a/src/redux/activities/activities.selector.ts b/src/redux/activities/activities.selector.ts new file mode 100644 index 0000000..89ab392 --- /dev/null +++ b/src/redux/activities/activities.selector.ts @@ -0,0 +1,126 @@ +import { RootState } from "../store"; +import { DatasetActivityStatus } from "./types/activities.interface"; + +// Main selector +export const ActivitiesSelector = (state: RootState) => state.activities; + +// Helper to get dataset key +const getDatasetKey = (dbName: string, datasetId: string): string => { + return `${dbName}:${datasetId}`; +}; + +// Get activity status for a specific dataset +export const selectDatasetActivityStatus = ( + state: RootState, + dbName: string, + datasetId: string +): DatasetActivityStatus | undefined => { + const key = getDatasetKey(dbName, datasetId); + return state.activities.datasetActivities[key]; +}; + +// Check if dataset is liked +export const selectIsDatasetLiked = ( + state: RootState, + dbName: string, + datasetId: string +): boolean => { + const status = selectDatasetActivityStatus(state, dbName, datasetId); + return status?.isLiked || false; +}; + +// Check if dataset is saved +export const selectIsDatasetSaved = ( + state: RootState, + dbName: string, + datasetId: string +): boolean => { + const status = selectDatasetActivityStatus(state, dbName, datasetId); + return status?.isSaved || false; +}; + +// Get comments for a dataset +export const selectDatasetComments = ( + state: RootState, + dbName: string, + datasetId: string +) => { + const status = selectDatasetActivityStatus(state, dbName, datasetId); + return status?.comments || []; +}; + +// Get views count for a dataset +export const selectDatasetViewsCount = ( + state: RootState, + dbName: string, + datasetId: string +): number => { + const status = selectDatasetActivityStatus(state, dbName, datasetId); + return status?.viewsCount || 0; +}; + +// Get likes count for a dataset +export const selectDatasetLikesCount = ( + state: RootState, + dbName: string, + datasetId: string +): number => { + const status = selectDatasetActivityStatus(state, dbName, datasetId); + return status?.likesCount || 0; +}; + +// Get loading states +export const selectIsLikeLoading = ( + state: RootState, + dbName: string, + datasetId: string +): boolean => { + const status = selectDatasetActivityStatus(state, dbName, datasetId); + return status?.isLoadingLike || false; +}; + +export const selectIsSaveLoading = ( + state: RootState, + dbName: string, + datasetId: string +): boolean => { + const status = selectDatasetActivityStatus(state, dbName, datasetId); + return status?.isLoadingSave || false; +}; + +export const selectAreCommentsLoading = ( + state: RootState, + dbName: string, + datasetId: string +): boolean => { + const status = selectDatasetActivityStatus(state, dbName, datasetId); + return status?.isLoadingComments || false; +}; + +export const selectAreStatsLoading = ( + state: RootState, + dbName: string, + datasetId: string +): boolean => { + const status = selectDatasetActivityStatus(state, dbName, datasetId); + return status?.isLoadingStats || false; +}; + +// Get most viewed datasets +export const selectMostViewedDatasets = (state: RootState) => { + return state.activities.mostViewedDatasets; +}; + +// Get error +export const selectActivitiesError = (state: RootState): string | null => { + return state.activities.error; +}; + +// Get global loading state +export const selectActivitiesLoading = (state: RootState): boolean => { + return state.activities.loading; +}; + +// import { RootState } from "../store"; + +// export const ActivitiesSelector = (state: RootState) => state.activities; diff --git a/src/redux/activities/activities.slice.ts b/src/redux/activities/activities.slice.ts new file mode 100644 index 0000000..fb5c23f --- /dev/null +++ b/src/redux/activities/activities.slice.ts @@ -0,0 +1,269 @@ +import { + likeDataset, + unlikeDataset, + saveDataset, + unsaveDataset, + getComments, + addComment, + updateComment, + deleteComment, + getDatasetStats, + getMostViewedDatasets, +} from "./activities.action"; +import { ActivitiesState } from "./types/activities.interface"; +import { createSlice, PayloadAction } from "@reduxjs/toolkit"; + +// Helper function to get dataset key +const getDatasetKey = (dbName: string, datasetId: string): string => { + return `${dbName}:${datasetId}`; +}; + +// Helper function to get or initialize dataset activity status +const getOrInitStatus = (state: ActivitiesState, key: string) => { + if (!state.datasetActivities[key]) { + state.datasetActivities[key] = { + isLiked: false, + isSaved: false, + comments: [], + viewsCount: 0, + likesCount: 0, + isLoadingLike: false, + isLoadingSave: false, + isLoadingComments: false, + isLoadingStats: false, + }; + } + return state.datasetActivities[key]; +}; + +const initialState: ActivitiesState = { + datasetActivities: {}, + mostViewedDatasets: [], + error: null, + loading: false, +}; + +const activitiesSlice = createSlice({ + name: "activities", + initialState, + reducers: { + clearError: (state) => { + state.error = null; + }, + // Initialize dataset activity status (useful when navigating to a dataset page) + initializeDatasetStatus: ( + state, + action: PayloadAction<{ dbName: string; datasetId: string }> + ) => { + const { dbName, datasetId } = action.payload; + const key = getDatasetKey(dbName, datasetId); + getOrInitStatus(state, key); + }, + }, + extraReducers: (builder) => { + builder + // Like Dataset + .addCase(likeDataset.pending, (state, action) => { + const { dbName, datasetId } = action.meta.arg; + const key = getDatasetKey(dbName, datasetId); + const status = getOrInitStatus(state, key); + status.isLoadingLike = true; + state.error = null; + }) + .addCase(likeDataset.fulfilled, (state, action) => { + const { dbName, datasetId } = action.payload; + const key = getDatasetKey(dbName, datasetId); + const status = getOrInitStatus(state, key); + status.isLiked = true; + status.isLoadingLike = false; + }) + .addCase(likeDataset.rejected, (state, action) => { + const { dbName, datasetId } = action.meta.arg; + const key = getDatasetKey(dbName, datasetId); + const status = getOrInitStatus(state, key); + status.isLoadingLike = false; + state.error = action.payload as string; + }) + // Unlike Dataset + .addCase(unlikeDataset.pending, (state, action) => { + const { dbName, datasetId } = action.meta.arg; + const key = getDatasetKey(dbName, datasetId); + const status = getOrInitStatus(state, key); + status.isLoadingLike = true; + state.error = null; + }) + .addCase(unlikeDataset.fulfilled, (state, action) => { + const { dbName, datasetId } = action.payload; + const key = getDatasetKey(dbName, datasetId); + const status = getOrInitStatus(state, key); + status.isLiked = false; + status.isLoadingLike = false; + }) + .addCase(unlikeDataset.rejected, (state, action) => { + const { dbName, datasetId } = action.meta.arg; + const key = getDatasetKey(dbName, datasetId); + const status = getOrInitStatus(state, key); + status.isLoadingLike = false; + state.error = action.payload as string; + }) + // Save Dataset + .addCase(saveDataset.pending, (state, action) => { + const { dbName, datasetId } = action.meta.arg; + const key = getDatasetKey(dbName, datasetId); + const status = getOrInitStatus(state, key); + status.isLoadingSave = true; + state.error = null; + }) + .addCase(saveDataset.fulfilled, (state, action) => { + const { dbName, datasetId } = action.payload; + const key = getDatasetKey(dbName, datasetId); + const status = getOrInitStatus(state, key); + status.isSaved = true; + status.isLoadingSave = false; + }) + .addCase(saveDataset.rejected, (state, action) => { + const { dbName, datasetId } = action.meta.arg; + const key = getDatasetKey(dbName, datasetId); + const status = getOrInitStatus(state, key); + status.isLoadingSave = false; + state.error = action.payload as string; + }) + // Unsave Dataset + .addCase(unsaveDataset.pending, (state, action) => { + const { dbName, datasetId } = action.meta.arg; + const key = getDatasetKey(dbName, datasetId); + const status = getOrInitStatus(state, key); + status.isLoadingSave = true; + state.error = null; + }) + .addCase(unsaveDataset.fulfilled, (state, action) => { + const { dbName, datasetId } = action.payload; + const key = getDatasetKey(dbName, datasetId); + const status = getOrInitStatus(state, key); + status.isSaved = false; + status.isLoadingSave = false; + }) + .addCase(unsaveDataset.rejected, (state, action) => { + const { dbName, datasetId } = action.meta.arg; + const key = getDatasetKey(dbName, datasetId); + const status = getOrInitStatus(state, key); + status.isLoadingSave = false; + state.error = action.payload as string; + }) + // Get Comments + .addCase(getComments.pending, (state, action) => { + const { dbName, datasetId } = action.meta.arg; + const key = getDatasetKey(dbName, datasetId); + const status = getOrInitStatus(state, key); + status.isLoadingComments = true; + state.error = null; + }) + .addCase(getComments.fulfilled, (state, action) => { + const { dbName, datasetId, comments } = action.payload; + const key = getDatasetKey(dbName, datasetId); + const status = getOrInitStatus(state, key); + status.comments = comments; + status.isLoadingComments = false; + }) + .addCase(getComments.rejected, (state, action) => { + const { dbName, datasetId } = action.meta.arg; + const key = getDatasetKey(dbName, datasetId); + const status = getOrInitStatus(state, key); + status.isLoadingComments = false; + state.error = action.payload as string; + }) + // Add Comment + .addCase(addComment.pending, (state) => { + state.loading = true; + state.error = null; + }) + .addCase(addComment.fulfilled, (state, action) => { + const { dbName, datasetId, comment } = action.payload; + const key = getDatasetKey(dbName, datasetId); + const status = getOrInitStatus(state, key); + status.comments = [comment, ...status.comments]; + state.loading = false; + }) + .addCase(addComment.rejected, (state, action) => { + state.loading = false; + state.error = action.payload as string; + }) + // Update Comment + .addCase(updateComment.pending, (state) => { + state.loading = true; + state.error = null; + }) + .addCase(updateComment.fulfilled, (state, action) => { + const { comment } = action.payload; + // Find and update the comment in all datasets + Object.values(state.datasetActivities).forEach((status) => { + const index = status.comments.findIndex((c) => c.id === comment.id); + if (index !== -1) { + status.comments[index] = comment; + } + }); + state.loading = false; + }) + .addCase(updateComment.rejected, (state, action) => { + state.loading = false; + state.error = action.payload as string; + }) + // Delete Comment + .addCase(deleteComment.pending, (state) => { + state.loading = true; + state.error = null; + }) + .addCase(deleteComment.fulfilled, (state, action) => { + const { commentId } = action.payload; + // Remove the comment from all datasets + Object.values(state.datasetActivities).forEach((status) => { + status.comments = status.comments.filter((c) => c.id !== commentId); + }); + state.loading = false; + }) + .addCase(deleteComment.rejected, (state, action) => { + state.loading = false; + state.error = action.payload as string; + }) + // Get Dataset Stats + .addCase(getDatasetStats.pending, (state, action) => { + const { dbName, datasetId } = action.meta.arg; + const key = getDatasetKey(dbName, datasetId); + const status = getOrInitStatus(state, key); + status.isLoadingStats = true; + state.error = null; + }) + .addCase(getDatasetStats.fulfilled, (state, action) => { + const { dbName, datasetId, viewsCount, likesCount } = action.payload; + const key = getDatasetKey(dbName, datasetId); + const status = getOrInitStatus(state, key); + status.viewsCount = viewsCount; + status.likesCount = likesCount; + status.isLoadingStats = false; + }) + .addCase(getDatasetStats.rejected, (state, action) => { + const { dbName, datasetId } = action.meta.arg; + const key = getDatasetKey(dbName, datasetId); + const status = getOrInitStatus(state, key); + status.isLoadingStats = false; + state.error = action.payload as string; + }) + // Get Most Viewed Datasets + .addCase(getMostViewedDatasets.pending, (state) => { + state.loading = true; + state.error = null; + }) + .addCase(getMostViewedDatasets.fulfilled, (state, action) => { + state.mostViewedDatasets = action.payload.mostViewed; + state.loading = false; + }) + .addCase(getMostViewedDatasets.rejected, (state, action) => { + state.loading = false; + state.error = action.payload as string; + }); + }, +}); + +export const { clearError, initializeDatasetStatus } = activitiesSlice.actions; + +export default activitiesSlice.reducer; diff --git a/src/redux/activities/types/activities.interface.ts b/src/redux/activities/types/activities.interface.ts index c004d22..27704a0 100644 --- a/src/redux/activities/types/activities.interface.ts +++ b/src/redux/activities/types/activities.interface.ts @@ -62,6 +62,7 @@ export interface DatasetActivityStatus { export interface ActivitiesState { // Key format: "dbName:datasetId" datasetActivities: Record; + mostViewedDatasets: MostViewedDataset[]; error: string | null; loading: boolean; } @@ -102,6 +103,10 @@ export interface GetDatasetStatsPayload { datasetId: string; } +export interface GetMostViewedDatasetsPayload { + limit?: number; +} + // API Response interfaces export interface GetCommentsResponse { comments: Comment[]; @@ -142,3 +147,15 @@ export interface GetDatasetStatsResponse { likesCount: number; dataset: Dataset | null; } + +export interface MostViewedDataset { + id: number; + couch_db: string; + ds_id: string; + views_count: number; +} + +export interface GetMostViewedDatasetsResponse { + mostViewed: MostViewedDataset[]; + datasetsCount: number; +} diff --git a/src/redux/store.ts b/src/redux/store.ts index fa6f436..e1e41a1 100644 --- a/src/redux/store.ts +++ b/src/redux/store.ts @@ -1,3 +1,4 @@ +import activitiesReducer from "./activities/activities.slice"; import authReducer from "./auth/auth.slice"; import neurojsonReducer from "./neurojson/neurojson.slice"; import { @@ -10,6 +11,7 @@ import { const appReducer = combineReducers({ neurojson: neurojsonReducer, // Add other slices here as needed auth: authReducer, + activities: activitiesReducer, }); export const rootReducer = ( diff --git a/src/services/activities.service.ts b/src/services/activities.service.ts index f7cafe9..ad87322 100644 --- a/src/services/activities.service.ts +++ b/src/services/activities.service.ts @@ -10,6 +10,7 @@ import { SaveResponse, UnsaveResponse, GetDatasetStatsResponse, + GetMostViewedDatasetsResponse, } from "../redux/activities/types/activities.interface"; const API_URL = process.env.REACT_APP_API_URL || "http://localhost:5000/api/v1"; @@ -218,4 +219,25 @@ export const ActivitiesService = { return data; }, + + // Get most viewed datasets + getMostViewedDatasets: async ( + limit: number = 10 + ): Promise => { + const response = await fetch( + `${API_URL}/activities/datasets/most-viewed?limit=${limit}`, + { + method: "GET", + credentials: "include", + } + ); + + const data = await response.json(); + + if (!response.ok) { + throw new Error(data.message || "Failed to fetch most viewed datasets"); + } + + return data; + }, }; From 2e2034668dcec95e996d6566c46db910e14eb47c Mon Sep 17 00:00:00 2001 From: elainefan331 Date: Mon, 26 Jan 2026 15:04:18 -0500 Subject: [PATCH 04/65] feat: add check user activities to controller and routes --- .../src/controllers/activity.controller.js | 51 +++++++++++++++++++ backend/src/routes/activities.routes.js | 4 +- 2 files changed, 54 insertions(+), 1 deletion(-) diff --git a/backend/src/controllers/activity.controller.js b/backend/src/controllers/activity.controller.js index 2b76085..8514283 100644 --- a/backend/src/controllers/activity.controller.js +++ b/backend/src/controllers/activity.controller.js @@ -426,6 +426,56 @@ const getDatasetStats = async (req, res) => { } }; +// check user activity +const checkUserActivity = async (req, res) => { + try { + const user = req.user; + const { dbName, datasetId } = req.params; + + // If user is not authenticated, return false for both + if (!user) { + return res.status(200).json({ + isLiked: false, + isSaved: false, + }); + } + + // Find dataset + const dataset = await Dataset.findOne({ + where: { couch_db: dbName, ds_id: datasetId }, + }); + + // If dataset doesn't exist yet, user hasn't liked or saved it + if (!dataset) { + return res.status(200).json({ + isLiked: false, + isSaved: false, + }); + } + + // Check if user has liked this dataset + const like = await DatasetLike.findOne({ + where: { user_id: user.id, dataset_id: dataset.id }, + }); + + // Check if user has saved this dataset + const save = await SavedDataset.findOne({ + where: { user_id: user.id, dataset_id: dataset.id }, + }); + + res.status(200).json({ + isLiked: !!like, + isSaved: !!save, + }); + } catch (error) { + console.error("Check user activity error:", error); + res.status(500).json({ + message: "Error checking user activity", + error: error.message, + }); + } +}; + module.exports = { likeDataset, unlikeDataset, @@ -438,4 +488,5 @@ module.exports = { trackView, getMostViewedDatasets, getDatasetStats, + checkUserActivity, }; diff --git a/backend/src/routes/activities.routes.js b/backend/src/routes/activities.routes.js index 6fe859b..10729d2 100644 --- a/backend/src/routes/activities.routes.js +++ b/backend/src/routes/activities.routes.js @@ -12,6 +12,7 @@ const { trackView, getMostViewedDatasets, getDatasetStats, + checkUserActivity, } = require("../controllers/activity.controller"); const { restoreUser, requireAuth } = require("../middleware/auth.middleware"); @@ -40,5 +41,6 @@ router.get("/datasets/most-viewed", getMostViewedDatasets); // Public // Dataset statistics (views count, likes count) router.get("/datasets/:dbName/:datasetId/stats", getDatasetStats); // Public - +// Check user activity (isLiked or isSaved) +router.get("/datasets/:dbName/:datasetId/user-activity", checkUserActivity); module.exports = router; From 8509efa4feaf6288b694cd9456f5622f3bf47847 Mon Sep 17 00:00:00 2001 From: elainefan331 Date: Mon, 26 Jan 2026 15:29:01 -0500 Subject: [PATCH 05/65] feat: add check user activities in redux --- src/redux/activities/activities.action.ts | 21 +++++++++++++++ src/redux/activities/activities.slice.ts | 27 +++++++++++++++++++ .../activities/types/activities.interface.ts | 11 ++++++++ src/services/activities.service.ts | 22 +++++++++++++++ 4 files changed, 81 insertions(+) diff --git a/src/redux/activities/activities.action.ts b/src/redux/activities/activities.action.ts index b352635..e53a85b 100644 --- a/src/redux/activities/activities.action.ts +++ b/src/redux/activities/activities.action.ts @@ -7,6 +7,7 @@ import { GetCommentsPayload, GetDatasetStatsPayload, GetMostViewedDatasetsPayload, + CheckUserActivityPayload, } from "./types/activities.interface"; import { createAsyncThunk } from "@reduxjs/toolkit"; import { ActivitiesService } from "services/activities.service"; @@ -175,3 +176,23 @@ export const getMostViewedDatasets = createAsyncThunk( } } ); + +export const checkUserActivity = createAsyncThunk( + "activities/checkUserActivity", + async (payload: CheckUserActivityPayload, { rejectWithValue }) => { + try { + const response = await ActivitiesService.checkUserActivity( + payload.dbName, + payload.datasetId + ); + return { + dbName: payload.dbName, + datasetId: payload.datasetId, + isLiked: response.isLiked, + isSaved: response.isSaved, + }; + } catch (error: any) { + return rejectWithValue(error.message || "Failed to check user activity"); + } + } +); diff --git a/src/redux/activities/activities.slice.ts b/src/redux/activities/activities.slice.ts index fb5c23f..a52dcfa 100644 --- a/src/redux/activities/activities.slice.ts +++ b/src/redux/activities/activities.slice.ts @@ -9,6 +9,7 @@ import { deleteComment, getDatasetStats, getMostViewedDatasets, + checkUserActivity, } from "./activities.action"; import { ActivitiesState } from "./types/activities.interface"; import { createSlice, PayloadAction } from "@reduxjs/toolkit"; @@ -260,6 +261,32 @@ const activitiesSlice = createSlice({ .addCase(getMostViewedDatasets.rejected, (state, action) => { state.loading = false; state.error = action.payload as string; + }) + // Check User Activity + .addCase(checkUserActivity.pending, (state, action) => { + const { dbName, datasetId } = action.meta.arg; + const key = getDatasetKey(dbName, datasetId); + const status = getOrInitStatus(state, key); + status.isLoadingLike = true; + status.isLoadingSave = true; + state.error = null; + }) + .addCase(checkUserActivity.fulfilled, (state, action) => { + const { dbName, datasetId, isLiked, isSaved } = action.payload; + const key = getDatasetKey(dbName, datasetId); + const status = getOrInitStatus(state, key); + status.isLiked = isLiked; + status.isSaved = isSaved; + status.isLoadingLike = false; + status.isLoadingSave = false; + }) + .addCase(checkUserActivity.rejected, (state, action) => { + const { dbName, datasetId } = action.meta.arg; + const key = getDatasetKey(dbName, datasetId); + const status = getOrInitStatus(state, key); + status.isLoadingLike = false; + status.isLoadingSave = false; + state.error = action.payload as string; }); }, }); diff --git a/src/redux/activities/types/activities.interface.ts b/src/redux/activities/types/activities.interface.ts index 27704a0..734da8a 100644 --- a/src/redux/activities/types/activities.interface.ts +++ b/src/redux/activities/types/activities.interface.ts @@ -159,3 +159,14 @@ export interface GetMostViewedDatasetsResponse { mostViewed: MostViewedDataset[]; datasetsCount: number; } + +// Add +export interface CheckUserActivityPayload { + dbName: string; + datasetId: string; +} + +export interface CheckUserActivityResponse { + isLiked: boolean; + isSaved: boolean; +} diff --git a/src/services/activities.service.ts b/src/services/activities.service.ts index ad87322..f815a8f 100644 --- a/src/services/activities.service.ts +++ b/src/services/activities.service.ts @@ -11,6 +11,7 @@ import { UnsaveResponse, GetDatasetStatsResponse, GetMostViewedDatasetsResponse, + CheckUserActivityResponse, } from "../redux/activities/types/activities.interface"; const API_URL = process.env.REACT_APP_API_URL || "http://localhost:5000/api/v1"; @@ -238,6 +239,27 @@ export const ActivitiesService = { throw new Error(data.message || "Failed to fetch most viewed datasets"); } + return data; + }, + // check user liked or saved a dataset already or not + checkUserActivity: async ( + dbName: string, + datasetId: string + ): Promise => { + const response = await fetch( + `${API_URL}/activities/datasets/${dbName}/${datasetId}/user-activity`, + { + method: "GET", + credentials: "include", + } + ); + + const data = await response.json(); + + if (!response.ok) { + throw new Error(data.message || "Failed to check user activity"); + } + return data; }, }; From a3ca4e869b997536a7b11389b0a0069324635e00 Mon Sep 17 00:00:00 2001 From: elainefan331 Date: Tue, 27 Jan 2026 10:23:31 -0500 Subject: [PATCH 06/65] feat: add like and save buttons in dataset page --- .../DatasetDetailPage/DatasetAction.tsx | 151 ++++++++++++++++++ src/pages/UpdatedDatasetDetailPage.tsx | 101 +++++++++++- 2 files changed, 251 insertions(+), 1 deletion(-) create mode 100644 src/components/DatasetDetailPage/DatasetAction.tsx diff --git a/src/components/DatasetDetailPage/DatasetAction.tsx b/src/components/DatasetDetailPage/DatasetAction.tsx new file mode 100644 index 0000000..1935a19 --- /dev/null +++ b/src/components/DatasetDetailPage/DatasetAction.tsx @@ -0,0 +1,151 @@ +import BookmarkIcon from "@mui/icons-material/Bookmark"; +import BookmarkBorderIcon from "@mui/icons-material/BookmarkBorder"; +import FavoriteIcon from "@mui/icons-material/Favorite"; +import FavoriteBorderIcon from "@mui/icons-material/FavoriteBorder"; +import { + Box, + Button, + CircularProgress, + Typography, + Snackbar, + Alert, +} from "@mui/material"; +import { Colors } from "design/theme"; +import React, { useState } from "react"; + +interface DatasetActionsProps { + isLiked: boolean; + isSaved: boolean; + likesCount: number; + viewsCount: number; + isLikeLoading: boolean; + isSaveLoading: boolean; + isAuthenticated: boolean; + onLikeToggle: () => void; + onSaveToggle: () => void; +} + +const DatasetActions: React.FC = ({ + isLiked, + isSaved, + likesCount, + viewsCount, + isLikeLoading, + isSaveLoading, + isAuthenticated, + onLikeToggle, + onSaveToggle, +}) => { + const [showLoginAlert, setShowLoginAlert] = useState(false); + + const handleUnauthenticatedClick = () => { + setShowLoginAlert(true); + }; + return ( + <> + + {/* Like Button */} + + + {/* Save Button */} + + + {/* Views Count Display */} + {viewsCount > 0 && ( + + {viewsCount} {viewsCount === 1 ? "view" : "views"} + + )} + + {/* Login Alert */} + setShowLoginAlert(false)} + anchorOrigin={{ vertical: "bottom", horizontal: "center" }} + > + setShowLoginAlert(false)} + severity="info" + sx={{ width: "100%" }} + > + Please log in to like or save datasets + + + + ); +}; + +export default DatasetActions; diff --git a/src/pages/UpdatedDatasetDetailPage.tsx b/src/pages/UpdatedDatasetDetailPage.tsx index 82768bd..8c83daa 100644 --- a/src/pages/UpdatedDatasetDetailPage.tsx +++ b/src/pages/UpdatedDatasetDetailPage.tsx @@ -18,6 +18,8 @@ import { Tooltip, IconButton, } from "@mui/material"; +// add +import DatasetActions from "components/DatasetDetailPage/DatasetAction"; import FileTree from "components/DatasetDetailPage/FileTree/FileTree"; import { buildTreeFromDoc, @@ -32,6 +34,23 @@ import { useAppSelector } from "hooks/useAppSelector"; import React, { useEffect, useMemo, useState, useRef } from "react"; // import ReactJson from "react-json-view"; import { useParams, useNavigate, useSearchParams } from "react-router-dom"; +import { + likeDataset, + unlikeDataset, + saveDataset, + unsaveDataset, + getDatasetStats, + checkUserActivity, +} from "redux/activities/activities.action"; +import { + selectIsDatasetLiked, + selectIsDatasetSaved, + selectDatasetLikesCount, + selectDatasetViewsCount, + selectIsLikeLoading, + selectIsSaveLoading, +} from "redux/activities/activities.selector"; +import { AuthSelector } from "redux/auth/auth.selector"; import { fetchDocumentDetails, fetchDbInfoByDatasetId, @@ -66,6 +85,75 @@ const UpdatedDatasetDetailPage: React.FC = () => { error, datasetViewInfo: dbViewInfo, } = useAppSelector(NeurojsonSelector); + // user activities + // ✅ Add auth state + const { isLoggedIn: isAuthenticated } = useAppSelector(AuthSelector); + + // ✅ Add activities state - with safe defaults + const isLiked = useAppSelector((state) => + dbName && docId ? selectIsDatasetLiked(state, dbName, docId) : false + ); + const isSaved = useAppSelector((state) => + dbName && docId ? selectIsDatasetSaved(state, dbName, docId) : false + ); + const likesCount = useAppSelector((state) => + dbName && docId ? selectDatasetLikesCount(state, dbName, docId) : 0 + ); + const viewsCount = useAppSelector((state) => + dbName && docId ? selectDatasetViewsCount(state, dbName, docId) : 0 + ); + const isLikeLoading = useAppSelector((state) => + dbName && docId ? selectIsLikeLoading(state, dbName, docId) : false + ); + const isSaveLoading = useAppSelector((state) => + dbName && docId ? selectIsSaveLoading(state, dbName, docId) : false + ); + + // Handle like/unlike + const handleLikeToggle = async () => { + if (!dbName || !docId) return; + + try { + if (isLiked) { + await dispatch(unlikeDataset({ dbName, datasetId: docId })).unwrap(); + } else { + await dispatch(likeDataset({ dbName, datasetId: docId })).unwrap(); + } + // Refresh stats after like/unlike + dispatch(getDatasetStats({ dbName, datasetId: docId })); + } catch (error) { + console.error("Error toggling like:", error); + } + }; + + // Handle save/unsave + const handleSaveToggle = async () => { + if (!dbName || !docId) return; + + try { + if (isSaved) { + await dispatch(unsaveDataset({ dbName, datasetId: docId })).unwrap(); + } else { + await dispatch(saveDataset({ dbName, datasetId: docId })).unwrap(); + } + } catch (error) { + console.error("Error toggling save:", error); + } + }; + + // ✅ Add this useEffect to load user activity status and stats + useEffect(() => { + if (!dbName || !docId) return; + + // Fetch stats (views count, likes count) + dispatch(getDatasetStats({ dbName, datasetId: docId })); + + // Check if user has liked/saved this dataset (only if authenticated) + if (isAuthenticated) { + dispatch(checkUserActivity({ dbName, datasetId: docId })); + } + }, [dbName, docId, isAuthenticated, dispatch]); + // get params from url const [searchParams, setSearchParams] = useSearchParams(); const focus = searchParams.get("focus") || undefined; // get highlight from url @@ -807,7 +895,18 @@ const UpdatedDatasetDetailPage: React.FC = () => { : datasetDocument["dataset_description.json"].Authors} )} - + {/* user actions component */} + {/* ai summary */} {aiSummary ? ( <> From e4de9df3d9a80907d79dfc6591acdc6dae0cb939 Mon Sep 17 00:00:00 2001 From: elainefan331 Date: Tue, 27 Jan 2026 11:08:26 -0500 Subject: [PATCH 07/65] feat: add get user's liked and saved datasets in the backend routes --- .../src/controllers/activity.controller.js | 80 +++++++++ backend/src/routes/activities.routes.js | 5 + .../User/Dashboard/SavedDatasetsTab.tsx | 165 ++++++++++++++++++ .../User/Dashboard/likedDatasetsTab.tsx | 165 ++++++++++++++++++ src/components/User/UserDashboard.tsx | 30 +++- 5 files changed, 444 insertions(+), 1 deletion(-) create mode 100644 src/components/User/Dashboard/SavedDatasetsTab.tsx create mode 100644 src/components/User/Dashboard/likedDatasetsTab.tsx diff --git a/backend/src/controllers/activity.controller.js b/backend/src/controllers/activity.controller.js index 8514283..597a23d 100644 --- a/backend/src/controllers/activity.controller.js +++ b/backend/src/controllers/activity.controller.js @@ -476,6 +476,84 @@ const checkUserActivity = async (req, res) => { } }; +// Get user's saved datasets +const getUserSavedDatasets = async (req, res) => { + try { + const userId = req.user.id; + + const savedDatasets = await SavedDataset.findAll({ + where: { user_id: userId }, + include: [ + { + model: Dataset, + attributes: ["id", "couch_db", "ds_id", "views_count"], + }, + ], + order: [["created_at", "DESC"]], // Most recently saved first + attributes: ["id", "created_at"], + }); + + // Transform the data for frontend + const datasets = savedDatasets.map((saved) => ({ + id: saved.Dataset.id, + couch_db: saved.Dataset.couch_db, + ds_id: saved.Dataset.ds_id, + views_count: saved.Dataset.views_count, + saved_at: saved.created_at, + })); + + res.status(200).json({ + savedDatasets: datasets, + count: datasets.length, + }); + } catch (error) { + console.error("Get saved datasets error:", error); + res.status(500).json({ + message: "Error fetching saved datasets", + error: error.message, + }); + } +}; + +// Get user's liked datasets +const getUserLikedDatasets = async (req, res) => { + try { + const userId = req.user.id; + + const likedDatasets = await DatasetLike.findAll({ + where: { user_id: userId }, + include: [ + { + model: Dataset, + attributes: ["id", "couch_db", "ds_id", "views_count"], + }, + ], + order: [["created_at", "DESC"]], // Most recently liked first + attributes: ["id", "created_at"], + }); + + // Transform the data for frontend + const datasets = likedDatasets.map((like) => ({ + id: like.Dataset.id, + couch_db: like.Dataset.couch_db, + ds_id: like.Dataset.ds_id, + views_count: like.Dataset.views_count, + liked_at: like.created_at, + })); + + res.status(200).json({ + likedDatasets: datasets, + count: datasets.length, + }); + } catch (error) { + console.error("Get liked datasets error:", error); + res.status(500).json({ + message: "Error fetching liked datasets", + error: error.message, + }); + } +}; + module.exports = { likeDataset, unlikeDataset, @@ -489,4 +567,6 @@ module.exports = { getMostViewedDatasets, getDatasetStats, checkUserActivity, + getUserSavedDatasets, + getUserLikedDatasets, }; diff --git a/backend/src/routes/activities.routes.js b/backend/src/routes/activities.routes.js index 10729d2..a316b11 100644 --- a/backend/src/routes/activities.routes.js +++ b/backend/src/routes/activities.routes.js @@ -43,4 +43,9 @@ router.get("/datasets/most-viewed", getMostViewedDatasets); // Public router.get("/datasets/:dbName/:datasetId/stats", getDatasetStats); // Public // Check user activity (isLiked or isSaved) router.get("/datasets/:dbName/:datasetId/user-activity", checkUserActivity); + +// User's collections +router.get("/users/me/saved-datasets", requireAuth, getUserSavedDatasets); +router.get("/users/me/liked-datasets", requireAuth, getUserLikedDatasets); + module.exports = router; diff --git a/src/components/User/Dashboard/SavedDatasetsTab.tsx b/src/components/User/Dashboard/SavedDatasetsTab.tsx new file mode 100644 index 0000000..dce979d --- /dev/null +++ b/src/components/User/Dashboard/SavedDatasetsTab.tsx @@ -0,0 +1,165 @@ +import { Bookmark, Visibility } from "@mui/icons-material"; +import { + Box, + Typography, + Paper, + CircularProgress, + Alert, + List, + ListItem, + ListItemText, + Divider, + Button, + Chip, +} from "@mui/material"; +import { Colors } from "design/theme"; +import { useAppDispatch } from "hooks/useAppDispatch"; +import { useAppSelector } from "hooks/useAppSelector"; +import React, { useEffect } from "react"; +import { useNavigate } from "react-router-dom"; + +// You'll need to create these actions +// import { getUserSavedDatasets } from "redux/activities/activities.action"; +// import { selectUserSavedDatasets } from "redux/activities/activities.selector"; + +interface SavedDatasetsTabProps { + userId: number; +} + +const SavedDatasetsTab: React.FC = ({ userId }) => { + const dispatch = useAppDispatch(); + const navigate = useNavigate(); + + // Temporary mock data - replace with actual Redux selector + const savedDatasets = [ + { + id: 1, + couch_db: "openneuro", + ds_id: "ds000001", + saved_at: "2024-01-15T10:30:00Z", + }, + { + id: 2, + couch_db: "openneuro", + ds_id: "ds000002", + saved_at: "2024-01-20T14:20:00Z", + }, + ]; + const loading = false; + const error = null; + + useEffect(() => { + // Fetch user's saved datasets + // dispatch(getUserSavedDatasets(userId)); + }, [userId, dispatch]); + + const handleViewDataset = (dbName: string, datasetId: string) => { + navigate(`/db/${dbName}/${datasetId}`); + }; + + const formatDate = (dateString: string) => { + return new Date(dateString).toLocaleDateString("en-US", { + year: "numeric", + month: "short", + day: "numeric", + }); + }; + + if (loading) { + return ( + + + + ); + } + + if (error) { + return ( + + {error} + + ); + } + + if (savedDatasets.length === 0) { + return ( + + + + No Saved Datasets + + + Datasets you save will appear here + + + ); + } + + return ( + + + Saved Datasets + + + You have {savedDatasets.length} saved{" "} + {savedDatasets.length === 1 ? "dataset" : "datasets"} + + + + + {savedDatasets.map((dataset, index) => ( + + {index > 0 && } + + + + + {dataset.ds_id} + + + + } + secondary={`Saved on ${formatDate(dataset.saved_at)}`} + /> + + + + + ))} + + + + ); +}; + +export default SavedDatasetsTab; diff --git a/src/components/User/Dashboard/likedDatasetsTab.tsx b/src/components/User/Dashboard/likedDatasetsTab.tsx new file mode 100644 index 0000000..5f77dc8 --- /dev/null +++ b/src/components/User/Dashboard/likedDatasetsTab.tsx @@ -0,0 +1,165 @@ +import { Favorite, Visibility } from "@mui/icons-material"; +import { + Box, + Typography, + Paper, + CircularProgress, + Alert, + List, + ListItem, + ListItemText, + Divider, + Button, + Chip, +} from "@mui/material"; +import { Colors } from "design/theme"; +import { useAppDispatch } from "hooks/useAppDispatch"; +import { useAppSelector } from "hooks/useAppSelector"; +import React, { useEffect } from "react"; +import { useNavigate } from "react-router-dom"; + +// You'll need to create these actions +// import { getUserLikedDatasets } from "redux/activities/activities.action"; +// import { selectUserLikedDatasets } from "redux/activities/activities.selector"; + +interface LikedDatasetsTabProps { + userId: number; +} + +const LikedDatasetsTab: React.FC = ({ userId }) => { + const dispatch = useAppDispatch(); + const navigate = useNavigate(); + + // Temporary mock data - replace with actual Redux selector + const likedDatasets = [ + { + id: 1, + couch_db: "openneuro", + ds_id: "ds000001", + liked_at: "2024-01-10T08:15:00Z", + }, + { + id: 2, + couch_db: "openneuro", + ds_id: "ds000003", + liked_at: "2024-01-18T16:45:00Z", + }, + ]; + const loading = false; + const error = null; + + useEffect(() => { + // Fetch user's liked datasets + // dispatch(getUserLikedDatasets(userId)); + }, [userId, dispatch]); + + const handleViewDataset = (dbName: string, datasetId: string) => { + navigate(`/db/${dbName}/${datasetId}`); + }; + + const formatDate = (dateString: string) => { + return new Date(dateString).toLocaleDateString("en-US", { + year: "numeric", + month: "short", + day: "numeric", + }); + }; + + if (loading) { + return ( + + + + ); + } + + if (error) { + return ( + + {error} + + ); + } + + if (likedDatasets.length === 0) { + return ( + + + + No Liked Datasets + + + Datasets you like will appear here + + + ); + } + + return ( + + + Liked Datasets + + + You have liked {likedDatasets.length}{" "} + {likedDatasets.length === 1 ? "dataset" : "datasets"} + + + + + {likedDatasets.map((dataset, index) => ( + + {index > 0 && } + + + + + {dataset.ds_id} + + + + } + secondary={`Liked on ${formatDate(dataset.liked_at)}`} + /> + + + + + ))} + + + + ); +}; + +export default LikedDatasetsTab; diff --git a/src/components/User/UserDashboard.tsx b/src/components/User/UserDashboard.tsx index 6759fb5..2454645 100644 --- a/src/components/User/UserDashboard.tsx +++ b/src/components/User/UserDashboard.tsx @@ -1,6 +1,14 @@ import ProfileTab from "./Dashboard/ProfileTab"; +import SavedDatasetsTab from "./Dashboard/SavedDatasetsTab"; import SecurityTab from "./Dashboard/SecurityTab"; -import { AccountCircle, Lock, Settings } from "@mui/icons-material"; +import LikedDatasetsTab from "./Dashboard/likedDatasetsTab"; +import { + AccountCircle, + Lock, + Settings, + Bookmark, + Favorite, +} from "@mui/icons-material"; import { Box, Container, @@ -96,6 +104,8 @@ const UserDashboard: React.FC = () => { value={tabValue} onChange={handleTabChange} aria-label="dashboard tabs" + variant="scrollable" + scrollButtons="auto" sx={{ borderBottom: 1, borderColor: "divider", @@ -122,6 +132,18 @@ const UserDashboard: React.FC = () => { id="dashboard-tab-1" aria-controls="dashboard-tabpanel-1" /> + } + label="Saved" + id="dashboard-tab-2" + aria-controls="dashboard-tabpanel-2" + /> + } + label="Liked" + id="dashboard-tab-3" + aria-controls="dashboard-tabpanel-3" + /> } label="Settings" @@ -136,6 +158,12 @@ const UserDashboard: React.FC = () => { + + + + + + ); From ac4b5bda1a4a9ce967e1e2fe27795bc5e2ea916c Mon Sep 17 00:00:00 2001 From: elainefan331 Date: Tue, 27 Jan 2026 12:28:45 -0500 Subject: [PATCH 08/65] feat: add get user's liked and saved datasets in redux --- backend/src/routes/activities.routes.js | 2 + .../User/Dashboard/SavedDatasetsTab.tsx | 36 ++++++----------- .../User/Dashboard/likedDatasetsTab.tsx | 36 ++++++----------- src/redux/activities/activities.action.ts | 26 ++++++++++++ src/redux/activities/activities.selector.ts | 10 +++++ src/redux/activities/activities.slice.ts | 31 ++++++++++++++ .../activities/types/activities.interface.ts | 30 +++++++++++++- src/services/activities.service.ts | 40 +++++++++++++++++++ 8 files changed, 162 insertions(+), 49 deletions(-) diff --git a/backend/src/routes/activities.routes.js b/backend/src/routes/activities.routes.js index a316b11..700122d 100644 --- a/backend/src/routes/activities.routes.js +++ b/backend/src/routes/activities.routes.js @@ -13,6 +13,8 @@ const { getMostViewedDatasets, getDatasetStats, checkUserActivity, + getUserLikedDatasets, + getUserSavedDatasets, } = require("../controllers/activity.controller"); const { restoreUser, requireAuth } = require("../middleware/auth.middleware"); diff --git a/src/components/User/Dashboard/SavedDatasetsTab.tsx b/src/components/User/Dashboard/SavedDatasetsTab.tsx index dce979d..df819fe 100644 --- a/src/components/User/Dashboard/SavedDatasetsTab.tsx +++ b/src/components/User/Dashboard/SavedDatasetsTab.tsx @@ -17,10 +17,12 @@ import { useAppDispatch } from "hooks/useAppDispatch"; import { useAppSelector } from "hooks/useAppSelector"; import React, { useEffect } from "react"; import { useNavigate } from "react-router-dom"; - -// You'll need to create these actions -// import { getUserSavedDatasets } from "redux/activities/activities.action"; -// import { selectUserSavedDatasets } from "redux/activities/activities.selector"; +import { getUserSavedDatasets } from "redux/activities/activities.action"; +import { + selectUserSavedDatasets, + selectActivitiesLoading, + selectActivitiesError, +} from "redux/activities/activities.selector"; interface SavedDatasetsTabProps { userId: number; @@ -30,28 +32,14 @@ const SavedDatasetsTab: React.FC = ({ userId }) => { const dispatch = useAppDispatch(); const navigate = useNavigate(); - // Temporary mock data - replace with actual Redux selector - const savedDatasets = [ - { - id: 1, - couch_db: "openneuro", - ds_id: "ds000001", - saved_at: "2024-01-15T10:30:00Z", - }, - { - id: 2, - couch_db: "openneuro", - ds_id: "ds000002", - saved_at: "2024-01-20T14:20:00Z", - }, - ]; - const loading = false; - const error = null; + // Get real data from Redux + const savedDatasets = useAppSelector(selectUserSavedDatasets); + const loading = useAppSelector(selectActivitiesLoading); + const error = useAppSelector(selectActivitiesError); useEffect(() => { - // Fetch user's saved datasets - // dispatch(getUserSavedDatasets(userId)); - }, [userId, dispatch]); + dispatch(getUserSavedDatasets()); + }, [dispatch]); const handleViewDataset = (dbName: string, datasetId: string) => { navigate(`/db/${dbName}/${datasetId}`); diff --git a/src/components/User/Dashboard/likedDatasetsTab.tsx b/src/components/User/Dashboard/likedDatasetsTab.tsx index 5f77dc8..566fa0a 100644 --- a/src/components/User/Dashboard/likedDatasetsTab.tsx +++ b/src/components/User/Dashboard/likedDatasetsTab.tsx @@ -17,10 +17,12 @@ import { useAppDispatch } from "hooks/useAppDispatch"; import { useAppSelector } from "hooks/useAppSelector"; import React, { useEffect } from "react"; import { useNavigate } from "react-router-dom"; - -// You'll need to create these actions -// import { getUserLikedDatasets } from "redux/activities/activities.action"; -// import { selectUserLikedDatasets } from "redux/activities/activities.selector"; +import { getUserLikedDatasets } from "redux/activities/activities.action"; +import { + selectUserLikedDatasets, + selectActivitiesLoading, + selectActivitiesError, +} from "redux/activities/activities.selector"; interface LikedDatasetsTabProps { userId: number; @@ -30,28 +32,14 @@ const LikedDatasetsTab: React.FC = ({ userId }) => { const dispatch = useAppDispatch(); const navigate = useNavigate(); - // Temporary mock data - replace with actual Redux selector - const likedDatasets = [ - { - id: 1, - couch_db: "openneuro", - ds_id: "ds000001", - liked_at: "2024-01-10T08:15:00Z", - }, - { - id: 2, - couch_db: "openneuro", - ds_id: "ds000003", - liked_at: "2024-01-18T16:45:00Z", - }, - ]; - const loading = false; - const error = null; + // Get real data from Redux + const likedDatasets = useAppSelector(selectUserLikedDatasets); + const loading = useAppSelector(selectActivitiesLoading); + const error = useAppSelector(selectActivitiesError); useEffect(() => { - // Fetch user's liked datasets - // dispatch(getUserLikedDatasets(userId)); - }, [userId, dispatch]); + dispatch(getUserLikedDatasets()); + }, [dispatch]); const handleViewDataset = (dbName: string, datasetId: string) => { navigate(`/db/${dbName}/${datasetId}`); diff --git a/src/redux/activities/activities.action.ts b/src/redux/activities/activities.action.ts index e53a85b..e308d61 100644 --- a/src/redux/activities/activities.action.ts +++ b/src/redux/activities/activities.action.ts @@ -196,3 +196,29 @@ export const checkUserActivity = createAsyncThunk( } } ); + +// Get user's saved datasets +export const getUserSavedDatasets = createAsyncThunk( + "activities/getUserSavedDatasets", + async (_, { rejectWithValue }) => { + try { + const response = await ActivitiesService.getUserSavedDatasets(); + return response.savedDatasets; + } catch (error: any) { + return rejectWithValue(error.message || "Failed to fetch saved datasets"); + } + } +); + +// Get user's liked datasets +export const getUserLikedDatasets = createAsyncThunk( + "activities/getUserLikedDatasets", + async (_, { rejectWithValue }) => { + try { + const response = await ActivitiesService.getUserLikedDatasets(); + return response.likedDatasets; + } catch (error: any) { + return rejectWithValue(error.message || "Failed to fetch liked datasets"); + } + } +); diff --git a/src/redux/activities/activities.selector.ts b/src/redux/activities/activities.selector.ts index 89ab392..210d496 100644 --- a/src/redux/activities/activities.selector.ts +++ b/src/redux/activities/activities.selector.ts @@ -111,6 +111,16 @@ export const selectMostViewedDatasets = (state: RootState) => { return state.activities.mostViewedDatasets; }; +// Get user's saved datasets +export const selectUserSavedDatasets = (state: RootState) => { + return state.activities.userSavedDatasets; +}; + +// Get user's liked datasets +export const selectUserLikedDatasets = (state: RootState) => { + return state.activities.userLikedDatasets; +}; + // Get error export const selectActivitiesError = (state: RootState): string | null => { return state.activities.error; diff --git a/src/redux/activities/activities.slice.ts b/src/redux/activities/activities.slice.ts index a52dcfa..97887e5 100644 --- a/src/redux/activities/activities.slice.ts +++ b/src/redux/activities/activities.slice.ts @@ -10,6 +10,8 @@ import { getDatasetStats, getMostViewedDatasets, checkUserActivity, + getUserLikedDatasets, + getUserSavedDatasets, } from "./activities.action"; import { ActivitiesState } from "./types/activities.interface"; import { createSlice, PayloadAction } from "@reduxjs/toolkit"; @@ -40,6 +42,8 @@ const getOrInitStatus = (state: ActivitiesState, key: string) => { const initialState: ActivitiesState = { datasetActivities: {}, mostViewedDatasets: [], + userSavedDatasets: [], + userLikedDatasets: [], error: null, loading: false, }; @@ -287,6 +291,33 @@ const activitiesSlice = createSlice({ status.isLoadingLike = false; status.isLoadingSave = false; state.error = action.payload as string; + }) + // Get User Saved Datasets + .addCase(getUserSavedDatasets.pending, (state) => { + state.loading = true; + state.error = null; + }) + .addCase(getUserSavedDatasets.fulfilled, (state, action) => { + state.userSavedDatasets = action.payload; + state.loading = false; + }) + .addCase(getUserSavedDatasets.rejected, (state, action) => { + state.loading = false; + state.error = action.payload as string; + }) + + // Get User Liked Datasets + .addCase(getUserLikedDatasets.pending, (state) => { + state.loading = true; + state.error = null; + }) + .addCase(getUserLikedDatasets.fulfilled, (state, action) => { + state.userLikedDatasets = action.payload; + state.loading = false; + }) + .addCase(getUserLikedDatasets.rejected, (state, action) => { + state.loading = false; + state.error = action.payload as string; }); }, }); diff --git a/src/redux/activities/types/activities.interface.ts b/src/redux/activities/types/activities.interface.ts index 734da8a..7776035 100644 --- a/src/redux/activities/types/activities.interface.ts +++ b/src/redux/activities/types/activities.interface.ts @@ -58,11 +58,13 @@ export interface DatasetActivityStatus { isLoadingStats: boolean; } -// Redux state +// Redux state (for initial state and update) export interface ActivitiesState { // Key format: "dbName:datasetId" datasetActivities: Record; mostViewedDatasets: MostViewedDataset[]; + userSavedDatasets: UserSavedDataset[]; + userLikedDatasets: UserLikedDataset[]; error: string | null; loading: boolean; } @@ -170,3 +172,29 @@ export interface CheckUserActivityResponse { isLiked: boolean; isSaved: boolean; } + +export interface UserSavedDataset { + id: number; + couch_db: string; + ds_id: string; + views_count: number; + saved_at: string; +} + +export interface UserLikedDataset { + id: number; + couch_db: string; + ds_id: string; + views_count: number; + liked_at: string; +} + +export interface GetUserSavedDatasetsResponse { + savedDatasets: UserSavedDataset[]; + count: number; +} + +export interface GetUserLikedDatasetsResponse { + likedDatasets: UserLikedDataset[]; + count: number; +} diff --git a/src/services/activities.service.ts b/src/services/activities.service.ts index f815a8f..9d406d2 100644 --- a/src/services/activities.service.ts +++ b/src/services/activities.service.ts @@ -12,6 +12,8 @@ import { GetDatasetStatsResponse, GetMostViewedDatasetsResponse, CheckUserActivityResponse, + GetUserSavedDatasetsResponse, + GetUserLikedDatasetsResponse, } from "../redux/activities/types/activities.interface"; const API_URL = process.env.REACT_APP_API_URL || "http://localhost:5000/api/v1"; @@ -262,4 +264,42 @@ export const ActivitiesService = { return data; }, + + // Get current user's saved datasets + getUserSavedDatasets: async (): Promise => { + const response = await fetch( + `${API_URL}/activities/users/me/saved-datasets`, + { + method: "GET", + credentials: "include", + } + ); + + const data = await response.json(); + + if (!response.ok) { + throw new Error(data.message || "Failed to fetch saved datasets"); + } + + return data; + }, + + // Get current user's liked datasets + getUserLikedDatasets: async (): Promise => { + const response = await fetch( + `${API_URL}/activities/users/me/liked-datasets`, + { + method: "GET", + credentials: "include", + } + ); + + const data = await response.json(); + + if (!response.ok) { + throw new Error(data.message || "Failed to fetch liked datasets"); + } + + return data; + }, }; From 9fa3a2cd0cda7043b681a1b9cf721cd011c41afe Mon Sep 17 00:00:00 2001 From: elainefan331 Date: Tue, 27 Jan 2026 15:47:08 -0500 Subject: [PATCH 09/65] feat: add collections migration file --- .../20260127203625-create-collections.js | 62 +++++++++++++++++++ ...260127203731-create-collection-datasets.js | 22 +++++++ .../20260127203948-create-projects.js | 22 +++++++ 3 files changed, 106 insertions(+) create mode 100644 backend/migrations/20260127203625-create-collections.js create mode 100644 backend/migrations/20260127203731-create-collection-datasets.js create mode 100644 backend/migrations/20260127203948-create-projects.js diff --git a/backend/migrations/20260127203625-create-collections.js b/backend/migrations/20260127203625-create-collections.js new file mode 100644 index 0000000..cc4e25d --- /dev/null +++ b/backend/migrations/20260127203625-create-collections.js @@ -0,0 +1,62 @@ +"use strict"; + +/** @type {import('sequelize-cli').Migration} */ +module.exports = { + async up(queryInterface, Sequelize) { + await queryInterface.createTable("collections", { + id: { + type: Sequelize.INTEGER, + autoIncrement: true, + primaryKey: true, + allowNull: false, + }, + user_id: { + type: Sequelize.INTEGER, + allowNull: false, + references: { + model: "users", + key: "id", + }, + onUpdate: "CASCADE", + onDelete: "CASCADE", + }, + name: { + type: Sequelize.STRING(100), + allowNull: false, + }, + description: { + type: Sequelize.TEXT, + allowNull: true, + }, + is_public: { + type: Sequelize.BOOLEAN, + defaultValue: false, + allowNull: false, + }, + created_at: { + type: Sequelize.DATE, + allowNull: false, + defaultValue: Sequelize.literal("CURRENT_TIMESTAMP"), + }, + updated_at: { + type: Sequelize.DATE, + allowNull: false, + defaultValue: Sequelize.literal("CURRENT_TIMESTAMP"), + }, + }); + + // Add indexes + await queryInterface.addIndex("collections", ["user_id"], { + name: "collections_user_id_idx", + }); + + await queryInterface.addIndex("collections", ["user_id", "name"], { + unique: true, + name: "collections_user_name_unique", + }); + }, + + async down(queryInterface, Sequelize) { + await queryInterface.dropTable("collections"); + }, +}; diff --git a/backend/migrations/20260127203731-create-collection-datasets.js b/backend/migrations/20260127203731-create-collection-datasets.js new file mode 100644 index 0000000..b6e01de --- /dev/null +++ b/backend/migrations/20260127203731-create-collection-datasets.js @@ -0,0 +1,22 @@ +'use strict'; + +/** @type {import('sequelize-cli').Migration} */ +module.exports = { + async up (queryInterface, Sequelize) { + /** + * Add altering commands here. + * + * Example: + * await queryInterface.createTable('users', { id: Sequelize.INTEGER }); + */ + }, + + async down (queryInterface, Sequelize) { + /** + * Add reverting commands here. + * + * Example: + * await queryInterface.dropTable('users'); + */ + } +}; diff --git a/backend/migrations/20260127203948-create-projects.js b/backend/migrations/20260127203948-create-projects.js new file mode 100644 index 0000000..b6e01de --- /dev/null +++ b/backend/migrations/20260127203948-create-projects.js @@ -0,0 +1,22 @@ +'use strict'; + +/** @type {import('sequelize-cli').Migration} */ +module.exports = { + async up (queryInterface, Sequelize) { + /** + * Add altering commands here. + * + * Example: + * await queryInterface.createTable('users', { id: Sequelize.INTEGER }); + */ + }, + + async down (queryInterface, Sequelize) { + /** + * Add reverting commands here. + * + * Example: + * await queryInterface.dropTable('users'); + */ + } +}; From ffbb6b43eb3c03cfcfe179657bc4c0c826370055 Mon Sep 17 00:00:00 2001 From: elainefan331 Date: Tue, 27 Jan 2026 15:52:13 -0500 Subject: [PATCH 10/65] feat: add collection-datasets migration file --- ...260127203731-create-collection-datasets.js | 61 +++++++++++++++---- 1 file changed, 49 insertions(+), 12 deletions(-) diff --git a/backend/migrations/20260127203731-create-collection-datasets.js b/backend/migrations/20260127203731-create-collection-datasets.js index b6e01de..4c1099c 100644 --- a/backend/migrations/20260127203731-create-collection-datasets.js +++ b/backend/migrations/20260127203731-create-collection-datasets.js @@ -3,20 +3,57 @@ /** @type {import('sequelize-cli').Migration} */ module.exports = { async up (queryInterface, Sequelize) { - /** - * Add altering commands here. - * - * Example: - * await queryInterface.createTable('users', { id: Sequelize.INTEGER }); - */ + await queryInterface.createTable("collection_datasets", { + id: { + type: Sequelize.INTEGER, + autoIncrement: true, + primaryKey: true, + allowNull: false, + }, + collection_id: { + type: Sequelize.INTEGER, + allowNull: false, + references: { + model: "collections", + key: "id", + }, + onUpdate: "CASCADE", + onDelete: "CASCADE", + }, + dataset_id: { + type: Sequelize.INTEGER, + allowNull: false, + references: { + model: "datasets", + key: "id", + }, + onUpdate: "CASCADE", + onDelete: "CASCADE", + }, + created_at: { + type: Sequelize.DATE, + allowNull: false, + defaultValue: Sequelize.literal("CURRENT_TIMESTAMP"), + }, + }); + + / Add indexes + await queryInterface.addIndex("collection_datasets", ["collection_id"], { + name: "collection_datasets_collection_id_idx", + }); + + await queryInterface.addIndex("collection_datasets", ["dataset_id"], { + name: "collection_datasets_dataset_id_idx", + }); + + await queryInterface.addIndex("collection_datasets", ["collection_id", "dataset_id"], { + unique: true, + name: "collection_datasets_unique", + }); + }, async down (queryInterface, Sequelize) { - /** - * Add reverting commands here. - * - * Example: - * await queryInterface.dropTable('users'); - */ + await queryInterface.dropTable("collection_datasets"); } }; From 2835682e7446b9f58cb76dc25b5b2cd5c827c0ae Mon Sep 17 00:00:00 2001 From: elainefan331 Date: Tue, 27 Jan 2026 15:55:54 -0500 Subject: [PATCH 11/65] feat: add projects migration file --- .../20260127203948-create-projects.js | 65 ++++++++++++++----- 1 file changed, 49 insertions(+), 16 deletions(-) diff --git a/backend/migrations/20260127203948-create-projects.js b/backend/migrations/20260127203948-create-projects.js index b6e01de..c3e8d90 100644 --- a/backend/migrations/20260127203948-create-projects.js +++ b/backend/migrations/20260127203948-create-projects.js @@ -1,22 +1,55 @@ -'use strict'; +"use strict"; /** @type {import('sequelize-cli').Migration} */ module.exports = { - async up (queryInterface, Sequelize) { - /** - * Add altering commands here. - * - * Example: - * await queryInterface.createTable('users', { id: Sequelize.INTEGER }); - */ + async up(queryInterface, Sequelize) { + await queryInterface.createTable("projects", { + id: { + type: Sequelize.INTEGER, + autoIncrement: true, + primaryKey: true, + allowNull: false, + }, + user_id: { + type: Sequelize.INTEGER, + allowNull: false, + references: { + model: "users", + key: "id", + }, + onUpdate: "CASCADE", + onDelete: "CASCADE", + }, + name: { + type: Sequelize.STRING(200), + allowNull: false, + }, + description: { + type: Sequelize.TEXT, + allowNull: true, + }, + extractor_state: { + type: Sequelize.JSON, + allowNull: true, + }, + created_at: { + type: Sequelize.DATE, + allowNull: false, + defaultValue: Sequelize.literal("CURRENT_TIMESTAMP"), + }, + updated_at: { + type: Sequelize.DATE, + allowNull: false, + defaultValue: Sequelize.literal("CURRENT_TIMESTAMP"), + }, + }); + // Add indexes + await queryInterface.addIndex("projects", ["user_id"], { + name: "projects_user_id_idx", + }); }, - async down (queryInterface, Sequelize) { - /** - * Add reverting commands here. - * - * Example: - * await queryInterface.dropTable('users'); - */ - } + async down(queryInterface, Sequelize) { + await queryInterface.dropTable("projects"); + }, }; From 1d54d4cbdc34654408ad218841bc816cbe64de27 Mon Sep 17 00:00:00 2001 From: elainefan331 Date: Tue, 27 Jan 2026 17:52:22 -0500 Subject: [PATCH 12/65] add collection, collectionDataset and project models; add collection controller --- ...260127203731-create-collection-datasets.js | 23 +- .../src/controllers/collection.controller.js | 377 ++++++++++++++++++ backend/src/models/Collection.js | 52 +++ backend/src/models/CollectionDataset.js | 42 ++ backend/src/models/Project.js | 52 +++ backend/src/models/index.js | 28 ++ 6 files changed, 564 insertions(+), 10 deletions(-) create mode 100644 backend/src/controllers/collection.controller.js create mode 100644 backend/src/models/Collection.js create mode 100644 backend/src/models/CollectionDataset.js create mode 100644 backend/src/models/Project.js diff --git a/backend/migrations/20260127203731-create-collection-datasets.js b/backend/migrations/20260127203731-create-collection-datasets.js index 4c1099c..e654e59 100644 --- a/backend/migrations/20260127203731-create-collection-datasets.js +++ b/backend/migrations/20260127203731-create-collection-datasets.js @@ -1,8 +1,8 @@ -'use strict'; +"use strict"; /** @type {import('sequelize-cli').Migration} */ module.exports = { - async up (queryInterface, Sequelize) { + async up(queryInterface, Sequelize) { await queryInterface.createTable("collection_datasets", { id: { type: Sequelize.INTEGER, @@ -37,7 +37,7 @@ module.exports = { }, }); - / Add indexes + // Add indexes await queryInterface.addIndex("collection_datasets", ["collection_id"], { name: "collection_datasets_collection_id_idx", }); @@ -46,14 +46,17 @@ module.exports = { name: "collection_datasets_dataset_id_idx", }); - await queryInterface.addIndex("collection_datasets", ["collection_id", "dataset_id"], { - unique: true, - name: "collection_datasets_unique", - }); - + await queryInterface.addIndex( + "collection_datasets", + ["collection_id", "dataset_id"], + { + unique: true, + name: "collection_datasets_unique", + } + ); }, - async down (queryInterface, Sequelize) { + async down(queryInterface, Sequelize) { await queryInterface.dropTable("collection_datasets"); - } + }, }; diff --git a/backend/src/controllers/collection.controller.js b/backend/src/controllers/collection.controller.js new file mode 100644 index 0000000..cb984aa --- /dev/null +++ b/backend/src/controllers/collection.controller.js @@ -0,0 +1,377 @@ +const { Collection, CollectionDataset, Dataset, User } = require("../models"); + +// Get all collections for current user +const getUserCollections = async (req, res) => { + try { + const userId = req.user.id; + + const collections = await Collection.findAll({ + where: { user_id: userId }, + include: [ + { + model: Dataset, + as: "datasets", + through: { attributes: ["created_at"] }, + attributes: ["id", "couch_db", "ds_id", "views_count"], + }, + ], + order: [["created_at", "DESC"]], + }); + + // Transform to include dataset count + const collectionsWithCount = collections.map((col) => ({ + id: col.id, + name: col.name, + description: col.description, + is_public: col.is_public, + created_at: col.created_at, + updated_at: col.updated_at, + datasets_count: col.datasets ? col.datasets.length : 0, + datasets: col.datasets, // Include full dataset details + })); + + res.status(200).json({ + collections: collectionsWithCount, + count: collectionsWithCount.length, + }); + } catch (error) { + console.error("Get collections error:", error); + res.status(500).json({ + message: "Error fetching collections", + error: error.message, + }); + } +}; + +// Create a new collection +const createCollection = async (req, res) => { + try { + const userId = req.user.id; + const { name, description, is_public } = req.body; + + if (!name || name.trim() === "") { + return res.status(400).json({ message: "Collection name is required" }); + } + + // Check if collection name already exists for this user + const existing = await Collection.findOne({ + where: { user_id: userId, name: name.trim() }, + }); + + if (existing) { + return res.status(400).json({ + message: "A collection with this name already exists", + }); + } + + const collection = await Collection.create({ + user_id: userId, + name: name.trim(), + description: description?.trim() || null, + is_public: is_public || false, + }); + + res.status(201).json({ + message: "Collection created successfully", + collection, + }); + } catch (error) { + console.error("Create collection error:", error); + res.status(500).json({ + message: "Error creating collection", + error: error.message, + }); + } +}; + +// Get a specific collection with its datasets +const getCollection = async (req, res) => { + try { + const userId = req.user.id; + const { collectionId } = req.params; + + // Verify collection belongs to user + const collection = await Collection.findOne({ + where: { id: collectionId, user_id: userId }, + include: [ + { + model: Dataset, + as: "datasets", + through: { attributes: ["created_at"] }, + attributes: ["id", "couch_db", "ds_id", "views_count"], + }, + ], + }); + + if (!collection) { + return res.status(404).json({ message: "Collection not found" }); + } + + res.status(200).json({ + collection: { + id: collection.id, + name: collection.name, + description: collection.description, + is_public: collection.is_public, + created_at: collection.created_at, + updated_at: collection.updated_at, + datasets: collection.datasets || [], + datasets_count: collection.datasets ? collection.datasets.length : 0, + }, + }); + } catch (error) { + console.error("Get collection error:", error); + res.status(500).json({ + message: "Error fetching collection", + error: error.message, + }); + } +}; + +// Add dataset to collection +const addDatasetToCollection = async (req, res) => { + try { + const userId = req.user.id; + const { collectionId } = req.params; + const { dbName, datasetId } = req.body; + + if (!dbName || !datasetId) { + return res.status(400).json({ + message: "dbName and datasetId are required", + }); + } + + // Verify collection belongs to user + const collection = await Collection.findOne({ + where: { id: collectionId, user_id: userId }, + }); + + if (!collection) { + return res.status(404).json({ message: "Collection not found" }); + } + + // Get or create dataset + let dataset = await Dataset.findOne({ + where: { couch_db: dbName, ds_id: datasetId }, + }); + + if (!dataset) { + dataset = await Dataset.create({ + couch_db: dbName, + ds_id: datasetId, + views_count: 0, + }); + } + + // Check if already in collection + const existing = await CollectionDataset.findOne({ + where: { collection_id: collectionId, dataset_id: dataset.id }, + }); + + if (existing) { + return res.status(400).json({ + message: "Dataset already in this collection", + }); + } + + // Add to collection + await CollectionDataset.create({ + collection_id: collectionId, + dataset_id: dataset.id, + }); + + res.status(201).json({ + message: "Dataset added to collection successfully", + }); + } catch (error) { + console.error("Add dataset to collection error:", error); + res.status(500).json({ + message: "Error adding dataset to collection", + error: error.message, + }); + } +}; + +// Remove dataset from collection +const removeDatasetFromCollection = async (req, res) => { + try { + const userId = req.user.id; + const { collectionId, datasetId } = req.params; + + // Verify collection belongs to user + const collection = await Collection.findOne({ + where: { id: collectionId, user_id: userId }, + }); + + if (!collection) { + return res.status(404).json({ message: "Collection not found" }); + } + + // Remove from collection (datasetId here is the Dataset.id, not ds_id) + const deleted = await CollectionDataset.destroy({ + where: { collection_id: collectionId, dataset_id: datasetId }, + }); + + if (deleted === 0) { + return res.status(404).json({ + message: "Dataset not found in this collection", + }); + } + + res.status(200).json({ + message: "Dataset removed from collection successfully", + }); + } catch (error) { + console.error("Remove dataset from collection error:", error); + res.status(500).json({ + message: "Error removing dataset from collection", + error: error.message, + }); + } +}; + +// Update collection (rename, change description) +const updateCollection = async (req, res) => { + try { + const userId = req.user.id; + const { collectionId } = req.params; + const { name, description, is_public } = req.body; + + const collection = await Collection.findOne({ + where: { id: collectionId, user_id: userId }, + }); + + if (!collection) { + return res.status(404).json({ message: "Collection not found" }); + } + + if (name !== undefined) { + // Check for duplicate name + const existing = await Collection.findOne({ + where: { + user_id: userId, + name: name.trim(), + id: { [require("sequelize").Op.ne]: collectionId }, // Exclude current collection + }, + }); + + if (existing) { + return res.status(400).json({ + message: "A collection with this name already exists", + }); + } + + collection.name = name.trim(); + } + + if (description !== undefined) { + collection.description = description?.trim() || null; + } + + if (is_public !== undefined) { + collection.is_public = is_public; + } + + await collection.save(); + + res.status(200).json({ + message: "Collection updated successfully", + collection, + }); + } catch (error) { + console.error("Update collection error:", error); + res.status(500).json({ + message: "Error updating collection", + error: error.message, + }); + } +}; + +// Delete collection +const deleteCollection = async (req, res) => { + try { + const userId = req.user.id; + const { collectionId } = req.params; + + const collection = await Collection.findOne({ + where: { id: collectionId, user_id: userId }, + }); + + if (!collection) { + return res.status(404).json({ message: "Collection not found" }); + } + + // Cascade delete will automatically remove collection_datasets entries + await collection.destroy(); + + res.status(200).json({ + message: "Collection deleted successfully", + }); + } catch (error) { + console.error("Delete collection error:", error); + res.status(500).json({ + message: "Error deleting collection", + error: error.message, + }); + } +}; + +// Check which collections contain a specific dataset +const getDatasetCollections = async (req, res) => { + try { + const userId = req.user.id; + const { dbName, datasetId } = req.params; + + // Find the dataset + const dataset = await Dataset.findOne({ + where: { couch_db: dbName, ds_id: datasetId }, + }); + + if (!dataset) { + return res.status(200).json({ + collections: [], + }); + } + + // Find all user's collections that contain this dataset + const collectionDatasets = await CollectionDataset.findAll({ + where: { dataset_id: dataset.id }, + include: [ + { + model: Collection, + where: { user_id: userId }, + attributes: ["id", "name", "description"], + }, + ], + }); + + const collections = collectionDatasets.map((cd) => ({ + id: cd.Collection.id, + name: cd.Collection.name, + description: cd.Collection.description, + added_at: cd.created_at, + })); + + res.status(200).json({ + collections, + count: collections.length, + }); + } catch (error) { + console.error("Get dataset collections error:", error); + res.status(500).json({ + message: "Error fetching dataset collections", + error: error.message, + }); + } +}; + +module.exports = { + getUserCollections, + createCollection, + getCollection, + addDatasetToCollection, + removeDatasetFromCollection, + updateCollection, + deleteCollection, + getDatasetCollections, // optional +}; diff --git a/backend/src/models/Collection.js b/backend/src/models/Collection.js new file mode 100644 index 0000000..0614900 --- /dev/null +++ b/backend/src/models/Collection.js @@ -0,0 +1,52 @@ +const { DataTypes, Model } = require("sequelize"); +const { sequelize } = require("../config/database"); + +class Collection extends Model {} + +Collection.init( + { + id: { + type: DataTypes.INTEGER, + autoIncrement: true, + primaryKey: true, + }, + user_id: { + type: DataTypes.INTEGER, + allowNull: false, + references: { + model: "users", + key: "id", + }, + }, + name: { + type: DataTypes.STRING(100), + allowNull: false, + }, + description: { + type: DataTypes.TEXT, + allowNull: true, + }, + is_public: { + type: DataTypes.BOOLEAN, + defaultValue: false, + }, + created_at: { + type: DataTypes.DATE, + defaultValue: DataTypes.NOW, + }, + updated_at: { + type: DataTypes.DATE, + defaultValue: DataTypes.NOW, + }, + }, + { + sequelize, + tableName: "collections", + timestamps: true, + underscored: true, + createdAt: "created_at", + updatedAt: "updated_at", + } +); + +module.exports = Collection; diff --git a/backend/src/models/CollectionDataset.js b/backend/src/models/CollectionDataset.js new file mode 100644 index 0000000..b78552c --- /dev/null +++ b/backend/src/models/CollectionDataset.js @@ -0,0 +1,42 @@ +const { DataTypes, Model } = require("sequelize"); +const { sequelize } = require("../config/database"); + +class CollectionDataset extends Model {} + +CollectionDataset.init( + { + id: { + type: DataTypes.INTEGER, + autoIncrement: true, + primaryKey: true, + }, + collection_id: { + type: DataTypes.INTEGER, + allowNull: false, + references: { + model: "collections", + key: "id", + }, + }, + dataset_id: { + type: DataTypes.INTEGER, + allowNull: false, + references: { + model: "datasets", + key: "id", + }, + }, + created_at: { + type: DataTypes.DATE, + defaultValue: DataTypes.NOW, + }, + }, + { + sequelize, + tableName: "collection_datasets", + timestamps: false, + underscored: true, + } +); + +module.exports = CollectionDataset; diff --git a/backend/src/models/Project.js b/backend/src/models/Project.js new file mode 100644 index 0000000..5217740 --- /dev/null +++ b/backend/src/models/Project.js @@ -0,0 +1,52 @@ +const { DataTypes, Model } = require("sequelize"); +const { sequelize } = require("../config/database"); + +class Project extends Model {} + +Project.init( + { + id: { + type: DataTypes.INTEGER, + autoIncrement: true, + primaryKey: true, + }, + user_id: { + type: DataTypes.INTEGER, + allowNull: false, + references: { + model: "users", + key: "id", + }, + }, + name: { + type: DataTypes.STRING(200), + allowNull: false, + }, + description: { + type: DataTypes.TEXT, + allowNull: true, + }, + extractor_state: { + type: DataTypes.JSON, + allowNull: true, + }, + created_at: { + type: DataTypes.DATE, + defaultValue: DataTypes.NOW, + }, + updated_at: { + type: DataTypes.DATE, + defaultValue: DataTypes.NOW, + }, + }, + { + sequelize, + tableName: "projects", + timestamps: true, + underscored: true, + createdAt: "created_at", + updatedAt: "updated_at", + } +); + +module.exports = Project; diff --git a/backend/src/models/index.js b/backend/src/models/index.js index c613110..0f69874 100644 --- a/backend/src/models/index.js +++ b/backend/src/models/index.js @@ -2,6 +2,9 @@ const { DataTypes, Model } = require("sequelize"); const { sequelize } = require("../config/database"); const User = require("../models/User"); const Dataset = require("../models/Dataset"); +const Collection = require("../models/Collection"); +const CollectionDataset = require("../models/CollectionDataset"); +const Project = require("../models/Project"); // DatasetLike Model class DatasetLike extends Model {} @@ -197,6 +200,28 @@ Comment.belongsTo(Dataset, { foreignKey: "dataset_id" }); Dataset.hasMany(ViewHistory, { foreignKey: "dataset_id", as: "viewHistory" }); ViewHistory.belongsTo(Dataset, { foreignKey: "dataset_id" }); +// NEW: Collection Associations +User.hasMany(Collection, { foreignKey: "user_id", as: "collections" }); +Collection.belongsTo(User, { foreignKey: "user_id" }); + +Collection.belongsToMany(Dataset, { + through: CollectionDataset, + foreignKey: "collection_id", + otherKey: "dataset_id", + as: "datasets", +}); + +Dataset.belongsToMany(Collection, { + through: CollectionDataset, + foreignKey: "dataset_id", + otherKey: "collection_id", + as: "collections", +}); + +// NEW: Project Associations +User.hasMany(Project, { foreignKey: "user_id", as: "projects" }); +Project.belongsTo(User, { foreignKey: "user_id" }); + module.exports = { User, Dataset, @@ -204,4 +229,7 @@ module.exports = { SavedDataset, Comment, ViewHistory, + Collection, + CollectionDataset, + Project, }; From dc5b93888acee92d893bed87c3249976528c2c6d Mon Sep 17 00:00:00 2001 From: elainefan331 Date: Tue, 27 Jan 2026 18:23:39 -0500 Subject: [PATCH 13/65] feat: add collection routes --- backend/src/models/index.js | 6 +++ backend/src/routes/collection.route.js | 55 ++++++++++++++++++++++++++ backend/src/server.js | 2 + 3 files changed, 63 insertions(+) create mode 100644 backend/src/routes/collection.route.js diff --git a/backend/src/models/index.js b/backend/src/models/index.js index 0f69874..79fb712 100644 --- a/backend/src/models/index.js +++ b/backend/src/models/index.js @@ -218,6 +218,12 @@ Dataset.belongsToMany(Collection, { as: "collections", }); +CollectionDataset.belongsTo(Collection, { foreignKey: "collection_id" }); +Collection.hasMany(CollectionDataset, { foreignKey: "collection_id" }); + +CollectionDataset.belongsTo(Dataset, { foreignKey: "dataset_id" }); +Dataset.hasMany(CollectionDataset, { foreignKey: "dataset_id" }); + // NEW: Project Associations User.hasMany(Project, { foreignKey: "user_id", as: "projects" }); Project.belongsTo(User, { foreignKey: "user_id" }); diff --git a/backend/src/routes/collection.route.js b/backend/src/routes/collection.route.js new file mode 100644 index 0000000..ed51265 --- /dev/null +++ b/backend/src/routes/collection.route.js @@ -0,0 +1,55 @@ +const express = require("express"); +const { + getUserCollections, + createCollection, + getCollection, + addDatasetToCollection, + removeDatasetFromCollection, + updateCollection, + deleteCollection, + getDatasetCollections, +} = require("../controllers/collection.controller"); +const { restoreUser, requireAuth } = require("../middleware/auth.middleware"); + +const router = express.Router(); + +// Apply restoreUser to all routes +router.use(restoreUser); + +// Get all user's collections +router.get("/me/collections", requireAuth, getUserCollections); + +// Create new collection +router.post("/collections", requireAuth, createCollection); + +// Get specific collection +router.get("/collections/:collectionId", requireAuth, getCollection); + +// Add dataset to collection +router.post( + "/collections/:collectionId/datasets", + requireAuth, + addDatasetToCollection +); + +// Remove dataset from collection +router.delete( + "/collections/:collectionId/datasets/:datasetId", + requireAuth, + removeDatasetFromCollection +); + +// Update collection +router.put("/collections/:collectionId", requireAuth, updateCollection); + +// Delete collection +router.delete("/collections/:collectionId", requireAuth, deleteCollection); + +// Check which collections contain a specific dataset (for the "Add to Collection" menu) +router.get( + "/datasets/:dbName/:datasetId/collections", + requireAuth, + getDatasetCollections +); + +module.exports = router; diff --git a/backend/src/server.js b/backend/src/server.js index 47e87bf..d0d25e2 100644 --- a/backend/src/server.js +++ b/backend/src/server.js @@ -11,6 +11,7 @@ const userRoutes = require("./routes/users.routes"); const activitiesRoutes = require("./routes/activities.routes"); const dbsRoutes = require("./routes/dbs.routes"); const datasetsRoutes = require("./routes/datasets.routes"); +const collectionRoutes = require("./routes/collection.route"); const app = express(); const PORT = process.env.PORT || 5000; @@ -45,6 +46,7 @@ app.use("/api/v1/users", userRoutes); app.use("/api/v1/activities", activitiesRoutes); app.use("/api/v1/dbs", dbsRoutes); app.use("/api/v1/datasets", datasetsRoutes); +app.use("/api/v1/collections", collectionRoutes); // health check endpoint app.get("/api/health", async (req, res) => { From 9dd4dc26ec44615876267462fd1b03aa61e25b1a Mon Sep 17 00:00:00 2001 From: elainefan331 Date: Wed, 28 Jan 2026 10:59:23 -0500 Subject: [PATCH 14/65] feat: add collections action, service and interface files --- src/redux/collections/collections.action.ts | 123 +++++++++++ .../types/collections.interface.ts | 109 ++++++++++ src/services/collections.service.ts | 198 ++++++++++++++++++ 3 files changed, 430 insertions(+) create mode 100644 src/redux/collections/collections.action.ts create mode 100644 src/redux/collections/types/collections.interface.ts create mode 100644 src/services/collections.service.ts diff --git a/src/redux/collections/collections.action.ts b/src/redux/collections/collections.action.ts new file mode 100644 index 0000000..29f5e17 --- /dev/null +++ b/src/redux/collections/collections.action.ts @@ -0,0 +1,123 @@ +import { + CreateCollectionPayload, + UpdateCollectionPayload, + DeleteCollectionPayload, + GetCollectionPayload, + AddDatasetToCollectionPayload, + RemoveDatasetFromCollectionPayload, + GetDatasetCollectionsPayload, +} from "./types/collections.interface"; +import { createAsyncThunk } from "@reduxjs/toolkit"; +import { CollectionsService } from "services/collections.service"; + +// Get all user's collections +export const getUserCollections = createAsyncThunk( + "collections/getUserCollections", + async (_, { rejectWithValue }) => { + try { + const response = await CollectionsService.getUserCollections(); + return response.collections; + } catch (error: any) { + return rejectWithValue(error.message || "Failed to fetch collections"); + } + } +); + +// Create new collection +export const createCollection = createAsyncThunk( + "collections/createCollection", + async (payload: CreateCollectionPayload, { rejectWithValue }) => { + try { + const response = await CollectionsService.createCollection(payload); + return response.collection; + } catch (error: any) { + return rejectWithValue(error.message || "Failed to create collection"); + } + } +); + +// Get specific collection +export const getCollection = createAsyncThunk( + "collections/getCollection", + async (payload: GetCollectionPayload, { rejectWithValue }) => { + try { + const response = await CollectionsService.getCollection( + payload.collectionId + ); + return response.collection; + } catch (error: any) { + return rejectWithValue(error.message || "Failed to fetch collection"); + } + } +); + +// Add dataset to collection +export const addDatasetToCollection = createAsyncThunk( + "collections/addDatasetToCollection", + async (payload: AddDatasetToCollectionPayload, { rejectWithValue }) => { + try { + await CollectionsService.addDatasetToCollection(payload); + return payload; + } catch (error: any) { + return rejectWithValue( + error.message || "Failed to add dataset to collection" + ); + } + } +); + +// Remove dataset from collection +export const removeDatasetFromCollection = createAsyncThunk( + "collections/removeDatasetFromCollection", + async (payload: RemoveDatasetFromCollectionPayload, { rejectWithValue }) => { + try { + await CollectionsService.removeDatasetFromCollection(payload); + return payload; + } catch (error: any) { + return rejectWithValue( + error.message || "Failed to remove dataset from collection" + ); + } + } +); + +// Update collection +export const updateCollection = createAsyncThunk( + "collections/updateCollection", + async (payload: UpdateCollectionPayload, { rejectWithValue }) => { + try { + const response = await CollectionsService.updateCollection(payload); + return response.collection; + } catch (error: any) { + return rejectWithValue(error.message || "Failed to update collection"); + } + } +); + +// Delete collection +export const deleteCollection = createAsyncThunk( + "collections/deleteCollection", + async (payload: DeleteCollectionPayload, { rejectWithValue }) => { + try { + await CollectionsService.deleteCollection(payload.collectionId); + return payload.collectionId; + } catch (error: any) { + return rejectWithValue(error.message || "Failed to delete collection"); + } + } +); + +// Get which collections contain a specific dataset (for "Add to Collection" menu) +export const getDatasetCollections = createAsyncThunk( + "collections/getDatasetCollections", + async (payload: GetDatasetCollectionsPayload, { rejectWithValue }) => { + try { + const response = await CollectionsService.getDatasetCollections(payload); + return response.collections; + } catch (error: any) { + return rejectWithValue( + error.message || "Failed to fetch dataset collections" + ); + } + } +); diff --git a/src/redux/collections/types/collections.interface.ts b/src/redux/collections/types/collections.interface.ts new file mode 100644 index 0000000..1475ecc --- /dev/null +++ b/src/redux/collections/types/collections.interface.ts @@ -0,0 +1,109 @@ +// Collection from the database +export interface Collection { + id: number; + user_id: number; + name: string; + description: string | null; + is_public: boolean; + created_at: string; + updated_at: string; + datasets_count?: number; + datasets?: CollectionDataset[]; +} + +// Dataset within a collection (with junction table data) +export interface CollectionDataset { + id: number; + couch_db: string; + ds_id: string; + views_count: number; + CollectionDataset?: { + created_at: string; // When added to collection + }; +} + +// Redux state +export interface CollectionsState { + collections: Collection[]; // All user's collections + currentCollection: Collection | null; // Currently viewing collection + datasetCollections: Collection[]; // Collections containing a specific dataset (for menu) + error: string | null; + loading: boolean; + isCreating: boolean; + isAdding: boolean; +} + +// Action payloads +export interface CreateCollectionPayload { + name: string; + description?: string; + is_public?: boolean; +} + +export interface UpdateCollectionPayload { + collectionId: number; + name?: string; + description?: string; + is_public?: boolean; +} + +export interface DeleteCollectionPayload { + collectionId: number; +} + +export interface GetCollectionPayload { + collectionId: number; +} + +export interface AddDatasetToCollectionPayload { + collectionId: number; + dbName: string; + datasetId: string; +} + +export interface RemoveDatasetFromCollectionPayload { + collectionId: number; + datasetId: number; // Dataset.id (not ds_id) +} + +export interface GetDatasetCollectionsPayload { + dbName: string; + datasetId: string; +} + +// API Response interfaces +export interface GetUserCollectionsResponse { + collections: Collection[]; + count: number; +} + +export interface CreateCollectionResponse { + message: string; + collection: Collection; +} + +export interface GetCollectionResponse { + collection: Collection; +} + +export interface AddDatasetResponse { + message: string; +} + +export interface RemoveDatasetResponse { + message: string; +} + +export interface UpdateCollectionResponse { + message: string; + collection: Collection; +} + +export interface DeleteCollectionResponse { + message: string; +} + +export interface GetDatasetCollectionsResponse { + collections: Collection[]; + count: number; +} diff --git a/src/services/collections.service.ts b/src/services/collections.service.ts new file mode 100644 index 0000000..be7bb47 --- /dev/null +++ b/src/services/collections.service.ts @@ -0,0 +1,198 @@ +import { + GetUserCollectionsResponse, + CreateCollectionResponse, + CreateCollectionPayload, + GetCollectionResponse, + AddDatasetResponse, + AddDatasetToCollectionPayload, + RemoveDatasetResponse, + UpdateCollectionResponse, + UpdateCollectionPayload, + DeleteCollectionResponse, + GetDatasetCollectionsResponse, + RemoveDatasetFromCollectionPayload, + GetDatasetCollectionsPayload, +} from "../redux/collections/types/collections.interface"; + +const API_URL = process.env.REACT_APP_API_URL || "http://localhost:5000/api/v1"; + +export const CollectionsService = { + // Get all user's collections + getUserCollections: async (): Promise => { + const response = await fetch(`${API_URL}/collections/me/collections`, { + method: "GET", + credentials: "include", + }); + + const data = await response.json(); + + if (!response.ok) { + throw new Error(data.message || "Failed to fetch collections"); + } + + return data; + }, + + // Create new collection + createCollection: async ( + payload: CreateCollectionPayload + ): Promise => { + const response = await fetch(`${API_URL}/collections/collections`, { + method: "POST", + headers: { + "Content-Type": "application/json", + }, + credentials: "include", + body: JSON.stringify(payload), + }); + + const data = await response.json(); + + if (!response.ok) { + throw new Error(data.message || "Failed to create collection"); + } + + return data; + }, + + // Get specific collection with datasets + getCollection: async ( + collectionId: number + ): Promise => { + const response = await fetch( + `${API_URL}/collections/collections/${collectionId}`, + { + method: "GET", + credentials: "include", + } + ); + + const data = await response.json(); + + if (!response.ok) { + throw new Error(data.message || "Failed to fetch collection"); + } + + return data; + }, + + // Add dataset to collection + addDatasetToCollection: async ( + payload: AddDatasetToCollectionPayload + ): Promise => { + const response = await fetch( + `${API_URL}/collections/collections/${payload.collectionId}/datasets`, + { + method: "POST", + headers: { + "Content-Type": "application/json", + }, + credentials: "include", + body: JSON.stringify({ + dbName: payload.dbName, + datasetId: payload.datasetId, + }), + } + ); + + const data = await response.json(); + + if (!response.ok) { + throw new Error(data.message || "Failed to add dataset to collection"); + } + + return data; + }, + + // Remove dataset from collection + removeDatasetFromCollection: async ( + payload: RemoveDatasetFromCollectionPayload + ): Promise => { + const response = await fetch( + `${API_URL}/collections/collections/${payload.collectionId}/datasets/${payload.datasetId}`, + { + method: "DELETE", + credentials: "include", + } + ); + + const data = await response.json(); + + if (!response.ok) { + throw new Error( + data.message || "Failed to remove dataset from collection" + ); + } + + return data; + }, + + // Update collection + updateCollection: async ( + payload: UpdateCollectionPayload + ): Promise => { + const { collectionId, ...updates } = payload; + + const response = await fetch( + `${API_URL}/collections/collections/${collectionId}`, + { + method: "PUT", + headers: { + "Content-Type": "application/json", + }, + credentials: "include", + body: JSON.stringify(updates), + } + ); + + const data = await response.json(); + + if (!response.ok) { + throw new Error(data.message || "Failed to update collection"); + } + + return data; + }, + + // Delete collection + deleteCollection: async ( + collectionId: number + ): Promise => { + const response = await fetch( + `${API_URL}/collections/collections/${collectionId}`, + { + method: "DELETE", + credentials: "include", + } + ); + + const data = await response.json(); + + if (!response.ok) { + throw new Error(data.message || "Failed to delete collection"); + } + + return data; + }, + + // Get which collections contain a specific dataset + getDatasetCollections: async ( + payload: GetDatasetCollectionsPayload + ): Promise => { + const response = await fetch( + `${API_URL}/collections/datasets/${payload.dbName}/${payload.datasetId}/collections`, + { + method: "GET", + credentials: "include", + } + ); + + const data = await response.json(); + + if (!response.ok) { + throw new Error(data.message || "Failed to fetch dataset collections"); + } + + return data; + }, +}; From abd483db9c18a2c0296997cb5f56638d50707107 Mon Sep 17 00:00:00 2001 From: elainefan331 Date: Wed, 28 Jan 2026 11:16:57 -0500 Subject: [PATCH 15/65] feat: add collections slice file --- src/redux/collections/collections.selector.ts | 0 src/redux/collections/collections.slice.ts | 153 ++++++++++++++++++ 2 files changed, 153 insertions(+) create mode 100644 src/redux/collections/collections.selector.ts create mode 100644 src/redux/collections/collections.slice.ts diff --git a/src/redux/collections/collections.selector.ts b/src/redux/collections/collections.selector.ts new file mode 100644 index 0000000..e69de29 diff --git a/src/redux/collections/collections.slice.ts b/src/redux/collections/collections.slice.ts new file mode 100644 index 0000000..1e6324f --- /dev/null +++ b/src/redux/collections/collections.slice.ts @@ -0,0 +1,153 @@ +import { + getUserCollections, + createCollection, + getCollection, + addDatasetToCollection, + removeDatasetFromCollection, + updateCollection, + deleteCollection, + getDatasetCollections, +} from "./collections.action"; +import { CollectionsState } from "./types/collections.interface"; +import { createSlice } from "@reduxjs/toolkit"; + +const initialState: CollectionsState = { + collections: [], + currentCollection: null, + datasetCollections: [], + error: null, + loading: false, + isCreating: false, + isAdding: false, +}; + +const collectionsSlice = createSlice({ + name: "collections", + initialState, + reducers: { + clearError: (state) => { + state.error = null; + }, + clearCurrentCollection: (state) => { + state.currentCollection = null; + }, + }, + extraReducers: (builder) => { + builder + // Get User Collections + .addCase(getUserCollections.pending, (state) => { + state.loading = true; + state.error = null; + }) + .addCase(getUserCollections.fulfilled, (state, action) => { + state.collections = action.payload; + state.loading = false; + }) + .addCase(getUserCollections.rejected, (state, action) => { + state.loading = false; + state.error = action.payload as string; + }) + + // Create Collection + .addCase(createCollection.pending, (state) => { + state.isCreating = true; + state.error = null; + }) + .addCase(createCollection.fulfilled, (state, action) => { + state.collections = [action.payload, ...state.collections]; + state.isCreating = false; + }) + .addCase(createCollection.rejected, (state, action) => { + state.isCreating = false; + state.error = action.payload as string; + }) + + // Get Collection + .addCase(getCollection.pending, (state) => { + state.loading = true; + state.error = null; + }) + .addCase(getCollection.fulfilled, (state, action) => { + state.currentCollection = action.payload; + state.loading = false; + }) + .addCase(getCollection.rejected, (state, action) => { + state.loading = false; + state.error = action.payload as string; + }) + + // Add Dataset to Collection + .addCase(addDatasetToCollection.pending, (state) => { + state.isAdding = true; + state.error = null; + }) + .addCase(addDatasetToCollection.fulfilled, (state, action) => { + state.isAdding = false; + // Component will refetch collections list + }) + .addCase(addDatasetToCollection.rejected, (state, action) => { + state.isAdding = false; + state.error = action.payload as string; + }) + + // Remove Dataset from Collection + .addCase(removeDatasetFromCollection.pending, (state) => { + state.loading = true; + state.error = null; + }) + .addCase(removeDatasetFromCollection.fulfilled, (state) => { + state.loading = false; + // Component will refetch collections list + }) + .addCase(removeDatasetFromCollection.rejected, (state, action) => { + state.loading = false; + state.error = action.payload as string; + }) + + // Update Collection + .addCase(updateCollection.pending, (state) => { + state.loading = true; + state.error = null; + }) + .addCase(updateCollection.fulfilled, (state) => { + state.loading = false; + // Component will refetch collections list + }) + .addCase(updateCollection.rejected, (state, action) => { + state.loading = false; + state.error = action.payload as string; + }) + + // Delete Collection + .addCase(deleteCollection.pending, (state) => { + state.loading = true; + state.error = null; + }) + .addCase(deleteCollection.fulfilled, (state) => { + state.loading = false; + // Component will refetch or navigate away + }) + .addCase(deleteCollection.rejected, (state, action) => { + state.loading = false; + state.error = action.payload as string; + }) + + // Get Dataset Collections (for "Add to Collection" menu) + .addCase(getDatasetCollections.pending, (state) => { + state.loading = true; + state.error = null; + }) + .addCase(getDatasetCollections.fulfilled, (state, action) => { + state.datasetCollections = action.payload; + state.loading = false; + }) + .addCase(getDatasetCollections.rejected, (state, action) => { + state.loading = false; + state.error = action.payload as string; + }); + }, +}); + +export const { clearError, clearCurrentCollection } = collectionsSlice.actions; + +export default collectionsSlice.reducer; From 713cefe7098c2e1ed06e05b12f8986cf2fee709b Mon Sep 17 00:00:00 2001 From: elainefan331 Date: Wed, 28 Jan 2026 11:22:51 -0500 Subject: [PATCH 16/65] feat: add collection selector file --- src/redux/collections/collections.selector.ts | 45 +++++++++++++++++++ src/redux/store.ts | 2 + 2 files changed, 47 insertions(+) diff --git a/src/redux/collections/collections.selector.ts b/src/redux/collections/collections.selector.ts index e69de29..b6fd05f 100644 --- a/src/redux/collections/collections.selector.ts +++ b/src/redux/collections/collections.selector.ts @@ -0,0 +1,45 @@ +import { RootState } from "../store"; + +// Main selector +export const CollectionsSelector = (state: RootState) => state.collections; + +// Get all user's collections +export const selectUserCollections = (state: RootState) => { + return state.collections.collections; +}; + +// Get current collection being viewed +export const selectCurrentCollection = (state: RootState) => { + return state.collections.currentCollection; +}; + +// Get collections that contain a specific dataset (for menu) +export const selectDatasetCollections = (state: RootState) => { + return state.collections.datasetCollections; +}; + +// Get loading states +export const selectCollectionsLoading = (state: RootState): boolean => { + return state.collections.loading; +}; + +export const selectIsCreatingCollection = (state: RootState): boolean => { + return state.collections.isCreating; +}; + +export const selectIsAddingToCollection = (state: RootState): boolean => { + return state.collections.isAdding; +}; + +// Get error +export const selectCollectionsError = (state: RootState): string | null => { + return state.collections.error; +}; + +// Get collection by ID (from cached list) +export const selectCollectionById = ( + state: RootState, + collectionId: number +) => { + return state.collections.collections.find((c) => c.id === collectionId); +}; diff --git a/src/redux/store.ts b/src/redux/store.ts index e1e41a1..67b0e6e 100644 --- a/src/redux/store.ts +++ b/src/redux/store.ts @@ -1,5 +1,6 @@ import activitiesReducer from "./activities/activities.slice"; import authReducer from "./auth/auth.slice"; +import collectionsReducer from "./collections/collections.slice"; import neurojsonReducer from "./neurojson/neurojson.slice"; import { configureStore, @@ -12,6 +13,7 @@ const appReducer = combineReducers({ neurojson: neurojsonReducer, // Add other slices here as needed auth: authReducer, activities: activitiesReducer, + collections: collectionsReducer, }); export const rootReducer = ( From f1ffe0ad9859428335e5bc036f7e5819b830e194 Mon Sep 17 00:00:00 2001 From: elainefan331 Date: Wed, 28 Jan 2026 12:39:26 -0500 Subject: [PATCH 17/65] feat: add collection menu to save button in dataset page --- .../DatasetDetailPage/DatasetAction.tsx | 270 +++++++++++++++++- src/pages/UpdatedDatasetDetailPage.tsx | 199 +++++++++---- 2 files changed, 403 insertions(+), 66 deletions(-) diff --git a/src/components/DatasetDetailPage/DatasetAction.tsx b/src/components/DatasetDetailPage/DatasetAction.tsx index 1935a19..6c8d256 100644 --- a/src/components/DatasetDetailPage/DatasetAction.tsx +++ b/src/components/DatasetDetailPage/DatasetAction.tsx @@ -1,5 +1,7 @@ +import AddIcon from "@mui/icons-material/Add"; import BookmarkIcon from "@mui/icons-material/Bookmark"; import BookmarkBorderIcon from "@mui/icons-material/BookmarkBorder"; +import CheckIcon from "@mui/icons-material/Check"; import FavoriteIcon from "@mui/icons-material/Favorite"; import FavoriteBorderIcon from "@mui/icons-material/FavoriteBorder"; import { @@ -9,6 +11,17 @@ import { Typography, Snackbar, Alert, + Menu, + MenuItem, + ListItemIcon, + ListItemText, + Divider, + Dialog, + DialogTitle, + DialogContent, + DialogActions, + TextField, + Tooltip, } from "@mui/material"; import { Colors } from "design/theme"; import React, { useState } from "react"; @@ -22,7 +35,13 @@ interface DatasetActionsProps { isSaveLoading: boolean; isAuthenticated: boolean; onLikeToggle: () => void; - onSaveToggle: () => void; + // onSaveToggle: () => void; + // New collection props + collections: Array<{ id: number; name: string; isInCollection: boolean }>; + onCreateCollection: (name: string, description?: string) => void; + onAddToCollection: (collectionId: number) => void; + // onRemoveFromCollection: (collectionId: number) => void; + isLoadingCollections: boolean; } const DatasetActions: React.FC = ({ @@ -34,13 +53,90 @@ const DatasetActions: React.FC = ({ isSaveLoading, isAuthenticated, onLikeToggle, - onSaveToggle, + // onSaveToggle, + // Collections props + collections, + onCreateCollection, + onAddToCollection, + // onRemoveFromCollection, + isLoadingCollections, }) => { const [showLoginAlert, setShowLoginAlert] = useState(false); + // Collection menu state + const [saveMenuAnchor, setSaveMenuAnchor] = useState( + null + ); + const [createDialogOpen, setCreateDialogOpen] = useState(false); + const [newCollectionName, setNewCollectionName] = useState(""); + const [newCollectionDescription, setNewCollectionDescription] = useState(""); + const [showAlreadyInMessage, setShowAlreadyInMessage] = useState(false); + const [selectedCollectionName, setSelectedCollectionName] = useState(""); + const handleUnauthenticatedClick = () => { setShowLoginAlert(true); }; + + // Handle save button click - open menu instead of toggle + const handleSaveClick = (event: React.MouseEvent) => { + if (!isAuthenticated) { + handleUnauthenticatedClick(); + return; + } + setSaveMenuAnchor(event.currentTarget); + }; + + // Close menu + const handleCloseMenu = () => { + setSaveMenuAnchor(null); + setShowAlreadyInMessage(false); + }; + + // Toggle dataset in/out of collection + const handleCollectionClick = ( + collectionId: number, + isInCollection: boolean, + collectionName: string + ) => { + if (isInCollection) { + setSelectedCollectionName(collectionName); + setShowAlreadyInMessage(true); + setTimeout(() => setShowAlreadyInMessage(false), 3000); + // handleCloseMenu(); + } else { + onAddToCollection(collectionId); + handleCloseMenu(); + setShowAlreadyInMessage(false); + } + // handleCloseMenu(); + }; + + // Open create dialog + const handleCreateNew = () => { + setCreateDialogOpen(true); + handleCloseMenu(); + }; + + // Create new collection + const handleCreateSubmit = () => { + if (newCollectionName.trim()) { + onCreateCollection( + newCollectionName.trim(), + newCollectionDescription.trim() || undefined + ); + setNewCollectionName(""); + setNewCollectionDescription(""); + setCreateDialogOpen(false); + } + }; + + // Cancel create dialog + const handleCreateCancel = () => { + setNewCollectionName(""); + setNewCollectionDescription(""); + setCreateDialogOpen(false); + }; + return ( <> = ({ {/* Save Button */} - */} + + {/* Save Button - Now opens menu */} + + {/* Collections Menu */} + + + Save to Collection + + + + {/* ✅ Show message inside menu */} + {showAlreadyInMessage && ( + <> + + + ✓ Already in "{selectedCollectionName}" + + + + + )} + + {collections.length === 0 ? ( + + No collections yet + + ) : ( + collections.map((collection) => ( + + handleCollectionClick( + collection.id, + collection.isInCollection, + collection.name + ) + } + sx={{ fontSize: "0.875rem" }} + > + {collection.isInCollection && ( + + + + )} + + + )) + )} + + + + + + + + + + {/* Views Count Display */} {viewsCount > 0 && ( = ({ )} + + {/* Create Collection Dialog */} + + Create New Collection + + setNewCollectionName(e.target.value)} + sx={{ mb: 2, mt: 1 }} + /> + setNewCollectionDescription(e.target.value)} + /> + + + + + + + {/* Login Alert */} { error, datasetViewInfo: dbViewInfo, } = useAppSelector(NeurojsonSelector); - // user activities - // ✅ Add auth state + // user auth state const { isLoggedIn: isAuthenticated } = useAppSelector(AuthSelector); - // ✅ Add activities state - with safe defaults + // Collections state + const userCollections = useAppSelector(selectUserCollections); + const datasetCollections = useAppSelector(selectDatasetCollections); + const isCreatingCollection = useAppSelector(selectIsCreatingCollection); + const isAddingToCollection = useAppSelector(selectIsAddingToCollection); + const isLoadingCollections = useAppSelector(selectCollectionsLoading); + + // activities state - with safe defaults const isLiked = useAppSelector((state) => dbName && docId ? selectIsDatasetLiked(state, dbName, docId) : false ); @@ -127,21 +145,21 @@ const UpdatedDatasetDetailPage: React.FC = () => { }; // Handle save/unsave - const handleSaveToggle = async () => { - if (!dbName || !docId) return; - - try { - if (isSaved) { - await dispatch(unsaveDataset({ dbName, datasetId: docId })).unwrap(); - } else { - await dispatch(saveDataset({ dbName, datasetId: docId })).unwrap(); - } - } catch (error) { - console.error("Error toggling save:", error); - } - }; + // const handleSaveToggle = async () => { + // if (!dbName || !docId) return; + + // try { + // if (isSaved) { + // await dispatch(unsaveDataset({ dbName, datasetId: docId })).unwrap(); + // } else { + // await dispatch(saveDataset({ dbName, datasetId: docId })).unwrap(); + // } + // } catch (error) { + // console.error("Error toggling save:", error); + // } + // }; - // ✅ Add this useEffect to load user activity status and stats + // useEffect to load user activity status and stats useEffect(() => { if (!dbName || !docId) return; @@ -151,9 +169,95 @@ const UpdatedDatasetDetailPage: React.FC = () => { // Check if user has liked/saved this dataset (only if authenticated) if (isAuthenticated) { dispatch(checkUserActivity({ dbName, datasetId: docId })); + // Load user's collections + dispatch(getUserCollections()); + + // Check which collections contain this dataset + dispatch(getDatasetCollections({ dbName, datasetId: docId })); } }, [dbName, docId, isAuthenticated, dispatch]); + // Merge user collections with dataset collections to show checkmark + const collectionsForMenu = userCollections.map((collection) => ({ + id: collection.id, + name: collection.name, + isInCollection: datasetCollections.some((dc) => dc.id === collection.id), // if true --> has checkmark + })); + + // Check if dataset is in any collection + const isInAnyCollection = datasetCollections.length > 0; + + const handleCreateCollection = async (name: string, description?: string) => { + if (!dbName || !docId) return; + + try { + const newCollection = await dispatch( + createCollection({ name, description }) + ).unwrap(); + + // After creating, add this dataset to the new collection + await dispatch( + addDatasetToCollection({ + collectionId: newCollection.id, + dbName, + datasetId: docId, + }) + ).unwrap(); + + // Refetch collections + dispatch(getUserCollections()); + dispatch(getDatasetCollections({ dbName, datasetId: docId })); + } catch (error) { + console.error("Error creating collection:", error); + } + }; + + const handleAddToCollection = async (collectionId: number) => { + if (!dbName || !docId) return; + + try { + await dispatch( + addDatasetToCollection({ + collectionId, + dbName, + datasetId: docId, + }) + ).unwrap(); + + // Refetch to update menu + dispatch(getDatasetCollections({ dbName, datasetId: docId })); + } catch (error) { + console.error("Error adding to collection:", error); + } + }; + + // const handleRemoveFromCollection = async (collectionId: number) => { + // if (!dbName || !docId) return; + + // try { + // // Find the dataset's database ID from the collections + // const collection = datasetCollections.find((c) => c.id === collectionId); + // if (!collection || !collection.datasets) return; + + // const dataset = collection.datasets.find( + // (ds) => ds.couch_db === dbName && ds.ds_id === docId + // ); + // if (!dataset) return; + + // await dispatch( + // removeDatasetFromCollection({ + // collectionId, + // datasetId: dataset.id, + // }) + // ).unwrap(); + + // // Refetch to update menu + // dispatch(getDatasetCollections({ dbName, datasetId: docId })); + // } catch (error) { + // console.error("Error removing from collection:", error); + // } + // }; + // get params from url const [searchParams, setSearchParams] = useSearchParams(); const focus = searchParams.get("focus") || undefined; // get highlight from url @@ -170,14 +274,6 @@ const UpdatedDatasetDetailPage: React.FC = () => { const [jsonSize, setJsonSize] = useState(0); const [previewIndex, setPreviewIndex] = useState(0); const [isPreviewLoading, setIsPreviewLoading] = useState(false); - // const [copiedToast, setCopiedToast] = useState<{ - // open: boolean; - // text: string; - // }>({ - // open: false, - // text: "", - // }); - // const [copiedUrlOpen, setCopiedUrlOpen] = useState(false); const [copiedKey, setCopiedKey] = useState(null); const copyTimer = useRef(null); const aiSummary = datasetDocument?.[".datainfo"]?.AISummary ?? ""; @@ -199,17 +295,6 @@ const UpdatedDatasetDetailPage: React.FC = () => { ); const treeTitle = "Files"; - // const filesCount = externalLinks.length; - // const totalBytes = useMemo(() => { - // let bytes = 0; - // for (const l of externalLinks) { - // const m = l.url.match(/size=(\d+)/); - // if (m) bytes += parseInt(m[1], 10); - // } - // return bytes; - // }, [externalLinks]); - - // add spinner const formatSize = (sizeInBytes: number): string => { if (sizeInBytes < 1024) { @@ -371,12 +456,6 @@ const UpdatedDatasetDetailPage: React.FC = () => { } }; - // const handleUrlCopyClick = async (e: React.MouseEvent, path: string) => { - // await copyPreviewUrl(path); - // setCopiedUrlOpen(true); - // setTimeout(() => setCopiedUrlOpen(false), 2500); - // }; - const handleUrlCopyClick = async ( e: React.MouseEvent, path: string @@ -772,22 +851,6 @@ const UpdatedDatasetDetailPage: React.FC = () => { return ( <> - {/* */} - {/* Breadcrumb Navigation (Home → Database → Dataset) */} { {/* user actions component */} {/* ai summary */} {aiSummary ? ( From d72e67b0e598290f9207f0ef8658154b9fc5455b Mon Sep 17 00:00:00 2001 From: elainefan331 Date: Wed, 28 Jan 2026 14:44:55 -0500 Subject: [PATCH 18/65] feat: update the like and save buttons to non-login user --- .../DatasetDetailPage/DatasetAction.tsx | 244 +++++++++++++----- src/design/theme.ts | 1 + 2 files changed, 177 insertions(+), 68 deletions(-) diff --git a/src/components/DatasetDetailPage/DatasetAction.tsx b/src/components/DatasetDetailPage/DatasetAction.tsx index 6c8d256..9e33d14 100644 --- a/src/components/DatasetDetailPage/DatasetAction.tsx +++ b/src/components/DatasetDetailPage/DatasetAction.tsx @@ -1,5 +1,5 @@ import AddIcon from "@mui/icons-material/Add"; -import BookmarkIcon from "@mui/icons-material/Bookmark"; +import BookmarkAddedIcon from "@mui/icons-material/BookmarkAdded"; import BookmarkBorderIcon from "@mui/icons-material/BookmarkBorder"; import CheckIcon from "@mui/icons-material/Check"; import FavoriteIcon from "@mui/icons-material/Favorite"; @@ -21,7 +21,6 @@ import { DialogContent, DialogActions, TextField, - Tooltip, } from "@mui/material"; import { Colors } from "design/theme"; import React, { useState } from "react"; @@ -35,12 +34,9 @@ interface DatasetActionsProps { isSaveLoading: boolean; isAuthenticated: boolean; onLikeToggle: () => void; - // onSaveToggle: () => void; - // New collection props collections: Array<{ id: number; name: string; isInCollection: boolean }>; onCreateCollection: (name: string, description?: string) => void; onAddToCollection: (collectionId: number) => void; - // onRemoveFromCollection: (collectionId: number) => void; isLoadingCollections: boolean; } @@ -53,12 +49,9 @@ const DatasetActions: React.FC = ({ isSaveLoading, isAuthenticated, onLikeToggle, - // onSaveToggle, - // Collections props collections, onCreateCollection, onAddToCollection, - // onRemoveFromCollection, isLoadingCollections, }) => { const [showLoginAlert, setShowLoginAlert] = useState(false); @@ -77,6 +70,104 @@ const DatasetActions: React.FC = ({ setShowLoginAlert(true); }; + // Add early return for non-authenticated users + if (!isAuthenticated) { + return ( + <> + + {/* Read-only Like Button */} + + + {/* Read-only Save Button */} + + + {/* Views Count */} + {viewsCount > 0 && ( + + {viewsCount} {viewsCount === 1 ? "view" : "views"} + + )} + + + {/* Login Alert for non-authenticated users */} + setShowLoginAlert(false)} + anchorOrigin={{ vertical: "bottom", horizontal: "center" }} + > + setShowLoginAlert(false)} + severity="info" + sx={{ + backgroundColor: Colors.black, + color: Colors.green, + border: `1px solid ${Colors.black}`, + width: "100%", + + // icon color + "& .MuiAlert-icon": { + color: Colors.rose, + }, + }} + > + Please log in to like or save datasets + + + + ); + } + // Handle save button click - open menu instead of toggle const handleSaveClick = (event: React.MouseEvent) => { if (!isAuthenticated) { @@ -150,91 +241,61 @@ const DatasetActions: React.FC = ({ > {/* Like Button */} - {/* Save Button */} - {/* */} - {/* Save Button - Now opens menu */} + + + + {/* Empty State */} + {collections.length === 0 ? ( + + + + No Collections Yet + + + Create collections to organize your datasets + + + + ) : ( + // Collections List + + + {collections.map((collection, index) => ( + + {index > 0 && } + + + + + + {collection.name} + + + + } + secondary={ + <> + {collection.description && ( + + {collection.description} + + )} + + Created {formatDate(collection.created_at)} + + + } + /> + + + + + handleDeleteClick(collection.id, collection.name) + } + sx={{ + color: "error.main", + "&:hover": { + backgroundColor: "rgba(211, 47, 47, 0.1)", + }, + }} + > + + + + + + ))} + + + )} + + {/* Create Collection Dialog */} + + Create New Collection + + setNewCollectionName(e.target.value)} + sx={{ mb: 2, mt: 1 }} + /> + setNewCollectionDescription(e.target.value)} + /> + + + + + + + + {/* Delete Confirmation Dialog */} + + Delete Collection? + + + Are you sure you want to delete "{collectionToDelete?.name}"? + + + The datasets will not be deleted, only the collection. + + + + + + + + + ); +}; + +export default CollectionsTab; From b3fa232402eb5fb4cd10682ad5187efff618c83e Mon Sep 17 00:00:00 2001 From: elainefan331 Date: Thu, 29 Jan 2026 13:21:55 -0500 Subject: [PATCH 20/65] feat: add collection detail page --- src/components/Routes.tsx | 9 +- .../User/Dashboard/CollectionDetailPage.tsx | 346 ++++++++++++++++++ src/components/User/UserDashboard.tsx | 5 +- 3 files changed, 356 insertions(+), 4 deletions(-) create mode 100644 src/components/User/Dashboard/CollectionDetailPage.tsx diff --git a/src/components/Routes.tsx b/src/components/Routes.tsx index 3120159..824ed8a 100644 --- a/src/components/Routes.tsx +++ b/src/components/Routes.tsx @@ -1,13 +1,12 @@ import ScrollToTop from "./ScrollToTop"; import CompleteProfile from "./User/CompleteProfile"; +import CollectionDetailPage from "./User/Dashboard/CollectionDetailPage"; import ForgotPassword from "./User/ForgotPassword"; import ResetPassword from "./User/ResetPassword"; import UserDashboard from "./User/UserDashboard"; import FullScreen from "design/Layouts/FullScreen"; import AboutPage from "pages/AboutPage"; import DatabasePage from "pages/DatabasePage"; -import DatasetDetailPage from "pages/DatasetDetailPage"; -import DatasetPage from "pages/DatasetPage"; import Home from "pages/Home"; import ResendVerification from "pages/ResendVerification"; import SearchPage from "pages/SearchPage"; @@ -61,6 +60,12 @@ const Routes = () => ( {/* Dashboard Page */} } /> + + {/* Collection detail page */} + } + /> diff --git a/src/components/User/Dashboard/CollectionDetailPage.tsx b/src/components/User/Dashboard/CollectionDetailPage.tsx new file mode 100644 index 0000000..46e8eeb --- /dev/null +++ b/src/components/User/Dashboard/CollectionDetailPage.tsx @@ -0,0 +1,346 @@ +import { + Home, + Folder, + Visibility, + Delete, + ArrowBack, +} from "@mui/icons-material"; +import { + Box, + Container, + Typography, + Paper, + CircularProgress, + Alert, + List, + ListItem, + ListItemText, + Divider, + Button, + Chip, + IconButton, + Breadcrumbs, + Link, + Dialog, + DialogTitle, + DialogContent, + DialogActions, +} from "@mui/material"; +import { Colors } from "design/theme"; +import { useAppDispatch } from "hooks/useAppDispatch"; +import { useAppSelector } from "hooks/useAppSelector"; +import React, { useEffect } from "react"; +import { useParams, useNavigate } from "react-router-dom"; +import { AuthSelector } from "redux/auth/auth.selector"; +import { + getCollection, + removeDatasetFromCollection, +} from "redux/collections/collections.action"; +import { + selectCurrentCollection, + selectCollectionsLoading, + selectCollectionsError, +} from "redux/collections/collections.selector"; + +const CollectionDetailPage: React.FC = () => { + const { collectionId } = useParams<{ collectionId: string }>(); + const { user } = useAppSelector(AuthSelector); + const navigate = useNavigate(); + const dispatch = useAppDispatch(); + + const collection = useAppSelector(selectCurrentCollection); + const loading = useAppSelector(selectCollectionsLoading); + const error = useAppSelector(selectCollectionsError); + + // ✅ Add state for delete confirmation dialog + const [deleteDialogOpen, setDeleteDialogOpen] = React.useState(false); + const [datasetToDelete, setDatasetToDelete] = React.useState<{ + id: number; + name: string; + } | null>(null); + + if (!user) { + return ( + + + Please log in to access your dashboard. + + + ); + } + + useEffect(() => { + if (collectionId) { + dispatch(getCollection({ collectionId: parseInt(collectionId) })); + } + }, [collectionId, dispatch]); + + const handleViewDataset = (dbName: string, datasetId: string) => { + navigate(`/db/${dbName}/${datasetId}`); + }; + + // ✅ Open delete confirmation + const handleDeleteClick = (datasetId: number, datasetName: string) => { + setDatasetToDelete({ id: datasetId, name: datasetName }); + setDeleteDialogOpen(true); + }; + + // ✅ Confirm delete + const handleDeleteConfirm = async () => { + if (!collectionId || !datasetToDelete) return; + + try { + await dispatch( + removeDatasetFromCollection({ + collectionId: parseInt(collectionId), + datasetId: datasetToDelete.id, + }) + ).unwrap(); + + // Refetch collection + dispatch(getCollection({ collectionId: parseInt(collectionId) })); + + // Close dialog + setDeleteDialogOpen(false); + setDatasetToDelete(null); + } catch (error) { + console.error("Error removing dataset:", error); + } + }; + + // ✅ Cancel delete + const handleDeleteCancel = () => { + setDeleteDialogOpen(false); + setDatasetToDelete(null); + }; + + const formatDate = (dateString: string) => { + return new Date(dateString).toLocaleDateString("en-US", { + year: "numeric", + month: "short", + day: "numeric", + }); + }; + + if (loading && !collection) { + return ( + + + + + + ); + } + + if (error) { + return ( + + {error} + + ); + } + + if (!collection) { + return ( + + Collection not found + + + ); + } + + const datasets = collection.datasets || []; + + return ( + + {/* Breadcrumbs */} + + navigate("/")} + sx={{ + display: "flex", + alignItems: "center", + gap: 0.5, + color: Colors.white, + textDecoration: "none", + "&:hover": { textDecoration: "underline" }, + }} + > + + Home + + navigate("/dashboard")} + sx={{ + color: Colors.white, + textDecoration: "none", + "&:hover": { textDecoration: "underline" }, + }} + > + Dashboard + + {collection.name} + + + {/* Back Button */} + + + {/* Collection Header */} + + + + + + {collection.name} + + {collection.description && ( + + {collection.description} + + )} + + Created {formatDate(collection.created_at)} • {datasets.length}{" "} + {datasets.length === 1 ? "dataset" : "datasets"} + + + + + + {/* Datasets List */} + {datasets.length === 0 ? ( + + + No datasets in this collection + + + Add datasets to this collection from any dataset page + + + ) : ( + + + {datasets.map((dataset, index) => ( + + {index > 0 && } + + + + + {dataset.ds_id} + + + + } + secondary={ + dataset.CollectionDataset?.created_at && + `Added ${formatDate( + dataset.CollectionDataset.created_at + )}` + } + /> + + + + + handleDeleteClick(dataset.id, dataset.ds_id) + } + sx={{ + color: "error.main", + "&:hover": { + backgroundColor: "rgba(211, 47, 47, 0.1)", + }, + }} + > + + + + + + ))} + + + )} + + {/* Delete Dataset from Collection Confirmation Dialog */} + + Remove Dataset from Collection? + + + Remove "{datasetToDelete?.name}" from this collection? + + + The dataset will not be deleted from NeuroJSON, only removed from + this collection. + + + + + + + + + ); +}; + +export default CollectionDetailPage; diff --git a/src/components/User/UserDashboard.tsx b/src/components/User/UserDashboard.tsx index 2454645..bcb1972 100644 --- a/src/components/User/UserDashboard.tsx +++ b/src/components/User/UserDashboard.tsx @@ -1,3 +1,4 @@ +import CollectionsTab from "./Dashboard/CollectionsTab"; import ProfileTab from "./Dashboard/ProfileTab"; import SavedDatasetsTab from "./Dashboard/SavedDatasetsTab"; import SecurityTab from "./Dashboard/SecurityTab"; @@ -134,7 +135,7 @@ const UserDashboard: React.FC = () => { /> } - label="Saved" + label="Collections" id="dashboard-tab-2" aria-controls="dashboard-tabpanel-2" /> @@ -159,7 +160,7 @@ const UserDashboard: React.FC = () => { - + From 1d445a2f18b816b9188d67545ed9c3049f186ab1 Mon Sep 17 00:00:00 2001 From: elainefan331 Date: Fri, 30 Jan 2026 09:52:48 -0500 Subject: [PATCH 21/65] feat: adjust the color of text in collection tab --- .../User/Dashboard/CollectionsTab.tsx | 67 +++++++++++++++++-- 1 file changed, 62 insertions(+), 5 deletions(-) diff --git a/src/components/User/Dashboard/CollectionsTab.tsx b/src/components/User/Dashboard/CollectionsTab.tsx index c187535..3ba6afb 100644 --- a/src/components/User/Dashboard/CollectionsTab.tsx +++ b/src/components/User/Dashboard/CollectionsTab.tsx @@ -296,7 +296,9 @@ const CollectionsTab: React.FC = ({ userId }) => { maxWidth="sm" fullWidth > - Create New Collection + + Create New Collection + = ({ userId }) => { variant="outlined" value={newCollectionName} onChange={(e) => setNewCollectionName(e.target.value)} - sx={{ mb: 2, mt: 1 }} + sx={{ + mb: 2, + mt: 1, + // focused label color + "& .MuiInputLabel-root.Mui-focused": { + color: Colors.purple, + }, + + // focused outline color + "& .MuiOutlinedInput-root.Mui-focused .MuiOutlinedInput-notchedOutline": + { + borderColor: Colors.purple, + }, + + // optional: hover outline color + "& .MuiOutlinedInput-root:hover .MuiOutlinedInput-notchedOutline": + { + borderColor: Colors.purple, + }, + }} /> = ({ userId }) => { rows={3} value={newCollectionDescription} onChange={(e) => setNewCollectionDescription(e.target.value)} + sx={{ + // focused label color + "& .MuiInputLabel-root.Mui-focused": { + color: Colors.purple, + }, + + // focused outline color + "& .MuiOutlinedInput-root.Mui-focused .MuiOutlinedInput-notchedOutline": + { + borderColor: Colors.purple, + }, + + // optional: hover outline color + "& .MuiOutlinedInput-root:hover .MuiOutlinedInput-notchedOutline": + { + borderColor: Colors.purple, + }, + }} /> - + + + diff --git a/src/components/User/Dashboard/CollectionsTab.tsx b/src/components/User/Dashboard/CollectionsTab.tsx index 3ba6afb..5d3d730 100644 --- a/src/components/User/Dashboard/CollectionsTab.tsx +++ b/src/components/User/Dashboard/CollectionsTab.tsx @@ -1,4 +1,4 @@ -import { Folder, Visibility, Add, Delete } from "@mui/icons-material"; +import { Folder, Visibility, Add, Delete, Edit } from "@mui/icons-material"; import { Box, Typography, @@ -17,6 +17,8 @@ import { DialogContent, DialogActions, TextField, + Checkbox, + FormControlLabel, } from "@mui/material"; import { Colors } from "design/theme"; import { useAppDispatch } from "hooks/useAppDispatch"; @@ -27,6 +29,7 @@ import { getUserCollections, createCollection, deleteCollection, + updateCollection, } from "redux/collections/collections.action"; import { selectUserCollections, @@ -56,6 +59,14 @@ const CollectionsTab: React.FC = ({ userId }) => { id: number; name: string; } | null>(null); + // edit dialog state + const [editDialogOpen, setEditDialogOpen] = useState(false); + const [editingCollection, setEditingCollection] = useState<{ + id: number; + name: string; + description: string; + is_public: boolean; + } | null>(null); useEffect(() => { dispatch(getUserCollections()); @@ -119,6 +130,46 @@ const CollectionsTab: React.FC = ({ userId }) => { setCollectionToDelete(null); }; + // Open edit dialog + const handleEditClick = (collection: any) => { + setEditingCollection({ + id: collection.id, + name: collection.name, + description: collection.description || "", + is_public: collection.is_public || false, + }); + setEditDialogOpen(true); + }; + + // Submit edit + const handleEditSubmit = async () => { + if (!editingCollection || !editingCollection.name.trim()) return; + + try { + await dispatch( + updateCollection({ + collectionId: editingCollection.id, + name: editingCollection.name.trim(), + description: editingCollection.description.trim() || undefined, + is_public: editingCollection.is_public, + }) + ).unwrap(); + + // Refetch collections + dispatch(getUserCollections()); + + handleEditClose(); + } catch (error) { + console.error("Error updating collection:", error); + } + }; + + // Close edit dialog + const handleEditClose = () => { + setEditDialogOpen(false); + setEditingCollection(null); + }; + const formatDate = (dateString: string) => { return new Date(dateString).toLocaleDateString("en-US", { year: "numeric", @@ -164,9 +215,27 @@ const CollectionsTab: React.FC = ({ userId }) => { variant="contained" startIcon={} onClick={handleCreateOpen} + // sx={{ + // backgroundColor: Colors.purple, + // "&:hover": { backgroundColor: Colors.secondaryPurple }, + // }} sx={{ - backgroundColor: Colors.purple, - "&:hover": { backgroundColor: Colors.secondaryPurple }, + background: `linear-gradient( + 135deg, + ${Colors.purple} 0%, + ${Colors.secondaryPurple} 100% + )`, + color: "#fff", + textTransform: "none", + + // keep gradient on hover + "&:hover": { + background: `linear-gradient( + 135deg, + ${Colors.secondaryPurple} 0%, + ${Colors.purple} 100% + )`, + }, }} > New Collection @@ -217,7 +286,9 @@ const CollectionsTab: React.FC = ({ userId }) => { - + {collection.name} @@ -251,6 +322,20 @@ const CollectionsTab: React.FC = ({ userId }) => { /> + {/* Edit button */} + handleEditClick(collection)} + sx={{ + color: Colors.purple, + "&:hover": { + backgroundColor: "rgba(128, 90, 213, 0.1)", + }, + }} + > + + + {/* view button */} + {/* Edit Collection Dialog */} + + + Edit Collection + + + + setEditingCollection( + editingCollection + ? { ...editingCollection, name: e.target.value } + : null + ) + } + sx={{ + mb: 2, + mt: 1, + // focused label color + "& .MuiInputLabel-root.Mui-focused": { + color: Colors.purple, + }, + // focused outline color + "& .MuiOutlinedInput-root.Mui-focused .MuiOutlinedInput-notchedOutline": + { + borderColor: Colors.purple, + }, + // optional: hover outline color + "& .MuiOutlinedInput-root:hover .MuiOutlinedInput-notchedOutline": + { + borderColor: Colors.purple, + }, + }} + /> + + setEditingCollection( + editingCollection + ? { ...editingCollection, description: e.target.value } + : null + ) + } + sx={{ + mb: 2, + // focused label color + "& .MuiInputLabel-root.Mui-focused": { + color: Colors.purple, + }, + + // focused outline color + "& .MuiOutlinedInput-root.Mui-focused .MuiOutlinedInput-notchedOutline": + { + borderColor: Colors.purple, + }, + + // optional: hover outline color + "& .MuiOutlinedInput-root:hover .MuiOutlinedInput-notchedOutline": + { + borderColor: Colors.purple, + }, + }} + /> + {/* + + setEditingCollection( + editingCollection + ? { ...editingCollection, is_public: e.target.checked } + : null + ) + } + style={{ width: 18, height: 18, cursor: "pointer" }} + /> + + */} + {/* ✅ Replace HTML checkbox with Material-UI Checkbox */} + ) => + setEditingCollection( + editingCollection + ? { ...editingCollection, is_public: e.target.checked } + : null + ) + } + sx={{ + color: Colors.purple, + "&.Mui-checked": { + color: Colors.purple, + }, + }} + /> + } + label="Make this collection public" + /> + + Public collections can be viewed by others (feature coming soon) + + + + + + + ); }; From 4a53f1ebe4688bea6495084a661f23b94f032cd2 Mon Sep 17 00:00:00 2001 From: elainefan331 Date: Fri, 30 Jan 2026 15:02:44 -0500 Subject: [PATCH 23/65] feat: add update profile controller --- backend/src/controllers/auth.controller.js | 82 ++++++++++++++++++++-- 1 file changed, 76 insertions(+), 6 deletions(-) diff --git a/backend/src/controllers/auth.controller.js b/backend/src/controllers/auth.controller.js index 45d7abf..5f6db30 100644 --- a/backend/src/controllers/auth.controller.js +++ b/backend/src/controllers/auth.controller.js @@ -150,10 +150,10 @@ const register = async (req, res) => { username: user.username, email: user.email, email_verified: user.email_verified, - firstName: user.first_name, // NEW - lastName: user.last_name, // NEW - company: user.company, // NEW - interests: user.interests, // NEW + firstName: user.first_name, + lastName: user.last_name, + company: user.company, + interests: user.interests, }, }); } catch (error) { @@ -354,7 +354,7 @@ const resendVerificationEmail = async (req, res) => { } }; -// NEW: Complete profile for OAuth users +// Complete profile for OAuth users const completeProfile = async (req, res) => { try { const { token, firstName, lastName, company, interests } = req.body; @@ -595,6 +595,75 @@ const resetPassword = async (req, res) => { } }; +// Update user profile +const updateProfile = async (req, res) => { + try { + const userId = req.user.id; + const { firstName, lastName, company, interests } = req.body; + + // Validate input + if (!firstName || !lastName || !company) { + return res.status(400).json({ + message: "First name, last name, and company/institution are required", + }); + } + + // Validate field lengths + if (firstName.trim().length < 1 || firstName.trim().length > 255) { + return res.status(400).json({ + message: "First name must be between 1 and 255 characters", + }); + } + if (lastName.trim().length < 1 || lastName.trim().length > 255) { + return res.status(400).json({ + message: "Last name must be between 1 and 255 characters", + }); + } + if (company.trim().length < 1 || company.trim().length > 255) { + return res.status(400).json({ + message: "Company/institution must be between 1 and 255 characters", + }); + } + + // Find user + const user = await User.findByPk(userId); + if (!user) { + return res.status(404).json({ message: "User not found" }); + } + + // Update profile + user.first_name = firstName.trim(); + user.last_name = lastName.trim(); + user.company = company.trim(); + user.interests = interests ? interests.trim() : null; + await user.save(); + + res.json({ + message: "Profile updated successfully", + user: { + id: user.id, + username: user.username, + email: user.email, + email_verified: user.email_verified, + firstName: user.first_name, + lastName: user.last_name, + company: user.company, + interests: user.interests, + isOAuthUser: !!(user.google_id || user.orcid_id || user.github_id), + hasPassword: !!user.hashed_password, + created_at: user.created_at, + updated_at: user.updated_at, + }, + }); + } catch (error) { + console.error("Update profile error:", error); + res.status(500).json({ + message: "Error updating profile", + error: error.message, + }); + } +}; + module.exports = { register, login, @@ -603,6 +672,7 @@ module.exports = { resendVerificationEmail, completeProfile, changePassword, - forgotPassword, // New + forgotPassword, resetPassword, + updateProfile, }; From f58fd75855e906415551c90394c00797bdb2ba7a Mon Sep 17 00:00:00 2001 From: elainefan331 Date: Fri, 30 Jan 2026 15:06:55 -0500 Subject: [PATCH 24/65] feat: add update profile route to the backend --- backend/src/routes/auth.routes.js | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/backend/src/routes/auth.routes.js b/backend/src/routes/auth.routes.js index ebbb82d..f7e2b70 100644 --- a/backend/src/routes/auth.routes.js +++ b/backend/src/routes/auth.routes.js @@ -11,6 +11,7 @@ const { changePassword, forgotPassword, resetPassword, + updateProfile, } = require("../controllers/auth.controller"); const { verifyEmail } = require("../controllers/verification.controller"); const { @@ -33,12 +34,15 @@ router.post("/logout", requireAuth, logout); router.get("/verify-email", verifyEmail); router.post("/resend-verification", resendVerificationEmail); -// NEW: Password management routes +// Password management routes router.post("/change-password", requireAuth, changePassword); router.post("/forgot-password", forgotPassword); router.post("/reset-password", resetPassword); -// NEW: OAuth profile completion route +// update profile route +router.put("/update-profile", requireAuth, updateProfile); + +// OAuth profile completion route router.post("/complete-profile", completeProfile); // Google OAuth routes From eb759c1785d7cc0e14becb62546e8817ae8f7822 Mon Sep 17 00:00:00 2001 From: elainefan331 Date: Fri, 30 Jan 2026 15:10:27 -0500 Subject: [PATCH 25/65] feat: add update profile to interface and service files --- src/redux/auth/types/auth.interface.ts | 12 ++++++++++++ src/services/auth.service.ts | 22 ++++++++++++++++++++++ 2 files changed, 34 insertions(+) diff --git a/src/redux/auth/types/auth.interface.ts b/src/redux/auth/types/auth.interface.ts index f4e4e65..3abc7e4 100644 --- a/src/redux/auth/types/auth.interface.ts +++ b/src/redux/auth/types/auth.interface.ts @@ -76,3 +76,15 @@ export interface ResetPasswordData { export interface ResetPasswordResponse { message: string; } + +export interface UpdateProfileData { + firstName: string; + lastName: string; + company: string; + interests?: string; +} + +export interface UpdateProfileResponse { + message: string; + user: User; +} diff --git a/src/services/auth.service.ts b/src/services/auth.service.ts index 524b49c..bdd90b7 100644 --- a/src/services/auth.service.ts +++ b/src/services/auth.service.ts @@ -10,6 +10,8 @@ import { ForgotPasswordResponse, ResetPasswordData, ResetPasswordResponse, + UpdateProfileData, + UpdateProfileResponse, } from "redux/auth/types/auth.interface"; const API_URL = process.env.REACT_APP_API_URL || "http://localhost:5000/api/v1"; @@ -147,4 +149,24 @@ export const AuthService = { return responseData; }, + updateProfile: async ( + profileData: UpdateProfileData + ): Promise => { + const response = await fetch(`${API_URL}/auth/update-profile`, { + method: "PUT", + headers: { + "Content-Type": "application/json", + }, + credentials: "include", + body: JSON.stringify(profileData), + }); + + const data = await response.json(); + + if (!response.ok) { + throw new Error(data.message || "Failed to update profile"); + } + + return data; + }, }; From 178af5e73a06103dc53f1c5404b10871d4cf055f Mon Sep 17 00:00:00 2001 From: elainefan331 Date: Fri, 30 Jan 2026 15:16:29 -0500 Subject: [PATCH 26/65] feat: add update profile to redux action and slice files --- src/redux/auth/auth.action.ts | 13 +++++++++++++ src/redux/auth/auth.slice.ts | 13 +++++++++++++ 2 files changed, 26 insertions(+) diff --git a/src/redux/auth/auth.action.ts b/src/redux/auth/auth.action.ts index dac57a3..c8241f6 100644 --- a/src/redux/auth/auth.action.ts +++ b/src/redux/auth/auth.action.ts @@ -4,6 +4,7 @@ import { ChangePasswordData, ForgotPasswordData, ResetPasswordData, + UpdateProfileData, } from "./types/auth.interface"; import { createAsyncThunk } from "@reduxjs/toolkit"; import { AuthService } from "services/auth.service"; @@ -96,3 +97,15 @@ export const resetPassword = createAsyncThunk( } } ); + +export const updateProfile = createAsyncThunk( + "auth/updateProfile", + async (profileData: UpdateProfileData, { rejectWithValue }) => { + try { + const response = await AuthService.updateProfile(profileData); + return response.user; + } catch (error: any) { + return rejectWithValue(error.message || "Failed to update profile"); + } + } +); diff --git a/src/redux/auth/auth.slice.ts b/src/redux/auth/auth.slice.ts index 51373e7..b06f7cd 100644 --- a/src/redux/auth/auth.slice.ts +++ b/src/redux/auth/auth.slice.ts @@ -6,6 +6,7 @@ import { changePassword, forgotPassword, resetPassword, + updateProfile, } from "./auth.action"; import { IAuthState, @@ -175,6 +176,18 @@ const authSlice = createSlice({ .addCase(resetPassword.rejected, (state, action) => { state.loading = false; state.error = action.payload as string; + }) + .addCase(updateProfile.pending, (state) => { + state.loading = true; + state.error = null; + }) + .addCase(updateProfile.fulfilled, (state, action) => { + state.user = action.payload; + state.loading = false; + }) + .addCase(updateProfile.rejected, (state, action) => { + state.loading = false; + state.error = action.payload as string; }); }, }); From 72623a7c7cd2d7cf7b11a755c3a1194c5b66ef20 Mon Sep 17 00:00:00 2001 From: elainefan331 Date: Fri, 30 Jan 2026 16:11:47 -0500 Subject: [PATCH 27/65] feat: add update profile feature to profile tab --- .../User/Dashboard/CollectionsTab.tsx | 23 +- src/components/User/Dashboard/ProfileTab.tsx | 285 ++++++++++++++++-- 2 files changed, 281 insertions(+), 27 deletions(-) diff --git a/src/components/User/Dashboard/CollectionsTab.tsx b/src/components/User/Dashboard/CollectionsTab.tsx index 5d3d730..e041449 100644 --- a/src/components/User/Dashboard/CollectionsTab.tsx +++ b/src/components/User/Dashboard/CollectionsTab.tsx @@ -215,10 +215,6 @@ const CollectionsTab: React.FC = ({ userId }) => { variant="contained" startIcon={} onClick={handleCreateOpen} - // sx={{ - // backgroundColor: Colors.purple, - // "&:hover": { backgroundColor: Colors.secondaryPurple }, - // }} sx={{ background: `linear-gradient( 135deg, @@ -460,8 +456,17 @@ const CollectionsTab: React.FC = ({ userId }) => { variant="contained" disabled={!newCollectionName.trim() || isCreating} sx={{ - backgroundColor: Colors.purple, - "&:hover": { backgroundColor: Colors.secondaryPurple }, + background: `linear-gradient(135deg, ${Colors.rose} 0%, ${Colors.purple} 100%)`, + color: "#fff", + "&:hover": { + background: `linear-gradient(135deg, ${Colors.purple} 0%, ${Colors.rose} 100%)`, + }, + "&.Mui-disabled": { + background: "linear-gradient(135deg, #e0e0e0 0%, #cfcfcf 100%)", + color: "#9e9e9e", + cursor: "not-allowed", + boxShadow: "none", + }, }} > {isCreating ? : "Create"} @@ -669,6 +674,12 @@ const CollectionsTab: React.FC = ({ userId }) => { "&:hover": { background: `linear-gradient(135deg, ${Colors.purple} 0%, ${Colors.rose} 100%)`, }, + "&.Mui-disabled": { + background: "linear-gradient(135deg, #e0e0e0 0%, #cfcfcf 100%)", + color: "#9e9e9e", + cursor: "not-allowed", + boxShadow: "none", + }, }} > {loading ? : "Save Changes"} diff --git a/src/components/User/Dashboard/ProfileTab.tsx b/src/components/User/Dashboard/ProfileTab.tsx index 03debf2..0c11a46 100644 --- a/src/components/User/Dashboard/ProfileTab.tsx +++ b/src/components/User/Dashboard/ProfileTab.tsx @@ -4,9 +4,28 @@ import { Business, CalendarToday, Verified, + Edit, + Save, + Cancel, } from "@mui/icons-material"; -import { Box, Typography, Grid, Paper, Chip, Divider } from "@mui/material"; -import React from "react"; +import { + Box, + Typography, + Grid, + Paper, + Chip, + Divider, + Button, + TextField, + CircularProgress, + Alert, +} from "@mui/material"; +import { Colors } from "design/theme"; +import { useAppDispatch } from "hooks/useAppDispatch"; +import { useAppSelector } from "hooks/useAppSelector"; +import React, { useState } from "react"; +import { updateProfile } from "redux/auth/auth.action"; +import { AuthSelector } from "redux/auth/auth.selector"; interface User { id: number; @@ -26,6 +45,18 @@ interface ProfileTabProps { } const ProfileTab: React.FC = ({ user }) => { + const dispatch = useAppDispatch(); + const { loading, error } = useAppSelector(AuthSelector); + + const [isEditing, setIsEditing] = useState(false); + const [editData, setEditData] = useState({ + firstName: user.firstName || "", + lastName: user.lastName || "", + company: user.company || "", + interests: user.interests || "", + }); + const [successMessage, setSuccessMessage] = useState(""); + const formatDate = (dateString?: string) => { if (!dateString) return "N/A"; return new Date(dateString).toLocaleDateString("en-US", { @@ -35,11 +66,124 @@ const ProfileTab: React.FC = ({ user }) => { }); }; + const handleEdit = () => { + setIsEditing(true); + setSuccessMessage(""); + }; + + const handleCancel = () => { + setIsEditing(false); + setEditData({ + firstName: user.firstName || "", + lastName: user.lastName || "", + company: user.company || "", + interests: user.interests || "", + }); + setSuccessMessage(""); + }; + + const handleChange = (e: React.ChangeEvent) => { + setEditData({ + ...editData, + [e.target.name]: e.target.value, + }); + }; + + const handleSave = async () => { + try { + await dispatch(updateProfile(editData)).unwrap(); + setIsEditing(false); + setSuccessMessage("Profile updated successfully!"); + setTimeout(() => setSuccessMessage(""), 3000); + } catch (error) { + console.error("Error updating profile:", error); + } + }; + return ( - + {/* Profile Information - + */} + {/* ✅ ADD: Header with Edit Button */} + + Profile Information + {!isEditing ? ( + + ) : ( + + + + + )} + + + {/* ✅ ADD: Success Message */} + {successMessage && ( + + {successMessage} + + )} + + {/* ✅ ADD: Error Message */} + {error && ( + + {error} + + )} @@ -85,27 +229,77 @@ const ProfileTab: React.FC = ({ user }) => { {/* First Name */} - {user.firstName && ( + {/* {user.firstName && ( First Name {user.firstName} - )} + )} */} + + + First Name + + {isEditing ? ( + + ) : ( + {user.firstName} + )} + {/* Last Name */} - {user.lastName && ( + {/* {user.lastName && ( Last Name {user.lastName} - )} + )} */} + + + Last Name + + {isEditing ? ( + + ) : ( + {user.lastName} + )} + {/* Company */} - {user.company && ( + {/* {user.company && ( @@ -117,10 +311,40 @@ const ProfileTab: React.FC = ({ user }) => { - )} + )} */} + + + + + + Company/Institution + + {isEditing ? ( + + ) : ( + {user.company} + )} + + + {/* Interests */} - {user.interests && ( + {/* {user.interests && ( Research Interests @@ -129,8 +353,36 @@ const ProfileTab: React.FC = ({ user }) => { {user.interests} - )} + )} */} + + + Research Interests + + {isEditing ? ( + + ) : ( + + {user.interests || "Not specified"} + + )} + @@ -163,15 +415,6 @@ const ProfileTab: React.FC = ({ user }) => { )} - - {/* Note about editing */} - - To update your profile information, please contact support. - ); }; From 19cb899dab5b1b8c42cdeab3587065a2eb051f4a Mon Sep 17 00:00:00 2001 From: elainefan331 Date: Mon, 2 Feb 2026 11:49:40 -0500 Subject: [PATCH 28/65] feat: update the color theme of profiletab --- src/components/User/Dashboard/ProfileTab.tsx | 50 ++------------------ 1 file changed, 3 insertions(+), 47 deletions(-) diff --git a/src/components/User/Dashboard/ProfileTab.tsx b/src/components/User/Dashboard/ProfileTab.tsx index 0c11a46..d912eac 100644 --- a/src/components/User/Dashboard/ProfileTab.tsx +++ b/src/components/User/Dashboard/ProfileTab.tsx @@ -102,10 +102,7 @@ const ProfileTab: React.FC = ({ user }) => { return ( - {/* - Profile Information - */} - {/* ✅ ADD: Header with Edit Button */} + {/* Header with Edit Button */} = ({ user }) => { )} - {/* ✅ ADD: Success Message */} + {/* Success Message */} {successMessage && ( {successMessage} )} - {/* ✅ ADD: Error Message */} + {/* Error Message */} {error && ( {error} @@ -229,15 +226,6 @@ const ProfileTab: React.FC = ({ user }) => { {/* First Name */} - {/* {user.firstName && ( - - - First Name - - {user.firstName} - - )} */} - First Name @@ -264,14 +252,6 @@ const ProfileTab: React.FC = ({ user }) => { )} {/* Last Name */} - {/* {user.lastName && ( - - - Last Name - - {user.lastName} - - )} */} Last Name @@ -299,19 +279,6 @@ const ProfileTab: React.FC = ({ user }) => { {/* Company */} - {/* {user.company && ( - - - - - - Company/Institution - - {user.company} - - - - )} */} @@ -344,17 +311,6 @@ const ProfileTab: React.FC = ({ user }) => { {/* Interests */} - {/* {user.interests && ( - - - Research Interests - - - {user.interests} - - - )} */} - Research Interests From 07654ce77c68457590e27fcec0d83b4186ba0b38 Mon Sep 17 00:00:00 2001 From: elainefan331 Date: Mon, 2 Feb 2026 12:23:18 -0500 Subject: [PATCH 29/65] feat: add project controller file --- backend/src/controllers/projectController.js | 188 +++++++++++++++++++ 1 file changed, 188 insertions(+) create mode 100644 backend/src/controllers/projectController.js diff --git a/backend/src/controllers/projectController.js b/backend/src/controllers/projectController.js new file mode 100644 index 0000000..a421ca6 --- /dev/null +++ b/backend/src/controllers/projectController.js @@ -0,0 +1,188 @@ +const { Project, User } = require("../models"); + +// Create a new organizer project +const createProject = async (req, res) => { + try { + const user = req.user; + const { name, description } = req.body; + + const project = await Project.create({ + user_id: user.id, + name: name || `Dataset Project ${new Date().toLocaleDateString()}`, + description: description || "don't have description yet", + extractor_state: { + files: [], + selectedIds: [], + expandedIds: [], + }, + }); + + res.status(201).json({ + message: "Project created successfully", + project, + }); + } catch (error) { + console.error("Create project error:", error); + res.status(500).json({ + message: "Error creating project", + error: error.message, + }); + } +}; + +// Get all projects for current user +const getUserProjects = async (req, res) => { + try { + const user = req.user; + + const projects = await Project.findAll({ + where: { user_id: user.id }, + order: [["updated_at", "DESC"]], + attributes: [ + "id", + "name", + "description", + "created_at", + "updated_at", + "extractor_state", + ], + }); + + // Add file count to each project + const projectsWithCount = projects.map((project) => { + const state = project.extractor_state || { files: [] }; + return { + id: project.id, + name: project.name, + description: project.description, + created_at: project.created_at, + updated_at: project.updated_at, + file_count: state.files ? state.files.length : 0, + }; + }); + + res.status(200).json({ + projects: projectsWithCount, + count: projectsWithCount.length, + }); + } catch (error) { + console.error("Get projects error:", error); + res.status(500).json({ + message: "Error fetching projects", + error: error.message, + }); + } +}; + +// Get a specific project +const getProject = async (req, res) => { + try { + const user = req.user; + const { id } = req.params; + + const project = await Project.findOne({ + where: { + id: id, + user_id: user.id, + }, + }); + + if (!project) { + return res.status(404).json({ + message: "Project not found", + }); + } + + res.status(200).json({ project }); + } catch (error) { + console.error("Get project error:", error); + res.status(500).json({ + message: "Error fetching project", + error: error.message, + }); + } +}; + +// Update project +const updateProject = async (req, res) => { + try { + const user = req.user; + const { id } = req.params; + const { name, description, extractor_state } = req.body; + + const project = await Project.findOne({ + where: { + id: id, + user_id: user.id, + }, + }); + + if (!project) { + return res.status(404).json({ + message: "Project not found", + }); + } + + // Update only provided fields + if (name !== undefined) project.name = name; + if (description !== undefined) project.description = description; + if (extractor_state !== undefined) { + project.extractor_state = extractor_state; + // Mark as changed for JSON field + project.changed("extractor_state", true); + } + + await project.save(); + + res.status(200).json({ + message: "Project updated successfully", + project, + }); + } catch (error) { + console.error("Update project error:", error); + res.status(500).json({ + message: "Error updating project", + error: error.message, + }); + } +}; +// Delete project +const deleteProject = async (req, res) => { + try { + const user = req.user; + const { id } = req.params; + + const project = await Project.findOne({ + where: { + id: id, + user_id: user.id, + }, + }); + + if (!project) { + return res.status(404).json({ + message: "Project not found", + }); + } + + await project.destroy(); + + res.status(200).json({ + message: "Project deleted successfully", + }); + } catch (error) { + console.error("Delete project error:", error); + res.status(500).json({ + message: "Error deleting project", + error: error.message, + }); + } +}; + +module.exports = { + createProject, + getUserProjects, + getProject, + updateProject, + deleteProject, +}; From 7fa0145690d4ee7d7ff6ea585db6f48b5f4db2dd Mon Sep 17 00:00:00 2001 From: elainefan331 Date: Mon, 2 Feb 2026 12:38:07 -0500 Subject: [PATCH 30/65] feat: add projects routes --- backend/src/controllers/projectController.js | 14 ++++----- backend/src/routes/projects.routes.js | 31 ++++++++++++++++++++ 2 files changed, 38 insertions(+), 7 deletions(-) create mode 100644 backend/src/routes/projects.routes.js diff --git a/backend/src/controllers/projectController.js b/backend/src/controllers/projectController.js index a421ca6..8a7c4f5 100644 --- a/backend/src/controllers/projectController.js +++ b/backend/src/controllers/projectController.js @@ -78,11 +78,11 @@ const getUserProjects = async (req, res) => { const getProject = async (req, res) => { try { const user = req.user; - const { id } = req.params; + const { projectId } = req.params; const project = await Project.findOne({ where: { - id: id, + id: projectId, user_id: user.id, }, }); @@ -107,12 +107,12 @@ const getProject = async (req, res) => { const updateProject = async (req, res) => { try { const user = req.user; - const { id } = req.params; + const { projectId } = req.params; const { name, description, extractor_state } = req.body; const project = await Project.findOne({ where: { - id: id, + id: projectId, user_id: user.id, }, }); @@ -128,7 +128,7 @@ const updateProject = async (req, res) => { if (description !== undefined) project.description = description; if (extractor_state !== undefined) { project.extractor_state = extractor_state; - // Mark as changed for JSON field + // Mark as changed for JSON field - tells Sequelize to UPDATE this field project.changed("extractor_state", true); } @@ -150,11 +150,11 @@ const updateProject = async (req, res) => { const deleteProject = async (req, res) => { try { const user = req.user; - const { id } = req.params; + const { projectId } = req.params; const project = await Project.findOne({ where: { - id: id, + id: projectId, user_id: user.id, }, }); diff --git a/backend/src/routes/projects.routes.js b/backend/src/routes/projects.routes.js new file mode 100644 index 0000000..de111c2 --- /dev/null +++ b/backend/src/routes/projects.routes.js @@ -0,0 +1,31 @@ +const express = require("express"); +const { + getUserProjects, + createProject, + getProject, + updateProject, + deleteProject, +} = require("../controllers/projectController"); +const { restoreUser, requireAuth } = require("../middleware/auth.middleware"); + +const router = express.Router(); + +// Apply restoreUser to all routes +router.use(restoreUser); + +// Get all user's projects +router.get("/me/projects", requireAuth, getUserProjects); + +// Create new project +router.post("/projects", requireAuth, createProject); + +// Get specific project +router.get("/projects/:projectId", requireAuth, getProject); + +// Update project +router.put("/projects/:projectId", requireAuth, updateProject); + +// Delete project +router.delete("/projects/:projectId", requireAuth, deleteProject); + +module.exports = router; From 859a4924dfae99e03469cb492eb10a013ef46604 Mon Sep 17 00:00:00 2001 From: elainefan331 Date: Mon, 2 Feb 2026 12:40:18 -0500 Subject: [PATCH 31/65] feat: register project routes in server --- backend/src/server.js | 2 ++ 1 file changed, 2 insertions(+) diff --git a/backend/src/server.js b/backend/src/server.js index d0d25e2..b185a84 100644 --- a/backend/src/server.js +++ b/backend/src/server.js @@ -12,6 +12,7 @@ const activitiesRoutes = require("./routes/activities.routes"); const dbsRoutes = require("./routes/dbs.routes"); const datasetsRoutes = require("./routes/datasets.routes"); const collectionRoutes = require("./routes/collection.route"); +const projectRoutes = require("./routes/projects.routes"); const app = express(); const PORT = process.env.PORT || 5000; @@ -47,6 +48,7 @@ app.use("/api/v1/activities", activitiesRoutes); app.use("/api/v1/dbs", dbsRoutes); app.use("/api/v1/datasets", datasetsRoutes); app.use("/api/v1/collections", collectionRoutes); +app.use("/api/v1/projects", projectRoutes); // health check endpoint app.get("/api/health", async (req, res) => { From e9fb650b757922f17deeeb5f41309317a224c84b Mon Sep 17 00:00:00 2001 From: elainefan331 Date: Mon, 2 Feb 2026 14:45:30 -0500 Subject: [PATCH 32/65] feat: add projects interface types --- backend/src/routes/projects.routes.js | 8 ++-- src/redux/projects/projects.action.ts | 0 .../projects/types/projects.interface.ts | 47 +++++++++++++++++++ 3 files changed, 51 insertions(+), 4 deletions(-) create mode 100644 src/redux/projects/projects.action.ts create mode 100644 src/redux/projects/types/projects.interface.ts diff --git a/backend/src/routes/projects.routes.js b/backend/src/routes/projects.routes.js index de111c2..4d4a834 100644 --- a/backend/src/routes/projects.routes.js +++ b/backend/src/routes/projects.routes.js @@ -17,15 +17,15 @@ router.use(restoreUser); router.get("/me/projects", requireAuth, getUserProjects); // Create new project -router.post("/projects", requireAuth, createProject); +router.post("/", requireAuth, createProject); // Get specific project -router.get("/projects/:projectId", requireAuth, getProject); +router.get("/:projectId", requireAuth, getProject); // Update project -router.put("/projects/:projectId", requireAuth, updateProject); +router.put("/:projectId", requireAuth, updateProject); // Delete project -router.delete("/projects/:projectId", requireAuth, deleteProject); +router.delete("/:projectId", requireAuth, deleteProject); module.exports = router; diff --git a/src/redux/projects/projects.action.ts b/src/redux/projects/projects.action.ts new file mode 100644 index 0000000..e69de29 diff --git a/src/redux/projects/types/projects.interface.ts b/src/redux/projects/types/projects.interface.ts new file mode 100644 index 0000000..fd4e8e0 --- /dev/null +++ b/src/redux/projects/types/projects.interface.ts @@ -0,0 +1,47 @@ +export interface FileItem { + id: string; + name: string; + type: "file" | "folder" | "zip"; + parentId: string | null; + fileType?: + | "text" + | "nifti" + | "hdf5" + | "neurojsonText" + | "neurojsonBinary" + | "office" + | "meta" + | "other"; + content?: string; + contentType?: string; + sourcePath?: string; + isUserMeta?: boolean; + note?: string; + loading?: boolean; +} + +export interface ExtractorState { + files: FileItem[]; + selectedIds: string[]; + expandedIds: string[]; +} + +export interface Project { + id: number; + user_id: number; + name: string; + description: string | null; + extractor_state: ExtractorState; + created_at: string; + updated_at: string; + file_count?: number; // Added by backend (not included in database) +} + +export interface LLMProvider { + name: string; + baseUrl: string; + models: Array<{ id: string; name: string }>; + noApiKey?: boolean; + customUrl?: boolean; + isAnthropic?: boolean; +} From b679dfc8b4d02862ab139633000ee2996c42ccdb Mon Sep 17 00:00:00 2001 From: elainefan331 Date: Mon, 2 Feb 2026 14:56:47 -0500 Subject: [PATCH 33/65] feat: update the projects interface types --- .../projects/types/projects.interface.ts | 71 +++++++++++++++++-- 1 file changed, 64 insertions(+), 7 deletions(-) diff --git a/src/redux/projects/types/projects.interface.ts b/src/redux/projects/types/projects.interface.ts index fd4e8e0..257906a 100644 --- a/src/redux/projects/types/projects.interface.ts +++ b/src/redux/projects/types/projects.interface.ts @@ -20,12 +20,14 @@ export interface FileItem { loading?: boolean; } +// Extractor State export interface ExtractorState { files: FileItem[]; selectedIds: string[]; expandedIds: string[]; } +// Project Interface export interface Project { id: number; user_id: number; @@ -37,11 +39,66 @@ export interface Project { file_count?: number; // Added by backend (not included in database) } -export interface LLMProvider { - name: string; - baseUrl: string; - models: Array<{ id: string; name: string }>; - noApiKey?: boolean; - customUrl?: boolean; - isAnthropic?: boolean; +// Redux State +export interface ProjectsState { + projects: Project[]; + currentProject: Project | null; + error: string | null; + loading: boolean; + isCreating: boolean; + isUpdating: boolean; +} + +// API Response Types +export interface GetUserProjectsResponse { + projects: Project[]; + count: number; +} + +export interface CreateProjectResponse { + message: string; + project: Project; +} + +export interface GetProjectResponse { + project: Project; +} + +export interface UpdateProjectResponse { + message: string; + project: Project; +} + +export interface DeleteProjectResponse { + message: string; +} + +// Payload Types +export interface CreateProjectPayload { + name?: string; + description?: string; +} + +export interface GetProjectPayload { + projectId: number; } + +export interface UpdateProjectPayload { + projectId: number; + name?: string; + description?: string; + extractor_state?: ExtractorState; +} + +export interface DeleteProjectPayload { + projectId: number; +} + +// export interface LLMProvider { +// name: string; +// baseUrl: string; +// models: Array<{ id: string; name: string }>; +// noApiKey?: boolean; +// customUrl?: boolean; +// isAnthropic?: boolean; +// } From 804289c3fcdbb667943bbb6bd2fd54bdba523884 Mon Sep 17 00:00:00 2001 From: elainefan331 Date: Mon, 2 Feb 2026 15:03:26 -0500 Subject: [PATCH 34/65] feat: add projects service file --- src/services/projects.service.ts | 107 +++++++++++++++++++++++++++++++ 1 file changed, 107 insertions(+) create mode 100644 src/services/projects.service.ts diff --git a/src/services/projects.service.ts b/src/services/projects.service.ts new file mode 100644 index 0000000..be38f0b --- /dev/null +++ b/src/services/projects.service.ts @@ -0,0 +1,107 @@ +import { + GetUserProjectsResponse, + CreateProjectResponse, + CreateProjectPayload, + GetProjectResponse, + UpdateProjectResponse, + UpdateProjectPayload, + DeleteProjectResponse, +} from "../redux/projects/types/projects.interface"; + +const API_URL = process.env.REACT_APP_API_URL || "http://localhost:5000/api/v1"; + +export const ProjectsService = { + // Get all user's projects + getUserProjects: async (): Promise => { + const response = await fetch(`${API_URL}/projects/me/projects`, { + method: "GET", + credentials: "include", + }); + + const data = await response.json(); + + if (!response.ok) { + throw new Error(data.message || "Failed to fetch projects"); + } + + return data; + }, + + // Create new project + createProject: async ( + payload: CreateProjectPayload + ): Promise => { + const response = await fetch(`${API_URL}/projects`, { + method: "POST", + headers: { + "Content-Type": "application/json", + }, + credentials: "include", + body: JSON.stringify(payload), + }); + + const data = await response.json(); + + if (!response.ok) { + throw new Error(data.message || "Failed to create project"); + } + + return data; + }, + + // Get specific project + getProject: async (projectId: number): Promise => { + const response = await fetch(`${API_URL}/projects/${projectId}`, { + method: "GET", + credentials: "include", + }); + + const data = await response.json(); + + if (!response.ok) { + throw new Error(data.message || "Failed to fetch project"); + } + + return data; + }, + + // Update project + updateProject: async ( + payload: UpdateProjectPayload + ): Promise => { + const { projectId, ...updates } = payload; + + const response = await fetch(`${API_URL}/projects/${projectId}`, { + method: "PUT", + headers: { + "Content-Type": "application/json", + }, + credentials: "include", + body: JSON.stringify(updates), + }); + + const data = await response.json(); + + if (!response.ok) { + throw new Error(data.message || "Failed to update project"); + } + + return data; + }, + + // Delete project + deleteProject: async (projectId: number): Promise => { + const response = await fetch(`${API_URL}/projects/${projectId}`, { + method: "DELETE", + credentials: "include", + }); + + const data = await response.json(); + + if (!response.ok) { + throw new Error(data.message || "Failed to delete project"); + } + + return data; + }, +}; From 9f04eb61de9f6f1555fbb02c7b84f1a1eafb1454 Mon Sep 17 00:00:00 2001 From: elainefan331 Date: Mon, 2 Feb 2026 15:08:08 -0500 Subject: [PATCH 35/65] feat: add projects action file --- src/redux/projects/projects.action.ts | 73 +++++++++++++++++++++++++++ 1 file changed, 73 insertions(+) diff --git a/src/redux/projects/projects.action.ts b/src/redux/projects/projects.action.ts index e69de29..6ab5226 100644 --- a/src/redux/projects/projects.action.ts +++ b/src/redux/projects/projects.action.ts @@ -0,0 +1,73 @@ +import { + CreateProjectPayload, + UpdateProjectPayload, + DeleteProjectPayload, + GetProjectPayload, +} from "./types/projects.interface"; +import { createAsyncThunk } from "@reduxjs/toolkit"; +import { ProjectsService } from "services/projects.service"; + +// Get all user's projects +export const getUserProjects = createAsyncThunk( + "projects/getUserProjects", + async (_, { rejectWithValue }) => { + try { + const response = await ProjectsService.getUserProjects(); + return response.projects; + } catch (error: any) { + return rejectWithValue(error.message || "Failed to fetch projects"); + } + } +); + +// Create new project +export const createProject = createAsyncThunk( + "projects/createProject", + async (payload: CreateProjectPayload, { rejectWithValue }) => { + try { + const response = await ProjectsService.createProject(payload); + return response.project; + } catch (error: any) { + return rejectWithValue(error.message || "Failed to create project"); + } + } +); + +// Get specific project +export const getProject = createAsyncThunk( + "projects/getProject", + async (payload: GetProjectPayload, { rejectWithValue }) => { + try { + const response = await ProjectsService.getProject(payload.projectId); + return response.project; + } catch (error: any) { + return rejectWithValue(error.message || "Failed to fetch project"); + } + } +); + +// Update project +export const updateProject = createAsyncThunk( + "projects/updateProject", + async (payload: UpdateProjectPayload, { rejectWithValue }) => { + try { + const response = await ProjectsService.updateProject(payload); + return response.project; + } catch (error: any) { + return rejectWithValue(error.message || "Failed to update project"); + } + } +); + +// Delete project +export const deleteProject = createAsyncThunk( + "projects/deleteProject", + async (payload: DeleteProjectPayload, { rejectWithValue }) => { + try { + await ProjectsService.deleteProject(payload.projectId); + return payload.projectId; + } catch (error: any) { + return rejectWithValue(error.message || "Failed to delete project"); + } + } +); From 21305732f82ab06456c74aec5e677e670e83159a Mon Sep 17 00:00:00 2001 From: elainefan331 Date: Mon, 2 Feb 2026 15:16:51 -0500 Subject: [PATCH 36/65] feat: add projects slice file --- src/redux/projects/projects.slice.ts | 109 +++++++++++++++++++++++++++ 1 file changed, 109 insertions(+) create mode 100644 src/redux/projects/projects.slice.ts diff --git a/src/redux/projects/projects.slice.ts b/src/redux/projects/projects.slice.ts new file mode 100644 index 0000000..4e7ed3a --- /dev/null +++ b/src/redux/projects/projects.slice.ts @@ -0,0 +1,109 @@ +// src/redux/projects/projects.slice.ts +import { + getUserProjects, + createProject, + getProject, + updateProject, + deleteProject, +} from "./projects.action"; +import { ProjectsState } from "./types/projects.interface"; +import { createSlice } from "@reduxjs/toolkit"; + +const initialState: ProjectsState = { + projects: [], + currentProject: null, + error: null, + loading: false, + isCreating: false, + isUpdating: false, +}; + +const projectsSlice = createSlice({ + name: "projects", + initialState, + reducers: { + clearError: (state) => { + state.error = null; + }, + clearCurrentProject: (state) => { + state.currentProject = null; + }, + }, + extraReducers: (builder) => { + builder + // Get User Projects + .addCase(getUserProjects.pending, (state) => { + state.loading = true; + state.error = null; + }) + .addCase(getUserProjects.fulfilled, (state, action) => { + state.projects = action.payload; + state.loading = false; + }) + .addCase(getUserProjects.rejected, (state, action) => { + state.loading = false; + state.error = action.payload as string; + }) + + // Create Project + .addCase(createProject.pending, (state) => { + state.isCreating = true; + state.error = null; + }) + .addCase(createProject.fulfilled, (state, action) => { + state.projects = [action.payload, ...state.projects]; + // state.currentProject = action.payload; + state.isCreating = false; + }) + .addCase(createProject.rejected, (state, action) => { + state.isCreating = false; + state.error = action.payload as string; + }) + + // Get Project + .addCase(getProject.pending, (state) => { + state.loading = true; + state.error = null; + }) + .addCase(getProject.fulfilled, (state, action) => { + state.currentProject = action.payload; + state.loading = false; + }) + .addCase(getProject.rejected, (state, action) => { + state.loading = false; + state.error = action.payload as string; + }) + + // Update Project + .addCase(updateProject.pending, (state) => { + state.isUpdating = true; + state.error = null; + }) + .addCase(updateProject.fulfilled, (state) => { + state.isUpdating = false; + // Component will refetch projects list + }) + .addCase(updateProject.rejected, (state, action) => { + state.isUpdating = false; + state.error = action.payload as string; + }) + + // Delete Project + .addCase(deleteProject.pending, (state) => { + state.loading = true; + state.error = null; + }) + .addCase(deleteProject.fulfilled, (state) => { + state.loading = false; + // Component will refetch or navigate away + }) + .addCase(deleteProject.rejected, (state, action) => { + state.loading = false; + state.error = action.payload as string; + }); + }, +}); + +export const { clearError, clearCurrentProject } = projectsSlice.actions; + +export default projectsSlice.reducer; From 4cc9c0a4d65ced9f9df572731a1230f0158f627b Mon Sep 17 00:00:00 2001 From: elainefan331 Date: Mon, 2 Feb 2026 15:20:38 -0500 Subject: [PATCH 37/65] feat: add projects selector file --- src/redux/projects/projects.selector.ts | 37 +++++++++++++++++++++++++ src/redux/store.ts | 2 ++ 2 files changed, 39 insertions(+) create mode 100644 src/redux/projects/projects.selector.ts diff --git a/src/redux/projects/projects.selector.ts b/src/redux/projects/projects.selector.ts new file mode 100644 index 0000000..0eae214 --- /dev/null +++ b/src/redux/projects/projects.selector.ts @@ -0,0 +1,37 @@ +import { RootState } from "../store"; + +// Main selector +export const ProjectsSelector = (state: RootState) => state.projects; + +// Get all user's projects +export const selectUserProjects = (state: RootState) => { + return state.projects.projects; +}; + +// Get current project being viewed +export const selectCurrentProject = (state: RootState) => { + return state.projects.currentProject; +}; + +// Get loading states +export const selectProjectsLoading = (state: RootState): boolean => { + return state.projects.loading; +}; + +export const selectIsCreatingProject = (state: RootState): boolean => { + return state.projects.isCreating; +}; + +export const selectIsUpdatingProject = (state: RootState): boolean => { + return state.projects.isUpdating; +}; + +// Get error +export const selectProjectsError = (state: RootState): string | null => { + return state.projects.error; +}; + +// Get project by ID (from cached list) +export const selectProjectById = (state: RootState, projectId: number) => { + return state.projects.projects.find((p) => p.id === projectId); +}; diff --git a/src/redux/store.ts b/src/redux/store.ts index 67b0e6e..f0a53da 100644 --- a/src/redux/store.ts +++ b/src/redux/store.ts @@ -2,6 +2,7 @@ import activitiesReducer from "./activities/activities.slice"; import authReducer from "./auth/auth.slice"; import collectionsReducer from "./collections/collections.slice"; import neurojsonReducer from "./neurojson/neurojson.slice"; +import projectsReducer from "./projects/projects.slice"; import { configureStore, combineReducers, @@ -14,6 +15,7 @@ const appReducer = combineReducers({ auth: authReducer, activities: activitiesReducer, collections: collectionsReducer, + projects: projectsReducer, }); export const rootReducer = ( From 05f4dcd05f2b68909362d48d5a2fceb2e7e6b522 Mon Sep 17 00:00:00 2001 From: elainefan331 Date: Mon, 2 Feb 2026 15:44:30 -0500 Subject: [PATCH 38/65] feat: add projects tab to user dashboard --- src/components/User/Dashboard/ProjectsTab.tsx | 610 ++++++++++++++++++ src/components/User/UserDashboard.tsx | 11 + 2 files changed, 621 insertions(+) create mode 100644 src/components/User/Dashboard/ProjectsTab.tsx diff --git a/src/components/User/Dashboard/ProjectsTab.tsx b/src/components/User/Dashboard/ProjectsTab.tsx new file mode 100644 index 0000000..2304852 --- /dev/null +++ b/src/components/User/Dashboard/ProjectsTab.tsx @@ -0,0 +1,610 @@ +// src/components/Dashboard/ProjectsTab.tsx +import { FolderOpen, Add, Delete, Edit, Visibility } from "@mui/icons-material"; +import { + Box, + Typography, + Paper, + CircularProgress, + Alert, + List, + ListItem, + ListItemText, + Divider, + Button, + Chip, + IconButton, + Dialog, + DialogTitle, + DialogContent, + DialogActions, + TextField, +} from "@mui/material"; +import { Colors } from "design/theme"; +import { useAppDispatch } from "hooks/useAppDispatch"; +import { useAppSelector } from "hooks/useAppSelector"; +import React, { useEffect, useState } from "react"; +import { useNavigate } from "react-router-dom"; +import { + getUserProjects, + createProject, + deleteProject, + updateProject, +} from "redux/projects/projects.action"; +import { + selectUserProjects, + selectProjectsLoading, + selectProjectsError, + selectIsCreatingProject, +} from "redux/projects/projects.selector"; + +interface ProjectsTabProps { + userId: number; +} + +const ProjectsTab: React.FC = ({ userId }) => { + const dispatch = useAppDispatch(); + const navigate = useNavigate(); + + const projects = useAppSelector(selectUserProjects); + const loading = useAppSelector(selectProjectsLoading); + const error = useAppSelector(selectProjectsError); + const isCreating = useAppSelector(selectIsCreatingProject); + + const [createDialogOpen, setCreateDialogOpen] = useState(false); + const [newProjectName, setNewProjectName] = useState(""); + const [newProjectDescription, setNewProjectDescription] = useState(""); + const [deleteDialogOpen, setDeleteDialogOpen] = useState(false); + const [projectToDelete, setProjectToDelete] = useState<{ + id: number; + name: string; + } | null>(null); + const [editDialogOpen, setEditDialogOpen] = useState(false); + const [editingProject, setEditingProject] = useState<{ + id: number; + name: string; + description: string; + } | null>(null); + + useEffect(() => { + dispatch(getUserProjects()); + }, [dispatch]); + + const handleViewProject = (projectId: number) => { + navigate(`/projects/${projectId}`); + }; + + const handleCreateOpen = () => { + setCreateDialogOpen(true); + }; + + const handleCreateClose = () => { + setNewProjectName(""); + setNewProjectDescription(""); + setCreateDialogOpen(false); + }; + + const handleCreateSubmit = async () => { + if (!newProjectName.trim()) return; + + try { + await dispatch( + createProject({ + name: newProjectName.trim(), + description: newProjectDescription.trim() || undefined, + }) + ).unwrap(); + + handleCreateClose(); + // Refetch projects after create + dispatch(getUserProjects()); + } catch (error) { + console.error("Error creating project:", error); + } + }; + + const handleDeleteClick = (projectId: number, projectName: string) => { + setProjectToDelete({ id: projectId, name: projectName }); + setDeleteDialogOpen(true); + }; + + const handleDeleteConfirm = async () => { + if (!projectToDelete) return; + + try { + await dispatch(deleteProject({ projectId: projectToDelete.id })).unwrap(); + setDeleteDialogOpen(false); + setProjectToDelete(null); + + // Refetch projects after delete + dispatch(getUserProjects()); + } catch (error) { + console.error("Error deleting project:", error); + } + }; + + const handleDeleteCancel = () => { + setDeleteDialogOpen(false); + setProjectToDelete(null); + }; + + const handleEditClick = (project: any) => { + setEditingProject({ + id: project.id, + name: project.name, + description: project.description || "", + }); + setEditDialogOpen(true); + }; + + const handleEditSubmit = async () => { + if (!editingProject || !editingProject.name.trim()) return; + + try { + await dispatch( + updateProject({ + projectId: editingProject.id, + name: editingProject.name.trim(), + description: editingProject.description.trim() || undefined, + }) + ).unwrap(); + + // Refetch projects + dispatch(getUserProjects()); + + handleEditClose(); + } catch (error) { + console.error("Error updating project:", error); + } + }; + + const handleEditClose = () => { + setEditDialogOpen(false); + setEditingProject(null); + }; + + const formatDate = (dateString: string) => { + return new Date(dateString).toLocaleDateString("en-US", { + year: "numeric", + month: "short", + day: "numeric", + }); + }; + + if (loading && projects.length === 0) { + return ( + + + + ); + } + + if (error) { + return ( + + {error} + + ); + } + + return ( + + {/* Header with Create Button */} + + + + Dataset Organizer Projects + + + Organize and convert your neuroimaging datasets to BIDS format + + + + + + {/* Empty State */} + {projects.length === 0 ? ( + + + + No Projects Yet + + + Create a project to start organizing your neuroimaging datasets + + + + ) : ( + // Projects List + + + {projects.map((project, index) => ( + + {index > 0 && } + + + + + + {project.name} + + + + } + secondary={ + <> + {project.description && ( + + {project.description} + + )} + + Created {formatDate(project.created_at)} + + + } + /> + + + handleEditClick(project)} + sx={{ + color: Colors.purple, + "&:hover": { + backgroundColor: "rgba(128, 90, 213, 0.1)", + }, + }} + > + + + + + handleDeleteClick(project.id, project.name) + } + sx={{ + color: Colors.rose, + "&:hover": { + backgroundColor: "rgba(211, 47, 47, 0.1)", + }, + }} + > + + + + + + ))} + + + )} + + {/* Create Project Dialog */} + + + Create New Project + + + setNewProjectName(e.target.value)} + sx={{ + mb: 2, + mt: 1, + "& .MuiInputLabel-root.Mui-focused": { + color: Colors.purple, + }, + "& .MuiOutlinedInput-root.Mui-focused .MuiOutlinedInput-notchedOutline": + { + borderColor: Colors.purple, + }, + "& .MuiOutlinedInput-root:hover .MuiOutlinedInput-notchedOutline": + { + borderColor: Colors.purple, + }, + }} + /> + setNewProjectDescription(e.target.value)} + sx={{ + "& .MuiInputLabel-root.Mui-focused": { + color: Colors.purple, + }, + "& .MuiOutlinedInput-root.Mui-focused .MuiOutlinedInput-notchedOutline": + { + borderColor: Colors.purple, + }, + "& .MuiOutlinedInput-root:hover .MuiOutlinedInput-notchedOutline": + { + borderColor: Colors.purple, + }, + }} + /> + + + + + + + + {/* Delete Confirmation Dialog */} + + + Delete Project? + + + + Are you sure you want to delete "{projectToDelete?.name}"? + + + This will permanently delete the project and all its data. + + + + + + + + + {/* Edit Project Dialog */} + + + Edit Project + + + + setEditingProject( + editingProject + ? { ...editingProject, name: e.target.value } + : null + ) + } + sx={{ + mb: 2, + mt: 1, + "& .MuiInputLabel-root.Mui-focused": { + color: Colors.purple, + }, + "& .MuiOutlinedInput-root.Mui-focused .MuiOutlinedInput-notchedOutline": + { + borderColor: Colors.purple, + }, + "& .MuiOutlinedInput-root:hover .MuiOutlinedInput-notchedOutline": + { + borderColor: Colors.purple, + }, + }} + /> + + setEditingProject( + editingProject + ? { ...editingProject, description: e.target.value } + : null + ) + } + sx={{ + mb: 2, + "& .MuiInputLabel-root.Mui-focused": { + color: Colors.purple, + }, + "& .MuiOutlinedInput-root.Mui-focused .MuiOutlinedInput-notchedOutline": + { + borderColor: Colors.purple, + }, + "& .MuiOutlinedInput-root:hover .MuiOutlinedInput-notchedOutline": + { + borderColor: Colors.purple, + }, + }} + /> + + + + + + + + ); +}; + +export default ProjectsTab; diff --git a/src/components/User/UserDashboard.tsx b/src/components/User/UserDashboard.tsx index bcb1972..21d4e53 100644 --- a/src/components/User/UserDashboard.tsx +++ b/src/components/User/UserDashboard.tsx @@ -1,5 +1,6 @@ import CollectionsTab from "./Dashboard/CollectionsTab"; import ProfileTab from "./Dashboard/ProfileTab"; +import ProjectsTab from "./Dashboard/ProjectsTab"; import SavedDatasetsTab from "./Dashboard/SavedDatasetsTab"; import SecurityTab from "./Dashboard/SecurityTab"; import LikedDatasetsTab from "./Dashboard/likedDatasetsTab"; @@ -9,6 +10,7 @@ import { Settings, Bookmark, Favorite, + FolderOpen, } from "@mui/icons-material"; import { Box, @@ -145,6 +147,12 @@ const UserDashboard: React.FC = () => { id="dashboard-tab-3" aria-controls="dashboard-tabpanel-3" /> + } + label="Projects" + id="dashboard-tab-3" + aria-controls="dashboard-tabpanel-3" + /> } label="Settings" @@ -165,6 +173,9 @@ const UserDashboard: React.FC = () => { + + + ); From 406e86e73b711ecee6fa9819a9b50b8f73d8c494 Mon Sep 17 00:00:00 2001 From: elainefan331 Date: Wed, 4 Feb 2026 14:21:23 -0500 Subject: [PATCH 39/65] feat: add dataset organizer page --- package.json | 2 + src/components/Routes.tsx | 4 +- .../Dashboard/DatasetOrganizer/DropZone.tsx | 175 +++++++ .../Dashboard/DatasetOrganizer/FileTree.tsx | 489 ++++++++++++++++++ .../Dashboard/DatasetOrganizer/LLMPanel.tsx | 382 ++++++++++++++ .../User/Dashboard/DatasetOrganizer/index.tsx | 328 ++++++++++++ .../DatasetOrganizer/utils/fileProcessors.ts | 353 +++++++++++++ yarn.lock | 210 +++++++- 8 files changed, 1933 insertions(+), 10 deletions(-) create mode 100644 src/components/User/Dashboard/DatasetOrganizer/DropZone.tsx create mode 100644 src/components/User/Dashboard/DatasetOrganizer/FileTree.tsx create mode 100644 src/components/User/Dashboard/DatasetOrganizer/LLMPanel.tsx create mode 100644 src/components/User/Dashboard/DatasetOrganizer/index.tsx create mode 100644 src/components/User/Dashboard/DatasetOrganizer/utils/fileProcessors.ts diff --git a/package.json b/package.json index 3620db4..8e9a464 100644 --- a/package.json +++ b/package.json @@ -33,11 +33,13 @@ "dayjs": "^1.11.10", "jquery": "^3.7.1", "json-stringify-safe": "^5.0.1", + "jszip": "^3.10.1", "jwt-decode": "^3.1.2", "lzma": "^2.3.2", "numjs": "^0.16.1", "pako": "1.0.11", "path-browserify": "^1.0.1", + "pdfjs-dist": "3.4.120", "query-string": "^8.1.0", "react": "^18.2.0", "react-dom": "^18.2.0", diff --git a/src/components/Routes.tsx b/src/components/Routes.tsx index 824ed8a..5983701 100644 --- a/src/components/Routes.tsx +++ b/src/components/Routes.tsx @@ -1,6 +1,7 @@ import ScrollToTop from "./ScrollToTop"; import CompleteProfile from "./User/CompleteProfile"; import CollectionDetailPage from "./User/Dashboard/CollectionDetailPage"; +import DatasetOrganizer from "./User/Dashboard/DatasetOrganizer"; import ForgotPassword from "./User/ForgotPassword"; import ResetPassword from "./User/ResetPassword"; import UserDashboard from "./User/UserDashboard"; @@ -61,11 +62,12 @@ const Routes = () => ( {/* Dashboard Page */} } /> - {/* Collection detail page */} + {/* pages redirect from user dashboard */} } /> + } /> diff --git a/src/components/User/Dashboard/DatasetOrganizer/DropZone.tsx b/src/components/User/Dashboard/DatasetOrganizer/DropZone.tsx new file mode 100644 index 0000000..fb0a450 --- /dev/null +++ b/src/components/User/Dashboard/DatasetOrganizer/DropZone.tsx @@ -0,0 +1,175 @@ +// src/components/DatasetOrganizer/DropZone.tsx +import { processFile, processFolder, processZip } from "./utils/fileProcessors"; +import { CloudUpload, Add } from "@mui/icons-material"; +import { Box, Typography, Paper, Button } from "@mui/material"; +import { Colors } from "design/theme"; +import React, { useState, useRef } from "react"; +import { FileItem } from "redux/projects/types/projects.interface"; + +interface DropZoneProps { + files: FileItem[]; + setFiles: React.Dispatch>; + selectedIds: Set; + setSelectedIds: React.Dispatch>>; + expandedIds: Set; + setExpandedIds: React.Dispatch>>; +} + +const DropZone: React.FC = ({ + files, + setFiles, + selectedIds, + setSelectedIds, + expandedIds, + setExpandedIds, +}) => { + const [isDragging, setIsDragging] = useState(false); + const fileInputRef = useRef(null); + + const handleDragOver = (e: React.DragEvent) => { + e.preventDefault(); + setIsDragging(true); + }; + + const handleDragLeave = (e: React.DragEvent) => { + e.preventDefault(); + setIsDragging(false); + }; + + const handleDrop = async (e: React.DragEvent) => { + e.preventDefault(); + setIsDragging(false); + + const items = Array.from(e.dataTransfer.items); // detect if it is a folder + const droppedFiles = Array.from(e.dataTransfer.files); // only gives file objects, can't detect folders + + // Separate folders and files + const folderEntries: any[] = []; + const fileItems: File[] = []; + + for (let i = 0; i < items.length; i++) { + const entry = (items[i] as any).webkitGetAsEntry?.(); + if (entry && entry.isDirectory) { + folderEntries.push(entry); + } else if (droppedFiles[i]) { + fileItems.push(droppedFiles[i]); + } + } + + // Process folders + for (const folderEntry of folderEntries) { + const folderFiles = await processFolder(folderEntry, null); + setFiles((prev) => [...prev, ...folderFiles]); + } + + // Process files + for (const file of fileItems) { + if (file.name.toLowerCase().endsWith(".zip")) { + const zipFiles = await processZip(file); + setFiles((prev) => [...prev, ...zipFiles]); + } else { + const fileItem = await processFile(file); + setFiles((prev) => [...prev, fileItem]); + } + } + }; + + const handleFileSelect = async (e: React.ChangeEvent) => { + const selectedFiles = Array.from(e.target.files || []); + + for (const file of selectedFiles) { + if (file.name.toLowerCase().endsWith(".zip")) { + const zipFiles = await processZip(file); + setFiles((prev) => [...prev, ...zipFiles]); + } else { + const fileItem = await processFile(file); + setFiles((prev) => [...prev, fileItem]); + } + } + }; + + return ( + + {/* Show file count if files exist */} + {files.length > 0 && ( + + + Dataset Files + + + {files.length} file{files.length !== 1 ? "s" : ""} added + + + )} + + {/* Always show drop zone */} + fileInputRef.current?.click()} + sx={{ + border: `2px dashed ${isDragging ? Colors.purple : Colors.lightGray}`, + borderRadius: 2, + p: 6, + textAlign: "center", + cursor: "pointer", + transition: "all 0.2s", + backgroundColor: isDragging + ? "rgba(128, 90, 213, 0.05)" + : "transparent", + "&:hover": { + borderColor: Colors.purple, + backgroundColor: "rgba(128, 90, 213, 0.05)", + }, + }} + > + 0 ? 40 : 64, // ← Smaller icon when files exist + color: Colors.purple, + mb: 1, + }} + /> + 0 ? "body1" : "h6"} gutterBottom> + {files.length > 0 + ? "Drop more files here" + : "Drop your neuroimaging files here"} + + + Supports NIfTI, SNIRF, HDF5, NeuroJSON, folders, and ZIP archives + + {files.length === 0 && ( + <> + + 📁 Folders • 🗜️ ZIP files • 📄 Documents (.json, .txt, .md) • 📊 + Office (.docx, .pdf, .xlsx) + + + + )} + + + + ); +}; + +export default DropZone; diff --git a/src/components/User/Dashboard/DatasetOrganizer/FileTree.tsx b/src/components/User/Dashboard/DatasetOrganizer/FileTree.tsx new file mode 100644 index 0000000..129f81a --- /dev/null +++ b/src/components/User/Dashboard/DatasetOrganizer/FileTree.tsx @@ -0,0 +1,489 @@ +import { + Folder, + InsertDriveFile, + ExpandMore, + ChevronRight, + Delete, + NoteAdd, + Edit, + Description, +} from "@mui/icons-material"; +import { + Box, + Typography, + IconButton, + Paper, + Button, + TextField, + Dialog, + DialogTitle, + DialogContent, + DialogActions, +} from "@mui/material"; +import { Colors } from "design/theme"; +import React, { useState } from "react"; +import { FileItem } from "redux/projects/types/projects.interface"; + +interface FileTreeProps { + files: FileItem[]; + setFiles: React.Dispatch>; + selectedIds: Set; + setSelectedIds: React.Dispatch>>; + expandedIds: Set; + setExpandedIds: React.Dispatch>>; +} + +const FileTree: React.FC = ({ + files, + setFiles, + selectedIds, + setSelectedIds, + expandedIds, + setExpandedIds, +}) => { + const [noteDialogOpen, setNoteDialogOpen] = useState(false); + const [editingNoteId, setEditingNoteId] = useState(null); + const [noteText, setNoteText] = useState(""); + + const handleToggleExpand = (id: string) => { + setExpandedIds((prev) => { + const newSet = new Set(prev); + if (newSet.has(id)) { + newSet.delete(id); + } else { + newSet.add(id); + } + return newSet; + }); + }; + + const handleToggleSelect = (id: string) => { + setSelectedIds((prev) => { + const newSet = new Set(prev); + if (newSet.has(id)) { + newSet.delete(id); + } else { + newSet.add(id); + } + return newSet; + }); + }; + + const handleDeleteSelected = () => { + if (selectedIds.size === 0) return; + if (!window.confirm(`Delete ${selectedIds.size} selected item(s)?`)) return; + + // Collect all descendants + const toDelete = new Set(selectedIds); + const collectDescendants = (parentId: string) => { + files.forEach((file) => { + if (file.parentId === parentId) { + toDelete.add(file.id); + collectDescendants(file.id); + } + }); + }; + + selectedIds.forEach((id) => collectDescendants(id)); + + // Remove files + setFiles((prev) => prev.filter((f) => !toDelete.has(f.id))); + setSelectedIds(new Set()); + }; + + const handleAddNote = (id: string) => { + const file = files.find((f) => f.id === id); + setEditingNoteId(id); + setNoteText(file?.note || ""); + setNoteDialogOpen(true); + }; + + const handleSaveNote = () => { + if (!editingNoteId) return; + + setFiles((prev) => + prev.map((f) => (f.id === editingNoteId ? { ...f, note: noteText } : f)) + ); + + setNoteDialogOpen(false); + setEditingNoteId(null); + setNoteText(""); + }; + + const renderFileIcon = (file: FileItem) => { + if (file.type === "folder" || file.type === "zip") { + return ; + } + + // Color based on file type + const colorMap: Record = { + text: "#22c55e", + nifti: "#f472b6", + hdf5: "#fb923c", + neurojsonText: Colors.purple, + neurojsonBinary: Colors.secondaryPurple, + office: "#38bdf8", + meta: Colors.yellow, + }; + + const color = colorMap[file.fileType || "other"] || "#9ca3af"; + return ; + }; + + // one item in the tree + const renderTreeItem = (file: FileItem, depth: number = 0) => { + const children = files.filter((f) => f.parentId === file.id); + const hasChildren = children.length > 0; + + // Check if file has content or children to show expand button + const hasContent = + file.content !== undefined && + file.content !== null && + file.content !== ""; + const canExpand = hasChildren || hasContent; + + const isExpanded = expandedIds.has(file.id); + const isSelected = selectedIds.has(file.id); + + return ( + + {/* File Row */} + handleToggleSelect(file.id)} + > + {/* Expand/Collapse Icon */} + {canExpand ? ( + { + e.stopPropagation(); + handleToggleExpand(file.id); + }} + sx={{ p: 0.25 }} + > + {isExpanded ? ( + + ) : ( + + )} + + ) : ( + + )} + + {/* File Icon */} + {renderFileIcon(file)} + + {/* File Name */} + + {file.name} + + + {/* Note Icon */} + { + e.stopPropagation(); + handleAddNote(file.id); + }} + sx={{ + p: 0.25, + color: file.note ? Colors.darkGreen : "text.secondary", + }} + title={file.note ? "Edit note" : "Add note"} + > + {file.note ? ( + + ) : ( + + )} + + + + {/* Show Note Preview */} + {file.note && isExpanded && ( + + + Note: {file.note} + + + )} + + {/* Show Content Preview */} + {hasContent && isExpanded && ( + + {file.content} + + )} + + {/* Children */} + {hasChildren && isExpanded && ( + {children.map((child) => renderTreeItem(child, depth + 1))} + )} + + ); + }; + + const rootFiles = files.filter((f) => f.parentId === null); + + if (files.length === 0) { + return ( + + + No files yet. Drop files to get started. + + + ); + } + + return ( + <> + + {/* Header */} + + + + Virtual File System + + + {files.length} item{files.length !== 1 ? "s" : ""} + + + + {selectedIds.size > 0 && ( + + )} + + + {/* File Tree */} + + {rootFiles.map((file) => renderTreeItem(file))} + + + {/* Footer Legend */} + + + + + Text + + + + NIfTI + + + + HDF5 + + + + NeuroJSON + + + + Office + + + + User Meta + + + + + + {/* Note Editor Dialog */} + setNoteDialogOpen(false)} + maxWidth="sm" + fullWidth + > + + {editingNoteId && files.find((f) => f.id === editingNoteId) + ? `Note for: ${files.find((f) => f.id === editingNoteId)?.name}` + : "Add Note"} + + + setNoteText(e.target.value)} + sx={{ + mt: 1, + "& .MuiInputLabel-root.Mui-focused": { + color: Colors.purple, + }, + "& .MuiOutlinedInput-root.Mui-focused .MuiOutlinedInput-notchedOutline": + { + borderColor: Colors.purple, + }, + }} + /> + + + + + + + + ); +}; + +export default FileTree; diff --git a/src/components/User/Dashboard/DatasetOrganizer/LLMPanel.tsx b/src/components/User/Dashboard/DatasetOrganizer/LLMPanel.tsx new file mode 100644 index 0000000..844a762 --- /dev/null +++ b/src/components/User/Dashboard/DatasetOrganizer/LLMPanel.tsx @@ -0,0 +1,382 @@ +// src/components/DatasetOrganizer/LLMPanel.tsx +import { Close, ContentCopy, Download, AutoAwesome } from "@mui/icons-material"; +import { + Box, + Paper, + Typography, + Button, + TextField, + Select, + MenuItem, + FormControl, + InputLabel, + CircularProgress, + IconButton, + Alert, +} from "@mui/material"; +import { Colors } from "design/theme"; +import React, { useState } from "react"; +import { FileItem } from "redux/projects/types/projects.interface"; + +interface LLMPanelProps { + files: FileItem[]; + onClose: () => void; +} + +interface LLMProvider { + name: string; + baseUrl: string; + models: Array<{ id: string; name: string }>; + noApiKey?: boolean; + isAnthropic?: boolean; +} + +const llmProviders: Record = { + ollama: { + name: "Ollama (Local)", + baseUrl: "http://localhost:11434/v1/chat/completions", + models: [ + { id: "qwen2.5-coder:latest", name: "Qwen 2.5 Coder" }, + { id: "codellama:latest", name: "Code Llama" }, + { id: "llama3.1:latest", name: "Llama 3.1" }, + ], + noApiKey: true, + }, + groq: { + name: "Groq", + baseUrl: "https://api.groq.com/openai/v1/chat/completions", + models: [ + { id: "llama-3.3-70b-versatile", name: "Llama 3.3 70B" }, + { id: "llama-3.1-8b-instant", name: "Llama 3.1 8B (Fast)" }, + ], + }, + anthropic: { + name: "Anthropic", + baseUrl: "https://api.anthropic.com/v1/messages", + models: [ + { id: "claude-sonnet-4-20250514", name: "Claude Sonnet 4" }, + { id: "claude-3-5-haiku-20241022", name: "Claude 3.5 Haiku" }, + ], + isAnthropic: true, + }, +}; + +const LLMPanel: React.FC = ({ files, onClose }) => { + const [provider, setProvider] = useState("groq"); + const [model, setModel] = useState("llama-3.3-70b-versatile"); + const [apiKey, setApiKey] = useState(""); + const [generatedScript, setGeneratedScript] = useState(""); + const [loading, setLoading] = useState(false); + const [error, setError] = useState(null); + const [status, setStatus] = useState(""); + + const currentProvider = llmProviders[provider]; + + const buildFileSummary = ( + parentId: string | null, + indent: string = "" + ): string => { + let summary = ""; + const children = files.filter((f) => f.parentId === parentId); + + children.forEach((child) => { + summary += `${indent}${child.name}`; + if (child.type === "folder" || child.type === "zip") { + summary += "/\n"; + summary += buildFileSummary(child.id, indent + " "); + } else { + if (child.contentType) summary += ` [${child.contentType}]`; + summary += "\n"; + if (child.content && child.content.length < 500) { + summary += `${indent} Content: ${child.content + .slice(0, 300) + .replace(/\n/g, " ")}\n`; + } + } + }); + + return summary; + }; + + const handleGenerate = async () => { + if (!currentProvider.noApiKey && !apiKey.trim()) { + setError("Please enter an API key"); + return; + } + + setLoading(true); + setError(null); + setStatus(`Generating script using ${currentProvider.name}...`); + + const fileSummary = buildFileSummary(null); + const prompt = `You are a neuroimaging data expert. Analyze the following file structure and metadata from a neuroimaging dataset and generate a Python script to convert it to BIDS format. + +FILE STRUCTURE AND METADATA: +${fileSummary} + +Please generate a Python script that: +1. Reads the source files +2. Renames and reorganizes them according to BIDS specification +3. Creates required BIDS metadata files (dataset_description.json, participants.tsv, etc.) +4. Handles the specific file types present (NIfTI, SNIRF, JSON sidecars, etc.) + +Include comments explaining the BIDS structure. +Output ONLY the Python script.`; + + try { + let response; + + if (currentProvider.isAnthropic) { + response = await fetch(currentProvider.baseUrl, { + method: "POST", + headers: { + "Content-Type": "application/json", + "x-api-key": apiKey, + "anthropic-version": "2023-06-01", + }, + body: JSON.stringify({ + model, + max_tokens: 4096, + messages: [{ role: "user", content: prompt }], + }), + }); + } else { + const headers: Record = { + "Content-Type": "application/json", + }; + + if (!currentProvider.noApiKey) { + headers["Authorization"] = `Bearer ${apiKey}`; + } + + response = await fetch(currentProvider.baseUrl, { + method: "POST", + headers, + body: JSON.stringify({ + model, + messages: [ + { + role: "system", + content: + "You are a neuroimaging data expert specializing in BIDS format conversion. Output only Python code.", + }, + { role: "user", content: prompt }, + ], + max_tokens: 4096, + temperature: 0.7, + }), + }); + } + + const data = await response.json(); + + if (!response.ok) { + throw new Error(data.error?.message || "Failed to generate script"); + } + + let script = ""; + if (currentProvider.isAnthropic) { + script = data.content[0].text; + } else { + script = data.choices[0].message.content; + } + + setGeneratedScript(script); + setStatus(`✓ Script generated using ${currentProvider.name}`); + } catch (err: any) { + setError(err.message || "Failed to generate script"); + setStatus("❌ Error generating script"); + } finally { + setLoading(false); + } + }; + + const handleCopy = () => { + navigator.clipboard.writeText(generatedScript); + setStatus("✓ Copied to clipboard!"); + setTimeout(() => setStatus(""), 2000); + }; + + const handleDownload = () => { + const blob = new Blob([generatedScript], { type: "text/plain" }); + const url = URL.createObjectURL(blob); + const a = document.createElement("a"); + a.href = url; + a.download = "bids_conversion_script.py"; + a.click(); + URL.revokeObjectURL(url); + }; + + return ( + + {/* Header */} + + + + AI-Generated BIDS Conversion Script + + + + + + + {/* Content */} + + {/* Left: Configuration */} + + + LLM Provider + + + + + Model + + + + {!currentProvider.noApiKey && ( + setApiKey(e.target.value)} + placeholder="Enter your API key..." + sx={{ mb: 2 }} + /> + )} + + + + {error && ( + + {error} + + )} + + {status && !error && ( + + {status} + + )} + + + {/* Right: Generated Script */} + + + + + + + + {generatedScript || + 'Configure your LLM provider and click "Generate Script"...'} + + + + + ); +}; + +export default LLMPanel; diff --git a/src/components/User/Dashboard/DatasetOrganizer/index.tsx b/src/components/User/Dashboard/DatasetOrganizer/index.tsx new file mode 100644 index 0000000..1e6db42 --- /dev/null +++ b/src/components/User/Dashboard/DatasetOrganizer/index.tsx @@ -0,0 +1,328 @@ +import DropZone from "./DropZone"; +import FileTree from "./FileTree"; +import LLMPanel from "./LLMPanel"; +import { ArrowBack, Save, GetApp, Psychology } from "@mui/icons-material"; +import { + Box, + Button, + Typography, + Alert, + CircularProgress, +} from "@mui/material"; +import { Colors } from "design/theme"; +import { useAppDispatch } from "hooks/useAppDispatch"; +import { useAppSelector } from "hooks/useAppSelector"; +import React, { useState, useEffect, useRef } from "react"; +import { useParams, useNavigate } from "react-router-dom"; +import { getProject, updateProject } from "redux/projects/projects.action"; +import { + selectCurrentProject, + selectProjectsLoading, + selectIsUpdatingProject, +} from "redux/projects/projects.selector"; +import { FileItem } from "redux/projects/types/projects.interface"; + +const DatasetOrganizer: React.FC = () => { + const { projectId } = useParams<{ projectId: string }>(); + const navigate = useNavigate(); + const dispatch = useAppDispatch(); + + const currentProject = useAppSelector(selectCurrentProject); + const loading = useAppSelector(selectProjectsLoading); + const isSaving = useAppSelector(selectIsUpdatingProject); + + // Local state for the organizer + const [files, setFiles] = useState([]); + const [selectedIds, setSelectedIds] = useState>(new Set()); + const [expandedIds, setExpandedIds] = useState>(new Set()); + const [showLLMPanel, setShowLLMPanel] = useState(false); + const [hasUnsavedChanges, setHasUnsavedChanges] = useState(false); + const [error, setError] = useState(null); + const isRestoringState = useRef(false); + + // Load project on mount + useEffect(() => { + if (projectId) { + dispatch(getProject({ projectId: parseInt(projectId) })); + } + }, [projectId, dispatch]); + + // Restore state from project when loaded + useEffect(() => { + if (currentProject && currentProject.extractor_state) { + isRestoringState.current = true; // add + const state = currentProject.extractor_state; + setFiles(state.files || []); + setSelectedIds(new Set(state.selectedIds || [])); + setExpandedIds(new Set(state.expandedIds || [])); + setHasUnsavedChanges(false); + + setTimeout(() => { + isRestoringState.current = false; + }, 0); + } + }, [currentProject]); + + // Track changes + useEffect(() => { + if (currentProject && !isRestoringState.current) { + console.log("before set has UnsavedChanges", hasUnsavedChanges); + setHasUnsavedChanges(true); + console.log("after it turns true", hasUnsavedChanges); + } + }, [files, selectedIds, expandedIds, currentProject]); + + const handleSave = async () => { + if (!currentProject) return; + + try { + await dispatch( + updateProject({ + projectId: currentProject.id, + extractor_state: { + files, + selectedIds: Array.from(selectedIds), + expandedIds: Array.from(expandedIds), + }, + }) + ).unwrap(); + + setHasUnsavedChanges(false); + setError(null); + } catch (err: any) { + setError(err.message || "Failed to save project"); + } + }; + + const handleExportJSON = () => { + const buildTree = (parentId: string | null): any => { + const children = files.filter((f) => f.parentId === parentId); + const result: any = {}; + + children.forEach((child) => { + if (child.type === "folder" || child.type === "zip") { + result[child.name] = { + _type: child.type, + _sourcePath: child.sourcePath || "", + _children: buildTree(child.id), + }; + } else { + const fileData: any = { + _type: "file", + _fileType: child.fileType || "other", + }; + if (child.sourcePath) fileData._sourcePath = child.sourcePath; + if (child.isUserMeta) fileData._isUserMeta = true; + if (child.content) fileData._content = child.content; + if (child.contentType) fileData._contentType = child.contentType; + if (child.note) fileData._note = child.note; + result[child.name] = fileData; + } + }); + + return result; + }; + + const exportData = { + _exportDate: new Date().toISOString(), + _totalFiles: files.length, + _projectName: currentProject?.name, + files: buildTree(null), + }; + + const blob = new Blob([JSON.stringify(exportData, null, 2)], { + type: "application/json", + }); + const url = URL.createObjectURL(blob); + const a = document.createElement("a"); + a.href = url; + a.download = `${ + currentProject?.name?.replace(/\s+/g, "_") || "bids_metadata" + }_export.json`; + a.click(); + URL.revokeObjectURL(url); + }; + + const handleBack = () => { + if (hasUnsavedChanges) { + const userWantsToSave = window.confirm( + "You have unsaved changes. Do you want to save before leaving?" + ); + + if (userWantsToSave) { + handleSave(); + navigate("/dashboard"); + } + // If user clicks Cancel, do nothing (stay on page) + } else { + // No unsaved changes, go back directly + navigate("/dashboard"); + } + }; + + if (loading && !currentProject) { + return ( + + + + ); + } + + if (!currentProject) { + return ( + + Project not found + + + ); + } + + return ( + + {/* Header */} + + + + + {currentProject.name} + {currentProject.description && ( + + {currentProject.description} + + )} + + + + + + + + + + + {error && ( + setError(null)} sx={{ m: 2 }}> + {error} + + )} + + {/* Main Content */} + + {/* Left: Drop Zone */} + + + + + {/* Right: File Tree */} + + + + {/* LLM Panel */} + {/* {showLLMPanel && ( + setShowLLMPanel(false)} /> + )} */} + + ); +}; + +export default DatasetOrganizer; diff --git a/src/components/User/Dashboard/DatasetOrganizer/utils/fileProcessors.ts b/src/components/User/Dashboard/DatasetOrganizer/utils/fileProcessors.ts new file mode 100644 index 0000000..e5f320b --- /dev/null +++ b/src/components/User/Dashboard/DatasetOrganizer/utils/fileProcessors.ts @@ -0,0 +1,353 @@ +// src/components/DatasetOrganizer/utils/fileProcessors.ts +import JSZip from "jszip"; +import pako from "pako"; +import * as pdfjsLib from "pdfjs-dist"; +import { FileItem } from "redux/projects/types/projects.interface"; + +pdfjsLib.GlobalWorkerOptions.workerSrc = + "https://cdnjs.cloudflare.com/ajax/libs/pdf.js/3.4.120/pdf.worker.min.js"; + +export const generateId = (): string => { + return Math.random().toString(36).substr(2, 9); +}; + +export const getFileType = (name: string): string => { + const lower = name.toLowerCase(); + if (lower.endsWith(".nii.gz") || lower.endsWith(".nii")) return "nifti"; + + const ext = lower.split(".").pop() || ""; + const fileTypes: Record = { + text: ["json", "md", "txt", "tsv", "bvec", "bval", "csv"], + nifti: ["nii"], + hdf5: ["snirf", "h5", "hdf5", "hdf"], + neurojsonText: ["jnii", "jmsh", "jdt", "jnirs"], + neurojsonBinary: ["jdb", "bjd", "bnii", "bmsh", "bnirs"], + office: ["docx", "pdf", "xlsx", "xls"], + }; + + for (const [type, extensions] of Object.entries(fileTypes)) { + if (extensions.includes(ext)) return type; + } + + return "other"; +}; + +// Extract PDF text content +const extractPDFContent = async (buffer: ArrayBuffer): Promise => { + try { + const loadingTask = pdfjsLib.getDocument({ data: buffer }); + const pdf = await loadingTask.promise; + + let fullText = `PDF: ${pdf.numPages} page${ + pdf.numPages !== 1 ? "s" : "" + }\n`; + fullText += "─".repeat(50) + "\n\n"; + + // Extract first 5 pages only + const maxPages = Math.min(pdf.numPages, 5); + + for (let i = 1; i <= maxPages; i++) { + const page = await pdf.getPage(i); + const textContent = await page.getTextContent(); + const pageText = textContent.items.map((item: any) => item.str).join(" "); + + fullText += `[Page ${i}]\n${pageText.slice(0, 1000)}\n\n`; + } + + if (pdf.numPages > 5) { + fullText += `... (${pdf.numPages - 5} more pages not shown)`; + } + + return fullText; + } catch (error: any) { + return `Error extracting PDF: ${error.message}`; + } +}; + +// Simple file processing - just store file info without deep parsing +export const processFile = async (file: File): Promise => { + const entry: FileItem = { + id: generateId(), + name: file.name, + type: "file", + parentId: null, + fileType: getFileType(file.name) as any, + sourcePath: file.name, + }; + + // Only extract content for text files + const fileType = getFileType(file.name); + const ext = file.name.toLowerCase().split(".").pop(); + + try { + if (fileType === "text") { + // Extract text files + const text = await file.text(); + entry.content = text.slice(0, 5000); + entry.contentType = "text"; + } else if (fileType === "office" && ext === "pdf") { + // ✅ EXTRACT PDF - This was missing! + console.log("Processing PDF file..."); + const buffer = await file.arrayBuffer(); + entry.content = await extractPDFContent(buffer); + entry.contentType = "office"; + console.log("PDF processed successfully"); + } else if (fileType === "office" && ext === "docx") { + // DOCX placeholder + entry.content = `DOCX file: ${file.name}\nSize: ${( + file.size / 1024 + ).toFixed(2)} KB\n\nNote: Install mammoth.js to extract DOCX content`; + entry.contentType = "office"; + } else if (fileType === "office" && (ext === "xlsx" || ext === "xls")) { + // Excel placeholder + entry.content = `Excel file: ${file.name}\nSize: ${( + file.size / 1024 + ).toFixed(2)} KB\n\nNote: Install xlsx.js to extract Excel content`; + entry.contentType = "office"; + } else { + // For other binary files, just store basic info + entry.content = `File: ${file.name}\nSize: ${(file.size / 1024).toFixed( + 2 + )} KB\nType: ${file.type || "Unknown"}`; + entry.contentType = fileType; + } + } catch (e: any) { + console.error("File processing error:", e); + entry.content = `Error reading file: ${e.message}`; + } + + // if (fileType === "text") { + // try { + // const text = await file.text(); + // entry.content = text.slice(0, 5000); // First 5000 chars + // entry.contentType = "text"; + // } catch (e: any) { + // entry.content = `Error reading file: ${e.message}`; + // } + // } else { + // // For binary files, just store basic info + // entry.content = `File: ${file.name}\nSize: ${(file.size / 1024).toFixed( + // 2 + // )} KB\nType: ${file.type || "Unknown"}`; + // entry.contentType = fileType; + // } + + return entry; +}; + +// Process ZIP files +export const processZip = async (file: File): Promise => { + const zip = new JSZip(); + const zipName = file.name; + + try { + const contents = await zip.loadAsync(file); + const entries: FileItem[] = []; + const pathMap: Record = {}; + const paths = Object.keys(contents.files).sort(); + + for (const path of paths) { + const zipEntry = contents.files[path]; + + // Skip directories + if (zipEntry.dir || path.endsWith("/")) continue; + + const parts = path.split("/"); + const fileName = parts.pop()!; + let currentPath = ""; + let parentId: string | null = null; + + // Create folder hierarchy + parts.forEach((part) => { + const folderPath = currentPath ? `${currentPath}/${part}` : part; + if (!pathMap[folderPath]) { + const folderId = generateId(); + pathMap[folderPath] = folderId; + entries.push({ + id: folderId, + name: part, + type: "folder", + parentId: parentId, + sourcePath: `${zipName}/${folderPath}`, + }); + } + parentId = pathMap[folderPath]; + currentPath = folderPath; + }); + + // Add file + const fileId = generateId(); + const fileType = getFileType(fileName); + const ext = fileName.toLowerCase().split(".").pop(); + + const entry: FileItem = { + id: fileId, + name: fileName, + type: "file", + parentId: parentId, + fileType: fileType as any, + sourcePath: `${zipName}/${path}`, + }; + + // Only extract text files + // if (fileType === "text") { + // try { + // const text = await zipEntry.async("text"); + // entry.content = text.slice(0, 5000); + // entry.contentType = "text"; + // } catch (e: any) { + // entry.content = `Error: ${e.message}`; + // } + // } else { + // // For binary files, just store info + // // entry.content = `ZIP Entry: ${fileName}\nCompressed Size: ${( + // // zipEntry._data.compressedSize / 1024 + // // ).toFixed(2)} KB`; + // // entry.contentType = fileType; + // // ✅ FIX 1: Get file size from the ZIP entry properly + // const arrayBuffer = await zipEntry.async("arraybuffer"); + // const sizeKB = (arrayBuffer.byteLength / 1024).toFixed(2); + // entry.content = `ZIP Entry: ${fileName}\nSize: ${sizeKB} KB`; + // entry.contentType = fileType; + // } + + // Extract content based on file type + if (fileType === "text") { + try { + const text = await zipEntry.async("text"); + entry.content = text.slice(0, 5000); + entry.contentType = "text"; + } catch (e: any) { + entry.content = `Error: ${e.message}`; + } + } else if (fileType === "office" && ext === "pdf") { + // ✅ EXTRACT PDF FROM ZIP - This was missing! + try { + console.log(`Extracting PDF from ZIP: ${fileName}`); + const arrayBuffer = await zipEntry.async("arraybuffer"); + entry.content = await extractPDFContent(arrayBuffer); + entry.contentType = "office"; + console.log("ZIP PDF extracted successfully"); + } catch (e: any) { + console.error("ZIP PDF extraction error:", e); + entry.content = `Error extracting PDF: ${e.message}`; + } + } else { + // For other binary files, just store info + const arrayBuffer = await zipEntry.async("arraybuffer"); + const sizeKB = (arrayBuffer.byteLength / 1024).toFixed(2); + entry.content = `ZIP Entry: ${fileName}\nSize: ${sizeKB} KB`; + entry.contentType = fileType; + } + + entries.push(entry); + } + + return entries; + } catch (e: any) { + console.error("Error processing ZIP:", e); + return [ + { + id: generateId(), + name: zipName, + type: "file", + parentId: null, + content: `Error processing ZIP: ${e.message}`, + fileType: "other", + }, + ]; + } +}; + +// Process folder - Web API limitation: can't fully traverse folders like Node.js +export const processFolder = async ( + folderEntry: FileSystemDirectoryEntry, + parentId: string | null +): Promise => { + const entries: FileItem[] = []; + const folderId = generateId(); + + // Add the folder itself + entries.push({ + id: folderId, + name: folderEntry.name, + type: "folder", + parentId: parentId, + sourcePath: folderEntry.fullPath, + }); + + // Note: Full folder traversal requires complex recursive logic + // For MVP, just create the folder entry + // You can enhance this later + + return entries; +}; + +// Helper: Extract basic NIfTI header info (without full parsing) +// export const extractNiftiBasicInfo = async ( +// buffer: ArrayBuffer +// ): Promise => { +// try { +// let data = buffer; +// const arr = new Uint8Array(buffer); + +// // Check if gzipped +// if (arr[0] === 0x1f && arr[1] === 0x8b) { +// const decompressed = pako.inflate(arr); +// data = decompressed.buffer; +// } + +// const view = new DataView(data); +// const sizeof_hdr = view.getInt32(0, true); + +// if (sizeof_hdr === 348) { +// return "NIfTI-1 format detected\nHeader size: 348 bytes"; +// } else if (sizeof_hdr === 540) { +// return "NIfTI-2 format detected\nHeader size: 540 bytes"; +// } else { +// return "Invalid NIfTI file"; +// } +// } catch (e: any) { +// return `Error parsing NIfTI: ${e.message}`; +// } +// }; + +// ✅ FIX 2: Correct type handling for pako +export const extractNiftiBasicInfo = async ( + buffer: ArrayBuffer +): Promise => { + try { + const arr = new Uint8Array(buffer); + + // Check if gzipped + if (arr[0] === 0x1f && arr[1] === 0x8b) { + // pako.inflate returns Uint8Array, so use it directly + const decompressed = pako.inflate(arr); + // Create a new DataView from the decompressed Uint8Array + const view = new DataView(decompressed.buffer); + const sizeof_hdr = view.getInt32(0, true); + + if (sizeof_hdr === 348) { + return "NIfTI-1 format detected\nHeader size: 348 bytes"; + } else if (sizeof_hdr === 540) { + return "NIfTI-2 format detected\nHeader size: 540 bytes"; + } else { + return "Invalid NIfTI file"; + } + } else { + // Not gzipped, use original buffer + const view = new DataView(buffer); + const sizeof_hdr = view.getInt32(0, true); + + if (sizeof_hdr === 348) { + return "NIfTI-1 format detected\nHeader size: 348 bytes"; + } else if (sizeof_hdr === 540) { + return "NIfTI-2 format detected\nHeader size: 540 bytes"; + } else { + return "Invalid NIfTI file"; + } + } + } catch (e: any) { + return `Error parsing NIfTI: ${e.message}`; + } +}; diff --git a/yarn.lock b/yarn.lock index ec7f415..c31cfa5 100644 --- a/yarn.lock +++ b/yarn.lock @@ -2268,6 +2268,21 @@ resolved "https://registry.yarnpkg.com/@leichtgewicht/ip-codec/-/ip-codec-2.0.5.tgz#4fc56c15c580b9adb7dc3c333a134e540b44bfb1" integrity sha512-Vo+PSpZG2/fmgmiNzYK9qWRh8h/CHrwD0mo1h1DzL4yzHNSfWYujGTYsWGreD000gcgmZ7K4Ys6Tx9TxtsKdDw== +"@mapbox/node-pre-gyp@^1.0.0": + version "1.0.11" + resolved "https://registry.yarnpkg.com/@mapbox/node-pre-gyp/-/node-pre-gyp-1.0.11.tgz#417db42b7f5323d79e93b34a6d7a2a12c0df43fa" + integrity sha512-Yhlar6v9WQgUp/He7BdgzOz8lqMQ8sU+jkCq7Wx8Myc5YFJLbEe7lgui/V7G1qB1DJykHSGwreceSaD60Y0PUQ== + dependencies: + detect-libc "^2.0.0" + https-proxy-agent "^5.0.0" + make-dir "^3.1.0" + node-fetch "^2.6.7" + nopt "^5.0.0" + npmlog "^5.0.1" + rimraf "^3.0.2" + semver "^7.3.5" + tar "^6.1.11" + "@mui/core-downloads-tracker@^5.17.1": version "5.17.1" resolved "https://registry.yarnpkg.com/@mui/core-downloads-tracker/-/core-downloads-tracker-5.17.1.tgz#49b88ecb68b800431b5c2f2bfb71372d1f1478fa" @@ -3461,6 +3476,11 @@ abab@^2.0.3, abab@^2.0.5: resolved "https://registry.yarnpkg.com/abab/-/abab-2.0.6.tgz#41b80f2c871d19686216b82309231cfd3cb3d291" integrity sha512-j2afSsaIENvHZN2B8GOpF566vZ5WVk5opAiMTvWgaQT8DkbOqsTfvNAvHoRGU2zzP8cPoqys+xHTRDWW8L+/BA== +abbrev@1: + version "1.1.1" + resolved "https://registry.yarnpkg.com/abbrev/-/abbrev-1.1.1.tgz#f8f2c887ad10bf67f634f005b6987fed3179aac8" + integrity sha512-nne9/IiQ/hzIhY6pdDnbBtz7DjPTKrY00P/zvPSm5pOFkl6xuGrGnXn/VtTNNfNtAfZ9/1RtehkszU9qcTii0Q== + accepts@~1.3.4, accepts@~1.3.8: version "1.3.8" resolved "https://registry.yarnpkg.com/accepts/-/accepts-1.3.8.tgz#0bf0be125b67014adcb0b0921e62db7bffe16b2e" @@ -3656,6 +3676,19 @@ aproba@^1.0.3: resolved "https://registry.yarnpkg.com/aproba/-/aproba-1.2.0.tgz#6802e6264efd18c790a1b0d517f0f2627bf2c94a" integrity sha512-Y9J6ZjXtoYh8RnXVCMOU/ttDmk1aBjunq9vO0ta5x85WDQiQfUF9sIPBITdbiiIVcBo03Hi3jMxigBtsddlXRw== +"aproba@^1.0.3 || ^2.0.0": + version "2.1.0" + resolved "https://registry.yarnpkg.com/aproba/-/aproba-2.1.0.tgz#75500a190313d95c64e871e7e4284c6ac219f0b1" + integrity sha512-tLIEcj5GuR2RSTnxNKdkK0dJ/GrC7P38sUkiDmDuHfsHmbagTFAxDVIBltoklXEVIQ/f14IL8IMJ5pn9Hez1Ew== + +are-we-there-yet@^2.0.0: + version "2.0.0" + resolved "https://registry.yarnpkg.com/are-we-there-yet/-/are-we-there-yet-2.0.0.tgz#372e0e7bd279d8e94c653aaa1f67200884bf3e1c" + integrity sha512-Ci/qENmwHnsYo9xKIcUJN5LeDKdJ6R1Z1j9V/J5wyq8nh/mYPEpIKJbBZXtZjG04HiK7zV/p6Vs9952MrMeUIw== + dependencies: + delegates "^1.0.0" + readable-stream "^3.6.0" + are-we-there-yet@~1.1.2: version "1.1.7" resolved "https://registry.yarnpkg.com/are-we-there-yet/-/are-we-there-yet-1.1.7.tgz#b15474a932adab4ff8a50d9adfa7e4e926f21146" @@ -4304,6 +4337,15 @@ caniuse-lite@^1.0.0, caniuse-lite@^1.0.30001702, caniuse-lite@^1.0.30001718: resolved "https://registry.yarnpkg.com/caniuse-lite/-/caniuse-lite-1.0.30001723.tgz#c4f3174f02089720736e1887eab345e09bb10944" integrity sha512-1R/elMjtehrFejxwmexeXAtae5UO9iSyFn6G/I806CYC/BLyyBk1EPhrKBkWhy6wM6Xnm47dSJQec+tLJ39WHw== +canvas@^2.11.0: + version "2.11.2" + resolved "https://registry.yarnpkg.com/canvas/-/canvas-2.11.2.tgz#553d87b1e0228c7ac0fc72887c3adbac4abbd860" + integrity sha512-ItanGBMrmRV7Py2Z+Xhs7cT+FNt5K0vPL4p9EZ/UX/Mu7hFbkxSjKF2KVtPwX7UYWp7dRKnrTvReflgrItJbdw== + dependencies: + "@mapbox/node-pre-gyp" "^1.0.0" + nan "^2.17.0" + simple-get "^3.0.3" + case-sensitive-paths-webpack-plugin@^2.4.0: version "2.4.0" resolved "https://registry.yarnpkg.com/case-sensitive-paths-webpack-plugin/-/case-sensitive-paths-webpack-plugin-2.4.0.tgz#db64066c6422eed2e08cc14b986ca43796dbc6d4" @@ -4404,6 +4446,11 @@ chownr@^1.1.1: resolved "https://registry.yarnpkg.com/chownr/-/chownr-1.1.4.tgz#6fc9d7b42d32a583596337666e7d08084da2cc6b" integrity sha512-jJ0bqzaylmJtVnNgzTeSOs8DPavpbYgEr/b0YL8/2GO3xJEhInFmhKMUnEJQjZumK7KXGFhUy89PrsJWlakBVg== +chownr@^2.0.0: + version "2.0.0" + resolved "https://registry.yarnpkg.com/chownr/-/chownr-2.0.0.tgz#15bfbe53d2eab4cf70f18a8cd68ebe5b3cb1dece" + integrity sha512-bIomtDF5KGpdogkLd9VspvFzk9KfpyyGlS8YFVZl7TGPBHL5snIOnxeshwVgPteQ9b4Eydl+pVbIyE1DcvCWgQ== + chrome-trace-event@^1.0.2: version "1.0.4" resolved "https://registry.yarnpkg.com/chrome-trace-event/-/chrome-trace-event-1.0.4.tgz#05bffd7ff928465093314708c93bdfa9bd1f0f5b" @@ -4522,6 +4569,11 @@ color-string@^1.6.0, color-string@^1.9.0: color-name "^1.0.0" simple-swizzle "^0.2.2" +color-support@^1.1.2: + version "1.1.3" + resolved "https://registry.yarnpkg.com/color-support/-/color-support-1.1.3.tgz#93834379a1cc9a0c61f82f52f0d04322251bd5a2" + integrity sha512-qiBjkpbMLO/HL68y+lh4q0/O1MZFj2RX6X/KmMa3+gJD3z+WwI1ZzDHysvqHGS3mP6mznPckpXmw1nI9cJjyRg== + color@^3.1.3: version "3.2.1" resolved "https://registry.yarnpkg.com/color/-/color-3.2.1.tgz#3544dc198caf4490c3ecc9a790b54fe9ff45e164" @@ -4679,7 +4731,7 @@ connect-history-api-fallback@^2.0.0: resolved "https://registry.yarnpkg.com/connect-history-api-fallback/-/connect-history-api-fallback-2.0.0.tgz#647264845251a0daf25b97ce87834cace0f5f1c8" integrity sha512-U73+6lQFmfiNPrYbXqr6kZ1i1wiRqXnp2nhMsINseWXO8lDau0LGEffJ8kQi4EjLZympVgRdvqjAgiZ1tgzDDA== -console-control-strings@^1.0.0, console-control-strings@~1.1.0: +console-control-strings@^1.0.0, console-control-strings@^1.1.0, console-control-strings@~1.1.0: version "1.1.0" resolved "https://registry.yarnpkg.com/console-control-strings/-/console-control-strings-1.1.0.tgz#3d7cf4464db6446ea644bf4b39507f9851008e8e" integrity sha512-ty/fTekppD2fIwRvnZAVdeOiGd1c7YXEixbgJTNzqcxJWKQnjJ/V1bNEEE6hygpM3WjwHFUVK6HTjWSzV4a8sQ== @@ -5447,6 +5499,11 @@ detect-libc@^1.0.3: resolved "https://registry.yarnpkg.com/detect-libc/-/detect-libc-1.0.3.tgz#fa137c4bd698edf55cd5cd02ac559f91a4c4ba9b" integrity sha512-pGjwhsmsp4kL2RTz08wcOlGN83otlqHeD/Z5T8GXZB+/YcpQ/dgo+lbU8ZsGxV0HIvqqxo9l7mqYwyYMD9bKDg== +detect-libc@^2.0.0: + version "2.1.2" + resolved "https://registry.yarnpkg.com/detect-libc/-/detect-libc-2.1.2.tgz#689c5dcdc1900ef5583a4cb9f6d7b473742074ad" + integrity sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ== + detect-libc@^2.0.3: version "2.0.4" resolved "https://registry.yarnpkg.com/detect-libc/-/detect-libc-2.0.4.tgz#f04715b8ba815e53b4d8109655b6508a6865a7e8" @@ -6748,6 +6805,13 @@ fs-extra@^9.0.0, fs-extra@^9.0.1: jsonfile "^6.0.1" universalify "^2.0.0" +fs-minipass@^2.0.0: + version "2.1.0" + resolved "https://registry.yarnpkg.com/fs-minipass/-/fs-minipass-2.1.0.tgz#7f5036fdbf12c63c169190cbe4199c852271f9fb" + integrity sha512-V/JgOLFCS+R6Vcq0slCuaeWEdNC3ouDlJMNIsacH2VtALiu9mV4LPrHc5cDl8k5aw6J8jwgWWpiTo5RYhmIzvg== + dependencies: + minipass "^3.0.0" + fs-monkey@^1.0.4: version "1.0.6" resolved "https://registry.yarnpkg.com/fs-monkey/-/fs-monkey-1.0.6.tgz#8ead082953e88d992cf3ff844faa907b26756da2" @@ -6785,6 +6849,21 @@ functions-have-names@^1.2.3: resolved "https://registry.yarnpkg.com/functions-have-names/-/functions-have-names-1.2.3.tgz#0404fe4ee2ba2f607f0e0ec3c80bae994133b834" integrity sha512-xckBUXyTIqT97tq2x2AMb+g163b5JFysYk0x4qxNFwbfQkmNZoiRHb6sPzI9/QV33WeuvVYBUIiD4NzNIyqaRQ== +gauge@^3.0.0: + version "3.0.2" + resolved "https://registry.yarnpkg.com/gauge/-/gauge-3.0.2.tgz#03bf4441c044383908bcfa0656ad91803259b395" + integrity sha512-+5J6MS/5XksCuXq++uFRsnUd7Ovu1XenbeuIuNRJxYWjgQbPuFhT14lAvsWfqfAmnwluf1OwMjz39HjfLPci0Q== + dependencies: + aproba "^1.0.3 || ^2.0.0" + color-support "^1.1.2" + console-control-strings "^1.0.0" + has-unicode "^2.0.1" + object-assign "^4.1.1" + signal-exit "^3.0.0" + string-width "^4.2.3" + strip-ansi "^6.0.1" + wide-align "^1.1.2" + gauge@~2.7.3: version "2.7.4" resolved "https://registry.yarnpkg.com/gauge/-/gauge-2.7.4.tgz#2c03405c7538c39d7eb37b317022e325fb018bf7" @@ -7043,7 +7122,7 @@ has-tostringtag@^1.0.2: dependencies: has-symbols "^1.0.3" -has-unicode@^2.0.0: +has-unicode@^2.0.0, has-unicode@^2.0.1: version "2.0.1" resolved "https://registry.yarnpkg.com/has-unicode/-/has-unicode-2.0.1.tgz#e0e6fe6a28cf51138855e086d1691e771de2a8b9" integrity sha512-8Rf9Y83NBReMnx0gFzA8JImQACstCYWUplepDa9xprwwtmgEZUF0h/i5xSA625zB/I37EtrswSST6OXxwaaIJQ== @@ -7278,6 +7357,11 @@ ignore@^5.2.0: resolved "https://registry.yarnpkg.com/ignore/-/ignore-5.3.2.tgz#3cd40e729f3643fd87cb04e50bf0eb722bc596f5" integrity sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g== +immediate@~3.0.5: + version "3.0.6" + resolved "https://registry.yarnpkg.com/immediate/-/immediate-3.0.6.tgz#9db1dbd0faf8de6fbe0f5dd5e56bb606280de69b" + integrity sha512-XXOFtyqDjNDAQxVfYxuF7g9Il/IbWmmlQg2MYKOH8ExIT1qg6xc4zyS3HaEEATgs1btfzxq15ciUiY7gjSXRGQ== + immer@^9.0.21, immer@^9.0.7: version "9.0.21" resolved "https://registry.yarnpkg.com/immer/-/immer-9.0.21.tgz#1e025ea31a40f24fb064f1fef23e931496330176" @@ -8508,6 +8592,16 @@ jsonpointer@^5.0.0, jsonpointer@^5.0.1: object.assign "^4.1.4" object.values "^1.1.6" +jszip@^3.10.1: + version "3.10.1" + resolved "https://registry.yarnpkg.com/jszip/-/jszip-3.10.1.tgz#34aee70eb18ea1faec2f589208a157d1feb091c2" + integrity sha512-xXDvecyTpGLrqFrvkrUSoxxfJI5AH7U8zxxtVclpsUtMCq4JQ290LY8AW5c7Ggnr/Y/oK+bQMbqK2qmtk3pN4g== + dependencies: + lie "~3.3.0" + pako "~1.0.2" + readable-stream "~2.3.6" + setimmediate "^1.0.5" + jwt-decode@^3.1.2: version "3.1.2" resolved "https://registry.yarnpkg.com/jwt-decode/-/jwt-decode-3.1.2.tgz#3fb319f3675a2df0c2895c8f5e9fa4b67b04ed59" @@ -8595,6 +8689,13 @@ levn@~0.3.0: prelude-ls "~1.1.2" type-check "~0.3.2" +lie@~3.3.0: + version "3.3.0" + resolved "https://registry.yarnpkg.com/lie/-/lie-3.3.0.tgz#dcf82dee545f46074daf200c7c1c5a08e0f40f6a" + integrity sha512-UaiMJzeWRlEujzAuw5LokY1L5ecNQYZKfmyZ9L7wDHb/p5etKaxXhohBcrw0EYby+G/NA52vRSN4N39dxHAIwQ== + dependencies: + immediate "~3.0.5" + lilconfig@^2.0.3: version "2.1.0" resolved "https://registry.yarnpkg.com/lilconfig/-/lilconfig-2.1.0.tgz#78e23ac89ebb7e1bfbf25b18043de756548e7f52" @@ -8936,16 +9037,41 @@ minimist@^1.2.0, minimist@^1.2.3, minimist@^1.2.6: resolved "https://registry.yarnpkg.com/minimist/-/minimist-1.2.8.tgz#c1a464e7693302e082a075cee0c057741ac4772c" integrity sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA== +minipass@^3.0.0: + version "3.3.6" + resolved "https://registry.yarnpkg.com/minipass/-/minipass-3.3.6.tgz#7bba384db3a1520d18c9c0e5251c3444e95dd94a" + integrity sha512-DxiNidxSEK+tHG6zOIklvNOwm3hvCrbUrdtzY74U6HKTJxvIDfOUL5W5P2Ghd3DTkhhKPYGqeNUIh5qcM4YBfw== + dependencies: + yallist "^4.0.0" + +minipass@^5.0.0: + version "5.0.0" + resolved "https://registry.yarnpkg.com/minipass/-/minipass-5.0.0.tgz#3e9788ffb90b694a5d0ec94479a45b5d8738133d" + integrity sha512-3FnjYuehv9k6ovOEbyOswadCDPX1piCfhV8ncmYtHOjuPwylVWsghTLo7rabjC3Rx5xD4HDx8Wm1xnMF7S5qFQ== + "minipass@^5.0.0 || ^6.0.2 || ^7.0.0", minipass@^7.1.2: version "7.1.2" resolved "https://registry.yarnpkg.com/minipass/-/minipass-7.1.2.tgz#93a9626ce5e5e66bd4db86849e7515e92340a707" integrity sha512-qOOzS1cBTWYF4BH8fVePDBOO9iptMnGUEZwNc/cMWnTV2nVLZ7VoNWEPHkYczZA0pdoA7dl6e7FL659nX9S2aw== +minizlib@^2.1.1: + version "2.1.2" + resolved "https://registry.yarnpkg.com/minizlib/-/minizlib-2.1.2.tgz#e90d3466ba209b932451508a11ce3d3632145931" + integrity sha512-bAxsR8BVfj60DWXHE3u30oHzfl4G7khkSuPW+qvpd7jFRHm7dLxOjUk1EHACJ/hxLY8phGJ0YhYHZo7jil7Qdg== + dependencies: + minipass "^3.0.0" + yallist "^4.0.0" + mkdirp-classic@^0.5.2, mkdirp-classic@^0.5.3: version "0.5.3" resolved "https://registry.yarnpkg.com/mkdirp-classic/-/mkdirp-classic-0.5.3.tgz#fa10c9115cc6d8865be221ba47ee9bed78601113" integrity sha512-gKLcREMhtuZRwRAfqP3RFW+TK4JqApVBtOIftVgjuABpAtpxhPGaDcfvbhNvD0B8iD1oUr/txX35NjcaY6Ns/A== +mkdirp@^1.0.3: + version "1.0.4" + resolved "https://registry.yarnpkg.com/mkdirp/-/mkdirp-1.0.4.tgz#3eb5ed62622756d79a5f0e2a221dfebad75c2f7e" + integrity sha512-vVqVZQyf3WLx2Shd0qJ9xuvqgAyKPLAiqITEtqW0oIUjzo3PePDd6fW9iFz30ef7Ysp/oiWqbhszeGWW2T6Gzw== + mkdirp@~0.5.1: version "0.5.6" resolved "https://registry.yarnpkg.com/mkdirp/-/mkdirp-0.5.6.tgz#7def03d2432dcae4ba1d611445c48396062255f6" @@ -8980,6 +9106,11 @@ mz@^2.7.0: object-assign "^4.0.1" thenify-all "^1.0.0" +nan@^2.17.0: + version "2.25.0" + resolved "https://registry.yarnpkg.com/nan/-/nan-2.25.0.tgz#937ed345e63d9481362a7942d49c4860d27eeabd" + integrity sha512-0M90Ag7Xn5KMLLZ7zliPWP3rT90P6PN+IzVFS0VqmnPktBk3700xUVv8Ikm9EUaUE5SDWdp/BIxdENzVznpm1g== + nanoid@^3.3.11, nanoid@^3.3.7: version "3.3.11" resolved "https://registry.yarnpkg.com/nanoid/-/nanoid-3.3.11.tgz#4f4f112cefbe303202f2199838128936266d185b" @@ -9102,7 +9233,7 @@ node-addon-api@^3.1.0: resolved "https://registry.yarnpkg.com/node-addon-api/-/node-addon-api-3.2.1.tgz#81325e0a2117789c0128dab65e7e38f07ceba161" integrity sha512-mmcei9JghVNDYydghQmeDX8KoAm0FAiYyIcUt/N4nhyAipB17pllZQDOJD2fotxABnt4Mdz+dKTO7eftLg4d0A== -node-fetch@^2.7.0: +node-fetch@^2.6.7, node-fetch@^2.7.0: version "2.7.0" resolved "https://registry.yarnpkg.com/node-fetch/-/node-fetch-2.7.0.tgz#d0f0fa6e3e2dc1d27efcd8ad99d550bda94d187d" integrity sha512-c4FRfUm/dbcWZ7U+1Wq0AwCyFL+3nt2bEw05wfxSz+DWpWsitgmSgYmy2dQdWyKC1694ELPqMs/YzUSNozLt8A== @@ -9124,6 +9255,13 @@ node-releases@^2.0.19: resolved "https://registry.yarnpkg.com/node-releases/-/node-releases-2.0.19.tgz#9e445a52950951ec4d177d843af370b411caf314" integrity sha512-xxOWJsBKtzAq7DY0J+DTzuz58K8e7sJbdgwkbMWQe8UYB6ekmsQ45q0M/tJDsGaZmbC+l7n57UV8Hl5tHxO9uw== +nopt@^5.0.0: + version "5.0.0" + resolved "https://registry.yarnpkg.com/nopt/-/nopt-5.0.0.tgz#530942bb58a512fccafe53fe210f13a25355dc88" + integrity sha512-Tbj67rffqceeLpcRXrT7vKAN8CwfPeIBgM7E6iBkmKLV7bEMwpGgYLGv0jACUsECaa/vuxP0IjEont6umdMgtQ== + dependencies: + abbrev "1" + normalize-path@^3.0.0, normalize-path@~3.0.0: version "3.0.0" resolved "https://registry.yarnpkg.com/normalize-path/-/normalize-path-3.0.0.tgz#0dcd69ff23a1c9b11fd0978316644a0388216a65" @@ -9156,6 +9294,16 @@ npmlog@^4.0.1, npmlog@^4.1.2: gauge "~2.7.3" set-blocking "~2.0.0" +npmlog@^5.0.1: + version "5.0.1" + resolved "https://registry.yarnpkg.com/npmlog/-/npmlog-5.0.1.tgz#f06678e80e29419ad67ab964e0fa69959c1eb8b0" + integrity sha512-AqZtDUWOMKs1G/8lwylVjrdYgqA4d9nu8hc+0gzRxlDb1I10+FHBGMXs6aiQHFdCUUlqH99MUMuLfzWDNDtfxw== + dependencies: + are-we-there-yet "^2.0.0" + console-control-strings "^1.1.0" + gauge "^3.0.0" + set-blocking "^2.0.0" + nth-check@^1.0.2, nth-check@^2.0.1: version "2.1.1" resolved "https://registry.yarnpkg.com/nth-check/-/nth-check-2.1.1.tgz#c9eab428effce36cd6b92c924bdb000ef1f1ed1d" @@ -9430,7 +9578,7 @@ package-json-from-dist@^1.0.0: resolved "https://registry.yarnpkg.com/package-json-from-dist/-/package-json-from-dist-1.0.1.tgz#4f1471a010827a86f94cfd9b0727e36d267de505" integrity sha512-UEZIS3/by4OC8vL3P2dTXRETpebLI2NiI5vIrjaD/5UtrkFX/tNbwjTSRAGC/+7CAo2pIcBaRgWmcBBHcsaCIw== -pako@1.0.11: +pako@1.0.11, pako@~1.0.2: version "1.0.11" resolved "https://registry.yarnpkg.com/pako/-/pako-1.0.11.tgz#6c9599d340d54dfd3946380252a35705a6b992bf" integrity sha512-4hLB8Py4zZce5s4yd9XzopqwVv/yGNhV1Bl8NTmCq1763HeK2+EwVTv+leGeL13Dnh2wfbqowVPXCIO0z4taYw== @@ -9550,6 +9698,28 @@ path-type@^4.0.0: resolved "https://registry.yarnpkg.com/path-type/-/path-type-4.0.0.tgz#84ed01c0a7ba380afe09d90a8c180dcd9d03043b" integrity sha512-gDKb8aZMDeD/tZWs9P6+q0J9Mwkdl6xMV8TjnGP3qJVJ06bdMgkbBlLU8IdfOsIsFz2BW1rNVT3XuNEl8zPAvw== +path2d-polyfill@^2.0.1: + version "2.1.1" + resolved "https://registry.yarnpkg.com/path2d-polyfill/-/path2d-polyfill-2.1.1.tgz#6098b7bf2fc24c306c6377bcd558b17ba437ea27" + integrity sha512-4Rka5lN+rY/p0CdD8+E+BFv51lFaFvJOrlOhyQ+zjzyQrzyh3ozmxd1vVGGDdIbUFSBtIZLSnspxTgPT0iJhvA== + dependencies: + path2d "0.1.1" + +path2d@0.1.1: + version "0.1.1" + resolved "https://registry.yarnpkg.com/path2d/-/path2d-0.1.1.tgz#d3c3886cd2252fb2a7830c27ea7bb9a862d937ea" + integrity sha512-/+S03c8AGsDYKKBtRDqieTJv2GlkMb0bWjnqOgtF6MkjdUQ9a8ARAtxWf9NgKLGm2+WQr6+/tqJdU8HNGsIDoA== + +pdfjs-dist@3.4.120: + version "3.4.120" + resolved "https://registry.yarnpkg.com/pdfjs-dist/-/pdfjs-dist-3.4.120.tgz#6f4222117157498f179c95dc4569fad6336a8fdd" + integrity sha512-B1hw9ilLG4m/jNeFA0C2A0PZydjxslP8ylU+I4XM7Bzh/xWETo9EiBV848lh0O0hLut7T6lK1V7cpAXv5BhxWw== + dependencies: + path2d-polyfill "^2.0.1" + web-streams-polyfill "^3.2.1" + optionalDependencies: + canvas "^2.11.0" + performance-now@^2.1.0: version "2.1.0" resolved "https://registry.yarnpkg.com/performance-now/-/performance-now-2.1.0.tgz#6309f4e0e5fa913ec1c69307ae364b4b377c9e7b" @@ -10641,7 +10811,7 @@ read-cache@^1.0.0: dependencies: pify "^2.3.0" -readable-stream@^2.0.1, readable-stream@^2.0.6, readable-stream@^2.2.2: +readable-stream@^2.0.1, readable-stream@^2.0.6, readable-stream@^2.2.2, readable-stream@~2.3.6: version "2.3.8" resolved "https://registry.yarnpkg.com/readable-stream/-/readable-stream-2.3.8.tgz#91125e8042bba1b9887f49345f6277027ce8be9b" integrity sha512-8p0AUk4XODgIewSi0l8Epjs+EVnWiK7NoDIEGU0HhE7+ZyY8D1IMY7odu5lRrFXGg71L15KG8QrPmum45RTtdA== @@ -10654,7 +10824,7 @@ readable-stream@^2.0.1, readable-stream@^2.0.6, readable-stream@^2.2.2: string_decoder "~1.1.1" util-deprecate "~1.0.1" -readable-stream@^3.0.6, readable-stream@^3.1.1, readable-stream@^3.4.0: +readable-stream@^3.0.6, readable-stream@^3.1.1, readable-stream@^3.4.0, readable-stream@^3.6.0: version "3.6.2" resolved "https://registry.yarnpkg.com/readable-stream/-/readable-stream-3.6.2.tgz#56a9b36ea965c00c5a93ef31eb111a0f11056967" integrity sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA== @@ -11139,7 +11309,7 @@ serve-static@1.16.2: parseurl "~1.3.3" send "0.19.0" -set-blocking@~2.0.0: +set-blocking@^2.0.0, set-blocking@~2.0.0: version "2.0.0" resolved "https://registry.yarnpkg.com/set-blocking/-/set-blocking-2.0.0.tgz#045f9782d011ae9a6803ddd382b24392b3d890f7" integrity sha512-KiKBS8AnWGEyLzofFfmvKwpdPzqiy16LvQfK3yv/fVH7Bj13/wl3JSR1J+rfgRE9q7xUJK4qvgS8raSOeLUehw== @@ -11582,7 +11752,7 @@ string-width@^1.0.1: is-fullwidth-code-point "^1.0.0" strip-ansi "^3.0.0" -"string-width@^1.0.2 || 2 || 3 || 4", string-width@^4.1.0, string-width@^4.2.0: +"string-width@^1.0.2 || 2 || 3 || 4", string-width@^4.1.0, string-width@^4.2.0, string-width@^4.2.3: version "4.2.3" resolved "https://registry.yarnpkg.com/string-width/-/string-width-4.2.3.tgz#269c7117d27b05ad2e536830a8ec895ef9c6d010" integrity sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g== @@ -11934,6 +12104,18 @@ tar-stream@^2.1.4: inherits "^2.0.3" readable-stream "^3.1.1" +tar@^6.1.11: + version "6.2.1" + resolved "https://registry.yarnpkg.com/tar/-/tar-6.2.1.tgz#717549c541bc3c2af15751bea94b1dd068d4b03a" + integrity sha512-DZ4yORTwrbTj/7MZYq2w+/ZFdI6OZ/f9SFHR+71gIVUZhOQPHzVCLpvRnPgyaMpfWxxk/4ONva3GQSyNIKRv6A== + dependencies: + chownr "^2.0.0" + fs-minipass "^2.0.0" + minipass "^5.0.0" + minizlib "^2.1.1" + mkdirp "^1.0.3" + yallist "^4.0.0" + temp-dir@^2.0.0: version "2.0.0" resolved "https://registry.yarnpkg.com/temp-dir/-/temp-dir-2.0.0.tgz#bde92b05bdfeb1516e804c9c00ad45177f31321e" @@ -12589,6 +12771,11 @@ wbuf@^1.1.0, wbuf@^1.7.3: dependencies: minimalistic-assert "^1.0.0" +web-streams-polyfill@^3.2.1: + version "3.3.3" + resolved "https://registry.yarnpkg.com/web-streams-polyfill/-/web-streams-polyfill-3.3.3.tgz#2073b91a2fdb1fbfbd401e7de0ac9f8214cecb4b" + integrity sha512-d2JWLCivmZYTSIoge9MsgFCZrt571BikcWGYkjC1khllbTeDlGqZ2D8vD8E/lJa8WGWbb7Plm8/XJYV7IJHZZw== + web-vitals@^2.1.0: version "2.1.4" resolved "https://registry.yarnpkg.com/web-vitals/-/web-vitals-2.1.4.tgz#76563175a475a5e835264d373704f9dde718290c" @@ -12853,7 +13040,7 @@ which@^2.0.1: dependencies: isexe "^2.0.0" -wide-align@^1.1.0: +wide-align@^1.1.0, wide-align@^1.1.2: version "1.1.5" resolved "https://registry.yarnpkg.com/wide-align/-/wide-align-1.1.5.tgz#df1d4c206854369ecf3c9a4898f1b23fbd9d15d3" integrity sha512-eDMORYaPNZ4sQIuuYPDHdQvf4gyCF9rEEV/yPxGfwPkRodwEgiMUUXTx/dex+Me0wxx53S+NgUHaP7y3MGlDmg== @@ -13138,6 +13325,11 @@ yallist@^3.0.2: resolved "https://registry.yarnpkg.com/yallist/-/yallist-3.1.1.tgz#dbb7daf9bfd8bac9ab45ebf602b8cbad0d5d08fd" integrity sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g== +yallist@^4.0.0: + version "4.0.0" + resolved "https://registry.yarnpkg.com/yallist/-/yallist-4.0.0.tgz#9bb92790d9c0effec63be73519e11a35019a3a72" + integrity sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A== + yaml@^1.10.0, yaml@^1.10.2, yaml@^1.7.2: version "1.10.2" resolved "https://registry.yarnpkg.com/yaml/-/yaml-1.10.2.tgz#2301c5ffbf12b467de8da2333a459e29e7920e4b" From 002d8c9bd10737ffca01f5bf2c1ca6f0593146d8 Mon Sep 17 00:00:00 2001 From: elainefan331 Date: Wed, 4 Feb 2026 14:38:27 -0500 Subject: [PATCH 40/65] feat: fix the track changes functions --- .../User/Dashboard/DatasetOrganizer/index.tsx | 45 ++++++++++--------- 1 file changed, 24 insertions(+), 21 deletions(-) diff --git a/src/components/User/Dashboard/DatasetOrganizer/index.tsx b/src/components/User/Dashboard/DatasetOrganizer/index.tsx index 1e6db42..40cde3f 100644 --- a/src/components/User/Dashboard/DatasetOrganizer/index.tsx +++ b/src/components/User/Dashboard/DatasetOrganizer/index.tsx @@ -38,7 +38,26 @@ const DatasetOrganizer: React.FC = () => { const [showLLMPanel, setShowLLMPanel] = useState(false); const [hasUnsavedChanges, setHasUnsavedChanges] = useState(false); const [error, setError] = useState(null); - const isRestoringState = useRef(false); + // Helper to mark as changed + const markAsChanged = () => { + setHasUnsavedChanges(true); + }; + + // Wrapper functions that mark as changed + const updateFiles = (updater: React.SetStateAction) => { + setFiles(updater); + markAsChanged(); + }; + + const updateSelectedIds = (updater: React.SetStateAction>) => { + setSelectedIds(updater); + markAsChanged(); + }; + + const updateExpandedIds = (updater: React.SetStateAction>) => { + setExpandedIds(updater); + markAsChanged(); + }; // Load project on mount useEffect(() => { @@ -50,28 +69,14 @@ const DatasetOrganizer: React.FC = () => { // Restore state from project when loaded useEffect(() => { if (currentProject && currentProject.extractor_state) { - isRestoringState.current = true; // add const state = currentProject.extractor_state; setFiles(state.files || []); setSelectedIds(new Set(state.selectedIds || [])); setExpandedIds(new Set(state.expandedIds || [])); setHasUnsavedChanges(false); - - setTimeout(() => { - isRestoringState.current = false; - }, 0); } }, [currentProject]); - // Track changes - useEffect(() => { - if (currentProject && !isRestoringState.current) { - console.log("before set has UnsavedChanges", hasUnsavedChanges); - setHasUnsavedChanges(true); - console.log("after it turns true", hasUnsavedChanges); - } - }, [files, selectedIds, expandedIds, currentProject]); - const handleSave = async () => { if (!currentProject) return; @@ -248,11 +253,9 @@ const DatasetOrganizer: React.FC = () => { sx={{ backgroundColor: Colors.darkGreen, color: Colors.lightGray, - // transition: "transform .2s ease", "&:hover": { backgroundColor: Colors.darkGreen, border: "none", - // transform: "scale(1.1)", }, }} > @@ -298,7 +301,7 @@ const DatasetOrganizer: React.FC = () => { { {/* Right: File Tree */} From 92dbf6fff0018319317b84a1e335b4f9ca083815 Mon Sep 17 00:00:00 2001 From: elainefan331 Date: Thu, 5 Feb 2026 12:01:35 -0500 Subject: [PATCH 41/65] feat: add full processFolder function in fileProcessors file --- .../DatasetOrganizer/utils/fileProcessors.ts | 158 ++++++++++++------ 1 file changed, 110 insertions(+), 48 deletions(-) diff --git a/src/components/User/Dashboard/DatasetOrganizer/utils/fileProcessors.ts b/src/components/User/Dashboard/DatasetOrganizer/utils/fileProcessors.ts index e5f320b..f6fbc1c 100644 --- a/src/components/User/Dashboard/DatasetOrganizer/utils/fileProcessors.ts +++ b/src/components/User/Dashboard/DatasetOrganizer/utils/fileProcessors.ts @@ -86,12 +86,10 @@ export const processFile = async (file: File): Promise => { entry.content = text.slice(0, 5000); entry.contentType = "text"; } else if (fileType === "office" && ext === "pdf") { - // ✅ EXTRACT PDF - This was missing! - console.log("Processing PDF file..."); + // Extract PDF const buffer = await file.arrayBuffer(); entry.content = await extractPDFContent(buffer); entry.contentType = "office"; - console.log("PDF processed successfully"); } else if (fileType === "office" && ext === "docx") { // DOCX placeholder entry.content = `DOCX file: ${file.name}\nSize: ${( @@ -116,22 +114,6 @@ export const processFile = async (file: File): Promise => { entry.content = `Error reading file: ${e.message}`; } - // if (fileType === "text") { - // try { - // const text = await file.text(); - // entry.content = text.slice(0, 5000); // First 5000 chars - // entry.contentType = "text"; - // } catch (e: any) { - // entry.content = `Error reading file: ${e.message}`; - // } - // } else { - // // For binary files, just store basic info - // entry.content = `File: ${file.name}\nSize: ${(file.size / 1024).toFixed( - // 2 - // )} KB\nType: ${file.type || "Unknown"}`; - // entry.contentType = fileType; - // } - return entry; }; @@ -189,28 +171,6 @@ export const processZip = async (file: File): Promise => { sourcePath: `${zipName}/${path}`, }; - // Only extract text files - // if (fileType === "text") { - // try { - // const text = await zipEntry.async("text"); - // entry.content = text.slice(0, 5000); - // entry.contentType = "text"; - // } catch (e: any) { - // entry.content = `Error: ${e.message}`; - // } - // } else { - // // For binary files, just store info - // // entry.content = `ZIP Entry: ${fileName}\nCompressed Size: ${( - // // zipEntry._data.compressedSize / 1024 - // // ).toFixed(2)} KB`; - // // entry.contentType = fileType; - // // ✅ FIX 1: Get file size from the ZIP entry properly - // const arrayBuffer = await zipEntry.async("arraybuffer"); - // const sizeKB = (arrayBuffer.byteLength / 1024).toFixed(2); - // entry.content = `ZIP Entry: ${fileName}\nSize: ${sizeKB} KB`; - // entry.contentType = fileType; - // } - // Extract content based on file type if (fileType === "text") { try { @@ -221,17 +181,27 @@ export const processZip = async (file: File): Promise => { entry.content = `Error: ${e.message}`; } } else if (fileType === "office" && ext === "pdf") { - // ✅ EXTRACT PDF FROM ZIP - This was missing! + // Extract PDF try { - console.log(`Extracting PDF from ZIP: ${fileName}`); const arrayBuffer = await zipEntry.async("arraybuffer"); entry.content = await extractPDFContent(arrayBuffer); entry.contentType = "office"; - console.log("ZIP PDF extracted successfully"); } catch (e: any) { console.error("ZIP PDF extraction error:", e); entry.content = `Error extracting PDF: ${e.message}`; } + } else if (fileType === "office" && ext === "docx") { + // ADD: DOCX placeholder + const arrayBuffer = await zipEntry.async("arraybuffer"); + const sizeKB = (arrayBuffer.byteLength / 1024).toFixed(2); + entry.content = `DOCX file: ${fileName}\nSize: ${sizeKB} KB\n\nNote: Install mammoth.js to extract DOCX content`; + entry.contentType = "office"; + } else if (fileType === "office" && (ext === "xlsx" || ext === "xls")) { + // ADD: Excel placeholder + const arrayBuffer = await zipEntry.async("arraybuffer"); + const sizeKB = (arrayBuffer.byteLength / 1024).toFixed(2); + entry.content = `Excel file: ${fileName}\nSize: ${sizeKB} KB\n\nNote: Install xlsx.js to extract Excel content`; + entry.contentType = "office"; } else { // For other binary files, just store info const arrayBuffer = await zipEntry.async("arraybuffer"); @@ -260,12 +230,38 @@ export const processZip = async (file: File): Promise => { }; // Process folder - Web API limitation: can't fully traverse folders like Node.js +// export const processFolder = async ( +// folderEntry: FileSystemDirectoryEntry, +// parentId: string | null +// ): Promise => { +// const entries: FileItem[] = []; +// const folderId = generateId(); + +// // Add the folder itself +// entries.push({ +// id: folderId, +// name: folderEntry.name, +// type: "folder", +// parentId: parentId, +// sourcePath: folderEntry.fullPath, +// }); + +// // Note: Full folder traversal requires complex recursive logic +// // For MVP, just create the folder entry +// // You can enhance this later + +// return entries; +// }; + +// src/components/DatasetOrganizer/utils/fileProcessors.ts + export const processFolder = async ( folderEntry: FileSystemDirectoryEntry, parentId: string | null ): Promise => { const entries: FileItem[] = []; const folderId = generateId(); + const basePath = folderEntry.name; // Add the folder itself entries.push({ @@ -273,12 +269,78 @@ export const processFolder = async ( name: folderEntry.name, type: "folder", parentId: parentId, - sourcePath: folderEntry.fullPath, + sourcePath: basePath, }); - // Note: Full folder traversal requires complex recursive logic - // For MVP, just create the folder entry - // You can enhance this later + // Helper: Promisify readEntries + const readEntries = ( + reader: FileSystemDirectoryReader + ): Promise => { + return new Promise((resolve, reject) => { + reader.readEntries(resolve, reject); + }); + }; + + // Helper: Promisify file() method + const getFile = (fileEntry: FileSystemFileEntry): Promise => { + return new Promise((resolve, reject) => { + fileEntry.file(resolve, reject); + }); + }; + + // Recursive traversal function + async function traverseDirectory( + dirEntry: FileSystemDirectoryEntry, + currentParentId: string, + currentPath: string + ): Promise { + const dirReader = dirEntry.createReader(); + let allEntries: FileSystemEntry[] = []; + + // Read all entries (may require multiple calls) + const readBatch = async (): Promise => { + const batch = await readEntries(dirReader); + if (batch.length > 0) { + allEntries = allEntries.concat(Array.from(batch)); + await readBatch(); // Keep reading + } + }; + + await readBatch(); + + // Process each entry + for (const entry of allEntries) { + const entryPath = `${currentPath}/${entry.name}`; + + if (entry.isFile) { + // Process file + const fileEntry = entry as FileSystemFileEntry; + const file = await getFile(fileEntry); + const fileItem = await processFile(file); + fileItem.parentId = currentParentId; + fileItem.sourcePath = entryPath; + entries.push(fileItem); + } else if (entry.isDirectory) { + // Process subfolder + const subFolderId = generateId(); + entries.push({ + id: subFolderId, + name: entry.name, + type: "folder", + parentId: currentParentId, + sourcePath: entryPath, + }); + await traverseDirectory( + entry as FileSystemDirectoryEntry, + subFolderId, + entryPath + ); + } + } + } + + // Start traversal + await traverseDirectory(folderEntry, folderId, basePath); return entries; }; From d90edfa576955453e6ca116683c9d72d2b0e5955 Mon Sep 17 00:00:00 2001 From: elainefan331 Date: Fri, 6 Feb 2026 13:39:50 -0500 Subject: [PATCH 42/65] feat: modify fileProcessors file to be able extract docx, excel,nifti, and snirf files --- package.json | 5 +- .../Dashboard/DatasetOrganizer/DropZone.tsx | 21 +- .../User/Dashboard/DatasetOrganizer/index.tsx | 9 +- .../DatasetOrganizer/utils/fileProcessors.ts | 456 ++++++++++++++---- src/types/jsfive.d.ts | 23 + yarn.lock | 138 +++++- 6 files changed, 542 insertions(+), 110 deletions(-) create mode 100644 src/types/jsfive.d.ts diff --git a/package.json b/package.json index 8e9a464..bca8298 100644 --- a/package.json +++ b/package.json @@ -32,10 +32,12 @@ "buffer": "6.0.3", "dayjs": "^1.11.10", "jquery": "^3.7.1", + "jsfive": "^0.4.0", "json-stringify-safe": "^5.0.1", "jszip": "^3.10.1", "jwt-decode": "^3.1.2", "lzma": "^2.3.2", + "mammoth": "^1.11.0", "numjs": "^0.16.1", "pako": "1.0.11", "path-browserify": "^1.0.1", @@ -54,7 +56,8 @@ "three": "0.145.0", "typescript": "^5.1.6", "uplot": "1.6.17", - "web-vitals": "^2.1.0" + "web-vitals": "^2.1.0", + "xlsx": "^0.18.5" }, "devDependencies": { "@babel/plugin-proposal-private-property-in-object": "^7.21.11", diff --git a/src/components/User/Dashboard/DatasetOrganizer/DropZone.tsx b/src/components/User/Dashboard/DatasetOrganizer/DropZone.tsx index fb0a450..9fff191 100644 --- a/src/components/User/Dashboard/DatasetOrganizer/DropZone.tsx +++ b/src/components/User/Dashboard/DatasetOrganizer/DropZone.tsx @@ -1,7 +1,7 @@ // src/components/DatasetOrganizer/DropZone.tsx import { processFile, processFolder, processZip } from "./utils/fileProcessors"; import { CloudUpload, Add } from "@mui/icons-material"; -import { Box, Typography, Paper, Button } from "@mui/material"; +import { Box, Typography, Paper, Button, TextField } from "@mui/material"; import { Colors } from "design/theme"; import React, { useState, useRef } from "react"; import { FileItem } from "redux/projects/types/projects.interface"; @@ -25,6 +25,7 @@ const DropZone: React.FC = ({ }) => { const [isDragging, setIsDragging] = useState(false); const fileInputRef = useRef(null); + const [basePath, setBasePath] = useState(""); const handleDragOver = (e: React.DragEvent) => { e.preventDefault(); @@ -68,7 +69,7 @@ const DropZone: React.FC = ({ const zipFiles = await processZip(file); setFiles((prev) => [...prev, ...zipFiles]); } else { - const fileItem = await processFile(file); + const fileItem = await processFile(file, basePath); setFiles((prev) => [...prev, fileItem]); } } @@ -82,10 +83,13 @@ const DropZone: React.FC = ({ const zipFiles = await processZip(file); setFiles((prev) => [...prev, ...zipFiles]); } else { - const fileItem = await processFile(file); + const fileItem = await processFile(file, basePath); setFiles((prev) => [...prev, fileItem]); } } + + // Reset input + e.target.value = ""; }; return ( @@ -139,6 +143,7 @@ const DropZone: React.FC = ({ Supports NIfTI, SNIRF, HDF5, NeuroJSON, folders, and ZIP archives + {files.length === 0 && ( <> = ({ accept=".nii,.nii.gz,.snirf,.h5,.hdf5,.jnii,.jmsh,.json,.txt,.md,.zip,.docx,.pdf,.xlsx,.xls" /> + setBasePath(e.target.value)} + fullWidth + size="small" + sx={{ mb: 2 }} + helperText="Enter the folder path where these files are located" + /> ); }; diff --git a/src/components/User/Dashboard/DatasetOrganizer/index.tsx b/src/components/User/Dashboard/DatasetOrganizer/index.tsx index 40cde3f..1e0cd29 100644 --- a/src/components/User/Dashboard/DatasetOrganizer/index.tsx +++ b/src/components/User/Dashboard/DatasetOrganizer/index.tsx @@ -180,7 +180,12 @@ const DatasetOrganizer: React.FC = () => { if (!currentProject) { return ( - + Project not found + + + + {selectedIds.size > 0 && ( + + {/* Meta File Editor Dialog */} + setMetaEditorOpen(false)} + maxWidth="sm" + fullWidth + > + + {metaType && metaConfigs[metaType].label} + + + setMetaFileName(e.target.value)} + sx={{ + mb: 2, + mt: 1, + "& .MuiInputLabel-root.Mui-focused": { color: Colors.purple }, + "& .MuiOutlinedInput-root.Mui-focused .MuiOutlinedInput-notchedOutline": + { + borderColor: Colors.purple, + }, + }} + /> + setMetaContent(e.target.value)} + sx={{ + "& .MuiInputLabel-root.Mui-focused": { color: Colors.purple }, + "& .MuiOutlinedInput-root.Mui-focused .MuiOutlinedInput-notchedOutline": + { + borderColor: Colors.purple, + }, + }} + /> + + + + + + ); }; diff --git a/src/components/User/Dashboard/DatasetOrganizer/utils/fileProcessors.ts b/src/components/User/Dashboard/DatasetOrganizer/utils/fileProcessors.ts index 89a2219..900595e 100644 --- a/src/components/User/Dashboard/DatasetOrganizer/utils/fileProcessors.ts +++ b/src/components/User/Dashboard/DatasetOrganizer/utils/fileProcessors.ts @@ -158,7 +158,10 @@ export const processFile = async ( }; // Process ZIP files -export const processZip = async (file: File): Promise => { +export const processZip = async ( + file: File, + basePath?: string +): Promise => { const zip = new JSZip(); const zipName = file.name; @@ -166,6 +169,16 @@ export const processZip = async (file: File): Promise => { const contents = await zip.loadAsync(file); const entries: FileItem[] = []; const pathMap: Record = {}; + // ✅ ADD: Create root ZIP container + const zipRootId = generateId(); + entries.push({ + id: zipRootId, + name: zipName, + type: "zip", + parentId: null, + sourcePath: zipName, + }); + const paths = Object.keys(contents.files).sort(); for (const path of paths) { @@ -177,7 +190,8 @@ export const processZip = async (file: File): Promise => { const parts = path.split("/"); const fileName = parts.pop()!; let currentPath = ""; - let parentId: string | null = null; + // let parentId: string | null = null; + let parentId: string | null = zipRootId; // Create folder hierarchy parts.forEach((part) => { @@ -185,12 +199,16 @@ export const processZip = async (file: File): Promise => { if (!pathMap[folderPath]) { const folderId = generateId(); pathMap[folderPath] = folderId; + const folderSourcePath = basePath + ? `${basePath}/${zipName}/${folderPath}`.replace(/\/+/g, "/") + : `${zipName}/${folderPath}`; entries.push({ id: folderId, name: part, type: "folder", parentId: parentId, - sourcePath: `${zipName}/${folderPath}`, + // sourcePath: `${zipName}/${folderPath}`, + sourcePath: folderSourcePath, }); } parentId = pathMap[folderPath]; @@ -202,13 +220,19 @@ export const processZip = async (file: File): Promise => { const fileType = getFileType(fileName); const ext = fileName.toLowerCase().split(".").pop(); + // Add basePath to file sourcePath + const fileSourcePath = basePath + ? `${basePath}/${zipName}/${path}`.replace(/\/+/g, "/") + : `${zipName}/${path}`; + const entry: FileItem = { id: fileId, name: fileName, type: "file", parentId: parentId, fileType: fileType as any, - sourcePath: `${zipName}/${path}`, + // sourcePath: `${zipName}/${path}`, + sourcePath: fileSourcePath, }; // Extract content based on file type @@ -323,11 +347,18 @@ export const processZip = async (file: File): Promise => { export const processFolder = async ( folderEntry: FileSystemDirectoryEntry, - parentId: string | null + parentId: string | null, + basePath?: string ): Promise => { const entries: FileItem[] = []; const folderId = generateId(); - const basePath = folderEntry.name; + // const basePath = folderEntry.name; + const folderName = folderEntry.name; + + // Add basePath to root folder sourcePath + const rootSourcePath = basePath + ? `${basePath}/${folderName}`.replace(/\/+/g, "/") + : folderName; // Add the folder itself entries.push({ @@ -335,7 +366,8 @@ export const processFolder = async ( name: folderEntry.name, type: "folder", parentId: parentId, - sourcePath: basePath, + // sourcePath: basePath, + sourcePath: rootSourcePath, }); // Helper: Promisify readEntries @@ -376,7 +408,11 @@ export const processFolder = async ( // Process each entry for (const entry of allEntries) { - const entryPath = `${currentPath}/${entry.name}`; + // const entryPath = `${currentPath}/${entry.name}`; + // Construct full path with basePath + const entryPath = basePath + ? `${basePath}/${currentPath}/${entry.name}`.replace(/\/+/g, "/") + : `${currentPath}/${entry.name}`; if (entry.isFile) { // Process file @@ -399,14 +435,15 @@ export const processFolder = async ( await traverseDirectory( entry as FileSystemDirectoryEntry, subFolderId, - entryPath + // entryPath + `${currentPath}/${entry.name}` ); } } } // Start traversal - await traverseDirectory(folderEntry, folderId, basePath); + await traverseDirectory(folderEntry, folderId, folderName); return entries; }; From 0e3bb1d7604a2e3a0b3cdf7a56ddbe5138858230 Mon Sep 17 00:00:00 2001 From: elainefan331 Date: Tue, 10 Feb 2026 09:32:37 -0500 Subject: [PATCH 44/65] feat: add LLM panel to dataset organizer --- backend/src/middleware/auth.middleware.js | 32 ++++ .../Dashboard/DatasetOrganizer/LLMPanel.tsx | 137 +++++++++++++++++- .../User/Dashboard/DatasetOrganizer/index.tsx | 4 +- 3 files changed, 163 insertions(+), 10 deletions(-) diff --git a/backend/src/middleware/auth.middleware.js b/backend/src/middleware/auth.middleware.js index 3f00fc5..37a5869 100644 --- a/backend/src/middleware/auth.middleware.js +++ b/backend/src/middleware/auth.middleware.js @@ -12,6 +12,19 @@ const setTokenCookie = (res, user) => { username: user.username, }; + // Add session start time for new logins + const payload = { + data: safeUser, + }; + + // If this is a new session, add the session start time + if (isNewSession) { + payload.sessionStart = Math.floor(Date.now() / 1000); // Unix timestamp + } else { + // Preserve the original session start time when refreshing + payload.sessionStart = user.sessionStart; + } + // sign JWT token const token = jwt.sign({ data: safeUser }, JWT_SECRET, { expiresIn: parseInt(JWT_EXPIRES_IN), @@ -54,6 +67,19 @@ const restoreUser = (req, res, next) => { // extract user id from token payload const { id } = jwtPayload.data; + // Check maximum session duration (e.g., 24 hours) + const MAX_SESSION_DURATION = parseInt( + process.env.MAX_SESSION_DURATION || "86400" + ); // 24 hours default + const currentTime = Math.floor(Date.now() / 1000); + const sessionAge = currentTime - jwtPayload.sessionStart; + + if (sessionAge > MAX_SESSION_DURATION) { + // Session has exceeded maximum duration + res.clearCookie("token"); + return next(); + } + //load user from database req.user = await User.findByPk(id, { attributes: { @@ -61,6 +87,12 @@ const restoreUser = (req, res, next) => { exclude: ["hashed_password"], // Never send password }, }); + + // refresh token - issue new token with extended expiration + if (req.user) { + req.user.sessionStart = jwtPayload.sessionStart; // Pass along the original session start + setTokenCookie(res, req.user, false); + } } catch (error) { res.clearCookie("token"); return next(); diff --git a/src/components/User/Dashboard/DatasetOrganizer/LLMPanel.tsx b/src/components/User/Dashboard/DatasetOrganizer/LLMPanel.tsx index 844a762..f012042 100644 --- a/src/components/User/Dashboard/DatasetOrganizer/LLMPanel.tsx +++ b/src/components/User/Dashboard/DatasetOrganizer/LLMPanel.tsx @@ -15,7 +15,7 @@ import { Alert, } from "@mui/material"; import { Colors } from "design/theme"; -import React, { useState } from "react"; +import React, { useState, useEffect } from "react"; import { FileItem } from "redux/projects/types/projects.interface"; interface LLMPanelProps { @@ -29,29 +29,47 @@ interface LLMProvider { models: Array<{ id: string; name: string }>; noApiKey?: boolean; isAnthropic?: boolean; + customUrl?: boolean; } const llmProviders: Record = { ollama: { - name: "Ollama (Local)", + name: "Ollama (Local Server)", baseUrl: "http://localhost:11434/v1/chat/completions", models: [ + { id: "qwen3-coder:30b", name: "Qwen 3 Coder" }, { id: "qwen2.5-coder:latest", name: "Qwen 2.5 Coder" }, { id: "codellama:latest", name: "Code Llama" }, { id: "llama3.1:latest", name: "Llama 3.1" }, + { id: "mistral:latest", name: "Mistral" }, + { id: "deepseek-coder:latest", name: "DeepSeek Coder" }, ], noApiKey: true, + customUrl: true, }, groq: { - name: "Groq", + name: "Groq (Free API Key - 14,400 req/day)", baseUrl: "https://api.groq.com/openai/v1/chat/completions", models: [ { id: "llama-3.3-70b-versatile", name: "Llama 3.3 70B" }, { id: "llama-3.1-8b-instant", name: "Llama 3.1 8B (Fast)" }, + { id: "mixtral-8x7b-32768", name: "Mixtral 8x7B" }, + ], + }, + openrouter: { + name: "OpenRouter (Free models available)", + baseUrl: "https://openrouter.ai/api/v1/chat/completions", + models: [ + { + id: "meta-llama/llama-3.1-8b-instruct:free", + name: "Llama 3.1 8B (Free)", + }, + { id: "google/gemma-2-9b-it:free", name: "Gemma 2 9B (Free)" }, + { id: "mistralai/mistral-7b-instruct:free", name: "Mistral 7B (Free)" }, ], }, anthropic: { - name: "Anthropic", + name: "Anthropic Claude (Paid)", baseUrl: "https://api.anthropic.com/v1/messages", models: [ { id: "claude-sonnet-4-20250514", name: "Claude Sonnet 4" }, @@ -59,17 +77,64 @@ const llmProviders: Record = { ], isAnthropic: true, }, + openai: { + name: "OpenAI (Paid)", + baseUrl: "https://api.openai.com/v1/chat/completions", + models: [ + { id: "gpt-4o-mini", name: "GPT-4o Mini" }, + { id: "gpt-4o", name: "GPT-4o" }, + ], + }, }; const LLMPanel: React.FC = ({ files, onClose }) => { - const [provider, setProvider] = useState("groq"); - const [model, setModel] = useState("llama-3.3-70b-versatile"); + const [provider, setProvider] = useState("ollama"); + const [model, setModel] = useState("qwen3-coder:30b"); + const [ollamaUrl, setOllamaUrl] = useState( + "http://huo.neu.edu:11434" + ); const [apiKey, setApiKey] = useState(""); const [generatedScript, setGeneratedScript] = useState(""); const [loading, setLoading] = useState(false); const [error, setError] = useState(null); const [status, setStatus] = useState(""); + const [panelHeight, setPanelHeight] = useState(350); + const [isResizing, setIsResizing] = useState(false); + + const handleMouseDown = (e: React.MouseEvent) => { + setIsResizing(true); + e.preventDefault(); + }; + + const handleMouseMove = (e: MouseEvent) => { + if (!isResizing) return; + + const newHeight = window.innerHeight - e.clientY; + if (newHeight >= 100 && newHeight <= window.innerHeight - 100) { + setPanelHeight(newHeight); + } + }; + + const handleMouseUp = () => { + setIsResizing(false); + }; + + // Add event listeners + useEffect(() => { + if (isResizing) { + document.addEventListener("mousemove", handleMouseMove); + document.addEventListener("mouseup", handleMouseUp); + document.body.style.cursor = "ns-resize"; + + return () => { + document.removeEventListener("mousemove", handleMouseMove); + document.removeEventListener("mouseup", handleMouseUp); + document.body.style.cursor = ""; + }; + } + }, [isResizing]); + const currentProvider = llmProviders[provider]; const buildFileSummary = ( @@ -126,7 +191,27 @@ Output ONLY the Python script.`; try { let response; - if (currentProvider.isAnthropic) { + if (provider === "ollama") { + const ollamaBaseUrl = ollamaUrl || "http://localhost:11434"; + response = await fetch(`${ollamaBaseUrl}/v1/chat/completions`, { + method: "POST", + headers: { + "Content-Type": "application/json", + }, + body: JSON.stringify({ + model, + messages: [ + { + role: "system", + content: + "You are a neuroimaging data expert specializing in BIDS format conversion. Output only Python code.", + }, + { role: "user", content: prompt }, + ], + stream: false, + }), + }); + } else if (currentProvider.isAnthropic) { response = await fetch(currentProvider.baseUrl, { method: "POST", headers: { @@ -214,7 +299,7 @@ Output ONLY the Python script.`; bottom: 0, left: 0, right: 0, - height: "50vh", + height: `${panelHeight}px`, zIndex: 1000, borderTop: 2, borderColor: Colors.purple, @@ -222,6 +307,30 @@ Output ONLY the Python script.`; flexDirection: "column", }} > + {/* Resize Handle */} + + + {/* Header */} + {/* ✅ ADD THIS: Ollama Server URL field */} + {provider === "ollama" && ( + setOllamaUrl(e.target.value)} + placeholder="http://localhost:11434" + sx={{ mb: 2 }} + /> + )} + {!currentProvider.noApiKey && ( { {/* LLM Panel */} - {/* {showLLMPanel && ( + {showLLMPanel && ( setShowLLMPanel(false)} /> - )} */} + )} ); }; From e0136d112518980806156ae37c3afee760eb78e7 Mon Sep 17 00:00:00 2001 From: elainefan331 Date: Sat, 14 Feb 2026 08:27:24 +0800 Subject: [PATCH 45/65] feat: implement sliding session window with automatic token refresh on user activity --- backend/src/middleware/auth.middleware.js | 24 ++++++++++--------- .../Dashboard/DatasetOrganizer/LLMPanel.tsx | 3 ++- 2 files changed, 15 insertions(+), 12 deletions(-) diff --git a/backend/src/middleware/auth.middleware.js b/backend/src/middleware/auth.middleware.js index 37a5869..b711717 100644 --- a/backend/src/middleware/auth.middleware.js +++ b/backend/src/middleware/auth.middleware.js @@ -2,9 +2,10 @@ const jwt = require("jsonwebtoken"); const { User } = require("../models"); const JWT_SECRET = process.env.JWT_SECRET; -const JWT_EXPIRES_IN = process.env.JWT_EXPIRES_IN || "3600"; +const JWT_EXPIRES_IN = process.env.JWT_EXPIRES_IN || 14400; // default 4 hours +const MAX_SESSION_DURATION = process.env.MAX_SESSION_DURATION || "86400"; // 24 hours default -const setTokenCookie = (res, user) => { +const setTokenCookie = (res, user, isNewSession = true) => { // create safe user object for token const safeUser = { id: user.id, @@ -12,12 +13,12 @@ const setTokenCookie = (res, user) => { username: user.username, }; - // Add session start time for new logins + // Add const payload = { data: safeUser, }; - // If this is a new session, add the session start time + // Add: If this is a new session, add the session start time if (isNewSession) { payload.sessionStart = Math.floor(Date.now() / 1000); // Unix timestamp } else { @@ -26,7 +27,11 @@ const setTokenCookie = (res, user) => { } // sign JWT token - const token = jwt.sign({ data: safeUser }, JWT_SECRET, { + // const token = jwt.sign({ data: safeUser }, JWT_SECRET, { + // expiresIn: parseInt(JWT_EXPIRES_IN), + // }); + // replace with + const token = jwt.sign(payload, JWT_SECRET, { expiresIn: parseInt(JWT_EXPIRES_IN), }); @@ -67,14 +72,11 @@ const restoreUser = (req, res, next) => { // extract user id from token payload const { id } = jwtPayload.data; - // Check maximum session duration (e.g., 24 hours) - const MAX_SESSION_DURATION = parseInt( - process.env.MAX_SESSION_DURATION || "86400" - ); // 24 hours default + // Add: Check maximum session duration const currentTime = Math.floor(Date.now() / 1000); const sessionAge = currentTime - jwtPayload.sessionStart; - if (sessionAge > MAX_SESSION_DURATION) { + if (sessionAge > parseInt(MAX_SESSION_DURATION)) { // Session has exceeded maximum duration res.clearCookie("token"); return next(); @@ -88,7 +90,7 @@ const restoreUser = (req, res, next) => { }, }); - // refresh token - issue new token with extended expiration + // Add: refresh token - issue new token with extended expiration if (req.user) { req.user.sessionStart = jwtPayload.sessionStart; // Pass along the original session start setTokenCookie(res, req.user, false); diff --git a/src/components/User/Dashboard/DatasetOrganizer/LLMPanel.tsx b/src/components/User/Dashboard/DatasetOrganizer/LLMPanel.tsx index f012042..9cd6b82 100644 --- a/src/components/User/Dashboard/DatasetOrganizer/LLMPanel.tsx +++ b/src/components/User/Dashboard/DatasetOrganizer/LLMPanel.tsx @@ -1,4 +1,3 @@ -// src/components/DatasetOrganizer/LLMPanel.tsx import { Close, ContentCopy, Download, AutoAwesome } from "@mui/icons-material"; import { Box, @@ -179,6 +178,8 @@ const LLMPanel: React.FC = ({ files, onClose }) => { FILE STRUCTURE AND METADATA: ${fileSummary} +all _sourcePath are relative to the root path /Users/elaine/Downloads + Please generate a Python script that: 1. Reads the source files 2. Renames and reorganizes them according to BIDS specification From 7b5772a637f2f41ada0e6f939b0ea549613f43a6 Mon Sep 17 00:00:00 2001 From: elainefan331 Date: Sat, 14 Feb 2026 08:46:44 +0800 Subject: [PATCH 46/65] feat: Add base directory path requirement to LLM script generation --- .../Dashboard/DatasetOrganizer/LLMPanel.tsx | 23 ++++++++++++++++--- 1 file changed, 20 insertions(+), 3 deletions(-) diff --git a/src/components/User/Dashboard/DatasetOrganizer/LLMPanel.tsx b/src/components/User/Dashboard/DatasetOrganizer/LLMPanel.tsx index 9cd6b82..08df793 100644 --- a/src/components/User/Dashboard/DatasetOrganizer/LLMPanel.tsx +++ b/src/components/User/Dashboard/DatasetOrganizer/LLMPanel.tsx @@ -97,6 +97,7 @@ const LLMPanel: React.FC = ({ files, onClose }) => { const [loading, setLoading] = useState(false); const [error, setError] = useState(null); const [status, setStatus] = useState(""); + const [baseDirectoryPath, setBaseDirectoryPath] = useState(""); // ✅ Add this const [panelHeight, setPanelHeight] = useState(350); const [isResizing, setIsResizing] = useState(false); @@ -178,7 +179,7 @@ const LLMPanel: React.FC = ({ files, onClose }) => { FILE STRUCTURE AND METADATA: ${fileSummary} -all _sourcePath are relative to the root path /Users/elaine/Downloads +all _sourcePath are relative to the root path ${baseDirectoryPath} Please generate a Python script that: 1. Reads the source files @@ -400,7 +401,7 @@ Output ONLY the Python script.`; - {/* ✅ ADD THIS: Ollama Server URL field */} + {/* Ollama Server URL field */} {provider === "ollama" && ( )} + {/* ADD THIS: Base Directory Path field (shows for ALL providers) */} + setBaseDirectoryPath(e.target.value)} + placeholder="Enter the folder path where these files are located" + helperText="e.g., /Users/name/datasets/study1 or C:\Data\Study1" + sx={{ mb: 2 }} + /> {!currentProvider.noApiKey && ( : } onClick={handleGenerate} - disabled={loading} + // disabled={loading} + disabled={loading || !baseDirectoryPath.trim()} // Add sx={{ background: `linear-gradient(135deg, ${Colors.purple} 0%, ${Colors.secondaryPurple} 100%)`, "&:hover": { background: `linear-gradient(135deg, ${Colors.secondaryPurple} 0%, ${Colors.purple} 100%)`, }, + "&.Mui-disabled": { + background: "#e0e0e0", + color: "#9e9e9e", + }, }} > {loading ? "Generating..." : "Generate Script"} From 3d60707d4d1c89b11cea62a8bd6c6f232e3a1ce3 Mon Sep 17 00:00:00 2001 From: elainefan331 Date: Mon, 23 Feb 2026 17:32:34 +0800 Subject: [PATCH 47/65] feat: decouple base directory from file paths for flexible path management --- .../Dashboard/DatasetOrganizer/DropZone.tsx | 31 ++++++++---- .../Dashboard/DatasetOrganizer/LLMPanel.tsx | 11 ++++- .../User/Dashboard/DatasetOrganizer/index.tsx | 30 ++++++++++-- .../DatasetOrganizer/utils/fileProcessors.ts | 47 ++++++++++--------- .../projects/types/projects.interface.ts | 1 + 5 files changed, 85 insertions(+), 35 deletions(-) diff --git a/src/components/User/Dashboard/DatasetOrganizer/DropZone.tsx b/src/components/User/Dashboard/DatasetOrganizer/DropZone.tsx index fccf21f..b929d61 100644 --- a/src/components/User/Dashboard/DatasetOrganizer/DropZone.tsx +++ b/src/components/User/Dashboard/DatasetOrganizer/DropZone.tsx @@ -9,6 +9,8 @@ import { FileItem } from "redux/projects/types/projects.interface"; interface DropZoneProps { files: FileItem[]; setFiles: React.Dispatch>; + baseDirectoryPath: string; // ✅ ADD this line + setBaseDirectoryPath: React.Dispatch>; // ✅ ADD this line selectedIds: Set; setSelectedIds: React.Dispatch>>; expandedIds: Set; @@ -18,6 +20,8 @@ interface DropZoneProps { const DropZone: React.FC = ({ files, setFiles, + baseDirectoryPath, // ✅ ADD this line + setBaseDirectoryPath, // ✅ ADD this line selectedIds, setSelectedIds, expandedIds, @@ -25,7 +29,7 @@ const DropZone: React.FC = ({ }) => { const [isDragging, setIsDragging] = useState(false); const fileInputRef = useRef(null); - const [basePath, setBasePath] = useState(""); + // const [basePath, setBasePath] = useState(""); // change const handleDragOver = (e: React.DragEvent) => { e.preventDefault(); @@ -59,17 +63,24 @@ const DropZone: React.FC = ({ // Process folders for (const folderEntry of folderEntries) { - const folderFiles = await processFolder(folderEntry, null, basePath); + // const folderFiles = await processFolder(folderEntry, null, basePath);// change + const folderFiles = await processFolder( + folderEntry, + null, + baseDirectoryPath + ); // add setFiles((prev) => [...prev, ...folderFiles]); } // Process files for (const file of fileItems) { if (file.name.toLowerCase().endsWith(".zip")) { - const zipFiles = await processZip(file, basePath); + // const zipFiles = await processZip(file, basePath);//change + const zipFiles = await processZip(file, baseDirectoryPath); //add setFiles((prev) => [...prev, ...zipFiles]); } else { - const fileItem = await processFile(file, basePath); + // const fileItem = await processFile(file, basePath);//change + const fileItem = await processFile(file, baseDirectoryPath); //add setFiles((prev) => [...prev, fileItem]); } } @@ -80,10 +91,12 @@ const DropZone: React.FC = ({ for (const file of selectedFiles) { if (file.name.toLowerCase().endsWith(".zip")) { - const zipFiles = await processZip(file, basePath); + // const zipFiles = await processZip(file, basePath);//change + const zipFiles = await processZip(file, baseDirectoryPath); //add setFiles((prev) => [...prev, ...zipFiles]); } else { - const fileItem = await processFile(file, basePath); + // const fileItem = await processFile(file, basePath); //change + const fileItem = await processFile(file, baseDirectoryPath); //add setFiles((prev) => [...prev, fileItem]); } } @@ -176,8 +189,10 @@ const DropZone: React.FC = ({ setBasePath(e.target.value)} + // value={basePath} // change + // onChange={(e) => setBasePath(e.target.value)} //change + value={baseDirectoryPath} // ✅ CHANGE: Use prop + onChange={(e) => setBaseDirectoryPath(e.target.value)} // ✅ CHANGE: Use prop setter fullWidth size="small" sx={{ mb: 2 }} diff --git a/src/components/User/Dashboard/DatasetOrganizer/LLMPanel.tsx b/src/components/User/Dashboard/DatasetOrganizer/LLMPanel.tsx index 08df793..07c92ad 100644 --- a/src/components/User/Dashboard/DatasetOrganizer/LLMPanel.tsx +++ b/src/components/User/Dashboard/DatasetOrganizer/LLMPanel.tsx @@ -19,6 +19,8 @@ import { FileItem } from "redux/projects/types/projects.interface"; interface LLMPanelProps { files: FileItem[]; + baseDirectoryPath: string; // ✅ ADD this line + setBaseDirectoryPath: (path: string) => void; // ✅ ADD this line onClose: () => void; } @@ -86,7 +88,12 @@ const llmProviders: Record = { }, }; -const LLMPanel: React.FC = ({ files, onClose }) => { +const LLMPanel: React.FC = ({ + files, + baseDirectoryPath, // ✅ ADD this line + setBaseDirectoryPath, // ✅ ADD this line + onClose, +}) => { const [provider, setProvider] = useState("ollama"); const [model, setModel] = useState("qwen3-coder:30b"); const [ollamaUrl, setOllamaUrl] = useState( @@ -97,7 +104,7 @@ const LLMPanel: React.FC = ({ files, onClose }) => { const [loading, setLoading] = useState(false); const [error, setError] = useState(null); const [status, setStatus] = useState(""); - const [baseDirectoryPath, setBaseDirectoryPath] = useState(""); // ✅ Add this + // const [baseDirectoryPath, setBaseDirectoryPath] = useState(""); // change const [panelHeight, setPanelHeight] = useState(350); const [isResizing, setIsResizing] = useState(false); diff --git a/src/components/User/Dashboard/DatasetOrganizer/index.tsx b/src/components/User/Dashboard/DatasetOrganizer/index.tsx index a53c1c5..964f9a7 100644 --- a/src/components/User/Dashboard/DatasetOrganizer/index.tsx +++ b/src/components/User/Dashboard/DatasetOrganizer/index.tsx @@ -38,6 +38,7 @@ const DatasetOrganizer: React.FC = () => { const [showLLMPanel, setShowLLMPanel] = useState(false); const [hasUnsavedChanges, setHasUnsavedChanges] = useState(false); const [error, setError] = useState(null); + const [baseDirectoryPath, setBaseDirectoryPath] = useState(""); // ✅ ADD this line // Helper to mark as changed const markAsChanged = () => { setHasUnsavedChanges(true); @@ -73,6 +74,7 @@ const DatasetOrganizer: React.FC = () => { setFiles(state.files || []); setSelectedIds(new Set(state.selectedIds || [])); setExpandedIds(new Set(state.expandedIds || [])); + setBaseDirectoryPath(state.baseDirectoryPath || ""); // ✅ ADD this line setHasUnsavedChanges(false); } }, [currentProject]); @@ -88,6 +90,7 @@ const DatasetOrganizer: React.FC = () => { files, selectedIds: Array.from(selectedIds), expandedIds: Array.from(expandedIds), + baseDirectoryPath, // ✅ ADD this line }, }) ).unwrap(); @@ -108,7 +111,13 @@ const DatasetOrganizer: React.FC = () => { if (child.type === "folder" || child.type === "zip") { result[child.name] = { _type: child.type, - _sourcePath: child.sourcePath || "", + // _sourcePath: child.sourcePath || "", //change + //add + _sourcePath: baseDirectoryPath + ? `${baseDirectoryPath}/${ + child.sourcePath || child.name + }`.replace(/\/+/g, "/") + : child.sourcePath || "", _children: buildTree(child.id), }; } else { @@ -116,7 +125,15 @@ const DatasetOrganizer: React.FC = () => { _type: "file", _fileType: child.fileType || "other", }; - if (child.sourcePath) fileData._sourcePath = child.sourcePath; + // if (child.sourcePath) fileData._sourcePath = child.sourcePath; // change + //add + if (child.sourcePath || baseDirectoryPath) { + fileData._sourcePath = baseDirectoryPath + ? `${baseDirectoryPath}/${ + child.sourcePath || child.name + }`.replace(/\/+/g, "/") + : child.sourcePath; + } if (child.isUserMeta) fileData._isUserMeta = true; if (child.content) fileData._content = child.content; if (child.contentType) fileData._contentType = child.contentType; @@ -307,6 +324,8 @@ const DatasetOrganizer: React.FC = () => { { {/* LLM Panel */} {showLLMPanel && ( - setShowLLMPanel(false)} /> + setShowLLMPanel(false)} + /> )} ); diff --git a/src/components/User/Dashboard/DatasetOrganizer/utils/fileProcessors.ts b/src/components/User/Dashboard/DatasetOrganizer/utils/fileProcessors.ts index 900595e..0ed9ee6 100644 --- a/src/components/User/Dashboard/DatasetOrganizer/utils/fileProcessors.ts +++ b/src/components/User/Dashboard/DatasetOrganizer/utils/fileProcessors.ts @@ -72,9 +72,9 @@ export const processFile = async ( basePath?: string ): Promise => { const relativePath = file.webkitRelativePath || file.name; - const fullPath = basePath - ? `${basePath}/${relativePath}`.replace(/\/+/g, "/") // Clean up double slashes - : relativePath; + // const fullPath = basePath + // ? `${basePath}/${relativePath}`.replace(/\/+/g, "/") // Clean up double slashes + // : relativePath; const entry: FileItem = { id: generateId(), name: file.name, @@ -82,7 +82,8 @@ export const processFile = async ( parentId: null, fileType: getFileType(file.name) as any, // sourcePath: file.name, - sourcePath: fullPath, // ← Now includes base path if provided + // sourcePath: fullPath, // ← Now includes base path if provided + sourcePath: relativePath, //add }; // Only extract content for text files @@ -199,16 +200,17 @@ export const processZip = async ( if (!pathMap[folderPath]) { const folderId = generateId(); pathMap[folderPath] = folderId; - const folderSourcePath = basePath - ? `${basePath}/${zipName}/${folderPath}`.replace(/\/+/g, "/") - : `${zipName}/${folderPath}`; + // const folderSourcePath = basePath + // ? `${basePath}/${zipName}/${folderPath}`.replace(/\/+/g, "/") + // : `${zipName}/${folderPath}`; entries.push({ id: folderId, name: part, type: "folder", parentId: parentId, // sourcePath: `${zipName}/${folderPath}`, - sourcePath: folderSourcePath, + // sourcePath: folderSourcePath, + sourcePath: `${zipName}/${folderPath}`, //add }); } parentId = pathMap[folderPath]; @@ -221,9 +223,9 @@ export const processZip = async ( const ext = fileName.toLowerCase().split(".").pop(); // Add basePath to file sourcePath - const fileSourcePath = basePath - ? `${basePath}/${zipName}/${path}`.replace(/\/+/g, "/") - : `${zipName}/${path}`; + // const fileSourcePath = basePath + // ? `${basePath}/${zipName}/${path}`.replace(/\/+/g, "/") + // : `${zipName}/${path}`; const entry: FileItem = { id: fileId, @@ -231,8 +233,8 @@ export const processZip = async ( type: "file", parentId: parentId, fileType: fileType as any, - // sourcePath: `${zipName}/${path}`, - sourcePath: fileSourcePath, + sourcePath: `${zipName}/${path}`, // only relative path + // sourcePath: fileSourcePath,//change }; // Extract content based on file type @@ -356,9 +358,9 @@ export const processFolder = async ( const folderName = folderEntry.name; // Add basePath to root folder sourcePath - const rootSourcePath = basePath - ? `${basePath}/${folderName}`.replace(/\/+/g, "/") - : folderName; + // const rootSourcePath = basePath + // ? `${basePath}/${folderName}`.replace(/\/+/g, "/") + // : folderName; // Add the folder itself entries.push({ @@ -367,7 +369,8 @@ export const processFolder = async ( type: "folder", parentId: parentId, // sourcePath: basePath, - sourcePath: rootSourcePath, + // sourcePath: rootSourcePath, + sourcePath: folderName, //add }); // Helper: Promisify readEntries @@ -408,11 +411,11 @@ export const processFolder = async ( // Process each entry for (const entry of allEntries) { - // const entryPath = `${currentPath}/${entry.name}`; + const entryPath = `${currentPath}/${entry.name}`; // Construct full path with basePath - const entryPath = basePath - ? `${basePath}/${currentPath}/${entry.name}`.replace(/\/+/g, "/") - : `${currentPath}/${entry.name}`; + // const entryPath = basePath + // ? `${basePath}/${currentPath}/${entry.name}`.replace(/\/+/g, "/") + // : `${currentPath}/${entry.name}`; if (entry.isFile) { // Process file @@ -420,7 +423,7 @@ export const processFolder = async ( const file = await getFile(fileEntry); const fileItem = await processFile(file); fileItem.parentId = currentParentId; - fileItem.sourcePath = entryPath; + fileItem.sourcePath = entryPath; // only relative path entries.push(fileItem); } else if (entry.isDirectory) { // Process subfolder diff --git a/src/redux/projects/types/projects.interface.ts b/src/redux/projects/types/projects.interface.ts index 257906a..a81100c 100644 --- a/src/redux/projects/types/projects.interface.ts +++ b/src/redux/projects/types/projects.interface.ts @@ -25,6 +25,7 @@ export interface ExtractorState { files: FileItem[]; selectedIds: string[]; expandedIds: string[]; + baseDirectoryPath?: string; //add } // Project Interface From fc98e29c606755b4ac47bfa3de5b996a8ea4d797 Mon Sep 17 00:00:00 2001 From: elainefan331 Date: Mon, 23 Feb 2026 20:44:21 +0800 Subject: [PATCH 48/65] feat: include base path context in AI prompt for accurate BIDS conversion scripts --- .../User/Dashboard/DatasetOrganizer/LLMPanel.tsx | 15 ++++++++++----- 1 file changed, 10 insertions(+), 5 deletions(-) diff --git a/src/components/User/Dashboard/DatasetOrganizer/LLMPanel.tsx b/src/components/User/Dashboard/DatasetOrganizer/LLMPanel.tsx index 07c92ad..07873b7 100644 --- a/src/components/User/Dashboard/DatasetOrganizer/LLMPanel.tsx +++ b/src/components/User/Dashboard/DatasetOrganizer/LLMPanel.tsx @@ -183,16 +183,21 @@ const LLMPanel: React.FC = ({ const fileSummary = buildFileSummary(null); const prompt = `You are a neuroimaging data expert. Analyze the following file structure and metadata from a neuroimaging dataset and generate a Python script to convert it to BIDS format. +BASE DIRECTORY PATH: ${baseDirectoryPath} + FILE STRUCTURE AND METADATA: ${fileSummary} -all _sourcePath are relative to the root path ${baseDirectoryPath} +IMPORTANT: All file paths shown above are RELATIVE paths. The actual files are located in the base directory: ${baseDirectoryPath} +For example, if you see "test.zip/sub-01/scan.nii", the full path is: ${baseDirectoryPath}/test.zip/sub-01/scan.nii Please generate a Python script that: -1. Reads the source files -2. Renames and reorganizes them according to BIDS specification -3. Creates required BIDS metadata files (dataset_description.json, participants.tsv, etc.) -4. Handles the specific file types present (NIfTI, SNIRF, JSON sidecars, etc.) +1. Uses the base directory path: ${baseDirectoryPath} +2. Reads the source files by combining base path + relative paths +3. Reads the source files +4. Renames and reorganizes them according to BIDS specification +5. Creates required BIDS metadata files (dataset_description.json, participants.tsv, etc.) +6. Handles the specific file types present (NIfTI, SNIRF, JSON sidecars, etc.) Include comments explaining the BIDS structure. Output ONLY the Python script.`; From d174aa57cbb413ab0941146da892140fc8bcdc63 Mon Sep 17 00:00:00 2001 From: elainefan331 Date: Tue, 24 Feb 2026 11:26:10 +0800 Subject: [PATCH 49/65] feat: test updated LLM prompts --- .../Dashboard/DatasetOrganizer/LLMPanel.tsx | 360 +++++++++++++++--- 1 file changed, 315 insertions(+), 45 deletions(-) diff --git a/src/components/User/Dashboard/DatasetOrganizer/LLMPanel.tsx b/src/components/User/Dashboard/DatasetOrganizer/LLMPanel.tsx index 07873b7..a8052ab 100644 --- a/src/components/User/Dashboard/DatasetOrganizer/LLMPanel.tsx +++ b/src/components/User/Dashboard/DatasetOrganizer/LLMPanel.tsx @@ -19,8 +19,8 @@ import { FileItem } from "redux/projects/types/projects.interface"; interface LLMPanelProps { files: FileItem[]; - baseDirectoryPath: string; // ✅ ADD this line - setBaseDirectoryPath: (path: string) => void; // ✅ ADD this line + baseDirectoryPath: string; + setBaseDirectoryPath: (path: string) => void; onClose: () => void; } @@ -90,8 +90,8 @@ const llmProviders: Record = { const LLMPanel: React.FC = ({ files, - baseDirectoryPath, // ✅ ADD this line - setBaseDirectoryPath, // ✅ ADD this line + baseDirectoryPath, + setBaseDirectoryPath, onClose, }) => { const [provider, setProvider] = useState("ollama"); @@ -104,8 +104,6 @@ const LLMPanel: React.FC = ({ const [loading, setLoading] = useState(false); const [error, setError] = useState(null); const [status, setStatus] = useState(""); - // const [baseDirectoryPath, setBaseDirectoryPath] = useState(""); // change - const [panelHeight, setPanelHeight] = useState(350); const [isResizing, setIsResizing] = useState(false); @@ -144,63 +142,332 @@ const LLMPanel: React.FC = ({ const currentProvider = llmProviders[provider]; - const buildFileSummary = ( - parentId: string | null, - indent: string = "" - ): string => { - let summary = ""; - const children = files.filter((f) => f.parentId === parentId); - - children.forEach((child) => { - summary += `${indent}${child.name}`; - if (child.type === "folder" || child.type === "zip") { - summary += "/\n"; - summary += buildFileSummary(child.id, indent + " "); - } else { - if (child.contentType) summary += ` [${child.contentType}]`; - summary += "\n"; - if (child.content && child.content.length < 500) { - summary += `${indent} Content: ${child.content - .slice(0, 300) - .replace(/\n/g, " ")}\n`; - } - } + // const buildFileSummary = ( + // parentId: string | null, + // indent: string = "" + // ): string => { + // let summary = ""; + // const children = files.filter((f) => f.parentId === parentId); + + // children.forEach((child) => { + // summary += `${indent}${child.name}`; + // if (child.type === "folder" || child.type === "zip") { + // summary += "/\n"; + // summary += buildFileSummary(child.id, indent + " "); + // } else { + // if (child.contentType) summary += ` [${child.contentType}]`; + // summary += "\n"; + // if (child.content && child.content.length < 500) { + // summary += `${indent} Content: ${child.content + // .slice(0, 300) + // .replace(/\n/g, " ")}\n`; + // } + // } + // }); + + // return summary; + // }; + // ✅ UPDATED: Build structured file summary + const buildFileSummary = (): string => { + let summary = "FILE STRUCTURE:\n"; + summary += "=" + "=".repeat(70) + "\n\n"; + + // Separate data files and user metadata + const dataFiles = files.filter((f) => !f.isUserMeta); + const metaFiles = files.filter((f) => f.isUserMeta); + + // List all data files with their types + summary += "DATA FILES:\n"; + dataFiles.forEach((f) => { + const indent = " ".repeat((f.sourcePath?.split("/").length || 1) - 1); + summary += `${indent}- ${f.name}`; + if (f.fileType) summary += ` [${f.fileType}]`; + if (f.sourcePath) summary += ` (${f.sourcePath})`; + summary += "\n"; }); + // Show user metadata content + if (metaFiles.length > 0) { + summary += "\nUSER-PROVIDED METADATA:\n"; + summary += "-".repeat(70) + "\n"; + metaFiles.forEach((f) => { + summary += `\n[${f.name}]:\n${f.content}\n`; + }); + } + return summary; }; + // ✅ NEW: Analyze file patterns + const analyzeFilePatterns = (): string => { + const dataFiles = files.filter((f) => f.type === "file" && !f.isUserMeta); + const filenames = dataFiles.map((f) => f.name); + + const extensions = [ + ...new Set( + filenames.map((name) => { + const parts = name.toLowerCase().split("."); + return parts.length > 1 ? parts[parts.length - 1] : "none"; + }) + ), + ]; + + return ` +FILENAME ANALYSIS: +${"=".repeat(70)} +Total data files: ${dataFiles.length} +File types: ${extensions.join(", ")} + +Sample filenames (first 10): +${filenames + .slice(0, 10) + .map((name) => ` - ${name}`) + .join("\n")} +${ + filenames.length > 10 ? `\n ... and ${filenames.length - 10} more files` : "" +} +`; + }; + + // ✅ NEW: Extract user context + const getUserContext = (): string => { + const readme = files.find((f) => f.name.toLowerCase().includes("readme")); + const instructions = files.find( + (f) => + f.name.toLowerCase().includes("conversion") || + f.name.toLowerCase().includes("instruction") + ); + const participants = files.find((f) => + f.name.toLowerCase().includes("participant") + ); + + let context = ""; + + if (readme?.content) { + context += `README:\n${readme.content}\n\n`; + } + + if (instructions?.content) { + context += `CONVERSION INSTRUCTIONS:\n${instructions.content}\n\n`; + } + + if (participants?.content) { + context += `PARTICIPANT INFO:\n${participants.content}\n\n`; + } + + return context || "No user-provided context available."; + }; + + // ✅ NEW: Collect file annotations + const getFileAnnotations = (): string => { + const filesWithNotes = files.filter((f) => f.note); + if (filesWithNotes.length === 0) return ""; + + return ` +FILE ANNOTATIONS (User Notes): +${filesWithNotes.map((f) => ` ${f.name}: ${f.note}`).join("\n")} +`; + }; + + // const handleGenerate = async () => { + // if (!currentProvider.noApiKey && !apiKey.trim()) { + // setError("Please enter an API key"); + // return; + // } + + // setLoading(true); + // setError(null); + // setStatus(`Generating script using ${currentProvider.name}...`); + + // const fileSummary = buildFileSummary(null); + // const prompt = `You are a neuroimaging data expert. Analyze the following file structure and metadata from a neuroimaging dataset and generate a Python script to convert it to BIDS format. + + // BASE DIRECTORY PATH: ${baseDirectoryPath} + + // FILE STRUCTURE AND METADATA: + // ${fileSummary} + + // IMPORTANT: All file paths shown above are RELATIVE paths. The actual files are located in the base directory: ${baseDirectoryPath} + // For example, if you see "test.zip/sub-01/scan.nii", the full path is: ${baseDirectoryPath}/test.zip/sub-01/scan.nii + + // Please generate a Python script that: + // 1. Uses the base directory path: ${baseDirectoryPath} + // 2. Reads the source files by combining base path + relative paths + // 3. Reads the source files + // 4. Renames and reorganizes them according to BIDS specification + // 5. Creates required BIDS metadata files (dataset_description.json, participants.tsv, etc.) + // 6. Handles the specific file types present (NIfTI, SNIRF, JSON sidecars, etc.) + + // Include comments explaining the BIDS structure. + // Output ONLY the Python script.`; + + // try { + // let response; + + // if (provider === "ollama") { + // const ollamaBaseUrl = ollamaUrl || "http://localhost:11434"; + // response = await fetch(`${ollamaBaseUrl}/v1/chat/completions`, { + // method: "POST", + // headers: { + // "Content-Type": "application/json", + // }, + // body: JSON.stringify({ + // model, + // messages: [ + // { + // role: "system", + // content: + // "You are a neuroimaging data expert specializing in BIDS format conversion. Output only Python code.", + // }, + // { role: "user", content: prompt }, + // ], + // stream: false, + // }), + // }); + // } else if (currentProvider.isAnthropic) { + // response = await fetch(currentProvider.baseUrl, { + // method: "POST", + // headers: { + // "Content-Type": "application/json", + // "x-api-key": apiKey, + // "anthropic-version": "2023-06-01", + // }, + // body: JSON.stringify({ + // model, + // max_tokens: 4096, + // messages: [{ role: "user", content: prompt }], + // }), + // }); + // } else { + // const headers: Record = { + // "Content-Type": "application/json", + // }; + + // if (!currentProvider.noApiKey) { + // headers["Authorization"] = `Bearer ${apiKey}`; + // } + + // response = await fetch(currentProvider.baseUrl, { + // method: "POST", + // headers, + // body: JSON.stringify({ + // model, + // messages: [ + // { + // role: "system", + // content: + // "You are a neuroimaging data expert specializing in BIDS format conversion. Output only Python code.", + // }, + // { role: "user", content: prompt }, + // ], + // max_tokens: 4096, + // temperature: 0.7, + // }), + // }); + // } + + // const data = await response.json(); + + // if (!response.ok) { + // throw new Error(data.error?.message || "Failed to generate script"); + // } + + // let script = ""; + // if (currentProvider.isAnthropic) { + // script = data.content[0].text; + // } else { + // script = data.choices[0].message.content; + // } + + // setGeneratedScript(script); + // setStatus(`✓ Script generated using ${currentProvider.name}`); + // } catch (err: any) { + // setError(err.message || "Failed to generate script"); + // setStatus("❌ Error generating script"); + // } finally { + // setLoading(false); + // } + // }; + const handleGenerate = async () => { if (!currentProvider.noApiKey && !apiKey.trim()) { setError("Please enter an API key"); return; } + if (!baseDirectoryPath.trim()) { + setError("Please enter a base directory path"); + return; + } + setLoading(true); setError(null); setStatus(`Generating script using ${currentProvider.name}...`); - const fileSummary = buildFileSummary(null); - const prompt = `You are a neuroimaging data expert. Analyze the following file structure and metadata from a neuroimaging dataset and generate a Python script to convert it to BIDS format. + const fileSummary = buildFileSummary(); + const filePatterns = analyzeFilePatterns(); + const userContext = getUserContext(); + const annotations = getFileAnnotations(); -BASE DIRECTORY PATH: ${baseDirectoryPath} + // ✅ UPDATED: Improved prompt + const prompt = `You are a neuroimaging BIDS conversion expert. -FILE STRUCTURE AND METADATA: -${fileSummary} +╔════════════════════════════════════════════════════════════════════╗ +║ TASK: Generate Python script to convert dataset to BIDS format ║ +╚════════════════════════════════════════════════════════════════════╝ -IMPORTANT: All file paths shown above are RELATIVE paths. The actual files are located in the base directory: ${baseDirectoryPath} -For example, if you see "test.zip/sub-01/scan.nii", the full path is: ${baseDirectoryPath}/test.zip/sub-01/scan.nii +BASE DIRECTORY: ${baseDirectoryPath} -Please generate a Python script that: -1. Uses the base directory path: ${baseDirectoryPath} -2. Reads the source files by combining base path + relative paths -3. Reads the source files -4. Renames and reorganizes them according to BIDS specification -5. Creates required BIDS metadata files (dataset_description.json, participants.tsv, etc.) -6. Handles the specific file types present (NIfTI, SNIRF, JSON sidecars, etc.) +${fileSummary} -Include comments explaining the BIDS structure. -Output ONLY the Python script.`; +${filePatterns} + +USER-PROVIDED CONTEXT: +━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ +${userContext} + +${annotations} + +REQUIREMENTS: +━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ +1. CRITICAL: All file paths are RELATIVE to base directory: ${baseDirectoryPath} + Example: If you see "test.zip/scan.nii", the full path is: + ${baseDirectoryPath}/test.zip/scan.nii + +2. Analyze filename patterns to determine: + - Number of subjects (look for repeating patterns in filenames) + - Session structure (if any) + - Data modalities (anat, func, dwi, fmap, etc.) + - Task names (for functional scans) + +3. Use the user-provided context (README, instructions, participant info) to: + - Extract dataset name and description + - Identify subject groupings + - Understand data acquisition details + - Apply any special conversion requirements + +4. Generate a complete, runnable Python script that: + ✓ Imports necessary libraries (os, shutil, json, nibabel if needed) + ✓ Creates proper BIDS directory structure + ✓ Generates dataset_description.json with actual dataset information + ✓ Creates participants.tsv if participant info is available + ✓ Copies/renames files to BIDS naming convention (sub-XX_suffix.nii.gz) + ✓ Creates JSON sidecars for imaging files with relevant metadata + ✓ Handles specific file types present + ✓ Includes error handling and progress messages + +BIDS NAMING CONVENTIONS: +━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ +- Subject IDs: sub-