feat(dashboard): manage account layout drafts and recovery
This commit is contained in:
@@ -0,0 +1,125 @@
|
||||
import { useCallback, useEffect, useRef, useState, type Dispatch, type SetStateAction } from 'react';
|
||||
import { api } from '../api';
|
||||
import { AUTH_USER_CHANGED_EVENT, getAuthUserKey } from '../auth';
|
||||
import { getUserKeyFromToken } from '../themePrefs';
|
||||
import { defaultDashboardLayout, normalizeDashboardLayout, type DashboardLayout } from '../dashboardLayout';
|
||||
|
||||
type LayoutError = 'request' | 'unsupportedVersion' | null;
|
||||
type LayoutState = {
|
||||
owner: string; layout: DashboardLayout; draft: DashboardLayout | null; editing: boolean;
|
||||
loading: boolean; loaded: boolean; loadError: LayoutError; saveError: LayoutError; conflict: boolean; saving: boolean;
|
||||
};
|
||||
|
||||
function initialState(owner: string, loading: boolean): LayoutState {
|
||||
return { owner, layout: defaultDashboardLayout(), draft: null, editing: false, loading, loaded: false,
|
||||
loadError: null, saveError: null, conflict: false, saving: false };
|
||||
}
|
||||
|
||||
function readLayout(input: unknown): DashboardLayout {
|
||||
const value = input as Partial<DashboardLayout> | null;
|
||||
if (value && value.version !== 1) throw new Error('unsupportedVersion');
|
||||
if (!value || !Number.isSafeInteger(value.revision) || (value.revision as number) < 0 || !Array.isArray(value.widgets)) throw new Error('request');
|
||||
const layout = normalizeDashboardLayout(input);
|
||||
if (layout.revision === 0 && layout.widgets.length && value.widgets.length === 0 && getUserKeyFromToken() !== 'anon') {
|
||||
try {
|
||||
const legacy = JSON.parse(localStorage.getItem(`dashboardPrefs:${getUserKeyFromToken()}`) || 'null');
|
||||
for (const widget of layout.widgets) {
|
||||
if (['cards', 'activity', 'funnel', 'companies', 'skills'].includes(widget.id) && typeof legacy?.[widget.id] === 'boolean') widget.visible = legacy[widget.id];
|
||||
}
|
||||
} catch { /* Corrupt or unavailable local preferences leave defaults intact. */ }
|
||||
}
|
||||
return layout;
|
||||
}
|
||||
|
||||
const clone = (layout: DashboardLayout): DashboardLayout => ({ ...layout, widgets: layout.widgets.map(w => ({ ...w })) });
|
||||
|
||||
export function useDashboardLayout(enabled: boolean) {
|
||||
const [state, setState] = useState<LayoutState>(() => initialState(getAuthUserKey(), enabled));
|
||||
const mounted = useRef(false);
|
||||
const generation = useRef(0);
|
||||
const controller = useRef<AbortController | null>(null);
|
||||
const savePending = useRef(false);
|
||||
const stateOwner = useRef(state.owner);
|
||||
stateOwner.current = state.owner;
|
||||
|
||||
const reload = useCallback(async () => {
|
||||
const owner = getAuthUserKey();
|
||||
const request = ++generation.current;
|
||||
controller.current?.abort();
|
||||
const abort = new AbortController();
|
||||
controller.current = abort;
|
||||
savePending.current = false;
|
||||
setState(s => enabled && s.owner === owner ? { ...s, loading: true, loaded: false, loadError: null, saveError: null, conflict: false, saving: false } : initialState(owner, enabled));
|
||||
if (!enabled) return;
|
||||
const current = () => mounted.current && generation.current === request && getAuthUserKey() === owner;
|
||||
try {
|
||||
const response = await api.get('/auth/preferences/dashboard', { signal: abort.signal });
|
||||
if (!current()) return;
|
||||
const layout = readLayout(response.data);
|
||||
setState(s => ({ ...s, layout, draft: s.editing ? clone(layout) : null, loading: false, loaded: true }));
|
||||
} catch (error) {
|
||||
if (current()) setState(s => ({ ...s, loading: false, loaded: false, loadError: error instanceof Error && error.message === 'unsupportedVersion' ? 'unsupportedVersion' : 'request' }));
|
||||
}
|
||||
}, [enabled]);
|
||||
|
||||
useEffect(() => {
|
||||
mounted.current = true;
|
||||
void reload();
|
||||
const identityChanged = () => {
|
||||
if (getAuthUserKey() !== stateOwner.current) {
|
||||
stateOwner.current = getAuthUserKey();
|
||||
void reload();
|
||||
}
|
||||
};
|
||||
window.addEventListener(AUTH_USER_CHANGED_EVENT, identityChanged);
|
||||
window.addEventListener('auth-changed', identityChanged);
|
||||
return () => {
|
||||
mounted.current = false;
|
||||
controller.current?.abort();
|
||||
window.removeEventListener(AUTH_USER_CHANGED_EVENT, identityChanged);
|
||||
window.removeEventListener('auth-changed', identityChanged);
|
||||
};
|
||||
}, [reload]);
|
||||
|
||||
const startEditing = () => {
|
||||
if (enabled && state.loaded && !state.loading && !state.saving && state.owner === getAuthUserKey()) setState(s => ({ ...s, draft: clone(s.layout), editing: true, saveError: null, conflict: false }));
|
||||
};
|
||||
const setDraft: Dispatch<SetStateAction<DashboardLayout | null>> = update => {
|
||||
setState(s => s.editing && !s.saving && s.owner === getAuthUserKey()
|
||||
? { ...s, draft: (typeof update === 'function' ? update(s.draft) : update) ?? s.draft } : s);
|
||||
};
|
||||
const cancelEditing = () => {
|
||||
if (!savePending.current) setState(s => ({ ...s, draft: null, editing: false, saveError: null, conflict: false }));
|
||||
};
|
||||
const save = async (): Promise<boolean> => {
|
||||
if (!enabled || !state.loaded || state.loading || !state.editing || !state.draft || state.conflict || savePending.current || state.owner !== getAuthUserKey()) return false;
|
||||
const owner = state.owner;
|
||||
const request = ++generation.current;
|
||||
savePending.current = true;
|
||||
const abort = new AbortController();
|
||||
controller.current?.abort();
|
||||
controller.current = abort;
|
||||
const current = () => mounted.current && generation.current === request && getAuthUserKey() === owner;
|
||||
setState(s => ({ ...s, saving: true, saveError: null }));
|
||||
try {
|
||||
const response = await api.put('/auth/preferences/dashboard', { ...normalizeDashboardLayout(state.draft), revision: state.layout.revision }, { signal: abort.signal });
|
||||
if (!current()) return false;
|
||||
const layout = readLayout(response.data);
|
||||
setState(s => ({ ...s, layout, draft: null, editing: false, saving: false, conflict: false }));
|
||||
return true;
|
||||
} catch (error) {
|
||||
if (current()) {
|
||||
const conflict = (error as { response?: { status?: number } })?.response?.status === 409;
|
||||
setState(s => ({ ...s, saving: false, conflict, saveError: error instanceof Error && error.message === 'unsupportedVersion' ? 'unsupportedVersion' : 'request' }));
|
||||
}
|
||||
return false;
|
||||
} finally {
|
||||
if (current()) savePending.current = false;
|
||||
}
|
||||
};
|
||||
|
||||
return { layout: state.layout, draft: state.draft, editing: state.editing, loading: state.loading,
|
||||
loadError: state.loadError, saveError: state.saveError, conflict: state.conflict, saving: state.saving,
|
||||
dirty: state.editing && JSON.stringify(state.draft) !== JSON.stringify(state.layout),
|
||||
startEditing, setDraft, cancelEditing, save, reload };
|
||||
}
|
||||
Reference in New Issue
Block a user