0.29.1原版
This commit is contained in:
@@ -0,0 +1,204 @@
|
||||
import { useQueryClient } from "@tanstack/react-query";
|
||||
import { createContext, type ReactNode, useCallback, useContext, useMemo, useState } from "react";
|
||||
import { clearAccessToken, getAccessToken } from "@/auth-state";
|
||||
import { authServiceClient, refreshAccessToken, shortcutServiceClient, userServiceClient } from "@/connect";
|
||||
import { userKeys } from "@/hooks/useUserQueries";
|
||||
import type { Shortcut } from "@/types/proto/api/v1/shortcut_service_pb";
|
||||
import type { User, UserSetting_GeneralSetting, UserSetting_WebhooksSetting } from "@/types/proto/api/v1/user_service_pb";
|
||||
|
||||
interface AuthState {
|
||||
currentUser: User | undefined;
|
||||
userGeneralSetting: UserSetting_GeneralSetting | undefined;
|
||||
userWebhooksSetting: UserSetting_WebhooksSetting | undefined;
|
||||
shortcuts: Shortcut[];
|
||||
isInitialized: boolean;
|
||||
isLoading: boolean;
|
||||
}
|
||||
|
||||
interface AuthContextValue extends AuthState {
|
||||
initialize: () => Promise<void>;
|
||||
logout: () => Promise<void>;
|
||||
refetchSettings: () => Promise<void>;
|
||||
setCurrentUser: (user: User | undefined) => void;
|
||||
}
|
||||
|
||||
const AuthContext = createContext<AuthContextValue | null>(null);
|
||||
|
||||
export function AuthProvider({ children }: { children: ReactNode }) {
|
||||
const queryClient = useQueryClient();
|
||||
const [state, setState] = useState<AuthState>({
|
||||
currentUser: undefined,
|
||||
userGeneralSetting: undefined,
|
||||
userWebhooksSetting: undefined,
|
||||
shortcuts: [],
|
||||
isInitialized: false,
|
||||
isLoading: true,
|
||||
});
|
||||
|
||||
const fetchUserSettings = useCallback(async (userName: string) => {
|
||||
const [{ settings }, { shortcuts }] = await Promise.all([
|
||||
userServiceClient.listUserSettings({ parent: userName }),
|
||||
shortcutServiceClient.listShortcuts({ parent: userName }),
|
||||
]);
|
||||
|
||||
const generalSetting = settings.find((s) => s.value.case === "generalSetting");
|
||||
const webhooksSetting = settings.find((s) => s.value.case === "webhooksSetting");
|
||||
|
||||
return {
|
||||
userGeneralSetting: generalSetting?.value.case === "generalSetting" ? generalSetting.value.value : undefined,
|
||||
userWebhooksSetting: webhooksSetting?.value.case === "webhooksSetting" ? webhooksSetting.value.value : undefined,
|
||||
shortcuts,
|
||||
};
|
||||
}, []);
|
||||
|
||||
const initialize = useCallback(async () => {
|
||||
setState((prev) => ({ ...prev, isLoading: true }));
|
||||
|
||||
// Try to get or refresh the access token.
|
||||
// This handles PWA isolated storage scenarios (e.g., iOS Safari) where localStorage
|
||||
// may be empty but a valid HTTP-only refresh token cookie still exists.
|
||||
// getAccessToken() returns a cached token or loads from localStorage if valid.
|
||||
if (!getAccessToken()) {
|
||||
try {
|
||||
await refreshAccessToken();
|
||||
} catch {
|
||||
// Refresh failed - no valid session
|
||||
}
|
||||
}
|
||||
|
||||
// If we still don't have a token after refresh attempt, skip getCurrentUser call
|
||||
// to avoid unnecessary network request for unauthenticated users.
|
||||
if (!getAccessToken()) {
|
||||
setState({
|
||||
currentUser: undefined,
|
||||
userGeneralSetting: undefined,
|
||||
userWebhooksSetting: undefined,
|
||||
shortcuts: [],
|
||||
isInitialized: true,
|
||||
isLoading: false,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const { user: currentUser } = await authServiceClient.getCurrentUser({});
|
||||
|
||||
if (!currentUser) {
|
||||
clearAccessToken();
|
||||
setState({
|
||||
currentUser: undefined,
|
||||
userGeneralSetting: undefined,
|
||||
userWebhooksSetting: undefined,
|
||||
shortcuts: [],
|
||||
isInitialized: true,
|
||||
isLoading: false,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const settings = await fetchUserSettings(currentUser.name);
|
||||
|
||||
setState({
|
||||
currentUser,
|
||||
...settings,
|
||||
isInitialized: true,
|
||||
isLoading: false,
|
||||
});
|
||||
|
||||
// Pre-populate React Query cache
|
||||
queryClient.setQueryData(userKeys.currentUser(), currentUser);
|
||||
queryClient.setQueryData(userKeys.detail(currentUser.name), currentUser);
|
||||
} catch (error) {
|
||||
console.error("Failed to initialize auth:", error);
|
||||
clearAccessToken();
|
||||
setState({
|
||||
currentUser: undefined,
|
||||
userGeneralSetting: undefined,
|
||||
userWebhooksSetting: undefined,
|
||||
shortcuts: [],
|
||||
isInitialized: true,
|
||||
isLoading: false,
|
||||
});
|
||||
}
|
||||
}, [fetchUserSettings, queryClient]);
|
||||
|
||||
const logout = useCallback(async () => {
|
||||
try {
|
||||
await authServiceClient.signOut({});
|
||||
} catch (error) {
|
||||
console.error("[AuthContext] Failed to sign out:", error);
|
||||
} finally {
|
||||
clearAccessToken();
|
||||
setState({
|
||||
currentUser: undefined,
|
||||
userGeneralSetting: undefined,
|
||||
userWebhooksSetting: undefined,
|
||||
shortcuts: [],
|
||||
isInitialized: true,
|
||||
isLoading: false,
|
||||
});
|
||||
queryClient.clear();
|
||||
}
|
||||
}, [queryClient]);
|
||||
|
||||
const refetchSettings = useCallback(async () => {
|
||||
const currentUserName = state.currentUser?.name;
|
||||
if (!currentUserName) {
|
||||
return;
|
||||
}
|
||||
|
||||
const settings = await fetchUserSettings(currentUserName);
|
||||
setState((prev) => {
|
||||
if (prev.currentUser?.name !== currentUserName) {
|
||||
return prev;
|
||||
}
|
||||
return { ...prev, ...settings };
|
||||
});
|
||||
}, [fetchUserSettings, state.currentUser?.name]);
|
||||
|
||||
// Sync the updated user to AuthContext and React Query cache after profile changes
|
||||
const setCurrentUser = useCallback(
|
||||
(user: User | undefined) => {
|
||||
const previousUser = queryClient.getQueryData<User>(userKeys.currentUser());
|
||||
setState((prev) => ({ ...prev, currentUser: user }));
|
||||
if (user) {
|
||||
queryClient.setQueryData(userKeys.currentUser(), user);
|
||||
queryClient.setQueryData(userKeys.detail(user.name), user);
|
||||
} else {
|
||||
queryClient.removeQueries({ queryKey: userKeys.currentUser(), exact: true });
|
||||
if (previousUser?.name) {
|
||||
queryClient.removeQueries({ queryKey: userKeys.detail(previousUser.name), exact: true });
|
||||
}
|
||||
}
|
||||
},
|
||||
[queryClient],
|
||||
);
|
||||
|
||||
// Memoize context value to prevent unnecessary re-renders of consumers
|
||||
const value = useMemo(
|
||||
() => ({
|
||||
...state,
|
||||
initialize,
|
||||
logout,
|
||||
refetchSettings,
|
||||
setCurrentUser,
|
||||
}),
|
||||
[state, initialize, logout, refetchSettings, setCurrentUser],
|
||||
);
|
||||
|
||||
return <AuthContext.Provider value={value}>{children}</AuthContext.Provider>;
|
||||
}
|
||||
|
||||
export function useAuth() {
|
||||
const context = useContext(AuthContext);
|
||||
if (!context) {
|
||||
throw new Error("useAuth must be used within AuthProvider");
|
||||
}
|
||||
return context;
|
||||
}
|
||||
|
||||
// Convenience hook for just the current user
|
||||
export function useCurrentUserFromAuth() {
|
||||
const { currentUser } = useAuth();
|
||||
return currentUser;
|
||||
}
|
||||
@@ -0,0 +1,239 @@
|
||||
import { create } from "@bufbuild/protobuf";
|
||||
import { createContext, type ReactNode, useCallback, useContext, useMemo, useRef, useState } from "react";
|
||||
import { instanceServiceClient } from "@/connect";
|
||||
import {
|
||||
InstanceProfile,
|
||||
InstanceProfileSchema,
|
||||
InstanceSetting,
|
||||
InstanceSetting_AISetting,
|
||||
InstanceSetting_AISettingSchema,
|
||||
InstanceSetting_GeneralSetting,
|
||||
InstanceSetting_GeneralSettingSchema,
|
||||
InstanceSetting_Key,
|
||||
InstanceSetting_MemoRelatedSetting,
|
||||
InstanceSetting_MemoRelatedSettingSchema,
|
||||
InstanceSetting_NotificationSetting,
|
||||
InstanceSetting_NotificationSettingSchema,
|
||||
InstanceSetting_StorageSetting,
|
||||
InstanceSetting_StorageSettingSchema,
|
||||
InstanceSetting_TagsSetting,
|
||||
InstanceSetting_TagsSettingSchema,
|
||||
} from "@/types/proto/api/v1/instance_service_pb";
|
||||
|
||||
const instanceSettingNamePrefix = "instance/settings/";
|
||||
|
||||
const buildInstanceSettingName = (key: InstanceSetting_Key): string => {
|
||||
const keyName = InstanceSetting_Key[key];
|
||||
return `${instanceSettingNamePrefix}${keyName}`;
|
||||
};
|
||||
|
||||
interface InstanceState {
|
||||
profile: InstanceProfile;
|
||||
settings: InstanceSetting[];
|
||||
isInitialized: boolean;
|
||||
isLoading: boolean;
|
||||
// True only when the profile was successfully fetched from the server.
|
||||
// Remains false if initialization failed, so consumers can distinguish
|
||||
// "no admin exists" from "failed to load profile".
|
||||
profileLoaded: boolean;
|
||||
}
|
||||
|
||||
interface InstanceContextValue extends InstanceState {
|
||||
generalSetting: InstanceSetting_GeneralSetting;
|
||||
memoRelatedSetting: InstanceSetting_MemoRelatedSetting;
|
||||
storageSetting: InstanceSetting_StorageSetting;
|
||||
tagsSetting: InstanceSetting_TagsSetting;
|
||||
notificationSetting: InstanceSetting_NotificationSetting;
|
||||
aiSetting: InstanceSetting_AISetting;
|
||||
initialize: () => Promise<void>;
|
||||
fetchSetting: (key: InstanceSetting_Key) => Promise<void>;
|
||||
fetchSettings: (keys: InstanceSetting_Key[]) => Promise<void>;
|
||||
updateSetting: (setting: InstanceSetting) => Promise<void>;
|
||||
}
|
||||
|
||||
const InstanceContext = createContext<InstanceContextValue | null>(null);
|
||||
|
||||
export function InstanceProvider({ children }: { children: ReactNode }) {
|
||||
const [state, setState] = useState<InstanceState>({
|
||||
profile: create(InstanceProfileSchema, {}),
|
||||
settings: [],
|
||||
isInitialized: false,
|
||||
isLoading: true,
|
||||
profileLoaded: false,
|
||||
});
|
||||
|
||||
const fetchedSettingsRef = useRef<Set<string>>(new Set());
|
||||
|
||||
// Memoize derived settings to prevent unnecessary recalculations
|
||||
const generalSetting = useMemo((): InstanceSetting_GeneralSetting => {
|
||||
const setting = state.settings.find((s) => s.name === `${instanceSettingNamePrefix}GENERAL`);
|
||||
if (setting?.value.case === "generalSetting") {
|
||||
return setting.value.value;
|
||||
}
|
||||
return create(InstanceSetting_GeneralSettingSchema, {});
|
||||
}, [state.settings]);
|
||||
|
||||
const memoRelatedSetting = useMemo((): InstanceSetting_MemoRelatedSetting => {
|
||||
const setting = state.settings.find((s) => s.name === `${instanceSettingNamePrefix}MEMO_RELATED`);
|
||||
if (setting?.value.case === "memoRelatedSetting") {
|
||||
return setting.value.value;
|
||||
}
|
||||
return create(InstanceSetting_MemoRelatedSettingSchema, {});
|
||||
}, [state.settings]);
|
||||
|
||||
const storageSetting = useMemo((): InstanceSetting_StorageSetting => {
|
||||
const setting = state.settings.find((s) => s.name === `${instanceSettingNamePrefix}STORAGE`);
|
||||
if (setting?.value.case === "storageSetting") {
|
||||
return setting.value.value;
|
||||
}
|
||||
return create(InstanceSetting_StorageSettingSchema, {});
|
||||
}, [state.settings]);
|
||||
|
||||
const tagsSetting = useMemo((): InstanceSetting_TagsSetting => {
|
||||
const setting = state.settings.find((s) => s.name === `${instanceSettingNamePrefix}TAGS`);
|
||||
if (setting?.value.case === "tagsSetting") {
|
||||
return setting.value.value;
|
||||
}
|
||||
return create(InstanceSetting_TagsSettingSchema, {});
|
||||
}, [state.settings]);
|
||||
|
||||
const notificationSetting = useMemo((): InstanceSetting_NotificationSetting => {
|
||||
const setting = state.settings.find((s) => s.name === `${instanceSettingNamePrefix}NOTIFICATION`);
|
||||
if (setting?.value.case === "notificationSetting") {
|
||||
return setting.value.value;
|
||||
}
|
||||
return create(InstanceSetting_NotificationSettingSchema, {});
|
||||
}, [state.settings]);
|
||||
|
||||
const aiSetting = useMemo((): InstanceSetting_AISetting => {
|
||||
const setting = state.settings.find((s) => s.name === `${instanceSettingNamePrefix}AI`);
|
||||
if (setting?.value.case === "aiSetting") {
|
||||
return setting.value.value;
|
||||
}
|
||||
return create(InstanceSetting_AISettingSchema, {});
|
||||
}, [state.settings]);
|
||||
|
||||
const initialize = useCallback(async () => {
|
||||
setState((prev) => ({ ...prev, isLoading: true }));
|
||||
try {
|
||||
const profile = await instanceServiceClient.getInstanceProfile({});
|
||||
|
||||
const settingsResponse = await instanceServiceClient.batchGetInstanceSettings({
|
||||
names: [
|
||||
buildInstanceSettingName(InstanceSetting_Key.GENERAL),
|
||||
buildInstanceSettingName(InstanceSetting_Key.MEMO_RELATED),
|
||||
buildInstanceSettingName(InstanceSetting_Key.TAGS),
|
||||
],
|
||||
});
|
||||
for (const setting of settingsResponse.settings) {
|
||||
fetchedSettingsRef.current.add(setting.name);
|
||||
}
|
||||
|
||||
setState({
|
||||
profile,
|
||||
settings: settingsResponse.settings,
|
||||
isInitialized: true,
|
||||
isLoading: false,
|
||||
profileLoaded: true,
|
||||
});
|
||||
} catch (error) {
|
||||
console.error("Failed to initialize instance:", error);
|
||||
setState((prev) => ({
|
||||
...prev,
|
||||
isInitialized: true,
|
||||
isLoading: false,
|
||||
}));
|
||||
}
|
||||
}, []);
|
||||
|
||||
const fetchSettings = useCallback(async (keys: InstanceSetting_Key[]) => {
|
||||
const names = keys.map(buildInstanceSettingName).filter((name) => !fetchedSettingsRef.current.has(name));
|
||||
if (names.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
for (const name of names) {
|
||||
fetchedSettingsRef.current.add(name);
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await instanceServiceClient.batchGetInstanceSettings({ names });
|
||||
const fetchedNames = new Set(response.settings.map((setting) => setting.name));
|
||||
setState((prev) => ({
|
||||
...prev,
|
||||
settings: [...prev.settings.filter((setting) => !fetchedNames.has(setting.name)), ...response.settings],
|
||||
}));
|
||||
} catch (error) {
|
||||
for (const name of names) {
|
||||
fetchedSettingsRef.current.delete(name);
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}, []);
|
||||
|
||||
const fetchSetting = useCallback(async (key: InstanceSetting_Key) => {
|
||||
const name = buildInstanceSettingName(key);
|
||||
if (fetchedSettingsRef.current.has(name)) {
|
||||
return;
|
||||
}
|
||||
fetchedSettingsRef.current.add(name);
|
||||
try {
|
||||
const setting = await instanceServiceClient.getInstanceSetting({ name });
|
||||
setState((prev) => ({
|
||||
...prev,
|
||||
settings: [...prev.settings.filter((s) => s.name !== setting.name), setting],
|
||||
}));
|
||||
} catch (error) {
|
||||
fetchedSettingsRef.current.delete(name);
|
||||
throw error;
|
||||
}
|
||||
}, []);
|
||||
|
||||
const updateSetting = useCallback(async (setting: InstanceSetting) => {
|
||||
const updatedSetting = await instanceServiceClient.updateInstanceSetting({ setting });
|
||||
setState((prev) => ({
|
||||
...prev,
|
||||
settings: [...prev.settings.filter((s) => s.name !== updatedSetting.name), updatedSetting],
|
||||
}));
|
||||
}, []);
|
||||
|
||||
// Memoize context value to prevent unnecessary re-renders of consumers
|
||||
const value = useMemo(
|
||||
() => ({
|
||||
...state,
|
||||
generalSetting,
|
||||
memoRelatedSetting,
|
||||
storageSetting,
|
||||
tagsSetting,
|
||||
notificationSetting,
|
||||
aiSetting,
|
||||
initialize,
|
||||
fetchSetting,
|
||||
fetchSettings,
|
||||
updateSetting,
|
||||
}),
|
||||
[
|
||||
state,
|
||||
generalSetting,
|
||||
memoRelatedSetting,
|
||||
storageSetting,
|
||||
tagsSetting,
|
||||
notificationSetting,
|
||||
aiSetting,
|
||||
initialize,
|
||||
fetchSetting,
|
||||
fetchSettings,
|
||||
updateSetting,
|
||||
],
|
||||
);
|
||||
|
||||
return <InstanceContext.Provider value={value}>{children}</InstanceContext.Provider>;
|
||||
}
|
||||
|
||||
export function useInstance() {
|
||||
const context = useContext(InstanceContext);
|
||||
if (!context) {
|
||||
throw new Error("useInstance must be used within InstanceProvider");
|
||||
}
|
||||
return context;
|
||||
}
|
||||
@@ -0,0 +1,160 @@
|
||||
import { uniqBy } from "lodash-es";
|
||||
import { createContext, type ReactNode, useCallback, useContext, useEffect, useRef, useState } from "react";
|
||||
import { useSearchParams } from "react-router-dom";
|
||||
|
||||
export type FilterFactor =
|
||||
| "tagSearch"
|
||||
| "visibility"
|
||||
| "contentSearch"
|
||||
| "displayTime"
|
||||
| "pinned"
|
||||
| "property.hasLink"
|
||||
| "property.hasTaskList"
|
||||
| "property.hasCode";
|
||||
|
||||
export interface MemoFilter {
|
||||
factor: FilterFactor;
|
||||
value: string;
|
||||
}
|
||||
|
||||
export const getMemoFilterKey = (filter: MemoFilter): string => `${filter.factor}:${filter.value}`;
|
||||
|
||||
export const parseFilterQuery = (query: string | null): MemoFilter[] => {
|
||||
if (!query) return [];
|
||||
try {
|
||||
return query.split(",").map((filterStr) => {
|
||||
const [factor, value] = filterStr.split(":");
|
||||
return {
|
||||
factor: factor as FilterFactor,
|
||||
value: decodeURIComponent(value || ""),
|
||||
};
|
||||
});
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
};
|
||||
|
||||
export const stringifyFilters = (filters: MemoFilter[]): string => {
|
||||
return filters.map((filter) => `${filter.factor}:${encodeURIComponent(filter.value)}`).join(",");
|
||||
};
|
||||
|
||||
interface MemoFilterContextValue {
|
||||
filters: MemoFilter[];
|
||||
shortcut: string | undefined;
|
||||
hasActiveFilters: boolean;
|
||||
getFiltersByFactor: (factor: FilterFactor) => MemoFilter[];
|
||||
setFilters: (filters: MemoFilter[]) => void;
|
||||
addFilter: (filter: MemoFilter) => void;
|
||||
removeFilter: (predicate: (f: MemoFilter) => boolean) => void;
|
||||
removeFiltersByFactor: (factor: FilterFactor) => void;
|
||||
clearAllFilters: () => void;
|
||||
setShortcut: (shortcut?: string) => void;
|
||||
hasFilter: (filter: MemoFilter) => boolean;
|
||||
}
|
||||
|
||||
const MemoFilterContext = createContext<MemoFilterContextValue | null>(null);
|
||||
|
||||
export function MemoFilterProvider({ children }: { children: ReactNode }) {
|
||||
const [searchParams, setSearchParams] = useSearchParams();
|
||||
const lastSyncedUrlRef = useRef("");
|
||||
const lastSyncedStoreRef = useRef("");
|
||||
|
||||
// Initialize from URL
|
||||
const [filters, setFiltersState] = useState<MemoFilter[]>(() => {
|
||||
return parseFilterQuery(searchParams.get("filter"));
|
||||
});
|
||||
const [shortcut, setShortcutState] = useState<string | undefined>(undefined);
|
||||
|
||||
// Sync URL to state when URL changes externally
|
||||
useEffect(() => {
|
||||
const filterParam = searchParams.get("filter") || "";
|
||||
if (filterParam !== lastSyncedUrlRef.current) {
|
||||
lastSyncedUrlRef.current = filterParam;
|
||||
const newFilters = parseFilterQuery(filterParam);
|
||||
setFiltersState(newFilters);
|
||||
lastSyncedStoreRef.current = stringifyFilters(newFilters);
|
||||
}
|
||||
}, [searchParams]);
|
||||
|
||||
// Sync state to URL when state changes
|
||||
useEffect(() => {
|
||||
const storeString = stringifyFilters(filters);
|
||||
if (storeString !== lastSyncedStoreRef.current && storeString !== lastSyncedUrlRef.current) {
|
||||
lastSyncedStoreRef.current = storeString;
|
||||
const newParams = new URLSearchParams(searchParams);
|
||||
if (filters.length > 0) {
|
||||
newParams.set("filter", storeString);
|
||||
} else {
|
||||
newParams.delete("filter");
|
||||
}
|
||||
setSearchParams(newParams, { replace: true });
|
||||
lastSyncedUrlRef.current = filters.length > 0 ? storeString : "";
|
||||
}
|
||||
}, [filters, searchParams, setSearchParams]);
|
||||
|
||||
const getFiltersByFactor = useCallback((factor: FilterFactor) => filters.filter((f) => f.factor === factor), [filters]);
|
||||
|
||||
const setFilters = useCallback((newFilters: MemoFilter[]) => {
|
||||
setFiltersState(newFilters);
|
||||
}, []);
|
||||
|
||||
const addFilter = useCallback((filter: MemoFilter) => {
|
||||
setFiltersState((prev) => uniqBy([...prev, filter], getMemoFilterKey));
|
||||
}, []);
|
||||
|
||||
const removeFilter = useCallback((predicate: (f: MemoFilter) => boolean) => {
|
||||
setFiltersState((prev) => prev.filter((f) => !predicate(f)));
|
||||
}, []);
|
||||
|
||||
const removeFiltersByFactor = useCallback((factor: FilterFactor) => {
|
||||
setFiltersState((prev) => prev.filter((f) => f.factor !== factor));
|
||||
}, []);
|
||||
|
||||
const clearAllFilters = useCallback(() => {
|
||||
setFiltersState([]);
|
||||
setShortcutState(undefined);
|
||||
}, []);
|
||||
|
||||
const setShortcut = useCallback((newShortcut?: string) => {
|
||||
setShortcutState(newShortcut);
|
||||
// Clear content search filter when selecting a shortcut (issue #5462)
|
||||
if (newShortcut !== undefined) {
|
||||
setFiltersState((prev) => prev.filter((f) => f.factor !== "contentSearch"));
|
||||
}
|
||||
}, []);
|
||||
|
||||
const hasFilter = useCallback((filter: MemoFilter) => filters.some((f) => getMemoFilterKey(f) === getMemoFilterKey(filter)), [filters]);
|
||||
|
||||
const hasActiveFilters = filters.length > 0 || shortcut !== undefined;
|
||||
|
||||
return (
|
||||
<MemoFilterContext.Provider
|
||||
value={{
|
||||
filters,
|
||||
shortcut,
|
||||
hasActiveFilters,
|
||||
getFiltersByFactor,
|
||||
setFilters,
|
||||
addFilter,
|
||||
removeFilter,
|
||||
removeFiltersByFactor,
|
||||
clearAllFilters,
|
||||
setShortcut,
|
||||
hasFilter,
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
</MemoFilterContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
export function useMemoFilterContext() {
|
||||
const context = useContext(MemoFilterContext);
|
||||
if (!context) {
|
||||
throw new Error("useMemoFilterContext must be used within MemoFilterProvider");
|
||||
}
|
||||
return context;
|
||||
}
|
||||
|
||||
// Alias for backwards compatibility during migration
|
||||
export const useMemoFilter = useMemoFilterContext;
|
||||
@@ -0,0 +1,88 @@
|
||||
import { createContext, type ReactNode, useContext, useState } from "react";
|
||||
|
||||
export type MemoTimeBasis = "create_time" | "update_time";
|
||||
|
||||
interface ViewState {
|
||||
orderByTimeAsc: boolean;
|
||||
timeBasis?: MemoTimeBasis;
|
||||
sortTimeField?: MemoTimeBasis;
|
||||
}
|
||||
|
||||
interface ViewContextValue {
|
||||
orderByTimeAsc: boolean;
|
||||
timeBasis: MemoTimeBasis;
|
||||
toggleSortOrder: () => void;
|
||||
setTimeBasis: (field: MemoTimeBasis) => void;
|
||||
}
|
||||
|
||||
const ViewContext = createContext<ViewContextValue | null>(null);
|
||||
|
||||
const LOCAL_STORAGE_KEY = "memos-view-setting";
|
||||
|
||||
export function ViewProvider({ children }: { children: ReactNode }) {
|
||||
const getInitialState = (): ViewState => {
|
||||
try {
|
||||
const cached = localStorage.getItem(LOCAL_STORAGE_KEY);
|
||||
if (cached) {
|
||||
const data = JSON.parse(cached) as Partial<ViewState>;
|
||||
const cachedTimeBasis = data.timeBasis ?? data.sortTimeField;
|
||||
const timeBasis = cachedTimeBasis === "create_time" || cachedTimeBasis === "update_time" ? cachedTimeBasis : undefined;
|
||||
return {
|
||||
orderByTimeAsc: Boolean(data.orderByTimeAsc ?? false),
|
||||
timeBasis,
|
||||
};
|
||||
}
|
||||
} catch (error) {
|
||||
console.warn("Failed to load view settings from localStorage:", error);
|
||||
}
|
||||
return { orderByTimeAsc: false };
|
||||
};
|
||||
|
||||
const [viewState, setViewState] = useState(getInitialState);
|
||||
const timeBasis = viewState.timeBasis ?? "create_time";
|
||||
|
||||
const persistToStorage = (newState: ViewState) => {
|
||||
try {
|
||||
localStorage.setItem(LOCAL_STORAGE_KEY, JSON.stringify(newState));
|
||||
} catch (error) {
|
||||
console.warn("Failed to persist view settings:", error);
|
||||
}
|
||||
};
|
||||
|
||||
const toggleSortOrder = () => {
|
||||
setViewState((prev) => {
|
||||
const newState = { ...prev, orderByTimeAsc: !prev.orderByTimeAsc };
|
||||
persistToStorage(newState);
|
||||
return newState;
|
||||
});
|
||||
};
|
||||
|
||||
const setTimeBasis = (field: MemoTimeBasis) => {
|
||||
setViewState((prev) => {
|
||||
const newState = { ...prev, timeBasis: field };
|
||||
persistToStorage(newState);
|
||||
return newState;
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<ViewContext.Provider
|
||||
value={{
|
||||
orderByTimeAsc: viewState.orderByTimeAsc,
|
||||
timeBasis,
|
||||
toggleSortOrder,
|
||||
setTimeBasis,
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
</ViewContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
export function useView() {
|
||||
const context = useContext(ViewContext);
|
||||
if (!context) {
|
||||
throw new Error("useView must be used within ViewProvider");
|
||||
}
|
||||
return context;
|
||||
}
|
||||
Reference in New Issue
Block a user