feat: user-chosen browse views with grid/list toggle and profile sync
Build & Push Docker Image / build (push) Successful in 2m25s
Build & Push Docker Image / build (push) Successful in 2m25s
- view modes grid | table | rows + density cozy/compact, persisted via localStorage and shareable ?view=?density= URL params (URL wins) - table view: sortable columns (name, rating, reviews), new sort options name_asc/name_desc/recently_updated (backend enum + handler) - live debounced search, removable filter chips, '/' focuses search - virtualization via @tanstack/react-virtual for table and rows views - profile sync: users.preferences jsonb + GET/PUT /api/auth/me/preferences; preference precedence URL > server profile > localStorage > default - add local rollup/lightningcss/tailwindcss oxide native binaries for macos dev
This commit is contained in:
@@ -0,0 +1,89 @@
|
||||
import { useState, useRef, useEffect } from "react";
|
||||
import { useAuth } from "@/hooks/use-auth";
|
||||
import {
|
||||
useGetMePreferences,
|
||||
useUpdateMePreferences,
|
||||
getGetMePreferencesQueryKey,
|
||||
type UserPreferences,
|
||||
} from "@workspace/api-client-react";
|
||||
|
||||
export type ViewMode = "grid" | "table" | "rows";
|
||||
export type Density = "cozy" | "compact";
|
||||
|
||||
const LS_KEY = "toolrate:browse-preferences";
|
||||
const VIEWS = new Set<string>(["grid", "table", "rows"]);
|
||||
const DENSITIES = new Set<string>(["cozy", "compact"]);
|
||||
|
||||
export function isViewMode(v: string | null): v is ViewMode {
|
||||
return v !== null && VIEWS.has(v);
|
||||
}
|
||||
|
||||
export function isDensity(v: string | null): v is Density {
|
||||
return v !== null && DENSITIES.has(v);
|
||||
}
|
||||
|
||||
function readLocal(): Partial<UserPreferences> {
|
||||
try {
|
||||
const raw = window.localStorage.getItem(LS_KEY);
|
||||
if (!raw) return {};
|
||||
const parsed = JSON.parse(raw) as Record<string, unknown>;
|
||||
return {
|
||||
...(isViewMode(typeof parsed.view === "string" ? parsed.view : null) ? { view: parsed.view as ViewMode } : {}),
|
||||
...(isDensity(typeof parsed.density === "string" ? parsed.density : null) ? { density: parsed.density as Density } : {}),
|
||||
};
|
||||
} catch {
|
||||
return {};
|
||||
}
|
||||
}
|
||||
|
||||
function writeLocal(prefs: Partial<UserPreferences>) {
|
||||
try {
|
||||
window.localStorage.setItem(LS_KEY, JSON.stringify(prefs));
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
}
|
||||
|
||||
export function useBrowsePreferences() {
|
||||
const { isAuthenticated } = useAuth();
|
||||
const [localPrefs, setLocalPrefs] = useState(readLocal);
|
||||
const syncTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
|
||||
const { data: serverPrefs } = useGetMePreferences({
|
||||
query: {
|
||||
queryKey: getGetMePreferencesQueryKey(),
|
||||
enabled: isAuthenticated,
|
||||
retry: false,
|
||||
staleTime: 5 * 60 * 1000,
|
||||
},
|
||||
});
|
||||
const { mutate: updatePrefs } = useUpdateMePreferences();
|
||||
|
||||
function persist(view: ViewMode, density: Density) {
|
||||
const prefs: Partial<UserPreferences> = { view, density };
|
||||
setLocalPrefs(prefs);
|
||||
writeLocal(prefs);
|
||||
if (isAuthenticated) {
|
||||
if (syncTimer.current) clearTimeout(syncTimer.current);
|
||||
syncTimer.current = setTimeout(() => updatePrefs({ data: prefs }), 400);
|
||||
}
|
||||
}
|
||||
|
||||
useEffect(
|
||||
() => () => {
|
||||
if (syncTimer.current) clearTimeout(syncTimer.current);
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
const serverView = isViewMode(serverPrefs?.view ?? null) ? serverPrefs!.view : undefined;
|
||||
const serverDensity = isDensity(serverPrefs?.density ?? null) ? serverPrefs!.density : undefined;
|
||||
|
||||
return {
|
||||
serverView,
|
||||
serverDensity,
|
||||
localView: localPrefs.view,
|
||||
localDensity: localPrefs.density,
|
||||
persist,
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user