mirror of https://github.com/usememos/memos.git
feat: react query migration (#5379)
This commit is contained in:
parent
4109fe3245
commit
f87f728b0f
53
CLAUDE.md
53
CLAUDE.md
|
|
@ -17,9 +17,16 @@ Memos is a self-hosted knowledge management platform with a Go backend and React
|
|||
- Each driver has its own migration files in `store/db/{driver}/migration/`
|
||||
- Schema version tracked in `instance_setting` table (key: `bb.general.version`)
|
||||
|
||||
**Why MobX for frontend state?**
|
||||
- Simpler than Redux for this application's needs
|
||||
- Stores in `web/src/store/` handle global state (user, memos, editor, dialogs)
|
||||
**Why React Query + Context for frontend state?**
|
||||
- **Server state** (memos, users, attachments) managed by React Query (TanStack Query v5)
|
||||
- Automatic caching, deduplication, and background refetching
|
||||
- Hooks in `web/src/hooks/useMemoQueries.ts`, `useUserQueries.ts`, `useAttachmentQueries.ts`
|
||||
- **Client state** (UI preferences, filters) managed by React Context
|
||||
- ViewContext (`web/src/contexts/ViewContext.tsx`) - layout, sort order
|
||||
- MemoFilterContext (`web/src/contexts/MemoFilterContext.tsx`) - filter state
|
||||
- **Legacy MobX** still present in some components (gradual migration in progress)
|
||||
- Stores in `web/src/store/` used by unmigrated components
|
||||
- Both systems coexist during transition period
|
||||
|
||||
## Critical Development Commands
|
||||
|
||||
|
|
@ -32,7 +39,7 @@ golangci-lint run # Lint
|
|||
|
||||
**Frontend:**
|
||||
```bash
|
||||
cd web && pnpm dev # Start dev server
|
||||
cd web && pnpm dev # Start dev server (React Query devtools at bottom-left)
|
||||
cd web && pnpm lint:fix # Lint and fix
|
||||
cd web && pnpm release # Build and copy to backend
|
||||
```
|
||||
|
|
@ -42,6 +49,42 @@ cd web && pnpm release # Build and copy to backend
|
|||
cd proto && buf generate # Regenerate Go + TypeScript from .proto
|
||||
```
|
||||
|
||||
## Frontend State Management
|
||||
|
||||
**Using React Query (Server State):**
|
||||
```typescript
|
||||
// Fetch memos
|
||||
import { useMemos, useMemo } from "@/hooks/useMemoQueries";
|
||||
const { data: memos, isLoading } = useMemos({ filter });
|
||||
const { data: memo } = useMemo(memoName);
|
||||
|
||||
// Mutations
|
||||
import { useCreateMemo, useUpdateMemo } from "@/hooks/useMemoQueries";
|
||||
const { mutate: createMemo } = useCreateMemo();
|
||||
const { mutate: updateMemo } = useUpdateMemo();
|
||||
```
|
||||
|
||||
**Using Context (Client State):**
|
||||
```typescript
|
||||
// View preferences
|
||||
import { useView } from "@/contexts/ViewContext";
|
||||
const { layout, setLayout, orderByTimeAsc, toggleSortOrder } = useView();
|
||||
|
||||
// Filters
|
||||
import { useMemoFilter } from "@/contexts/MemoFilterContext";
|
||||
const { filter, updateFilter } = useMemoFilter();
|
||||
```
|
||||
|
||||
**React Query DevTools:**
|
||||
- Available in dev mode at bottom-left corner
|
||||
- Inspect query cache, mutations, and refetch behavior
|
||||
- Query keys organized by resource: `memoKeys`, `userKeys`, `attachmentKeys`
|
||||
|
||||
**Migration Status:**
|
||||
- ✅ Migrated: Home, MemoDetail, UserProfile, Inboxes pages
|
||||
- 🔄 In Progress: Remaining pages and components (gradual migration)
|
||||
- See `web/scripts/migration-guide.md` for migration patterns
|
||||
|
||||
## Key Workflows
|
||||
|
||||
**Modifying APIs:**
|
||||
|
|
@ -66,7 +109,7 @@ cd proto && buf generate # Regenerate Go + TypeScript from .
|
|||
**Entry point:** `cmd/memos/` starts the server
|
||||
**API layer:** `server/router/api/v1/` implements gRPC services
|
||||
**Data layer:** `store/` handles all persistence
|
||||
**Frontend:** `web/src/` React app with MobX state management
|
||||
**Frontend:** `web/src/` React app with React Query + Context (migrating from MobX)
|
||||
|
||||
## Testing Expectations
|
||||
|
||||
|
|
|
|||
|
|
@ -30,6 +30,8 @@
|
|||
"@radix-ui/react-switch": "^1.2.6",
|
||||
"@radix-ui/react-tooltip": "^1.2.8",
|
||||
"@tailwindcss/vite": "^4.1.17",
|
||||
"@tanstack/react-query": "^5.90.12",
|
||||
"@tanstack/react-query-devtools": "^5.91.1",
|
||||
"class-variance-authority": "^0.7.1",
|
||||
"clsx": "^2.1.1",
|
||||
"copy-to-clipboard": "^3.3.3",
|
||||
|
|
@ -45,8 +47,6 @@
|
|||
"mermaid": "^11.12.1",
|
||||
"micromark-extension-gfm": "^3.0.0",
|
||||
"mime": "^4.1.0",
|
||||
"mobx": "^6.15.0",
|
||||
"mobx-react-lite": "^4.1.1",
|
||||
"react": "^18.3.1",
|
||||
"react-dom": "^18.3.1",
|
||||
"react-force-graph-2d": "^1.29.0",
|
||||
|
|
|
|||
|
|
@ -68,6 +68,12 @@ importers:
|
|||
'@tailwindcss/vite':
|
||||
specifier: ^4.1.17
|
||||
version: 4.1.17(vite@7.2.4(@types/node@24.10.1)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.1))
|
||||
'@tanstack/react-query':
|
||||
specifier: ^5.90.12
|
||||
version: 5.90.12(react@18.3.1)
|
||||
'@tanstack/react-query-devtools':
|
||||
specifier: ^5.91.1
|
||||
version: 5.91.1(@tanstack/react-query@5.90.12(react@18.3.1))(react@18.3.1)
|
||||
class-variance-authority:
|
||||
specifier: ^0.7.1
|
||||
version: 0.7.1
|
||||
|
|
@ -113,12 +119,6 @@ importers:
|
|||
mime:
|
||||
specifier: ^4.1.0
|
||||
version: 4.1.0
|
||||
mobx:
|
||||
specifier: ^6.15.0
|
||||
version: 6.15.0
|
||||
mobx-react-lite:
|
||||
specifier: ^4.1.1
|
||||
version: 4.1.1(mobx@6.15.0)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
|
||||
react:
|
||||
specifier: ^18.3.1
|
||||
version: 18.3.1
|
||||
|
|
@ -1342,6 +1342,23 @@ packages:
|
|||
peerDependencies:
|
||||
vite: ^5.2.0 || ^6 || ^7
|
||||
|
||||
'@tanstack/query-core@5.90.12':
|
||||
resolution: {integrity: sha512-T1/8t5DhV/SisWjDnaiU2drl6ySvsHj1bHBCWNXd+/T+Hh1cf6JodyEYMd5sgwm+b/mETT4EV3H+zCVczCU5hg==}
|
||||
|
||||
'@tanstack/query-devtools@5.91.1':
|
||||
resolution: {integrity: sha512-l8bxjk6BMsCaVQH6NzQEE/bEgFy1hAs5qbgXl0xhzezlaQbPk6Mgz9BqEg2vTLPOHD8N4k+w/gdgCbEzecGyNg==}
|
||||
|
||||
'@tanstack/react-query-devtools@5.91.1':
|
||||
resolution: {integrity: sha512-tRnJYwEbH0kAOuToy8Ew7bJw1lX3AjkkgSlf/vzb+NpnqmHPdWM+lA2DSdGQSLi1SU0PDRrrCI1vnZnci96CsQ==}
|
||||
peerDependencies:
|
||||
'@tanstack/react-query': ^5.90.10
|
||||
react: ^18 || ^19
|
||||
|
||||
'@tanstack/react-query@5.90.12':
|
||||
resolution: {integrity: sha512-graRZspg7EoEaw0a8faiUASCyJrqjKPdqJ9EwuDRUF9mEYJ1YPczI9H+/agJ0mOJkPCJDk0lsz5QTrLZ/jQ2rg==}
|
||||
peerDependencies:
|
||||
react: ^18 || ^19
|
||||
|
||||
'@tweenjs/tween.js@25.0.0':
|
||||
resolution: {integrity: sha512-XKLA6syeBUaPzx4j3qwMqzzq+V4uo72BnlbOjmuljLrRqdsd3qnzvZZoxvMHZ23ndsRS4aufU6JOZYpCbU6T1A==}
|
||||
|
||||
|
|
@ -2403,22 +2420,6 @@ packages:
|
|||
mlly@1.8.0:
|
||||
resolution: {integrity: sha512-l8D9ODSRWLe2KHJSifWGwBqpTZXIXTeo8mlKjY+E2HAakaTeNpqAyBZ8GSqLzHgw4XmHmC8whvpjJNMbFZN7/g==}
|
||||
|
||||
mobx-react-lite@4.1.1:
|
||||
resolution: {integrity: sha512-iUxiMpsvNraCKXU+yPotsOncNNmyeS2B5DKL+TL6Tar/xm+wwNJAubJmtRSeAoYawdZqwv8Z/+5nPRHeQxTiXg==}
|
||||
peerDependencies:
|
||||
mobx: ^6.9.0
|
||||
react: ^16.8.0 || ^17 || ^18 || ^19
|
||||
react-dom: '*'
|
||||
react-native: '*'
|
||||
peerDependenciesMeta:
|
||||
react-dom:
|
||||
optional: true
|
||||
react-native:
|
||||
optional: true
|
||||
|
||||
mobx@6.15.0:
|
||||
resolution: {integrity: sha512-UczzB+0nnwGotYSgllfARAqWCJ5e/skuV2K/l+Zyck/H6pJIhLXuBnz+6vn2i211o7DtbE78HQtsYEKICHGI+g==}
|
||||
|
||||
ms@2.1.3:
|
||||
resolution: {integrity: sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==}
|
||||
|
||||
|
|
@ -2883,11 +2884,6 @@ packages:
|
|||
'@types/react':
|
||||
optional: true
|
||||
|
||||
use-sync-external-store@1.6.0:
|
||||
resolution: {integrity: sha512-Pp6GSwGP/NrPIrxVFAIkOQeyw8lFenOHijQWkUTrDvrF4ALqylP2C/KCkeS9dpUM3KvYRQhna5vt7IL95+ZQ9w==}
|
||||
peerDependencies:
|
||||
react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0
|
||||
|
||||
uuid@11.1.0:
|
||||
resolution: {integrity: sha512-0/A9rDy9P7cJ+8w1c9WD9V//9Wj15Ce2MPz8Ri6032usz+NfePxx5AcN3bN+r6ZL6jEo066/yNYB3tn4pQEx+A==}
|
||||
hasBin: true
|
||||
|
|
@ -3990,6 +3986,21 @@ snapshots:
|
|||
tailwindcss: 4.1.17
|
||||
vite: 7.2.4(@types/node@24.10.1)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.1)
|
||||
|
||||
'@tanstack/query-core@5.90.12': {}
|
||||
|
||||
'@tanstack/query-devtools@5.91.1': {}
|
||||
|
||||
'@tanstack/react-query-devtools@5.91.1(@tanstack/react-query@5.90.12(react@18.3.1))(react@18.3.1)':
|
||||
dependencies:
|
||||
'@tanstack/query-devtools': 5.91.1
|
||||
'@tanstack/react-query': 5.90.12(react@18.3.1)
|
||||
react: 18.3.1
|
||||
|
||||
'@tanstack/react-query@5.90.12(react@18.3.1)':
|
||||
dependencies:
|
||||
'@tanstack/query-core': 5.90.12
|
||||
react: 18.3.1
|
||||
|
||||
'@tweenjs/tween.js@25.0.0': {}
|
||||
|
||||
'@types/babel__core@7.20.5':
|
||||
|
|
@ -5376,16 +5387,6 @@ snapshots:
|
|||
pkg-types: 1.3.1
|
||||
ufo: 1.6.1
|
||||
|
||||
mobx-react-lite@4.1.1(mobx@6.15.0)(react-dom@18.3.1(react@18.3.1))(react@18.3.1):
|
||||
dependencies:
|
||||
mobx: 6.15.0
|
||||
react: 18.3.1
|
||||
use-sync-external-store: 1.6.0(react@18.3.1)
|
||||
optionalDependencies:
|
||||
react-dom: 18.3.1(react@18.3.1)
|
||||
|
||||
mobx@6.15.0: {}
|
||||
|
||||
ms@2.1.3: {}
|
||||
|
||||
nano-css@5.6.2(react-dom@18.3.1(react@18.3.1))(react@18.3.1):
|
||||
|
|
@ -5921,10 +5922,6 @@ snapshots:
|
|||
optionalDependencies:
|
||||
'@types/react': 18.3.27
|
||||
|
||||
use-sync-external-store@1.6.0(react@18.3.1):
|
||||
dependencies:
|
||||
react: 18.3.1
|
||||
|
||||
uuid@11.1.0: {}
|
||||
|
||||
vfile-location@5.0.3:
|
||||
|
|
|
|||
|
|
@ -1,16 +1,15 @@
|
|||
import { observer } from "mobx-react-lite";
|
||||
import { useEffect } from "react";
|
||||
import { Outlet } from "react-router-dom";
|
||||
import { useInstance } from "./contexts/InstanceContext";
|
||||
import { MemoFilterProvider } from "./contexts/MemoFilterContext";
|
||||
import useNavigateTo from "./hooks/useNavigateTo";
|
||||
import { useUserLocale } from "./hooks/useUserLocale";
|
||||
import { useUserTheme } from "./hooks/useUserTheme";
|
||||
import { instanceStore } from "./store";
|
||||
import { cleanupExpiredOAuthState } from "./utils/oauth";
|
||||
|
||||
const App = observer(() => {
|
||||
const App = () => {
|
||||
const navigateTo = useNavigateTo();
|
||||
const instanceProfile = instanceStore.state.profile;
|
||||
const instanceGeneralSetting = instanceStore.state.generalSetting;
|
||||
const { profile: instanceProfile, generalSetting: instanceGeneralSetting } = useInstance();
|
||||
|
||||
// Apply user preferences reactively
|
||||
useUserLocale();
|
||||
|
|
@ -21,12 +20,12 @@ const App = observer(() => {
|
|||
cleanupExpiredOAuthState();
|
||||
}, []);
|
||||
|
||||
// Redirect to sign up page if no instance owner.
|
||||
// Redirect to sign up page if no instance owner
|
||||
useEffect(() => {
|
||||
if (!instanceProfile.owner) {
|
||||
navigateTo("/auth/signup");
|
||||
}
|
||||
}, [instanceProfile.owner]);
|
||||
}, [instanceProfile.owner, navigateTo]);
|
||||
|
||||
useEffect(() => {
|
||||
if (instanceGeneralSetting.additionalStyle) {
|
||||
|
|
@ -45,7 +44,7 @@ const App = observer(() => {
|
|||
}
|
||||
}, [instanceGeneralSetting.additionalScript]);
|
||||
|
||||
// Dynamic update metadata with customized profile.
|
||||
// Dynamic update metadata with customized profile
|
||||
useEffect(() => {
|
||||
if (!instanceGeneralSetting.customProfile) {
|
||||
return;
|
||||
|
|
@ -56,7 +55,11 @@ const App = observer(() => {
|
|||
link.href = instanceGeneralSetting.customProfile.logoUrl || "/logo.webp";
|
||||
}, [instanceGeneralSetting.customProfile]);
|
||||
|
||||
return <Outlet />;
|
||||
});
|
||||
return (
|
||||
<MemoFilterProvider>
|
||||
<Outlet />
|
||||
</MemoFilterProvider>
|
||||
);
|
||||
};
|
||||
|
||||
export default App;
|
||||
|
|
|
|||
|
|
@ -1,65 +1,63 @@
|
|||
import dayjs from "dayjs";
|
||||
import { observer } from "mobx-react-lite";
|
||||
import { memo, useMemo } from "react";
|
||||
import { TooltipProvider } from "@/components/ui/tooltip";
|
||||
import { instanceStore } from "@/store";
|
||||
import { useInstance } from "@/contexts/InstanceContext";
|
||||
import type { ActivityCalendarProps } from "@/types/statistics";
|
||||
import { useTranslate } from "@/utils/i18n";
|
||||
import { CalendarCell } from "./CalendarCell";
|
||||
import { getTooltipText, useTodayDate, useWeekdayLabels } from "./shared";
|
||||
import { useCalendarMatrix } from "./useCalendarMatrix";
|
||||
|
||||
export const ActivityCalendar = memo(
|
||||
observer((props: ActivityCalendarProps) => {
|
||||
const t = useTranslate();
|
||||
const { month, selectedDate, data, onClick } = props;
|
||||
const weekStartDayOffset = instanceStore.state.generalSetting.weekStartDayOffset;
|
||||
export const ActivityCalendar = memo((props: ActivityCalendarProps) => {
|
||||
const t = useTranslate();
|
||||
const { month, selectedDate, data, onClick } = props;
|
||||
const { generalSetting } = useInstance();
|
||||
const weekStartDayOffset = generalSetting.weekStartDayOffset;
|
||||
|
||||
const today = useTodayDate();
|
||||
const weekDaysRaw = useWeekdayLabels();
|
||||
const selectedDateFormatted = useMemo(() => dayjs(selectedDate).format("YYYY-MM-DD"), [selectedDate]);
|
||||
const today = useTodayDate();
|
||||
const weekDaysRaw = useWeekdayLabels();
|
||||
const selectedDateFormatted = useMemo(() => dayjs(selectedDate).format("YYYY-MM-DD"), [selectedDate]);
|
||||
|
||||
const { weeks, weekDays, maxCount } = useCalendarMatrix({
|
||||
month,
|
||||
data,
|
||||
weekDays: weekDaysRaw,
|
||||
weekStartDayOffset,
|
||||
today,
|
||||
selectedDate: selectedDateFormatted,
|
||||
});
|
||||
const { weeks, weekDays, maxCount } = useCalendarMatrix({
|
||||
month,
|
||||
data,
|
||||
weekDays: weekDaysRaw,
|
||||
weekStartDayOffset,
|
||||
today,
|
||||
selectedDate: selectedDateFormatted,
|
||||
});
|
||||
|
||||
return (
|
||||
<TooltipProvider>
|
||||
<div className="w-full flex flex-col gap-0.5">
|
||||
<div className="grid grid-cols-7 gap-0.5 text-xs text-muted-foreground">
|
||||
{weekDays.map((label, index) => (
|
||||
<div key={index} className="flex h-4 items-center justify-center text-muted-foreground/80">
|
||||
{label}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-7 gap-0.5">
|
||||
{weeks.map((week, weekIndex) =>
|
||||
week.days.map((day, dayIndex) => {
|
||||
const tooltipText = getTooltipText(day.count, day.date, t);
|
||||
|
||||
return (
|
||||
<CalendarCell
|
||||
key={`${weekIndex}-${dayIndex}-${day.date}`}
|
||||
day={day}
|
||||
maxCount={maxCount}
|
||||
tooltipText={tooltipText}
|
||||
onClick={onClick}
|
||||
/>
|
||||
);
|
||||
}),
|
||||
)}
|
||||
</div>
|
||||
return (
|
||||
<TooltipProvider>
|
||||
<div className="w-full flex flex-col gap-0.5">
|
||||
<div className="grid grid-cols-7 gap-0.5 text-xs text-muted-foreground">
|
||||
{weekDays.map((label, index) => (
|
||||
<div key={index} className="flex h-4 items-center justify-center text-muted-foreground/80">
|
||||
{label}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</TooltipProvider>
|
||||
);
|
||||
}),
|
||||
);
|
||||
|
||||
<div className="grid grid-cols-7 gap-0.5">
|
||||
{weeks.map((week, weekIndex) =>
|
||||
week.days.map((day, dayIndex) => {
|
||||
const tooltipText = getTooltipText(day.count, day.date, t);
|
||||
|
||||
return (
|
||||
<CalendarCell
|
||||
key={`${weekIndex}-${dayIndex}-${day.date}`}
|
||||
day={day}
|
||||
maxCount={maxCount}
|
||||
tooltipText={tooltipText}
|
||||
onClick={onClick}
|
||||
/>
|
||||
);
|
||||
}),
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</TooltipProvider>
|
||||
);
|
||||
});
|
||||
|
||||
ActivityCalendar.displayName = "ActivityCalendar";
|
||||
|
|
|
|||
|
|
@ -1,7 +1,6 @@
|
|||
import { observer } from "mobx-react-lite";
|
||||
import { memo } from "react";
|
||||
import { useInstance } from "@/contexts/InstanceContext";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { instanceStore } from "@/store";
|
||||
import { useTranslate } from "@/utils/i18n";
|
||||
import { CalendarCell } from "./CalendarCell";
|
||||
import { DEFAULT_CELL_SIZE, SMALL_CELL_SIZE } from "./constants";
|
||||
|
|
@ -9,48 +8,47 @@ import { getTooltipText, useTodayDate, useWeekdayLabels } from "./shared";
|
|||
import type { CompactMonthCalendarProps } from "./types";
|
||||
import { useCalendarMatrix } from "./useCalendarMatrix";
|
||||
|
||||
export const CompactMonthCalendar = memo(
|
||||
observer((props: CompactMonthCalendarProps) => {
|
||||
const { month, data, maxCount, size = "default", onClick } = props;
|
||||
const t = useTranslate();
|
||||
export const CompactMonthCalendar = memo((props: CompactMonthCalendarProps) => {
|
||||
const { month, data, maxCount, size = "default", onClick } = props;
|
||||
const t = useTranslate();
|
||||
const { generalSetting } = useInstance();
|
||||
|
||||
const weekStartDayOffset = instanceStore.state.generalSetting.weekStartDayOffset;
|
||||
const weekStartDayOffset = generalSetting.weekStartDayOffset;
|
||||
|
||||
const today = useTodayDate();
|
||||
const weekDays = useWeekdayLabels();
|
||||
const today = useTodayDate();
|
||||
const weekDays = useWeekdayLabels();
|
||||
|
||||
const { weeks } = useCalendarMatrix({
|
||||
month,
|
||||
data,
|
||||
weekDays,
|
||||
weekStartDayOffset,
|
||||
today,
|
||||
selectedDate: "",
|
||||
});
|
||||
const { weeks } = useCalendarMatrix({
|
||||
month,
|
||||
data,
|
||||
weekDays,
|
||||
weekStartDayOffset,
|
||||
today,
|
||||
selectedDate: "",
|
||||
});
|
||||
|
||||
const sizeConfig = size === "small" ? SMALL_CELL_SIZE : DEFAULT_CELL_SIZE;
|
||||
const sizeConfig = size === "small" ? SMALL_CELL_SIZE : DEFAULT_CELL_SIZE;
|
||||
|
||||
return (
|
||||
<div className={cn("grid grid-cols-7", sizeConfig.gap)}>
|
||||
{weeks.map((week, weekIndex) =>
|
||||
week.days.map((day, dayIndex) => {
|
||||
const tooltipText = getTooltipText(day.count, day.date, t);
|
||||
return (
|
||||
<div className={cn("grid grid-cols-7", sizeConfig.gap)}>
|
||||
{weeks.map((week, weekIndex) =>
|
||||
week.days.map((day, dayIndex) => {
|
||||
const tooltipText = getTooltipText(day.count, day.date, t);
|
||||
|
||||
return (
|
||||
<CalendarCell
|
||||
key={`${weekIndex}-${dayIndex}-${day.date}`}
|
||||
day={day}
|
||||
maxCount={maxCount}
|
||||
tooltipText={tooltipText}
|
||||
onClick={onClick}
|
||||
size={size}
|
||||
/>
|
||||
);
|
||||
}),
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}),
|
||||
);
|
||||
return (
|
||||
<CalendarCell
|
||||
key={`${weekIndex}-${dayIndex}-${day.date}`}
|
||||
day={day}
|
||||
maxCount={maxCount}
|
||||
tooltipText={tooltipText}
|
||||
onClick={onClick}
|
||||
size={size}
|
||||
/>
|
||||
);
|
||||
}),
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
});
|
||||
|
||||
CompactMonthCalendar.displayName = "CompactMonthCalendar";
|
||||
|
|
|
|||
|
|
@ -1,4 +1,3 @@
|
|||
import { observer } from "mobx-react-lite";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import i18n from "@/i18n";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
|
@ -10,7 +9,7 @@ interface Props {
|
|||
className?: string;
|
||||
}
|
||||
|
||||
const AuthFooter = observer(({ className }: Props) => {
|
||||
const AuthFooter = ({ className }: Props) => {
|
||||
const { i18n: i18nInstance } = useTranslation();
|
||||
const currentLocale = i18nInstance.language as Locale;
|
||||
const currentTheme = getInitialTheme();
|
||||
|
|
@ -29,6 +28,6 @@ const AuthFooter = observer(({ className }: Props) => {
|
|||
<ThemeSelect value={currentTheme} onValueChange={handleThemeChange} />
|
||||
</div>
|
||||
);
|
||||
});
|
||||
};
|
||||
|
||||
export default AuthFooter;
|
||||
|
|
|
|||
|
|
@ -4,7 +4,7 @@ import { Button } from "@/components/ui/button";
|
|||
import { Dialog, DialogContent, DialogFooter, DialogHeader, DialogTitle } from "@/components/ui/dialog";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { userStore } from "@/store";
|
||||
import { useUpdateUser } from "@/hooks/useUserQueries";
|
||||
import { User } from "@/types/proto/api/v1/user_service_pb";
|
||||
import { useTranslate } from "@/utils/i18n";
|
||||
|
||||
|
|
@ -17,6 +17,7 @@ interface Props {
|
|||
|
||||
function ChangeMemberPasswordDialog({ open, onOpenChange, user, onSuccess }: Props) {
|
||||
const t = useTranslate();
|
||||
const { mutateAsync: updateUser } = useUpdateUser();
|
||||
const [newPassword, setNewPassword] = useState("");
|
||||
const [newPasswordAgain, setNewPasswordAgain] = useState("");
|
||||
|
||||
|
|
@ -49,13 +50,13 @@ function ChangeMemberPasswordDialog({ open, onOpenChange, user, onSuccess }: Pro
|
|||
}
|
||||
|
||||
try {
|
||||
await userStore.updateUser(
|
||||
{
|
||||
await updateUser({
|
||||
user: {
|
||||
name: user.name,
|
||||
password: newPassword,
|
||||
},
|
||||
["password"],
|
||||
);
|
||||
updateMask: ["password"],
|
||||
});
|
||||
toast(t("message.password-changed"));
|
||||
onSuccess?.();
|
||||
onOpenChange(false);
|
||||
|
|
|
|||
|
|
@ -75,7 +75,7 @@ function CreateAccessTokenDialog({ open, onOpenChange, onSuccess }: Props) {
|
|||
try {
|
||||
requestState.setLoading();
|
||||
const response = await userServiceClient.createPersonalAccessToken({
|
||||
parent: currentUser.name,
|
||||
parent: currentUser?.name,
|
||||
description: state.description,
|
||||
expiresInDays: state.expiration,
|
||||
});
|
||||
|
|
|
|||
|
|
@ -8,9 +8,9 @@ import { Input } from "@/components/ui/input";
|
|||
import { Label } from "@/components/ui/label";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
import { shortcutServiceClient } from "@/connect";
|
||||
import { useAuth } from "@/contexts/AuthContext";
|
||||
import useCurrentUser from "@/hooks/useCurrentUser";
|
||||
import useLoading from "@/hooks/useLoading";
|
||||
import { userStore } from "@/store";
|
||||
import { Shortcut, ShortcutSchema } from "@/types/proto/api/v1/shortcut_service_pb";
|
||||
import { useTranslate } from "@/utils/i18n";
|
||||
|
||||
|
|
@ -24,6 +24,7 @@ interface Props {
|
|||
function CreateShortcutDialog({ open, onOpenChange, shortcut: initialShortcut, onSuccess }: Props) {
|
||||
const t = useTranslate();
|
||||
const user = useCurrentUser();
|
||||
const { refetchSettings } = useAuth();
|
||||
const [shortcut, setShortcut] = useState<Shortcut>(
|
||||
create(ShortcutSchema, {
|
||||
name: initialShortcut?.name || "",
|
||||
|
|
@ -66,7 +67,7 @@ function CreateShortcutDialog({ open, onOpenChange, shortcut: initialShortcut, o
|
|||
requestState.setLoading();
|
||||
if (isCreating) {
|
||||
await shortcutServiceClient.createShortcut({
|
||||
parent: user.name,
|
||||
parent: user?.name,
|
||||
shortcut: {
|
||||
name: "", // Will be set by server
|
||||
title: shortcut.title,
|
||||
|
|
@ -85,7 +86,7 @@ function CreateShortcutDialog({ open, onOpenChange, shortcut: initialShortcut, o
|
|||
toast.success("Update shortcut successfully");
|
||||
}
|
||||
// Refresh shortcuts.
|
||||
await userStore.fetchUserSettings();
|
||||
await refetchSettings();
|
||||
requestState.setFinish();
|
||||
onSuccess?.();
|
||||
onOpenChange(false);
|
||||
|
|
|
|||
|
|
@ -1,15 +1,14 @@
|
|||
import { timestampDate } from "@bufbuild/protobuf/wkt";
|
||||
import { create } from "@bufbuild/protobuf";
|
||||
import { FieldMaskSchema, timestampDate } from "@bufbuild/protobuf/wkt";
|
||||
import { CheckIcon, MessageCircleIcon, TrashIcon, XIcon } from "lucide-react";
|
||||
import { observer } from "mobx-react-lite";
|
||||
import { useState } from "react";
|
||||
import toast from "react-hot-toast";
|
||||
import UserAvatar from "@/components/UserAvatar";
|
||||
import { activityServiceClient } from "@/connect";
|
||||
import { activityServiceClient, memoServiceClient, userServiceClient } from "@/connect";
|
||||
import { activityNamePrefix } from "@/helpers/resource-names";
|
||||
import useAsyncEffect from "@/hooks/useAsyncEffect";
|
||||
import useNavigateTo from "@/hooks/useNavigateTo";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { memoStore, userStore } from "@/store";
|
||||
import { activityNamePrefix } from "@/store/common";
|
||||
import { Memo } from "@/types/proto/api/v1/memo_service_pb";
|
||||
import { User, UserNotification, UserNotification_Status } from "@/types/proto/api/v1/user_service_pb";
|
||||
import { useTranslate } from "@/utils/i18n";
|
||||
|
|
@ -18,7 +17,7 @@ interface Props {
|
|||
notification: UserNotification;
|
||||
}
|
||||
|
||||
const MemoCommentMessage = observer(({ notification }: Props) => {
|
||||
function MemoCommentMessage({ notification }: Props) {
|
||||
const t = useTranslate();
|
||||
const navigateTo = useNavigateTo();
|
||||
const [relatedMemo, setRelatedMemo] = useState<Memo | undefined>(undefined);
|
||||
|
|
@ -39,18 +38,20 @@ const MemoCommentMessage = observer(({ notification }: Props) => {
|
|||
|
||||
if (activity.payload?.payload?.case === "memoComment") {
|
||||
const memoCommentPayload = activity.payload.payload.value;
|
||||
const memo = await memoStore.getOrFetchMemoByName(memoCommentPayload.relatedMemo, {
|
||||
skipStore: true,
|
||||
const memo = await memoServiceClient.getMemo({
|
||||
name: memoCommentPayload.relatedMemo,
|
||||
});
|
||||
setRelatedMemo(memo);
|
||||
|
||||
// Fetch the comment memo
|
||||
const comment = await memoStore.getOrFetchMemoByName(memoCommentPayload.memo, {
|
||||
skipStore: true,
|
||||
const comment = await memoServiceClient.getMemo({
|
||||
name: memoCommentPayload.memo,
|
||||
});
|
||||
setCommentMemo(comment);
|
||||
|
||||
const sender = await userStore.getOrFetchUser(notification.sender);
|
||||
const sender = await userServiceClient.getUser({
|
||||
name: notification.sender,
|
||||
});
|
||||
setSender(sender);
|
||||
setInitialized(true);
|
||||
}
|
||||
|
|
@ -73,20 +74,22 @@ const MemoCommentMessage = observer(({ notification }: Props) => {
|
|||
};
|
||||
|
||||
const handleArchiveMessage = async (silence = false) => {
|
||||
await userStore.updateNotification(
|
||||
{
|
||||
await userServiceClient.updateUserNotification({
|
||||
notification: {
|
||||
name: notification.name,
|
||||
status: UserNotification_Status.ARCHIVED,
|
||||
},
|
||||
["status"],
|
||||
);
|
||||
updateMask: create(FieldMaskSchema, { paths: ["status"] }),
|
||||
});
|
||||
if (!silence) {
|
||||
toast.success(t("message.archived-successfully"));
|
||||
}
|
||||
};
|
||||
|
||||
const handleDeleteMessage = async () => {
|
||||
await userStore.deleteNotification(notification.name);
|
||||
await userServiceClient.deleteUserNotification({
|
||||
name: notification.name,
|
||||
});
|
||||
toast.success(t("message.deleted-successfully"));
|
||||
};
|
||||
|
||||
|
|
@ -222,6 +225,6 @@ const MemoCommentMessage = observer(({ notification }: Props) => {
|
|||
</div>
|
||||
</div>
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
export default MemoCommentMessage;
|
||||
|
|
|
|||
|
|
@ -11,7 +11,6 @@ import {
|
|||
SquareCheckIcon,
|
||||
TrashIcon,
|
||||
} from "lucide-react";
|
||||
import { observer } from "mobx-react-lite";
|
||||
import { useState } from "react";
|
||||
import ConfirmDialog from "@/components/ConfirmDialog";
|
||||
import { Button } from "@/components/ui/button";
|
||||
|
|
@ -30,7 +29,7 @@ import { hasCompletedTasks } from "@/utils/markdown-manipulation";
|
|||
import { useMemoActionHandlers } from "./hooks";
|
||||
import type { MemoActionMenuProps } from "./types";
|
||||
|
||||
const MemoActionMenu = observer((props: MemoActionMenuProps) => {
|
||||
const MemoActionMenu = (props: MemoActionMenuProps) => {
|
||||
const { memo, readonly } = props;
|
||||
const t = useTranslate();
|
||||
|
||||
|
|
@ -157,6 +156,6 @@ const MemoActionMenu = observer((props: MemoActionMenuProps) => {
|
|||
/>
|
||||
</DropdownMenu>
|
||||
);
|
||||
});
|
||||
};
|
||||
|
||||
export default MemoActionMenu;
|
||||
|
|
|
|||
|
|
@ -1,9 +1,12 @@
|
|||
import { useQueryClient } from "@tanstack/react-query";
|
||||
import copy from "copy-to-clipboard";
|
||||
import { useCallback } from "react";
|
||||
import toast from "react-hot-toast";
|
||||
import { useLocation } from "react-router-dom";
|
||||
import { useInstance } from "@/contexts/InstanceContext";
|
||||
import { memoKeys, useDeleteMemo, useUpdateMemo } from "@/hooks/useMemoQueries";
|
||||
import useNavigateTo from "@/hooks/useNavigateTo";
|
||||
import { instanceStore, memoStore, userStore } from "@/store";
|
||||
import { userKeys } from "@/hooks/useUserQueries";
|
||||
import { State } from "@/types/proto/api/v1/common_pb";
|
||||
import type { Memo } from "@/types/proto/api/v1/memo_service_pb";
|
||||
import { useTranslate } from "@/utils/i18n";
|
||||
|
|
@ -20,25 +23,30 @@ export const useMemoActionHandlers = ({ memo, onEdit, setDeleteDialogOpen, setRe
|
|||
const t = useTranslate();
|
||||
const location = useLocation();
|
||||
const navigateTo = useNavigateTo();
|
||||
const queryClient = useQueryClient();
|
||||
const { profile } = useInstance();
|
||||
const { mutateAsync: updateMemo } = useUpdateMemo();
|
||||
const { mutateAsync: deleteMemo } = useDeleteMemo();
|
||||
const isInMemoDetailPage = location.pathname.startsWith(`/${memo.name}`);
|
||||
|
||||
const memoUpdatedCallback = useCallback(() => {
|
||||
userStore.setStatsStateId();
|
||||
}, []);
|
||||
// Invalidate user stats to trigger refetch
|
||||
queryClient.invalidateQueries({ queryKey: userKeys.stats() });
|
||||
}, [queryClient]);
|
||||
|
||||
const handleTogglePinMemoBtnClick = useCallback(async () => {
|
||||
try {
|
||||
await memoStore.updateMemo(
|
||||
{
|
||||
await updateMemo({
|
||||
update: {
|
||||
name: memo.name,
|
||||
pinned: !memo.pinned,
|
||||
},
|
||||
["pinned"],
|
||||
);
|
||||
updateMask: ["pinned"],
|
||||
});
|
||||
} catch {
|
||||
// do nothing
|
||||
}
|
||||
}, [memo.name, memo.pinned]);
|
||||
}, [memo.name, memo.pinned, updateMemo]);
|
||||
|
||||
const handleEditMemoClick = useCallback(() => {
|
||||
onEdit?.();
|
||||
|
|
@ -49,13 +57,13 @@ export const useMemoActionHandlers = ({ memo, onEdit, setDeleteDialogOpen, setRe
|
|||
const message = memo.state === State.ARCHIVED ? t("message.restored-successfully") : t("message.archived-successfully");
|
||||
|
||||
try {
|
||||
await memoStore.updateMemo(
|
||||
{
|
||||
await updateMemo({
|
||||
update: {
|
||||
name: memo.name,
|
||||
state,
|
||||
},
|
||||
["state"],
|
||||
);
|
||||
updateMask: ["state"],
|
||||
});
|
||||
toast.success(message);
|
||||
} catch (error: unknown) {
|
||||
const err = error as { details?: string };
|
||||
|
|
@ -68,16 +76,16 @@ export const useMemoActionHandlers = ({ memo, onEdit, setDeleteDialogOpen, setRe
|
|||
navigateTo(memo.state === State.ARCHIVED ? "/" : "/archived");
|
||||
}
|
||||
memoUpdatedCallback();
|
||||
}, [memo.name, memo.state, t, isInMemoDetailPage, navigateTo, memoUpdatedCallback]);
|
||||
}, [memo.name, memo.state, t, isInMemoDetailPage, navigateTo, memoUpdatedCallback, updateMemo]);
|
||||
|
||||
const handleCopyLink = useCallback(() => {
|
||||
let host = instanceStore.state.profile.instanceUrl;
|
||||
let host = profile.instanceUrl;
|
||||
if (host === "") {
|
||||
host = window.location.origin;
|
||||
}
|
||||
copy(`${host}/${memo.name}`);
|
||||
toast.success(t("message.succeed-copy-link"));
|
||||
}, [memo.name, t]);
|
||||
}, [memo.name, t, profile.instanceUrl]);
|
||||
|
||||
const handleCopyContent = useCallback(() => {
|
||||
copy(memo.content);
|
||||
|
|
@ -89,13 +97,13 @@ export const useMemoActionHandlers = ({ memo, onEdit, setDeleteDialogOpen, setRe
|
|||
}, [setDeleteDialogOpen]);
|
||||
|
||||
const confirmDeleteMemo = useCallback(async () => {
|
||||
await memoStore.deleteMemo(memo.name);
|
||||
await deleteMemo(memo.name);
|
||||
toast.success(t("message.deleted-successfully"));
|
||||
if (isInMemoDetailPage) {
|
||||
navigateTo("/");
|
||||
}
|
||||
memoUpdatedCallback();
|
||||
}, [memo.name, t, isInMemoDetailPage, navigateTo, memoUpdatedCallback]);
|
||||
}, [memo.name, t, isInMemoDetailPage, navigateTo, memoUpdatedCallback, deleteMemo]);
|
||||
|
||||
const handleRemoveCompletedTaskListItemsClick = useCallback(() => {
|
||||
setRemoveTasksDialogOpen(true);
|
||||
|
|
@ -103,16 +111,16 @@ export const useMemoActionHandlers = ({ memo, onEdit, setDeleteDialogOpen, setRe
|
|||
|
||||
const confirmRemoveCompletedTaskListItems = useCallback(async () => {
|
||||
const newContent = removeCompletedTasks(memo.content);
|
||||
await memoStore.updateMemo(
|
||||
{
|
||||
await updateMemo({
|
||||
update: {
|
||||
name: memo.name,
|
||||
content: newContent,
|
||||
},
|
||||
["content"],
|
||||
);
|
||||
updateMask: ["content"],
|
||||
});
|
||||
toast.success(t("message.remove-completed-task-list-items-successfully"));
|
||||
memoUpdatedCallback();
|
||||
}, [memo.name, memo.content, t, memoUpdatedCallback]);
|
||||
}, [memo.name, memo.content, t, memoUpdatedCallback, updateMemo]);
|
||||
|
||||
return {
|
||||
handleTogglePinMemoBtnClick,
|
||||
|
|
|
|||
|
|
@ -1,10 +1,9 @@
|
|||
import copy from "copy-to-clipboard";
|
||||
import hljs from "highlight.js";
|
||||
import { CheckIcon, CopyIcon } from "lucide-react";
|
||||
import { observer } from "mobx-react-lite";
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import { useAuth } from "@/contexts/AuthContext";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { userStore } from "@/store";
|
||||
import { getThemeWithFallback, resolveTheme } from "@/utils/theme";
|
||||
import { MermaidBlock } from "./MermaidBlock";
|
||||
import { extractCodeContent, extractLanguage } from "./utils";
|
||||
|
|
@ -14,7 +13,8 @@ interface CodeBlockProps {
|
|||
className?: string;
|
||||
}
|
||||
|
||||
export const CodeBlock = observer(({ children, className, ...props }: CodeBlockProps) => {
|
||||
export const CodeBlock = ({ children, className, ...props }: CodeBlockProps) => {
|
||||
const { userGeneralSetting } = useAuth();
|
||||
const [copied, setCopied] = useState(false);
|
||||
|
||||
const codeElement = children as React.ReactElement;
|
||||
|
|
@ -33,7 +33,7 @@ export const CodeBlock = observer(({ children, className, ...props }: CodeBlockP
|
|||
);
|
||||
}
|
||||
|
||||
const theme = getThemeWithFallback(userStore.state.userGeneralSetting?.theme);
|
||||
const theme = getThemeWithFallback(userGeneralSetting?.theme);
|
||||
const resolvedTheme = resolveTheme(theme);
|
||||
const isDarkTheme = resolvedTheme.includes("dark");
|
||||
|
||||
|
|
@ -131,4 +131,4 @@ export const CodeBlock = observer(({ children, className, ...props }: CodeBlockP
|
|||
</div>
|
||||
</pre>
|
||||
);
|
||||
});
|
||||
};
|
||||
|
|
|
|||
|
|
@ -1,8 +1,7 @@
|
|||
import mermaid from "mermaid";
|
||||
import { observer } from "mobx-react-lite";
|
||||
import { useEffect, useMemo, useRef, useState } from "react";
|
||||
import { useAuth } from "@/contexts/AuthContext";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { userStore } from "@/store";
|
||||
import { getThemeWithFallback, resolveTheme, setupSystemThemeListener } from "@/utils/theme";
|
||||
import { extractCodeContent } from "./utils";
|
||||
|
||||
|
|
@ -15,7 +14,8 @@ const getMermaidTheme = (appTheme: string): "default" | "dark" => {
|
|||
return appTheme === "default-dark" ? "dark" : "default";
|
||||
};
|
||||
|
||||
export const MermaidBlock = observer(({ children, className }: MermaidBlockProps) => {
|
||||
export const MermaidBlock = ({ children, className }: MermaidBlockProps) => {
|
||||
const { userGeneralSetting } = useAuth();
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
const [svg, setSvg] = useState<string>("");
|
||||
const [error, setError] = useState<string>("");
|
||||
|
|
@ -23,9 +23,9 @@ export const MermaidBlock = observer(({ children, className }: MermaidBlockProps
|
|||
|
||||
const codeContent = extractCodeContent(children);
|
||||
|
||||
// Get theme preference (reactive via MobX observer)
|
||||
// Get theme preference (reactive via AuthContext)
|
||||
// Falls back to localStorage or system preference if no user setting
|
||||
const themePreference = getThemeWithFallback(userStore.state.userGeneralSetting?.theme);
|
||||
const themePreference = getThemeWithFallback(userGeneralSetting?.theme);
|
||||
|
||||
// Resolve theme to actual value (handles "system" theme + system theme changes)
|
||||
const currentTheme = useMemo(() => resolveTheme(themePreference), [themePreference, systemThemeChange]);
|
||||
|
|
@ -90,4 +90,4 @@ export const MermaidBlock = observer(({ children, className }: MermaidBlockProps
|
|||
dangerouslySetInnerHTML={{ __html: svg }}
|
||||
/>
|
||||
);
|
||||
});
|
||||
};
|
||||
|
|
|
|||
|
|
@ -1,10 +1,9 @@
|
|||
import { useContext } from "react";
|
||||
import { useLocation } from "react-router-dom";
|
||||
import { type MemoFilter, stringifyFilters, useMemoFilterContext } from "@/contexts/MemoFilterContext";
|
||||
import useNavigateTo from "@/hooks/useNavigateTo";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { Routes } from "@/router";
|
||||
import { memoFilterStore } from "@/store";
|
||||
import { MemoFilter, stringifyFilters } from "@/store/memoFilter";
|
||||
import { MemoContentContext } from "./MemoContentContext";
|
||||
|
||||
interface TagProps extends React.HTMLAttributes<HTMLSpanElement> {
|
||||
|
|
@ -17,6 +16,7 @@ export const Tag: React.FC<TagProps> = ({ "data-tag": dataTag, children, classNa
|
|||
const context = useContext(MemoContentContext);
|
||||
const location = useLocation();
|
||||
const navigateTo = useNavigateTo();
|
||||
const { getFiltersByFactor, removeFilter, addFilter } = useMemoFilterContext();
|
||||
|
||||
const tag = dataTag || "";
|
||||
|
||||
|
|
@ -37,13 +37,13 @@ export const Tag: React.FC<TagProps> = ({ "data-tag": dataTag, children, classNa
|
|||
return;
|
||||
}
|
||||
|
||||
const isActive = memoFilterStore.getFiltersByFactor("tagSearch").some((filter: MemoFilter) => filter.value === tag);
|
||||
const isActive = getFiltersByFactor("tagSearch").some((filter: MemoFilter) => filter.value === tag);
|
||||
if (isActive) {
|
||||
memoFilterStore.removeFilter((f: MemoFilter) => f.factor === "tagSearch" && f.value === tag);
|
||||
removeFilter((f: MemoFilter) => f.factor === "tagSearch" && f.value === tag);
|
||||
} else {
|
||||
// Remove all existing tag filters first, then add the new one
|
||||
memoFilterStore.removeFilter((f: MemoFilter) => f.factor === "tagSearch");
|
||||
memoFilterStore.addFilter({
|
||||
removeFilter((f: MemoFilter) => f.factor === "tagSearch");
|
||||
addFilter({
|
||||
factor: "tagSearch",
|
||||
value: tag,
|
||||
});
|
||||
|
|
|
|||
|
|
@ -1,6 +1,8 @@
|
|||
import { useQueryClient } from "@tanstack/react-query";
|
||||
import { useContext, useRef } from "react";
|
||||
import { Checkbox } from "@/components/ui/checkbox";
|
||||
import { memoStore } from "@/store";
|
||||
import { memoKeys, useUpdateMemo } from "@/hooks/useMemoQueries";
|
||||
import type { Memo } from "@/types/proto/api/v1/memo_service_pb";
|
||||
import { toggleTaskAtIndex } from "@/utils/markdown-manipulation";
|
||||
import { MemoContentContext } from "./MemoContentContext";
|
||||
|
||||
|
|
@ -12,6 +14,8 @@ interface TaskListItemProps extends React.InputHTMLAttributes<HTMLInputElement>
|
|||
export const TaskListItem: React.FC<TaskListItemProps> = ({ checked, ...props }) => {
|
||||
const context = useContext(MemoContentContext);
|
||||
const checkboxRef = useRef<HTMLButtonElement>(null);
|
||||
const queryClient = useQueryClient();
|
||||
const { mutate: updateMemo } = useUpdateMemo();
|
||||
|
||||
const handleChange = async (newChecked: boolean) => {
|
||||
// Don't update if readonly or no memo context
|
||||
|
|
@ -49,19 +53,19 @@ export const TaskListItem: React.FC<TaskListItemProps> = ({ checked, ...props })
|
|||
}
|
||||
|
||||
// Update memo content using the string manipulation utility
|
||||
const memo = memoStore.getMemoByName(context.memoName);
|
||||
const memo = queryClient.getQueryData<Memo>(memoKeys.detail(context.memoName));
|
||||
if (!memo) {
|
||||
return;
|
||||
}
|
||||
|
||||
const newContent = toggleTaskAtIndex(memo.content, taskIndex, newChecked);
|
||||
await memoStore.updateMemo(
|
||||
{
|
||||
updateMemo({
|
||||
update: {
|
||||
name: memo.name,
|
||||
content: newContent,
|
||||
},
|
||||
["content"],
|
||||
);
|
||||
updateMask: ["content"],
|
||||
});
|
||||
};
|
||||
|
||||
// Override the disabled prop from remark-gfm (which defaults to true)
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
import { observer } from "mobx-react-lite";
|
||||
import { useQueryClient } from "@tanstack/react-query";
|
||||
import { memo } from "react";
|
||||
import ReactMarkdown from "react-markdown";
|
||||
import rehypeKatex from "rehype-katex";
|
||||
|
|
@ -8,8 +8,9 @@ import remarkBreaks from "remark-breaks";
|
|||
import remarkGfm from "remark-gfm";
|
||||
import remarkMath from "remark-math";
|
||||
import useCurrentUser from "@/hooks/useCurrentUser";
|
||||
import { memoKeys } from "@/hooks/useMemoQueries";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { memoStore } from "@/store";
|
||||
import type { Memo } from "@/types/proto/api/v1/memo_service_pb";
|
||||
import { useTranslate } from "@/utils/i18n";
|
||||
import { remarkDisableSetext } from "@/utils/remark-plugins/remark-disable-setext";
|
||||
import { remarkPreserveType } from "@/utils/remark-plugins/remark-preserve-type";
|
||||
|
|
@ -24,16 +25,17 @@ import { Tag } from "./Tag";
|
|||
import { TaskListItem } from "./TaskListItem";
|
||||
import type { MemoContentProps } from "./types";
|
||||
|
||||
const MemoContent = observer((props: MemoContentProps) => {
|
||||
const MemoContent = (props: MemoContentProps) => {
|
||||
const { className, contentClassName, content, memoName, onClick, onDoubleClick } = props;
|
||||
const t = useTranslate();
|
||||
const currentUser = useCurrentUser();
|
||||
const queryClient = useQueryClient();
|
||||
const {
|
||||
containerRef: memoContentContainerRef,
|
||||
mode: showCompactMode,
|
||||
toggle: toggleCompactMode,
|
||||
} = useCompactMode(Boolean(props.compact));
|
||||
const memo = memoName ? memoStore.getMemoByName(memoName) : null;
|
||||
const memo = memoName ? queryClient.getQueryData<Memo>(memoKeys.detail(memoName)) : null;
|
||||
const allowEdit = !props.readonly && memo && (currentUser?.name === memo.creator || isSuperUser(currentUser));
|
||||
|
||||
const contextValue = {
|
||||
|
|
@ -94,6 +96,6 @@ const MemoContent = observer((props: MemoContentProps) => {
|
|||
</div>
|
||||
</MemoContentContext.Provider>
|
||||
);
|
||||
});
|
||||
};
|
||||
|
||||
export default memo(MemoContent);
|
||||
|
|
|
|||
|
|
@ -1,8 +1,7 @@
|
|||
import { Settings2Icon } from "lucide-react";
|
||||
import { observer } from "mobx-react-lite";
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
||||
import { useView } from "@/contexts/ViewContext";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { viewStore } from "@/store";
|
||||
import { useTranslate } from "@/utils/i18n";
|
||||
import { Popover, PopoverContent, PopoverTrigger } from "./ui/popover";
|
||||
|
||||
|
|
@ -10,9 +9,10 @@ interface Props {
|
|||
className?: string;
|
||||
}
|
||||
|
||||
const MemoDisplaySettingMenu = observer(({ className }: Props) => {
|
||||
function MemoDisplaySettingMenu({ className }: Props) {
|
||||
const t = useTranslate();
|
||||
const isApplying = viewStore.state.orderByTimeAsc !== false || viewStore.state.layout !== "LIST";
|
||||
const { orderByTimeAsc, layout, toggleSortOrder, setLayout } = useView();
|
||||
const isApplying = orderByTimeAsc !== false || layout !== "LIST";
|
||||
|
||||
return (
|
||||
<Popover>
|
||||
|
|
@ -24,12 +24,12 @@ const MemoDisplaySettingMenu = observer(({ className }: Props) => {
|
|||
<div className="w-full flex flex-row justify-between items-center">
|
||||
<span className="text-sm shrink-0 mr-3 text-foreground">{t("memo.direction")}</span>
|
||||
<Select
|
||||
value={viewStore.state.orderByTimeAsc.toString()}
|
||||
onValueChange={(value) =>
|
||||
viewStore.state.setPartial({
|
||||
orderByTimeAsc: value === "true",
|
||||
})
|
||||
}
|
||||
value={orderByTimeAsc.toString()}
|
||||
onValueChange={(value) => {
|
||||
if ((value === "true") !== orderByTimeAsc) {
|
||||
toggleSortOrder();
|
||||
}
|
||||
}}
|
||||
>
|
||||
<SelectTrigger size="sm">
|
||||
<SelectValue />
|
||||
|
|
@ -42,14 +42,7 @@ const MemoDisplaySettingMenu = observer(({ className }: Props) => {
|
|||
</div>
|
||||
<div className="w-full flex flex-row justify-between items-center">
|
||||
<span className="text-sm shrink-0 mr-3 text-foreground">{t("common.layout")}</span>
|
||||
<Select
|
||||
value={viewStore.state.layout}
|
||||
onValueChange={(value) =>
|
||||
viewStore.state.setPartial({
|
||||
layout: value as "LIST" | "MASONRY",
|
||||
})
|
||||
}
|
||||
>
|
||||
<Select value={layout} onValueChange={(value) => setLayout(value as "LIST" | "MASONRY")}>
|
||||
<SelectTrigger size="sm">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
|
|
@ -63,6 +56,6 @@ const MemoDisplaySettingMenu = observer(({ className }: Props) => {
|
|||
</PopoverContent>
|
||||
</Popover>
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
export default MemoDisplaySettingMenu;
|
||||
|
|
|
|||
|
|
@ -1,4 +1,3 @@
|
|||
import { observer } from "mobx-react-lite";
|
||||
import type { EditorRefActions } from ".";
|
||||
import type { Command } from "./commands";
|
||||
import { SuggestionsPopup } from "./SuggestionsPopup";
|
||||
|
|
@ -10,7 +9,7 @@ interface SlashCommandsProps {
|
|||
commands: Command[];
|
||||
}
|
||||
|
||||
const SlashCommands = observer(({ editorRef, editorActions, commands }: SlashCommandsProps) => {
|
||||
const SlashCommands = ({ editorRef, editorActions, commands }: SlashCommandsProps) => {
|
||||
const { position, suggestions, selectedIndex, isVisible, handleItemSelect } = useSuggestions({
|
||||
editorRef,
|
||||
editorActions,
|
||||
|
|
@ -43,6 +42,6 @@ const SlashCommands = observer(({ editorRef, editorActions, commands }: SlashCom
|
|||
)}
|
||||
/>
|
||||
);
|
||||
});
|
||||
};
|
||||
|
||||
export default SlashCommands;
|
||||
|
|
|
|||
|
|
@ -1,7 +1,8 @@
|
|||
import { observer } from "mobx-react-lite";
|
||||
import { useMemo } from "react";
|
||||
import { matchPath } from "react-router-dom";
|
||||
import OverflowTip from "@/components/kit/OverflowTip";
|
||||
import { userStore } from "@/store";
|
||||
import { useTagCounts } from "@/hooks/useUserQueries";
|
||||
import { Routes } from "@/router";
|
||||
import type { EditorRefActions } from ".";
|
||||
import { SuggestionsPopup } from "./SuggestionsPopup";
|
||||
import { useSuggestions } from "./useSuggestions";
|
||||
|
|
@ -11,12 +12,16 @@ interface TagSuggestionsProps {
|
|||
editorActions: React.ForwardedRef<EditorRefActions>;
|
||||
}
|
||||
|
||||
const TagSuggestions = observer(({ editorRef, editorActions }: TagSuggestionsProps) => {
|
||||
export default function TagSuggestions({ editorRef, editorActions }: TagSuggestionsProps) {
|
||||
// On explore page, show all users' tags; otherwise show current user's tags
|
||||
const isExplorePage = Boolean(matchPath(Routes.EXPLORE, window.location.pathname));
|
||||
const { data: tagCount = {} } = useTagCounts(!isExplorePage);
|
||||
|
||||
const sortedTags = useMemo(() => {
|
||||
return Object.entries(userStore.state.tagCount)
|
||||
return Object.entries(tagCount)
|
||||
.sort((a, b) => b[1] - a[1] || a[0].localeCompare(b[0]))
|
||||
.map(([tag]) => tag);
|
||||
}, [userStore.state.tagCount]);
|
||||
}, [tagCount]);
|
||||
|
||||
const { position, suggestions, selectedIndex, isVisible, handleItemSelect } = useSuggestions({
|
||||
editorRef,
|
||||
|
|
@ -47,6 +52,4 @@ const TagSuggestions = observer(({ editorRef, editorActions }: TagSuggestionsPro
|
|||
)}
|
||||
/>
|
||||
);
|
||||
});
|
||||
|
||||
export default TagSuggestions;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
import { forwardRef, useCallback, useEffect, useImperativeHandle, useRef } from "react";
|
||||
import { forwardRef, useCallback, useEffect, useImperativeHandle, useMemo, useRef } from "react";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { EDITOR_HEIGHT } from "../constants";
|
||||
import { editorCommands } from "./commands";
|
||||
|
|
@ -48,99 +48,116 @@ const Editor = forwardRef(function Editor(props: Props, ref: React.ForwardedRef<
|
|||
} = props;
|
||||
const editorRef = useRef<HTMLTextAreaElement>(null);
|
||||
|
||||
const updateEditorHeight = useCallback(() => {
|
||||
if (editorRef.current) {
|
||||
editorRef.current.style.height = "auto";
|
||||
editorRef.current.style.height = `${editorRef.current.scrollHeight ?? 0}px`;
|
||||
}
|
||||
}, []);
|
||||
|
||||
const updateContent = useCallback(() => {
|
||||
if (editorRef.current) {
|
||||
handleContentChangeCallback(editorRef.current.value);
|
||||
updateEditorHeight();
|
||||
}
|
||||
}, [handleContentChangeCallback, updateEditorHeight]);
|
||||
|
||||
useEffect(() => {
|
||||
if (editorRef.current && initialContent) {
|
||||
editorRef.current.value = initialContent;
|
||||
handleContentChangeCallback(initialContent);
|
||||
updateEditorHeight();
|
||||
}
|
||||
// Only run once on mount to set initial content
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, []);
|
||||
|
||||
const updateEditorHeight = () => {
|
||||
if (editorRef.current) {
|
||||
editorRef.current.style.height = "auto";
|
||||
editorRef.current.style.height = (editorRef.current.scrollHeight ?? 0) + "px";
|
||||
// Update editor when content is externally changed (e.g., reset after save)
|
||||
useEffect(() => {
|
||||
if (editorRef.current && editorRef.current.value !== initialContent) {
|
||||
editorRef.current.value = initialContent;
|
||||
updateEditorHeight();
|
||||
}
|
||||
};
|
||||
}, [initialContent, updateEditorHeight]);
|
||||
|
||||
const updateContent = () => {
|
||||
const editorActions: EditorRefActions = useMemo(
|
||||
() => ({
|
||||
getEditor: () => editorRef.current,
|
||||
focus: () => editorRef.current?.focus(),
|
||||
scrollToCursor: () => {
|
||||
if (editorRef.current) {
|
||||
editorRef.current.scrollTop = editorRef.current.scrollHeight;
|
||||
}
|
||||
},
|
||||
insertText: (content = "", prefix = "", suffix = "") => {
|
||||
const editor = editorRef.current;
|
||||
if (!editor) return;
|
||||
|
||||
const cursorPos = editor.selectionStart;
|
||||
const endPos = editor.selectionEnd;
|
||||
const prev = editor.value;
|
||||
const actual = content || prev.slice(cursorPos, endPos);
|
||||
editor.value = prev.slice(0, cursorPos) + prefix + actual + suffix + prev.slice(endPos);
|
||||
|
||||
editor.focus();
|
||||
editor.setSelectionRange(cursorPos + prefix.length + actual.length, cursorPos + prefix.length + actual.length);
|
||||
updateContent();
|
||||
},
|
||||
removeText: (start: number, length: number) => {
|
||||
const editor = editorRef.current;
|
||||
if (!editor) return;
|
||||
|
||||
editor.value = editor.value.slice(0, start) + editor.value.slice(start + length);
|
||||
editor.focus();
|
||||
editor.selectionEnd = start;
|
||||
updateContent();
|
||||
},
|
||||
setContent: (text: string) => {
|
||||
const editor = editorRef.current;
|
||||
if (editor) {
|
||||
editor.value = text;
|
||||
updateContent();
|
||||
}
|
||||
},
|
||||
getContent: () => editorRef.current?.value ?? "",
|
||||
getCursorPosition: () => editorRef.current?.selectionStart ?? 0,
|
||||
getSelectedContent: () => {
|
||||
const editor = editorRef.current;
|
||||
if (!editor) return "";
|
||||
return editor.value.slice(editor.selectionStart, editor.selectionEnd);
|
||||
},
|
||||
setCursorPosition: (startPos: number, endPos?: number) => {
|
||||
const endPosition = Number.isNaN(endPos) ? startPos : (endPos as number);
|
||||
editorRef.current?.setSelectionRange(startPos, endPosition);
|
||||
},
|
||||
getCursorLineNumber: () => {
|
||||
const editor = editorRef.current;
|
||||
if (!editor) return 0;
|
||||
const lines = editor.value.slice(0, editor.selectionStart).split("\n");
|
||||
return lines.length - 1;
|
||||
},
|
||||
getLine: (lineNumber: number) => editorRef.current?.value.split("\n")[lineNumber] ?? "",
|
||||
setLine: (lineNumber: number, text: string) => {
|
||||
const editor = editorRef.current;
|
||||
if (!editor) return;
|
||||
const lines = editor.value.split("\n");
|
||||
lines[lineNumber] = text;
|
||||
editor.value = lines.join("\n");
|
||||
editor.focus();
|
||||
updateContent();
|
||||
},
|
||||
}),
|
||||
[updateContent],
|
||||
);
|
||||
|
||||
useImperativeHandle(ref, () => editorActions, [editorActions]);
|
||||
|
||||
const handleEditorInput = useCallback(() => {
|
||||
if (editorRef.current) {
|
||||
handleContentChangeCallback(editorRef.current.value);
|
||||
updateEditorHeight();
|
||||
}
|
||||
};
|
||||
|
||||
const editorActions: EditorRefActions = {
|
||||
getEditor: () => editorRef.current,
|
||||
focus: () => editorRef.current?.focus(),
|
||||
scrollToCursor: () => {
|
||||
editorRef.current && (editorRef.current.scrollTop = editorRef.current.scrollHeight);
|
||||
},
|
||||
insertText: (content = "", prefix = "", suffix = "") => {
|
||||
const editor = editorRef.current;
|
||||
if (!editor) return;
|
||||
|
||||
const cursorPos = editor.selectionStart;
|
||||
const endPos = editor.selectionEnd;
|
||||
const prev = editor.value;
|
||||
const actual = content || prev.slice(cursorPos, endPos);
|
||||
editor.value = prev.slice(0, cursorPos) + prefix + actual + suffix + prev.slice(endPos);
|
||||
|
||||
editor.focus();
|
||||
editor.setSelectionRange(cursorPos + prefix.length + actual.length, cursorPos + prefix.length + actual.length);
|
||||
updateContent();
|
||||
},
|
||||
removeText: (start: number, length: number) => {
|
||||
const editor = editorRef.current;
|
||||
if (!editor) return;
|
||||
|
||||
editor.value = editor.value.slice(0, start) + editor.value.slice(start + length);
|
||||
editor.focus();
|
||||
editor.selectionEnd = start;
|
||||
updateContent();
|
||||
},
|
||||
setContent: (text: string) => {
|
||||
const editor = editorRef.current;
|
||||
if (editor) {
|
||||
editor.value = text;
|
||||
updateContent();
|
||||
}
|
||||
},
|
||||
getContent: () => editorRef.current?.value ?? "",
|
||||
getCursorPosition: () => editorRef.current?.selectionStart ?? 0,
|
||||
getSelectedContent: () => {
|
||||
const editor = editorRef.current;
|
||||
if (!editor) return "";
|
||||
return editor.value.slice(editor.selectionStart, editor.selectionEnd);
|
||||
},
|
||||
setCursorPosition: (startPos: number, endPos?: number) => {
|
||||
const endPosition = isNaN(endPos as number) ? startPos : (endPos as number);
|
||||
editorRef.current?.setSelectionRange(startPos, endPosition);
|
||||
},
|
||||
getCursorLineNumber: () => {
|
||||
const editor = editorRef.current;
|
||||
if (!editor) return 0;
|
||||
const lines = editor.value.slice(0, editor.selectionStart).split("\n");
|
||||
return lines.length - 1;
|
||||
},
|
||||
getLine: (lineNumber: number) => editorRef.current?.value.split("\n")[lineNumber] ?? "",
|
||||
setLine: (lineNumber: number, text: string) => {
|
||||
const editor = editorRef.current;
|
||||
if (!editor) return;
|
||||
const lines = editor.value.split("\n");
|
||||
lines[lineNumber] = text;
|
||||
editor.value = lines.join("\n");
|
||||
editor.focus();
|
||||
updateContent();
|
||||
},
|
||||
};
|
||||
|
||||
useImperativeHandle(ref, () => editorActions, []);
|
||||
|
||||
const handleEditorInput = useCallback(() => {
|
||||
handleContentChangeCallback(editorRef.current?.value ?? "");
|
||||
updateEditorHeight();
|
||||
}, []);
|
||||
}, [handleContentChangeCallback, updateEditorHeight]);
|
||||
|
||||
// Auto-complete markdown lists when pressing Enter
|
||||
useListCompletion({
|
||||
|
|
|
|||
|
|
@ -1,7 +1,6 @@
|
|||
import { LatLng } from "leaflet";
|
||||
import { uniqBy } from "lodash-es";
|
||||
import { FileIcon, LinkIcon, LoaderIcon, MapPinIcon, Maximize2Icon, MoreHorizontalIcon, PlusIcon } from "lucide-react";
|
||||
import { observer } from "mobx-react-lite";
|
||||
import { useContext, useState } from "react";
|
||||
import type { LocalFile } from "@/components/memo-metadata";
|
||||
import { Button } from "@/components/ui/button";
|
||||
|
|
@ -30,7 +29,7 @@ interface Props {
|
|||
onToggleFocusMode?: () => void;
|
||||
}
|
||||
|
||||
const InsertMenu = observer((props: Props) => {
|
||||
const InsertMenu = (props: Props) => {
|
||||
const t = useTranslate();
|
||||
const context = useContext(MemoEditorContext);
|
||||
|
||||
|
|
@ -221,6 +220,6 @@ const InsertMenu = observer((props: Props) => {
|
|||
/>
|
||||
</>
|
||||
);
|
||||
});
|
||||
};
|
||||
|
||||
export default InsertMenu;
|
||||
|
|
|
|||
|
|
@ -3,8 +3,8 @@ import { useState } from "react";
|
|||
import useDebounce from "react-use/lib/useDebounce";
|
||||
import { memoServiceClient } from "@/connect";
|
||||
import { DEFAULT_LIST_MEMOS_PAGE_SIZE } from "@/helpers/consts";
|
||||
import { extractUserIdFromName } from "@/helpers/resource-names";
|
||||
import useCurrentUser from "@/hooks/useCurrentUser";
|
||||
import { extractUserIdFromName } from "@/store/common";
|
||||
import {
|
||||
Memo,
|
||||
MemoRelation,
|
||||
|
|
@ -37,7 +37,7 @@ export const useLinkMemo = ({ isOpen, currentMemoName, existingRelations, onAddR
|
|||
|
||||
setIsFetching(true);
|
||||
try {
|
||||
const conditions = [`creator_id == ${extractUserIdFromName(user.name)}`];
|
||||
const conditions = [`creator_id == ${extractUserIdFromName(user?.name ?? "")}`];
|
||||
if (searchText) {
|
||||
conditions.push(`content.contains("${searchText}")`);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,7 +1,8 @@
|
|||
import { observer } from "mobx-react-lite";
|
||||
import { useQueryClient } from "@tanstack/react-query";
|
||||
import { useMemo, useRef } from "react";
|
||||
import { toast } from "react-hot-toast";
|
||||
import useCurrentUser from "@/hooks/useCurrentUser";
|
||||
import { memoKeys } from "@/hooks/useMemoQueries";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { useTranslate } from "@/utils/i18n";
|
||||
import { EditorContent, EditorMetadata, EditorToolbar, FocusModeExitButton, FocusModeOverlay } from "./components";
|
||||
|
|
@ -23,7 +24,7 @@ export interface MemoEditorProps {
|
|||
onCancel?: () => void;
|
||||
}
|
||||
|
||||
const MemoEditor = observer((props: MemoEditorProps) => {
|
||||
const MemoEditor = (props: MemoEditorProps) => {
|
||||
const { className, cacheKey, memoName, parentMemoName, autoFocus, placeholder, onConfirm, onCancel } = props;
|
||||
|
||||
return (
|
||||
|
|
@ -40,7 +41,7 @@ const MemoEditor = observer((props: MemoEditorProps) => {
|
|||
/>
|
||||
</EditorProvider>
|
||||
);
|
||||
});
|
||||
};
|
||||
|
||||
const MemoEditorImpl: React.FC<MemoEditorProps> = ({
|
||||
className,
|
||||
|
|
@ -53,6 +54,7 @@ const MemoEditorImpl: React.FC<MemoEditorProps> = ({
|
|||
onCancel,
|
||||
}) => {
|
||||
const t = useTranslate();
|
||||
const queryClient = useQueryClient();
|
||||
const currentUser = useCurrentUser();
|
||||
const editorRef = useRef<EditorRefActions>(null);
|
||||
const { state, actions, dispatch } = useEditorContext();
|
||||
|
|
@ -66,7 +68,9 @@ const MemoEditorImpl: React.FC<MemoEditorProps> = ({
|
|||
setRelationList: (relations: typeof state.metadata.relations) => dispatch(actions.setMetadata({ relations })),
|
||||
memoName,
|
||||
addLocalFiles: (files: typeof state.localFiles) => {
|
||||
files.forEach((file) => dispatch(actions.addLocalFile(file)));
|
||||
files.forEach((file) => {
|
||||
dispatch(actions.addLocalFile(file));
|
||||
});
|
||||
},
|
||||
removeLocalFile: (previewUrl: string) => dispatch(actions.removeLocalFile(previewUrl)),
|
||||
localFiles: state.localFiles,
|
||||
|
|
@ -75,10 +79,10 @@ const MemoEditorImpl: React.FC<MemoEditorProps> = ({
|
|||
);
|
||||
|
||||
// Initialize editor (load memo or cache)
|
||||
useMemoInit(editorRef, memoName, cacheKey, currentUser.name, autoFocus);
|
||||
useMemoInit(editorRef, memoName, cacheKey, currentUser?.name ?? "", autoFocus);
|
||||
|
||||
// Auto-save content to localStorage
|
||||
useAutoSave(state.content, currentUser.name, cacheKey);
|
||||
useAutoSave(state.content, currentUser?.name ?? "", cacheKey);
|
||||
|
||||
// Focus mode management with body scroll lock
|
||||
useFocusMode(state.ui.isFocusMode);
|
||||
|
|
@ -91,6 +95,7 @@ const MemoEditorImpl: React.FC<MemoEditorProps> = ({
|
|||
useKeyboard(editorRef, { onSave: handleSave, onToggleFocusMode: handleToggleFocusMode });
|
||||
|
||||
async function handleSave() {
|
||||
// Validate before saving
|
||||
const { valid, reason } = validationService.canSave(state);
|
||||
if (!valid) {
|
||||
toast.error(reason || "Cannot save");
|
||||
|
|
@ -108,19 +113,26 @@ const MemoEditorImpl: React.FC<MemoEditorProps> = ({
|
|||
return;
|
||||
}
|
||||
|
||||
// Clear cache on successful save
|
||||
cacheService.clear(cacheService.key(currentUser.name, cacheKey));
|
||||
// Clear localStorage cache on successful save
|
||||
cacheService.clear(cacheService.key(currentUser?.name ?? "", cacheKey));
|
||||
|
||||
// Reset editor state
|
||||
// Invalidate React Query cache to refresh memo lists across the app
|
||||
await Promise.all([
|
||||
queryClient.invalidateQueries({ queryKey: memoKeys.lists() }),
|
||||
queryClient.invalidateQueries({ queryKey: ["users", "stats"] }),
|
||||
]);
|
||||
|
||||
// Reset editor state to initial values
|
||||
dispatch(actions.reset());
|
||||
|
||||
// Notify parent
|
||||
// Notify parent component of successful save
|
||||
onConfirm?.(result.memoName);
|
||||
|
||||
toast.success("Saved successfully");
|
||||
} catch (error) {
|
||||
const message = errorService.handle(error, t);
|
||||
toast.error(message);
|
||||
const errorMessage = errorService.getErrorMessage(error);
|
||||
toast.error(errorMessage);
|
||||
console.error("Failed to save memo:", error);
|
||||
} finally {
|
||||
dispatch(actions.setLoading("saving", false));
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,27 +1,8 @@
|
|||
import type { Translations } from "@/utils/i18n";
|
||||
|
||||
export type EditorErrorCode = "UPLOAD_FAILED" | "SAVE_FAILED" | "VALIDATION_FAILED" | "LOAD_FAILED";
|
||||
|
||||
export class EditorError extends Error {
|
||||
constructor(
|
||||
public code: EditorErrorCode,
|
||||
public details?: unknown,
|
||||
) {
|
||||
super(`Editor error: ${code}`);
|
||||
this.name = "EditorError";
|
||||
}
|
||||
}
|
||||
|
||||
export const errorService = {
|
||||
handle(error: unknown, t: (key: Translations, params?: Record<string, any>) => string): string {
|
||||
if (error instanceof EditorError) {
|
||||
// Try to get localized error message
|
||||
const key = `editor.error.${error.code.toLowerCase()}` as Translations;
|
||||
return t(key, { details: error.details });
|
||||
}
|
||||
|
||||
getErrorMessage(error: unknown): string {
|
||||
// Handle ConnectError or errors with details property
|
||||
if (error && typeof error === "object" && "details" in error) {
|
||||
return (error as { details?: string }).details || "An unknown error occurred";
|
||||
return (error as { details?: string }).details || "An error occurred";
|
||||
}
|
||||
|
||||
if (error instanceof Error) {
|
||||
|
|
|
|||
|
|
@ -1,12 +1,10 @@
|
|||
import { create } from "@bufbuild/protobuf";
|
||||
import { timestampDate, timestampFromDate } from "@bufbuild/protobuf/wkt";
|
||||
import { FieldMaskSchema, timestampDate, timestampFromDate } from "@bufbuild/protobuf/wkt";
|
||||
import { isEqual } from "lodash-es";
|
||||
import { memoServiceClient } from "@/connect";
|
||||
import { memoStore } from "@/store";
|
||||
import type { Memo } from "@/types/proto/api/v1/memo_service_pb";
|
||||
import { MemoSchema } from "@/types/proto/api/v1/memo_service_pb";
|
||||
import type { EditorState } from "../state";
|
||||
import { EditorError } from "./errorService";
|
||||
import { uploadService } from "./uploadService";
|
||||
|
||||
function buildUpdateMask(
|
||||
|
|
@ -73,91 +71,73 @@ export const memoService = {
|
|||
parentMemoName?: string;
|
||||
},
|
||||
): Promise<{ memoName: string; hasChanges: boolean }> {
|
||||
try {
|
||||
// 1. Upload local files first
|
||||
const newAttachments = await uploadService.uploadFiles(state.localFiles);
|
||||
const allAttachments = [...state.metadata.attachments, ...newAttachments];
|
||||
// 1. Upload local files first
|
||||
const newAttachments = await uploadService.uploadFiles(state.localFiles);
|
||||
const allAttachments = [...state.metadata.attachments, ...newAttachments];
|
||||
|
||||
// 2. Update existing memo
|
||||
if (options.memoName) {
|
||||
const prevMemo = await memoStore.getOrFetchMemoByName(options.memoName);
|
||||
if (!prevMemo) {
|
||||
throw new EditorError("SAVE_FAILED", "Memo not found");
|
||||
}
|
||||
// 2. Update existing memo
|
||||
if (options.memoName) {
|
||||
const prevMemo = await memoServiceClient.getMemo({ name: options.memoName });
|
||||
const { mask, patch } = buildUpdateMask(prevMemo, state, allAttachments);
|
||||
|
||||
const { mask, patch } = buildUpdateMask(prevMemo, state, allAttachments);
|
||||
|
||||
if (mask.size === 0) {
|
||||
return { memoName: prevMemo.name, hasChanges: false };
|
||||
}
|
||||
|
||||
const memo = await memoStore.updateMemo(patch, Array.from(mask));
|
||||
return { memoName: memo.name, hasChanges: true };
|
||||
if (mask.size === 0) {
|
||||
return { memoName: prevMemo.name, hasChanges: false };
|
||||
}
|
||||
|
||||
// 3. Create new memo or comment
|
||||
const memoData = create(MemoSchema, {
|
||||
content: state.content,
|
||||
visibility: state.metadata.visibility,
|
||||
attachments: allAttachments,
|
||||
relations: state.metadata.relations,
|
||||
location: state.metadata.location,
|
||||
createTime: state.timestamps.createTime ? timestampFromDate(state.timestamps.createTime) : undefined,
|
||||
updateTime: state.timestamps.updateTime ? timestampFromDate(state.timestamps.updateTime) : undefined,
|
||||
const memo = await memoServiceClient.updateMemo({
|
||||
memo: create(MemoSchema, patch as Record<string, unknown>),
|
||||
updateMask: create(FieldMaskSchema, { paths: Array.from(mask) }),
|
||||
});
|
||||
|
||||
const memo = options.parentMemoName
|
||||
? await memoServiceClient.createMemoComment({
|
||||
name: options.parentMemoName,
|
||||
comment: memoData,
|
||||
})
|
||||
: await memoStore.createMemo(memoData);
|
||||
|
||||
return { memoName: memo.name, hasChanges: true };
|
||||
} catch (error) {
|
||||
if (error instanceof EditorError) {
|
||||
throw error;
|
||||
}
|
||||
throw new EditorError("SAVE_FAILED", error);
|
||||
}
|
||||
|
||||
// 3. Create new memo or comment
|
||||
const memoData = create(MemoSchema, {
|
||||
content: state.content,
|
||||
visibility: state.metadata.visibility,
|
||||
attachments: allAttachments,
|
||||
relations: state.metadata.relations,
|
||||
location: state.metadata.location,
|
||||
createTime: state.timestamps.createTime ? timestampFromDate(state.timestamps.createTime) : undefined,
|
||||
updateTime: state.timestamps.updateTime ? timestampFromDate(state.timestamps.updateTime) : undefined,
|
||||
});
|
||||
|
||||
const memo = options.parentMemoName
|
||||
? await memoServiceClient.createMemoComment({
|
||||
name: options.parentMemoName,
|
||||
comment: memoData,
|
||||
})
|
||||
: await memoServiceClient.createMemo({ memo: memoData });
|
||||
|
||||
return { memoName: memo.name, hasChanges: true };
|
||||
},
|
||||
|
||||
async load(memoName: string): Promise<EditorState> {
|
||||
try {
|
||||
const memo = await memoStore.getOrFetchMemoByName(memoName);
|
||||
if (!memo) {
|
||||
throw new EditorError("LOAD_FAILED", "Memo not found");
|
||||
}
|
||||
const memo = await memoServiceClient.getMemo({ name: memoName });
|
||||
|
||||
return {
|
||||
content: memo.content,
|
||||
metadata: {
|
||||
visibility: memo.visibility,
|
||||
attachments: memo.attachments,
|
||||
relations: memo.relations,
|
||||
location: memo.location,
|
||||
return {
|
||||
content: memo.content,
|
||||
metadata: {
|
||||
visibility: memo.visibility,
|
||||
attachments: memo.attachments,
|
||||
relations: memo.relations,
|
||||
location: memo.location,
|
||||
},
|
||||
ui: {
|
||||
isFocusMode: false,
|
||||
isLoading: {
|
||||
saving: false,
|
||||
uploading: false,
|
||||
loading: false,
|
||||
},
|
||||
ui: {
|
||||
isFocusMode: false,
|
||||
isLoading: {
|
||||
saving: false,
|
||||
uploading: false,
|
||||
loading: false,
|
||||
},
|
||||
isDragging: false,
|
||||
isComposing: false,
|
||||
},
|
||||
timestamps: {
|
||||
createTime: memo.createTime ? timestampDate(memo.createTime) : undefined,
|
||||
updateTime: memo.updateTime ? timestampDate(memo.updateTime) : undefined,
|
||||
},
|
||||
localFiles: [],
|
||||
};
|
||||
} catch (error) {
|
||||
if (error instanceof EditorError) {
|
||||
throw error;
|
||||
}
|
||||
throw new EditorError("LOAD_FAILED", error);
|
||||
}
|
||||
isDragging: false,
|
||||
isComposing: false,
|
||||
},
|
||||
timestamps: {
|
||||
createTime: memo.createTime ? timestampDate(memo.createTime) : undefined,
|
||||
updateTime: memo.updateTime ? timestampDate(memo.updateTime) : undefined,
|
||||
},
|
||||
localFiles: [],
|
||||
};
|
||||
},
|
||||
};
|
||||
|
|
|
|||
|
|
@ -1,33 +1,28 @@
|
|||
import { create } from "@bufbuild/protobuf";
|
||||
import type { LocalFile } from "@/components/memo-metadata";
|
||||
import { attachmentStore } from "@/store";
|
||||
import { attachmentServiceClient } from "@/connect";
|
||||
import type { Attachment } from "@/types/proto/api/v1/attachment_service_pb";
|
||||
import { AttachmentSchema } from "@/types/proto/api/v1/attachment_service_pb";
|
||||
import { EditorError } from "./errorService";
|
||||
|
||||
export const uploadService = {
|
||||
async uploadFiles(localFiles: LocalFile[]): Promise<Attachment[]> {
|
||||
if (localFiles.length === 0) return [];
|
||||
|
||||
try {
|
||||
const attachments: Attachment[] = [];
|
||||
const attachments: Attachment[] = [];
|
||||
|
||||
for (const { file } of localFiles) {
|
||||
const buffer = new Uint8Array(await file.arrayBuffer());
|
||||
const attachment = await attachmentStore.createAttachment(
|
||||
create(AttachmentSchema, {
|
||||
filename: file.name,
|
||||
size: BigInt(file.size),
|
||||
type: file.type,
|
||||
content: buffer,
|
||||
}),
|
||||
);
|
||||
attachments.push(attachment);
|
||||
}
|
||||
|
||||
return attachments;
|
||||
} catch (error) {
|
||||
throw new EditorError("UPLOAD_FAILED", error);
|
||||
for (const { file } of localFiles) {
|
||||
const buffer = new Uint8Array(await file.arrayBuffer());
|
||||
const attachment = await attachmentServiceClient.createAttachment({
|
||||
attachment: create(AttachmentSchema, {
|
||||
filename: file.name,
|
||||
size: BigInt(file.size),
|
||||
type: file.type,
|
||||
content: buffer,
|
||||
}),
|
||||
});
|
||||
attachments.push(attachment);
|
||||
}
|
||||
|
||||
return attachments;
|
||||
},
|
||||
};
|
||||
|
|
|
|||
|
|
@ -1,4 +1,3 @@
|
|||
import { observer } from "mobx-react-lite";
|
||||
import SearchBar from "@/components/SearchBar";
|
||||
import useCurrentUser from "@/hooks/useCurrentUser";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
|
@ -63,7 +62,7 @@ const getDefaultFeatures = (context: MemoExplorerContext): MemoExplorerFeatures
|
|||
}
|
||||
};
|
||||
|
||||
const MemoExplorer = observer((props: Props) => {
|
||||
const MemoExplorer = (props: Props) => {
|
||||
const { className, context = "home", features: featureOverrides = {}, statisticsData, tagCount } = props;
|
||||
const currentUser = useCurrentUser();
|
||||
|
||||
|
|
@ -88,6 +87,6 @@ const MemoExplorer = observer((props: Props) => {
|
|||
</div>
|
||||
</aside>
|
||||
);
|
||||
});
|
||||
};
|
||||
|
||||
export default MemoExplorer;
|
||||
|
|
|
|||
|
|
@ -1,14 +1,12 @@
|
|||
import { Edit3Icon, MoreVerticalIcon, PlusIcon, TrashIcon } from "lucide-react";
|
||||
import { observer } from "mobx-react-lite";
|
||||
import { useState } from "react";
|
||||
import { useEffect, useState } from "react";
|
||||
import toast from "react-hot-toast";
|
||||
import ConfirmDialog from "@/components/ConfirmDialog";
|
||||
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/components/ui/tooltip";
|
||||
import { shortcutServiceClient } from "@/connect";
|
||||
import useAsyncEffect from "@/hooks/useAsyncEffect";
|
||||
import { useAuth } from "@/contexts/AuthContext";
|
||||
import { useMemoFilterContext } from "@/contexts/MemoFilterContext";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { userStore } from "@/store";
|
||||
import memoFilterStore from "@/store/memoFilter";
|
||||
import { Shortcut } from "@/types/proto/api/v1/shortcut_service_pb";
|
||||
import { useTranslate } from "@/utils/i18n";
|
||||
import CreateShortcutDialog from "../CreateShortcutDialog";
|
||||
|
|
@ -23,16 +21,17 @@ const getShortcutId = (name: string): string => {
|
|||
return parts.length === 4 ? parts[3] : "";
|
||||
};
|
||||
|
||||
const ShortcutsSection = observer(() => {
|
||||
function ShortcutsSection() {
|
||||
const t = useTranslate();
|
||||
const shortcuts = userStore.state.shortcuts;
|
||||
const { shortcuts, refetchSettings } = useAuth();
|
||||
const { shortcut: selectedShortcut, setShortcut } = useMemoFilterContext();
|
||||
const [isCreateShortcutDialogOpen, setIsCreateShortcutDialogOpen] = useState(false);
|
||||
const [deleteTarget, setDeleteTarget] = useState<Shortcut | undefined>();
|
||||
const [editingShortcut, setEditingShortcut] = useState<Shortcut | undefined>();
|
||||
|
||||
useAsyncEffect(async () => {
|
||||
await userStore.fetchUserSettings();
|
||||
}, []);
|
||||
useEffect(() => {
|
||||
refetchSettings();
|
||||
}, [refetchSettings]);
|
||||
|
||||
const handleDeleteShortcut = async (shortcut: Shortcut) => {
|
||||
setDeleteTarget(shortcut);
|
||||
|
|
@ -41,7 +40,7 @@ const ShortcutsSection = observer(() => {
|
|||
const confirmDeleteShortcut = async () => {
|
||||
if (!deleteTarget) return;
|
||||
await shortcutServiceClient.deleteShortcut({ name: deleteTarget.name });
|
||||
await userStore.fetchUserSettings();
|
||||
await refetchSettings();
|
||||
toast.success(t("setting.shortcut.delete-success", { title: deleteTarget.title }));
|
||||
setDeleteTarget(undefined);
|
||||
};
|
||||
|
|
@ -82,7 +81,7 @@ const ShortcutsSection = observer(() => {
|
|||
const maybeEmoji = shortcut.title.split(" ")[0];
|
||||
const emoji = emojiRegex.test(maybeEmoji) ? maybeEmoji : undefined;
|
||||
const title = emoji ? shortcut.title.replace(emoji, "") : shortcut.title;
|
||||
const selected = memoFilterStore.shortcut === shortcutId;
|
||||
const selected = selectedShortcut === shortcutId;
|
||||
return (
|
||||
<div
|
||||
key={shortcutId}
|
||||
|
|
@ -90,7 +89,7 @@ const ShortcutsSection = observer(() => {
|
|||
>
|
||||
<span
|
||||
className={cn("truncate cursor-pointer text-muted-foreground", selected && "text-primary font-medium")}
|
||||
onClick={() => (selected ? memoFilterStore.setShortcut(undefined) : memoFilterStore.setShortcut(shortcutId))}
|
||||
onClick={() => (selected ? setShortcut(undefined) : setShortcut(shortcutId))}
|
||||
>
|
||||
{emoji && <span className="text-base mr-1">{emoji}</span>}
|
||||
{title.trim()}
|
||||
|
|
@ -131,6 +130,6 @@ const ShortcutsSection = observer(() => {
|
|||
/>
|
||||
</div>
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
export default ShortcutsSection;
|
||||
|
|
|
|||
|
|
@ -1,9 +1,8 @@
|
|||
import { HashIcon, MoreVerticalIcon, TagsIcon } from "lucide-react";
|
||||
import { observer } from "mobx-react-lite";
|
||||
import useLocalStorage from "react-use/lib/useLocalStorage";
|
||||
import { Switch } from "@/components/ui/switch";
|
||||
import { type MemoFilter, useMemoFilterContext } from "@/contexts/MemoFilterContext";
|
||||
import { cn } from "@/lib/utils";
|
||||
import memoFilterStore, { MemoFilter } from "@/store/memoFilter";
|
||||
import { useTranslate } from "@/utils/i18n";
|
||||
import TagTree from "../TagTree";
|
||||
import { Popover, PopoverContent, PopoverTrigger } from "../ui/popover";
|
||||
|
|
@ -13,8 +12,9 @@ interface Props {
|
|||
tagCount: Record<string, number>;
|
||||
}
|
||||
|
||||
const TagsSection = observer((props: Props) => {
|
||||
const TagsSection = (props: Props) => {
|
||||
const t = useTranslate();
|
||||
const { getFiltersByFactor, addFilter, removeFilter } = useMemoFilterContext();
|
||||
const [treeMode, setTreeMode] = useLocalStorage<boolean>("tag-view-as-tree", false);
|
||||
const [treeAutoExpand, setTreeAutoExpand] = useLocalStorage<boolean>("tag-tree-auto-expand", false);
|
||||
|
||||
|
|
@ -23,13 +23,13 @@ const TagsSection = observer((props: Props) => {
|
|||
.sort((a, b) => b[1] - a[1]);
|
||||
|
||||
const handleTagClick = (tag: string) => {
|
||||
const isActive = memoFilterStore.getFiltersByFactor("tagSearch").some((filter: MemoFilter) => filter.value === tag);
|
||||
const isActive = getFiltersByFactor("tagSearch").some((filter: MemoFilter) => filter.value === tag);
|
||||
if (isActive) {
|
||||
memoFilterStore.removeFilter((f: MemoFilter) => f.factor === "tagSearch" && f.value === tag);
|
||||
removeFilter((f: MemoFilter) => f.factor === "tagSearch" && f.value === tag);
|
||||
} else {
|
||||
// Remove all existing tag filters first, then add the new one
|
||||
memoFilterStore.removeFilter((f: MemoFilter) => f.factor === "tagSearch");
|
||||
memoFilterStore.addFilter({
|
||||
removeFilter((f: MemoFilter) => f.factor === "tagSearch");
|
||||
addFilter({
|
||||
factor: "tagSearch",
|
||||
value: tag,
|
||||
});
|
||||
|
|
@ -64,7 +64,7 @@ const TagsSection = observer((props: Props) => {
|
|||
) : (
|
||||
<div className="w-full flex flex-row justify-start items-center relative flex-wrap gap-x-2 gap-y-1.5">
|
||||
{tags.map(([tag, amount]) => {
|
||||
const isActive = memoFilterStore.getFiltersByFactor("tagSearch").some((filter: MemoFilter) => filter.value === tag);
|
||||
const isActive = getFiltersByFactor("tagSearch").some((filter: MemoFilter) => filter.value === tag);
|
||||
return (
|
||||
<div
|
||||
key={tag}
|
||||
|
|
@ -95,6 +95,6 @@ const TagsSection = observer((props: Props) => {
|
|||
)}
|
||||
</div>
|
||||
);
|
||||
});
|
||||
};
|
||||
|
||||
export default TagsSection;
|
||||
|
|
|
|||
|
|
@ -11,11 +11,7 @@ import {
|
|||
SearchIcon,
|
||||
XIcon,
|
||||
} from "lucide-react";
|
||||
import { observer } from "mobx-react-lite";
|
||||
import { useEffect, useRef } from "react";
|
||||
import { useSearchParams } from "react-router-dom";
|
||||
import { memoFilterStore } from "@/store";
|
||||
import { FilterFactor, getMemoFilterKey, MemoFilter, parseFilterQuery, stringifyFilters } from "@/store/memoFilter";
|
||||
import { FilterFactor, getMemoFilterKey, MemoFilter, useMemoFilterContext } from "@/contexts/MemoFilterContext";
|
||||
import { useTranslate } from "@/utils/i18n";
|
||||
|
||||
interface FilterConfig {
|
||||
|
|
@ -58,38 +54,12 @@ const FILTER_CONFIGS: Record<FilterFactor, FilterConfig> = {
|
|||
},
|
||||
};
|
||||
|
||||
const MemoFilters = observer(() => {
|
||||
const MemoFilters = () => {
|
||||
const t = useTranslate();
|
||||
const [searchParams, setSearchParams] = useSearchParams();
|
||||
const filters = memoFilterStore.filters;
|
||||
const lastSyncedUrlRef = useRef("");
|
||||
const lastSyncedStoreRef = useRef("");
|
||||
|
||||
useEffect(() => {
|
||||
const filterParam = searchParams.get("filter") || "";
|
||||
if (filterParam !== lastSyncedUrlRef.current) {
|
||||
lastSyncedUrlRef.current = filterParam;
|
||||
const newFilters = parseFilterQuery(filterParam);
|
||||
memoFilterStore.setFilters(newFilters);
|
||||
lastSyncedStoreRef.current = stringifyFilters(newFilters);
|
||||
}
|
||||
}, [searchParams]);
|
||||
|
||||
useEffect(() => {
|
||||
const storeString = stringifyFilters(filters);
|
||||
if (storeString !== lastSyncedStoreRef.current && storeString !== lastSyncedUrlRef.current) {
|
||||
lastSyncedStoreRef.current = storeString;
|
||||
const newParams = new URLSearchParams();
|
||||
if (filters.length > 0) {
|
||||
newParams.set("filter", storeString);
|
||||
}
|
||||
setSearchParams(newParams, { replace: true });
|
||||
lastSyncedUrlRef.current = filters.length > 0 ? storeString : "";
|
||||
}
|
||||
}, [filters, setSearchParams]);
|
||||
const { filters, removeFilter } = useMemoFilterContext();
|
||||
|
||||
const handleRemoveFilter = (filter: MemoFilter) => {
|
||||
memoFilterStore.removeFilter((f: MemoFilter) => isEqual(f, filter));
|
||||
removeFilter((f: MemoFilter) => isEqual(f, filter));
|
||||
};
|
||||
|
||||
const getFilterDisplayText = (filter: MemoFilter): string => {
|
||||
|
|
@ -129,7 +99,7 @@ const MemoFilters = observer(() => {
|
|||
})}
|
||||
</div>
|
||||
);
|
||||
});
|
||||
};
|
||||
|
||||
MemoFilters.displayName = "MemoFilters";
|
||||
|
||||
|
|
|
|||
|
|
@ -1,4 +1,3 @@
|
|||
import { observer } from "mobx-react-lite";
|
||||
import { memo } from "react";
|
||||
import useCurrentUser from "@/hooks/useCurrentUser";
|
||||
import { State } from "@/types/proto/api/v1/common_pb";
|
||||
|
|
@ -12,7 +11,7 @@ interface Props {
|
|||
reactions: Reaction[];
|
||||
}
|
||||
|
||||
const MemoReactionListView = observer((props: Props) => {
|
||||
const MemoReactionListView = (props: Props) => {
|
||||
const { memo: memoData, reactions } = props;
|
||||
const currentUser = useCurrentUser();
|
||||
const reactionGroup = useReactionGroups(reactions);
|
||||
|
|
@ -30,6 +29,6 @@ const MemoReactionListView = observer((props: Props) => {
|
|||
{!readonly && currentUser && <ReactionSelector memo={memoData} />}
|
||||
</div>
|
||||
);
|
||||
});
|
||||
};
|
||||
|
||||
export default memo(MemoReactionListView);
|
||||
|
|
|
|||
|
|
@ -1,9 +1,8 @@
|
|||
import { SmilePlusIcon } from "lucide-react";
|
||||
import { observer } from "mobx-react-lite";
|
||||
import { useState } from "react";
|
||||
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
|
||||
import { useInstance } from "@/contexts/InstanceContext";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { instanceStore } from "@/store";
|
||||
import type { Memo } from "@/types/proto/api/v1/memo_service_pb";
|
||||
import { useReactionActions } from "./hooks";
|
||||
|
||||
|
|
@ -13,9 +12,10 @@ interface Props {
|
|||
onOpenChange?: (open: boolean) => void;
|
||||
}
|
||||
|
||||
const ReactionSelector = observer((props: Props) => {
|
||||
const ReactionSelector = (props: Props) => {
|
||||
const { memo, className, onOpenChange } = props;
|
||||
const [open, setOpen] = useState(false);
|
||||
const { memoRelatedSetting } = useInstance();
|
||||
|
||||
const handleOpenChange = (newOpen: boolean) => {
|
||||
setOpen(newOpen);
|
||||
|
|
@ -26,7 +26,6 @@ const ReactionSelector = observer((props: Props) => {
|
|||
memo,
|
||||
onComplete: () => handleOpenChange(false),
|
||||
});
|
||||
const instanceMemoRelatedSetting = instanceStore.state.memoRelatedSetting;
|
||||
|
||||
return (
|
||||
<Popover open={open} onOpenChange={handleOpenChange}>
|
||||
|
|
@ -42,7 +41,7 @@ const ReactionSelector = observer((props: Props) => {
|
|||
</PopoverTrigger>
|
||||
<PopoverContent align="center" className="max-w-[90vw] sm:max-w-md">
|
||||
<div className="grid grid-cols-4 sm:grid-cols-6 md:grid-cols-8 gap-1 max-h-64 overflow-y-auto">
|
||||
{instanceMemoRelatedSetting.reactions.map((reactionType) => (
|
||||
{memoRelatedSetting.reactions.map((reactionType) => (
|
||||
<button
|
||||
type="button"
|
||||
key={reactionType}
|
||||
|
|
@ -59,6 +58,6 @@ const ReactionSelector = observer((props: Props) => {
|
|||
</PopoverContent>
|
||||
</Popover>
|
||||
);
|
||||
});
|
||||
};
|
||||
|
||||
export default ReactionSelector;
|
||||
|
|
|
|||
|
|
@ -1,4 +1,3 @@
|
|||
import { observer } from "mobx-react-lite";
|
||||
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/components/ui/tooltip";
|
||||
import useCurrentUser from "@/hooks/useCurrentUser";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
|
@ -13,7 +12,7 @@ interface Props {
|
|||
users: User[];
|
||||
}
|
||||
|
||||
const ReactionView = observer((props: Props) => {
|
||||
const ReactionView = (props: Props) => {
|
||||
const { memo, reactionType, users } = props;
|
||||
const currentUser = useCurrentUser();
|
||||
const hasReaction = users.some((user) => currentUser && user.username === currentUser.username);
|
||||
|
|
@ -54,6 +53,6 @@ const ReactionView = observer((props: Props) => {
|
|||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
);
|
||||
});
|
||||
};
|
||||
|
||||
export default ReactionView;
|
||||
|
|
|
|||
|
|
@ -1,8 +1,7 @@
|
|||
import { uniq } from "lodash-es";
|
||||
import { useEffect, useState } from "react";
|
||||
import { memoServiceClient } from "@/connect";
|
||||
import { memoServiceClient, userServiceClient } from "@/connect";
|
||||
import useCurrentUser from "@/hooks/useCurrentUser";
|
||||
import { memoStore, userStore } from "@/store";
|
||||
import type { Memo, Reaction } from "@/types/proto/api/v1/memo_service_pb";
|
||||
import type { User } from "@/types/proto/api/v1/user_service_pb";
|
||||
|
||||
|
|
@ -15,7 +14,8 @@ export const useReactionGroups = (reactions: Reaction[]): ReactionGroup => {
|
|||
const fetchReactionGroups = async () => {
|
||||
const newReactionGroup = new Map<string, User[]>();
|
||||
for (const reaction of reactions) {
|
||||
const user = await userStore.getOrFetchUser(reaction.creator);
|
||||
// Fetch user via gRPC directly since we need it within an effect
|
||||
const user = await userServiceClient.getUser({ name: reaction.creator });
|
||||
const users = newReactionGroup.get(reaction.reactionType) || [];
|
||||
users.push(user);
|
||||
newReactionGroup.set(reaction.reactionType, uniq(users));
|
||||
|
|
@ -57,7 +57,8 @@ export const useReactionActions = ({ memo, onComplete }: UseReactionActionsOptio
|
|||
reaction: { contentId: memo.name, reactionType },
|
||||
});
|
||||
}
|
||||
await memoStore.getOrFetchMemoByName(memo.name, { skipCache: true });
|
||||
// Refetch the memo to get updated reactions
|
||||
await memoServiceClient.getMemo({ name: memo.name });
|
||||
} catch {
|
||||
// skip error
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,8 +1,8 @@
|
|||
import { useEffect, useRef, useState } from "react";
|
||||
import ForceGraph2D, { ForceGraphMethods, LinkObject, NodeObject } from "react-force-graph-2d";
|
||||
import { extractMemoIdFromName } from "@/helpers/resource-names";
|
||||
import useNavigateTo from "@/hooks/useNavigateTo";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { extractMemoIdFromName } from "@/store/common";
|
||||
import { Memo, MemoRelation_Type } from "@/types/proto/api/v1/memo_service_pb";
|
||||
import { LinkType, NodeType } from "./types";
|
||||
import { convertMemoRelationsToGraphData } from "./utils";
|
||||
|
|
|
|||
|
|
@ -1,4 +1,3 @@
|
|||
import { observer } from "mobx-react-lite";
|
||||
import { memo, useMemo, useRef, useState } from "react";
|
||||
import { cn } from "@/lib/utils";
|
||||
import type { Memo } from "@/types/proto/api/v1/memo_service_pb";
|
||||
|
|
@ -50,7 +49,7 @@ interface Props {
|
|||
* />
|
||||
* ```
|
||||
*/
|
||||
const MemoView: React.FC<Props> = observer((props: Props) => {
|
||||
const MemoView: React.FC<Props> = (props: Props) => {
|
||||
const { memo: memoData, className } = props;
|
||||
const cardRef = useRef<HTMLDivElement>(null);
|
||||
const [reactionSelectorOpen, setReactionSelectorOpen] = useState(false);
|
||||
|
|
@ -157,6 +156,6 @@ const MemoView: React.FC<Props> = observer((props: Props) => {
|
|||
</article>
|
||||
</MemoViewContext.Provider>
|
||||
);
|
||||
});
|
||||
};
|
||||
|
||||
export default memo(MemoView);
|
||||
|
|
|
|||
|
|
@ -1,19 +1,19 @@
|
|||
import toast from "react-hot-toast";
|
||||
import { memoStore, userStore } from "@/store";
|
||||
import { useUpdateMemo } from "@/hooks/useMemoQueries";
|
||||
import { State } from "@/types/proto/api/v1/common_pb";
|
||||
import type { Memo } from "@/types/proto/api/v1/memo_service_pb";
|
||||
import { useTranslate } from "@/utils/i18n";
|
||||
|
||||
export const useMemoActions = (memo: Memo) => {
|
||||
const t = useTranslate();
|
||||
const { mutateAsync: updateMemo } = useUpdateMemo();
|
||||
const isArchived = memo.state === State.ARCHIVED;
|
||||
|
||||
const archiveMemo = async () => {
|
||||
if (isArchived) return;
|
||||
try {
|
||||
await memoStore.updateMemo({ name: memo.name, state: State.ARCHIVED }, ["state"]);
|
||||
await updateMemo({ update: { name: memo.name, state: State.ARCHIVED }, updateMask: ["state"] });
|
||||
toast.success(t("message.archived-successfully"));
|
||||
userStore.setStatsStateId();
|
||||
} catch (error: unknown) {
|
||||
console.error(error);
|
||||
const err = error as { details?: string };
|
||||
|
|
@ -23,7 +23,7 @@ export const useMemoActions = (memo: Memo) => {
|
|||
|
||||
const unpinMemo = async () => {
|
||||
if (!memo.pinned) return;
|
||||
await memoStore.updateMemo({ name: memo.name, pinned: false }, ["pinned"]);
|
||||
await updateMemo({ update: { name: memo.name, pinned: false }, updateMask: ["pinned"] });
|
||||
};
|
||||
|
||||
return { archiveMemo, unpinMemo };
|
||||
|
|
|
|||
|
|
@ -1,12 +1,6 @@
|
|||
import { useEffect, useState } from "react";
|
||||
import { userStore } from "@/store";
|
||||
import { useUser } from "@/hooks/useUserQueries";
|
||||
|
||||
export const useMemoCreator = (creatorName: string) => {
|
||||
const [creator, setCreator] = useState(userStore.getUserByName(creatorName));
|
||||
|
||||
useEffect(() => {
|
||||
userStore.getOrFetchUser(creatorName).then(setCreator);
|
||||
}, [creatorName]);
|
||||
|
||||
const { data: creator } = useUser(creatorName);
|
||||
return creator;
|
||||
};
|
||||
|
|
|
|||
|
|
@ -1,5 +1,4 @@
|
|||
import { useState } from "react";
|
||||
import { userStore } from "@/store";
|
||||
|
||||
export const useMemoEditor = () => {
|
||||
const [showEditor, setShowEditor] = useState(false);
|
||||
|
|
@ -9,7 +8,6 @@ export const useMemoEditor = () => {
|
|||
openEditor: () => setShowEditor(true),
|
||||
handleEditorConfirm: () => {
|
||||
setShowEditor(false);
|
||||
userStore.setStatsStateId();
|
||||
},
|
||||
handleEditorCancel: () => setShowEditor(false),
|
||||
};
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
import { useCallback } from "react";
|
||||
import { useInstance } from "@/contexts/InstanceContext";
|
||||
import useNavigateTo from "@/hooks/useNavigateTo";
|
||||
import { instanceStore } from "@/store";
|
||||
|
||||
interface UseMemoHandlersOptions {
|
||||
memoName: string;
|
||||
|
|
@ -13,6 +13,7 @@ interface UseMemoHandlersOptions {
|
|||
export const useMemoHandlers = (options: UseMemoHandlersOptions) => {
|
||||
const { memoName, parentPage, readonly, openEditor, openPreview } = options;
|
||||
const navigateTo = useNavigateTo();
|
||||
const { memoRelatedSetting } = useInstance();
|
||||
|
||||
const handleGotoMemoDetailPage = useCallback(() => {
|
||||
navigateTo(`/${memoName}`, { state: { from: parentPage } });
|
||||
|
|
@ -34,12 +35,12 @@ export const useMemoHandlers = (options: UseMemoHandlersOptions) => {
|
|||
const handleMemoContentDoubleClick = useCallback(
|
||||
(e: React.MouseEvent) => {
|
||||
if (readonly) return;
|
||||
if (instanceStore.state.memoRelatedSetting.enableDoubleClickEdit) {
|
||||
if (memoRelatedSetting.enableDoubleClickEdit) {
|
||||
e.preventDefault();
|
||||
openEditor();
|
||||
}
|
||||
},
|
||||
[readonly, openEditor],
|
||||
[readonly, openEditor, memoRelatedSetting.enableDoubleClickEdit],
|
||||
);
|
||||
|
||||
return { handleGotoMemoDetailPage, handleMemoContentClick, handleMemoContentDoubleClick };
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
import { useState } from "react";
|
||||
import { instanceStore } from "@/store";
|
||||
import { useInstance } from "@/contexts/InstanceContext";
|
||||
import type { Memo } from "@/types/proto/api/v1/memo_service_pb";
|
||||
|
||||
export interface UseNsfwContentReturn {
|
||||
|
|
@ -10,11 +10,11 @@ export interface UseNsfwContentReturn {
|
|||
|
||||
export const useNsfwContent = (memo: Memo, initialShowNsfw?: boolean): UseNsfwContentReturn => {
|
||||
const [showNSFWContent, setShowNSFWContent] = useState(initialShowNsfw ?? false);
|
||||
const instanceMemoRelatedSetting = instanceStore.state.memoRelatedSetting;
|
||||
const { memoRelatedSetting } = useInstance();
|
||||
|
||||
const nsfw =
|
||||
instanceMemoRelatedSetting.enableBlurNsfwContent &&
|
||||
memo.tags?.some((tag) => instanceMemoRelatedSetting.nsfwTags.some((nsfwTag) => tag === nsfwTag || tag.startsWith(`${nsfwTag}/`)));
|
||||
memoRelatedSetting.enableBlurNsfwContent &&
|
||||
memo.tags?.some((tag) => memoRelatedSetting.nsfwTags.some((nsfwTag) => tag === nsfwTag || tag.startsWith(`${nsfwTag}/`)));
|
||||
|
||||
return {
|
||||
nsfw: nsfw ?? false,
|
||||
|
|
|
|||
|
|
@ -1,6 +1,5 @@
|
|||
import { observer } from "mobx-react-lite";
|
||||
import { useInstance } from "@/contexts/InstanceContext";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { instanceStore } from "@/store";
|
||||
import UserAvatar from "./UserAvatar";
|
||||
|
||||
interface Props {
|
||||
|
|
@ -8,9 +7,9 @@ interface Props {
|
|||
collapsed?: boolean;
|
||||
}
|
||||
|
||||
const MemosLogo = observer((props: Props) => {
|
||||
function MemosLogo(props: Props) {
|
||||
const { collapsed } = props;
|
||||
const instanceGeneralSetting = instanceStore.state.generalSetting;
|
||||
const { generalSetting: instanceGeneralSetting } = useInstance();
|
||||
const title = instanceGeneralSetting.customProfile?.title || "Memos";
|
||||
const avatarUrl = instanceGeneralSetting.customProfile?.logoUrl || "/full-logo.webp";
|
||||
|
||||
|
|
@ -22,6 +21,6 @@ const MemosLogo = observer((props: Props) => {
|
|||
</div>
|
||||
</div>
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
export default MemosLogo;
|
||||
|
|
|
|||
|
|
@ -1,12 +1,10 @@
|
|||
import { BellIcon, CalendarIcon, EarthIcon, LibraryIcon, PaperclipIcon, UserCircleIcon } from "lucide-react";
|
||||
import { observer } from "mobx-react-lite";
|
||||
import { useEffect } from "react";
|
||||
import { NavLink } from "react-router-dom";
|
||||
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/components/ui/tooltip";
|
||||
import useCurrentUser from "@/hooks/useCurrentUser";
|
||||
import { useNotifications } from "@/hooks/useUserQueries";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { Routes } from "@/router";
|
||||
import { userStore } from "@/store";
|
||||
import { UserNotification_Status } from "@/types/proto/api/v1/user_service_pb";
|
||||
import { useTranslate } from "@/utils/i18n";
|
||||
import MemosLogo from "./MemosLogo";
|
||||
|
|
@ -24,18 +22,11 @@ interface Props {
|
|||
className?: string;
|
||||
}
|
||||
|
||||
const Navigation = observer((props: Props) => {
|
||||
const Navigation = (props: Props) => {
|
||||
const { collapsed, className } = props;
|
||||
const t = useTranslate();
|
||||
const currentUser = useCurrentUser();
|
||||
|
||||
useEffect(() => {
|
||||
if (!currentUser) {
|
||||
return;
|
||||
}
|
||||
|
||||
userStore.fetchNotifications();
|
||||
}, []);
|
||||
const { data: notifications = [] } = useNotifications();
|
||||
|
||||
const homeNavLink: NavLinkItem = {
|
||||
id: "header-memos",
|
||||
|
|
@ -61,7 +52,7 @@ const Navigation = observer((props: Props) => {
|
|||
title: t("common.attachments"),
|
||||
icon: <PaperclipIcon className="w-6 h-auto shrink-0" />,
|
||||
};
|
||||
const unreadCount = userStore.state.notifications.filter((n) => n.status === UserNotification_Status.UNREAD).length;
|
||||
const unreadCount = notifications.filter((n) => n.status === UserNotification_Status.UNREAD).length;
|
||||
const inboxNavLink: NavLinkItem = {
|
||||
id: "header-inbox",
|
||||
path: Routes.INBOX,
|
||||
|
|
@ -135,6 +126,6 @@ const Navigation = observer((props: Props) => {
|
|||
)}
|
||||
</header>
|
||||
);
|
||||
});
|
||||
};
|
||||
|
||||
export default Navigation;
|
||||
|
|
|
|||
|
|
@ -1,18 +1,17 @@
|
|||
import { observer } from "mobx-react-lite";
|
||||
import { useEffect, useState } from "react";
|
||||
import { useLocation } from "react-router-dom";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Sheet, SheetContent, SheetHeader, SheetTitle, SheetTrigger } from "@/components/ui/sheet";
|
||||
import { instanceStore } from "@/store";
|
||||
import { useInstance } from "@/contexts/InstanceContext";
|
||||
import Navigation from "./Navigation";
|
||||
import UserAvatar from "./UserAvatar";
|
||||
|
||||
const NavigationDrawer = observer(() => {
|
||||
const NavigationDrawer = () => {
|
||||
const location = useLocation();
|
||||
const [open, setOpen] = useState(false);
|
||||
const instanceGeneralSetting = instanceStore.state.generalSetting;
|
||||
const title = instanceGeneralSetting.customProfile?.title || "Memos";
|
||||
const avatarUrl = instanceGeneralSetting.customProfile?.logoUrl || "/full-logo.webp";
|
||||
const { generalSetting } = useInstance();
|
||||
const title = generalSetting.customProfile?.title || "Memos";
|
||||
const avatarUrl = generalSetting.customProfile?.logoUrl || "/full-logo.webp";
|
||||
|
||||
useEffect(() => {
|
||||
setOpen(false);
|
||||
|
|
@ -34,6 +33,6 @@ const NavigationDrawer = observer(() => {
|
|||
</SheetContent>
|
||||
</Sheet>
|
||||
);
|
||||
});
|
||||
};
|
||||
|
||||
export default NavigationDrawer;
|
||||
|
|
|
|||
|
|
@ -1,13 +1,14 @@
|
|||
import { ArrowUpIcon, LoaderIcon } from "lucide-react";
|
||||
import { observer } from "mobx-react-lite";
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import { matchPath } from "react-router-dom";
|
||||
import PullToRefresh from "react-simple-pull-to-refresh";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { userServiceClient } from "@/connect";
|
||||
import { useView } from "@/contexts/ViewContext";
|
||||
import { DEFAULT_LIST_MEMOS_PAGE_SIZE } from "@/helpers/consts";
|
||||
import { useInfiniteMemos } from "@/hooks/useMemoQueries";
|
||||
import useResponsiveWidth from "@/hooks/useResponsiveWidth";
|
||||
import { Routes } from "@/router";
|
||||
import { memoStore, userStore, viewStore } from "@/store";
|
||||
import { State } from "@/types/proto/api/v1/common_pb";
|
||||
import type { Memo } from "@/types/proto/api/v1/memo_service_pb";
|
||||
import { useTranslate } from "@/utils/i18n";
|
||||
|
|
@ -28,120 +29,52 @@ interface Props {
|
|||
showCreator?: boolean;
|
||||
}
|
||||
|
||||
const PagedMemoList = observer((props: Props) => {
|
||||
const t = useTranslate();
|
||||
const { md } = useResponsiveWidth();
|
||||
|
||||
// Simplified state management - separate state variables for clarity
|
||||
const [isRequesting, setIsRequesting] = useState(true);
|
||||
const [nextPageToken, setNextPageToken] = useState("");
|
||||
|
||||
// Ref to manage auto-fetch timeout to prevent memory leaks
|
||||
/**
|
||||
* Custom hook to auto-fetch more content when page isn't scrollable.
|
||||
* This ensures users see content without needing to scroll on large screens.
|
||||
*/
|
||||
function useAutoFetchWhenNotScrollable({
|
||||
hasNextPage,
|
||||
isFetchingNextPage,
|
||||
memoCount,
|
||||
onFetchNext,
|
||||
}: {
|
||||
hasNextPage: boolean | undefined;
|
||||
isFetchingNextPage: boolean;
|
||||
memoCount: number;
|
||||
onFetchNext: () => Promise<unknown>;
|
||||
}) {
|
||||
const autoFetchTimeoutRef = useRef<number | null>(null);
|
||||
// Ref to track if initial fetch has been triggered to prevent duplicates
|
||||
const initialFetchTriggeredRef = useRef(false);
|
||||
|
||||
// Apply custom sorting if provided, otherwise use store memos directly
|
||||
const sortedMemoList = props.listSort ? props.listSort(memoStore.state.memos) : memoStore.state.memos;
|
||||
|
||||
// Show memo editor only on the root route
|
||||
const showMemoEditor = Boolean(matchPath(Routes.ROOT, window.location.pathname));
|
||||
|
||||
// Fetch more memos with pagination support
|
||||
const fetchMoreMemos = useCallback(
|
||||
async (pageToken: string) => {
|
||||
setIsRequesting(true);
|
||||
|
||||
try {
|
||||
const response = await memoStore.fetchMemos({
|
||||
state: props.state || State.NORMAL,
|
||||
orderBy: props.orderBy || "display_time desc",
|
||||
filter: props.filter,
|
||||
pageSize: props.pageSize || DEFAULT_LIST_MEMOS_PAGE_SIZE,
|
||||
pageToken,
|
||||
});
|
||||
|
||||
setNextPageToken(response?.nextPageToken || "");
|
||||
|
||||
// Batch-fetch creators in parallel to avoid individual fetches in MemoView
|
||||
// This significantly improves perceived performance by pre-populating the cache
|
||||
if (response?.memos && props.showCreator) {
|
||||
const uniqueCreators = Array.from(new Set(response.memos.map((memo) => memo.creator)));
|
||||
await Promise.allSettled(uniqueCreators.map((creator) => userStore.getOrFetchUser(creator)));
|
||||
}
|
||||
} finally {
|
||||
setIsRequesting(false);
|
||||
}
|
||||
},
|
||||
[props.state, props.orderBy, props.filter, props.pageSize, props.showCreator],
|
||||
);
|
||||
|
||||
// Helper function to check if page has enough content to be scrollable
|
||||
const isPageScrollable = useCallback(() => {
|
||||
const documentHeight = Math.max(document.body.scrollHeight, document.documentElement.scrollHeight);
|
||||
return documentHeight > window.innerHeight + 100; // 100px buffer for safe measure
|
||||
return documentHeight > window.innerHeight + 100;
|
||||
}, []);
|
||||
|
||||
// Auto-fetch more content if page isn't scrollable and more data is available
|
||||
const checkAndFetchIfNeeded = useCallback(async () => {
|
||||
// Clear any pending auto-fetch timeout
|
||||
if (autoFetchTimeoutRef.current) {
|
||||
clearTimeout(autoFetchTimeoutRef.current);
|
||||
}
|
||||
|
||||
// Wait for DOM to update before checking scrollability
|
||||
await new Promise((resolve) => setTimeout(resolve, 200));
|
||||
|
||||
// Only fetch if: page isn't scrollable, we have more data, not currently loading, and have memos
|
||||
const shouldFetch = !isPageScrollable() && nextPageToken && !isRequesting && sortedMemoList.length > 0;
|
||||
const shouldFetch = !isPageScrollable() && hasNextPage && !isFetchingNextPage && memoCount > 0;
|
||||
|
||||
if (shouldFetch) {
|
||||
await fetchMoreMemos(nextPageToken);
|
||||
await onFetchNext();
|
||||
|
||||
// Schedule another check with delay to prevent rapid successive calls
|
||||
autoFetchTimeoutRef.current = window.setTimeout(() => {
|
||||
checkAndFetchIfNeeded();
|
||||
void checkAndFetchIfNeeded();
|
||||
}, 500);
|
||||
}
|
||||
}, [nextPageToken, isRequesting, sortedMemoList.length, isPageScrollable, fetchMoreMemos]);
|
||||
}, [hasNextPage, isFetchingNextPage, memoCount, isPageScrollable, onFetchNext]);
|
||||
|
||||
// Refresh the entire memo list from the beginning
|
||||
const refreshList = useCallback(async () => {
|
||||
memoStore.state.updateStateId();
|
||||
setNextPageToken("");
|
||||
await fetchMoreMemos("");
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [fetchMoreMemos]);
|
||||
|
||||
// Track previous props to detect changes
|
||||
const propsKey = `${props.state}-${props.orderBy}-${props.filter}-${props.pageSize}`;
|
||||
const prevPropsKeyRef = useRef<string>();
|
||||
|
||||
// Initial load and reload when props change
|
||||
useEffect(() => {
|
||||
const propsChanged = prevPropsKeyRef.current !== undefined && prevPropsKeyRef.current !== propsKey;
|
||||
prevPropsKeyRef.current = propsKey;
|
||||
|
||||
// Skip first render if we haven't marked it yet
|
||||
if (!initialFetchTriggeredRef.current) {
|
||||
initialFetchTriggeredRef.current = true;
|
||||
refreshList();
|
||||
return;
|
||||
if (!isFetchingNextPage && memoCount > 0) {
|
||||
void checkAndFetchIfNeeded();
|
||||
}
|
||||
// For subsequent changes, refresh if props actually changed
|
||||
if (propsChanged) {
|
||||
refreshList();
|
||||
}
|
||||
}, [refreshList, propsKey]);
|
||||
}, [memoCount, isFetchingNextPage, checkAndFetchIfNeeded]);
|
||||
|
||||
// Auto-fetch more content when list changes and page isn't full
|
||||
useEffect(() => {
|
||||
if (!isRequesting && sortedMemoList.length > 0) {
|
||||
checkAndFetchIfNeeded();
|
||||
}
|
||||
}, [sortedMemoList.length, isRequesting, checkAndFetchIfNeeded]);
|
||||
|
||||
// Cleanup timeout on component unmount
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
if (autoFetchTimeoutRef.current) {
|
||||
|
|
@ -149,26 +82,74 @@ const PagedMemoList = observer((props: Props) => {
|
|||
}
|
||||
};
|
||||
}, []);
|
||||
}
|
||||
|
||||
const PagedMemoList = (props: Props) => {
|
||||
const t = useTranslate();
|
||||
const { md } = useResponsiveWidth();
|
||||
const { layout } = useView();
|
||||
|
||||
// Show memo editor only on the root route
|
||||
const showMemoEditor = Boolean(matchPath(Routes.ROOT, window.location.pathname));
|
||||
|
||||
// Use React Query's infinite query for pagination
|
||||
const { data, fetchNextPage, hasNextPage, isFetchingNextPage, isLoading, refetch } = useInfiniteMemos({
|
||||
state: props.state || State.NORMAL,
|
||||
orderBy: props.orderBy || "display_time desc",
|
||||
filter: props.filter,
|
||||
pageSize: props.pageSize || DEFAULT_LIST_MEMOS_PAGE_SIZE,
|
||||
});
|
||||
|
||||
// Flatten pages into a single array of memos
|
||||
const memos = useMemo(() => data?.pages.flatMap((page) => page.memos) || [], [data]);
|
||||
|
||||
// Apply custom sorting if provided, otherwise use memos directly
|
||||
const sortedMemoList = useMemo(() => (props.listSort ? props.listSort(memos) : memos), [memos, props.listSort]);
|
||||
|
||||
// Batch-fetch creators when new data arrives to improve performance
|
||||
useEffect(() => {
|
||||
if (!data?.pages || !props.showCreator) return;
|
||||
|
||||
const lastPage = data.pages[data.pages.length - 1];
|
||||
if (!lastPage?.memos) return;
|
||||
|
||||
const uniqueCreators = Array.from(new Set(lastPage.memos.map((memo) => memo.creator)));
|
||||
void Promise.allSettled(
|
||||
uniqueCreators.map((creator) =>
|
||||
userServiceClient.getUser({ name: creator }).catch(() => {
|
||||
/* silently ignore errors */
|
||||
}),
|
||||
),
|
||||
);
|
||||
}, [data?.pages, props.showCreator]);
|
||||
|
||||
// Auto-fetch hook: fetches more content when page isn't scrollable
|
||||
useAutoFetchWhenNotScrollable({
|
||||
hasNextPage,
|
||||
isFetchingNextPage,
|
||||
memoCount: sortedMemoList.length,
|
||||
onFetchNext: fetchNextPage,
|
||||
});
|
||||
|
||||
// Infinite scroll: fetch more when user scrolls near bottom
|
||||
useEffect(() => {
|
||||
if (!nextPageToken) return;
|
||||
if (!hasNextPage) return;
|
||||
|
||||
const handleScroll = () => {
|
||||
const nearBottom = window.innerHeight + window.scrollY >= document.body.offsetHeight - 300;
|
||||
if (nearBottom && !isRequesting) {
|
||||
fetchMoreMemos(nextPageToken);
|
||||
if (nearBottom && !isFetchingNextPage) {
|
||||
fetchNextPage();
|
||||
}
|
||||
};
|
||||
|
||||
window.addEventListener("scroll", handleScroll);
|
||||
return () => window.removeEventListener("scroll", handleScroll);
|
||||
}, [nextPageToken, isRequesting, fetchMoreMemos]);
|
||||
}, [hasNextPage, isFetchingNextPage, fetchNextPage]);
|
||||
|
||||
const children = (
|
||||
<div className="flex flex-col justify-start items-start w-full max-w-full">
|
||||
{/* Show skeleton loader during initial load */}
|
||||
{isRequesting && sortedMemoList.length === 0 ? (
|
||||
{isLoading ? (
|
||||
<div className="w-full flex flex-col justify-start items-center">
|
||||
<MemoSkeleton showCreator={props.showCreator} count={4} />
|
||||
</div>
|
||||
|
|
@ -185,20 +166,20 @@ const PagedMemoList = observer((props: Props) => {
|
|||
<MemoFilters />
|
||||
</>
|
||||
}
|
||||
listMode={viewStore.state.layout === "LIST"}
|
||||
listMode={layout === "LIST"}
|
||||
/>
|
||||
|
||||
{/* Loading indicator for pagination */}
|
||||
{isRequesting && (
|
||||
{isFetchingNextPage && (
|
||||
<div className="w-full flex flex-row justify-center items-center my-4">
|
||||
<LoaderIcon className="animate-spin text-muted-foreground" />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Empty state or back-to-top button */}
|
||||
{!isRequesting && (
|
||||
{!isFetchingNextPage && (
|
||||
<>
|
||||
{!nextPageToken && sortedMemoList.length === 0 ? (
|
||||
{!hasNextPage && sortedMemoList.length === 0 ? (
|
||||
<div className="w-full mt-12 mb-8 flex flex-col justify-center items-center italic">
|
||||
<Empty />
|
||||
<p className="mt-2 text-muted-foreground">{t("message.no-data")}</p>
|
||||
|
|
@ -221,7 +202,9 @@ const PagedMemoList = observer((props: Props) => {
|
|||
|
||||
return (
|
||||
<PullToRefresh
|
||||
onRefresh={() => refreshList()}
|
||||
onRefresh={async () => {
|
||||
await refetch();
|
||||
}}
|
||||
pullingContent={
|
||||
<div className="w-full flex flex-row justify-center items-center my-4">
|
||||
<LoaderIcon className="opacity-60" />
|
||||
|
|
@ -236,7 +219,7 @@ const PagedMemoList = observer((props: Props) => {
|
|||
{children}
|
||||
</PullToRefresh>
|
||||
);
|
||||
});
|
||||
};
|
||||
|
||||
const BackToTop = () => {
|
||||
const t = useTranslate();
|
||||
|
|
|
|||
|
|
@ -1,24 +1,25 @@
|
|||
import { timestampDate } from "@bufbuild/protobuf/wkt";
|
||||
import { LoaderIcon } from "lucide-react";
|
||||
import { observer } from "mobx-react-lite";
|
||||
import { useState } from "react";
|
||||
import { toast } from "react-hot-toast";
|
||||
import { setAccessToken } from "@/auth-state";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { authServiceClient } from "@/connect";
|
||||
import { useAuth } from "@/contexts/AuthContext";
|
||||
import { useInstance } from "@/contexts/InstanceContext";
|
||||
import useLoading from "@/hooks/useLoading";
|
||||
import useNavigateTo from "@/hooks/useNavigateTo";
|
||||
import { instanceStore } from "@/store";
|
||||
import { initialUserStore } from "@/store/user";
|
||||
import { useTranslate } from "@/utils/i18n";
|
||||
|
||||
const PasswordSignInForm = observer(() => {
|
||||
function PasswordSignInForm() {
|
||||
const t = useTranslate();
|
||||
const navigateTo = useNavigateTo();
|
||||
const { profile } = useInstance();
|
||||
const { initialize } = useAuth();
|
||||
const actionBtnLoadingState = useLoading(false);
|
||||
const [username, setUsername] = useState(instanceStore.state.profile.mode === "demo" ? "demo" : "");
|
||||
const [password, setPassword] = useState(instanceStore.state.profile.mode === "demo" ? "secret" : "");
|
||||
const [username, setUsername] = useState(profile.mode === "demo" ? "demo" : "");
|
||||
const [password, setPassword] = useState(profile.mode === "demo" ? "secret" : "");
|
||||
|
||||
const handleUsernameInputChanged = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const text = e.target.value as string;
|
||||
|
|
@ -56,7 +57,7 @@ const PasswordSignInForm = observer(() => {
|
|||
if (response.accessToken) {
|
||||
setAccessToken(response.accessToken, response.accessTokenExpiresAt ? timestampDate(response.accessTokenExpiresAt) : undefined);
|
||||
}
|
||||
await initialUserStore();
|
||||
await initialize();
|
||||
navigateTo("/");
|
||||
} catch (error: unknown) {
|
||||
console.error(error);
|
||||
|
|
@ -108,6 +109,6 @@ const PasswordSignInForm = observer(() => {
|
|||
</div>
|
||||
</form>
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
export default PasswordSignInForm;
|
||||
|
|
|
|||
|
|
@ -1,13 +1,13 @@
|
|||
import { SearchIcon } from "lucide-react";
|
||||
import { observer } from "mobx-react-lite";
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { useMemoFilterContext } from "@/contexts/MemoFilterContext";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { memoFilterStore } from "@/store";
|
||||
import { useTranslate } from "@/utils/i18n";
|
||||
import MemoDisplaySettingMenu from "./MemoDisplaySettingMenu";
|
||||
|
||||
const SearchBar = observer(() => {
|
||||
const SearchBar = () => {
|
||||
const t = useTranslate();
|
||||
const { addFilter } = useMemoFilterContext();
|
||||
const [queryText, setQueryText] = useState("");
|
||||
const inputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
|
|
@ -34,7 +34,7 @@ const SearchBar = observer(() => {
|
|||
if (trimmedText !== "") {
|
||||
const words = trimmedText.split(/\s+/);
|
||||
words.forEach((word) => {
|
||||
memoFilterStore.addFilter({
|
||||
addFilter({
|
||||
factor: "contentSearch",
|
||||
value: word,
|
||||
});
|
||||
|
|
@ -58,6 +58,6 @@ const SearchBar = observer(() => {
|
|||
<MemoDisplaySettingMenu className="absolute right-2 top-2 text-sidebar-foreground" />
|
||||
</div>
|
||||
);
|
||||
});
|
||||
};
|
||||
|
||||
export default SearchBar;
|
||||
|
|
|
|||
|
|
@ -30,13 +30,13 @@ const AccessTokenSection = () => {
|
|||
const [deleteTarget, setDeleteTarget] = useState<PersonalAccessToken | undefined>(undefined);
|
||||
|
||||
useEffect(() => {
|
||||
listAccessTokens(currentUser.name).then((tokens) => {
|
||||
listAccessTokens(currentUser?.name ?? "").then((tokens) => {
|
||||
setPersonalAccessTokens(tokens);
|
||||
});
|
||||
}, []);
|
||||
|
||||
const handleCreateAccessTokenDialogConfirm = async (response: CreatePersonalAccessTokenResponse) => {
|
||||
const tokens = await listAccessTokens(currentUser.name);
|
||||
const tokens = await listAccessTokens(currentUser?.name ?? "");
|
||||
setPersonalAccessTokens(tokens);
|
||||
// Copy the token to clipboard - this is the only time it will be shown
|
||||
if (response.token) {
|
||||
|
|
|
|||
|
|
@ -1,6 +1,5 @@
|
|||
import { create } from "@bufbuild/protobuf";
|
||||
import { isEqual } from "lodash-es";
|
||||
import { observer } from "mobx-react-lite";
|
||||
import { useEffect, useState } from "react";
|
||||
import { toast } from "react-hot-toast";
|
||||
import { Button } from "@/components/ui/button";
|
||||
|
|
@ -8,9 +7,8 @@ import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@
|
|||
import { Switch } from "@/components/ui/switch";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
import { identityProviderServiceClient } from "@/connect";
|
||||
import { useInstance } from "@/contexts/InstanceContext";
|
||||
import useDialog from "@/hooks/useDialog";
|
||||
import { instanceStore } from "@/store";
|
||||
import { buildInstanceSettingName } from "@/store/common";
|
||||
import { IdentityProvider } from "@/types/proto/api/v1/idp_service_pb";
|
||||
import {
|
||||
InstanceSetting_GeneralSetting,
|
||||
|
|
@ -24,27 +22,16 @@ import SettingGroup from "./SettingGroup";
|
|||
import SettingRow from "./SettingRow";
|
||||
import SettingSection from "./SettingSection";
|
||||
|
||||
// Helper to extract general setting value from InstanceSetting oneof
|
||||
function getGeneralSetting(setting: any): InstanceSetting_GeneralSetting | undefined {
|
||||
if (setting?.value?.case === "generalSetting") {
|
||||
return setting.value.value;
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const InstanceSection = observer(() => {
|
||||
const InstanceSection = () => {
|
||||
const t = useTranslate();
|
||||
const customizeDialog = useDialog();
|
||||
const originalSetting = create(
|
||||
InstanceSetting_GeneralSettingSchema,
|
||||
getGeneralSetting(instanceStore.getInstanceSettingByKey(InstanceSetting_Key.GENERAL)) || {},
|
||||
);
|
||||
const { generalSetting: originalSetting, profile, updateSetting, fetchSetting } = useInstance();
|
||||
const [instanceGeneralSetting, setInstanceGeneralSetting] = useState<InstanceSetting_GeneralSetting>(originalSetting);
|
||||
const [identityProviderList, setIdentityProviderList] = useState<IdentityProvider[]>([]);
|
||||
|
||||
useEffect(() => {
|
||||
setInstanceGeneralSetting({ ...instanceGeneralSetting, customProfile: originalSetting.customProfile });
|
||||
}, [instanceStore.getInstanceSettingByKey(InstanceSetting_Key.GENERAL)]);
|
||||
}, [originalSetting]);
|
||||
|
||||
const handleUpdateCustomizedProfileButtonClick = () => {
|
||||
customizeDialog.open();
|
||||
|
|
@ -61,15 +48,16 @@ const InstanceSection = observer(() => {
|
|||
|
||||
const handleSaveGeneralSetting = async () => {
|
||||
try {
|
||||
await instanceStore.upsertInstanceSetting(
|
||||
await updateSetting(
|
||||
create(InstanceSettingSchema, {
|
||||
name: buildInstanceSettingName(InstanceSetting_Key.GENERAL),
|
||||
name: `instance/settings/${InstanceSetting_Key[InstanceSetting_Key.GENERAL]}`,
|
||||
value: {
|
||||
case: "generalSetting",
|
||||
value: instanceGeneralSetting,
|
||||
},
|
||||
}),
|
||||
);
|
||||
await fetchSetting(InstanceSetting_Key.GENERAL);
|
||||
} catch (error: any) {
|
||||
toast.error(error.message);
|
||||
console.error(error);
|
||||
|
|
@ -122,7 +110,7 @@ const InstanceSection = observer(() => {
|
|||
<SettingGroup title={t("setting.instance-section.disallow-user-registration")} showSeparator>
|
||||
<SettingRow label={t("setting.instance-section.disallow-user-registration")}>
|
||||
<Switch
|
||||
disabled={instanceStore.state.profile.mode === "demo"}
|
||||
disabled={profile.mode === "demo"}
|
||||
checked={instanceGeneralSetting.disallowUserRegistration}
|
||||
onCheckedChange={(checked) => updatePartialSetting({ disallowUserRegistration: checked })}
|
||||
/>
|
||||
|
|
@ -130,10 +118,7 @@ const InstanceSection = observer(() => {
|
|||
|
||||
<SettingRow label={t("setting.instance-section.disallow-password-auth")}>
|
||||
<Switch
|
||||
disabled={
|
||||
instanceStore.state.profile.mode === "demo" ||
|
||||
(identityProviderList.length === 0 && !instanceGeneralSetting.disallowPasswordAuth)
|
||||
}
|
||||
disabled={profile.mode === "demo" || (identityProviderList.length === 0 && !instanceGeneralSetting.disallowPasswordAuth)}
|
||||
checked={instanceGeneralSetting.disallowPasswordAuth}
|
||||
onCheckedChange={(checked) => updatePartialSetting({ disallowPasswordAuth: checked })}
|
||||
/>
|
||||
|
|
@ -188,6 +173,6 @@ const InstanceSection = observer(() => {
|
|||
/>
|
||||
</SettingSection>
|
||||
);
|
||||
});
|
||||
};
|
||||
|
||||
export default InstanceSection;
|
||||
|
|
|
|||
|
|
@ -2,15 +2,14 @@ import { create } from "@bufbuild/protobuf";
|
|||
import { FieldMaskSchema } from "@bufbuild/protobuf/wkt";
|
||||
import { sortBy } from "lodash-es";
|
||||
import { MoreVerticalIcon, PlusIcon } from "lucide-react";
|
||||
import { observer } from "mobx-react-lite";
|
||||
import React, { useEffect, useState } from "react";
|
||||
import React, { useState } from "react";
|
||||
import toast from "react-hot-toast";
|
||||
import ConfirmDialog from "@/components/ConfirmDialog";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { userServiceClient } from "@/connect";
|
||||
import useCurrentUser from "@/hooks/useCurrentUser";
|
||||
import { useDialog } from "@/hooks/useDialog";
|
||||
import { userStore } from "@/store";
|
||||
import { useDeleteUser, useListUsers } from "@/hooks/useUserQueries";
|
||||
import { State } from "@/types/proto/api/v1/common_pb";
|
||||
import { User, User_Role } from "@/types/proto/api/v1/user_service_pb";
|
||||
import { useTranslate } from "@/utils/i18n";
|
||||
|
|
@ -19,10 +18,11 @@ import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigge
|
|||
import SettingSection from "./SettingSection";
|
||||
import SettingTable from "./SettingTable";
|
||||
|
||||
const MemberSection = observer(() => {
|
||||
const MemberSection = () => {
|
||||
const t = useTranslate();
|
||||
const currentUser = useCurrentUser();
|
||||
const [users, setUsers] = useState<User[]>([]);
|
||||
const { data: users = [], refetch: refetchUsers } = useListUsers();
|
||||
const deleteUserMutation = useDeleteUser();
|
||||
const createDialog = useDialog();
|
||||
const editDialog = useDialog();
|
||||
const [editingUser, setEditingUser] = useState<User | undefined>();
|
||||
|
|
@ -30,15 +30,6 @@ const MemberSection = observer(() => {
|
|||
const [archiveTarget, setArchiveTarget] = useState<User | undefined>(undefined);
|
||||
const [deleteTarget, setDeleteTarget] = useState<User | undefined>(undefined);
|
||||
|
||||
useEffect(() => {
|
||||
fetchUsers();
|
||||
}, []);
|
||||
|
||||
const fetchUsers = async () => {
|
||||
const users = await userStore.fetchUsers();
|
||||
setUsers(users);
|
||||
};
|
||||
|
||||
const stringifyUserRole = (role: User_Role) => {
|
||||
if (role === User_Role.HOST) {
|
||||
return "Host";
|
||||
|
|
@ -75,7 +66,7 @@ const MemberSection = observer(() => {
|
|||
});
|
||||
setArchiveTarget(undefined);
|
||||
toast.success(t("setting.member-section.archive-success", { username }));
|
||||
await fetchUsers();
|
||||
await refetchUsers();
|
||||
};
|
||||
|
||||
const handleRestoreUserClick = async (user: User) => {
|
||||
|
|
@ -88,7 +79,7 @@ const MemberSection = observer(() => {
|
|||
updateMask: create(FieldMaskSchema, { paths: ["state"] }),
|
||||
});
|
||||
toast.success(t("setting.member-section.restore-success", { username }));
|
||||
await fetchUsers();
|
||||
await refetchUsers();
|
||||
};
|
||||
|
||||
const handleDeleteUserClick = async (user: User) => {
|
||||
|
|
@ -98,10 +89,9 @@ const MemberSection = observer(() => {
|
|||
const confirmDeleteUser = async () => {
|
||||
if (!deleteTarget) return;
|
||||
const { username, name } = deleteTarget;
|
||||
await userStore.deleteUser(name);
|
||||
deleteUserMutation.mutate(name);
|
||||
setDeleteTarget(undefined);
|
||||
toast.success(t("setting.member-section.delete-success", { username }));
|
||||
await fetchUsers();
|
||||
};
|
||||
|
||||
return (
|
||||
|
|
@ -180,10 +170,10 @@ const MemberSection = observer(() => {
|
|||
/>
|
||||
|
||||
{/* Create User Dialog */}
|
||||
<CreateUserDialog open={createDialog.isOpen} onOpenChange={createDialog.setOpen} onSuccess={fetchUsers} />
|
||||
<CreateUserDialog open={createDialog.isOpen} onOpenChange={createDialog.setOpen} onSuccess={refetchUsers} />
|
||||
|
||||
{/* Edit User Dialog */}
|
||||
<CreateUserDialog open={editDialog.isOpen} onOpenChange={editDialog.setOpen} user={editingUser} onSuccess={fetchUsers} />
|
||||
<CreateUserDialog open={editDialog.isOpen} onOpenChange={editDialog.setOpen} user={editingUser} onSuccess={refetchUsers} />
|
||||
|
||||
<ConfirmDialog
|
||||
open={!!archiveTarget}
|
||||
|
|
@ -208,6 +198,6 @@ const MemberSection = observer(() => {
|
|||
/>
|
||||
</SettingSection>
|
||||
);
|
||||
});
|
||||
};
|
||||
|
||||
export default MemberSection;
|
||||
|
|
|
|||
|
|
@ -1,15 +1,13 @@
|
|||
import { create } from "@bufbuild/protobuf";
|
||||
import { isEqual, uniq } from "lodash-es";
|
||||
import { CheckIcon, X } from "lucide-react";
|
||||
import { observer } from "mobx-react-lite";
|
||||
import { useState } from "react";
|
||||
import { toast } from "react-hot-toast";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Switch } from "@/components/ui/switch";
|
||||
import { instanceStore } from "@/store";
|
||||
import { buildInstanceSettingName } from "@/store/common";
|
||||
import { useInstance } from "@/contexts/InstanceContext";
|
||||
import {
|
||||
InstanceSetting_Key,
|
||||
InstanceSetting_MemoRelatedSetting,
|
||||
|
|
@ -21,9 +19,9 @@ import SettingGroup from "./SettingGroup";
|
|||
import SettingRow from "./SettingRow";
|
||||
import SettingSection from "./SettingSection";
|
||||
|
||||
const MemoRelatedSettings = observer(() => {
|
||||
const MemoRelatedSettings = () => {
|
||||
const t = useTranslate();
|
||||
const [originalSetting, setOriginalSetting] = useState<InstanceSetting_MemoRelatedSetting>(instanceStore.state.memoRelatedSetting);
|
||||
const { memoRelatedSetting: originalSetting, updateSetting, fetchSetting } = useInstance();
|
||||
const [memoRelatedSetting, setMemoRelatedSetting] = useState<InstanceSetting_MemoRelatedSetting>(originalSetting);
|
||||
const [editingReaction, setEditingReaction] = useState<string>("");
|
||||
const [editingNsfwTag, setEditingNsfwTag] = useState<string>("");
|
||||
|
|
@ -54,23 +52,23 @@ const MemoRelatedSettings = observer(() => {
|
|||
setEditingNsfwTag("");
|
||||
};
|
||||
|
||||
const updateSetting = async () => {
|
||||
const handleUpdateSetting = async () => {
|
||||
if (memoRelatedSetting.reactions.length === 0) {
|
||||
toast.error("Reactions must not be empty.");
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
await instanceStore.upsertInstanceSetting(
|
||||
await updateSetting(
|
||||
create(InstanceSettingSchema, {
|
||||
name: buildInstanceSettingName(InstanceSetting_Key.MEMO_RELATED),
|
||||
name: `instance/settings/${InstanceSetting_Key[InstanceSetting_Key.MEMO_RELATED]}`,
|
||||
value: {
|
||||
case: "memoRelatedSetting",
|
||||
value: memoRelatedSetting,
|
||||
},
|
||||
}),
|
||||
);
|
||||
setOriginalSetting(memoRelatedSetting);
|
||||
await fetchSetting(InstanceSetting_Key.MEMO_RELATED);
|
||||
toast.success(t("message.update-succeed"));
|
||||
} catch (error: any) {
|
||||
toast.error(error.message);
|
||||
|
|
@ -179,12 +177,12 @@ const MemoRelatedSettings = observer(() => {
|
|||
</SettingGroup>
|
||||
|
||||
<div className="w-full flex justify-end">
|
||||
<Button disabled={isEqual(memoRelatedSetting, originalSetting)} onClick={updateSetting}>
|
||||
<Button disabled={isEqual(memoRelatedSetting, originalSetting)} onClick={handleUpdateSetting}>
|
||||
{t("common.save")}
|
||||
</Button>
|
||||
</div>
|
||||
</SettingSection>
|
||||
);
|
||||
});
|
||||
};
|
||||
|
||||
export default MemoRelatedSettings;
|
||||
|
|
|
|||
|
|
@ -29,13 +29,13 @@ const MyAccountSection = () => {
|
|||
<SettingSection>
|
||||
<SettingGroup title={t("setting.account-section.title")}>
|
||||
<div className="w-full flex flex-row justify-start items-center gap-3">
|
||||
<UserAvatar className="shrink-0 w-12 h-12" avatarUrl={user.avatarUrl} />
|
||||
<UserAvatar className="shrink-0 w-12 h-12" avatarUrl={user?.avatarUrl} />
|
||||
<div className="flex-1 min-w-0 flex flex-col justify-center items-start gap-1">
|
||||
<div className="w-full">
|
||||
<span className="text-lg font-semibold">{user.displayName}</span>
|
||||
<span className="ml-2 text-sm text-muted-foreground">@{user.username}</span>
|
||||
<span className="text-lg font-semibold">{user?.displayName}</span>
|
||||
<span className="ml-2 text-sm text-muted-foreground">@{user?.username}</span>
|
||||
</div>
|
||||
{user.description && <p className="w-full text-sm text-muted-foreground truncate">{user.description}</p>}
|
||||
{user?.description && <p className="w-full text-sm text-muted-foreground truncate">{user?.description}</p>}
|
||||
</div>
|
||||
<div className="flex items-center gap-2 shrink-0">
|
||||
<Button variant="outline" size="sm" onClick={handleEditAccount}>
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
import { create } from "@bufbuild/protobuf";
|
||||
import { observer } from "mobx-react-lite";
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
||||
import { userStore } from "@/store";
|
||||
import { useAuth } from "@/contexts/AuthContext";
|
||||
import { useUpdateUserGeneralSetting } from "@/hooks/useUserQueries";
|
||||
import { Visibility } from "@/types/proto/api/v1/memo_service_pb";
|
||||
import { UserSetting_GeneralSetting, UserSetting_GeneralSettingSchema } from "@/types/proto/api/v1/user_service_pb";
|
||||
import { loadLocale, useTranslate } from "@/utils/i18n";
|
||||
|
|
@ -15,26 +15,48 @@ import SettingRow from "./SettingRow";
|
|||
import SettingSection from "./SettingSection";
|
||||
import WebhookSection from "./WebhookSection";
|
||||
|
||||
const PreferencesSection = observer(() => {
|
||||
const PreferencesSection = () => {
|
||||
const t = useTranslate();
|
||||
const generalSetting = userStore.state.userGeneralSetting;
|
||||
const { currentUser, userGeneralSetting: generalSetting, refetchSettings } = useAuth();
|
||||
const { mutate: updateUserGeneralSetting } = useUpdateUserGeneralSetting(currentUser?.name);
|
||||
|
||||
const handleLocaleSelectChange = async (locale: Locale) => {
|
||||
// Apply locale immediately for instant UI feedback and persist to localStorage
|
||||
loadLocale(locale);
|
||||
// Persist to user settings
|
||||
await userStore.updateUserGeneralSetting({ locale }, ["locale"]);
|
||||
updateUserGeneralSetting(
|
||||
{ generalSetting: { locale }, updateMask: ["locale"] },
|
||||
{
|
||||
onSuccess: () => {
|
||||
refetchSettings();
|
||||
},
|
||||
},
|
||||
);
|
||||
};
|
||||
|
||||
const handleDefaultMemoVisibilityChanged = async (value: string) => {
|
||||
await userStore.updateUserGeneralSetting({ memoVisibility: value }, ["memoVisibility"]);
|
||||
updateUserGeneralSetting(
|
||||
{ generalSetting: { memoVisibility: value }, updateMask: ["memoVisibility"] },
|
||||
{
|
||||
onSuccess: () => {
|
||||
refetchSettings();
|
||||
},
|
||||
},
|
||||
);
|
||||
};
|
||||
|
||||
const handleThemeChange = async (theme: string) => {
|
||||
// Apply theme immediately for instant UI feedback
|
||||
loadTheme(theme);
|
||||
// Persist to user settings
|
||||
await userStore.updateUserGeneralSetting({ theme }, ["theme"]);
|
||||
updateUserGeneralSetting(
|
||||
{ generalSetting: { theme }, updateMask: ["theme"] },
|
||||
{
|
||||
onSuccess: () => {
|
||||
refetchSettings();
|
||||
},
|
||||
},
|
||||
);
|
||||
};
|
||||
|
||||
// Provide default values if setting is not loaded yet
|
||||
|
|
@ -85,6 +107,6 @@ const PreferencesSection = observer(() => {
|
|||
</SettingGroup>
|
||||
</SettingSection>
|
||||
);
|
||||
});
|
||||
};
|
||||
|
||||
export default PreferencesSection;
|
||||
|
|
|
|||
|
|
@ -1,6 +1,5 @@
|
|||
import { create } from "@bufbuild/protobuf";
|
||||
import { isEqual } from "lodash-es";
|
||||
import { observer } from "mobx-react-lite";
|
||||
import React, { useEffect, useMemo, useState } from "react";
|
||||
import { toast } from "react-hot-toast";
|
||||
import { Button } from "@/components/ui/button";
|
||||
|
|
@ -8,8 +7,7 @@ import { Input } from "@/components/ui/input";
|
|||
import { Label } from "@/components/ui/label";
|
||||
import { RadioGroup, RadioGroupItem } from "@/components/ui/radio-group";
|
||||
import { Switch } from "@/components/ui/switch";
|
||||
import { instanceStore } from "@/store";
|
||||
import { buildInstanceSettingName } from "@/store/common";
|
||||
import { useInstance } from "@/contexts/InstanceContext";
|
||||
import {
|
||||
InstanceSetting_Key,
|
||||
InstanceSetting_StorageSetting,
|
||||
|
|
@ -24,41 +22,20 @@ import SettingGroup from "./SettingGroup";
|
|||
import SettingRow from "./SettingRow";
|
||||
import SettingSection from "./SettingSection";
|
||||
|
||||
// Helper to extract storage setting value from InstanceSetting oneof
|
||||
function getStorageSetting(setting: any): InstanceSetting_StorageSetting | undefined {
|
||||
if (setting?.value?.case === "storageSetting") {
|
||||
return setting.value.value;
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const StorageSection = observer(() => {
|
||||
const StorageSection = () => {
|
||||
const t = useTranslate();
|
||||
const [instanceStorageSetting, setInstanceStorageSetting] = useState<InstanceSetting_StorageSetting>(
|
||||
create(
|
||||
InstanceSetting_StorageSettingSchema,
|
||||
getStorageSetting(instanceStore.getInstanceSettingByKey(InstanceSetting_Key.STORAGE)) || {},
|
||||
),
|
||||
);
|
||||
const { storageSetting: originalSetting, updateSetting, fetchSetting } = useInstance();
|
||||
const [instanceStorageSetting, setInstanceStorageSetting] = useState<InstanceSetting_StorageSetting>(originalSetting);
|
||||
|
||||
useEffect(() => {
|
||||
setInstanceStorageSetting(
|
||||
create(
|
||||
InstanceSetting_StorageSettingSchema,
|
||||
getStorageSetting(instanceStore.getInstanceSettingByKey(InstanceSetting_Key.STORAGE)) || {},
|
||||
),
|
||||
);
|
||||
}, [instanceStore.getInstanceSettingByKey(InstanceSetting_Key.STORAGE)]);
|
||||
setInstanceStorageSetting(originalSetting);
|
||||
}, [originalSetting]);
|
||||
|
||||
const allowSaveStorageSetting = useMemo(() => {
|
||||
if (instanceStorageSetting.uploadSizeLimitMb <= 0) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const origin = create(
|
||||
InstanceSetting_StorageSettingSchema,
|
||||
getStorageSetting(instanceStore.getInstanceSettingByKey(InstanceSetting_Key.STORAGE)) || {},
|
||||
);
|
||||
if (instanceStorageSetting.storageType === InstanceSetting_StorageSetting_StorageType.LOCAL) {
|
||||
if (instanceStorageSetting.filepathTemplate.length === 0) {
|
||||
return false;
|
||||
|
|
@ -74,8 +51,8 @@ const StorageSection = observer(() => {
|
|||
return false;
|
||||
}
|
||||
}
|
||||
return !isEqual(origin, instanceStorageSetting);
|
||||
}, [instanceStorageSetting, instanceStore.state]);
|
||||
return !isEqual(originalSetting, instanceStorageSetting);
|
||||
}, [instanceStorageSetting, originalSetting]);
|
||||
|
||||
const handleMaxUploadSizeChanged = async (event: React.FocusEvent<HTMLInputElement>) => {
|
||||
let num = parseInt(event.target.value);
|
||||
|
|
@ -152,16 +129,22 @@ const StorageSection = observer(() => {
|
|||
};
|
||||
|
||||
const saveInstanceStorageSetting = async () => {
|
||||
await instanceStore.upsertInstanceSetting(
|
||||
create(InstanceSettingSchema, {
|
||||
name: buildInstanceSettingName(InstanceSetting_Key.STORAGE),
|
||||
value: {
|
||||
case: "storageSetting",
|
||||
value: instanceStorageSetting,
|
||||
},
|
||||
}),
|
||||
);
|
||||
toast.success("Updated");
|
||||
try {
|
||||
await updateSetting(
|
||||
create(InstanceSettingSchema, {
|
||||
name: `instance/settings/${InstanceSetting_Key[InstanceSetting_Key.STORAGE]}`,
|
||||
value: {
|
||||
case: "storageSetting",
|
||||
value: instanceStorageSetting,
|
||||
},
|
||||
}),
|
||||
);
|
||||
await fetchSetting(InstanceSetting_Key.STORAGE);
|
||||
toast.success("Updated");
|
||||
} catch (error: any) {
|
||||
toast.error(error.message);
|
||||
console.error(error);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
|
|
@ -253,6 +236,6 @@ const StorageSection = observer(() => {
|
|||
</div>
|
||||
</SettingSection>
|
||||
);
|
||||
});
|
||||
};
|
||||
|
||||
export default StorageSection;
|
||||
|
|
|
|||
|
|
@ -1,10 +1,9 @@
|
|||
import dayjs from "dayjs";
|
||||
import { ChevronLeftIcon, ChevronRightIcon } from "lucide-react";
|
||||
import { observer } from "mobx-react-lite";
|
||||
import i18n from "@/i18n";
|
||||
import type { MonthNavigatorProps } from "@/types/statistics";
|
||||
|
||||
export const MonthNavigator = observer(({ visibleMonth, onMonthChange }: MonthNavigatorProps) => {
|
||||
export const MonthNavigator = ({ visibleMonth, onMonthChange }: MonthNavigatorProps) => {
|
||||
const currentMonth = dayjs(visibleMonth).toDate();
|
||||
|
||||
const handlePrevMonth = () => {
|
||||
|
|
@ -30,4 +29,4 @@ export const MonthNavigator = observer(({ visibleMonth, onMonthChange }: MonthNa
|
|||
</div>
|
||||
</div>
|
||||
);
|
||||
});
|
||||
};
|
||||
|
|
|
|||
|
|
@ -1,5 +1,4 @@
|
|||
import dayjs from "dayjs";
|
||||
import { observer } from "mobx-react-lite";
|
||||
import { useMemo, useState } from "react";
|
||||
import { CompactMonthCalendar } from "@/components/ActivityCalendar";
|
||||
import { useDateFilterNavigation } from "@/hooks";
|
||||
|
|
@ -13,7 +12,7 @@ interface Props {
|
|||
statisticsData: StatisticsData;
|
||||
}
|
||||
|
||||
const StatisticsView = observer((props: Props) => {
|
||||
const StatisticsView = (props: Props) => {
|
||||
const { statisticsData } = props;
|
||||
const { activityStats } = statisticsData;
|
||||
const navigateToDateFilter = useDateFilterNavigation();
|
||||
|
|
@ -33,6 +32,6 @@ const StatisticsView = observer((props: Props) => {
|
|||
</div>
|
||||
</div>
|
||||
);
|
||||
});
|
||||
};
|
||||
|
||||
export default StatisticsView;
|
||||
|
|
|
|||
|
|
@ -1,8 +1,7 @@
|
|||
import { ChevronRightIcon, HashIcon } from "lucide-react";
|
||||
import { observer } from "mobx-react-lite";
|
||||
import { useEffect, useState } from "react";
|
||||
import useToggle from "react-use/lib/useToggle";
|
||||
import memoFilterStore, { MemoFilter } from "@/store/memoFilter";
|
||||
import { type MemoFilter, useMemoFilterContext } from "@/contexts/MemoFilterContext";
|
||||
|
||||
interface Tag {
|
||||
key: string;
|
||||
|
|
@ -86,9 +85,10 @@ interface TagItemContainerProps {
|
|||
expandSubTags: boolean;
|
||||
}
|
||||
|
||||
const TagItemContainer = observer((props: TagItemContainerProps) => {
|
||||
const TagItemContainer = (props: TagItemContainerProps) => {
|
||||
const { tag, expandSubTags } = props;
|
||||
const tagFilters = memoFilterStore.getFiltersByFactor("tagSearch");
|
||||
const { getFiltersByFactor, addFilter, removeFilter } = useMemoFilterContext();
|
||||
const tagFilters = getFiltersByFactor("tagSearch");
|
||||
const isActive = tagFilters.some((f: MemoFilter) => f.value === tag.text);
|
||||
const hasSubTags = tag.subTags.length > 0;
|
||||
const [showSubTags, toggleSubTags] = useToggle(false);
|
||||
|
|
@ -99,11 +99,11 @@ const TagItemContainer = observer((props: TagItemContainerProps) => {
|
|||
|
||||
const handleTagClick = () => {
|
||||
if (isActive) {
|
||||
memoFilterStore.removeFilter((f: MemoFilter) => f.factor === "tagSearch" && f.value === tag.text);
|
||||
removeFilter((f: MemoFilter) => f.factor === "tagSearch" && f.value === tag.text);
|
||||
} else {
|
||||
// Remove all existing tag filters first, then add the new one
|
||||
memoFilterStore.removeFilter((f: MemoFilter) => f.factor === "tagSearch");
|
||||
memoFilterStore.addFilter({
|
||||
removeFilter((f: MemoFilter) => f.factor === "tagSearch");
|
||||
addFilter({
|
||||
factor: "tagSearch",
|
||||
value: tag.text,
|
||||
});
|
||||
|
|
@ -155,6 +155,6 @@ const TagItemContainer = observer((props: TagItemContainerProps) => {
|
|||
) : null}
|
||||
</>
|
||||
);
|
||||
});
|
||||
};
|
||||
|
||||
export default TagTree;
|
||||
|
|
|
|||
|
|
@ -8,9 +8,10 @@ import { Dialog, DialogContent, DialogFooter, DialogHeader, DialogTitle } from "
|
|||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
import { useInstance } from "@/contexts/InstanceContext";
|
||||
import { convertFileToBase64 } from "@/helpers/utils";
|
||||
import useCurrentUser from "@/hooks/useCurrentUser";
|
||||
import { instanceStore, userStore } from "@/store";
|
||||
import { useUpdateUser } from "@/hooks/useUserQueries";
|
||||
import { User as UserPb, UserSchema } from "@/types/proto/api/v1/user_service_pb";
|
||||
import { useTranslate } from "@/utils/i18n";
|
||||
import UserAvatar from "./UserAvatar";
|
||||
|
|
@ -32,14 +33,15 @@ interface State {
|
|||
function UpdateAccountDialog({ open, onOpenChange, onSuccess }: Props) {
|
||||
const t = useTranslate();
|
||||
const currentUser = useCurrentUser();
|
||||
const { generalSetting: instanceGeneralSetting } = useInstance();
|
||||
const { mutateAsync: updateUser } = useUpdateUser();
|
||||
const [state, setState] = useState<State>({
|
||||
avatarUrl: currentUser.avatarUrl,
|
||||
username: currentUser.username,
|
||||
displayName: currentUser.displayName,
|
||||
email: currentUser.email,
|
||||
description: currentUser.description,
|
||||
avatarUrl: currentUser?.avatarUrl ?? "",
|
||||
username: currentUser?.username ?? "",
|
||||
displayName: currentUser?.displayName ?? "",
|
||||
email: currentUser?.email ?? "",
|
||||
description: currentUser?.description ?? "",
|
||||
});
|
||||
const instanceGeneralSetting = instanceStore.state.generalSetting;
|
||||
|
||||
const handleCloseBtnClick = () => {
|
||||
onOpenChange(false);
|
||||
|
|
@ -112,32 +114,32 @@ function UpdateAccountDialog({ open, onOpenChange, onSuccess }: Props) {
|
|||
|
||||
try {
|
||||
const updateMask = [];
|
||||
if (!isEqual(currentUser.username, state.username)) {
|
||||
if (!isEqual(currentUser?.username, state.username)) {
|
||||
updateMask.push("username");
|
||||
}
|
||||
if (!isEqual(currentUser.displayName, state.displayName)) {
|
||||
if (!isEqual(currentUser?.displayName, state.displayName)) {
|
||||
updateMask.push("display_name");
|
||||
}
|
||||
if (!isEqual(currentUser.email, state.email)) {
|
||||
if (!isEqual(currentUser?.email, state.email)) {
|
||||
updateMask.push("email");
|
||||
}
|
||||
if (!isEqual(currentUser.avatarUrl, state.avatarUrl)) {
|
||||
if (!isEqual(currentUser?.avatarUrl, state.avatarUrl)) {
|
||||
updateMask.push("avatar_url");
|
||||
}
|
||||
if (!isEqual(currentUser.description, state.description)) {
|
||||
if (!isEqual(currentUser?.description, state.description)) {
|
||||
updateMask.push("description");
|
||||
}
|
||||
await userStore.updateUser(
|
||||
create(UserSchema, {
|
||||
name: currentUser.name,
|
||||
await updateUser({
|
||||
user: {
|
||||
name: currentUser?.name,
|
||||
username: state.username,
|
||||
displayName: state.displayName,
|
||||
email: state.email,
|
||||
avatarUrl: state.avatarUrl,
|
||||
description: state.description,
|
||||
}),
|
||||
},
|
||||
updateMask,
|
||||
);
|
||||
});
|
||||
toast.success(t("message.update-succeed"));
|
||||
onSuccess?.();
|
||||
onOpenChange(false);
|
||||
|
|
|
|||
|
|
@ -6,8 +6,8 @@ import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, D
|
|||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
import { instanceStore } from "@/store";
|
||||
import { buildInstanceSettingName } from "@/store/common";
|
||||
import { useInstance } from "@/contexts/InstanceContext";
|
||||
import { buildInstanceSettingName } from "@/helpers/resource-names";
|
||||
import {
|
||||
InstanceSetting_GeneralSetting_CustomProfile,
|
||||
InstanceSetting_GeneralSetting_CustomProfileSchema,
|
||||
|
|
@ -24,7 +24,7 @@ interface Props {
|
|||
|
||||
function UpdateCustomizedProfileDialog({ open, onOpenChange, onSuccess }: Props) {
|
||||
const t = useTranslate();
|
||||
const instanceGeneralSetting = instanceStore.state.generalSetting;
|
||||
const { generalSetting: instanceGeneralSetting, updateSetting } = useInstance();
|
||||
const [customProfile, setCustomProfile] = useState<InstanceSetting_GeneralSetting_CustomProfile>(
|
||||
create(InstanceSetting_GeneralSetting_CustomProfileSchema, instanceGeneralSetting.customProfile || {}),
|
||||
);
|
||||
|
|
@ -76,7 +76,7 @@ function UpdateCustomizedProfileDialog({ open, onOpenChange, onSuccess }: Props)
|
|||
|
||||
setIsLoading(true);
|
||||
try {
|
||||
await instanceStore.upsertInstanceSetting(
|
||||
await updateSetting(
|
||||
create(InstanceSettingSchema, {
|
||||
name: buildInstanceSettingName(InstanceSetting_Key.GENERAL),
|
||||
value: {
|
||||
|
|
|
|||
|
|
@ -1,12 +1,14 @@
|
|||
import { create } from "@bufbuild/protobuf";
|
||||
import { FieldMaskSchema } from "@bufbuild/protobuf/wkt";
|
||||
import { ArchiveIcon, CheckIcon, GlobeIcon, LogOutIcon, PaletteIcon, SettingsIcon, SquareUserIcon, User2Icon } from "lucide-react";
|
||||
import { observer } from "mobx-react-lite";
|
||||
import { authServiceClient } from "@/connect";
|
||||
import { userServiceClient } from "@/connect";
|
||||
import { useAuth } from "@/contexts/AuthContext";
|
||||
import useCurrentUser from "@/hooks/useCurrentUser";
|
||||
import useNavigateTo from "@/hooks/useNavigateTo";
|
||||
import i18n, { locales } from "@/i18n";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { Routes } from "@/router";
|
||||
import { userStore } from "@/store";
|
||||
import { UserSetting_GeneralSettingSchema, UserSettingSchema } from "@/types/proto/api/v1/user_service_pb";
|
||||
import { getLocaleDisplayName, useTranslate } from "@/utils/i18n";
|
||||
import { loadTheme, THEME_OPTIONS } from "@/utils/theme";
|
||||
import UserAvatar from "./UserAvatar";
|
||||
|
|
@ -24,69 +26,107 @@ interface Props {
|
|||
collapsed?: boolean;
|
||||
}
|
||||
|
||||
const UserMenu = observer((props: Props) => {
|
||||
const UserMenu = (props: Props) => {
|
||||
const { collapsed } = props;
|
||||
const t = useTranslate();
|
||||
const navigateTo = useNavigateTo();
|
||||
const currentUser = useCurrentUser();
|
||||
const generalSetting = userStore.state.userGeneralSetting;
|
||||
const currentLocale = generalSetting?.locale || "en";
|
||||
const currentTheme = generalSetting?.theme || "default";
|
||||
const { userGeneralSetting, refetchSettings, logout } = useAuth();
|
||||
const currentLocale = userGeneralSetting?.locale || "en";
|
||||
const currentTheme = userGeneralSetting?.theme || "default";
|
||||
|
||||
const handleLocaleChange = async (locale: Locale) => {
|
||||
if (!currentUser) return;
|
||||
// Apply locale immediately for instant UI feedback
|
||||
i18n.changeLanguage(locale);
|
||||
// Persist to user settings
|
||||
await userStore.updateUserGeneralSetting({ locale }, ["locale"]);
|
||||
const settingName = `${currentUser.name}/setting`;
|
||||
const updatedGeneralSetting = create(UserSetting_GeneralSettingSchema, {
|
||||
locale,
|
||||
theme: userGeneralSetting?.theme,
|
||||
memoVisibility: userGeneralSetting?.memoVisibility,
|
||||
});
|
||||
await userServiceClient.updateUserSetting({
|
||||
setting: create(UserSettingSchema, {
|
||||
name: settingName,
|
||||
value: {
|
||||
case: "generalSetting",
|
||||
value: updatedGeneralSetting,
|
||||
},
|
||||
}),
|
||||
updateMask: create(FieldMaskSchema, { paths: ["general_setting.locale"] }),
|
||||
});
|
||||
await refetchSettings();
|
||||
};
|
||||
|
||||
const handleThemeChange = async (theme: string) => {
|
||||
if (!currentUser) return;
|
||||
// Apply theme immediately for instant UI feedback
|
||||
loadTheme(theme);
|
||||
// Persist to user settings
|
||||
await userStore.updateUserGeneralSetting({ theme }, ["theme"]);
|
||||
const settingName = `${currentUser.name}/setting`;
|
||||
const updatedGeneralSetting = create(UserSetting_GeneralSettingSchema, {
|
||||
locale: userGeneralSetting?.locale,
|
||||
theme,
|
||||
memoVisibility: userGeneralSetting?.memoVisibility,
|
||||
});
|
||||
await userServiceClient.updateUserSetting({
|
||||
setting: create(UserSettingSchema, {
|
||||
name: settingName,
|
||||
value: {
|
||||
case: "generalSetting",
|
||||
value: updatedGeneralSetting,
|
||||
},
|
||||
}),
|
||||
updateMask: create(FieldMaskSchema, { paths: ["general_setting.theme"] }),
|
||||
});
|
||||
await refetchSettings();
|
||||
};
|
||||
|
||||
const handleSignOut = async () => {
|
||||
await authServiceClient.signOut({});
|
||||
// First, clear auth state and cache BEFORE doing anything else
|
||||
await logout();
|
||||
|
||||
// Clear user-specific localStorage items (e.g., drafts)
|
||||
// Preserve app-wide settings like theme
|
||||
const keysToPreserve = ["memos-theme", "tag-view-as-tree", "tag-tree-auto-expand", "viewStore"];
|
||||
const keysToRemove: string[] = [];
|
||||
try {
|
||||
// Then clear user-specific localStorage items
|
||||
// Preserve app-wide settings like theme
|
||||
const keysToPreserve = ["memos-theme", "tag-view-as-tree", "tag-tree-auto-expand", "viewStore"];
|
||||
const keysToRemove: string[] = [];
|
||||
|
||||
for (let i = 0; i < localStorage.length; i++) {
|
||||
const key = localStorage.key(i);
|
||||
if (key && !keysToPreserve.includes(key)) {
|
||||
keysToRemove.push(key);
|
||||
for (let i = 0; i < localStorage.length; i++) {
|
||||
const key = localStorage.key(i);
|
||||
if (key && !keysToPreserve.includes(key)) {
|
||||
keysToRemove.push(key);
|
||||
}
|
||||
}
|
||||
|
||||
keysToRemove.forEach((key) => localStorage.removeItem(key));
|
||||
} catch {
|
||||
// Ignore errors from localStorage operations
|
||||
}
|
||||
|
||||
keysToRemove.forEach((key) => localStorage.removeItem(key));
|
||||
|
||||
// Use replace() instead of href to prevent back button from showing cached sensitive data
|
||||
// This removes the current page from browser history
|
||||
window.location.replace(Routes.AUTH);
|
||||
// Always redirect to auth page
|
||||
window.location.href = Routes.AUTH;
|
||||
};
|
||||
|
||||
return (
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild disabled={!currentUser}>
|
||||
<div className={cn("w-auto flex flex-row justify-start items-center cursor-pointer text-foreground", collapsed ? "px-1" : "px-3")}>
|
||||
{currentUser.avatarUrl ? (
|
||||
<UserAvatar className="shrink-0" avatarUrl={currentUser.avatarUrl} />
|
||||
{currentUser?.avatarUrl ? (
|
||||
<UserAvatar className="shrink-0" avatarUrl={currentUser?.avatarUrl} />
|
||||
) : (
|
||||
<User2Icon className="w-6 mx-auto h-auto text-muted-foreground" />
|
||||
)}
|
||||
{!collapsed && (
|
||||
<span className="ml-2 text-lg font-medium text-foreground grow truncate">
|
||||
{currentUser.displayName || currentUser.username}
|
||||
{currentUser?.displayName || currentUser?.username}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="start">
|
||||
<DropdownMenuItem onClick={() => navigateTo(`/u/${encodeURIComponent(currentUser.username)}`)}>
|
||||
<DropdownMenuItem onClick={() => navigateTo(`/u/${encodeURIComponent(currentUser?.username ?? "")}`)}>
|
||||
<SquareUserIcon className="size-4 text-muted-foreground" />
|
||||
{t("common.profile")}
|
||||
</DropdownMenuItem>
|
||||
|
|
@ -135,6 +175,6 @@ const UserMenu = observer((props: Props) => {
|
|||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
);
|
||||
});
|
||||
};
|
||||
|
||||
export default UserMenu;
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
import { LinkIcon, XIcon } from "lucide-react";
|
||||
import { Link } from "react-router-dom";
|
||||
import { extractMemoIdFromName } from "@/helpers/resource-names";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { extractMemoIdFromName } from "@/store/common";
|
||||
import { MemoRelation_Memo } from "@/types/proto/api/v1/memo_service_pb";
|
||||
import { DisplayMode } from "./types";
|
||||
|
||||
|
|
|
|||
|
|
@ -1,9 +1,8 @@
|
|||
import { create } from "@bufbuild/protobuf";
|
||||
import { LinkIcon, MilestoneIcon } from "lucide-react";
|
||||
import { observer } from "mobx-react-lite";
|
||||
import { useEffect, useState } from "react";
|
||||
import { memoServiceClient } from "@/connect";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { memoStore } from "@/store";
|
||||
import { Memo, MemoRelation, MemoRelation_MemoSchema, MemoRelation_Type } from "@/types/proto/api/v1/memo_service_pb";
|
||||
import { useTranslate } from "@/utils/i18n";
|
||||
import MetadataCard from "./MetadataCard";
|
||||
|
|
@ -17,7 +16,7 @@ interface RelationListProps extends BaseMetadataProps {
|
|||
parentPage?: string;
|
||||
}
|
||||
|
||||
const RelationList = observer(({ relations, currentMemoName, mode, onRelationsChange, parentPage, className }: RelationListProps) => {
|
||||
function RelationList({ relations, currentMemoName, mode, onRelationsChange, parentPage, className }: RelationListProps) {
|
||||
const t = useTranslate();
|
||||
const [referencingMemos, setReferencingMemos] = useState<Memo[]>([]);
|
||||
const [selectedTab, setSelectedTab] = useState<"referencing" | "referenced">("referencing");
|
||||
|
|
@ -43,7 +42,7 @@ const RelationList = observer(({ relations, currentMemoName, mode, onRelationsCh
|
|||
(async () => {
|
||||
if (referencingRelations.length > 0) {
|
||||
const requests = referencingRelations.map(async (relation) => {
|
||||
return await memoStore.getOrFetchMemoByName(relation.relatedMemo!.name, { skipStore: true });
|
||||
return await memoServiceClient.getMemo({ name: relation.relatedMemo!.name });
|
||||
});
|
||||
const list = await Promise.all(requests);
|
||||
setReferencingMemos(list);
|
||||
|
|
@ -139,6 +138,6 @@ const RelationList = observer(({ relations, currentMemoName, mode, onRelationsCh
|
|||
)}
|
||||
</MetadataCard>
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
export default RelationList;
|
||||
|
|
|
|||
|
|
@ -2,8 +2,8 @@ import { timestampDate } from "@bufbuild/protobuf/wkt";
|
|||
import { Code, ConnectError, createClient, type Interceptor } from "@connectrpc/connect";
|
||||
import { createConnectTransport } from "@connectrpc/connect-web";
|
||||
import { getAccessToken, setAccessToken } from "./auth-state";
|
||||
import { getInstanceConfig } from "./instance-config";
|
||||
import { ROUTES } from "./router/routes";
|
||||
import { instanceStore } from "./store";
|
||||
import { ActivityService } from "./types/proto/api/v1/activity_service_pb";
|
||||
import { AttachmentService } from "./types/proto/api/v1/attachment_service_pb";
|
||||
import { AuthService } from "./types/proto/api/v1/auth_service_pb";
|
||||
|
|
@ -37,30 +37,28 @@ const ROUTE_CONFIG = {
|
|||
// Token Refresh State Management
|
||||
// ============================================================================
|
||||
|
||||
class TokenRefreshManager {
|
||||
private isRefreshing = false;
|
||||
private refreshPromise: Promise<void> | null = null;
|
||||
const createTokenRefreshManager = () => {
|
||||
let isRefreshing = false;
|
||||
let refreshPromise: Promise<void> | null = null;
|
||||
|
||||
async refresh(refreshFn: () => Promise<void>): Promise<void> {
|
||||
if (this.isRefreshing && this.refreshPromise) {
|
||||
return this.refreshPromise;
|
||||
}
|
||||
return {
|
||||
async refresh(refreshFn: () => Promise<void>): Promise<void> {
|
||||
if (isRefreshing && refreshPromise) {
|
||||
return refreshPromise;
|
||||
}
|
||||
|
||||
this.isRefreshing = true;
|
||||
this.refreshPromise = refreshFn().finally(() => {
|
||||
this.isRefreshing = false;
|
||||
this.refreshPromise = null;
|
||||
});
|
||||
isRefreshing = true;
|
||||
refreshPromise = refreshFn().finally(() => {
|
||||
isRefreshing = false;
|
||||
refreshPromise = null;
|
||||
});
|
||||
|
||||
return this.refreshPromise;
|
||||
}
|
||||
return refreshPromise;
|
||||
},
|
||||
};
|
||||
};
|
||||
|
||||
isCurrentlyRefreshing(): boolean {
|
||||
return this.isRefreshing;
|
||||
}
|
||||
}
|
||||
|
||||
const tokenRefreshManager = new TokenRefreshManager();
|
||||
const tokenRefreshManager = createTokenRefreshManager();
|
||||
|
||||
// ============================================================================
|
||||
// Route Access Control
|
||||
|
|
@ -79,7 +77,7 @@ function getAuthFailureRedirect(currentPath: string): string | null {
|
|||
return null;
|
||||
}
|
||||
|
||||
if (instanceStore.state.memoRelatedSetting.disallowPublicVisibility) {
|
||||
if (getInstanceConfig().memoRelatedSetting.disallowPublicVisibility) {
|
||||
return ROUTES.AUTH;
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -0,0 +1,148 @@
|
|||
import { useQueryClient } from "@tanstack/react-query";
|
||||
import { createContext, type ReactNode, useCallback, useContext, useState } from "react";
|
||||
import { clearAccessToken } from "@/auth-state";
|
||||
import { authServiceClient, 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>;
|
||||
}
|
||||
|
||||
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 {
|
||||
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 () => {
|
||||
if (!state.currentUser) return;
|
||||
const settings = await fetchUserSettings(state.currentUser.name);
|
||||
setState((prev) => ({ ...prev, ...settings }));
|
||||
}, [state.currentUser, fetchUserSettings]);
|
||||
|
||||
return (
|
||||
<AuthContext.Provider
|
||||
value={{
|
||||
...state,
|
||||
initialize,
|
||||
logout,
|
||||
refetchSettings,
|
||||
}}
|
||||
>
|
||||
{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,151 @@
|
|||
import { create } from "@bufbuild/protobuf";
|
||||
import { createContext, type ReactNode, useCallback, useContext, useState } from "react";
|
||||
import { instanceServiceClient } from "@/connect";
|
||||
import { updateInstanceConfig } from "@/instance-config";
|
||||
import {
|
||||
InstanceProfile,
|
||||
InstanceProfileSchema,
|
||||
InstanceSetting,
|
||||
InstanceSetting_GeneralSetting,
|
||||
InstanceSetting_GeneralSettingSchema,
|
||||
InstanceSetting_Key,
|
||||
InstanceSetting_MemoRelatedSetting,
|
||||
InstanceSetting_MemoRelatedSettingSchema,
|
||||
InstanceSetting_StorageSetting,
|
||||
InstanceSetting_StorageSettingSchema,
|
||||
} 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;
|
||||
}
|
||||
|
||||
interface InstanceContextValue extends InstanceState {
|
||||
generalSetting: InstanceSetting_GeneralSetting;
|
||||
memoRelatedSetting: InstanceSetting_MemoRelatedSetting;
|
||||
storageSetting: InstanceSetting_StorageSetting;
|
||||
initialize: () => Promise<void>;
|
||||
fetchSetting: (key: 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,
|
||||
});
|
||||
|
||||
const getGeneralSetting = (): 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, {});
|
||||
};
|
||||
|
||||
const getMemoRelatedSetting = (): 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, {});
|
||||
};
|
||||
|
||||
const getStorageSetting = (): 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, {});
|
||||
};
|
||||
|
||||
const initialize = useCallback(async () => {
|
||||
setState((prev) => ({ ...prev, isLoading: true }));
|
||||
try {
|
||||
const profile = await instanceServiceClient.getInstanceProfile({});
|
||||
|
||||
const [generalSetting, memoRelatedSettingResponse] = await Promise.all([
|
||||
instanceServiceClient.getInstanceSetting({ name: buildInstanceSettingName(InstanceSetting_Key.GENERAL) }),
|
||||
instanceServiceClient.getInstanceSetting({ name: buildInstanceSettingName(InstanceSetting_Key.MEMO_RELATED) }),
|
||||
]);
|
||||
|
||||
// Update global config for non-React code (like connect.ts interceptors)
|
||||
if (memoRelatedSettingResponse.value.case === "memoRelatedSetting") {
|
||||
updateInstanceConfig({
|
||||
memoRelatedSetting: {
|
||||
disallowPublicVisibility: memoRelatedSettingResponse.value.value.disallowPublicVisibility,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
setState({
|
||||
profile,
|
||||
settings: [generalSetting, memoRelatedSettingResponse],
|
||||
isInitialized: true,
|
||||
isLoading: false,
|
||||
});
|
||||
} catch (error) {
|
||||
console.error("Failed to initialize instance:", error);
|
||||
setState((prev) => ({
|
||||
...prev,
|
||||
isInitialized: true,
|
||||
isLoading: false,
|
||||
}));
|
||||
}
|
||||
}, []);
|
||||
|
||||
const fetchSetting = useCallback(async (key: InstanceSetting_Key) => {
|
||||
const setting = await instanceServiceClient.getInstanceSetting({
|
||||
name: buildInstanceSettingName(key),
|
||||
});
|
||||
setState((prev) => ({
|
||||
...prev,
|
||||
settings: [...prev.settings.filter((s) => s.name !== setting.name), setting],
|
||||
}));
|
||||
}, []);
|
||||
|
||||
const updateSetting = useCallback(async (setting: InstanceSetting) => {
|
||||
await instanceServiceClient.updateInstanceSetting({ setting });
|
||||
setState((prev) => ({
|
||||
...prev,
|
||||
settings: [...prev.settings.filter((s) => s.name !== setting.name), setting],
|
||||
}));
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<InstanceContext.Provider
|
||||
value={{
|
||||
...state,
|
||||
generalSetting: getGeneralSetting(),
|
||||
memoRelatedSetting: getMemoRelatedSetting(),
|
||||
storageSetting: getStorageSetting(),
|
||||
initialize,
|
||||
fetchSetting,
|
||||
updateSetting,
|
||||
}}
|
||||
>
|
||||
{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,156 @@
|
|||
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);
|
||||
}, []);
|
||||
|
||||
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,79 @@
|
|||
import { createContext, type ReactNode, useContext, useState } from "react";
|
||||
|
||||
export type LayoutMode = "LIST" | "MASONRY";
|
||||
|
||||
interface ViewContextValue {
|
||||
orderByTimeAsc: boolean;
|
||||
layout: LayoutMode;
|
||||
toggleSortOrder: () => void;
|
||||
setLayout: (layout: LayoutMode) => void;
|
||||
}
|
||||
|
||||
const ViewContext = createContext<ViewContextValue | null>(null);
|
||||
|
||||
const LOCAL_STORAGE_KEY = "memos-view-setting";
|
||||
|
||||
export function ViewProvider({ children }: { children: ReactNode }) {
|
||||
// Load initial state from localStorage
|
||||
const getInitialState = () => {
|
||||
try {
|
||||
const cached = localStorage.getItem(LOCAL_STORAGE_KEY);
|
||||
if (cached) {
|
||||
const data = JSON.parse(cached);
|
||||
return {
|
||||
orderByTimeAsc: Boolean(data.orderByTimeAsc ?? false),
|
||||
layout: (["LIST", "MASONRY"].includes(data.layout) ? data.layout : "LIST") as LayoutMode,
|
||||
};
|
||||
}
|
||||
} catch (error) {
|
||||
console.warn("Failed to load view settings from localStorage:", error);
|
||||
}
|
||||
return { orderByTimeAsc: false, layout: "LIST" as LayoutMode };
|
||||
};
|
||||
|
||||
const [viewState, setViewState] = useState(getInitialState);
|
||||
|
||||
const persistToStorage = (newState: typeof 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 setLayout = (layout: LayoutMode) => {
|
||||
setViewState((prev) => {
|
||||
const newState = { ...prev, layout };
|
||||
persistToStorage(newState);
|
||||
return newState;
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<ViewContext.Provider
|
||||
value={{
|
||||
...viewState,
|
||||
toggleSortOrder,
|
||||
setLayout,
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
</ViewContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
export function useView() {
|
||||
const context = useContext(ViewContext);
|
||||
if (!context) {
|
||||
throw new Error("useView must be used within ViewProvider");
|
||||
}
|
||||
return context;
|
||||
}
|
||||
|
|
@ -0,0 +1,56 @@
|
|||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { attachmentServiceClient } from "@/connect";
|
||||
|
||||
// Query keys factory
|
||||
export const attachmentKeys = {
|
||||
all: ["attachments"] as const,
|
||||
lists: () => [...attachmentKeys.all, "list"] as const,
|
||||
list: (filters?: any) => [...attachmentKeys.lists(), filters] as const,
|
||||
details: () => [...attachmentKeys.all, "detail"] as const,
|
||||
detail: (name: string) => [...attachmentKeys.details(), name] as const,
|
||||
};
|
||||
|
||||
// Hook to fetch attachments
|
||||
export function useAttachments() {
|
||||
return useQuery({
|
||||
queryKey: attachmentKeys.lists(),
|
||||
queryFn: async () => {
|
||||
const { attachments } = await attachmentServiceClient.listAttachments({});
|
||||
return attachments;
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
// Hook to create/upload attachment
|
||||
export function useCreateAttachment() {
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
return useMutation({
|
||||
mutationFn: async (attachment: any) => {
|
||||
const result = await attachmentServiceClient.createAttachment({ attachment });
|
||||
return result;
|
||||
},
|
||||
onSuccess: () => {
|
||||
// Invalidate attachments list
|
||||
queryClient.invalidateQueries({ queryKey: attachmentKeys.lists() });
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
// Hook to delete attachment
|
||||
export function useDeleteAttachment() {
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
return useMutation({
|
||||
mutationFn: async (name: string) => {
|
||||
await attachmentServiceClient.deleteAttachment({ name });
|
||||
return name;
|
||||
},
|
||||
onSuccess: (name) => {
|
||||
// Remove from cache
|
||||
queryClient.removeQueries({ queryKey: attachmentKeys.detail(name) });
|
||||
// Invalidate lists
|
||||
queryClient.invalidateQueries({ queryKey: attachmentKeys.lists() });
|
||||
},
|
||||
});
|
||||
}
|
||||
|
|
@ -1,7 +1,8 @@
|
|||
import { userStore } from "@/store";
|
||||
import { useAuth } from "@/contexts/AuthContext";
|
||||
|
||||
const useCurrentUser = () => {
|
||||
return userStore.state.userMapByName[userStore.state.currentUser || ""];
|
||||
const { currentUser } = useAuth();
|
||||
return currentUser;
|
||||
};
|
||||
|
||||
export default useCurrentUser;
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
import { useCallback } from "react";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import { stringifyFilters } from "@/store/memoFilter";
|
||||
import { stringifyFilters } from "@/contexts/MemoFilterContext";
|
||||
|
||||
export const useDateFilterNavigation = () => {
|
||||
const navigate = useNavigate();
|
||||
|
|
|
|||
|
|
@ -1,8 +1,9 @@
|
|||
import { timestampDate } from "@bufbuild/protobuf/wkt";
|
||||
import dayjs from "dayjs";
|
||||
import { countBy } from "lodash-es";
|
||||
import { useEffect, useState } from "react";
|
||||
import { memoStore, userStore } from "@/store";
|
||||
import { useMemo } from "react";
|
||||
import { useMemos } from "@/hooks/useMemoQueries";
|
||||
import { useUserStats } from "@/hooks/useUserQueries";
|
||||
import type { StatisticsData } from "@/types/statistics";
|
||||
|
||||
export interface FilteredMemoStats {
|
||||
|
|
@ -11,100 +12,68 @@ export interface FilteredMemoStats {
|
|||
loading: boolean;
|
||||
}
|
||||
|
||||
const getUserStatsKey = (userName: string): string => {
|
||||
return `${userName}/stats`;
|
||||
};
|
||||
|
||||
export interface UseFilteredMemoStatsOptions {
|
||||
userName?: string;
|
||||
}
|
||||
|
||||
export const useFilteredMemoStats = (options: UseFilteredMemoStatsOptions = {}): FilteredMemoStats => {
|
||||
const { userName } = options;
|
||||
const [data, setData] = useState<FilteredMemoStats>({
|
||||
statistics: {
|
||||
activityStats: {},
|
||||
},
|
||||
tags: {},
|
||||
loading: false,
|
||||
});
|
||||
// React to memo store changes (create, update, delete)
|
||||
const memoStoreStateId = memoStore.state.stateId;
|
||||
// React to user stats changes (for tag counts)
|
||||
const userStatsStateId = userStore.state.statsStateId;
|
||||
|
||||
useEffect(() => {
|
||||
const computeStats = async () => {
|
||||
let activityStats: Record<string, number> = {};
|
||||
let tagCount: Record<string, number> = {};
|
||||
let useBackendStats = false;
|
||||
// Fetch user stats if userName is provided
|
||||
const { data: userStats, isLoading: isLoadingUserStats } = useUserStats(userName);
|
||||
|
||||
// Try to use backend user stats if userName is provided
|
||||
if (userName) {
|
||||
// Check if stats are already cached, otherwise fetch them
|
||||
const statsKey = getUserStatsKey(userName);
|
||||
let userStats = userStore.state.userStatsByName[statsKey];
|
||||
// Fetch memos for fallback computation (or when userName is not provided)
|
||||
const { data: memosResponse, isLoading: isLoadingMemos } = useMemos({});
|
||||
|
||||
if (!userStats) {
|
||||
try {
|
||||
await userStore.fetchUserStats(userName);
|
||||
userStats = userStore.state.userStatsByName[statsKey];
|
||||
} catch (error) {
|
||||
console.error("Failed to fetch user stats:", error);
|
||||
// Will fall back to computing from cache below
|
||||
}
|
||||
}
|
||||
const data = useMemo(() => {
|
||||
const loading = isLoadingUserStats || isLoadingMemos;
|
||||
let activityStats: Record<string, number> = {};
|
||||
let tagCount: Record<string, number> = {};
|
||||
|
||||
if (userStats) {
|
||||
// Use activity timestamps from user stats
|
||||
if (userStats.memoDisplayTimestamps && userStats.memoDisplayTimestamps.length > 0) {
|
||||
activityStats = countBy(
|
||||
userStats.memoDisplayTimestamps
|
||||
.map((ts) => (ts ? timestampDate(ts) : undefined))
|
||||
.filter((date): date is Date => date !== undefined)
|
||||
.map((date) => dayjs(date).format("YYYY-MM-DD")),
|
||||
);
|
||||
}
|
||||
// Use tag counts from user stats
|
||||
if (userStats.tagCount) {
|
||||
tagCount = userStats.tagCount;
|
||||
}
|
||||
useBackendStats = true;
|
||||
}
|
||||
// Try to use backend user stats if userName is provided and available
|
||||
if (userName && userStats) {
|
||||
// Use activity timestamps from user stats
|
||||
if (userStats.memoDisplayTimestamps && userStats.memoDisplayTimestamps.length > 0) {
|
||||
activityStats = countBy(
|
||||
userStats.memoDisplayTimestamps
|
||||
.map((ts) => (ts ? timestampDate(ts) : undefined))
|
||||
.filter((date): date is Date => date !== undefined)
|
||||
.map((date) => dayjs(date).format("YYYY-MM-DD")),
|
||||
);
|
||||
}
|
||||
|
||||
// Fallback: compute from cached memos if backend stats not available
|
||||
// Use tag counts from user stats
|
||||
if (userStats.tagCount) {
|
||||
tagCount = userStats.tagCount;
|
||||
}
|
||||
} else if (memosResponse?.memos) {
|
||||
// Fallback: compute from memos if backend stats not available
|
||||
// Also used for Explore and Archived contexts
|
||||
if (!useBackendStats) {
|
||||
const displayTimeList: Date[] = [];
|
||||
const memos = memoStore.state.memos;
|
||||
const displayTimeList: Date[] = [];
|
||||
const memos = memosResponse.memos;
|
||||
|
||||
for (const memo of memos) {
|
||||
// Collect display timestamps for activity calendar
|
||||
const displayTime = memo.displayTime ? timestampDate(memo.displayTime) : undefined;
|
||||
if (displayTime) {
|
||||
displayTimeList.push(displayTime);
|
||||
}
|
||||
// Count tags
|
||||
if (memo.tags && memo.tags.length > 0) {
|
||||
for (const tag of memo.tags) {
|
||||
tagCount[tag] = (tagCount[tag] || 0) + 1;
|
||||
}
|
||||
for (const memo of memos) {
|
||||
// Collect display timestamps for activity calendar
|
||||
const displayTime = memo.displayTime ? timestampDate(memo.displayTime) : undefined;
|
||||
if (displayTime) {
|
||||
displayTimeList.push(displayTime);
|
||||
}
|
||||
// Count tags
|
||||
if (memo.tags && memo.tags.length > 0) {
|
||||
for (const tag of memo.tags) {
|
||||
tagCount[tag] = (tagCount[tag] || 0) + 1;
|
||||
}
|
||||
}
|
||||
|
||||
activityStats = countBy(displayTimeList.map((date) => dayjs(date).format("YYYY-MM-DD")));
|
||||
}
|
||||
|
||||
setData({
|
||||
statistics: { activityStats },
|
||||
tags: tagCount,
|
||||
loading: false,
|
||||
});
|
||||
};
|
||||
activityStats = countBy(displayTimeList.map((date) => dayjs(date).format("YYYY-MM-DD")));
|
||||
}
|
||||
|
||||
computeStats();
|
||||
}, [memoStoreStateId, userStatsStateId, userName]);
|
||||
return {
|
||||
statistics: { activityStats },
|
||||
tags: tagCount,
|
||||
loading,
|
||||
};
|
||||
}, [userName, userStats, memosResponse, isLoadingUserStats, isLoadingMemos]);
|
||||
|
||||
return data;
|
||||
};
|
||||
|
|
|
|||
|
|
@ -0,0 +1,80 @@
|
|||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { instanceServiceClient } from "@/connect";
|
||||
import { InstanceSetting, InstanceSetting_Key } from "@/types/proto/api/v1/instance_service_pb";
|
||||
|
||||
// Query keys factory
|
||||
export const instanceKeys = {
|
||||
all: ["instance"] as const,
|
||||
profile: () => [...instanceKeys.all, "profile"] as const,
|
||||
settings: () => [...instanceKeys.all, "settings"] as const,
|
||||
setting: (key: InstanceSetting_Key) => [...instanceKeys.settings(), key] as const,
|
||||
};
|
||||
|
||||
// Build setting name from key
|
||||
const buildInstanceSettingName = (key: InstanceSetting_Key): string => {
|
||||
const keyName = InstanceSetting_Key[key];
|
||||
return `instance/settings/${keyName}`;
|
||||
};
|
||||
|
||||
// Hook to fetch instance profile
|
||||
export function useInstanceProfile() {
|
||||
return useQuery({
|
||||
queryKey: instanceKeys.profile(),
|
||||
queryFn: async () => {
|
||||
const profile = await instanceServiceClient.getInstanceProfile({});
|
||||
return profile;
|
||||
},
|
||||
staleTime: 1000 * 60 * 10, // 10 minutes - instance profile rarely changes
|
||||
});
|
||||
}
|
||||
|
||||
// Hook to fetch a specific instance setting
|
||||
export function useInstanceSetting(key: InstanceSetting_Key) {
|
||||
return useQuery({
|
||||
queryKey: instanceKeys.setting(key),
|
||||
queryFn: async () => {
|
||||
const setting = await instanceServiceClient.getInstanceSetting({
|
||||
name: buildInstanceSettingName(key),
|
||||
});
|
||||
return setting;
|
||||
},
|
||||
staleTime: 1000 * 60 * 5, // 5 minutes
|
||||
});
|
||||
}
|
||||
|
||||
// Hook to update instance setting
|
||||
export function useUpdateInstanceSetting() {
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
return useMutation({
|
||||
mutationFn: async (setting: InstanceSetting) => {
|
||||
await instanceServiceClient.updateInstanceSetting({ setting });
|
||||
return setting;
|
||||
},
|
||||
onSuccess: (setting) => {
|
||||
// Extract key from setting name and invalidate
|
||||
const keyMatch = setting.name.match(/instance\/settings\/(\w+)/);
|
||||
if (keyMatch) {
|
||||
const keyName = keyMatch[1] as keyof typeof InstanceSetting_Key;
|
||||
const key = InstanceSetting_Key[keyName];
|
||||
if (key !== undefined) {
|
||||
queryClient.setQueryData(instanceKeys.setting(key), setting);
|
||||
}
|
||||
}
|
||||
queryClient.invalidateQueries({ queryKey: instanceKeys.settings() });
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
// Derived hooks for common settings
|
||||
export function useGeneralSetting() {
|
||||
const { data: setting, ...rest } = useInstanceSetting(InstanceSetting_Key.GENERAL);
|
||||
const generalSetting = setting?.value.case === "generalSetting" ? setting.value.value : undefined;
|
||||
return { data: generalSetting, ...rest };
|
||||
}
|
||||
|
||||
export function useMemoRelatedSetting() {
|
||||
const { data: setting, ...rest } = useInstanceSetting(InstanceSetting_Key.MEMO_RELATED);
|
||||
const memoRelatedSetting = setting?.value.case === "memoRelatedSetting" ? setting.value.value : undefined;
|
||||
return { data: memoRelatedSetting, ...rest };
|
||||
}
|
||||
|
|
@ -1,10 +1,27 @@
|
|||
import { useMemo } from "react";
|
||||
import { instanceStore, userStore } from "@/store";
|
||||
import { extractUserIdFromName, getVisibilityName } from "@/store/common";
|
||||
import memoFilterStore from "@/store/memoFilter";
|
||||
import { InstanceSetting_Key } from "@/types/proto/api/v1/instance_service_pb";
|
||||
import { useAuth } from "@/contexts/AuthContext";
|
||||
import { useInstance } from "@/contexts/InstanceContext";
|
||||
import { useMemoFilterContext } from "@/contexts/MemoFilterContext";
|
||||
import { Visibility } from "@/types/proto/api/v1/memo_service_pb";
|
||||
|
||||
const extractUserIdFromName = (name: string): string => {
|
||||
const match = name.match(/users\/(\d+)/);
|
||||
return match ? match[1] : "";
|
||||
};
|
||||
|
||||
const getVisibilityName = (visibility: Visibility): string => {
|
||||
switch (visibility) {
|
||||
case Visibility.PUBLIC:
|
||||
return "PUBLIC";
|
||||
case Visibility.PROTECTED:
|
||||
return "PROTECTED";
|
||||
case Visibility.PRIVATE:
|
||||
return "PRIVATE";
|
||||
default:
|
||||
return "PRIVATE";
|
||||
}
|
||||
};
|
||||
|
||||
const getShortcutId = (name: string): string => {
|
||||
const parts = name.split("/");
|
||||
return parts.length === 4 ? parts[3] : "";
|
||||
|
|
@ -20,10 +37,9 @@ export interface UseMemoFiltersOptions {
|
|||
export const useMemoFilters = (options: UseMemoFiltersOptions = {}): string | undefined => {
|
||||
const { creatorName, includeShortcuts = false, includePinned = false, visibilities } = options;
|
||||
|
||||
// Extract MobX observable values to avoid issues with React dependency tracking
|
||||
const currentShortcut = memoFilterStore.shortcut;
|
||||
const shortcuts = userStore.state.shortcuts;
|
||||
const filters = memoFilterStore.filters;
|
||||
const { shortcuts } = useAuth();
|
||||
const { filters, shortcut: currentShortcut } = useMemoFilterContext();
|
||||
const { memoRelatedSetting } = useInstance();
|
||||
|
||||
// Get selected shortcut if needed
|
||||
const selectedShortcut = useMemo(() => {
|
||||
|
|
@ -31,7 +47,7 @@ export const useMemoFilters = (options: UseMemoFiltersOptions = {}): string | un
|
|||
return shortcuts.find((shortcut) => getShortcutId(shortcut.name) === currentShortcut);
|
||||
}, [includeShortcuts, currentShortcut, shortcuts]);
|
||||
|
||||
// Build filter - wrapped in useMemo but also using observer for reactivity
|
||||
// Build filter
|
||||
return useMemo(() => {
|
||||
const conditions: string[] = [];
|
||||
|
||||
|
|
@ -45,7 +61,7 @@ export const useMemoFilters = (options: UseMemoFiltersOptions = {}): string | un
|
|||
conditions.push(selectedShortcut.filter);
|
||||
}
|
||||
|
||||
// Add active filters from memoFilterStore
|
||||
// Add active filters from context
|
||||
for (const filter of filters) {
|
||||
if (filter.factor === "contentSearch") {
|
||||
conditions.push(`content.contains("${filter.value}")`);
|
||||
|
|
@ -55,7 +71,6 @@ export const useMemoFilters = (options: UseMemoFiltersOptions = {}): string | un
|
|||
if (includePinned) {
|
||||
conditions.push(`pinned`);
|
||||
}
|
||||
// Skip pinned filter if not enabled
|
||||
} else if (filter.factor === "property.hasLink") {
|
||||
conditions.push(`has_link`);
|
||||
} else if (filter.factor === "property.hasTaskList") {
|
||||
|
|
@ -63,12 +78,9 @@ export const useMemoFilters = (options: UseMemoFiltersOptions = {}): string | un
|
|||
} else if (filter.factor === "property.hasCode") {
|
||||
conditions.push(`has_code`);
|
||||
} else if (filter.factor === "displayTime") {
|
||||
// Check instance setting for display time factor
|
||||
const setting = instanceStore.getInstanceSettingByKey(InstanceSetting_Key.MEMO_RELATED);
|
||||
const displayWithUpdateTime = setting?.value.case === "memoRelatedSetting" ? setting.value.value.displayWithUpdateTime : false;
|
||||
const displayWithUpdateTime = memoRelatedSetting?.displayWithUpdateTime ?? false;
|
||||
const factor = displayWithUpdateTime ? "updated_ts" : "created_ts";
|
||||
|
||||
// Convert date to UTC timestamp range
|
||||
const filterDate = new Date(filter.value);
|
||||
const filterUtcTimestamp = filterDate.getTime() + filterDate.getTimezoneOffset() * 60 * 1000;
|
||||
const timestampAfter = filterUtcTimestamp / 1000;
|
||||
|
|
@ -77,15 +89,12 @@ export const useMemoFilters = (options: UseMemoFiltersOptions = {}): string | un
|
|||
}
|
||||
}
|
||||
|
||||
// Add visibility filter if specified (for Explore page)
|
||||
// Add visibility filter if specified
|
||||
if (visibilities && visibilities.length > 0) {
|
||||
// Build visibility filter based on allowed visibility levels
|
||||
// Format: visibility in ["PUBLIC", "PROTECTED"]
|
||||
// Convert enum values to string names (e.g., 3 -> "PUBLIC", 2 -> "PROTECTED")
|
||||
const visibilityValues = visibilities.map((v) => `"${getVisibilityName(v)}"`).join(", ");
|
||||
conditions.push(`visibility in [${visibilityValues}]`);
|
||||
}
|
||||
|
||||
return conditions.length > 0 ? conditions.join(" && ") : undefined;
|
||||
}, [creatorName, includeShortcuts, includePinned, visibilities, selectedShortcut, filters]);
|
||||
}, [creatorName, includeShortcuts, includePinned, visibilities, selectedShortcut, filters, memoRelatedSetting]);
|
||||
};
|
||||
|
|
|
|||
|
|
@ -0,0 +1,168 @@
|
|||
import { create } from "@bufbuild/protobuf";
|
||||
import { FieldMaskSchema } from "@bufbuild/protobuf/wkt";
|
||||
import { useInfiniteQuery, useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { memoServiceClient } from "@/connect";
|
||||
import type { ListMemosRequest, Memo } from "@/types/proto/api/v1/memo_service_pb";
|
||||
import { ListMemosRequestSchema, MemoSchema } from "@/types/proto/api/v1/memo_service_pb";
|
||||
|
||||
// Query keys factory for consistent cache management
|
||||
export const memoKeys = {
|
||||
all: ["memos"] as const,
|
||||
lists: () => [...memoKeys.all, "list"] as const,
|
||||
list: (filters: Partial<ListMemosRequest>) => [...memoKeys.lists(), filters] as const,
|
||||
details: () => [...memoKeys.all, "detail"] as const,
|
||||
detail: (name: string) => [...memoKeys.details(), name] as const,
|
||||
};
|
||||
|
||||
/**
|
||||
* Hook to fetch a list of memos with filtering and sorting.
|
||||
* @param request - Request parameters (state, orderBy, filter, pageSize)
|
||||
*/
|
||||
export function useMemos(request: Partial<ListMemosRequest> = {}) {
|
||||
return useQuery({
|
||||
queryKey: memoKeys.list(request),
|
||||
queryFn: async () => {
|
||||
const response = await memoServiceClient.listMemos(create(ListMemosRequestSchema, request as Record<string, unknown>));
|
||||
return response;
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Hook for infinite scrolling/pagination of memos.
|
||||
* Automatically fetches pages as the user scrolls.
|
||||
*
|
||||
* @param request - Partial request configuration (state, orderBy, filter, pageSize)
|
||||
* @returns React Query infinite query result with pages of memos
|
||||
*/
|
||||
export function useInfiniteMemos(request: Partial<ListMemosRequest> = {}) {
|
||||
return useInfiniteQuery({
|
||||
queryKey: memoKeys.list(request),
|
||||
queryFn: async ({ pageParam }) => {
|
||||
const response = await memoServiceClient.listMemos(
|
||||
create(ListMemosRequestSchema, {
|
||||
...request,
|
||||
pageToken: pageParam || "",
|
||||
} as Record<string, unknown>),
|
||||
);
|
||||
return response;
|
||||
},
|
||||
initialPageParam: "",
|
||||
getNextPageParam: (lastPage) => lastPage.nextPageToken || undefined,
|
||||
staleTime: 1000 * 60, // Consider data fresh for 1 minute
|
||||
gcTime: 1000 * 60 * 5, // Keep unused data in cache for 5 minutes
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Hook to fetch a single memo by its resource name.
|
||||
* @param name - Memo resource name (e.g., "memos/123")
|
||||
* @param options - Query options including enabled flag
|
||||
*/
|
||||
export function useMemo(name: string, options?: { enabled?: boolean }) {
|
||||
return useQuery({
|
||||
queryKey: memoKeys.detail(name),
|
||||
queryFn: async () => {
|
||||
const memo = await memoServiceClient.getMemo({ name });
|
||||
return memo;
|
||||
},
|
||||
enabled: options?.enabled ?? true,
|
||||
staleTime: 1000 * 60, // 1 minute - memos can be edited frequently
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Hook to create a new memo.
|
||||
* Automatically invalidates memo lists and user stats on success.
|
||||
*/
|
||||
export function useCreateMemo() {
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
return useMutation({
|
||||
mutationFn: async (memoToCreate: Memo) => {
|
||||
const memo = await memoServiceClient.createMemo({ memo: memoToCreate });
|
||||
return memo;
|
||||
},
|
||||
onSuccess: (newMemo) => {
|
||||
// Invalidate memo lists to refetch
|
||||
queryClient.invalidateQueries({ queryKey: memoKeys.lists() });
|
||||
// Add new memo to cache
|
||||
queryClient.setQueryData(memoKeys.detail(newMemo.name), newMemo);
|
||||
// Invalidate user stats
|
||||
queryClient.invalidateQueries({ queryKey: ["users", "stats"] });
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Hook to update an existing memo with optimistic updates.
|
||||
* Implements rollback on error for better UX.
|
||||
*/
|
||||
export function useUpdateMemo() {
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
return useMutation({
|
||||
mutationFn: async ({ update, updateMask }: { update: Partial<Memo>; updateMask: string[] }) => {
|
||||
const memo = await memoServiceClient.updateMemo({
|
||||
memo: create(MemoSchema, update as Record<string, unknown>),
|
||||
updateMask: create(FieldMaskSchema, { paths: updateMask }),
|
||||
});
|
||||
return memo;
|
||||
},
|
||||
onMutate: async ({ update }) => {
|
||||
if (!update.name) {
|
||||
return { previousMemo: undefined };
|
||||
}
|
||||
|
||||
// Cancel outgoing refetches to prevent race conditions
|
||||
await queryClient.cancelQueries({ queryKey: memoKeys.detail(update.name) });
|
||||
|
||||
// Snapshot previous value for rollback on error
|
||||
const previousMemo = queryClient.getQueryData<Memo>(memoKeys.detail(update.name));
|
||||
|
||||
// Optimistically update the cache
|
||||
if (previousMemo) {
|
||||
queryClient.setQueryData(memoKeys.detail(update.name), { ...previousMemo, ...update });
|
||||
}
|
||||
|
||||
return { previousMemo };
|
||||
},
|
||||
onError: (_err, { update }, context) => {
|
||||
// Rollback on error
|
||||
if (context?.previousMemo && update.name) {
|
||||
queryClient.setQueryData(memoKeys.detail(update.name), context.previousMemo);
|
||||
}
|
||||
},
|
||||
onSuccess: (updatedMemo) => {
|
||||
// Update cache with server response
|
||||
queryClient.setQueryData(memoKeys.detail(updatedMemo.name), updatedMemo);
|
||||
// Invalidate lists to refresh
|
||||
queryClient.invalidateQueries({ queryKey: memoKeys.lists() });
|
||||
// Invalidate user stats
|
||||
queryClient.invalidateQueries({ queryKey: ["users", "stats"] });
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Hook to delete a memo.
|
||||
* Automatically removes memo from cache and invalidates lists on success.
|
||||
*/
|
||||
export function useDeleteMemo() {
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
return useMutation({
|
||||
mutationFn: async (name: string) => {
|
||||
await memoServiceClient.deleteMemo({ name });
|
||||
return name;
|
||||
},
|
||||
onSuccess: (name) => {
|
||||
// Remove from cache
|
||||
queryClient.removeQueries({ queryKey: memoKeys.detail(name) });
|
||||
// Invalidate lists
|
||||
queryClient.invalidateQueries({ queryKey: memoKeys.lists() });
|
||||
// Invalidate user stats
|
||||
queryClient.invalidateQueries({ queryKey: ["users", "stats"] });
|
||||
},
|
||||
});
|
||||
}
|
||||
|
|
@ -1,7 +1,7 @@
|
|||
import { timestampDate } from "@bufbuild/protobuf/wkt";
|
||||
import dayjs from "dayjs";
|
||||
import { useMemo } from "react";
|
||||
import { viewStore } from "@/store";
|
||||
import { useView } from "@/contexts/ViewContext";
|
||||
import { State } from "@/types/proto/api/v1/common_pb";
|
||||
import { Memo } from "@/types/proto/api/v1/memo_service_pb";
|
||||
|
||||
|
|
@ -17,9 +17,7 @@ export interface UseMemoSortingResult {
|
|||
|
||||
export const useMemoSorting = (options: UseMemoSortingOptions = {}): UseMemoSortingResult => {
|
||||
const { pinnedFirst = false, state = State.NORMAL } = options;
|
||||
|
||||
// Extract MobX observable values to avoid issues with React dependency tracking
|
||||
const orderByTimeAsc = viewStore.state.orderByTimeAsc;
|
||||
const { orderByTimeAsc } = useView();
|
||||
|
||||
// Generate orderBy string for API
|
||||
const orderBy = useMemo(() => {
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
import { useEffect } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { userStore } from "@/store";
|
||||
import { useAuth } from "@/contexts/AuthContext";
|
||||
import { getLocaleWithFallback, loadLocale } from "@/utils/i18n";
|
||||
|
||||
/**
|
||||
|
|
@ -9,7 +9,7 @@ import { getLocaleWithFallback, loadLocale } from "@/utils/i18n";
|
|||
*/
|
||||
export const useUserLocale = () => {
|
||||
const { i18n } = useTranslation();
|
||||
const userGeneralSetting = userStore.state.userGeneralSetting;
|
||||
const { userGeneralSetting } = useAuth();
|
||||
|
||||
// Apply locale when user setting changes or user logs in
|
||||
useEffect(() => {
|
||||
|
|
|
|||
|
|
@ -0,0 +1,257 @@
|
|||
import { create } from "@bufbuild/protobuf";
|
||||
import { FieldMaskSchema } from "@bufbuild/protobuf/wkt";
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { authServiceClient, shortcutServiceClient, userServiceClient } from "@/connect";
|
||||
import { buildUserSettingName } from "@/helpers/resource-names";
|
||||
import { User, UserSetting, UserSetting_GeneralSetting, UserSetting_Key, UserSettingSchema } from "@/types/proto/api/v1/user_service_pb";
|
||||
|
||||
// Query keys factory
|
||||
export const userKeys = {
|
||||
all: ["users"] as const,
|
||||
details: () => [...userKeys.all, "detail"] as const,
|
||||
detail: (name: string) => [...userKeys.details(), name] as const,
|
||||
stats: () => [...userKeys.all, "stats"] as const,
|
||||
userStats: (name: string) => [...userKeys.stats(), name] as const,
|
||||
currentUser: () => [...userKeys.all, "current"] as const,
|
||||
shortcuts: () => [...userKeys.all, "shortcuts"] as const,
|
||||
notifications: () => [...userKeys.all, "notifications"] as const,
|
||||
};
|
||||
|
||||
/**
|
||||
* Hook to get the current authenticated user.
|
||||
* Data is cached for 5 minutes as auth state changes infrequently.
|
||||
*/
|
||||
export function useCurrentUser() {
|
||||
return useQuery({
|
||||
queryKey: userKeys.currentUser(),
|
||||
queryFn: async () => {
|
||||
const { user } = await authServiceClient.getCurrentUser({});
|
||||
return user;
|
||||
},
|
||||
staleTime: 1000 * 60 * 5, // 5 minutes - auth doesn't change often
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Hook to fetch a specific user by name.
|
||||
* @param name - User resource name (e.g., "users/123")
|
||||
* @param options - Query options including enabled flag
|
||||
*/
|
||||
export function useUser(name: string, options?: { enabled?: boolean }) {
|
||||
return useQuery({
|
||||
queryKey: userKeys.detail(name),
|
||||
queryFn: async () => {
|
||||
const user = await userServiceClient.getUser({ name });
|
||||
return user;
|
||||
},
|
||||
enabled: options?.enabled ?? true,
|
||||
staleTime: 1000 * 60 * 5, // 5 minutes - user profiles don't change often
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Hook to fetch statistics for a specific user.
|
||||
* @param username - User resource name (e.g., "users/123")
|
||||
*/
|
||||
export function useUserStats(username?: string) {
|
||||
return useQuery({
|
||||
queryKey: username ? userKeys.userStats(username) : userKeys.stats(),
|
||||
queryFn: async () => {
|
||||
if (!username) {
|
||||
throw new Error("Username is required");
|
||||
}
|
||||
const stats = await userServiceClient.getUserStats({ name: username });
|
||||
return stats;
|
||||
},
|
||||
enabled: !!username,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Hook to fetch shortcuts for the current user.
|
||||
*/
|
||||
export function useShortcuts() {
|
||||
return useQuery({
|
||||
queryKey: userKeys.shortcuts(),
|
||||
queryFn: async () => {
|
||||
const { shortcuts } = await shortcutServiceClient.listShortcuts({});
|
||||
return shortcuts;
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Hook to fetch notifications for the current user.
|
||||
* Only fetches when a user is authenticated.
|
||||
*/
|
||||
export function useNotifications() {
|
||||
const { data: currentUser } = useCurrentUser();
|
||||
|
||||
return useQuery({
|
||||
queryKey: userKeys.notifications(),
|
||||
queryFn: async () => {
|
||||
if (!currentUser?.name) {
|
||||
return [];
|
||||
}
|
||||
const { notifications } = await userServiceClient.listUserNotifications({ parent: currentUser.name });
|
||||
return notifications;
|
||||
},
|
||||
enabled: !!currentUser?.name,
|
||||
staleTime: 1000 * 30, // 30 seconds - notifications should update frequently
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Hook to fetch tag counts for autocomplete suggestions.
|
||||
* @param forCurrentUser - If true, fetches only current user's tags; if false, fetches all public tags
|
||||
*/
|
||||
export function useTagCounts(forCurrentUser = false) {
|
||||
const { data: currentUser } = useCurrentUser();
|
||||
|
||||
return useQuery({
|
||||
queryKey: forCurrentUser ? [...userKeys.stats(), "tagCounts", "current"] : [...userKeys.stats(), "tagCounts", "all"],
|
||||
queryFn: async () => {
|
||||
if (forCurrentUser) {
|
||||
// Fetch current user stats only
|
||||
if (!currentUser?.name) {
|
||||
return {};
|
||||
}
|
||||
const stats = await userServiceClient.getUserStats({ name: currentUser.name });
|
||||
return stats.tagCount || {};
|
||||
} else {
|
||||
// Fetch all user stats
|
||||
const { stats } = await userServiceClient.listAllUserStats({});
|
||||
|
||||
// Aggregate tag counts from all users
|
||||
const tagCount: Record<string, number> = {};
|
||||
for (const userStats of stats) {
|
||||
if (userStats.tagCount) {
|
||||
for (const [tag, count] of Object.entries(userStats.tagCount)) {
|
||||
tagCount[tag] = (tagCount[tag] || 0) + count;
|
||||
}
|
||||
}
|
||||
}
|
||||
return tagCount;
|
||||
}
|
||||
},
|
||||
enabled: !forCurrentUser || !!currentUser?.name,
|
||||
staleTime: 1000 * 60 * 2, // 2 minutes - tags don't change frequently
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Hook to update a user's profile.
|
||||
* Automatically updates the cache on success.
|
||||
*/
|
||||
export function useUpdateUser() {
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
return useMutation({
|
||||
mutationFn: async ({ user, updateMask }: { user: Partial<User>; updateMask: string[] }) => {
|
||||
const updatedUser = await userServiceClient.updateUser({
|
||||
user: user as User,
|
||||
updateMask: create(FieldMaskSchema, { paths: updateMask }),
|
||||
});
|
||||
return updatedUser;
|
||||
},
|
||||
onSuccess: (updatedUser) => {
|
||||
queryClient.setQueryData(userKeys.detail(updatedUser.name), updatedUser);
|
||||
queryClient.invalidateQueries({ queryKey: userKeys.currentUser() });
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Hook to delete a user.
|
||||
* Automatically removes the user from cache on success.
|
||||
*/
|
||||
export function useDeleteUser() {
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
return useMutation({
|
||||
mutationFn: async (name: string) => {
|
||||
await userServiceClient.deleteUser({ name });
|
||||
return name;
|
||||
},
|
||||
onSuccess: (name) => {
|
||||
queryClient.removeQueries({ queryKey: userKeys.detail(name) });
|
||||
queryClient.invalidateQueries({ queryKey: userKeys.all });
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
// Hook to fetch user settings
|
||||
export function useUserSettings(parent?: string) {
|
||||
return useQuery({
|
||||
queryKey: [...userKeys.all, "settings", parent],
|
||||
queryFn: async () => {
|
||||
if (!parent) return { settings: [], shortcuts: [] };
|
||||
const [{ settings }, { shortcuts }] = await Promise.all([
|
||||
userServiceClient.listUserSettings({ parent }),
|
||||
shortcutServiceClient.listShortcuts({ parent }),
|
||||
]);
|
||||
return { settings, shortcuts };
|
||||
},
|
||||
enabled: !!parent,
|
||||
});
|
||||
}
|
||||
|
||||
// Hook to update user setting
|
||||
export function useUpdateUserSetting() {
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
return useMutation({
|
||||
mutationFn: async ({ setting, updateMask }: { setting: UserSetting; updateMask: string[] }) => {
|
||||
const updatedSetting = await userServiceClient.updateUserSetting({
|
||||
setting,
|
||||
updateMask: create(FieldMaskSchema, { paths: updateMask }),
|
||||
});
|
||||
return updatedSetting;
|
||||
},
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: [...userKeys.all, "settings"] });
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
// Hook to list all users
|
||||
export function useListUsers() {
|
||||
return useQuery({
|
||||
queryKey: userKeys.all,
|
||||
queryFn: async () => {
|
||||
const { users } = await userServiceClient.listUsers({});
|
||||
return users;
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
// Hook to update user general setting (convenience wrapper)
|
||||
export function useUpdateUserGeneralSetting(currentUserName?: string) {
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
return useMutation({
|
||||
mutationFn: async ({ generalSetting, updateMask }: { generalSetting: Partial<UserSetting_GeneralSetting>; updateMask: string[] }) => {
|
||||
if (!currentUserName) {
|
||||
throw new Error("No current user");
|
||||
}
|
||||
|
||||
const settingName = buildUserSettingName(currentUserName, UserSetting_Key.GENERAL);
|
||||
const userSetting = create(UserSettingSchema, {
|
||||
name: settingName,
|
||||
value: {
|
||||
case: "generalSetting",
|
||||
value: generalSetting as UserSetting_GeneralSetting,
|
||||
},
|
||||
});
|
||||
|
||||
const updatedSetting = await userServiceClient.updateUserSetting({
|
||||
setting: userSetting,
|
||||
updateMask: create(FieldMaskSchema, { paths: updateMask }),
|
||||
});
|
||||
return updatedSetting;
|
||||
},
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: [...userKeys.all, "settings"] });
|
||||
},
|
||||
});
|
||||
}
|
||||
|
|
@ -1,5 +1,5 @@
|
|||
import { useEffect } from "react";
|
||||
import { userStore } from "@/store";
|
||||
import { useAuth } from "@/contexts/AuthContext";
|
||||
import { getThemeWithFallback, loadTheme, setupSystemThemeListener } from "@/utils/theme";
|
||||
|
||||
/**
|
||||
|
|
@ -7,7 +7,7 @@ import { getThemeWithFallback, loadTheme, setupSystemThemeListener } from "@/uti
|
|||
* Priority: User setting → localStorage → system preference
|
||||
*/
|
||||
export const useUserTheme = () => {
|
||||
const userGeneralSetting = userStore.state.userGeneralSetting;
|
||||
const { userGeneralSetting } = useAuth();
|
||||
|
||||
// Apply theme when user setting changes or user logs in
|
||||
useEffect(() => {
|
||||
|
|
|
|||
|
|
@ -0,0 +1,23 @@
|
|||
// Simple configuration module for instance settings
|
||||
// This allows non-React code (like connect.ts interceptors) to access instance settings
|
||||
// The values are updated by InstanceContext when it initializes
|
||||
|
||||
interface InstanceConfig {
|
||||
memoRelatedSetting: {
|
||||
disallowPublicVisibility: boolean;
|
||||
};
|
||||
}
|
||||
|
||||
let instanceConfig: InstanceConfig = {
|
||||
memoRelatedSetting: {
|
||||
disallowPublicVisibility: false,
|
||||
},
|
||||
};
|
||||
|
||||
export function getInstanceConfig(): InstanceConfig {
|
||||
return instanceConfig;
|
||||
}
|
||||
|
||||
export function updateInstanceConfig(config: Partial<InstanceConfig>): void {
|
||||
instanceConfig = { ...instanceConfig, ...config };
|
||||
}
|
||||
|
|
@ -1,17 +1,16 @@
|
|||
import { observer } from "mobx-react-lite";
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import { matchPath, Outlet, useLocation } from "react-router-dom";
|
||||
import type { MemoExplorerContext } from "@/components/MemoExplorer";
|
||||
import { MemoExplorer, MemoExplorerDrawer } from "@/components/MemoExplorer";
|
||||
import MobileHeader from "@/components/MobileHeader";
|
||||
import { userServiceClient } from "@/connect";
|
||||
import useCurrentUser from "@/hooks/useCurrentUser";
|
||||
import { useFilteredMemoStats } from "@/hooks/useFilteredMemoStats";
|
||||
import useResponsiveWidth from "@/hooks/useResponsiveWidth";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { Routes } from "@/router";
|
||||
import { userStore } from "@/store";
|
||||
|
||||
const MainLayout = observer(() => {
|
||||
const MainLayout = () => {
|
||||
const { md, lg } = useResponsiveWidth();
|
||||
const location = useLocation();
|
||||
const currentUser = useCurrentUser();
|
||||
|
|
@ -34,8 +33,8 @@ const MainLayout = observer(() => {
|
|||
if (username) {
|
||||
// Fetch or get user to obtain user name (e.g., "users/123")
|
||||
// Note: User stats will be fetched by useFilteredMemoStats
|
||||
userStore
|
||||
.getOrFetchUser(`users/${username}`)
|
||||
userServiceClient
|
||||
.getUser({ name: `users/${username}` })
|
||||
.then((user) => {
|
||||
setProfileUserName(user.name);
|
||||
})
|
||||
|
|
@ -86,6 +85,6 @@ const MainLayout = observer(() => {
|
|||
</div>
|
||||
</section>
|
||||
);
|
||||
});
|
||||
};
|
||||
|
||||
export default MainLayout;
|
||||
|
|
|
|||
|
|
@ -1,30 +1,30 @@
|
|||
import { observer } from "mobx-react-lite";
|
||||
import { Suspense, useEffect, useMemo, useState } from "react";
|
||||
import { Outlet, useLocation, useSearchParams } from "react-router-dom";
|
||||
import usePrevious from "react-use/lib/usePrevious";
|
||||
import Navigation from "@/components/Navigation";
|
||||
import { useInstance } from "@/contexts/InstanceContext";
|
||||
import { useMemoFilterContext } from "@/contexts/MemoFilterContext";
|
||||
import useCurrentUser from "@/hooks/useCurrentUser";
|
||||
import useResponsiveWidth from "@/hooks/useResponsiveWidth";
|
||||
import { cn } from "@/lib/utils";
|
||||
import Loading from "@/pages/Loading";
|
||||
import { Routes } from "@/router";
|
||||
import { instanceStore } from "@/store";
|
||||
import memoFilterStore from "@/store/memoFilter";
|
||||
|
||||
const RootLayout = observer(() => {
|
||||
const RootLayout = () => {
|
||||
const location = useLocation();
|
||||
const [searchParams] = useSearchParams();
|
||||
const { sm } = useResponsiveWidth();
|
||||
const currentUser = useCurrentUser();
|
||||
const { memoRelatedSetting } = useInstance();
|
||||
const { removeFilter } = useMemoFilterContext();
|
||||
const [initialized, setInitialized] = useState(false);
|
||||
const pathname = useMemo(() => location.pathname, [location.pathname]);
|
||||
const prevPathname = usePrevious(pathname);
|
||||
|
||||
useEffect(() => {
|
||||
if (!currentUser) {
|
||||
// If disallowPublicVisibility is enabled, redirect to the login page if the user is not logged in.
|
||||
if (instanceStore.state.memoRelatedSetting.disallowPublicVisibility) {
|
||||
// Use replace() to prevent back button from showing cached sensitive data
|
||||
// If disallowPublicVisibility is enabled, redirect to login
|
||||
if (memoRelatedSetting.disallowPublicVisibility) {
|
||||
window.location.replace(Routes.AUTH);
|
||||
return;
|
||||
} else if (
|
||||
|
|
@ -35,14 +35,14 @@ const RootLayout = observer(() => {
|
|||
}
|
||||
}
|
||||
setInitialized(true);
|
||||
}, []);
|
||||
}, [currentUser, memoRelatedSetting.disallowPublicVisibility, location.pathname]);
|
||||
|
||||
useEffect(() => {
|
||||
// When the route changes and there is no filter in the search params, remove all filters.
|
||||
// When the route changes and there is no filter in the search params, remove all filters
|
||||
if (prevPathname !== pathname && !searchParams.has("filter")) {
|
||||
memoFilterStore.removeFilter(() => true);
|
||||
removeFilter(() => true);
|
||||
}
|
||||
}, [prevPathname, pathname, searchParams]);
|
||||
}, [prevPathname, pathname, searchParams, removeFilter]);
|
||||
|
||||
return !initialized ? (
|
||||
<Loading />
|
||||
|
|
@ -66,6 +66,6 @@ const RootLayout = observer(() => {
|
|||
</main>
|
||||
</div>
|
||||
);
|
||||
});
|
||||
};
|
||||
|
||||
export default RootLayout;
|
||||
|
|
|
|||
|
|
@ -0,0 +1,17 @@
|
|||
import { QueryClient } from "@tanstack/react-query";
|
||||
|
||||
export const queryClient = new QueryClient({
|
||||
defaultOptions: {
|
||||
queries: {
|
||||
// Memos app is real-time focused, so we want fresh data
|
||||
staleTime: 1000 * 10, // 10 seconds
|
||||
gcTime: 1000 * 60 * 5, // 5 minutes (formerly cacheTime)
|
||||
retry: 1,
|
||||
refetchOnWindowFocus: true,
|
||||
refetchOnReconnect: true,
|
||||
},
|
||||
mutations: {
|
||||
retry: 1,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
|
@ -1,37 +1,69 @@
|
|||
import "@github/relative-time-element";
|
||||
import { observer } from "mobx-react-lite";
|
||||
import { QueryClientProvider } from "@tanstack/react-query";
|
||||
import { ReactQueryDevtools } from "@tanstack/react-query-devtools";
|
||||
import React, { useEffect, useState } from "react";
|
||||
import { createRoot } from "react-dom/client";
|
||||
import { Toaster } from "react-hot-toast";
|
||||
import { RouterProvider } from "react-router-dom";
|
||||
import "./i18n";
|
||||
import "./index.css";
|
||||
import { AuthProvider, useAuth } from "@/contexts/AuthContext";
|
||||
import { InstanceProvider, useInstance } from "@/contexts/InstanceContext";
|
||||
import { ViewProvider } from "@/contexts/ViewContext";
|
||||
import { queryClient } from "@/lib/query-client";
|
||||
import Loading from "@/pages/Loading";
|
||||
import router from "./router";
|
||||
// Configure MobX before importing any stores
|
||||
import "./store/config";
|
||||
import { initialInstanceStore } from "./store/instance";
|
||||
import { initialUserStore } from "./store/user";
|
||||
import { applyLocaleEarly } from "./utils/i18n";
|
||||
import { applyThemeEarly } from "./utils/theme";
|
||||
import "leaflet/dist/leaflet.css";
|
||||
|
||||
// Apply theme and locale early to prevent flash of wrong theme/language
|
||||
// This uses localStorage as the source before user settings are loaded
|
||||
// Apply theme and locale early to prevent flash
|
||||
applyThemeEarly();
|
||||
applyLocaleEarly();
|
||||
|
||||
const Main = observer(() => (
|
||||
<>
|
||||
<RouterProvider router={router} />
|
||||
<Toaster position="top-right" />
|
||||
</>
|
||||
));
|
||||
// Inner component that initializes contexts
|
||||
function AppInitializer({ children }: { children: React.ReactNode }) {
|
||||
const { isInitialized: authInitialized, initialize: initAuth } = useAuth();
|
||||
const { isInitialized: instanceInitialized, initialize: initInstance } = useInstance();
|
||||
const [initStarted, setInitStarted] = useState(false);
|
||||
|
||||
(async () => {
|
||||
// Initialize stores
|
||||
await initialInstanceStore();
|
||||
await initialUserStore();
|
||||
// Initialize on mount
|
||||
useEffect(() => {
|
||||
if (initStarted) return;
|
||||
setInitStarted(true);
|
||||
|
||||
const container = document.getElementById("root");
|
||||
const root = createRoot(container as HTMLElement);
|
||||
root.render(<Main />);
|
||||
})();
|
||||
const init = async () => {
|
||||
await initInstance();
|
||||
await initAuth();
|
||||
};
|
||||
init();
|
||||
}, [initAuth, initInstance, initStarted]);
|
||||
|
||||
if (!authInitialized || !instanceInitialized) {
|
||||
return <Loading />;
|
||||
}
|
||||
|
||||
return <>{children}</>;
|
||||
}
|
||||
|
||||
function Main() {
|
||||
return (
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<InstanceProvider>
|
||||
<AuthProvider>
|
||||
<ViewProvider>
|
||||
<AppInitializer>
|
||||
<RouterProvider router={router} />
|
||||
<Toaster position="top-right" />
|
||||
</AppInitializer>
|
||||
</ViewProvider>
|
||||
</AuthProvider>
|
||||
</InstanceProvider>
|
||||
<ReactQueryDevtools initialIsOpen={false} />
|
||||
</QueryClientProvider>
|
||||
);
|
||||
}
|
||||
|
||||
const container = document.getElementById("root");
|
||||
const root = createRoot(container as HTMLElement);
|
||||
root.render(<Main />);
|
||||
|
|
|
|||
|
|
@ -1,10 +1,9 @@
|
|||
import { observer } from "mobx-react-lite";
|
||||
import AuthFooter from "@/components/AuthFooter";
|
||||
import PasswordSignInForm from "@/components/PasswordSignInForm";
|
||||
import { instanceStore } from "@/store";
|
||||
import { useInstance } from "@/contexts/InstanceContext";
|
||||
|
||||
const AdminSignIn = observer(() => {
|
||||
const instanceGeneralSetting = instanceStore.state.generalSetting;
|
||||
const AdminSignIn = () => {
|
||||
const { generalSetting: instanceGeneralSetting } = useInstance();
|
||||
|
||||
return (
|
||||
<div className="py-4 sm:py-8 w-80 max-w-full min-h-svh mx-auto flex flex-col justify-start items-center">
|
||||
|
|
@ -19,6 +18,6 @@ const AdminSignIn = observer(() => {
|
|||
<AuthFooter />
|
||||
</div>
|
||||
);
|
||||
});
|
||||
};
|
||||
|
||||
export default AdminSignIn;
|
||||
|
|
|
|||
|
|
@ -1,4 +1,3 @@
|
|||
import { observer } from "mobx-react-lite";
|
||||
import { MemoRenderContext } from "@/components/MasonryView";
|
||||
import MemoView from "@/components/MemoView";
|
||||
import PagedMemoList from "@/components/PagedMemoList";
|
||||
|
|
@ -7,12 +6,12 @@ import useCurrentUser from "@/hooks/useCurrentUser";
|
|||
import { State } from "@/types/proto/api/v1/common_pb";
|
||||
import { Memo } from "@/types/proto/api/v1/memo_service_pb";
|
||||
|
||||
const Archived = observer(() => {
|
||||
const Archived = () => {
|
||||
const user = useCurrentUser();
|
||||
|
||||
// Build filter using unified hook (no shortcuts or pinned filter)
|
||||
const memoFilter = useMemoFilters({
|
||||
creatorName: user.name,
|
||||
creatorName: user?.name,
|
||||
includeShortcuts: false,
|
||||
includePinned: false,
|
||||
});
|
||||
|
|
@ -34,6 +33,6 @@ const Archived = observer(() => {
|
|||
filter={memoFilter}
|
||||
/>
|
||||
);
|
||||
});
|
||||
};
|
||||
|
||||
export default Archived;
|
||||
|
|
|
|||
|
|
@ -1,7 +1,6 @@
|
|||
import { timestampDate } from "@bufbuild/protobuf/wkt";
|
||||
import dayjs from "dayjs";
|
||||
import { ExternalLinkIcon, PaperclipIcon, SearchIcon, Trash } from "lucide-react";
|
||||
import { observer } from "mobx-react-lite";
|
||||
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||
import { toast } from "react-hot-toast";
|
||||
import { Link } from "react-router-dom";
|
||||
|
|
@ -13,11 +12,11 @@ import { Button } from "@/components/ui/button";
|
|||
import { Input } from "@/components/ui/input";
|
||||
import { Separator } from "@/components/ui/separator";
|
||||
import { attachmentServiceClient } from "@/connect";
|
||||
import { useDeleteAttachment } from "@/hooks/useAttachmentQueries";
|
||||
import useDialog from "@/hooks/useDialog";
|
||||
import useLoading from "@/hooks/useLoading";
|
||||
import useResponsiveWidth from "@/hooks/useResponsiveWidth";
|
||||
import i18n from "@/i18n";
|
||||
import { attachmentStore } from "@/store";
|
||||
import type { Attachment } from "@/types/proto/api/v1/attachment_service_pb";
|
||||
import { useTranslate } from "@/utils/i18n";
|
||||
|
||||
|
|
@ -68,11 +67,12 @@ const AttachmentItem = ({ attachment }: AttachmentItemProps) => (
|
|||
</div>
|
||||
);
|
||||
|
||||
const Attachments = observer(() => {
|
||||
const Attachments = () => {
|
||||
const t = useTranslate();
|
||||
const { md } = useResponsiveWidth();
|
||||
const loadingState = useLoading();
|
||||
const deleteUnusedAttachmentsDialog = useDialog();
|
||||
const { mutateAsync: deleteAttachment } = useDeleteAttachment();
|
||||
|
||||
const [searchQuery, setSearchQuery] = useState("");
|
||||
const [attachments, setAttachments] = useState<Attachment[]>([]);
|
||||
|
|
@ -149,7 +149,7 @@ const Attachments = observer(() => {
|
|||
// Delete all unused attachments
|
||||
const handleDeleteUnusedAttachments = useCallback(async () => {
|
||||
try {
|
||||
await Promise.all(unusedAttachments.map((attachment) => attachmentStore.deleteAttachment(attachment.name)));
|
||||
await Promise.all(unusedAttachments.map((attachment) => deleteAttachment(attachment.name)));
|
||||
toast.success(t("resource.delete-all-unused-success"));
|
||||
} catch (error) {
|
||||
console.error("Failed to delete unused attachments:", error);
|
||||
|
|
@ -157,7 +157,7 @@ const Attachments = observer(() => {
|
|||
} finally {
|
||||
await handleRefetch();
|
||||
}
|
||||
}, [unusedAttachments, t, handleRefetch]);
|
||||
}, [unusedAttachments, t, handleRefetch, deleteAttachment]);
|
||||
|
||||
// Handle search input change
|
||||
const handleSearchChange = useCallback((e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
|
|
@ -266,6 +266,6 @@ const Attachments = observer(() => {
|
|||
/>
|
||||
</section>
|
||||
);
|
||||
});
|
||||
};
|
||||
|
||||
export default Attachments;
|
||||
|
|
|
|||
|
|
@ -1,13 +1,12 @@
|
|||
import { timestampDate } from "@bufbuild/protobuf/wkt";
|
||||
import { LoaderIcon } from "lucide-react";
|
||||
import { observer } from "mobx-react-lite";
|
||||
import { useEffect, useState } from "react";
|
||||
import { useSearchParams } from "react-router-dom";
|
||||
import { setAccessToken } from "@/auth-state";
|
||||
import { authServiceClient } from "@/connect";
|
||||
import { useAuth } from "@/contexts/AuthContext";
|
||||
import { absolutifyLink } from "@/helpers/utils";
|
||||
import useNavigateTo from "@/hooks/useNavigateTo";
|
||||
import { initialUserStore } from "@/store/user";
|
||||
import { validateOAuthState } from "@/utils/oauth";
|
||||
|
||||
interface State {
|
||||
|
|
@ -15,8 +14,9 @@ interface State {
|
|||
errorMessage: string;
|
||||
}
|
||||
|
||||
const AuthCallback = observer(() => {
|
||||
const AuthCallback = () => {
|
||||
const navigateTo = useNavigateTo();
|
||||
const { initialize } = useAuth();
|
||||
const [searchParams] = useSearchParams();
|
||||
const [state, setState] = useState<State>({
|
||||
loading: true,
|
||||
|
|
@ -91,7 +91,7 @@ const AuthCallback = observer(() => {
|
|||
loading: false,
|
||||
errorMessage: "",
|
||||
});
|
||||
await initialUserStore();
|
||||
await initialize();
|
||||
// Redirect to return URL if specified, otherwise home
|
||||
navigateTo(returnUrl || "/");
|
||||
} catch (error: unknown) {
|
||||
|
|
@ -114,6 +114,6 @@ const AuthCallback = observer(() => {
|
|||
)}
|
||||
</div>
|
||||
);
|
||||
});
|
||||
};
|
||||
|
||||
export default AuthCallback;
|
||||
|
|
|
|||
|
|
@ -1,5 +1,4 @@
|
|||
import { ChevronLeftIcon, ChevronRightIcon } from "lucide-react";
|
||||
import { observer } from "mobx-react-lite";
|
||||
import { useMemo, useState } from "react";
|
||||
import {
|
||||
CompactMonthCalendar,
|
||||
|
|
@ -20,7 +19,7 @@ import { useTranslate } from "@/utils/i18n";
|
|||
const MIN_YEAR = 2000;
|
||||
const MAX_YEAR = new Date().getFullYear() + 1;
|
||||
|
||||
const Calendar = observer(() => {
|
||||
const Calendar = () => {
|
||||
const currentUser = useCurrentUser();
|
||||
const t = useTranslate();
|
||||
const navigateToDateFilter = useDateFilterNavigation();
|
||||
|
|
@ -140,6 +139,6 @@ const Calendar = observer(() => {
|
|||
</div>
|
||||
</section>
|
||||
);
|
||||
});
|
||||
};
|
||||
|
||||
export default Calendar;
|
||||
|
|
|
|||
|
|
@ -1,4 +1,3 @@
|
|||
import { observer } from "mobx-react-lite";
|
||||
import { MemoRenderContext } from "@/components/MasonryView";
|
||||
import MemoView from "@/components/MemoView";
|
||||
import PagedMemoList from "@/components/PagedMemoList";
|
||||
|
|
@ -7,7 +6,7 @@ import useCurrentUser from "@/hooks/useCurrentUser";
|
|||
import { State } from "@/types/proto/api/v1/common_pb";
|
||||
import { Memo, Visibility } from "@/types/proto/api/v1/memo_service_pb";
|
||||
|
||||
const Explore = observer(() => {
|
||||
const Explore = () => {
|
||||
const currentUser = useCurrentUser();
|
||||
|
||||
// Determine visibility filter based on authentication status
|
||||
|
|
@ -40,6 +39,6 @@ const Explore = observer(() => {
|
|||
showCreator
|
||||
/>
|
||||
);
|
||||
});
|
||||
};
|
||||
|
||||
export default Explore;
|
||||
|
|
|
|||
|
|
@ -1,4 +1,3 @@
|
|||
import { observer } from "mobx-react-lite";
|
||||
import { MemoRenderContext } from "@/components/MasonryView";
|
||||
import MemoView from "@/components/MemoView";
|
||||
import PagedMemoList from "@/components/PagedMemoList";
|
||||
|
|
@ -7,12 +6,12 @@ import useCurrentUser from "@/hooks/useCurrentUser";
|
|||
import { State } from "@/types/proto/api/v1/common_pb";
|
||||
import { Memo } from "@/types/proto/api/v1/memo_service_pb";
|
||||
|
||||
const Home = observer(() => {
|
||||
const Home = () => {
|
||||
const user = useCurrentUser();
|
||||
|
||||
// Build filter using unified hook
|
||||
const memoFilter = useMemoFilters({
|
||||
creatorName: user.name,
|
||||
creatorName: user?.name,
|
||||
includeShortcuts: true,
|
||||
includePinned: true,
|
||||
});
|
||||
|
|
@ -35,6 +34,6 @@ const Home = observer(() => {
|
|||
/>
|
||||
</div>
|
||||
);
|
||||
});
|
||||
};
|
||||
|
||||
export default Home;
|
||||
|
|
|
|||
|
|
@ -1,23 +1,25 @@
|
|||
import { timestampDate } from "@bufbuild/protobuf/wkt";
|
||||
import { sortBy } from "lodash-es";
|
||||
import { ArchiveIcon, BellIcon, InboxIcon } from "lucide-react";
|
||||
import { observer } from "mobx-react-lite";
|
||||
import { useEffect, useState } from "react";
|
||||
import { useState } from "react";
|
||||
import Empty from "@/components/Empty";
|
||||
import MemoCommentMessage from "@/components/Inbox/MemoCommentMessage";
|
||||
import MobileHeader from "@/components/MobileHeader";
|
||||
import useResponsiveWidth from "@/hooks/useResponsiveWidth";
|
||||
import { useNotifications } from "@/hooks/useUserQueries";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { userStore } from "@/store";
|
||||
import { UserNotification, UserNotification_Status, UserNotification_Type } from "@/types/proto/api/v1/user_service_pb";
|
||||
import { useTranslate } from "@/utils/i18n";
|
||||
|
||||
const Inboxes = observer(() => {
|
||||
const Inboxes = () => {
|
||||
const t = useTranslate();
|
||||
const { md } = useResponsiveWidth();
|
||||
const [filter, setFilter] = useState<"all" | "unread" | "archived">("all");
|
||||
|
||||
const allNotifications = sortBy(userStore.state.notifications, (notification: UserNotification) => {
|
||||
// Fetch notifications with React Query
|
||||
const { data: fetchedNotifications = [] } = useNotifications();
|
||||
|
||||
const allNotifications = sortBy(fetchedNotifications, (notification: UserNotification) => {
|
||||
return -((notification.createTime ? timestampDate(notification.createTime) : undefined)?.getTime() || 0);
|
||||
});
|
||||
|
||||
|
|
@ -30,18 +32,6 @@ const Inboxes = observer(() => {
|
|||
const unreadCount = allNotifications.filter((n) => n.status === UserNotification_Status.UNREAD).length;
|
||||
const archivedCount = allNotifications.filter((n) => n.status === UserNotification_Status.ARCHIVED).length;
|
||||
|
||||
const fetchNotifications = async () => {
|
||||
try {
|
||||
await userStore.fetchNotifications();
|
||||
} catch (error) {
|
||||
console.error("Failed to fetch notifications:", error);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
fetchNotifications();
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<section className="@container w-full max-w-5xl min-h-full flex flex-col justify-start items-center sm:pt-3 md:pt-6 pb-8">
|
||||
{!md && <MobileHeader />}
|
||||
|
|
@ -127,6 +117,6 @@ const Inboxes = observer(() => {
|
|||
</div>
|
||||
</section>
|
||||
);
|
||||
});
|
||||
};
|
||||
|
||||
export default Inboxes;
|
||||
|
|
|
|||
|
|
@ -1,7 +1,6 @@
|
|||
import { ConnectError } from "@connectrpc/connect";
|
||||
import { ArrowUpLeftFromCircleIcon, MessageCircleIcon } from "lucide-react";
|
||||
import { observer } from "mobx-react-lite";
|
||||
import { useEffect, useState } from "react";
|
||||
import { useState } from "react";
|
||||
import { toast } from "react-hot-toast";
|
||||
import { Link, useLocation, useParams } from "react-router-dom";
|
||||
import { MemoDetailSidebar, MemoDetailSidebarDrawer } from "@/components/MemoDetailSidebar";
|
||||
|
|
@ -9,16 +8,16 @@ import MemoEditor from "@/components/MemoEditor";
|
|||
import MemoView from "@/components/MemoView";
|
||||
import MobileHeader from "@/components/MobileHeader";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { memoNamePrefix } from "@/helpers/resource-names";
|
||||
import useCurrentUser from "@/hooks/useCurrentUser";
|
||||
import { useMemo as useMemoQuery } from "@/hooks/useMemoQueries";
|
||||
import useNavigateTo from "@/hooks/useNavigateTo";
|
||||
import useResponsiveWidth from "@/hooks/useResponsiveWidth";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { memoStore } from "@/store";
|
||||
import { memoNamePrefix } from "@/store/common";
|
||||
import { Memo, MemoRelation_Type } from "@/types/proto/api/v1/memo_service_pb";
|
||||
import { useTranslate } from "@/utils/i18n";
|
||||
|
||||
const MemoDetail = observer(() => {
|
||||
const MemoDetail = () => {
|
||||
const t = useTranslate();
|
||||
const { md } = useResponsiveWidth();
|
||||
const params = useParams();
|
||||
|
|
@ -27,45 +26,34 @@ const MemoDetail = observer(() => {
|
|||
const currentUser = useCurrentUser();
|
||||
const uid = params.uid;
|
||||
const memoName = `${memoNamePrefix}${uid}`;
|
||||
const memo = memoStore.getMemoByName(memoName);
|
||||
const [parentMemo, setParentMemo] = useState<Memo | undefined>(undefined);
|
||||
const [showCommentEditor, setShowCommentEditor] = useState(false);
|
||||
|
||||
// Fetch main memo with React Query
|
||||
const { data: memo, error, isLoading } = useMemoQuery(memoName, { enabled: !!memoName });
|
||||
|
||||
// Handle errors
|
||||
if (error) {
|
||||
toast.error((error as ConnectError).message);
|
||||
navigateTo("/403");
|
||||
}
|
||||
|
||||
// Fetch parent memo if exists
|
||||
const { data: parentMemo } = useMemoQuery(memo?.parent || "", {
|
||||
enabled: !!memo?.parent,
|
||||
});
|
||||
|
||||
// Get comment relations and memo names
|
||||
const commentRelations =
|
||||
memo?.relations.filter((relation) => relation.relatedMemo?.name === memo.name && relation.type === MemoRelation_Type.COMMENT) || [];
|
||||
const comments = commentRelations.map((relation) => memoStore.getMemoByName(relation.memo!.name)).filter((memo) => memo) as any as Memo[];
|
||||
const commentMemoNames = commentRelations.map((relation) => relation.memo!.name);
|
||||
|
||||
// Fetch all comment memos
|
||||
const commentQueries = commentMemoNames.map((name) => useMemoQuery(name));
|
||||
const comments = commentQueries.map((q) => q.data).filter((memo): memo is Memo => !!memo);
|
||||
|
||||
const showCreateCommentButton = currentUser && !showCommentEditor;
|
||||
|
||||
// Prepare memo.
|
||||
useEffect(() => {
|
||||
if (memoName) {
|
||||
memoStore.getOrFetchMemoByName(memoName).catch((error: ConnectError) => {
|
||||
toast.error(error.message);
|
||||
navigateTo("/403");
|
||||
});
|
||||
} else {
|
||||
navigateTo("/404");
|
||||
}
|
||||
}, [memoName]);
|
||||
|
||||
// Prepare memo comments.
|
||||
useEffect(() => {
|
||||
if (!memo) {
|
||||
return;
|
||||
}
|
||||
|
||||
(async () => {
|
||||
if (memo.parent) {
|
||||
memoStore.getOrFetchMemoByName(memo.parent).then((memo: Memo) => {
|
||||
setParentMemo(memo);
|
||||
});
|
||||
} else {
|
||||
setParentMemo(undefined);
|
||||
}
|
||||
await Promise.all(commentRelations.map((relation) => memoStore.getOrFetchMemoByName(relation.memo!.name)));
|
||||
})();
|
||||
}, [memo]);
|
||||
|
||||
if (!memo) {
|
||||
if (isLoading || !memo) {
|
||||
return null;
|
||||
}
|
||||
|
||||
|
|
@ -74,8 +62,7 @@ const MemoDetail = observer(() => {
|
|||
};
|
||||
|
||||
const handleCommentCreated = async (memoCommentName: string) => {
|
||||
await memoStore.getOrFetchMemoByName(memoCommentName);
|
||||
await memoStore.getOrFetchMemoByName(memo.name, { skipCache: true });
|
||||
// React Query will auto-refetch due to invalidation in the mutation
|
||||
setShowCommentEditor(false);
|
||||
};
|
||||
|
||||
|
|
@ -174,6 +161,6 @@ const MemoDetail = observer(() => {
|
|||
</div>
|
||||
</section>
|
||||
);
|
||||
});
|
||||
};
|
||||
|
||||
export default MemoDetail;
|
||||
|
|
|
|||
|
|
@ -1,5 +1,4 @@
|
|||
import { CogIcon, DatabaseIcon, KeyIcon, LibraryIcon, LucideIcon, Settings2Icon, UserIcon, UsersIcon } from "lucide-react";
|
||||
import { observer } from "mobx-react-lite";
|
||||
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||
import { useLocation } from "react-router-dom";
|
||||
import MobileHeader from "@/components/MobileHeader";
|
||||
|
|
@ -12,9 +11,9 @@ import SectionMenuItem from "@/components/Settings/SectionMenuItem";
|
|||
import SSOSection from "@/components/Settings/SSOSection";
|
||||
import StorageSection from "@/components/Settings/StorageSection";
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
||||
import { useInstance } from "@/contexts/InstanceContext";
|
||||
import useCurrentUser from "@/hooks/useCurrentUser";
|
||||
import useResponsiveWidth from "@/hooks/useResponsiveWidth";
|
||||
import { instanceStore } from "@/store";
|
||||
import { InstanceSetting_Key } from "@/types/proto/api/v1/instance_service_pb";
|
||||
import { User_Role } from "@/types/proto/api/v1/user_service_pb";
|
||||
import { useTranslate } from "@/utils/i18n";
|
||||
|
|
@ -37,15 +36,16 @@ const SECTION_ICON_MAP: Record<SettingSection, LucideIcon> = {
|
|||
sso: KeyIcon,
|
||||
};
|
||||
|
||||
const Setting = observer(() => {
|
||||
const Setting = () => {
|
||||
const t = useTranslate();
|
||||
const { md } = useResponsiveWidth();
|
||||
const location = useLocation();
|
||||
const user = useCurrentUser();
|
||||
const { profile, fetchSetting } = useInstance();
|
||||
const [state, setState] = useState<State>({
|
||||
selectedSection: "my-account",
|
||||
});
|
||||
const isHost = user.role === User_Role.HOST;
|
||||
const isHost = user?.role === User_Role.HOST;
|
||||
|
||||
const settingsSectionList = useMemo(() => {
|
||||
let settingList = [...BASIC_SECTIONS];
|
||||
|
|
@ -74,10 +74,10 @@ const Setting = observer(() => {
|
|||
// Initial fetch for instance settings.
|
||||
(async () => {
|
||||
[InstanceSetting_Key.MEMO_RELATED, InstanceSetting_Key.STORAGE].forEach(async (key) => {
|
||||
await instanceStore.fetchInstanceSetting(key);
|
||||
await fetchSetting(key);
|
||||
});
|
||||
})();
|
||||
}, [isHost]);
|
||||
}, [isHost, fetchSetting]);
|
||||
|
||||
const handleSectionSelectorItemClick = useCallback((settingSection: SettingSection) => {
|
||||
window.location.hash = settingSection;
|
||||
|
|
@ -115,7 +115,7 @@ const Setting = observer(() => {
|
|||
/>
|
||||
))}
|
||||
<span className="px-3 mt-2 opacity-70 text-sm">
|
||||
{t("setting.version")}: v{instanceStore.state.profile.version}
|
||||
{t("setting.version")}: v{profile.version}
|
||||
</span>
|
||||
</div>
|
||||
</>
|
||||
|
|
@ -156,6 +156,6 @@ const Setting = observer(() => {
|
|||
</div>
|
||||
</section>
|
||||
);
|
||||
});
|
||||
};
|
||||
|
||||
export default Setting;
|
||||
|
|
|
|||
|
|
@ -1,4 +1,3 @@
|
|||
import { observer } from "mobx-react-lite";
|
||||
import { useEffect, useState } from "react";
|
||||
import { toast } from "react-hot-toast";
|
||||
import { Link } from "react-router-dom";
|
||||
|
|
@ -7,27 +6,27 @@ import PasswordSignInForm from "@/components/PasswordSignInForm";
|
|||
import { Button } from "@/components/ui/button";
|
||||
import { Separator } from "@/components/ui/separator";
|
||||
import { identityProviderServiceClient } from "@/connect";
|
||||
import { useInstance } from "@/contexts/InstanceContext";
|
||||
import { extractIdentityProviderIdFromName } from "@/helpers/resource-names";
|
||||
import { absolutifyLink } from "@/helpers/utils";
|
||||
import useCurrentUser from "@/hooks/useCurrentUser";
|
||||
import { Routes } from "@/router";
|
||||
import { instanceStore } from "@/store";
|
||||
import { extractIdentityProviderIdFromName } from "@/store/common";
|
||||
import { IdentityProvider, IdentityProvider_Type } from "@/types/proto/api/v1/idp_service_pb";
|
||||
import { useTranslate } from "@/utils/i18n";
|
||||
import { storeOAuthState } from "@/utils/oauth";
|
||||
|
||||
const SignIn = observer(() => {
|
||||
const SignIn = () => {
|
||||
const t = useTranslate();
|
||||
const currentUser = useCurrentUser();
|
||||
const [identityProviderList, setIdentityProviderList] = useState<IdentityProvider[]>([]);
|
||||
const instanceGeneralSetting = instanceStore.state.generalSetting;
|
||||
const { generalSetting: instanceGeneralSetting } = useInstance();
|
||||
|
||||
// Redirect to root page if already signed in.
|
||||
useEffect(() => {
|
||||
if (currentUser) {
|
||||
if (currentUser?.name) {
|
||||
window.location.href = Routes.ROOT;
|
||||
}
|
||||
}, []);
|
||||
}, [currentUser]);
|
||||
|
||||
// Prepare identity provider list.
|
||||
useEffect(() => {
|
||||
|
|
@ -79,7 +78,7 @@ const SignIn = observer(() => {
|
|||
{!instanceGeneralSetting.disallowPasswordAuth ? (
|
||||
<PasswordSignInForm />
|
||||
) : (
|
||||
identityProviderList.length == 0 && <p className="w-full text-2xl mt-2 text-muted-foreground">Password auth is not allowed.</p>
|
||||
identityProviderList.length === 0 && <p className="w-full text-2xl mt-2 text-muted-foreground">Password auth is not allowed.</p>
|
||||
)}
|
||||
{!instanceGeneralSetting.disallowUserRegistration && !instanceGeneralSetting.disallowPasswordAuth && (
|
||||
<p className="w-full mt-4 text-sm">
|
||||
|
|
@ -117,6 +116,6 @@ const SignIn = observer(() => {
|
|||
<AuthFooter />
|
||||
</div>
|
||||
);
|
||||
});
|
||||
};
|
||||
|
||||
export default SignIn;
|
||||
|
|
|
|||
|
|
@ -1,7 +1,6 @@
|
|||
import { create } from "@bufbuild/protobuf";
|
||||
import { timestampDate } from "@bufbuild/protobuf/wkt";
|
||||
import { LoaderIcon } from "lucide-react";
|
||||
import { observer } from "mobx-react-lite";
|
||||
import { useState } from "react";
|
||||
import { toast } from "react-hot-toast";
|
||||
import { Link } from "react-router-dom";
|
||||
|
|
@ -10,20 +9,21 @@ import AuthFooter from "@/components/AuthFooter";
|
|||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { authServiceClient, userServiceClient } from "@/connect";
|
||||
import { useAuth } from "@/contexts/AuthContext";
|
||||
import { useInstance } from "@/contexts/InstanceContext";
|
||||
import useLoading from "@/hooks/useLoading";
|
||||
import useNavigateTo from "@/hooks/useNavigateTo";
|
||||
import { instanceStore } from "@/store";
|
||||
import { initialUserStore } from "@/store/user";
|
||||
import { User_Role, UserSchema } from "@/types/proto/api/v1/user_service_pb";
|
||||
import { useTranslate } from "@/utils/i18n";
|
||||
|
||||
const SignUp = observer(() => {
|
||||
const SignUp = () => {
|
||||
const t = useTranslate();
|
||||
const navigateTo = useNavigateTo();
|
||||
const actionBtnLoadingState = useLoading(false);
|
||||
const [username, setUsername] = useState("");
|
||||
const [password, setPassword] = useState("");
|
||||
const instanceGeneralSetting = instanceStore.state.generalSetting;
|
||||
const { generalSetting: instanceGeneralSetting, profile } = useInstance();
|
||||
const { initialize } = useAuth();
|
||||
|
||||
const handleUsernameInputChanged = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const text = e.target.value as string;
|
||||
|
|
@ -67,7 +67,7 @@ const SignUp = observer(() => {
|
|||
if (response.accessToken) {
|
||||
setAccessToken(response.accessToken, response.accessTokenExpiresAt ? timestampDate(response.accessTokenExpiresAt) : undefined);
|
||||
}
|
||||
await initialUserStore();
|
||||
await initialize();
|
||||
navigateTo("/");
|
||||
} catch (error: unknown) {
|
||||
console.error(error);
|
||||
|
|
@ -131,7 +131,7 @@ const SignUp = observer(() => {
|
|||
) : (
|
||||
<p className="w-full text-2xl mt-2 text-muted-foreground">Sign up is not allowed.</p>
|
||||
)}
|
||||
{!instanceStore.state.profile.owner ? (
|
||||
{!profile.owner ? (
|
||||
<p className="w-full mt-4 text-sm font-medium text-muted-foreground">{t("auth.host-tip")}</p>
|
||||
) : (
|
||||
<p className="w-full mt-4 text-sm">
|
||||
|
|
@ -145,6 +145,6 @@ const SignUp = observer(() => {
|
|||
<AuthFooter />
|
||||
</div>
|
||||
);
|
||||
});
|
||||
};
|
||||
|
||||
export default SignUp;
|
||||
|
|
|
|||
|
|
@ -1,7 +1,5 @@
|
|||
import copy from "copy-to-clipboard";
|
||||
import { ExternalLinkIcon } from "lucide-react";
|
||||
import { observer } from "mobx-react-lite";
|
||||
import { useEffect, useState } from "react";
|
||||
import { toast } from "react-hot-toast";
|
||||
import { useParams } from "react-router-dom";
|
||||
import { MemoRenderContext } from "@/components/MasonryView";
|
||||
|
|
@ -10,36 +8,29 @@ import PagedMemoList from "@/components/PagedMemoList";
|
|||
import UserAvatar from "@/components/UserAvatar";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { useMemoFilters, useMemoSorting } from "@/hooks";
|
||||
import useLoading from "@/hooks/useLoading";
|
||||
import { userStore } from "@/store";
|
||||
import { useUser } from "@/hooks/useUserQueries";
|
||||
import { State } from "@/types/proto/api/v1/common_pb";
|
||||
import { Memo } from "@/types/proto/api/v1/memo_service_pb";
|
||||
import { User } from "@/types/proto/api/v1/user_service_pb";
|
||||
import { useTranslate } from "@/utils/i18n";
|
||||
|
||||
const UserProfile = observer(() => {
|
||||
const UserProfile = () => {
|
||||
const t = useTranslate();
|
||||
const params = useParams();
|
||||
const loadingState = useLoading();
|
||||
const [user, setUser] = useState<User>();
|
||||
const username = params.username;
|
||||
|
||||
useEffect(() => {
|
||||
const username = params.username;
|
||||
if (!username) {
|
||||
throw new Error("username is required");
|
||||
}
|
||||
// Fetch user with React Query
|
||||
const {
|
||||
data: user,
|
||||
isLoading,
|
||||
error,
|
||||
} = useUser(`users/${username}`, {
|
||||
enabled: !!username,
|
||||
});
|
||||
|
||||
userStore
|
||||
.getOrFetchUser(`users/${username}`)
|
||||
.then((user) => {
|
||||
setUser(user);
|
||||
loadingState.setFinish();
|
||||
})
|
||||
.catch((error) => {
|
||||
console.error(error);
|
||||
toast.error(t("message.user-not-found"));
|
||||
});
|
||||
}, [params.username]);
|
||||
// Handle errors
|
||||
if (error && !isLoading) {
|
||||
toast.error(t("message.user-not-found"));
|
||||
}
|
||||
|
||||
// Build filter using unified hook (no shortcuts, but includes pinned)
|
||||
const memoFilter = useMemoFilters({
|
||||
|
|
@ -65,7 +56,7 @@ const UserProfile = observer(() => {
|
|||
|
||||
return (
|
||||
<section className="w-full min-h-full flex flex-col justify-start items-center">
|
||||
{!loadingState.isLoading &&
|
||||
{!isLoading &&
|
||||
(user ? (
|
||||
<>
|
||||
{/* User profile header - centered with max width */}
|
||||
|
|
@ -107,6 +98,6 @@ const UserProfile = observer(() => {
|
|||
))}
|
||||
</section>
|
||||
);
|
||||
});
|
||||
};
|
||||
|
||||
export default UserProfile;
|
||||
|
|
|
|||
|
|
@ -1,277 +0,0 @@
|
|||
# Store Architecture
|
||||
|
||||
This directory contains the application's state management implementation using MobX.
|
||||
|
||||
## Overview
|
||||
|
||||
The store architecture follows a clear separation of concerns:
|
||||
|
||||
- **Server State Stores**: Manage data fetched from the backend API
|
||||
- **Client State Stores**: Manage UI preferences and transient state
|
||||
|
||||
## Store Files
|
||||
|
||||
### Server State Stores (API Data)
|
||||
|
||||
| Store | File | Purpose |
|
||||
|-------|------|---------|
|
||||
| `memoStore` | `memo.ts` | Memo CRUD operations, optimistic updates |
|
||||
| `userStore` | `user.ts` | User authentication, settings, stats |
|
||||
| `instanceStore` | `instance.ts` | Instance profile and settings |
|
||||
| `attachmentStore` | `attachment.ts` | File attachment management |
|
||||
|
||||
**Features:**
|
||||
- ✅ Request deduplication (prevents duplicate API calls)
|
||||
- ✅ Structured error handling with `StoreError`
|
||||
- ✅ Computed property memoization for performance
|
||||
- ✅ Optimistic updates (immediate UI feedback)
|
||||
- ✅ Automatic caching
|
||||
|
||||
### Client State Stores (UI State)
|
||||
|
||||
| Store | File | Purpose | Persistence |
|
||||
|-------|------|---------|-------------|
|
||||
| `viewStore` | `view.ts` | Display preferences (sort, layout) | localStorage |
|
||||
| `memoFilterStore` | `memoFilter.ts` | Active search filters | URL params |
|
||||
|
||||
**Features:**
|
||||
- ✅ No API calls (instant updates)
|
||||
- ✅ localStorage persistence (viewStore)
|
||||
- ✅ URL synchronization (memoFilterStore - shareable links)
|
||||
|
||||
### Utilities
|
||||
|
||||
| File | Purpose |
|
||||
|------|---------|
|
||||
| `base-store.ts` | Base classes and factory functions |
|
||||
| `store-utils.ts` | Request deduplication, error handling, optimistic updates |
|
||||
| `config.ts` | MobX configuration |
|
||||
| `common.ts` | Shared constants and utilities |
|
||||
| `index.ts` | Centralized exports |
|
||||
|
||||
## Usage Examples
|
||||
|
||||
### Basic Store Usage
|
||||
|
||||
```typescript
|
||||
import { memoStore, userStore, viewStore } from "@/store";
|
||||
import { observer } from "mobx-react-lite";
|
||||
|
||||
const MyComponent = observer(() => {
|
||||
// Access state
|
||||
const memos = memoStore.state.memos;
|
||||
const currentUser = userStore.state.currentUser;
|
||||
const sortOrder = viewStore.state.orderByTimeAsc;
|
||||
|
||||
// Call actions
|
||||
const handleCreate = async () => {
|
||||
await memoStore.createMemo({ content: "Hello" });
|
||||
};
|
||||
|
||||
const toggleSort = () => {
|
||||
viewStore.toggleSortOrder();
|
||||
};
|
||||
|
||||
return <div>...</div>;
|
||||
});
|
||||
```
|
||||
|
||||
### Server Store Pattern
|
||||
|
||||
```typescript
|
||||
// Fetch data with automatic deduplication
|
||||
const memo = await memoStore.getOrFetchMemoByName("memos/123");
|
||||
|
||||
// Update with optimistic UI updates
|
||||
await memoStore.updateMemo({ name: "memos/123", content: "Updated" }, ["content"]);
|
||||
|
||||
// Errors are wrapped in StoreError
|
||||
try {
|
||||
await memoStore.deleteMemo("memos/123");
|
||||
} catch (error) {
|
||||
if (error instanceof StoreError) {
|
||||
console.error(error.code, error.message);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Client Store Pattern
|
||||
|
||||
```typescript
|
||||
// View preferences (persisted to localStorage)
|
||||
viewStore.setLayout("MASONRY");
|
||||
viewStore.toggleSortOrder();
|
||||
|
||||
// Filters (synced to URL)
|
||||
memoFilterStore.addFilter({ factor: "tagSearch", value: "work" });
|
||||
memoFilterStore.removeFiltersByFactor("tagSearch");
|
||||
memoFilterStore.clearAllFilters();
|
||||
```
|
||||
|
||||
## Creating New Stores
|
||||
|
||||
### Server State Store
|
||||
|
||||
```typescript
|
||||
import { StandardState, createServerStore } from "./base-store";
|
||||
import { createRequestKey, StoreError } from "./store-utils";
|
||||
|
||||
class MyState extends StandardState {
|
||||
dataMap: Record<string, Data> = {};
|
||||
|
||||
get items() {
|
||||
return Object.values(this.dataMap);
|
||||
}
|
||||
}
|
||||
|
||||
const myStore = (() => {
|
||||
const base = createServerStore(new MyState(), {
|
||||
name: "myStore",
|
||||
enableDeduplication: true,
|
||||
});
|
||||
|
||||
const { state, executeRequest } = base;
|
||||
|
||||
const fetchItems = async () => {
|
||||
return executeRequest(
|
||||
createRequestKey("fetchItems"),
|
||||
async () => {
|
||||
const items = await api.fetchItems();
|
||||
state.setPartial({ dataMap: items });
|
||||
return items;
|
||||
},
|
||||
"FETCH_ITEMS_FAILED"
|
||||
);
|
||||
};
|
||||
|
||||
return { state, fetchItems };
|
||||
})();
|
||||
```
|
||||
|
||||
### Client State Store
|
||||
|
||||
```typescript
|
||||
import { StandardState } from "./base-store";
|
||||
|
||||
class MyState extends StandardState {
|
||||
preference: string = "default";
|
||||
|
||||
setPartial(partial: Partial<MyState>) {
|
||||
Object.assign(this, partial);
|
||||
// Optional: persist to localStorage
|
||||
localStorage.setItem("my-preference", JSON.stringify(this));
|
||||
}
|
||||
}
|
||||
|
||||
const myStore = (() => {
|
||||
const state = new MyState();
|
||||
|
||||
const setPreference = (value: string) => {
|
||||
state.setPartial({ preference: value });
|
||||
};
|
||||
|
||||
return { state, setPreference };
|
||||
})();
|
||||
```
|
||||
|
||||
## Best Practices
|
||||
|
||||
### ✅ Do
|
||||
|
||||
- Use `observer()` HOC for components that access store state
|
||||
- Call store actions from event handlers
|
||||
- Use computed properties for derived state
|
||||
- Handle errors from async store operations
|
||||
- Keep stores focused on a single domain
|
||||
|
||||
### ❌ Don't
|
||||
|
||||
- Don't mutate store state directly - use `setPartial()` or action methods
|
||||
- Don't call async store methods during render
|
||||
- Don't mix server and client state in the same store
|
||||
- Don't access stores outside of React components (except initialization)
|
||||
|
||||
## Performance Tips
|
||||
|
||||
1. **Computed Properties**: Use getters for derived state - they're memoized by MobX
|
||||
2. **Request Deduplication**: Automatic for server stores - prevents wasted API calls
|
||||
3. **Optimistic Updates**: Used in `updateMemo` - immediate UI feedback
|
||||
4. **Fine-grained Reactivity**: MobX only re-renders components that access changed properties
|
||||
|
||||
## Testing
|
||||
|
||||
```typescript
|
||||
import { memoStore } from "@/store";
|
||||
|
||||
describe("memoStore", () => {
|
||||
it("should fetch memos", async () => {
|
||||
const memos = await memoStore.fetchMemos({ filter: "..." });
|
||||
expect(memos).toBeDefined();
|
||||
});
|
||||
|
||||
it("should cache memos", () => {
|
||||
const memo = memoStore.getMemoByName("memos/123");
|
||||
expect(memo).toBeDefined();
|
||||
});
|
||||
});
|
||||
```
|
||||
|
||||
## Migration Guide
|
||||
|
||||
If you're migrating from old store patterns:
|
||||
|
||||
1. **Replace direct state mutations** with `setPartial()`:
|
||||
```typescript
|
||||
// Before
|
||||
store.state.value = 5;
|
||||
|
||||
// After
|
||||
store.state.setPartial({ value: 5 });
|
||||
```
|
||||
|
||||
2. **Wrap API calls** with `executeRequest()`:
|
||||
```typescript
|
||||
// Before
|
||||
const data = await api.fetch();
|
||||
state.data = data;
|
||||
|
||||
// After
|
||||
return executeRequest("fetchData", async () => {
|
||||
const data = await api.fetch();
|
||||
state.setPartial({ data });
|
||||
return data;
|
||||
}, "FETCH_FAILED");
|
||||
```
|
||||
|
||||
3. **Use StandardState** for new stores:
|
||||
```typescript
|
||||
// Before
|
||||
class State {
|
||||
constructor() { makeAutoObservable(this); }
|
||||
}
|
||||
|
||||
// After
|
||||
class State extends StandardState {
|
||||
// makeAutoObservable() called automatically
|
||||
}
|
||||
```
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
**Q: Component not re-rendering when state changes?**
|
||||
A: Make sure you wrapped it with `observer()` from `mobx-react-lite`.
|
||||
|
||||
**Q: Getting "Cannot modify state outside of actions" error?**
|
||||
A: Use `state.setPartial()` instead of direct mutations.
|
||||
|
||||
**Q: API calls firing multiple times?**
|
||||
A: Check that your store uses `createServerStore()` with deduplication enabled.
|
||||
|
||||
**Q: localStorage not persisting?**
|
||||
A: Ensure your client store overrides `setPartial()` to call `localStorage.setItem()`.
|
||||
|
||||
## Resources
|
||||
|
||||
- [MobX Documentation](https://mobx.js.org/)
|
||||
- [mobx-react-lite](https://github.com/mobxjs/mobx-react-lite)
|
||||
- [Store Pattern Guide](./base-store.ts)
|
||||
Some files were not shown because too many files have changed in this diff Show More
Loading…
Reference in New Issue