Skip to content

Commit b13fa41

Browse files
committed
Persist OpenProjectDialog preferences; add user settings service & API
- Add client-side userSettingsService to load/save open-project dialog prefs - Add /api/user-settings endpoint to GET/PATCH user preferences (auth checked) - Extend StorageService with user-settings blob handling: sanitize, merge, persist, load, update - Integrate settings into OpenProjectDialog: - load saved tagFilter/sortOption on open, apply normalized tags - persist changes only when different from last saved (lastSavedPreferencesRef) - add settingsReady flag and avoid saving until initialized - wrap loadMindMaps in useCallback and minor selection/state resets - Ensure tag normalization and sort option typing across layers
1 parent 48199a1 commit b13fa41

File tree

4 files changed

+553
-16
lines changed

4 files changed

+553
-16
lines changed

src/components/editor/open-project-dialog.tsx

Lines changed: 131 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,11 @@
1-
import { useEffect, useMemo, useState, type ChangeEvent } from "react";
1+
import {
2+
useCallback,
3+
useEffect,
4+
useMemo,
5+
useRef,
6+
useState,
7+
type ChangeEvent,
8+
} from "react";
29
import {
310
Autocomplete,
411
Dialog,
@@ -29,6 +36,11 @@ import { MindMapMetadata } from "@/lib/storage";
2936
import { logger } from "@/services/logger";
3037
import { MindMapNode } from "@/model/types";
3138
import { useTheme } from "@/components/providers/ThemeProvider";
39+
import {
40+
userSettingsService,
41+
type OpenProjectDialogPreferences,
42+
type ProjectSortOption,
43+
} from "@/services/user-settings-service";
3244

3345
const normalizeTagsArray = (values: readonly unknown[]): string[] => {
3446
const seen = new Set<string>();
@@ -51,6 +63,9 @@ const normalizeTagsArray = (values: readonly unknown[]): string[] => {
5163
return normalized;
5264
};
5365

66+
const areTagFiltersEqual = (a: readonly string[], b: readonly string[]) =>
67+
a.length === b.length && a.every((value, index) => value === b[index]);
68+
5469
interface OpenProjectDialogProps {
5570
open: boolean;
5671
onClose: () => void;
@@ -62,7 +77,11 @@ export function OpenProjectDialog({ open, onClose }: OpenProjectDialogProps) {
6277
const [deleteConfirmOpen, setDeleteConfirmOpen] = useState(false);
6378
const [selectedMindMapIds, setSelectedMindMapIds] = useState<string[]>([]);
6479
const [tagFilter, setTagFilter] = useState<string[]>([]);
65-
const [sortOption, setSortOption] = useState<"name" | "lastModified">("name");
80+
const [sortOption, setSortOption] = useState<ProjectSortOption>("name");
81+
const [settingsReady, setSettingsReady] = useState(false);
82+
const lastSavedPreferencesRef = useRef<OpenProjectDialogPreferences | null>(
83+
null,
84+
);
6685
const router = useRouter();
6786
const { palette } = useTheme();
6887

@@ -99,19 +118,7 @@ export function OpenProjectDialog({ open, onClose }: OpenProjectDialogProps) {
99118
});
100119
}, [mindMaps, sortOption, tagFilter]);
101120

102-
useEffect(() => {
103-
if (open) {
104-
setSelectedMindMapIds([]);
105-
setTagFilter([]);
106-
setSortOption("name");
107-
loadMindMaps();
108-
} else {
109-
setSelectedMindMapIds([]);
110-
setTagFilter([]);
111-
}
112-
}, [open]);
113-
114-
const loadMindMaps = async () => {
121+
const loadMindMaps = useCallback(async () => {
115122
try {
116123
setLoading(true);
117124
const response = await fetch("/api/mindmaps/list");
@@ -147,7 +154,115 @@ export function OpenProjectDialog({ open, onClose }: OpenProjectDialogProps) {
147154
} finally {
148155
setLoading(false);
149156
}
150-
};
157+
}, []);
158+
159+
useEffect(() => {
160+
if (!open) {
161+
setSelectedMindMapIds([]);
162+
setSettingsReady(false);
163+
setTagFilter([]);
164+
setSortOption("name");
165+
lastSavedPreferencesRef.current = null;
166+
167+
return;
168+
}
169+
170+
let cancelled = false;
171+
172+
setSelectedMindMapIds([]);
173+
174+
const initializeSettings = async () => {
175+
try {
176+
const preferences =
177+
await userSettingsService.getOpenProjectDialogSettings();
178+
179+
if (cancelled) {
180+
return;
181+
}
182+
183+
const normalizedTagFilter = normalizeTagsArray(preferences.tagFilter);
184+
const resolvedSortOption: ProjectSortOption =
185+
preferences.sortOption === "lastModified" ? "lastModified" : "name";
186+
187+
setTagFilter(normalizedTagFilter);
188+
setSortOption(resolvedSortOption);
189+
lastSavedPreferencesRef.current = {
190+
tagFilter: [...normalizedTagFilter],
191+
sortOption: resolvedSortOption,
192+
};
193+
} catch (error) {
194+
logger.error("Error loading user settings:", error);
195+
196+
if (cancelled) {
197+
return;
198+
}
199+
200+
setTagFilter([]);
201+
setSortOption("name");
202+
lastSavedPreferencesRef.current = {
203+
tagFilter: [],
204+
sortOption: "name",
205+
};
206+
} finally {
207+
if (!cancelled) {
208+
setSettingsReady(true);
209+
loadMindMaps();
210+
}
211+
}
212+
};
213+
214+
initializeSettings();
215+
216+
return () => {
217+
cancelled = true;
218+
};
219+
}, [loadMindMaps, open]);
220+
221+
useEffect(() => {
222+
if (!open || !settingsReady) {
223+
return;
224+
}
225+
226+
const currentPreferences: OpenProjectDialogPreferences = {
227+
tagFilter,
228+
sortOption,
229+
};
230+
231+
const lastSaved = lastSavedPreferencesRef.current;
232+
233+
if (
234+
lastSaved &&
235+
lastSaved.sortOption === currentPreferences.sortOption &&
236+
areTagFiltersEqual(lastSaved.tagFilter, currentPreferences.tagFilter)
237+
) {
238+
return;
239+
}
240+
241+
let cancelled = false;
242+
243+
const persistPreferences = async () => {
244+
try {
245+
await userSettingsService.saveOpenProjectDialogSettings(
246+
currentPreferences,
247+
);
248+
249+
if (!cancelled) {
250+
lastSavedPreferencesRef.current = {
251+
tagFilter: [...currentPreferences.tagFilter],
252+
sortOption: currentPreferences.sortOption,
253+
};
254+
}
255+
} catch (error) {
256+
logger.error("Error saving user settings:", error);
257+
}
258+
};
259+
260+
persistPreferences();
261+
262+
return () => {
263+
cancelled = true;
264+
};
265+
}, [open, settingsReady, sortOption, tagFilter]);
151266

152267
const handleMindMapSelect = (mindMapId: string) => {
153268
router.push(`/editor/${mindMapId}`);

src/lib/storage.ts

Lines changed: 172 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -45,6 +45,17 @@ export interface MindMapMetadata {
4545
tags?: string[];
4646
}
4747

48+
export type UserSettingsSortOption = "name" | "lastModified";
49+
50+
export interface OpenProjectDialogSettings {
51+
tagFilter?: string[];
52+
sortOption?: UserSettingsSortOption;
53+
}
54+
55+
export interface UserSettings {
56+
openProjectDialog?: OpenProjectDialogSettings;
57+
}
58+
4859
export interface ShareMapping {
4960
id: string;
5061
ownerEmail: string;
@@ -136,6 +147,155 @@ export class StorageService {
136147
return `public-shares/${encodeURIComponent(shareId)}.thumbnail${normalizedExtension}`;
137148
}
138149

150+
private getUserSettingsBlobName(userPath: string): string {
151+
return `${userPath}/user-settings/user-settings.json`;
152+
}
153+
154+
private sanitizeUserSettingsData(input: unknown): UserSettings {
155+
const sanitized: UserSettings = {};
156+
157+
if (!input || typeof input !== "object") {
158+
return sanitized;
159+
}
160+
161+
const raw = input as Record<string, unknown>;
162+
const rawOpenProjectDialog = raw.openProjectDialog;
163+
164+
if (
165+
rawOpenProjectDialog &&
166+
typeof rawOpenProjectDialog === "object" &&
167+
!Array.isArray(rawOpenProjectDialog)
168+
) {
169+
const openProjectDialog: OpenProjectDialogSettings = {};
170+
const dialogRecord = rawOpenProjectDialog as Record<string, unknown>;
171+
172+
if (Object.prototype.hasOwnProperty.call(dialogRecord, "tagFilter")) {
173+
openProjectDialog.tagFilter = sanitizeTags(dialogRecord.tagFilter);
174+
}
175+
176+
if (
177+
dialogRecord.sortOption === "name" ||
178+
dialogRecord.sortOption === "lastModified"
179+
) {
180+
openProjectDialog.sortOption =
181+
dialogRecord.sortOption as UserSettingsSortOption;
182+
}
183+
184+
sanitized.openProjectDialog = openProjectDialog;
185+
}
186+
187+
return sanitized;
188+
}
189+
190+
private mergeUserSettings(
191+
current: UserSettings,
192+
updates: UserSettings,
193+
): UserSettings {
194+
const merged: UserSettings = { ...current };
195+
196+
if (updates.openProjectDialog) {
197+
const mergedOpenProjectDialog: OpenProjectDialogSettings = {
198+
...current.openProjectDialog,
199+
};
200+
201+
if (updates.openProjectDialog.tagFilter !== undefined) {
202+
mergedOpenProjectDialog.tagFilter = updates.openProjectDialog.tagFilter;
203+
}
204+
205+
if (updates.openProjectDialog.sortOption !== undefined) {
206+
mergedOpenProjectDialog.sortOption =
207+
updates.openProjectDialog.sortOption;
208+
}
209+
210+
merged.openProjectDialog = mergedOpenProjectDialog;
211+
}
212+
213+
return merged;
214+
}
215+
216+
private async persistUserSettings(
217+
userEmail: string,
218+
settings: UserSettings,
219+
): Promise<UserSettings> {
220+
const containerClient = await this.getContainerClient();
221+
const userPath = this.sanitizeEmailForPath(userEmail);
222+
const blobClient = containerClient.getBlockBlobClient(
223+
this.getUserSettingsBlobName(userPath),
224+
);
225+
226+
const sanitized = this.sanitizeUserSettingsData(settings);
227+
const hasOpenProjectSettings =
228+
sanitized.openProjectDialog &&
229+
(sanitized.openProjectDialog.tagFilter !== undefined ||
230+
sanitized.openProjectDialog.sortOption !== undefined);
231+
232+
if (!hasOpenProjectSettings) {
233+
await blobClient.deleteIfExists();
234+
235+
return {};
236+
}
237+
238+
const payload = JSON.stringify({
239+
openProjectDialog: {
240+
tagFilter: sanitized.openProjectDialog?.tagFilter ?? [],
241+
sortOption: sanitized.openProjectDialog?.sortOption ?? "name",
242+
},
243+
});
244+
245+
await blobClient.upload(payload, Buffer.byteLength(payload), {
246+
blobHTTPHeaders: { blobContentType: "application/json" },
247+
});
248+
249+
return {
250+
openProjectDialog: {
251+
tagFilter: sanitized.openProjectDialog?.tagFilter ?? [],
252+
sortOption: sanitized.openProjectDialog?.sortOption ?? "name",
253+
},
254+
};
255+
}
256+
257+
async loadUserSettings(userEmail: string): Promise<UserSettings> {
258+
const containerClient = await this.getContainerClient();
259+
const userPath = this.sanitizeEmailForPath(userEmail);
260+
const blobClient = containerClient.getBlockBlobClient(
261+
this.getUserSettingsBlobName(userPath),
262+
);
263+
264+
try {
265+
const download = await blobClient.download();
266+
const content = await streamToText(
267+
download.readableStreamBody as NodeJS.ReadableStream,
268+
);
269+
const parsed = JSON.parse(content);
270+
271+
return this.sanitizeUserSettingsData(parsed);
272+
} catch (error: any) {
273+
if (error?.statusCode === 404) {
274+
return {};
275+
}
276+
277+
logger.error(`Error loading user settings for ${userEmail}`, error);
278+
279+
return {};
280+
}
281+
}
282+
283+
async updateUserSettings(
284+
userEmail: string,
285+
updates: UserSettings,
286+
): Promise<UserSettings> {
287+
const sanitizedUpdates = this.sanitizeUserSettingsData(updates);
288+
289+
if (!sanitizedUpdates.openProjectDialog) {
290+
return this.loadUserSettings(userEmail);
291+
}
292+
293+
const current = await this.loadUserSettings(userEmail);
294+
const merged = this.mergeUserSettings(current, sanitizedUpdates);
295+
296+
return this.persistUserSettings(userEmail, merged);
297+
}
298+
139299
private getThumbnailExtensionForContentType(contentType: string): string {
140300
switch (contentType.toLowerCase()) {
141301
case "image/jpeg":
@@ -338,6 +498,18 @@ export class StorageService {
338498
);
339499

340500
for await (const blob of blobs) {
501+
if (!blob.name.endsWith(".json")) {
502+
continue;
503+
}
504+
505+
if (blob.name.endsWith(".shares.json")) {
506+
continue;
507+
}
508+
509+
if (blob.name.includes("/user-settings/")) {
510+
continue;
511+
}
512+
341513
const blobClient = containerClient.getBlobClient(blob.name);
342514
const properties = await blobClient.getProperties();
343515
let tags: string[] | undefined;

0 commit comments

Comments
 (0)