78244c3197
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
90 lines
2.6 KiB
TypeScript
90 lines
2.6 KiB
TypeScript
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,
|
|
};
|
|
}
|