first commit
Some checks failed
Backend Tests / Static Checks (push) Has been cancelled
Backend Tests / Tests (other) (push) Has been cancelled
Backend Tests / Tests (plugin) (push) Has been cancelled
Backend Tests / Tests (server) (push) Has been cancelled
Backend Tests / Tests (store) (push) Has been cancelled
Build Canary Image / build-frontend (push) Has been cancelled
Build Canary Image / build-push (linux/amd64) (push) Has been cancelled
Build Canary Image / build-push (linux/arm64) (push) Has been cancelled
Build Canary Image / merge (push) Has been cancelled
Frontend Tests / Lint (push) Has been cancelled
Frontend Tests / Build (push) Has been cancelled
Proto Linter / Lint Protos (push) Has been cancelled

This commit is contained in:
2026-03-04 06:30:47 +00:00
commit bb402d4ccc
777 changed files with 135661 additions and 0 deletions

7
web/.gitignore vendored Normal file
View File

@@ -0,0 +1,7 @@
node_modules
.pnpm-store
.DS_Store
dist
dist-ssr
*.local
src/types/proto/store

202
web/biome.json Normal file
View File

@@ -0,0 +1,202 @@
{
"$schema": "https://biomejs.dev/schemas/2.3.5/schema.json",
"vcs": {
"enabled": true,
"clientKind": "git",
"useIgnoreFile": true
},
"files": {
"includes": [
"**",
"!!**/dist",
"!src/types/proto"
],
"ignoreUnknown": true
},
"formatter": {
"enabled": true,
"formatWithErrors": false,
"indentStyle": "space",
"indentWidth": 2,
"lineEnding": "lf",
"lineWidth": 140,
"attributePosition": "auto",
"bracketSameLine": false,
"bracketSpacing": true,
"expand": "auto",
"useEditorconfig": true
},
"linter": {
"enabled": true,
"rules": {
"recommended": false,
"complexity": {
"noAdjacentSpacesInRegex": "error",
"noExtraBooleanCast": "error",
"noUselessCatch": "error",
"noUselessEscapeInRegex": "error",
"noUselessTypeConstraint": "error"
},
"correctness": {
"noConstAssign": "error",
"noConstantCondition": "error",
"noEmptyCharacterClassInRegex": "error",
"noEmptyPattern": "error",
"noGlobalObjectCalls": "error",
"noInvalidBuiltinInstantiation": "error",
"noInvalidConstructorSuper": "error",
"noNonoctalDecimalEscape": "error",
"noPrecisionLoss": "error",
"noSelfAssign": "error",
"noSetterReturn": "error",
"noSwitchDeclarations": "error",
"noUndeclaredVariables": "error",
"noUnreachable": "error",
"noUnreachableSuper": "error",
"noUnsafeFinally": "error",
"noUnsafeOptionalChaining": "error",
"noUnusedLabels": "error",
"noUnusedPrivateClassMembers": "error",
"noUnusedVariables": "error",
"useIsNan": "error",
"useValidForDirection": "error",
"useValidTypeof": "error",
"useYield": "error"
},
"style": {
"noCommonJs": "error",
"noNamespace": "error",
"useArrayLiterals": "error",
"useAsConstAssertion": "error",
"useBlockStatements": "off"
},
"suspicious": {
"noAsyncPromiseExecutor": "error",
"noCatchAssign": "error",
"noClassAssign": "error",
"noCompareNegZero": "error",
"noConstantBinaryExpressions": "error",
"noControlCharactersInRegex": "error",
"noDebugger": "error",
"noDuplicateCase": "error",
"noDuplicateClassMembers": "error",
"noDuplicateElseIf": "error",
"noDuplicateObjectKeys": "error",
"noDuplicateParameters": "error",
"noEmptyBlockStatements": "off",
"noExplicitAny": "error",
"noExtraNonNullAssertion": "error",
"noFallthroughSwitchClause": "error",
"noFunctionAssign": "error",
"noGlobalAssign": "error",
"noImportAssign": "error",
"noIrregularWhitespace": "error",
"noMisleadingCharacterClass": "error",
"noMisleadingInstantiator": "error",
"noNonNullAssertedOptionalChain": "error",
"noPrototypeBuiltins": "error",
"noRedeclare": "error",
"noShadowRestrictedNames": "error",
"noSparseArray": "error",
"noUnsafeDeclarationMerging": "error",
"noUnsafeNegation": "error",
"noUselessRegexBackrefs": "error",
"noWith": "error",
"useGetterReturn": "error",
"useNamespaceKeyword": "error"
}
},
"includes": [
"**",
"!**/dist/**",
"!**/node_modules/**",
"!src/types/proto/**"
]
},
"javascript": {
"formatter": {
"jsxQuoteStyle": "double",
"quoteProperties": "asNeeded",
"trailingCommas": "all",
"semicolons": "always",
"arrowParentheses": "always",
"bracketSameLine": false,
"quoteStyle": "double",
"attributePosition": "auto",
"bracketSpacing": true
},
"globals": []
},
"css": {
"parser": {
"cssModules": false,
"allowWrongLineComments": true,
"tailwindDirectives": true
}
},
"html": {
"formatter": {
"indentScriptAndStyle": false,
"selfCloseVoidElements": "always"
}
},
"overrides": [
{
"includes": [
"**/*.ts",
"**/*.tsx",
"**/*.mts",
"**/*.cts"
],
"linter": {
"rules": {
"complexity": {
"noArguments": "error"
},
"correctness": {
"noConstAssign": "off",
"noGlobalObjectCalls": "off",
"noInvalidBuiltinInstantiation": "off",
"noInvalidConstructorSuper": "off",
"noSetterReturn": "off",
"noUndeclaredVariables": "off",
"noUnreachable": "off",
"noUnreachableSuper": "off"
},
"style": {
"useConst": "error"
},
"suspicious": {
"noClassAssign": "off",
"noDuplicateClassMembers": "off",
"noDuplicateObjectKeys": "off",
"noDuplicateParameters": "off",
"noFunctionAssign": "off",
"noImportAssign": "off",
"noRedeclare": "off",
"noUnsafeNegation": "off",
"noVar": "error",
"noWith": "off",
"useGetterReturn": "off"
}
}
}
},
{
"includes": [
"src/utils/i18n.ts"
],
"linter": {
"rules": {}
}
}
],
"assist": {
"enabled": true,
"actions": {
"source": {
"organizeImports": "on"
}
}
}
}

21
web/components.json Normal file
View File

@@ -0,0 +1,21 @@
{
"$schema": "https://ui.shadcn.com/schema.json",
"style": "new-york",
"rsc": false,
"tsx": true,
"tailwind": {
"config": "",
"css": "src/index.css",
"baseColor": "zinc",
"cssVariables": true,
"prefix": ""
},
"aliases": {
"components": "@/components",
"utils": "@/lib/utils",
"ui": "@/components/ui",
"lib": "@/lib",
"hooks": "@/hooks"
},
"iconLibrary": "lucide"
}

View File

@@ -0,0 +1,55 @@
# Authentication State Architecture
## Current Approach: AuthContext
The application uses **AuthContext** for authentication state management, not React Query's `useCurrentUserQuery`. This is an intentional architectural decision.
### Why AuthContext Instead of React Query?
#### 1. **Synchronous Initialization**
- AuthContext fetches user data during app initialization (`main.tsx`)
- Provides synchronous access to `currentUser` throughout the app
- No need to handle loading states in every component
#### 2. **Single Source of Truth**
- User data fetched once on mount
- All components get consistent, up-to-date user info
- No race conditions from multiple query instances
#### 3. **Integration with React Query**
- AuthContext pre-populates React Query cache after fetch (line 81-82 in `AuthContext.tsx`)
- Best of both worlds: synchronous access + cache consistency
- React Query hooks like `useNotifications()` can still use the cached user data
#### 4. **Simpler Component Code**
```typescript
// With AuthContext (current)
const user = useCurrentUser(); // Always returns User | undefined
// With React Query (alternative)
const { data: user, isLoading } = useCurrentUserQuery();
if (isLoading) return <Spinner />;
// Need loading handling everywhere
```
### When to Use React Query for Auth?
Consider migrating auth to React Query if:
- App needs real-time user profile updates from external sources
- Multiple tabs need instant sync
- User data changes frequently during a session
For Memos (a notes app where user profile rarely changes), AuthContext is the right choice.
### Future Considerations
The unused `useCurrentUserQuery()` hook in `useUserQueries.ts` is kept for potential future use. If requirements change (e.g., real-time collaboration on user profiles), migration path is clear:
1. Remove AuthContext
2. Use `useCurrentUserQuery()` everywhere
3. Handle loading states in components
4. Add suspense boundaries if needed
## Recommendation
**Keep the current AuthContext approach.** It provides better DX and performance for this use case.

20
web/index.html Normal file
View File

@@ -0,0 +1,20 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta http-equiv="Cache-Control" content="no-cache, no-store, must-revalidate" />
<meta http-equiv="Pragma" content="no-cache" />
<meta http-equiv="Expires" content="0" />
<link rel="apple-touch-icon" sizes="180x180" href="/apple-touch-icon.png" />
<link rel="icon" type="image/webp" href="/logo.webp" />
<link rel="manifest" href="/site.webmanifest" />
<meta name="viewport" content="width=device-width, initial-scale=1, user-scalable=no" />
<!-- memos.metadata.head -->
<title>Memos</title>
</head>
<body class="text-base w-full min-h-svh">
<div id="root" class="relative w-full min-h-full"></div>
<script type="module" src="/src/main.tsx"></script>
<!-- memos.metadata.body -->
</body>
</html>

8340
web/package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

99
web/package.json Normal file
View File

@@ -0,0 +1,99 @@
{
"name": "memos",
"private": true,
"scripts": {
"dev": "vite",
"build": "vite build",
"release": "vite build --mode release --outDir=../server/router/frontend/dist --emptyOutDir",
"lint": "tsc --noEmit --skipLibCheck && biome check src",
"lint:fix": "biome check --write src",
"format": "biome format --write src"
},
"dependencies": {
"@connectrpc/connect": "^2.1.1",
"@connectrpc/connect-web": "^2.1.1",
"@emotion/react": "^11.14.0",
"@emotion/styled": "^11.14.1",
"@github/relative-time-element": "^4.5.0",
"@radix-ui/react-checkbox": "^1.3.3",
"@radix-ui/react-dialog": "^1.1.15",
"@radix-ui/react-dropdown-menu": "^2.1.16",
"@radix-ui/react-label": "^2.1.8",
"@radix-ui/react-popover": "^1.1.15",
"@radix-ui/react-radio-group": "^1.3.8",
"@radix-ui/react-select": "^2.2.6",
"@radix-ui/react-separator": "^1.1.8",
"@radix-ui/react-slot": "^1.2.4",
"@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",
"dayjs": "^1.11.19",
"fuse.js": "^7.1.0",
"highlight.js": "^11.11.1",
"i18next": "^25.6.3",
"katex": "^0.16.27",
"leaflet": "^1.9.4",
"leaflet.markercluster": "^1.5.3",
"lodash-es": "^4.17.21",
"lucide-react": "^0.544.0",
"mdast-util-from-markdown": "^2.0.2",
"mdast-util-gfm": "^3.1.0",
"mermaid": "^11.12.1",
"micromark-extension-gfm": "^3.0.0",
"mime": "^4.1.0",
"react": "^18.3.1",
"react-dom": "^18.3.1",
"react-force-graph-2d": "^1.29.0",
"react-hot-toast": "^2.6.0",
"react-i18next": "^15.7.4",
"react-leaflet": "^4.2.1",
"react-leaflet-cluster": "^2.1.0",
"react-markdown": "^10.1.0",
"react-router-dom": "^7.9.6",
"react-use": "^17.6.0",
"rehype-katex": "^7.0.1",
"rehype-raw": "^7.0.0",
"rehype-sanitize": "^6.0.0",
"remark-breaks": "^4.0.0",
"remark-gfm": "^4.0.1",
"remark-math": "^6.0.0",
"tailwind-merge": "^3.4.0",
"tailwindcss": "^4.1.17",
"textarea-caret": "^3.1.0",
"unist-util-visit": "^5.0.0",
"uuid": "^11.1.0"
},
"devDependencies": {
"@biomejs/biome": "^2.3.7",
"@bufbuild/protobuf": "^2.10.1",
"@types/d3": "^7.4.3",
"@types/hast": "^3.0.4",
"@types/katex": "^0.16.7",
"@types/leaflet": "^1.9.21",
"@types/lodash-es": "^4.17.12",
"@types/mdast": "^4.0.4",
"@types/node": "^24.10.1",
"@types/qs": "^6.14.0",
"@types/react": "^18.3.27",
"@types/react-dom": "^18.3.7",
"@types/textarea-caret": "^3.0.4",
"@types/unist": "^3.0.3",
"@types/uuid": "^10.0.0",
"@vitejs/plugin-react": "^4.7.0",
"long": "^5.3.2",
"terser": "^5.44.1",
"tw-animate-css": "^1.4.0",
"typescript": "^5.9.3",
"vite": "^7.2.4"
},
"pnpm": {
"onlyBuiltDependencies": [
"esbuild"
]
}
}

5949
web/pnpm-lock.yaml generated Normal file

File diff suppressed because it is too large Load Diff

Binary file not shown.

After

Width:  |  Height:  |  Size: 24 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 138 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 22 KiB

BIN
web/public/full-logo.webp Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 25 KiB

BIN
web/public/logo.webp Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 36 KiB

View File

@@ -0,0 +1,10 @@
{
"name": "Memos",
"short_name": "Memos",
"icons": [
{ "src": "/android-chrome-192x192.png", "sizes": "192x192", "type": "image/png" },
{ "src": "/android-chrome-512x512.png", "sizes": "512x512", "type": "image/png" }
],
"display": "standalone",
"start_url": "/"
}

66
web/src/App.tsx Normal file
View File

@@ -0,0 +1,66 @@
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 { cleanupExpiredOAuthState } from "./utils/oauth";
const App = () => {
const navigateTo = useNavigateTo();
const { profile: instanceProfile, profileLoaded, generalSetting: instanceGeneralSetting } = useInstance();
// Apply user preferences reactively
useUserLocale();
useUserTheme();
// Clean up expired OAuth states on app initialization
useEffect(() => {
cleanupExpiredOAuthState();
}, []);
// Redirect to sign up page if instance not initialized (no admin account exists yet).
// Guard with profileLoaded so a fetch failure doesn't incorrectly trigger the redirect.
useEffect(() => {
if (profileLoaded && !instanceProfile.admin) {
navigateTo("/auth/signup");
}
}, [profileLoaded, instanceProfile.admin, navigateTo]);
useEffect(() => {
if (instanceGeneralSetting.additionalStyle) {
const styleEl = document.createElement("style");
styleEl.innerHTML = instanceGeneralSetting.additionalStyle;
styleEl.setAttribute("type", "text/css");
document.body.insertAdjacentElement("beforeend", styleEl);
}
}, [instanceGeneralSetting.additionalStyle]);
useEffect(() => {
if (instanceGeneralSetting.additionalScript) {
const scriptEl = document.createElement("script");
scriptEl.innerHTML = instanceGeneralSetting.additionalScript;
document.head.appendChild(scriptEl);
}
}, [instanceGeneralSetting.additionalScript]);
// Dynamic update metadata with customized profile
useEffect(() => {
if (!instanceGeneralSetting.customProfile) {
return;
}
document.title = instanceGeneralSetting.customProfile.title;
const link = document.querySelector("link[rel~='icon']") as HTMLLinkElement;
link.href = instanceGeneralSetting.customProfile.logoUrl || "/logo.webp";
}, [instanceGeneralSetting.customProfile]);
return (
<MemoFilterProvider>
<Outlet />
</MemoFilterProvider>
);
};
export default App;

129
web/src/auth-state.ts Normal file
View File

@@ -0,0 +1,129 @@
// Access token storage using localStorage for persistence across tabs and sessions.
// Tokens are cleared on logout or expiry.
let accessToken: string | null = null;
let tokenExpiresAt: Date | null = null;
const TOKEN_KEY = "memos_access_token";
const EXPIRES_KEY = "memos_token_expires_at";
// BroadcastChannel lets tabs share freshly-refreshed tokens so that only one
// tab needs to hit the refresh endpoint. When another tab successfully refreshes
// we adopt the new token immediately, avoiding a redundant (and potentially
// conflicting) refresh request of our own.
const TOKEN_CHANNEL_NAME = "memos_token_sync";
// Token refresh policy:
// - REQUEST_TOKEN_EXPIRY_BUFFER_MS: used for normal API requests.
// - FOCUS_TOKEN_EXPIRY_BUFFER_MS: used on tab visibility restore to refresh earlier.
export const REQUEST_TOKEN_EXPIRY_BUFFER_MS = 30 * 1000;
export const FOCUS_TOKEN_EXPIRY_BUFFER_MS = 2 * 60 * 1000;
interface TokenBroadcastMessage {
token: string;
expiresAt: string; // ISO string
}
let tokenChannel: BroadcastChannel | null = null;
function getTokenChannel(): BroadcastChannel | null {
if (tokenChannel) return tokenChannel;
try {
tokenChannel = new BroadcastChannel(TOKEN_CHANNEL_NAME);
tokenChannel.onmessage = (event: MessageEvent<TokenBroadcastMessage>) => {
const { token, expiresAt } = event.data ?? {};
if (token && expiresAt) {
// Another tab refreshed — adopt the token in-memory so we don't
// fire our own refresh request.
accessToken = token;
tokenExpiresAt = new Date(expiresAt);
}
};
} catch {
// BroadcastChannel not available (e.g. some privacy modes)
tokenChannel = null;
}
return tokenChannel;
}
// Initialize the channel at module load so the listener is registered
// before any token refresh can occur in any tab.
getTokenChannel();
export const getAccessToken = (): string | null => {
if (!accessToken) {
try {
const storedToken = localStorage.getItem(TOKEN_KEY);
const storedExpires = localStorage.getItem(EXPIRES_KEY);
if (storedToken && storedExpires) {
const expiresAt = new Date(storedExpires);
if (expiresAt > new Date()) {
accessToken = storedToken;
tokenExpiresAt = expiresAt;
}
// Do NOT remove expired tokens here. Callers such as InstanceContext.initialize()
// run concurrently with AuthContext.initialize() via Promise.all. If we eagerly
// delete the expired token from localStorage, hasStoredToken() (called synchronously
// inside AuthContext.initialize()) finds nothing and skips the refresh attempt,
// logging the user out even when the refresh-token cookie is still valid.
// clearAccessToken() handles proper cleanup after a confirmed auth failure or logout.
}
} catch (e) {
// localStorage might not be available (e.g., in some privacy modes)
console.warn("Failed to access localStorage:", e);
}
}
return accessToken;
};
export const setAccessToken = (token: string | null, expiresAt?: Date): void => {
accessToken = token;
tokenExpiresAt = expiresAt || null;
try {
if (token && expiresAt) {
localStorage.setItem(TOKEN_KEY, token);
localStorage.setItem(EXPIRES_KEY, expiresAt.toISOString());
// Broadcast to other tabs so they adopt the new token without refreshing.
const msg: TokenBroadcastMessage = { token, expiresAt: expiresAt.toISOString() };
getTokenChannel()?.postMessage(msg);
} else {
localStorage.removeItem(TOKEN_KEY);
localStorage.removeItem(EXPIRES_KEY);
}
} catch (e) {
// localStorage might not be available (e.g., in some privacy modes)
console.warn("Failed to write to localStorage:", e);
}
};
export const isTokenExpired = (bufferMs: number = REQUEST_TOKEN_EXPIRY_BUFFER_MS): boolean => {
if (!tokenExpiresAt) return true;
// Consider expired with a safety buffer before actual expiry.
return new Date() >= new Date(tokenExpiresAt.getTime() - bufferMs);
};
// Returns true if a token exists in localStorage, even if it is expired.
// Used to decide whether to attempt GetCurrentUser on app init — if no token
// was ever stored, the user is definitively not logged in and there is nothing
// to refresh, so we can skip the network round-trip entirely.
export const hasStoredToken = (): boolean => {
if (accessToken) return true;
try {
return !!localStorage.getItem(TOKEN_KEY);
} catch {
return false;
}
};
export const clearAccessToken = (): void => {
accessToken = null;
tokenExpiresAt = null;
try {
localStorage.removeItem(TOKEN_KEY);
localStorage.removeItem(EXPIRES_KEY);
} catch (e) {
console.warn("Failed to clear localStorage:", e);
}
};

View File

@@ -0,0 +1,82 @@
import { memo } from "react";
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
import { cn } from "@/lib/utils";
import { DEFAULT_CELL_SIZE, SMALL_CELL_SIZE } from "./constants";
import type { CalendarDayCell, CalendarSize } from "./types";
import { getCellIntensityClass } from "./utils";
export interface CalendarCellProps {
day: CalendarDayCell;
maxCount: number;
tooltipText: string;
onClick?: (date: string) => void;
size?: CalendarSize;
disableTooltip?: boolean;
}
export const CalendarCell = memo((props: CalendarCellProps) => {
const { day, maxCount, tooltipText, onClick, size = "default", disableTooltip = false } = props;
const handleClick = () => {
if (day.count > 0 && onClick) {
onClick(day.date);
}
};
const sizeConfig = size === "small" ? SMALL_CELL_SIZE : DEFAULT_CELL_SIZE;
const smallExtraClasses = size === "small" ? `${SMALL_CELL_SIZE.dimensions} min-h-0` : "";
const baseClasses = cn(
"aspect-square w-full flex items-center justify-center text-center transition-all duration-150 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring/40 focus-visible:ring-offset-2 select-none border border-border/10 bg-muted/20",
sizeConfig.font,
sizeConfig.borderRadius,
smallExtraClasses,
);
const isInteractive = Boolean(onClick && day.count > 0);
const ariaLabel = day.isSelected ? `${tooltipText} (selected)` : tooltipText;
if (!day.isCurrentMonth) {
return <div className={cn(baseClasses, "text-muted-foreground/30 bg-transparent border-transparent cursor-default")}>{day.label}</div>;
}
const intensityClass = getCellIntensityClass(day, maxCount);
const buttonClasses = cn(
baseClasses,
intensityClass,
day.isToday && "ring-2 ring-primary/30 ring-offset-1 font-semibold z-10",
day.isSelected && "ring-2 ring-primary ring-offset-1 font-bold z-10",
isInteractive ? "cursor-pointer hover:bg-muted/40 hover:border-border/30" : "cursor-default",
);
const button = (
<button
type="button"
onClick={handleClick}
tabIndex={isInteractive ? 0 : -1}
aria-label={ariaLabel}
aria-current={day.isToday ? "date" : undefined}
aria-disabled={!isInteractive}
className={buttonClasses}
>
{day.label}
</button>
);
const shouldShowTooltip = tooltipText && day.count > 0 && !disableTooltip;
if (!shouldShowTooltip) {
return button;
}
return (
<Tooltip>
<TooltipTrigger asChild>{button}</TooltipTrigger>
<TooltipContent side="top">
<p>{tooltipText}</p>
</TooltipContent>
</Tooltip>
);
});
CalendarCell.displayName = "CalendarCell";

View File

@@ -0,0 +1,76 @@
import { memo, useMemo } from "react";
import { useInstance } from "@/contexts/InstanceContext";
import { cn } from "@/lib/utils";
import { useTranslate } from "@/utils/i18n";
import { CalendarCell } from "./CalendarCell";
import { useTodayDate, useWeekdayLabels } from "./hooks";
import type { CalendarSize, MonthCalendarProps } from "./types";
import { useCalendarMatrix } from "./useCalendar";
import { getTooltipText } from "./utils";
const GRID_STYLES: Record<CalendarSize, { gap: string; headerText: string }> = {
small: { gap: "gap-1.5", headerText: "text-[10px]" },
default: { gap: "gap-2", headerText: "text-xs" },
};
interface WeekdayHeaderProps {
weekDays: string[];
size: CalendarSize;
}
const WeekdayHeader = memo(({ weekDays, size }: WeekdayHeaderProps) => (
<div className={cn("grid grid-cols-7 mb-1", GRID_STYLES[size].gap, GRID_STYLES[size].headerText)} role="row">
{weekDays.map((label, index) => (
<div
key={index}
className="flex h-4 items-center justify-center font-medium uppercase tracking-wide text-muted-foreground/60"
role="columnheader"
aria-label={label}
>
{label}
</div>
))}
</div>
));
WeekdayHeader.displayName = "WeekdayHeader";
export const MonthCalendar = memo((props: MonthCalendarProps) => {
const { month, data, maxCount, size = "default", onClick, className, disableTooltips = false } = props;
const t = useTranslate();
const { generalSetting } = useInstance();
const today = useTodayDate();
const weekDays = useWeekdayLabels();
const { weeks, weekDays: rotatedWeekDays } = useCalendarMatrix({
month,
data,
weekDays,
weekStartDayOffset: generalSetting.weekStartDayOffset,
today,
selectedDate: "",
});
const flatDays = useMemo(() => weeks.flatMap((week) => week.days), [weeks]);
return (
<div className={cn("flex flex-col", className)} role="grid" aria-label={`Calendar for ${month}`}>
<WeekdayHeader weekDays={rotatedWeekDays} size={size} />
<div className={cn("grid grid-cols-7", GRID_STYLES[size].gap)} role="rowgroup">
{flatDays.map((day) => (
<CalendarCell
key={day.date}
day={day}
maxCount={maxCount}
tooltipText={getTooltipText(day.count, day.date, t)}
onClick={onClick}
size={size}
disableTooltip={disableTooltips}
/>
))}
</div>
</div>
);
});
MonthCalendar.displayName = "MonthCalendar";

View File

@@ -0,0 +1,116 @@
import { ChevronLeftIcon, ChevronRightIcon } from "lucide-react";
import { memo, useMemo } from "react";
import { Button } from "@/components/ui/button";
import { cn } from "@/lib/utils";
import { useTranslate } from "@/utils/i18n";
import { getMaxYear, MIN_YEAR } from "./constants";
import { MonthCalendar } from "./MonthCalendar";
import type { YearCalendarProps } from "./types";
import { calculateYearMaxCount, filterDataByYear, generateMonthsForYear, getMonthLabel } from "./utils";
interface YearNavigationProps {
selectedYear: number;
currentYear: number;
onPrev: () => void;
onNext: () => void;
onToday: () => void;
canGoPrev: boolean;
canGoNext: boolean;
}
const YearNavigation = memo(({ selectedYear, currentYear, onPrev, onNext, onToday, canGoPrev, canGoNext }: YearNavigationProps) => {
const t = useTranslate();
const isCurrentYear = selectedYear === currentYear;
return (
<div className="flex items-center justify-between px-1">
<h2 className="text-2xl font-semibold text-foreground tracking-tight">{selectedYear}</h2>
<nav className="inline-flex items-center gap-0.5 rounded-lg border border-border/30 bg-muted/10 p-0.5" aria-label="Year navigation">
<Button
variant="ghost"
size="sm"
onClick={onPrev}
disabled={!canGoPrev}
aria-label="Previous year"
className="h-7 w-7 p-0 rounded-md text-muted-foreground hover:text-foreground hover:bg-muted/40"
>
<ChevronLeftIcon className="w-4 h-4" />
</Button>
<Button
variant="ghost"
size="sm"
onClick={onToday}
disabled={isCurrentYear}
aria-label={t("common.today")}
className={cn(
"h-7 px-2.5 rounded-md text-[10px] font-medium uppercase tracking-wider",
isCurrentYear ? "text-muted-foreground/50 cursor-default" : "text-muted-foreground hover:text-foreground hover:bg-muted/40",
)}
>
{t("common.today")}
</Button>
<Button
variant="ghost"
size="sm"
onClick={onNext}
disabled={!canGoNext}
aria-label="Next year"
className="h-7 w-7 p-0 rounded-md text-muted-foreground hover:text-foreground hover:bg-muted/40"
>
<ChevronRightIcon className="w-4 h-4" />
</Button>
</nav>
</div>
);
});
YearNavigation.displayName = "YearNavigation";
interface MonthCardProps {
month: string;
data: Record<string, number>;
maxCount: number;
onDateClick: (date: string) => void;
}
const MonthCard = memo(({ month, data, maxCount, onDateClick }: MonthCardProps) => (
<article className="flex flex-col gap-2 rounded-xl border border-border/20 bg-muted/5 p-3 transition-colors hover:bg-muted/10">
<header className="text-[10px] font-medium text-muted-foreground/80 uppercase tracking-widest">{getMonthLabel(month)}</header>
<MonthCalendar month={month} data={data} maxCount={maxCount} size="small" onClick={onDateClick} disableTooltips />
</article>
));
MonthCard.displayName = "MonthCard";
export const YearCalendar = memo(({ selectedYear, data, onYearChange, onDateClick, className }: YearCalendarProps) => {
const currentYear = useMemo(() => new Date().getFullYear(), []);
const yearData = useMemo(() => filterDataByYear(data, selectedYear), [data, selectedYear]);
const months = useMemo(() => generateMonthsForYear(selectedYear), [selectedYear]);
const yearMaxCount = useMemo(() => calculateYearMaxCount(yearData), [yearData]);
const canGoPrev = selectedYear > MIN_YEAR;
const canGoNext = selectedYear < getMaxYear();
return (
<section className={cn("w-full flex flex-col gap-5 px-4 py-4 select-none", className)} aria-label={`Year ${selectedYear} calendar`}>
<YearNavigation
selectedYear={selectedYear}
currentYear={currentYear}
onPrev={() => canGoPrev && onYearChange(selectedYear - 1)}
onNext={() => canGoNext && onYearChange(selectedYear + 1)}
onToday={() => onYearChange(currentYear)}
canGoPrev={canGoPrev}
canGoNext={canGoNext}
/>
<div className="grid gap-4 grid-cols-1 sm:grid-cols-2 md:grid-cols-3 lg:grid-cols-4 animate-fade-in">
{months.map((month) => (
<MonthCard key={month} month={month} data={yearData} maxCount={yearMaxCount} onDateClick={onDateClick} />
))}
</div>
</section>
);
});
YearCalendar.displayName = "YearCalendar";

View File

@@ -0,0 +1,35 @@
export const DAYS_IN_WEEK = 7;
export const MONTHS_IN_YEAR = 12;
export const WEEKEND_DAYS = [0, 6] as const;
export const MIN_COUNT = 1;
export const MIN_YEAR = 1970;
export const getMaxYear = () => new Date().getFullYear() + 1;
export const INTENSITY_THRESHOLDS = {
HIGH: 0.75,
MEDIUM: 0.5,
LOW: 0.25,
MINIMAL: 0,
} as const;
export const CELL_STYLES = {
HIGH: "bg-primary text-primary-foreground shadow-sm border-transparent",
MEDIUM: "bg-primary/85 text-primary-foreground shadow-sm border-transparent",
LOW: "bg-primary/70 text-primary-foreground border-transparent",
MINIMAL: "bg-primary/50 text-foreground border-transparent",
EMPTY: "bg-muted/20 text-muted-foreground hover:bg-muted/30 border-border/10",
} as const;
export const SMALL_CELL_SIZE = {
font: "text-[11px]",
dimensions: "w-full h-full",
borderRadius: "rounded-lg",
gap: "gap-1.5",
} as const;
export const DEFAULT_CELL_SIZE = {
font: "text-xs",
borderRadius: "rounded-lg",
gap: "gap-2",
} as const;

View File

@@ -0,0 +1,12 @@
import dayjs from "dayjs";
import { useMemo } from "react";
import { useTranslate } from "@/utils/i18n";
export const useWeekdayLabels = () => {
const t = useTranslate();
return useMemo(() => [t("days.sun"), t("days.mon"), t("days.tue"), t("days.wed"), t("days.thu"), t("days.fri"), t("days.sat")], [t]);
};
export const useTodayDate = () => {
return dayjs().format("YYYY-MM-DD");
};

View File

@@ -0,0 +1,4 @@
export * from "./MonthCalendar";
export * from "./types";
export * from "./utils";
export * from "./YearCalendar";

View File

@@ -0,0 +1,39 @@
export type CalendarSize = "default" | "small";
export interface CalendarDayCell {
date: string;
label: number;
count: number;
isCurrentMonth: boolean;
isToday: boolean;
isSelected: boolean;
isWeekend: boolean;
}
export interface CalendarDayRow {
days: CalendarDayCell[];
}
export interface CalendarMatrixResult {
weeks: CalendarDayRow[];
weekDays: string[];
maxCount: number;
}
export interface MonthCalendarProps {
month: string;
data: Record<string, number>;
maxCount: number;
size?: CalendarSize;
onClick?: (date: string) => void;
className?: string;
disableTooltips?: boolean;
}
export interface YearCalendarProps {
selectedYear: number;
data: Record<string, number>;
onYearChange: (year: number) => void;
onDateClick: (date: string) => void;
className?: string;
}

View File

@@ -0,0 +1,94 @@
import dayjs from "dayjs";
import { useMemo } from "react";
import { DAYS_IN_WEEK, MIN_COUNT, WEEKEND_DAYS } from "./constants";
import type { CalendarDayCell, CalendarMatrixResult } from "./types";
export interface UseCalendarMatrixParams {
month: string;
data: Record<string, number>;
weekDays: string[];
weekStartDayOffset: number;
today: string;
selectedDate: string;
}
const createCalendarDayCell = (
current: dayjs.Dayjs,
monthKey: string,
data: Record<string, number>,
today: string,
selectedDate: string,
): CalendarDayCell => {
const isoDate = current.format("YYYY-MM-DD");
const isCurrentMonth = current.format("YYYY-MM") === monthKey;
const count = data[isoDate] ?? 0;
return {
date: isoDate,
label: current.date(),
count,
isCurrentMonth,
isToday: isoDate === today,
isSelected: isoDate === selectedDate,
isWeekend: WEEKEND_DAYS.includes(current.day() as 0 | 6),
};
};
const calculateCalendarBoundaries = (monthStart: dayjs.Dayjs, weekStartDayOffset: number) => {
const monthEnd = monthStart.endOf("month");
const startOffset = (monthStart.day() - weekStartDayOffset + DAYS_IN_WEEK) % DAYS_IN_WEEK;
const endOffset = (weekStartDayOffset + (DAYS_IN_WEEK - 1) - monthEnd.day() + DAYS_IN_WEEK) % DAYS_IN_WEEK;
const calendarStart = monthStart.subtract(startOffset, "day");
const calendarEnd = monthEnd.add(endOffset, "day");
const dayCount = calendarEnd.diff(calendarStart, "day") + 1;
return { calendarStart, dayCount };
};
/**
* Generates a matrix of calendar days for a given month, handling week alignment and data mapping.
*/
export const useCalendarMatrix = ({
month,
data,
weekDays,
weekStartDayOffset,
today,
selectedDate,
}: UseCalendarMatrixParams): CalendarMatrixResult => {
return useMemo(() => {
// Determine the start of the month and its formatted key (YYYY-MM)
const monthStart = dayjs(month).startOf("month");
const monthKey = monthStart.format("YYYY-MM");
// Rotate week labels based on the user's preferred start of the week
const rotatedWeekDays = weekDays.slice(weekStartDayOffset).concat(weekDays.slice(0, weekStartDayOffset));
// Calculate the start and end dates for the calendar grid to ensure full weeks
const { calendarStart, dayCount } = calculateCalendarBoundaries(monthStart, weekStartDayOffset);
const weeks: CalendarMatrixResult["weeks"] = [];
let maxCount = 0;
// Iterate through each day in the calendar grid
for (let index = 0; index < dayCount; index += 1) {
const current = calendarStart.add(index, "day");
const weekIndex = Math.floor(index / DAYS_IN_WEEK);
if (!weeks[weekIndex]) {
weeks[weekIndex] = { days: [] };
}
// Create the day cell object with data and status flags
const dayCell = createCalendarDayCell(current, monthKey, data, today, selectedDate);
weeks[weekIndex].days.push(dayCell);
maxCount = Math.max(maxCount, dayCell.count);
}
return {
weeks,
weekDays: rotatedWeekDays,
maxCount: Math.max(maxCount, MIN_COUNT),
};
}, [month, data, weekDays, weekStartDayOffset, today, selectedDate]);
};

View File

@@ -0,0 +1,72 @@
import dayjs from "dayjs";
import isSameOrAfter from "dayjs/plugin/isSameOrAfter";
import isSameOrBefore from "dayjs/plugin/isSameOrBefore";
import { useTranslate } from "@/utils/i18n";
import { CELL_STYLES, INTENSITY_THRESHOLDS, MIN_COUNT, MONTHS_IN_YEAR } from "./constants";
import type { CalendarDayCell } from "./types";
dayjs.extend(isSameOrAfter);
dayjs.extend(isSameOrBefore);
export type TranslateFunction = ReturnType<typeof useTranslate>;
export const getCellIntensityClass = (day: CalendarDayCell, maxCount: number): string => {
if (!day.isCurrentMonth || day.count === 0) {
return CELL_STYLES.EMPTY;
}
const ratio = day.count / maxCount;
if (ratio > INTENSITY_THRESHOLDS.HIGH) return CELL_STYLES.HIGH;
if (ratio > INTENSITY_THRESHOLDS.MEDIUM) return CELL_STYLES.MEDIUM;
if (ratio > INTENSITY_THRESHOLDS.LOW) return CELL_STYLES.LOW;
return CELL_STYLES.MINIMAL;
};
export const generateMonthsForYear = (year: number): string[] => {
return Array.from({ length: MONTHS_IN_YEAR }, (_, i) => dayjs(`${year}-01-01`).add(i, "month").format("YYYY-MM"));
};
export const calculateYearMaxCount = (data: Record<string, number>): number => {
let max = 0;
for (const count of Object.values(data)) {
max = Math.max(max, count);
}
return Math.max(max, MIN_COUNT);
};
export const getMonthLabel = (month: string): string => {
return dayjs(month).format("MMM");
};
export const filterDataByYear = (data: Record<string, number>, year: number): Record<string, number> => {
if (!data) return {};
const filtered: Record<string, number> = {};
const yearStart = dayjs(`${year}-01-01`);
const yearEnd = dayjs(`${year}-12-31`);
for (const [dateStr, count] of Object.entries(data)) {
const date = dayjs(dateStr);
if (date.isSameOrAfter(yearStart, "day") && date.isSameOrBefore(yearEnd, "day")) {
filtered[dateStr] = count;
}
}
return filtered;
};
export const hasActivityData = (data: Record<string, number>): boolean => {
return Object.values(data).some((count) => count > 0);
};
export const getTooltipText = (count: number, date: string, t: TranslateFunction): string => {
if (count === 0) {
return date;
}
return t("memo.count-memos-in-date", {
count,
memos: count === 1 ? t("common.memo") : t("common.memos"),
date,
}).toLowerCase();
};

View File

@@ -0,0 +1,108 @@
import {
BinaryIcon,
BookIcon,
FileArchiveIcon,
FileAudioIcon,
FileEditIcon,
FileIcon,
FileTextIcon,
FileVideo2Icon,
SheetIcon,
} from "lucide-react";
import React, { useState } from "react";
import { cn } from "@/lib/utils";
import { Attachment } from "@/types/proto/api/v1/attachment_service_pb";
import { getAttachmentThumbnailUrl, getAttachmentType, getAttachmentUrl } from "@/utils/attachment";
import SquareDiv from "./kit/SquareDiv";
import PreviewImageDialog from "./PreviewImageDialog";
interface Props {
attachment: Attachment;
className?: string;
strokeWidth?: number;
}
const AttachmentIcon = (props: Props) => {
const { attachment } = props;
const [previewImage, setPreviewImage] = useState<{ open: boolean; urls: string[]; index: number }>({
open: false,
urls: [],
index: 0,
});
const resourceType = getAttachmentType(attachment);
const attachmentUrl = getAttachmentUrl(attachment);
const className = cn("w-full h-auto", props.className);
const strokeWidth = props.strokeWidth;
const previewResource = () => {
window.open(attachmentUrl);
};
const handleImageClick = () => {
setPreviewImage({ open: true, urls: [attachmentUrl], index: 0 });
};
if (resourceType === "image/*") {
return (
<>
<SquareDiv className={cn(className, "flex items-center justify-center overflow-clip")}>
<img
className="min-w-full min-h-full object-cover"
src={getAttachmentThumbnailUrl(attachment)}
onClick={handleImageClick}
onError={(e) => {
// Fallback to original image if thumbnail fails
const target = e.target as HTMLImageElement;
if (target.src.includes("?thumbnail=true")) {
console.warn("Thumbnail failed, falling back to original image:", attachmentUrl);
target.src = attachmentUrl;
}
}}
decoding="async"
loading="lazy"
/>
</SquareDiv>
<PreviewImageDialog
open={previewImage.open}
onOpenChange={(open) => setPreviewImage((prev) => ({ ...prev, open }))}
imgUrls={previewImage.urls}
initialIndex={previewImage.index}
/>
</>
);
}
const getAttachmentIcon = () => {
switch (resourceType) {
case "video/*":
return <FileVideo2Icon strokeWidth={strokeWidth} className="w-full h-auto" />;
case "audio/*":
return <FileAudioIcon strokeWidth={strokeWidth} className="w-full h-auto" />;
case "text/*":
return <FileTextIcon strokeWidth={strokeWidth} className="w-full h-auto" />;
case "application/epub+zip":
return <BookIcon strokeWidth={strokeWidth} className="w-full h-auto" />;
case "application/pdf":
return <BookIcon strokeWidth={strokeWidth} className="w-full h-auto" />;
case "application/msword":
return <FileEditIcon strokeWidth={strokeWidth} className="w-full h-auto" />;
case "application/msexcel":
return <SheetIcon strokeWidth={strokeWidth} className="w-full h-auto" />;
case "application/zip":
return <FileArchiveIcon onClick={previewResource} strokeWidth={strokeWidth} className="w-full h-auto" />;
case "application/x-java-archive":
return <BinaryIcon strokeWidth={strokeWidth} className="w-full h-auto" />;
default:
return <FileIcon strokeWidth={strokeWidth} className="w-full h-auto" />;
}
};
return (
<div onClick={previewResource} className={cn(className, "max-w-16 opacity-50")}>
{getAttachmentIcon()}
</div>
);
};
export default React.memo(AttachmentIcon);

View File

@@ -0,0 +1,35 @@
import { useState } from "react";
import { useTranslation } from "react-i18next";
import { cn } from "@/lib/utils";
import { loadLocale } from "@/utils/i18n";
import { getInitialTheme, loadTheme, Theme } from "@/utils/theme";
import LocaleSelect from "./LocaleSelect";
import ThemeSelect from "./ThemeSelect";
interface Props {
className?: string;
}
const AuthFooter = ({ className }: Props) => {
const { i18n: i18nInstance } = useTranslation();
const currentLocale = i18nInstance.language as Locale;
const [currentTheme, setCurrentTheme] = useState(getInitialTheme());
const handleLocaleChange = (locale: Locale) => {
loadLocale(locale);
};
const handleThemeChange = (theme: string) => {
loadTheme(theme);
setCurrentTheme(theme as Theme);
};
return (
<div className={cn("mt-4 flex flex-row items-center justify-center w-full gap-2", className)}>
<LocaleSelect value={currentLocale} onChange={handleLocaleChange} />
<ThemeSelect value={currentTheme} onValueChange={handleThemeChange} />
</div>
);
};
export default AuthFooter;

View File

@@ -0,0 +1,114 @@
import { useState } from "react";
import { toast } from "react-hot-toast";
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 { useUpdateUser } from "@/hooks/useUserQueries";
import { handleError } from "@/lib/error";
import { User } from "@/types/proto/api/v1/user_service_pb";
import { useTranslate } from "@/utils/i18n";
interface Props {
open: boolean;
onOpenChange: (open: boolean) => void;
user?: User;
onSuccess?: () => void;
}
function ChangeMemberPasswordDialog({ open, onOpenChange, user, onSuccess }: Props) {
const t = useTranslate();
const { mutateAsync: updateUser } = useUpdateUser();
const [newPassword, setNewPassword] = useState("");
const [newPasswordAgain, setNewPasswordAgain] = useState("");
const handleCloseBtnClick = () => {
onOpenChange(false);
};
const handleNewPasswordChanged = (e: React.ChangeEvent<HTMLInputElement>) => {
const text = e.target.value as string;
setNewPassword(text);
};
const handleNewPasswordAgainChanged = (e: React.ChangeEvent<HTMLInputElement>) => {
const text = e.target.value as string;
setNewPasswordAgain(text);
};
const handleSaveBtnClick = async () => {
if (!user) return;
if (newPassword === "" || newPasswordAgain === "") {
toast.error(t("message.fill-all"));
return;
}
if (newPassword !== newPasswordAgain) {
toast.error(t("message.new-password-not-match"));
setNewPasswordAgain("");
return;
}
try {
await updateUser({
user: {
name: user.name,
password: newPassword,
},
updateMask: ["password"],
});
toast(t("message.password-changed"));
onSuccess?.();
onOpenChange(false);
} catch (error: unknown) {
await handleError(error, toast.error, {
context: "Change member password",
});
}
};
if (!user) return null;
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="max-w-md">
<DialogHeader>
<DialogTitle>
{t("setting.account-section.change-password")} ({user.displayName})
</DialogTitle>
</DialogHeader>
<div className="flex flex-col gap-4">
<div className="grid gap-2">
<Label htmlFor="newPassword">{t("auth.new-password")}</Label>
<Input
id="newPassword"
type="password"
placeholder={t("auth.new-password")}
value={newPassword}
onChange={handleNewPasswordChanged}
/>
</div>
<div className="grid gap-2">
<Label htmlFor="newPasswordAgain">{t("auth.repeat-new-password")}</Label>
<Input
id="newPasswordAgain"
type="password"
placeholder={t("auth.repeat-new-password")}
value={newPasswordAgain}
onChange={handleNewPasswordAgainChanged}
/>
</div>
</div>
<DialogFooter>
<Button variant="ghost" onClick={handleCloseBtnClick}>
{t("common.cancel")}
</Button>
<Button onClick={handleSaveBtnClick}>{t("common.save")}</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}
export default ChangeMemberPasswordDialog;

View File

@@ -0,0 +1,131 @@
# ConfirmDialog - Accessible Confirmation Dialog
## Overview
`ConfirmDialog` standardizes confirmation flows across the app. It replaces adhoc `window.confirm` usage with an accessible, themeable dialog that supports asynchronous operations.
## Key Features
### 1. Accessibility & UX
- Uses shared `Dialog` primitives (focus trap, ARIA roles)
- Blocks dismissal while async confirm is pending
- Clear separation of title (action) vs description (context)
### 2. Async-Aware
- Accepts sync or async `onConfirm`
- Auto-closes on resolve; remains open on error for retry / toast
### 3. Internationalization Ready
- All labels / text provided by caller through i18n hook
- Supports interpolation for dynamic context
### 4. Minimal Surface, Easy Extension
- Lightweight API (few required props)
- Style hook via `.container` class (SCSS module)
## Architecture
```
ConfirmDialog
├── State: loading (tracks pending confirm action)
├── Dialog primitives: Header (title + description), Footer (buttons)
└── External control: parent owns open state via onOpenChange
```
## Usage
```tsx
import { useTranslate } from "@/utils/i18n";
import ConfirmDialog from "@/components/ConfirmDialog";
const t = useTranslate();
<ConfirmDialog
open={open}
onOpenChange={setOpen}
title={t("memo.delete-confirm")}
description={t("memo.delete-confirm-description")}
confirmLabel={t("common.delete")}
cancelLabel={t("common.cancel")}
onConfirm={handleDelete}
confirmVariant="destructive"
/>;
```
## Props
| Prop | Type | Required | Acceptable Values |
|------|------|----------|------------------|
| `open` | `boolean` | Yes | `true` (visible) / `false` (hidden) |
| `onOpenChange` | `(open: boolean) => void` | Yes | Callback receiving next state; should update parent state |
| `title` | `React.ReactNode` | Yes | Short localized action summary (text / node) |
| `description` | `React.ReactNode` | No | Optional contextual message |
| `confirmLabel` | `string` | Yes | Non-empty localized action text (12 words) |
| `cancelLabel` | `string` | Yes | Localized cancel label |
| `onConfirm` | `() => void | Promise<void>` | Yes | Sync or async handler; resolve = close, reject = stay open |
| `confirmVariant` | `"default" | "destructive"` | No | Defaults to `"default"`; use `"destructive"` for irreversible actions |
## Benefits vs Previous Implementation
### Before (window.confirm / adhoc dialogs)
- Blocking native prompt, inconsistent styling
- No async progress handling
- No rich formatting
- Hard to localize consistently
### After (ConfirmDialog)
- Unified styling + accessibility semantics
- Async-safe with loading state shielding
- Plain description flexibility
- i18n-first via externalized labels
## Technical Implementation Details
### Async Handling
```tsx
const handleConfirm = async () => {
setLoading(true);
try {
await onConfirm(); // resolve -> close
onOpenChange(false);
} catch (e) {
console.error(e); // remain open for retry
} finally {
setLoading(false);
}
};
```
### Close Guard
```tsx
<Dialog open={open} onOpenChange={(next) => !loading && onOpenChange(next)} />
```
## Browser / Environment Support
- Works anywhere the existing `Dialog` primitives work (modern browsers)
- No ResizeObserver / layout dependencies
## Performance Considerations
1. Minimal renders: loading state toggles once per confirm attempt
2. No portal churn—relies on underlying dialog infra
## Future Enhancements
1. Severity icon / header accent
2. Auto-focus destructive button toggle
3. Secondary action (e.g. "Archive" vs "Delete")
4. Built-in retry / error slot
5. Optional checkbox confirmation ("I understand the consequences")
6. Motion/animation tokens integration
## Styling
The `ConfirmDialog.module.scss` file provides a `.container` hook. It currently only hosts a harmless custom property so the stylesheet is non-empty. Add real layout or variant tokens there instead of inline styles.
## Internationalization
All visible strings must come from the translation system. Use `useTranslate()` and pass localized values into props. Separate keys for title/description.
## Error Handling
Errors thrown in `onConfirm` are caught and logged. The dialog stays open so the caller can surface a toast or inline message and allow retry. (Consider routing serious errors to a higher-level handler.)
---
If you extend this component, update this README to keep usage discoverable.

View File

@@ -0,0 +1,59 @@
import * as React from "react";
import { Button } from "@/components/ui/button";
import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle } from "@/components/ui/dialog";
export interface ConfirmDialogProps {
open: boolean;
onOpenChange: (open: boolean) => void;
title: React.ReactNode;
description?: React.ReactNode;
confirmLabel: string;
cancelLabel: string;
onConfirm: () => void | Promise<void>;
confirmVariant?: "default" | "destructive";
}
export default function ConfirmDialog({
open,
onOpenChange,
title,
description,
confirmLabel,
cancelLabel,
onConfirm,
confirmVariant = "default",
}: ConfirmDialogProps) {
const [loading, setLoading] = React.useState(false);
const handleConfirm = async () => {
try {
setLoading(true);
await onConfirm();
onOpenChange(false);
} catch (e) {
// Intentionally swallow errors so user can retry; surface via caller's toast/logging
console.error("ConfirmDialog error for action:", title, e);
} finally {
setLoading(false);
}
};
return (
<Dialog open={open} onOpenChange={(o: boolean) => !loading && onOpenChange(o)}>
<DialogContent size="sm">
<DialogHeader>
<DialogTitle>{title}</DialogTitle>
{description ? <DialogDescription>{description}</DialogDescription> : null}
</DialogHeader>
<DialogFooter>
<Button variant="ghost" disabled={loading} onClick={() => onOpenChange(false)}>
{cancelLabel}
</Button>
<Button variant={confirmVariant} disabled={loading} onClick={handleConfirm} data-loading={loading}>
{confirmLabel}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}

View File

@@ -0,0 +1,183 @@
import copy from "copy-to-clipboard";
import React, { useEffect, useState } from "react";
import { toast } from "react-hot-toast";
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 { RadioGroup, RadioGroupItem } from "@/components/ui/radio-group";
import { Textarea } from "@/components/ui/textarea";
import { userServiceClient } from "@/connect";
import useCurrentUser from "@/hooks/useCurrentUser";
import useLoading from "@/hooks/useLoading";
import { handleError } from "@/lib/error";
import { CreatePersonalAccessTokenResponse } from "@/types/proto/api/v1/user_service_pb";
import { useTranslate } from "@/utils/i18n";
interface Props {
open: boolean;
onOpenChange: (open: boolean) => void;
onSuccess: (response: CreatePersonalAccessTokenResponse) => void;
}
interface State {
description: string;
expiration: number;
}
function CreateAccessTokenDialog({ open, onOpenChange, onSuccess }: Props) {
const t = useTranslate();
const currentUser = useCurrentUser();
const [state, setState] = useState({
description: "",
expiration: 30, // Default: 30 days
});
const [createdToken, setCreatedToken] = useState<string | null>(null);
const requestState = useLoading(false);
// Expiration options in days (0 = never expires)
const expirationOptions = [
{
label: t("setting.access-token-section.create-dialog.duration-1m"),
value: 30,
},
{
label: "90 Days",
value: 90,
},
{
label: t("setting.access-token-section.create-dialog.duration-never"),
value: 0,
},
];
const setPartialState = (partialState: Partial<State>) => {
setState({
...state,
...partialState,
});
};
const handleDescriptionInputChange = (e: React.ChangeEvent<HTMLInputElement>) => {
setPartialState({
description: e.target.value,
});
};
const handleRoleInputChange = (value: string) => {
setPartialState({
expiration: Number(value),
});
};
const handleSaveBtnClick = async () => {
if (!state.description) {
toast.error(t("message.description-is-required"));
return;
}
try {
requestState.setLoading();
const response = await userServiceClient.createPersonalAccessToken({
parent: currentUser?.name,
description: state.description,
expiresInDays: state.expiration,
});
requestState.setFinish();
onSuccess(response);
if (response.token) {
setCreatedToken(response.token);
} else {
onOpenChange(false);
}
} catch (error: unknown) {
handleError(error, toast.error, {
context: "Create access token",
onError: () => requestState.setError(),
});
}
};
const handleCopyToken = () => {
if (!createdToken) return;
copy(createdToken);
toast.success(t("message.copied"));
};
useEffect(() => {
if (!open) return;
setState({
description: "",
expiration: 30,
});
setCreatedToken(null);
}, [open]);
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="max-w-md">
<DialogHeader>
<DialogTitle>{t("setting.access-token-section.create-dialog.create-access-token")}</DialogTitle>
</DialogHeader>
{createdToken ? (
<div className="flex flex-col gap-4">
<div className="grid gap-2">
<Label>{t("setting.access-token-section.token")}</Label>
<Textarea value={createdToken} readOnly rows={3} className="font-mono text-xs" />
</div>
</div>
) : (
<div className="flex flex-col gap-4">
<div className="grid gap-2">
<Label htmlFor="description">
{t("setting.access-token-section.create-dialog.description")} <span className="text-destructive">*</span>
</Label>
<Input
id="description"
type="text"
placeholder={t("setting.access-token-section.create-dialog.some-description")}
value={state.description}
onChange={handleDescriptionInputChange}
/>
</div>
<div className="grid gap-2">
<Label>
{t("setting.access-token-section.create-dialog.expiration")} <span className="text-destructive">*</span>
</Label>
<RadioGroup value={state.expiration.toString()} onValueChange={handleRoleInputChange} className="flex flex-row gap-4">
{expirationOptions.map((option) => (
<div key={option.value} className="flex items-center space-x-2">
<RadioGroupItem value={option.value.toString()} id={`expiration-${option.value}`} />
<Label htmlFor={`expiration-${option.value}`}>{option.label}</Label>
</div>
))}
</RadioGroup>
</div>
</div>
)}
<DialogFooter>
{createdToken ? (
<>
<Button variant="ghost" onClick={handleCopyToken}>
{t("common.copy")}
</Button>
<Button onClick={() => onOpenChange(false)}>{t("common.close")}</Button>
</>
) : (
<>
<Button variant="ghost" disabled={requestState.isLoading} onClick={() => onOpenChange(false)}>
{t("common.cancel")}
</Button>
<Button disabled={requestState.isLoading} onClick={handleSaveBtnClick}>
{t("common.create")}
</Button>
</>
)}
</DialogFooter>
</DialogContent>
</Dialog>
);
}
export default CreateAccessTokenDialog;

View File

@@ -0,0 +1,497 @@
import { create } from "@bufbuild/protobuf";
import { FieldMaskSchema } from "@bufbuild/protobuf/wkt";
import { useEffect, useState } from "react";
import { toast } from "react-hot-toast";
import { Button } from "@/components/ui/button";
import { Dialog, DialogContent, DialogFooter, DialogHeader, DialogTitle } from "@/components/ui/dialog";
import { Input } from "@/components/ui/input";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import { Separator } from "@/components/ui/separator";
import { identityProviderServiceClient } from "@/connect";
import { absolutifyLink } from "@/helpers/utils";
import { handleError } from "@/lib/error";
import {
FieldMapping,
FieldMappingSchema,
IdentityProvider,
IdentityProvider_Type,
IdentityProviderConfigSchema,
IdentityProviderSchema,
OAuth2Config,
OAuth2ConfigSchema,
} from "@/types/proto/api/v1/idp_service_pb";
import { useTranslate } from "@/utils/i18n";
const templateList: IdentityProvider[] = [
create(IdentityProviderSchema, {
name: "",
title: "GitHub",
type: IdentityProvider_Type.OAUTH2,
identifierFilter: "",
config: create(IdentityProviderConfigSchema, {
config: {
case: "oauth2Config",
value: create(OAuth2ConfigSchema, {
clientId: "",
clientSecret: "",
authUrl: "https://github.com/login/oauth/authorize",
tokenUrl: "https://github.com/login/oauth/access_token",
userInfoUrl: "https://api.github.com/user",
scopes: ["read:user"],
fieldMapping: create(FieldMappingSchema, {
identifier: "login",
displayName: "name",
email: "email",
}),
}),
},
}),
}),
create(IdentityProviderSchema, {
name: "",
title: "GitLab",
type: IdentityProvider_Type.OAUTH2,
identifierFilter: "",
config: create(IdentityProviderConfigSchema, {
config: {
case: "oauth2Config",
value: create(OAuth2ConfigSchema, {
clientId: "",
clientSecret: "",
authUrl: "https://gitlab.com/oauth/authorize",
tokenUrl: "https://gitlab.com/oauth/token",
userInfoUrl: "https://gitlab.com/oauth/userinfo",
scopes: ["openid"],
fieldMapping: create(FieldMappingSchema, {
identifier: "name",
displayName: "name",
email: "email",
}),
}),
},
}),
}),
create(IdentityProviderSchema, {
name: "",
title: "Google",
type: IdentityProvider_Type.OAUTH2,
identifierFilter: "",
config: create(IdentityProviderConfigSchema, {
config: {
case: "oauth2Config",
value: create(OAuth2ConfigSchema, {
clientId: "",
clientSecret: "",
authUrl: "https://accounts.google.com/o/oauth2/v2/auth",
tokenUrl: "https://oauth2.googleapis.com/token",
userInfoUrl: "https://www.googleapis.com/oauth2/v2/userinfo",
scopes: ["https://www.googleapis.com/auth/userinfo.email", "https://www.googleapis.com/auth/userinfo.profile"],
fieldMapping: create(FieldMappingSchema, {
identifier: "email",
displayName: "name",
email: "email",
}),
}),
},
}),
}),
create(IdentityProviderSchema, {
name: "",
title: "Custom",
type: IdentityProvider_Type.OAUTH2,
identifierFilter: "",
config: create(IdentityProviderConfigSchema, {
config: {
case: "oauth2Config",
value: create(OAuth2ConfigSchema, {
clientId: "",
clientSecret: "",
authUrl: "",
tokenUrl: "",
userInfoUrl: "",
scopes: [],
fieldMapping: create(FieldMappingSchema, {
identifier: "",
displayName: "",
email: "",
}),
}),
},
}),
}),
];
interface Props {
open: boolean;
onOpenChange: (open: boolean) => void;
identityProvider?: IdentityProvider;
onSuccess?: () => void;
}
function CreateIdentityProviderDialog({ open, onOpenChange, identityProvider, onSuccess }: Props) {
const t = useTranslate();
const identityProviderTypes = [...new Set(templateList.map((t) => t.type))];
const [basicInfo, setBasicInfo] = useState({
title: "",
identifierFilter: "",
});
const [type, setType] = useState<IdentityProvider_Type>(IdentityProvider_Type.OAUTH2);
const [oauth2Config, setOAuth2Config] = useState<OAuth2Config>(
create(OAuth2ConfigSchema, {
clientId: "",
clientSecret: "",
authUrl: "",
tokenUrl: "",
userInfoUrl: "",
scopes: [],
fieldMapping: create(FieldMappingSchema, {
identifier: "",
displayName: "",
email: "",
}),
}),
);
const [oauth2Scopes, setOAuth2Scopes] = useState<string>("");
const [selectedTemplate, setSelectedTemplate] = useState<string>("GitHub");
const isCreating = identityProvider === undefined;
// Reset state when dialog is closed
useEffect(() => {
if (!open) {
// Reset to default state when dialog is closed
setBasicInfo({
title: "",
identifierFilter: "",
});
setType(IdentityProvider_Type.OAUTH2);
setOAuth2Config(
create(OAuth2ConfigSchema, {
clientId: "",
clientSecret: "",
authUrl: "",
tokenUrl: "",
userInfoUrl: "",
scopes: [],
fieldMapping: create(FieldMappingSchema, {
identifier: "",
displayName: "",
email: "",
}),
}),
);
setOAuth2Scopes("");
setSelectedTemplate("GitHub");
}
}, [open]);
// Load existing identity provider data when editing
useEffect(() => {
if (open && identityProvider) {
setBasicInfo({
title: identityProvider.title,
identifierFilter: identityProvider.identifierFilter,
});
setType(identityProvider.type);
if (identityProvider.type === IdentityProvider_Type.OAUTH2 && identityProvider.config?.config?.case === "oauth2Config") {
const oauth2Config = create(OAuth2ConfigSchema, identityProvider.config.config.value || {});
setOAuth2Config(oauth2Config);
setOAuth2Scopes(oauth2Config.scopes.join(" "));
}
}
}, [open, identityProvider]);
// Load template data when creating new IDP
useEffect(() => {
if (!isCreating || !open) {
return;
}
const template = templateList.find((t) => t.title === selectedTemplate);
if (template) {
setBasicInfo({
title: template.title,
identifierFilter: template.identifierFilter,
});
setType(template.type);
if (template.type === IdentityProvider_Type.OAUTH2 && template.config?.config?.case === "oauth2Config") {
const oauth2Config = create(OAuth2ConfigSchema, template.config.config.value || {});
setOAuth2Config(oauth2Config);
setOAuth2Scopes(oauth2Config.scopes.join(" "));
}
}
}, [selectedTemplate, isCreating, open]);
const handleCloseBtnClick = () => {
onOpenChange(false);
};
const allowConfirmAction = () => {
if (basicInfo.title === "") {
return false;
}
if (type === IdentityProvider_Type.OAUTH2) {
if (
oauth2Config.clientId === "" ||
oauth2Config.authUrl === "" ||
oauth2Config.tokenUrl === "" ||
oauth2Config.userInfoUrl === "" ||
oauth2Scopes === "" ||
oauth2Config.fieldMapping?.identifier === ""
) {
return false;
}
if (isCreating) {
if (oauth2Config.clientSecret === "") {
return false;
}
}
}
return true;
};
const handleConfirmBtnClick = async () => {
try {
if (isCreating) {
await identityProviderServiceClient.createIdentityProvider({
identityProvider: create(IdentityProviderSchema, {
...basicInfo,
type: type,
config: create(IdentityProviderConfigSchema, {
config: {
case: "oauth2Config",
value: {
...oauth2Config,
scopes: oauth2Scopes.split(" "),
},
},
}),
}),
});
toast.success(t("setting.sso-section.sso-created", { name: basicInfo.title }));
} else {
await identityProviderServiceClient.updateIdentityProvider({
identityProvider: create(IdentityProviderSchema, {
...basicInfo,
name: identityProvider!.name,
type: type,
config: create(IdentityProviderConfigSchema, {
config: {
case: "oauth2Config",
value: {
...oauth2Config,
scopes: oauth2Scopes.split(" "),
},
},
}),
}),
updateMask: create(FieldMaskSchema, { paths: ["title", "identifier_filter", "config"] }),
});
toast.success(t("setting.sso-section.sso-updated", { name: basicInfo.title }));
}
} catch (error: unknown) {
await handleError(error, toast.error, {
context: isCreating ? "Create identity provider" : "Update identity provider",
});
}
onSuccess?.();
onOpenChange(false);
};
const setPartialOAuth2Config = (state: Partial<OAuth2Config>) => {
setOAuth2Config({
...oauth2Config,
...state,
});
};
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="max-w-2xl max-h-[80vh] overflow-y-auto">
<DialogHeader>
<DialogTitle>{t(isCreating ? "setting.sso-section.create-sso" : "setting.sso-section.update-sso")}</DialogTitle>
</DialogHeader>
<div className="flex flex-col justify-start items-start w-full space-y-4">
{isCreating && (
<>
<p className="mb-1!">{t("common.type")}</p>
<Select value={String(type)} onValueChange={(value) => setType(parseInt(value) as unknown as IdentityProvider_Type)}>
<SelectTrigger className="w-full mb-4">
<SelectValue />
</SelectTrigger>
<SelectContent>
{identityProviderTypes.map((kind) => (
<SelectItem key={kind} value={String(kind)}>
{IdentityProvider_Type[kind] || kind}
</SelectItem>
))}
</SelectContent>
</Select>
<p className="mb-2 text-sm font-medium">{t("setting.sso-section.template")}</p>
<Select value={selectedTemplate} onValueChange={(value) => setSelectedTemplate(value)}>
<SelectTrigger className="mb-1 h-auto w-full">
<SelectValue />
</SelectTrigger>
<SelectContent>
{templateList.map((template) => (
<SelectItem key={template.title} value={template.title}>
{template.title}
</SelectItem>
))}
</SelectContent>
</Select>
<Separator className="my-2" />
</>
)}
<p className="mb-1 text-sm font-medium">
{t("common.name")}
<span className="text-destructive">*</span>
</p>
<Input
className="mb-2 w-full"
placeholder={t("common.name")}
value={basicInfo.title}
onChange={(e) =>
setBasicInfo({
...basicInfo,
title: e.target.value,
})
}
/>
<p className="mb-1 text-sm font-medium">{t("setting.sso-section.identifier-filter")}</p>
<Input
className="mb-2 w-full"
placeholder={t("setting.sso-section.identifier-filter")}
value={basicInfo.identifierFilter}
onChange={(e) =>
setBasicInfo({
...basicInfo,
identifierFilter: e.target.value,
})
}
/>
<Separator className="my-2" />
{type === IdentityProvider_Type.OAUTH2 && (
<>
{isCreating && (
<p className="border border-border rounded-md p-2 text-sm w-full mb-2 break-all">
{t("setting.sso-section.redirect-url")}: {absolutifyLink("/auth/callback")}
</p>
)}
<p className="mb-1 text-sm font-medium">
{t("setting.sso-section.client-id")}
<span className="text-destructive">*</span>
</p>
<Input
className="mb-2 w-full"
placeholder={t("setting.sso-section.client-id")}
value={oauth2Config.clientId}
onChange={(e) => setPartialOAuth2Config({ clientId: e.target.value })}
/>
<p className="mb-1 text-sm font-medium">
{t("setting.sso-section.client-secret")}
<span className="text-destructive">*</span>
</p>
<Input
className="mb-2 w-full"
placeholder={t("setting.sso-section.client-secret")}
value={oauth2Config.clientSecret}
onChange={(e) => setPartialOAuth2Config({ clientSecret: e.target.value })}
/>
<p className="mb-1 text-sm font-medium">
{t("setting.sso-section.authorization-endpoint")}
<span className="text-destructive">*</span>
</p>
<Input
className="mb-2 w-full"
placeholder={t("setting.sso-section.authorization-endpoint")}
value={oauth2Config.authUrl}
onChange={(e) => setPartialOAuth2Config({ authUrl: e.target.value })}
/>
<p className="mb-1 text-sm font-medium">
{t("setting.sso-section.token-endpoint")}
<span className="text-destructive">*</span>
</p>
<Input
className="mb-2 w-full"
placeholder={t("setting.sso-section.token-endpoint")}
value={oauth2Config.tokenUrl}
onChange={(e) => setPartialOAuth2Config({ tokenUrl: e.target.value })}
/>
<p className="mb-1 text-sm font-medium">
{t("setting.sso-section.user-endpoint")}
<span className="text-destructive">*</span>
</p>
<Input
className="mb-2 w-full"
placeholder={t("setting.sso-section.user-endpoint")}
value={oauth2Config.userInfoUrl}
onChange={(e) => setPartialOAuth2Config({ userInfoUrl: e.target.value })}
/>
<p className="mb-1 text-sm font-medium">
{t("setting.sso-section.scopes")}
<span className="text-destructive">*</span>
</p>
<Input
className="mb-2 w-full"
placeholder={t("setting.sso-section.scopes")}
value={oauth2Scopes}
onChange={(e) => setOAuth2Scopes(e.target.value)}
/>
<Separator className="my-2" />
<p className="mb-1 text-sm font-medium">
{t("setting.sso-section.identifier")}
<span className="text-destructive">*</span>
</p>
<Input
className="mb-2 w-full"
placeholder={t("setting.sso-section.identifier")}
value={oauth2Config.fieldMapping!.identifier}
onChange={(e) =>
setPartialOAuth2Config({ fieldMapping: { ...oauth2Config.fieldMapping, identifier: e.target.value } as FieldMapping })
}
/>
<p className="mb-1 text-sm font-medium">{t("setting.sso-section.display-name")}</p>
<Input
className="mb-2 w-full"
placeholder={t("setting.sso-section.display-name")}
value={oauth2Config.fieldMapping!.displayName}
onChange={(e) =>
setPartialOAuth2Config({ fieldMapping: { ...oauth2Config.fieldMapping, displayName: e.target.value } as FieldMapping })
}
/>
<p className="mb-1 text-sm font-medium">{t("common.email")}</p>
<Input
className="mb-2 w-full"
placeholder={t("common.email")}
value={oauth2Config.fieldMapping!.email}
onChange={(e) =>
setPartialOAuth2Config({ fieldMapping: { ...oauth2Config.fieldMapping, email: e.target.value } as FieldMapping })
}
/>
<p className="mb-1 text-sm font-medium">Avatar URL</p>
<Input
className="mb-2 w-full"
placeholder={"Avatar URL"}
value={oauth2Config.fieldMapping!.avatarUrl}
onChange={(e) =>
setPartialOAuth2Config({ fieldMapping: { ...oauth2Config.fieldMapping, avatarUrl: e.target.value } as FieldMapping })
}
/>
</>
)}
</div>
<DialogFooter>
<Button variant="ghost" onClick={handleCloseBtnClick}>
{t("common.cancel")}
</Button>
<Button onClick={handleConfirmBtnClick} disabled={!allowConfirmAction()}>
{t(isCreating ? "common.create" : "common.update")}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}
export default CreateIdentityProviderDialog;

View File

@@ -0,0 +1,158 @@
import { create } from "@bufbuild/protobuf";
import { FieldMaskSchema } from "@bufbuild/protobuf/wkt";
import { useEffect, useState } from "react";
import { toast } from "react-hot-toast";
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 { 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 { handleError } from "@/lib/error";
import { Shortcut, ShortcutSchema } from "@/types/proto/api/v1/shortcut_service_pb";
import { useTranslate } from "@/utils/i18n";
interface Props {
open: boolean;
onOpenChange: (open: boolean) => void;
shortcut?: Shortcut;
onSuccess?: () => void;
}
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 || "",
title: initialShortcut?.title || "",
filter: initialShortcut?.filter || "",
}),
);
const requestState = useLoading(false);
const isCreating = shortcut.name === "";
useEffect(() => {
setShortcut(
create(ShortcutSchema, {
name: initialShortcut?.name || "",
title: initialShortcut?.title || "",
filter: initialShortcut?.filter || "",
}),
);
}, [initialShortcut]);
const onShortcutTitleChange = (e: React.ChangeEvent<HTMLInputElement>) => {
setPartialState({
title: e.target.value,
});
};
const onShortcutFilterChange = (e: React.ChangeEvent<HTMLTextAreaElement>) => {
setPartialState({
filter: e.target.value,
});
};
const setPartialState = (partialState: Partial<Shortcut>) => {
setShortcut({
...shortcut,
...partialState,
});
};
const handleSaveBtnClick = async () => {
if (!shortcut.title || !shortcut.filter) {
toast.error("Title and filter cannot be empty");
return;
}
try {
requestState.setLoading();
if (isCreating) {
await shortcutServiceClient.createShortcut({
parent: user?.name,
shortcut: {
name: "",
title: shortcut.title,
filter: shortcut.filter,
},
});
toast.success("Create shortcut successfully");
} else {
await shortcutServiceClient.updateShortcut({
shortcut: {
...shortcut,
name: initialShortcut!.name,
},
updateMask: create(FieldMaskSchema, { paths: ["title", "filter"] }),
});
toast.success("Update shortcut successfully");
}
await refetchSettings();
requestState.setFinish();
onSuccess?.();
onOpenChange(false);
} catch (error: unknown) {
await handleError(error, toast.error, {
context: isCreating ? "Create shortcut" : "Update shortcut",
onError: () => requestState.setError(),
});
}
};
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="max-w-md">
<DialogHeader>
<DialogTitle>{`${isCreating ? t("common.create") : t("common.edit")} ${t("common.shortcuts")}`}</DialogTitle>
</DialogHeader>
<div className="flex flex-col gap-4">
<div className="grid gap-2">
<Label htmlFor="title">{t("common.title")}</Label>
<Input id="title" type="text" placeholder="" value={shortcut.title} onChange={onShortcutTitleChange} />
</div>
<div className="grid gap-2">
<Label htmlFor="filter">{t("common.filter")}</Label>
<Textarea
id="filter"
rows={3}
placeholder={t("common.shortcut-filter")}
value={shortcut.filter}
onChange={onShortcutFilterChange}
/>
</div>
<div className="text-sm text-muted-foreground">
<p className="mb-2">{t("common.learn-more")}:</p>
<ul className="list-disc list-inside space-y-1">
<li>
<a
className="text-primary hover:underline"
href="https://www.usememos.com/docs/usage/shortcuts"
target="_blank"
rel="noopener noreferrer"
>
Docs - Shortcuts
</a>
</li>
</ul>
</div>
</div>
<DialogFooter>
<Button variant="ghost" disabled={requestState.isLoading} onClick={() => onOpenChange(false)}>
{t("common.cancel")}
</Button>
<Button disabled={requestState.isLoading} onClick={handleSaveBtnClick}>
{t("common.save")}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}
export default CreateShortcutDialog;

View File

@@ -0,0 +1,150 @@
import { create } from "@bufbuild/protobuf";
import { FieldMaskSchema } from "@bufbuild/protobuf/wkt";
import { useEffect, useState } from "react";
import { toast } from "react-hot-toast";
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 { RadioGroup, RadioGroupItem } from "@/components/ui/radio-group";
import { userServiceClient } from "@/connect";
import useLoading from "@/hooks/useLoading";
import { handleError } from "@/lib/error";
import { User, User_Role, UserSchema } from "@/types/proto/api/v1/user_service_pb";
import { useTranslate } from "@/utils/i18n";
interface Props {
open: boolean;
onOpenChange: (open: boolean) => void;
user?: User;
onSuccess?: () => void;
}
function CreateUserDialog({ open, onOpenChange, user: initialUser, onSuccess }: Props) {
const t = useTranslate();
const [user, setUser] = useState(
create(UserSchema, initialUser ? { name: initialUser.name, username: initialUser.username, role: initialUser.role } : {}),
);
const requestState = useLoading(false);
const isCreating = !initialUser;
useEffect(() => {
if (initialUser) {
setUser(create(UserSchema, { name: initialUser.name, username: initialUser.username, role: initialUser.role }));
} else {
setUser(create(UserSchema, {}));
}
}, [initialUser]);
const setPartialUser = (state: Partial<User>) => {
setUser({
...user,
...state,
});
};
const handleConfirm = async () => {
if (isCreating && (!user.username || !user.password)) {
toast.error("Username and password cannot be empty");
return;
}
try {
requestState.setLoading();
if (isCreating) {
await userServiceClient.createUser({ user });
toast.success("Create user successfully");
} else {
const updateMask = [];
if (user.username !== initialUser?.username) {
updateMask.push("username");
}
if (user.password) {
updateMask.push("password");
}
if (user.role !== initialUser?.role) {
updateMask.push("role");
}
const userToUpdate = create(UserSchema, { ...user, name: initialUser?.name ?? user.name });
await userServiceClient.updateUser({ user: userToUpdate, updateMask: create(FieldMaskSchema, { paths: updateMask }) });
toast.success("Update user successfully");
}
requestState.setFinish();
onSuccess?.();
onOpenChange(false);
} catch (error: unknown) {
handleError(error, toast.error, {
context: user ? "Update user" : "Create user",
onError: () => requestState.setError(),
});
}
};
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="max-w-md">
<DialogHeader>
<DialogTitle>{`${isCreating ? t("common.create") : t("common.edit")} ${t("common.user")}`}</DialogTitle>
</DialogHeader>
<div className="flex flex-col gap-4">
<div className="grid gap-2">
<Label htmlFor="username">{t("common.username")}</Label>
<Input
id="username"
type="text"
placeholder={t("common.username")}
value={user.username}
onChange={(e) =>
setPartialUser({
username: e.target.value,
})
}
/>
</div>
<div className="grid gap-2">
<Label htmlFor="password">{t("common.password")}</Label>
<Input
id="password"
type="password"
placeholder={t("common.password")}
autoComplete="off"
value={user.password}
onChange={(e) =>
setPartialUser({
password: e.target.value,
})
}
/>
</div>
<div className="grid gap-2">
<Label>{t("common.role")}</Label>
<RadioGroup
value={String(user.role)}
onValueChange={(value) => setPartialUser({ role: Number(value) as User_Role })}
className="flex flex-row gap-4"
>
<div className="flex items-center space-x-2">
<RadioGroupItem value={String(User_Role.USER)} id="user" />
<Label htmlFor="user">{t("setting.member-section.user")}</Label>
</div>
<div className="flex items-center space-x-2">
<RadioGroupItem value={String(User_Role.ADMIN)} id="admin" />
<Label htmlFor="admin">{t("setting.member-section.admin")}</Label>
</div>
</RadioGroup>
</div>
</div>
<DialogFooter>
<Button variant="ghost" disabled={requestState.isLoading} onClick={() => onOpenChange(false)}>
{t("common.cancel")}
</Button>
<Button disabled={requestState.isLoading} onClick={handleConfirm}>
{t("common.confirm")}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}
export default CreateUserDialog;

View File

@@ -0,0 +1,168 @@
import { create } from "@bufbuild/protobuf";
import { FieldMaskSchema } from "@bufbuild/protobuf/wkt";
import React, { useEffect, useState } from "react";
import { toast } from "react-hot-toast";
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 { userServiceClient } from "@/connect";
import useCurrentUser from "@/hooks/useCurrentUser";
import useLoading from "@/hooks/useLoading";
import { handleError } from "@/lib/error";
import { useTranslate } from "@/utils/i18n";
interface Props {
open: boolean;
onOpenChange: (open: boolean) => void;
webhookName?: string;
onSuccess?: () => void;
}
interface State {
displayName: string;
url: string;
}
function CreateWebhookDialog({ open, onOpenChange, webhookName, onSuccess }: Props) {
const t = useTranslate();
const currentUser = useCurrentUser();
const [state, setState] = useState<State>({
displayName: "",
url: "",
});
const requestState = useLoading(false);
const isCreating = webhookName === undefined;
useEffect(() => {
if (webhookName && currentUser) {
// For editing, we need to get the webhook data
// Since we're using user webhooks now, we need to list all webhooks and find the one we want
userServiceClient
.listUserWebhooks({
parent: currentUser.name,
})
.then((response) => {
const webhook = response.webhooks.find((w) => w.name === webhookName);
if (webhook) {
setState({
displayName: webhook.displayName,
url: webhook.url,
});
}
});
}
}, [webhookName, currentUser]);
const setPartialState = (partialState: Partial<State>) => {
setState({
...state,
...partialState,
});
};
const handleTitleInputChange = (e: React.ChangeEvent<HTMLInputElement>) => {
setPartialState({
displayName: e.target.value,
});
};
const handleUrlInputChange = (e: React.ChangeEvent<HTMLInputElement>) => {
setPartialState({
url: e.target.value,
});
};
const handleSaveBtnClick = async () => {
if (!state.displayName || !state.url) {
toast.error(t("message.fill-all-required-fields"));
return;
}
if (!currentUser) {
toast.error("User not authenticated");
return;
}
try {
requestState.setLoading();
if (isCreating) {
await userServiceClient.createUserWebhook({
parent: currentUser.name,
webhook: {
displayName: state.displayName,
url: state.url,
},
});
} else {
await userServiceClient.updateUserWebhook({
webhook: {
name: webhookName,
displayName: state.displayName,
url: state.url,
},
updateMask: create(FieldMaskSchema, { paths: ["display_name", "url"] }),
});
}
onSuccess?.();
onOpenChange(false);
requestState.setFinish();
} catch (error: unknown) {
handleError(error, toast.error, {
context: webhookName ? "Update webhook" : "Create webhook",
onError: () => requestState.setError(),
});
}
};
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="max-w-md">
<DialogHeader>
<DialogTitle>
{isCreating
? t("setting.webhook-section.create-dialog.create-webhook")
: t("setting.webhook-section.create-dialog.edit-webhook")}
</DialogTitle>
</DialogHeader>
<div className="flex flex-col gap-4">
<div className="grid gap-2">
<Label htmlFor="displayName">
{t("setting.webhook-section.create-dialog.title")} <span className="text-destructive">*</span>
</Label>
<Input
id="displayName"
type="text"
placeholder={t("setting.webhook-section.create-dialog.an-easy-to-remember-name")}
value={state.displayName}
onChange={handleTitleInputChange}
/>
</div>
<div className="grid gap-2">
<Label htmlFor="url">
{t("setting.webhook-section.create-dialog.payload-url")} <span className="text-destructive">*</span>
</Label>
<Input
id="url"
type="text"
placeholder={t("setting.webhook-section.create-dialog.url-example-post-receive")}
value={state.url}
onChange={handleUrlInputChange}
/>
</div>
</div>
<DialogFooter>
<Button variant="ghost" disabled={requestState.isLoading} onClick={() => onOpenChange(false)}>
{t("common.cancel")}
</Button>
<Button disabled={requestState.isLoading} onClick={handleSaveBtnClick}>
{t("common.create")}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}
export default CreateWebhookDialog;

View File

@@ -0,0 +1,43 @@
import dayjs from "dayjs";
import toast from "react-hot-toast";
import { cn } from "@/lib/utils";
// must be compatible with JS Date.parse(), we use ISO 8601 (almost)
const DATE_TIME_FORMAT = "YYYY-MM-DD HH:mm:ss";
// convert Date to datetime string.
const formatDate = (date: Date): string => {
return dayjs(date).format(DATE_TIME_FORMAT);
};
interface Props {
value: Date;
onChange: (date: Date) => void;
}
const DateTimeInput: React.FC<Props> = ({ value, onChange }) => {
return (
<input
type="datetime-local"
className={cn("px-1 bg-transparent rounded text-xs transition-all", "border-transparent outline-none focus:border-border", "border")}
defaultValue={formatDate(value)}
onBlur={(e) => {
const inputValue = e.target.value;
if (inputValue) {
// note: inputValue must be compatible with JS Date.parse()
const date = dayjs(inputValue).toDate();
// Check if the date is valid.
if (!isNaN(date.getTime())) {
onChange(date);
} else {
toast.error("Invalid datetime format. Use format: 2023-12-31 23:59:59");
e.target.value = formatDate(value);
}
}
}}
placeholder={DATE_TIME_FORMAT}
/>
);
};
export default DateTimeInput;

View File

@@ -0,0 +1,11 @@
import { BirdIcon } from "lucide-react";
const Empty = () => {
return (
<div className="mx-auto">
<BirdIcon strokeWidth={0.5} absoluteStrokeWidth={true} className="w-24 h-auto text-muted-foreground" />
</div>
);
};
export default Empty;

View File

@@ -0,0 +1,70 @@
import { AlertCircle, RefreshCw } from "lucide-react";
import { Component, type ErrorInfo, type ReactNode } from "react";
import { Button } from "./ui/button";
interface Props {
children: ReactNode;
fallback?: ReactNode;
}
interface State {
hasError: boolean;
error: Error | null;
}
export class ErrorBoundary extends Component<Props, State> {
constructor(props: Props) {
super(props);
this.state = { hasError: false, error: null };
}
static getDerivedStateFromError(error: Error): State {
return { hasError: true, error };
}
componentDidCatch(error: Error, errorInfo: ErrorInfo) {
console.error("ErrorBoundary caught an error:", error, errorInfo);
}
handleReset = () => {
this.setState({ hasError: false, error: null });
window.location.reload();
};
render() {
if (this.state.hasError) {
if (this.props.fallback) {
return this.props.fallback;
}
return (
<div className="flex items-center justify-center min-h-screen bg-background">
<div className="max-w-md w-full p-6 space-y-4">
<div className="flex items-center gap-3 text-destructive">
<AlertCircle className="w-8 h-8" />
<h1 className="text-2xl font-bold">Something went wrong</h1>
</div>
<p className="text-foreground/70">
An unexpected error occurred. This could be due to a network issue or a problem with the application.
</p>
{this.state.error && (
<details className="bg-muted p-3 rounded-md text-sm">
<summary className="cursor-pointer font-medium mb-2">Error details</summary>
<pre className="whitespace-pre-wrap break-words text-xs text-foreground/60">{this.state.error.message}</pre>
</details>
)}
<Button onClick={this.handleReset} className="w-full gap-2">
<RefreshCw className="w-4 h-4" />
Reload Application
</Button>
</div>
</div>
);
}
return this.props.children;
}
}

View File

@@ -0,0 +1,232 @@
import { create } from "@bufbuild/protobuf";
import { FieldMaskSchema, timestampDate } from "@bufbuild/protobuf/wkt";
import { CheckIcon, MessageCircleIcon, TrashIcon, XIcon } from "lucide-react";
import { useState } from "react";
import toast from "react-hot-toast";
import UserAvatar from "@/components/UserAvatar";
import { activityServiceClient, memoServiceClient, userServiceClient } from "@/connect";
import { activityNamePrefix } from "@/helpers/resource-names";
import useAsyncEffect from "@/hooks/useAsyncEffect";
import useNavigateTo from "@/hooks/useNavigateTo";
import { useUser } from "@/hooks/useUserQueries";
import { handleError } from "@/lib/error";
import { cn } from "@/lib/utils";
import { Memo } from "@/types/proto/api/v1/memo_service_pb";
import { UserNotification, UserNotification_Status } from "@/types/proto/api/v1/user_service_pb";
import { useTranslate } from "@/utils/i18n";
interface Props {
notification: UserNotification;
}
function MemoCommentMessage({ notification }: Props) {
const t = useTranslate();
const navigateTo = useNavigateTo();
const [relatedMemo, setRelatedMemo] = useState<Memo | undefined>(undefined);
const [commentMemo, setCommentMemo] = useState<Memo | undefined>(undefined);
const [senderName, setSenderName] = useState<string | undefined>(undefined);
const [initialized, setInitialized] = useState<boolean>(false);
const [hasError, setHasError] = useState<boolean>(false);
const { data: sender } = useUser(senderName || "", { enabled: !!senderName });
useAsyncEffect(async () => {
if (!notification.activityId) {
return;
}
try {
const activity = await activityServiceClient.getActivity({
name: `${activityNamePrefix}${notification.activityId}`,
});
if (activity.payload?.payload?.case === "memoComment") {
const memoCommentPayload = activity.payload.payload.value;
const memo = await memoServiceClient.getMemo({
name: memoCommentPayload.relatedMemo,
});
setRelatedMemo(memo);
const comment = await memoServiceClient.getMemo({
name: memoCommentPayload.memo,
});
setCommentMemo(comment);
setSenderName(notification.sender);
setInitialized(true);
}
} catch (error) {
handleError(error, () => {}, {
context: "Failed to fetch activity",
onError: () => setHasError(true),
});
return;
}
}, [notification.activityId]);
const handleNavigateToMemo = async () => {
if (!relatedMemo) {
return;
}
navigateTo(`/${relatedMemo.name}`);
if (notification.status === UserNotification_Status.UNREAD) {
handleArchiveMessage(true);
}
};
const handleArchiveMessage = async (silence = false) => {
await userServiceClient.updateUserNotification({
notification: {
name: notification.name,
status: UserNotification_Status.ARCHIVED,
},
updateMask: create(FieldMaskSchema, { paths: ["status"] }),
});
if (!silence) {
toast.success(t("message.archived-successfully"));
}
};
const handleDeleteMessage = async () => {
await userServiceClient.deleteUserNotification({
name: notification.name,
});
toast.success(t("message.deleted-successfully"));
};
if (!initialized && !hasError) {
return (
<div className="w-full px-5 py-4 border-b border-border/60 last:border-b-0 bg-muted/10 animate-pulse">
<div className="flex items-start gap-3">
<div className="w-10 h-10 rounded-full bg-muted/50 shrink-0" />
<div className="flex-1 space-y-3">
<div className="h-4 bg-muted/50 rounded-md w-2/5" />
<div className="h-3 bg-muted/40 rounded-md w-3/4" />
<div className="h-20 bg-muted/30 rounded-xl" />
</div>
</div>
</div>
);
}
if (hasError) {
return (
<div className="w-full px-5 py-4 border-b border-border/60 last:border-b-0 bg-destructive/[0.04] group">
<div className="flex items-center justify-between">
<div className="flex items-center gap-3">
<div className="w-10 h-10 rounded-full bg-destructive/15 flex items-center justify-center shrink-0 ring-1 ring-destructive/20">
<XIcon className="w-5 h-5 text-destructive" strokeWidth={2} />
</div>
<span className="text-sm text-destructive/80 font-medium">{t("inbox.failed-to-load")}</span>
</div>
<button
onClick={handleDeleteMessage}
className="p-1.5 hover:bg-destructive/15 rounded-lg transition-all duration-150 opacity-0 group-hover:opacity-100"
title={t("common.delete")}
>
<TrashIcon className="w-4 h-4 text-destructive/70 hover:text-destructive transition-colors" strokeWidth={2} />
</button>
</div>
</div>
);
}
const isUnread = notification.status === UserNotification_Status.UNREAD;
return (
<div
className={cn(
"w-full px-5 py-4 border-b border-border/60 last:border-b-0 transition-all duration-200 group relative",
isUnread ? "bg-primary/[0.03] hover:bg-primary/[0.05]" : "hover:bg-muted/30",
)}
>
{/* Unread indicator bar */}
{isUnread && <div className="absolute left-0 top-0 bottom-0 w-0.5 bg-gradient-to-b from-primary to-primary/60" />}
<div className="flex items-start gap-3">
{/* Avatar & Icon */}
<div className="relative shrink-0">
<UserAvatar className="w-10 h-10 ring-1 ring-border/40" avatarUrl={sender?.avatarUrl} />
<div
className={cn(
"absolute -bottom-1 -right-1 w-5 h-5 rounded-full border-2 border-background flex items-center justify-center shadow-md transition-all",
isUnread ? "bg-primary text-primary-foreground" : "bg-muted/80 text-muted-foreground",
)}
>
<MessageCircleIcon className="w-2.5 h-2.5" strokeWidth={2.5} />
</div>
</div>
{/* Content */}
<div className="flex-1 min-w-0">
{/* Header */}
<div className="flex items-center justify-between gap-3 mb-1">
<div className="flex items-center gap-1.5 flex-wrap min-w-0">
<span className="font-semibold text-sm text-foreground/95">{sender?.displayName || sender?.username}</span>
<span className="text-sm text-muted-foreground/80">commented on your memo</span>
<span className="text-xs text-muted-foreground/60">
{notification.createTime &&
timestampDate(notification.createTime)?.toLocaleDateString([], { month: "short", day: "numeric" })}{" "}
at{" "}
{notification.createTime &&
timestampDate(notification.createTime)?.toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" })}
</span>
</div>
<div className="flex items-center gap-1 shrink-0">
{isUnread ? (
<button
onClick={() => handleArchiveMessage()}
className="p-1.5 hover:bg-primary/10 rounded-lg transition-all duration-150 opacity-0 group-hover:opacity-100"
title={t("common.archive")}
>
<CheckIcon className="w-4 h-4 text-muted-foreground hover:text-primary transition-colors" strokeWidth={2} />
</button>
) : (
<button
onClick={handleDeleteMessage}
className="p-1.5 hover:bg-destructive/10 rounded-lg transition-all duration-150 opacity-0 group-hover:opacity-100"
title={t("common.delete")}
>
<TrashIcon className="w-4 h-4 text-muted-foreground hover:text-destructive transition-colors" strokeWidth={2} />
</button>
)}
</div>
</div>
{/* Original Memo Snippet */}
{relatedMemo && (
<div className="pl-3 border-l-2 border-muted-foreground/20 mb-3">
<p className="text-sm text-foreground/60 line-clamp-1 leading-relaxed">
<span className="text-xs text-muted-foreground/50 font-medium mr-2 uppercase tracking-wide">Original:</span>
{relatedMemo.content || <span className="italic text-muted-foreground/40">Empty memo</span>}
</p>
</div>
)}
{/* Comment Preview */}
{commentMemo && (
<div
onClick={handleNavigateToMemo}
className="p-2 sm:p-3 rounded-lg bg-gradient-to-br from-primary/[0.06] to-primary/[0.03] hover:from-primary/[0.1] hover:to-primary/[0.06] cursor-pointer border border-primary/30 hover:border-primary/50 transition-all duration-200 group/comment shadow-sm hover:shadow"
>
<div className="flex items-start gap-2">
<div className="w-5 h-5 flex items-center justify-center shrink-0">
<MessageCircleIcon className="w-4 h-4 text-primary" />
</div>
<div className="flex-1 min-w-0">
<p className="text-xs text-primary/60 font-semibold mb-1 uppercase tracking-wider">Comment</p>
<p className="text-sm text-foreground/90 line-clamp-2">
{commentMemo.content || <span className="italic text-muted-foreground/50">Empty comment</span>}
</p>
</div>
</div>
</div>
)}
</div>
</div>
</div>
);
}
export default MemoCommentMessage;

View File

@@ -0,0 +1,31 @@
import { ExternalLinkIcon } from "lucide-react";
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/components/ui/tooltip";
import { useTranslate } from "@/utils/i18n";
interface Props {
className?: string;
url: string;
title?: string;
}
const LearnMore: React.FC<Props> = (props: Props) => {
const { className, url, title } = props;
const t = useTranslate();
return (
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>
<a className={`text-muted-foreground hover:text-primary ${className}`} href={url} target="_blank">
<ExternalLinkIcon className="w-4 h-auto" />
</a>
</TooltipTrigger>
<TooltipContent>
<p>{title ?? t("common.learn-more")}</p>
</TooltipContent>
</Tooltip>
</TooltipProvider>
);
};
export default LearnMore;

View File

@@ -0,0 +1,41 @@
import { GlobeIcon } from "lucide-react";
import { FC } from "react";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import { locales } from "@/i18n";
import { getLocaleDisplayName, loadLocale } from "@/utils/i18n";
interface Props {
value: Locale;
onChange: (locale: Locale) => void;
}
const LocaleSelect: FC<Props> = (props: Props) => {
const { onChange, value } = props;
const handleSelectChange = async (locale: Locale) => {
// Apply locale globally immediately
loadLocale(locale);
// Also notify parent component
onChange(locale);
};
return (
<Select value={value} onValueChange={handleSelectChange}>
<SelectTrigger>
<div className="flex items-center gap-2">
<GlobeIcon className="w-4 h-auto" />
<SelectValue placeholder="Select language" />
</div>
</SelectTrigger>
<SelectContent>
{locales.map((locale) => (
<SelectItem key={locale} value={locale}>
{getLocaleDisplayName(locale)}
</SelectItem>
))}
</SelectContent>
</Select>
);
};
export default LocaleSelect;

View File

@@ -0,0 +1,34 @@
import { MasonryItem } from "./MasonryItem";
import { MasonryColumnProps } from "./types";
export function MasonryColumn({
memoIndices,
memoList,
renderer,
renderContext,
onHeightChange,
isFirstColumn,
prefixElement,
prefixElementRef,
}: MasonryColumnProps) {
return (
<div className="min-w-0 mx-auto w-full max-w-2xl">
{/* Prefix element (like memo editor) goes in first column */}
{isFirstColumn && prefixElement && <div ref={prefixElementRef}>{prefixElement}</div>}
{/* Render all memos assigned to this column */}
{memoIndices?.map((memoIndex) => {
const memo = memoList[memoIndex];
return memo ? (
<MasonryItem
key={`${memo.name}-${memo.displayTime}`}
memo={memo}
renderer={renderer}
renderContext={renderContext}
onHeightChange={onHeightChange}
/>
) : null;
})}
</div>
);
}

View File

@@ -0,0 +1,32 @@
import { useEffect, useRef } from "react";
import { MasonryItemProps } from "./types";
export function MasonryItem({ memo, renderer, renderContext, onHeightChange }: MasonryItemProps) {
const itemRef = useRef<HTMLDivElement>(null);
const resizeObserverRef = useRef<ResizeObserver | null>(null);
useEffect(() => {
if (!itemRef.current) return;
const measureHeight = () => {
if (itemRef.current) {
const height = itemRef.current.offsetHeight;
onHeightChange(memo.name, height);
}
};
// Initial measurement
measureHeight();
// Set up ResizeObserver to track dynamic content changes
resizeObserverRef.current = new ResizeObserver(measureHeight);
resizeObserverRef.current.observe(itemRef.current);
// Cleanup on unmount
return () => {
resizeObserverRef.current?.disconnect();
};
}, [memo.name, onHeightChange]);
return <div ref={itemRef}>{renderer(memo, renderContext)}</div>;
}

View File

@@ -0,0 +1,47 @@
import { useMemo, useRef } from "react";
import { cn } from "@/lib/utils";
import { MasonryColumn } from "./MasonryColumn";
import { MasonryViewProps, MemoRenderContext } from "./types";
import { useMasonryLayout } from "./useMasonryLayout";
const MasonryView = ({ memoList, renderer, prefixElement, listMode = false }: MasonryViewProps) => {
const containerRef = useRef<HTMLDivElement>(null);
const prefixElementRef = useRef<HTMLDivElement>(null);
const { columns, distribution, handleHeightChange } = useMasonryLayout(memoList, listMode, containerRef, prefixElementRef);
// Create render context: always enable compact mode for list views
const renderContext: MemoRenderContext = useMemo(
() => ({
compact: true,
columns,
}),
[columns],
);
return (
<div
ref={containerRef}
className={cn("w-full grid gap-2")}
style={{
gridTemplateColumns: `repeat(${columns}, 1fr)`,
}}
>
{Array.from({ length: columns }).map((_, columnIndex) => (
<MasonryColumn
key={columnIndex}
memoIndices={distribution[columnIndex] || []}
memoList={memoList}
renderer={renderer}
renderContext={renderContext}
onHeightChange={handleHeightChange}
isFirstColumn={columnIndex === 0}
prefixElement={prefixElement}
prefixElementRef={prefixElementRef}
/>
))}
</div>
);
};
export default MasonryView;

View File

@@ -0,0 +1,116 @@
# MasonryView - Height-Based Masonry Layout
## Overview
This improved MasonryView component implements a true masonry layout that distributes memo cards based on their actual rendered heights, creating a balanced waterfall-style layout instead of naive sequential distribution.
## Key Features
### 1. Height Measurement
- **MemoItem Wrapper**: Each memo is wrapped in a `MemoItem` component that measures its actual height
- **ResizeObserver**: Automatically detects height changes when content changes (e.g., images load, content expands)
- **Real-time Updates**: Heights are measured on mount and updated dynamically
### 2. Smart Distribution Algorithm
- **Shortest Column First**: Memos are assigned to the column with the smallest total height
- **Dynamic Balancing**: As new memos are added or heights change, the layout rebalances
- **Prefix Element Support**: Properly accounts for the MemoEditor height in the first column
### 3. Performance Optimizations
- **Memoized Callbacks**: `handleHeightChange` is memoized to prevent unnecessary re-renders
- **Efficient State Updates**: Only redistributes when necessary (memo list changes, column count changes)
- **ResizeObserver Cleanup**: Properly disconnects observers to prevent memory leaks
## Architecture
```
MasonryView
├── State Management
│ ├── columns: number of columns based on viewport width
│ ├── itemHeights: Map<memoName, height> for each memo
│ ├── columnHeights: current total height of each column
│ └── distribution: which memos belong to which column
├── MemoItem (for each memo)
│ ├── Ref for height measurement
│ ├── ResizeObserver for dynamic updates
│ └── Callback to parent on height changes
└── Distribution Algorithm
├── Finds shortest column
├── Assigns memo to that column
└── Updates column height tracking
```
## Usage
The component maintains the same API as before, so no changes are needed in consuming components:
```tsx
<MasonryView memoList={memos} renderer={(memo) => <MemoView memo={memo} />} prefixElement={<MemoEditor />} listMode={false} />
```
## Benefits vs Previous Implementation
### Before (Naive)
- Distributed memos by index: `memo[i % columns]`
- No consideration of actual heights
- Resulted in unbalanced columns
- Static layout that didn't adapt to content
### After (Height-Based)
- Distributes memos by actual rendered height
- Creates balanced columns with similar total heights
- Adapts to dynamic content changes
- Smoother visual layout
## Technical Implementation Details
### Height Measurement
```tsx
const measureHeight = () => {
if (itemRef.current) {
const height = itemRef.current.offsetHeight;
onHeightChange(memo.name, height);
}
};
```
### Distribution Algorithm
```tsx
const shortestColumnIndex = columnHeights.reduce(
(minIndex, currentHeight, currentIndex) => (currentHeight < columnHeights[minIndex] ? currentIndex : minIndex),
0,
);
```
### Dynamic Updates
- **Window Resize**: Recalculates column count and redistributes
- **Content Changes**: ResizeObserver triggers height remeasurement
- **Memo List Changes**: Redistributes all memos with new ordering
## Browser Support
- Modern browsers with ResizeObserver support
- Fallback behavior: Falls back to sequential distribution if ResizeObserver is not available
- CSS Grid support required for column layout
## Performance Considerations
1. **Initial Load**: Slight delay as heights are measured
2. **Memory Usage**: Stores height data for each memo
3. **Re-renders**: Optimized to only update when necessary
4. **Large Lists**: Scales well with proper virtualization (if needed in future)
## Future Enhancements
1. **Virtualization**: For very large memo lists
2. **Animation**: Smooth transitions when items change position
3. **Gap Optimization**: More sophisticated gap handling
4. **Estimated Heights**: Faster initial layout with height estimation

View File

@@ -0,0 +1,3 @@
export const MINIMUM_MEMO_VIEWPORT_WIDTH = 512;
export const REDISTRIBUTION_DEBOUNCE_MS = 100;

View File

@@ -0,0 +1,68 @@
import { Memo } from "@/types/proto/api/v1/memo_service_pb";
import { DistributionResult } from "./types";
export function distributeItemsToColumns(
memos: Memo[],
columns: number,
itemHeights: Map<string, number>,
prefixElementHeight: number = 0,
): DistributionResult {
if (columns === 1) {
const totalHeight = memos.reduce((sum, memo) => sum + (itemHeights.get(memo.name) || 0), prefixElementHeight);
return {
distribution: [Array.from({ length: memos.length }, (_, i) => i)],
columnHeights: [totalHeight],
};
}
const distribution: number[][] = Array.from({ length: columns }, () => []);
const columnHeights: number[] = Array(columns).fill(0);
const columnCounts: number[] = Array(columns).fill(0);
if (prefixElementHeight > 0) {
columnHeights[0] = prefixElementHeight;
}
let startIndex = 0;
if (memos.length > 0) {
const firstMemoHeight = itemHeights.get(memos[0].name) || 0;
distribution[0].push(0);
columnHeights[0] += firstMemoHeight;
columnCounts[0] += 1;
startIndex = 1;
}
for (let i = startIndex; i < memos.length; i++) {
const memo = memos[i];
const height = itemHeights.get(memo.name) || 0;
const shortestColumnIndex = findShortestColumnIndex(columnHeights, columnCounts);
distribution[shortestColumnIndex].push(i);
columnHeights[shortestColumnIndex] += height;
columnCounts[shortestColumnIndex] += 1;
}
return { distribution, columnHeights };
}
function findShortestColumnIndex(columnHeights: number[], columnCounts: number[]): number {
let minIndex = 0;
let minHeight = columnHeights[0];
for (let i = 1; i < columnHeights.length; i++) {
const currentHeight = columnHeights[i];
if (currentHeight < minHeight) {
minHeight = currentHeight;
minIndex = i;
continue;
}
if (currentHeight === minHeight && columnCounts[i] < columnCounts[minIndex]) {
minIndex = i;
}
}
return minIndex;
}

View File

@@ -0,0 +1,22 @@
// Main component
// Constants
export { MINIMUM_MEMO_VIEWPORT_WIDTH, REDISTRIBUTION_DEBOUNCE_MS } from "./constants";
// Utilities
export { distributeItemsToColumns } from "./distributeItems";
// Sub-components (exported for testing or advanced usage)
export { MasonryColumn } from "./MasonryColumn";
export { MasonryItem } from "./MasonryItem";
export { default } from "./MasonryView";
// Types
export type {
DistributionResult,
MasonryColumnProps,
MasonryItemProps,
MasonryViewProps,
MemoRenderContext,
MemoWithHeight,
} from "./types";
// Hooks
export { useMasonryLayout } from "./useMasonryLayout";

View File

@@ -0,0 +1,41 @@
import { Memo } from "@/types/proto/api/v1/memo_service_pb";
export interface MemoRenderContext {
compact: boolean;
columns: number;
}
export interface MasonryViewProps {
memoList: Memo[];
renderer: (memo: Memo, context?: MemoRenderContext) => JSX.Element;
prefixElement?: JSX.Element;
listMode?: boolean;
}
export interface MasonryItemProps {
memo: Memo;
renderer: (memo: Memo, context?: MemoRenderContext) => JSX.Element;
renderContext: MemoRenderContext;
onHeightChange: (memoName: string, height: number) => void;
}
export interface MasonryColumnProps {
memoIndices: number[];
memoList: Memo[];
renderer: (memo: Memo, context?: MemoRenderContext) => JSX.Element;
renderContext: MemoRenderContext;
onHeightChange: (memoName: string, height: number) => void;
isFirstColumn: boolean;
prefixElement?: JSX.Element;
prefixElementRef?: React.RefObject<HTMLDivElement>;
}
export interface DistributionResult {
distribution: number[][];
columnHeights: number[];
}
export interface MemoWithHeight {
index: number;
height: number;
}

View File

@@ -0,0 +1,106 @@
import { useCallback, useEffect, useRef, useState } from "react";
import { Memo } from "@/types/proto/api/v1/memo_service_pb";
import { MINIMUM_MEMO_VIEWPORT_WIDTH, REDISTRIBUTION_DEBOUNCE_MS } from "./constants";
import { distributeItemsToColumns } from "./distributeItems";
export function useMasonryLayout(
memoList: Memo[],
listMode: boolean,
containerRef: React.RefObject<HTMLDivElement>,
prefixElementRef: React.RefObject<HTMLDivElement>,
) {
const [columns, setColumns] = useState(1);
const [itemHeights, setItemHeights] = useState<Map<string, number>>(new Map());
const [distribution, setDistribution] = useState<number[][]>([[]]);
const redistributionTimeoutRef = useRef<number | null>(null);
const itemHeightsRef = useRef<Map<string, number>>(itemHeights);
useEffect(() => {
itemHeightsRef.current = itemHeights;
}, [itemHeights]);
const calculateColumns = useCallback(() => {
if (!containerRef.current || listMode) return 1;
const containerWidth = containerRef.current.offsetWidth;
const scale = containerWidth / MINIMUM_MEMO_VIEWPORT_WIDTH;
return scale >= 1.2 ? Math.ceil(scale) : 1;
}, [containerRef, listMode]);
const redistributeMemos = useCallback(() => {
const prefixHeight = prefixElementRef.current?.offsetHeight || 0;
setDistribution(() => {
const { distribution: newDistribution } = distributeItemsToColumns(memoList, columns, itemHeightsRef.current, prefixHeight);
return newDistribution;
});
}, [memoList, columns, prefixElementRef]);
const debouncedRedistribute = useCallback(
(newItemHeights: Map<string, number>) => {
if (redistributionTimeoutRef.current) {
clearTimeout(redistributionTimeoutRef.current);
}
redistributionTimeoutRef.current = window.setTimeout(() => {
const prefixHeight = prefixElementRef.current?.offsetHeight || 0;
setDistribution(() => {
const { distribution: newDistribution } = distributeItemsToColumns(memoList, columns, newItemHeights, prefixHeight);
return newDistribution;
});
}, REDISTRIBUTION_DEBOUNCE_MS);
},
[memoList, columns, prefixElementRef],
);
const handleHeightChange = useCallback(
(memoName: string, height: number) => {
setItemHeights((prevHeights) => {
const newItemHeights = new Map(prevHeights);
const previousHeight = prevHeights.get(memoName);
if (previousHeight === height) {
return prevHeights;
}
newItemHeights.set(memoName, height);
debouncedRedistribute(newItemHeights);
return newItemHeights;
});
},
[debouncedRedistribute],
);
useEffect(() => {
const handleResize = () => {
if (!containerRef.current) return;
const newColumns = calculateColumns();
if (newColumns !== columns) {
setColumns(newColumns);
}
};
handleResize();
window.addEventListener("resize", handleResize);
return () => window.removeEventListener("resize", handleResize);
}, [calculateColumns, columns, containerRef]);
useEffect(() => {
redistributeMemos();
}, [columns, memoList, redistributeMemos]);
useEffect(() => {
return () => {
if (redistributionTimeoutRef.current) {
clearTimeout(redistributionTimeoutRef.current);
}
};
}, []);
return {
columns,
distribution,
handleHeightChange,
};
}

View File

@@ -0,0 +1,135 @@
import {
ArchiveIcon,
ArchiveRestoreIcon,
BookmarkMinusIcon,
BookmarkPlusIcon,
CopyIcon,
Edit3Icon,
FileTextIcon,
LinkIcon,
MoreVerticalIcon,
TrashIcon,
} from "lucide-react";
import { useState } from "react";
import ConfirmDialog from "@/components/ConfirmDialog";
import { Button } from "@/components/ui/button";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuSub,
DropdownMenuSubContent,
DropdownMenuSubTrigger,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import { State } from "@/types/proto/api/v1/common_pb";
import { useTranslate } from "@/utils/i18n";
import { useMemoActionHandlers } from "./hooks";
import type { MemoActionMenuProps } from "./types";
const MemoActionMenu = (props: MemoActionMenuProps) => {
const { memo, readonly } = props;
const t = useTranslate();
// Dialog state
const [deleteDialogOpen, setDeleteDialogOpen] = useState(false);
// Derived state
const isComment = Boolean(memo.parent);
const isArchived = memo.state === State.ARCHIVED;
// Action handlers
const {
handleTogglePinMemoBtnClick,
handleEditMemoClick,
handleToggleMemoStatusClick,
handleCopyLink,
handleCopyContent,
handleDeleteMemoClick,
confirmDeleteMemo,
} = useMemoActionHandlers({
memo,
onEdit: props.onEdit,
setDeleteDialogOpen,
});
return (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="ghost" size="icon" className="size-4">
<MoreVerticalIcon className="text-muted-foreground" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" sideOffset={2}>
{/* Edit actions (non-readonly, non-archived) */}
{!readonly && !isArchived && (
<>
{!isComment && (
<DropdownMenuItem onClick={handleTogglePinMemoBtnClick}>
{memo.pinned ? <BookmarkMinusIcon className="w-4 h-auto" /> : <BookmarkPlusIcon className="w-4 h-auto" />}
{memo.pinned ? t("common.unpin") : t("common.pin")}
</DropdownMenuItem>
)}
<DropdownMenuItem onClick={handleEditMemoClick}>
<Edit3Icon className="w-4 h-auto" />
{t("common.edit")}
</DropdownMenuItem>
</>
)}
{/* Copy submenu (non-archived) */}
{!isArchived && (
<DropdownMenuSub>
<DropdownMenuSubTrigger>
<CopyIcon className="w-4 h-auto" />
{t("common.copy")}
</DropdownMenuSubTrigger>
<DropdownMenuSubContent>
<DropdownMenuItem onClick={handleCopyLink}>
<LinkIcon className="w-4 h-auto" />
{t("memo.copy-link")}
</DropdownMenuItem>
<DropdownMenuItem onClick={handleCopyContent}>
<FileTextIcon className="w-4 h-auto" />
{t("memo.copy-content")}
</DropdownMenuItem>
</DropdownMenuSubContent>
</DropdownMenuSub>
)}
{/* Write actions (non-readonly) */}
{!readonly && (
<>
{/* Archive/Restore (non-comment) */}
{!isComment && (
<DropdownMenuItem onClick={handleToggleMemoStatusClick}>
{isArchived ? <ArchiveRestoreIcon className="w-4 h-auto" /> : <ArchiveIcon className="w-4 h-auto" />}
{isArchived ? t("common.restore") : t("common.archive")}
</DropdownMenuItem>
)}
{/* Delete */}
<DropdownMenuItem onClick={handleDeleteMemoClick}>
<TrashIcon className="w-4 h-auto" />
{t("common.delete")}
</DropdownMenuItem>
</>
)}
</DropdownMenuContent>
{/* Delete confirmation dialog */}
<ConfirmDialog
open={deleteDialogOpen}
onOpenChange={setDeleteDialogOpen}
title={t("memo.delete-confirm")}
confirmLabel={t("common.delete")}
description={t("memo.delete-confirm-description")}
cancelLabel={t("common.cancel")}
onConfirm={confirmDeleteMemo}
confirmVariant="destructive"
/>
</DropdownMenu>
);
};
export default MemoActionMenu;

View File

@@ -0,0 +1,126 @@
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 { userKeys } from "@/hooks/useUserQueries";
import { handleError } from "@/lib/error";
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";
interface UseMemoActionHandlersOptions {
memo: Memo;
onEdit?: () => void;
setDeleteDialogOpen: (open: boolean) => void;
}
export const useMemoActionHandlers = ({ memo, onEdit, setDeleteDialogOpen }: UseMemoActionHandlersOptions) => {
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(() => {
// Invalidate user stats to trigger refetch
queryClient.invalidateQueries({ queryKey: userKeys.stats() });
}, [queryClient]);
const handleTogglePinMemoBtnClick = useCallback(async () => {
try {
await updateMemo({
update: {
name: memo.name,
pinned: !memo.pinned,
},
updateMask: ["pinned"],
});
} catch {
// do nothing
}
}, [memo.name, memo.pinned, updateMemo]);
const handleEditMemoClick = useCallback(() => {
onEdit?.();
}, [onEdit]);
const handleToggleMemoStatusClick = useCallback(async () => {
const isArchiving = memo.state !== State.ARCHIVED;
const state = memo.state === State.ARCHIVED ? State.NORMAL : State.ARCHIVED;
const message = memo.state === State.ARCHIVED ? t("message.restored-successfully") : t("message.archived-successfully");
try {
await updateMemo({
update: {
name: memo.name,
state,
},
updateMask: ["state"],
});
toast.success(message);
} catch (error: unknown) {
handleError(error, toast.error, {
context: `${isArchiving ? "Archive" : "Restore"} memo`,
fallbackMessage: "An error occurred",
});
return;
}
if (isInMemoDetailPage) {
navigateTo(memo.state === State.ARCHIVED ? "/" : "/archived");
}
memoUpdatedCallback();
}, [memo.name, memo.state, t, isInMemoDetailPage, navigateTo, memoUpdatedCallback, updateMemo]);
const handleCopyLink = useCallback(() => {
let host = profile.instanceUrl;
if (host === "") {
host = window.location.origin;
}
copy(`${host}/${memo.name}`);
toast.success(t("message.succeed-copy-link"));
}, [memo.name, t, profile.instanceUrl]);
const handleCopyContent = useCallback(() => {
copy(memo.content);
toast.success(t("message.succeed-copy-content"));
}, [memo.content, t]);
const handleDeleteMemoClick = useCallback(() => {
setDeleteDialogOpen(true);
}, [setDeleteDialogOpen]);
const confirmDeleteMemo = useCallback(async () => {
try {
await deleteMemo(memo.name);
} catch (error: unknown) {
handleError(error, toast.error, { context: "Delete memo", fallbackMessage: "An error occurred" });
return;
}
toast.success(t("message.deleted-successfully"));
if (memo.parent) {
queryClient.invalidateQueries({ queryKey: memoKeys.comments(memo.parent) });
}
if (isInMemoDetailPage) {
navigateTo("/");
}
memoUpdatedCallback();
}, [memo.name, memo.parent, t, isInMemoDetailPage, navigateTo, memoUpdatedCallback, deleteMemo, queryClient]);
return {
handleTogglePinMemoBtnClick,
handleEditMemoClick,
handleToggleMemoStatusClick,
handleCopyLink,
handleCopyContent,
handleDeleteMemoClick,
confirmDeleteMemo,
};
};

View File

@@ -0,0 +1,3 @@
export { useMemoActionHandlers } from "./hooks";
export { default, default as MemoActionMenu } from "./MemoActionMenu";
export type { MemoActionMenuProps, UseMemoActionHandlersReturn } from "./types";

View File

@@ -0,0 +1,20 @@
import type { Memo } from "@/types/proto/api/v1/memo_service_pb";
export interface MemoActionMenuProps {
memo: Memo;
readonly?: boolean;
className?: string;
onEdit?: () => void;
}
export interface UseMemoActionHandlersReturn {
handleTogglePinMemoBtnClick: () => Promise<void>;
handleEditMemoClick: () => void;
handleToggleMemoStatusClick: () => Promise<void>;
handleCopyLink: () => void;
handleCopyContent: () => void;
handleDeleteMemoClick: () => void;
confirmDeleteMemo: () => Promise<void>;
handleRemoveCompletedTaskListItemsClick: () => void;
confirmRemoveCompletedTaskListItems: () => Promise<void>;
}

View File

@@ -0,0 +1,36 @@
import { Attachment } from "@/types/proto/api/v1/attachment_service_pb";
import { getAttachmentUrl, isMidiFile } from "@/utils/attachment";
import AttachmentIcon from "./AttachmentIcon";
interface Props {
attachment: Attachment;
className?: string;
}
const MemoAttachment: React.FC<Props> = (props: Props) => {
const { className, attachment } = props;
const attachmentUrl = getAttachmentUrl(attachment);
const handlePreviewBtnClick = () => {
window.open(attachmentUrl);
};
return (
<div
className={`w-auto flex flex-row justify-start items-center text-muted-foreground hover:text-foreground hover:bg-accent rounded px-2 py-1 transition-colors ${className}`}
>
{attachment.type.startsWith("audio") && !isMidiFile(attachment.type) ? (
<audio src={attachmentUrl} controls></audio>
) : (
<>
<AttachmentIcon className="w-4! h-4! mr-1" attachment={attachment} />
<span className="text-sm max-w-[256px] truncate cursor-pointer" onClick={handlePreviewBtnClick}>
{attachment.filename}
</span>
</>
)}
</div>
);
};
export default MemoAttachment;

View File

@@ -0,0 +1,156 @@
import copy from "copy-to-clipboard";
import hljs from "highlight.js";
import { CheckIcon, CopyIcon } from "lucide-react";
import { useEffect, useMemo, useState } from "react";
import { useAuth } from "@/contexts/AuthContext";
import { cn } from "@/lib/utils";
import { getThemeWithFallback, resolveTheme } from "@/utils/theme";
import { MermaidBlock } from "./MermaidBlock";
import type { ReactMarkdownProps } from "./markdown/types";
import { extractCodeContent, extractLanguage } from "./utils";
interface CodeBlockProps extends ReactMarkdownProps {
children?: React.ReactNode;
className?: string;
}
export const CodeBlock = ({ children, className, node: _node, ...props }: CodeBlockProps) => {
const { userGeneralSetting } = useAuth();
const [copied, setCopied] = useState(false);
const codeElement = children as React.ReactElement;
const codeClassName = codeElement?.props?.className || "";
const codeContent = extractCodeContent(children);
const language = extractLanguage(codeClassName);
// If it's a mermaid block, render with MermaidBlock component
if (language === "mermaid") {
return (
<pre className="relative">
<MermaidBlock className={cn(className)} {...props}>
{children}
</MermaidBlock>
</pre>
);
}
const theme = getThemeWithFallback(userGeneralSetting?.theme);
const resolvedTheme = resolveTheme(theme);
const isDarkTheme = resolvedTheme.includes("dark");
// Dynamically load highlight.js theme based on app theme
useEffect(() => {
const dynamicImportStyle = async () => {
// Remove any existing highlight.js style
const existingStyle = document.querySelector("style[data-hljs-theme]");
if (existingStyle) {
existingStyle.remove();
}
try {
const cssModule = isDarkTheme
? await import("highlight.js/styles/github-dark-dimmed.css?inline")
: await import("highlight.js/styles/github.css?inline");
// Create and inject the style
const style = document.createElement("style");
style.textContent = cssModule.default;
style.setAttribute("data-hljs-theme", isDarkTheme ? "dark" : "light");
document.head.appendChild(style);
} catch (error) {
console.warn("Failed to load highlight.js theme:", error);
}
};
dynamicImportStyle();
}, [resolvedTheme, isDarkTheme]);
// Highlight code using highlight.js
const highlightedCode = useMemo(() => {
try {
const lang = hljs.getLanguage(language);
if (lang) {
return hljs.highlight(codeContent, {
language: language,
}).value;
}
} catch {
// Skip error and use default highlighted code.
}
// Escape any HTML entities when rendering original content.
return Object.assign(document.createElement("span"), {
textContent: codeContent,
}).innerHTML;
}, [language, codeContent]);
const handleCopy = async () => {
try {
// Try native clipboard API first (requires HTTPS or localhost)
if (navigator.clipboard && window.isSecureContext) {
await navigator.clipboard.writeText(codeContent);
setCopied(true);
setTimeout(() => setCopied(false), 2000);
} else {
// Fallback to copy-to-clipboard library for non-secure contexts
const success = copy(codeContent);
if (success) {
setCopied(true);
setTimeout(() => setCopied(false), 2000);
} else {
console.error("Failed to copy code");
}
}
} catch (err) {
// If native API fails, try fallback
console.warn("Native clipboard failed, using fallback:", err);
const success = copy(codeContent);
if (success) {
setCopied(true);
setTimeout(() => setCopied(false), 2000);
} else {
console.error("Failed to copy code:", err);
}
}
};
return (
<pre className="relative my-2 rounded-lg border border-border bg-muted/20 overflow-hidden">
{/* Header with language label and copy button */}
<div className="flex items-center justify-between px-2 py-1 border-b border-border bg-muted/30">
<span className="text-xs text-foreground select-none">{language || "text"}</span>
<button
onClick={handleCopy}
className={cn(
"inline-flex items-center gap-1 px-1.5 py-0.5 rounded text-xs",
"transition-colors duration-200",
"hover:bg-accent active:scale-95",
copied ? "text-primary" : "text-muted-foreground hover:text-foreground",
)}
aria-label={copied ? "Copied" : "Copy code"}
title={copied ? "Copied!" : "Copy code"}
>
{copied ? (
<>
<CheckIcon className="w-3.5 h-3.5" />
<span>Copied</span>
</>
) : (
<>
<CopyIcon className="w-3.5 h-3.5" />
<span>Copy</span>
</>
)}
</button>
</div>
{/* Code content */}
<div className="overflow-x-auto">
<code
className={cn("block px-3 py-2 text-sm leading-relaxed", `language-${language}`)}
dangerouslySetInnerHTML={{ __html: highlightedCode }}
/>
</div>
</pre>
);
};

View File

@@ -0,0 +1,36 @@
import type { Element } from "hast";
import React from "react";
import { isTagElement, isTaskListItemElement } from "@/types/markdown";
/**
* Creates a conditional component that renders different components
* based on AST node type detection
*
* @param CustomComponent - Custom component to render when condition matches
* @param DefaultComponent - Default component/element to render otherwise
* @param condition - Function to test AST node
* @returns Conditional wrapper component
*/
export const createConditionalComponent = <P extends Record<string, unknown>>(
CustomComponent: React.ComponentType<P>,
DefaultComponent: React.ComponentType<P> | keyof JSX.IntrinsicElements,
condition: (node: Element) => boolean,
) => {
return (props: P & { node?: Element }) => {
const { node, ...restProps } = props;
// Check AST node to determine which component to use
if (node && condition(node)) {
return <CustomComponent {...(restProps as P)} node={node} />;
}
// Render default component/element
if (typeof DefaultComponent === "string") {
return React.createElement(DefaultComponent, restProps);
}
return <DefaultComponent {...(restProps as P)} />;
};
};
// Re-export type guards for convenience
export { isTagElement as isTagNode, isTaskListItemElement as isTaskListItemNode };

View File

@@ -0,0 +1,93 @@
import mermaid from "mermaid";
import { useEffect, useMemo, useRef, useState } from "react";
import { useAuth } from "@/contexts/AuthContext";
import { cn } from "@/lib/utils";
import { getThemeWithFallback, resolveTheme, setupSystemThemeListener } from "@/utils/theme";
import { extractCodeContent } from "./utils";
interface MermaidBlockProps {
children?: React.ReactNode;
className?: string;
}
const getMermaidTheme = (appTheme: string): "default" | "dark" => {
return appTheme === "default-dark" ? "dark" : "default";
};
export const MermaidBlock = ({ children, className }: MermaidBlockProps) => {
const { userGeneralSetting } = useAuth();
const containerRef = useRef<HTMLDivElement>(null);
const [svg, setSvg] = useState<string>("");
const [error, setError] = useState<string>("");
const [systemThemeChange, setSystemThemeChange] = useState(0);
const codeContent = extractCodeContent(children);
// Get theme preference (reactive via AuthContext)
// Falls back to localStorage or system preference if no user setting
const themePreference = getThemeWithFallback(userGeneralSetting?.theme);
// Resolve theme to actual value (handles "system" theme + system theme changes)
const currentTheme = useMemo(() => resolveTheme(themePreference), [themePreference, systemThemeChange]);
// Listen for OS theme changes when using "system" theme preference
useEffect(() => {
if (themePreference !== "system") {
return;
}
return setupSystemThemeListener(() => {
setSystemThemeChange((prev) => prev + 1);
});
}, [themePreference]);
// Render Mermaid diagram when content or theme changes
useEffect(() => {
if (!codeContent || !containerRef.current) {
return;
}
const renderDiagram = async () => {
try {
const id = `mermaid-${Math.random().toString(36).substring(7)}`;
const mermaidTheme = getMermaidTheme(currentTheme);
mermaid.initialize({
startOnLoad: false,
theme: mermaidTheme,
securityLevel: "strict",
fontFamily: "inherit",
});
const { svg: renderedSvg } = await mermaid.render(id, codeContent);
setSvg(renderedSvg);
setError("");
} catch (err) {
console.error("Failed to render mermaid diagram:", err);
setError(err instanceof Error ? err.message : "Failed to render diagram");
}
};
renderDiagram();
}, [codeContent, currentTheme]);
// If there's an error, fall back to showing the code
if (error) {
return (
<div className="w-full">
<div className="text-sm text-destructive mb-2">Mermaid Error: {error}</div>
<pre className={className}>
<code className="language-mermaid">{codeContent}</code>
</pre>
</div>
);
}
return (
<div
ref={containerRef}
className={cn("mermaid-diagram w-full flex justify-center items-center my-2 overflow-x-auto", className)}
dangerouslySetInnerHTML={{ __html: svg }}
/>
);
};

View File

@@ -0,0 +1,130 @@
import { useMemo } from "react";
import type { Attachment } from "@/types/proto/api/v1/attachment_service_pb";
import { getAttachmentUrl } from "@/utils/attachment";
interface PositionedImageViewProps {
imageId: string;
className?: string;
style?: React.CSSProperties | { cssText: string };
alt?: string;
}
// Simplified positioned image view for memo display (read-only)
export const PositionedImageView = ({ imageId, className, style, alt }: PositionedImageViewProps) => {
// Debug: log component props
// console.log('PositionedImageView props:', { imageId, style });
// Construct the URL directly using Memos standard URL structure
// Format: /file/{attachment-name}/{filename}
const imageUrl = useMemo(() => {
// Extract filename from imageId if it contains path info
// For simple IDs, we'll use a generic filename
const filename = imageId.includes('/') ? imageId.split('/').pop() || 'image' : 'image.jpg';
return `${window.location.origin}/file/${imageId}/${filename}`;
}, [imageId]);
const containerStyle = useMemo(() => {
const baseStyles: React.CSSProperties = {
display: 'block',
};
// Handle different style input types
if (style) {
if ('cssText' in style) {
// Handle string-based styles
const styleStr = style.cssText;
if (styleStr.includes('text-align: left')) baseStyles.textAlign = 'left';
if (styleStr.includes('text-align: center')) baseStyles.textAlign = 'center';
if (styleStr.includes('text-align: right')) baseStyles.textAlign = 'right';
if (styleStr.includes('float: left')) baseStyles.float = 'left';
if (styleStr.includes('float: right')) baseStyles.float = 'right';
// Handle margin - only set if not already defined
if (styleStr.includes('margin:') && !('margin' in baseStyles)) {
const marginMatch = styleStr.match(/margin:\s*([^;]+)/);
if (marginMatch) baseStyles.margin = marginMatch[1].trim();
}
// Set default margin-bottom only if no margin is specified
if (!styleStr.includes('margin:') && !('margin' in baseStyles) && !('marginBottom' in baseStyles)) {
baseStyles.marginBottom = '1rem';
}
} else {
// Handle CSSProperties object
Object.assign(baseStyles, style);
// Set default margin-bottom only if no margin properties exist
if (!('margin' in baseStyles) && !('marginBottom' in baseStyles)) {
baseStyles.marginBottom = '1rem';
}
}
}
return baseStyles;
}, [style]);
const imageStyle = useMemo(() => {
const baseStyles: React.CSSProperties = {
maxWidth: '100%',
height: 'auto',
borderRadius: '0.5rem',
};
// Extract width/height from style if present
if (style) {
if ('cssText' in style) {
const styleStr = style.cssText;
const widthMatch = styleStr.match(/width:\s*([^;]+)/);
const heightMatch = styleStr.match(/height:\s*([^;]+)/);
if (widthMatch) baseStyles.width = widthMatch[1].trim();
if (heightMatch) baseStyles.height = heightMatch[1].trim();
}
}
return baseStyles;
}, [style]);
// Debug: log image URL
// console.log('Image URL:', imageUrl);
// Fallback to showing the tag if image URL cannot be constructed
if (!imageUrl || imageUrl.includes('undefined')) {
return (
<span className="inline-block bg-yellow-100 text-yellow-800 px-1 rounded text-sm">
[img:{imageId}]
</span>
);
}
return (
<div
className={`positioned-image-wrapper ${className || ''}`}
style={containerStyle}
data-img-id={imageId}
>
<img
src={imageUrl}
alt={alt || `Image ${imageId}`}
style={imageStyle}
className="rounded-lg shadow-sm"
loading="lazy"
onError={(e) => {
// Fallback if image fails to load
const target = e.target as HTMLImageElement;
target.style.display = 'none';
const fallback = document.createElement('span');
fallback.className = 'inline-block bg-red-100 text-red-800 px-1 rounded text-sm';
fallback.textContent = `[img:${imageId}] (failed to load)`;
target.parentNode?.appendChild(fallback);
}}
/>
</div>
);
};
// Alternative approach: Create a hook to get attachments for the current memo
export const useMemoAttachments = () => {
// This would need to be implemented based on how memos are loaded
// For now, return empty array
return [];
};

View File

@@ -0,0 +1,83 @@
import { cn } from "@/lib/utils";
import type { ReactMarkdownProps } from "./markdown/types";
interface TableProps extends React.HTMLAttributes<HTMLTableElement>, ReactMarkdownProps {
children: React.ReactNode;
}
export const Table = ({ children, className, node: _node, ...props }: TableProps) => {
return (
<div className="w-full overflow-x-auto rounded-lg border border-border my-2">
<table className={cn("w-full border-collapse text-sm", className)} {...props}>
{children}
</table>
</div>
);
};
interface TableHeadProps extends React.HTMLAttributes<HTMLTableSectionElement>, ReactMarkdownProps {
children: React.ReactNode;
}
export const TableHead = ({ children, className, node: _node, ...props }: TableHeadProps) => {
return (
<thead className={cn("bg-accent/50", className)} {...props}>
{children}
</thead>
);
};
interface TableBodyProps extends React.HTMLAttributes<HTMLTableSectionElement>, ReactMarkdownProps {
children: React.ReactNode;
}
export const TableBody = ({ children, className, node: _node, ...props }: TableBodyProps) => {
return (
<tbody className={cn("divide-y divide-border", className)} {...props}>
{children}
</tbody>
);
};
interface TableRowProps extends React.HTMLAttributes<HTMLTableRowElement>, ReactMarkdownProps {
children: React.ReactNode;
}
export const TableRow = ({ children, className, node: _node, ...props }: TableRowProps) => {
return (
<tr className={cn("transition-colors hover:bg-muted/30", className)} {...props}>
{children}
</tr>
);
};
interface TableHeaderCellProps extends React.ThHTMLAttributes<HTMLTableCellElement>, ReactMarkdownProps {
children: React.ReactNode;
}
export const TableHeaderCell = ({ children, className, node: _node, ...props }: TableHeaderCellProps) => {
return (
<th
className={cn(
"px-3 py-2 text-left text-xs font-semibold uppercase tracking-wider text-muted-foreground",
"border-b-2 border-border",
className,
)}
{...props}
>
{children}
</th>
);
};
interface TableCellProps extends React.TdHTMLAttributes<HTMLTableCellElement>, ReactMarkdownProps {
children: React.ReactNode;
}
export const TableCell = ({ children, className, node: _node, ...props }: TableCellProps) => {
return (
<td className={cn("px-3 py-2 text-left", className)} {...props}>
{children}
</td>
);
};

View File

@@ -0,0 +1,59 @@
import type { Element } from "hast";
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 { useMemoViewContext } from "../MemoView/MemoViewContext";
interface TagProps extends React.HTMLAttributes<HTMLSpanElement> {
node?: Element; // AST node from react-markdown
"data-tag"?: string;
children?: React.ReactNode;
}
export const Tag: React.FC<TagProps> = ({ "data-tag": dataTag, children, className, ...props }) => {
const { parentPage } = useMemoViewContext();
const location = useLocation();
const navigateTo = useNavigateTo();
const { getFiltersByFactor, removeFilter, addFilter } = useMemoFilterContext();
const tag = dataTag || "";
const handleTagClick = (e: React.MouseEvent) => {
e.stopPropagation();
// If the tag is clicked in a memo detail page, we should navigate to the memo list page.
if (location.pathname.startsWith("/m")) {
const pathname = parentPage || Routes.ROOT;
const searchParams = new URLSearchParams();
searchParams.set("filter", stringifyFilters([{ factor: "tagSearch", value: tag }]));
navigateTo(`${pathname}?${searchParams.toString()}`);
return;
}
const isActive = getFiltersByFactor("tagSearch").some((filter: MemoFilter) => filter.value === tag);
if (isActive) {
removeFilter((f: MemoFilter) => f.factor === "tagSearch" && f.value === tag);
} else {
// Remove all existing tag filters first, then add the new one
removeFilter((f: MemoFilter) => f.factor === "tagSearch");
addFilter({
factor: "tagSearch",
value: tag,
});
}
};
return (
<span
className={cn("inline-block w-auto text-primary cursor-pointer transition-colors hover:text-primary/80", className)}
data-tag={tag}
{...props}
onClick={handleTagClick}
>
{children}
</span>
);
};

View File

@@ -0,0 +1,70 @@
import { useRef } from "react";
import { Checkbox } from "@/components/ui/checkbox";
import { useUpdateMemo } from "@/hooks/useMemoQueries";
import { toggleTaskAtIndex } from "@/utils/markdown-manipulation";
import { useMemoViewContext, useMemoViewDerived } from "../MemoView/MemoViewContext";
import { TASK_LIST_ITEM_CLASS } from "./constants";
import type { ReactMarkdownProps } from "./markdown/types";
interface TaskListItemProps extends React.InputHTMLAttributes<HTMLInputElement>, ReactMarkdownProps {
checked?: boolean;
}
export const TaskListItem: React.FC<TaskListItemProps> = ({ checked, node: _node, ...props }) => {
const { memo } = useMemoViewContext();
const { readonly } = useMemoViewDerived();
const checkboxRef = useRef<HTMLButtonElement>(null);
const { mutate: updateMemo } = useUpdateMemo();
const handleChange = async (newChecked: boolean) => {
// Don't update if readonly or no memo
if (readonly || !memo) {
return;
}
// Find the task index by walking up the DOM
const listItem = checkboxRef.current?.closest("li.task-list-item");
if (!listItem) {
return;
}
// Get task index from data attribute, or calculate by counting
const taskIndexStr = listItem.getAttribute("data-task-index");
let taskIndex = 0;
if (taskIndexStr !== null) {
taskIndex = parseInt(taskIndexStr);
} else {
// Fallback: Calculate index by counting all task list items in the entire memo
// We need to search from the root memo content container, not just the nearest list
// to ensure nested tasks are counted in document order
let searchRoot = listItem.closest("[data-memo-content]");
// If memo content container not found, search from document body
if (!searchRoot) {
searchRoot = document.body;
}
const allTaskItems = searchRoot.querySelectorAll(`li.${TASK_LIST_ITEM_CLASS}`);
for (let i = 0; i < allTaskItems.length; i++) {
if (allTaskItems[i] === listItem) {
taskIndex = i;
break;
}
}
}
// Update memo content using the string manipulation utility
const newContent = toggleTaskAtIndex(memo.content, taskIndex, newChecked);
updateMemo({
update: {
name: memo.name,
content: newContent,
},
updateMask: ["content"],
});
};
// Override the disabled prop from remark-gfm (which defaults to true)
return <Checkbox ref={checkboxRef} checked={checked} disabled={readonly} onCheckedChange={handleChange} className={props.className} />;
};

View File

@@ -0,0 +1,80 @@
import { defaultSchema } from "rehype-sanitize";
// Class names added by remark-gfm for task lists
export const TASK_LIST_CLASS = "contains-task-list";
export const TASK_LIST_ITEM_CLASS = "task-list-item";
// Compact mode display settings
export const COMPACT_MODE_CONFIG = {
maxHeightVh: 60, // 60% of viewport height
gradientHeight: "h-24", // Tailwind class for gradient overlay
} as const;
export const getMaxDisplayHeight = () => window.innerHeight * (COMPACT_MODE_CONFIG.maxHeightVh / 100);
export const COMPACT_STATES: Record<"ALL" | "SNIPPET", { textKey: string; next: "ALL" | "SNIPPET" }> = {
ALL: { textKey: "memo.show-more", next: "SNIPPET" },
SNIPPET: { textKey: "memo.show-less", next: "ALL" },
};
/**
* Sanitization schema for markdown HTML content.
* Extends the default schema to allow:
* - KaTeX math rendering elements (MathML tags)
* - KaTeX-specific attributes (className, style, aria-*, data-*)
* - Safe HTML elements for rich content
* - iframe embeds for trusted video providers (YouTube, Vimeo, etc.)
*
* This prevents XSS attacks while preserving math rendering functionality.
*/
export const SANITIZE_SCHEMA = {
...defaultSchema,
attributes: {
...defaultSchema.attributes,
div: [...(defaultSchema.attributes?.div || []), "className", ["data*"]],
span: [...(defaultSchema.attributes?.span || []), "className", "style", ["aria*"], ["data*"]],
img: [...(defaultSchema.attributes?.img || []), "className", "style", ["data*"]],
// iframe attributes for video embeds
iframe: ["src", "width", "height", "frameborder", "allowfullscreen", "allow", "title", "referrerpolicy", "loading"],
// MathML attributes for KaTeX rendering
annotation: ["encoding"],
math: ["xmlns"],
mi: [],
mn: [],
mo: [],
mrow: [],
mspace: [],
mstyle: [],
msup: [],
msub: [],
msubsup: [],
mfrac: [],
mtext: [],
semantics: [],
},
tagNames: [
...(defaultSchema.tagNames || []),
// iframe for video embeds
"iframe",
// MathML elements for KaTeX math rendering
"math",
"annotation",
"semantics",
"mi",
"mn",
"mo",
"mrow",
"mspace",
"mstyle",
"msup",
"msub",
"msubsup",
"mfrac",
"mtext",
],
protocols: {
...defaultSchema.protocols,
// Allow HTTPS iframe embeds only for security
iframe: { src: ["https"] },
},
};

View File

@@ -0,0 +1,28 @@
import { useCallback, useEffect, useRef, useState } from "react";
import { COMPACT_STATES, getMaxDisplayHeight } from "./constants";
import type { ContentCompactView } from "./types";
export const useCompactMode = (enabled: boolean) => {
const containerRef = useRef<HTMLDivElement>(null);
const [mode, setMode] = useState<ContentCompactView | undefined>(undefined);
useEffect(() => {
if (!enabled || !containerRef.current) return;
const maxHeight = getMaxDisplayHeight();
if (containerRef.current.getBoundingClientRect().height > maxHeight) {
setMode("ALL");
}
}, [enabled]);
const toggle = useCallback(() => {
if (!mode) return;
setMode(COMPACT_STATES[mode].next);
}, [mode]);
return { containerRef, mode, toggle };
};
export const useCompactLabel = (mode: ContentCompactView | undefined, t: (key: string) => string): string => {
if (!mode) return "";
return t(COMPACT_STATES[mode].textKey);
};

View File

@@ -0,0 +1,148 @@
import type { Element } from "hast";
import { ChevronDown, ChevronUp } from "lucide-react";
import { memo } from "react";
import ReactMarkdown from "react-markdown";
import rehypeKatex from "rehype-katex";
import rehypeRaw from "rehype-raw";
import rehypeSanitize from "rehype-sanitize";
import remarkBreaks from "remark-breaks";
import remarkGfm from "remark-gfm";
import remarkMath from "remark-math";
import { cn } from "@/lib/utils";
import { useTranslate } from "@/utils/i18n";
import { remarkDisableSetext } from "@/utils/remark-plugins/remark-disable-setext";
import { remarkPreserveType } from "@/utils/remark-plugins/remark-preserve-type";
import { remarkTag } from "@/utils/remark-plugins/remark-tag";
import { remarkImagePosition } from "@/utils/remark-plugins/remark-image-position";
import { PositionedImageView } from "./PositionedImageView";
import { CodeBlock } from "./CodeBlock";
import { isTagNode, isTaskListItemNode } from "./ConditionalComponent";
import { COMPACT_MODE_CONFIG, SANITIZE_SCHEMA } from "./constants";
import { useCompactLabel, useCompactMode } from "./hooks";
import { Blockquote, Heading, HorizontalRule, Image, InlineCode, Link, List, ListItem, Paragraph } from "./markdown";
import { Table, TableBody, TableCell, TableHead, TableHeaderCell, TableRow } from "./Table";
import { Tag } from "./Tag";
import { TaskListItem } from "./TaskListItem";
import type { MemoContentProps } from "./types";
const MemoContent = (props: MemoContentProps) => {
const { className, contentClassName, content, onClick, onDoubleClick } = props;
const t = useTranslate();
const {
containerRef: memoContentContainerRef,
mode: showCompactMode,
toggle: toggleCompactMode,
} = useCompactMode(Boolean(props.compact));
const compactLabel = useCompactLabel(showCompactMode, t as (key: string) => string);
return (
<div className={`w-full flex flex-col justify-start items-start text-foreground ${className || ""}`}>
<div
ref={memoContentContainerRef}
data-memo-content
className={cn(
"relative w-full max-w-full wrap-break-word text-base leading-6",
"[&>*:last-child]:mb-0",
showCompactMode === "ALL" && "overflow-hidden",
contentClassName,
)}
style={showCompactMode === "ALL" ? { maxHeight: `${COMPACT_MODE_CONFIG.maxHeightVh}vh` } : undefined}
onMouseUp={onClick}
onDoubleClick={onDoubleClick}
>
<ReactMarkdown
remarkPlugins={[remarkDisableSetext, remarkMath, remarkGfm, remarkBreaks, remarkTag, remarkPreserveType, remarkImagePosition]}
rehypePlugins={[rehypeRaw, [rehypeSanitize, SANITIZE_SCHEMA], rehypeKatex]}
remarkRehypeOptions={{ allowDangerousHtml: true }}
components={{
// Child components consume from MemoViewContext directly
input: ((inputProps: React.ComponentProps<"input"> & { node?: Element }) => {
if (inputProps.node && isTaskListItemNode(inputProps.node)) {
return <TaskListItem {...inputProps} />;
}
return <input {...inputProps} />;
}) as React.ComponentType<React.ComponentProps<"input">>,
span: ((spanProps: React.ComponentProps<"span"> & { node?: Element }) => {
const { node, ...rest } = spanProps;
if (node && isTagNode(node)) {
return <Tag {...spanProps} />;
}
return <span {...rest} />;
}) as React.ComponentType<React.ComponentProps<"span">>,
div: ((divProps: React.ComponentProps<"div"> & { node?: Element }) => {
const { node, ...rest } = divProps;
// Handle positioned image containers
if (node && node.properties && 'data-img-id' in node.properties) {
const imageId = node.properties['data-img-id'] as string;
// Pass along style information for positioning
const style = node.properties.style as string || '';
return <PositionedImageView imageId={imageId} style={{ cssText: style }} />;
}
// Debug: log other div nodes to see structure
// console.log('Div node:', node);
return <div {...rest} />;
}) as React.ComponentType<React.ComponentProps<"div">>,
// Headings
h1: ({ children }) => <Heading level={1}>{children}</Heading>,
h2: ({ children }) => <Heading level={2}>{children}</Heading>,
h3: ({ children }) => <Heading level={3}>{children}</Heading>,
h4: ({ children }) => <Heading level={4}>{children}</Heading>,
h5: ({ children }) => <Heading level={5}>{children}</Heading>,
h6: ({ children }) => <Heading level={6}>{children}</Heading>,
// Block elements
p: ({ children }) => <Paragraph>{children}</Paragraph>,
blockquote: ({ children }) => <Blockquote>{children}</Blockquote>,
hr: () => <HorizontalRule />,
// Lists
ul: ({ children, ...props }) => <List {...props}>{children}</List>,
ol: ({ children, ...props }) => (
<List ordered {...props}>
{children}
</List>
),
li: ({ children, ...props }) => <ListItem {...props}>{children}</ListItem>,
// Inline elements
a: ({ children, ...props }) => <Link {...props}>{children}</Link>,
code: ({ children }) => <InlineCode>{children}</InlineCode>,
img: ({ ...props }) => <Image {...props} />,
// Code blocks
pre: CodeBlock,
// Tables
table: ({ children }) => <Table>{children}</Table>,
thead: ({ children }) => <TableHead>{children}</TableHead>,
tbody: ({ children }) => <TableBody>{children}</TableBody>,
tr: ({ children }) => <TableRow>{children}</TableRow>,
th: ({ children, ...props }) => <TableHeaderCell {...props}>{children}</TableHeaderCell>,
td: ({ children, ...props }) => <TableCell {...props}>{children}</TableCell>,
}}
>
{content}
</ReactMarkdown>
{showCompactMode === "ALL" && (
<div
className={cn(
"absolute inset-x-0 bottom-0 pointer-events-none",
COMPACT_MODE_CONFIG.gradientHeight,
"bg-linear-to-t from-background from-0% via-background/60 via-40% to-transparent to-100%",
)}
/>
)}
</div>
{showCompactMode !== undefined && (
<div className="relative w-full mt-2">
<button
type="button"
className="group inline-flex items-center gap-1 px-2 py-1 text-xs text-muted-foreground hover:text-foreground transition-colors"
onClick={toggleCompactMode}
>
<span>{compactLabel}</span>
{showCompactMode === "ALL" ? <ChevronDown className="w-3 h-3" /> : <ChevronUp className="w-3 h-3" />}
</button>
</div>
)}
</div>
);
};
export default memo(MemoContent);

View File

@@ -0,0 +1,17 @@
import { cn } from "@/lib/utils";
import type { ReactMarkdownProps } from "./types";
interface BlockquoteProps extends React.BlockquoteHTMLAttributes<HTMLQuoteElement>, ReactMarkdownProps {
children: React.ReactNode;
}
/**
* Blockquote component with left border accent
*/
export const Blockquote = ({ children, className, node: _node, ...props }: BlockquoteProps) => {
return (
<blockquote className={cn("my-0 mb-2 border-l-4 border-primary/30 pl-3 text-muted-foreground italic", className)} {...props}>
{children}
</blockquote>
);
};

View File

@@ -0,0 +1,30 @@
import { cn } from "@/lib/utils";
import type { ReactMarkdownProps } from "./types";
interface HeadingProps extends React.HTMLAttributes<HTMLHeadingElement>, ReactMarkdownProps {
level: 1 | 2 | 3 | 4 | 5 | 6;
children: React.ReactNode;
}
/**
* Heading component for h1-h6 elements
* Renders semantic heading levels with consistent styling
*/
export const Heading = ({ level, children, className, node: _node, ...props }: HeadingProps) => {
const Component = `h${level}` as const;
const levelClasses = {
1: "text-3xl font-bold border-b border-border pb-2",
2: "text-2xl font-semibold border-b border-border pb-1.5",
3: "text-xl font-semibold",
4: "text-lg font-semibold",
5: "text-base font-semibold",
6: "text-base font-medium text-muted-foreground",
};
return (
<Component className={cn("mt-3 mb-2 leading-tight", levelClasses[level], className)} {...props}>
{children}
</Component>
);
};

View File

@@ -0,0 +1,11 @@
import { cn } from "@/lib/utils";
import type { ReactMarkdownProps } from "./types";
interface HorizontalRuleProps extends React.HTMLAttributes<HTMLHRElement>, ReactMarkdownProps {}
/**
* Horizontal rule separator
*/
export const HorizontalRule = ({ className, node: _node, ...props }: HorizontalRuleProps) => {
return <hr className={cn("my-2 h-0 border-0 border-b border-border", className)} {...props} />;
};

View File

@@ -0,0 +1,12 @@
import { cn } from "@/lib/utils";
import type { ReactMarkdownProps } from "./types";
interface ImageProps extends React.ImgHTMLAttributes<HTMLImageElement>, ReactMarkdownProps {}
/**
* Image component for markdown images
* Responsive with rounded corners
*/
export const Image = ({ className, alt, node: _node, ...props }: ImageProps) => {
return <img className={cn("max-w-full h-auto rounded-lg my-2", className)} alt={alt} {...props} />;
};

View File

@@ -0,0 +1,17 @@
import { cn } from "@/lib/utils";
import type { ReactMarkdownProps } from "./types";
interface InlineCodeProps extends React.HTMLAttributes<HTMLElement>, ReactMarkdownProps {
children: React.ReactNode;
}
/**
* Inline code component with background and monospace font
*/
export const InlineCode = ({ children, className, node: _node, ...props }: InlineCodeProps) => {
return (
<code className={cn("font-mono text-sm bg-muted px-1 py-0.5 rounded-md", className)} {...props}>
{children}
</code>
);
};

View File

@@ -0,0 +1,27 @@
import { cn } from "@/lib/utils";
import type { ReactMarkdownProps } from "./types";
interface LinkProps extends React.AnchorHTMLAttributes<HTMLAnchorElement>, ReactMarkdownProps {
children: React.ReactNode;
}
/**
* Link component for external links
* Opens in new tab with security attributes
*/
export const Link = ({ children, className, href, node: _node, ...props }: LinkProps) => {
return (
<a
href={href}
target="_blank"
rel="noopener noreferrer"
className={cn(
"text-primary underline decoration-primary/50 underline-offset-2 transition-colors hover:decoration-primary",
className,
)}
{...props}
>
{children}
</a>
);
};

View File

@@ -0,0 +1,71 @@
import { cn } from "@/lib/utils";
import { TASK_LIST_CLASS, TASK_LIST_ITEM_CLASS } from "../constants";
import type { ReactMarkdownProps } from "./types";
interface ListProps extends React.HTMLAttributes<HTMLUListElement | HTMLOListElement>, ReactMarkdownProps {
ordered?: boolean;
children: React.ReactNode;
}
/**
* List component for both regular and task lists (GFM)
* Detects task lists via the "contains-task-list" class added by remark-gfm
*/
export const List = ({ ordered, children, className, node: _node, ...domProps }: ListProps) => {
const Component = ordered ? "ol" : "ul";
const isTaskList = className?.includes(TASK_LIST_CLASS);
return (
<Component
className={cn(
"my-0 mb-2 list-outside",
isTaskList
? // Task list: no bullets, nested lists get left margin for indentation
"list-none [&_ul.contains-task-list]:ml-6"
: // Regular list: standard padding and list style
cn("pl-6", ordered ? "list-decimal" : "list-disc"),
className,
)}
{...domProps}
>
{children}
</Component>
);
};
interface ListItemProps extends React.LiHTMLAttributes<HTMLLIElement>, ReactMarkdownProps {
children: React.ReactNode;
}
/**
* List item component for both regular and task list items
* Detects task items via the "task-list-item" class added by remark-gfm
* Applies specialized styling for task checkboxes
*/
export const ListItem = ({ children, className, node: _node, ...domProps }: ListItemProps) => {
const isTaskListItem = className?.includes(TASK_LIST_ITEM_CLASS);
if (isTaskListItem) {
return (
<li
className={cn(
"mt-0.5 leading-6 list-none",
// Checkbox styling: margin and alignment
"[&>button]:mr-2 [&>button]:align-middle",
// Inline paragraph for task text
"[&>p]:inline [&>p]:m-0",
className,
)}
{...domProps}
>
{children}
</li>
);
}
return (
<li className={cn("mt-0.5 leading-6", className)} {...domProps}>
{children}
</li>
);
};

View File

@@ -0,0 +1,17 @@
import { cn } from "@/lib/utils";
import type { ReactMarkdownProps } from "./types";
interface ParagraphProps extends React.HTMLAttributes<HTMLParagraphElement>, ReactMarkdownProps {
children: React.ReactNode;
}
/**
* Paragraph component with compact spacing
*/
export const Paragraph = ({ children, className, node: _node, ...props }: ParagraphProps) => {
return (
<p className={cn("my-0 mb-2 leading-6", className)} {...props}>
{children}
</p>
);
};

View File

@@ -0,0 +1,97 @@
# Markdown Components
Modern, type-safe React components for rendering markdown content via react-markdown.
## Architecture
### Component-Based Rendering
Following patterns from popular AI chat apps (ChatGPT, Claude, Perplexity), we use React components instead of CSS selectors for markdown rendering. This provides:
- **Type Safety**: Full TypeScript support with proper prop types
- **Maintainability**: Components are easier to test, modify, and understand
- **Performance**: No CSS specificity conflicts, cleaner DOM
- **Modularity**: Each element is independently styled and documented
### Type System
All components extend `ReactMarkdownProps` which includes the AST `node` prop passed by react-markdown. This is explicitly destructured as `node: _node` to:
1. Filter it from DOM props (avoids `node="[object Object]"` in HTML)
2. Keep it available for advanced use cases (e.g., detecting task lists)
3. Maintain type safety without `as any` casts
### GFM Task Lists
Task lists (from remark-gfm) are handled by:
- **Detection**: `contains-task-list` and `task-list-item` classes from remark-gfm
- **Styling**: Tailwind utilities with arbitrary variants for nested elements
- **Checkboxes**: Custom `TaskListItem` component with Radix UI checkbox
- **Interactivity**: Updates memo content via `toggleTaskAtIndex` utility
### Component Patterns
Each component follows this structure:
```tsx
import { cn } from "@/lib/utils";
import type { ReactMarkdownProps } from "./types";
interface ComponentProps extends React.HTMLAttributes<HTMLElement>, ReactMarkdownProps {
children?: React.ReactNode;
// component-specific props
}
/**
* JSDoc description
*/
export const Component = ({ children, className, node: _node, ...props }: ComponentProps) => {
return (
<element className={cn("base-classes", className)} {...props}>
{children}
</element>
);
};
```
## Components
| Component | Element | Purpose |
|-----------|---------|---------|
| `Heading` | h1-h6 | Semantic headings with level-based styling |
| `Paragraph` | p | Compact paragraphs with consistent spacing |
| `Link` | a | External links with security attributes |
| `List` | ul/ol | Regular and GFM task lists |
| `ListItem` | li | List items with task checkbox support |
| `Blockquote` | blockquote | Quotes with left border accent |
| `InlineCode` | code | Inline code with background |
| `Image` | img | Responsive images with rounded corners |
| `HorizontalRule` | hr | Section separators |
## Styling Approach
- **Tailwind CSS**: All styling uses Tailwind utilities
- **Design Tokens**: Colors use CSS variables (e.g., `--primary`, `--muted-foreground`)
- **Responsive**: Max-width constraints, responsive images
- **Accessibility**: Semantic HTML, proper ARIA attributes via Radix UI
## Integration
Components are mapped to HTML elements in `MemoContent/index.tsx`:
```tsx
<ReactMarkdown
components={{
h1: ({ children }) => <Heading level={1}>{children}</Heading>,
p: ({ children, ...props }) => <Paragraph {...props}>{children}</Paragraph>,
// ... more mappings
}}
>
{content}
</ReactMarkdown>
```
## Future Enhancements
- [ ] Syntax highlighting themes for code blocks
- [ ] Table sorting/filtering interactions
- [ ] Image lightbox/zoom functionality
- [ ] Collapsible sections for long content
- [ ] Copy button for code blocks

View File

@@ -0,0 +1,8 @@
export { Blockquote } from "./Blockquote";
export { Heading } from "./Heading";
export { HorizontalRule } from "./HorizontalRule";
export { Image } from "./Image";
export { InlineCode } from "./InlineCode";
export { Link } from "./Link";
export { List, ListItem } from "./List";
export { Paragraph } from "./Paragraph";

View File

@@ -0,0 +1,9 @@
import type { Element } from "hast";
/**
* Props passed by react-markdown to custom components
* Includes the AST node for advanced use cases
*/
export interface ReactMarkdownProps {
node?: Element;
}

View File

@@ -0,0 +1,12 @@
import type React from "react";
export interface MemoContentProps {
content: string;
compact?: boolean;
className?: string;
contentClassName?: string;
onClick?: (e: React.MouseEvent) => void;
onDoubleClick?: (e: React.MouseEvent) => void;
}
export type ContentCompactView = "ALL" | "SNIPPET";

View File

@@ -0,0 +1,25 @@
import type React from "react";
/**
* Extracts code content from a react-markdown code element.
* Handles the nested structure where code is passed as children.
*
* @param children - The children prop from react-markdown (typically a code element)
* @returns The extracted code content as a string with trailing newline removed
*/
export const extractCodeContent = (children: React.ReactNode): string => {
const codeElement = children as React.ReactElement;
return String(codeElement?.props?.children || "").replace(/\n$/, "");
};
/**
* Extracts the language identifier from a code block's className.
* react-markdown uses the format "language-xxx" for code blocks.
*
* @param className - The className string from a code element
* @returns The language identifier, or empty string if none found
*/
export const extractLanguage = (className: string): string => {
const match = /language-(\w+)/.exec(className);
return match ? match[1] : "";
};

View File

@@ -0,0 +1,101 @@
import { create } from "@bufbuild/protobuf";
import { timestampDate } from "@bufbuild/protobuf/wkt";
import { isEqual } from "lodash-es";
import { CheckCircleIcon, Code2Icon, HashIcon, LinkIcon } from "lucide-react";
import { cn } from "@/lib/utils";
import { Memo, Memo_PropertySchema, MemoRelation_Type } from "@/types/proto/api/v1/memo_service_pb";
import { useTranslate } from "@/utils/i18n";
import MemoRelationForceGraph from "../MemoRelationForceGraph";
interface Props {
memo: Memo;
className?: string;
parentPage?: string;
}
const SectionLabel = ({ children }: { children: React.ReactNode }) => (
<p className="text-xs font-medium text-muted-foreground/50 uppercase tracking-wider">{children}</p>
);
const MemoDetailSidebar = ({ memo, className, parentPage }: Props) => {
const t = useTranslate();
const property = create(Memo_PropertySchema, memo.property || {});
const hasSpecialProperty = property.hasLink || property.hasTaskList || property.hasCode;
const hasReferenceRelations = memo.relations.some((r) => r.type === MemoRelation_Type.REFERENCE);
return (
<aside className={cn("relative w-full h-auto max-h-screen overflow-auto flex flex-col gap-5", className)}>
{hasReferenceRelations && (
<div className="w-full space-y-2">
<div className="flex items-center gap-1.5">
<SectionLabel>{t("common.relations")}</SectionLabel>
<span className="text-xs text-muted-foreground/30">(Beta)</span>
</div>
<div className="relative w-full h-36 border border-border rounded-lg bg-muted overflow-hidden">
<MemoRelationForceGraph className="w-full h-full" memo={memo} parentPage={parentPage} />
</div>
</div>
)}
<div className="w-full space-y-1">
<SectionLabel>{t("common.created-at")}</SectionLabel>
<p className="text-sm text-foreground/70">{memo.createTime ? timestampDate(memo.createTime).toLocaleString() : "—"}</p>
</div>
{!isEqual(memo.createTime, memo.updateTime) && (
<div className="w-full space-y-1">
<SectionLabel>{t("common.last-updated-at")}</SectionLabel>
<p className="text-sm text-foreground/70">{memo.updateTime ? timestampDate(memo.updateTime).toLocaleString() : "—"}</p>
</div>
)}
{hasSpecialProperty && (
<div className="w-full space-y-2">
<SectionLabel>{t("common.properties")}</SectionLabel>
<div className="flex flex-wrap gap-1.5">
{property.hasLink && (
<span className="inline-flex items-center gap-1.5 px-2 py-1 rounded-md border border-border/60 bg-muted/60 text-xs text-muted-foreground">
<LinkIcon className="w-3.5 h-3.5" />
{t("memo.links")}
</span>
)}
{property.hasTaskList && (
<span className="inline-flex items-center gap-1.5 px-2 py-1 rounded-md border border-border/60 bg-muted/60 text-xs text-muted-foreground">
<CheckCircleIcon className="w-3.5 h-3.5" />
{t("memo.to-do")}
</span>
)}
{property.hasCode && (
<span className="inline-flex items-center gap-1.5 px-2 py-1 rounded-md border border-border/60 bg-muted/60 text-xs text-muted-foreground">
<Code2Icon className="w-3.5 h-3.5" />
{t("memo.code")}
</span>
)}
</div>
</div>
)}
{memo.tags.length > 0 && (
<div className="w-full space-y-2">
<div className="flex items-center gap-1.5">
<SectionLabel>{t("common.tags")}</SectionLabel>
<span className="text-xs text-muted-foreground/30">({memo.tags.length})</span>
</div>
<div className="flex flex-wrap gap-1.5">
{memo.tags.map((tag) => (
<span
key={tag}
className="inline-flex items-center gap-1 px-1 rounded-md border border-border/60 bg-muted/60 text-sm text-muted-foreground hover:bg-muted hover:text-foreground/80 transition-colors cursor-pointer"
>
<HashIcon className="w-3 h-3 opacity-50" />
{tag}
</span>
))}
</div>
</div>
)}
</aside>
);
};
export default MemoDetailSidebar;

View File

@@ -0,0 +1,36 @@
import { GanttChartIcon } from "lucide-react";
import { useEffect, useState } from "react";
import { useLocation } from "react-router-dom";
import { Button } from "@/components/ui/button";
import { Sheet, SheetContent, SheetTrigger } from "@/components/ui/sheet";
import { Memo } from "@/types/proto/api/v1/memo_service_pb";
import MemoDetailSidebar from "./MemoDetailSidebar";
interface Props {
memo: Memo;
parentPage?: string;
}
const MemoDetailSidebarDrawer = ({ memo, parentPage }: Props) => {
const location = useLocation();
const [open, setOpen] = useState(false);
useEffect(() => {
setOpen(false);
}, [location.pathname]);
return (
<Sheet open={open} onOpenChange={setOpen}>
<SheetTrigger asChild>
<Button variant="ghost" size="sm" className="px-2">
<GanttChartIcon className="w-5 h-auto text-muted-foreground" />
</Button>
</SheetTrigger>
<SheetContent side="right" className="w-full sm:w-80 px-4 bg-background">
<MemoDetailSidebar className="py-4" memo={memo} parentPage={parentPage} />
</SheetContent>
</Sheet>
);
};
export default MemoDetailSidebarDrawer;

View File

@@ -0,0 +1,4 @@
import MemoDetailSidebar from "./MemoDetailSidebar";
import MemoDetailSidebarDrawer from "./MemoDetailSidebarDrawer";
export { MemoDetailSidebar, MemoDetailSidebarDrawer };

View File

@@ -0,0 +1,61 @@
import { Settings2Icon } from "lucide-react";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import { useView } from "@/contexts/ViewContext";
import { cn } from "@/lib/utils";
import { useTranslate } from "@/utils/i18n";
import { Popover, PopoverContent, PopoverTrigger } from "./ui/popover";
interface Props {
className?: string;
}
function MemoDisplaySettingMenu({ className }: Props) {
const t = useTranslate();
const { orderByTimeAsc, layout, toggleSortOrder, setLayout } = useView();
const isApplying = orderByTimeAsc !== false || layout !== "LIST";
return (
<Popover>
<PopoverTrigger className={cn(className, isApplying ? "text-primary bg-primary/10 rounded" : "opacity-40")}>
<Settings2Icon className="w-4 h-auto shrink-0" />
</PopoverTrigger>
<PopoverContent align="end" alignOffset={-12} sideOffset={14}>
<div className="flex flex-col gap-2 p-1">
<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={orderByTimeAsc.toString()}
onValueChange={(value) => {
if ((value === "true") !== orderByTimeAsc) {
toggleSortOrder();
}
}}
>
<SelectTrigger size="sm">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="false">{t("memo.direction-desc")}</SelectItem>
<SelectItem value="true">{t("memo.direction-asc")}</SelectItem>
</SelectContent>
</Select>
</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={layout} onValueChange={(value) => setLayout(value as "LIST" | "MASONRY")}>
<SelectTrigger size="sm">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="LIST">{t("memo.list")}</SelectItem>
<SelectItem value="MASONRY">{t("memo.masonry")}</SelectItem>
</SelectContent>
</Select>
</div>
</div>
</PopoverContent>
</Popover>
);
}
export default MemoDisplaySettingMenu;

View File

@@ -0,0 +1,181 @@
import { BotIcon } from "lucide-react";
import { toast } from "react-hot-toast";
import { aIServiceClient } from "@/connect";
import type { SlashCommandsProps } from "../types";
import type { EditorRefActions } from ".";
import { SuggestionsPopup } from "./SuggestionsPopup";
import { useSuggestions } from "./useSuggestions";
const SlashCommands = ({ editorRef, editorActions, commands }: SlashCommandsProps) => {
const handleAICompletion = async (actions: EditorRefActions) => {
try {
const selectedText = actions.getSelectedContent();
const fullContent = actions.getContent();
// Get custom instruction if exists
const customInstruction = localStorage.getItem("ai-custom-instruction");
const instructionPrefix = customInstruction ? `${customInstruction}\n\n` : '';
const prompt = instructionPrefix + (selectedText
? `Complete or improve this text: "${selectedText}"\n\nContext: ${fullContent}`
: `Continue writing this note: ${fullContent}`);
const response = await aIServiceClient.generateCompletion({
prompt,
provider: 1,
temperature: 0.7,
maxTokens: 500,
});
if (response.text) {
if (selectedText) {
const cursorPos = actions.getCursorPosition();
actions.removeText(cursorPos - selectedText.length, selectedText.length);
actions.insertText(response.text);
} else {
actions.insertText(response.text);
}
toast.success("AI completion added!");
}
} catch (error) {
console.error("AI completion failed:", error);
toast.error("Failed to generate AI completion");
}
};
const handleAISummarize = async (actions: EditorRefActions) => {
try {
const content = actions.getContent();
if (!content.trim()) {
toast.error("Nothing to summarize");
return;
}
// Get custom instruction if exists
const customInstruction = localStorage.getItem("ai-custom-instruction");
const instructionPrefix = customInstruction ? `${customInstruction}\n\n` : '';
const prompt = instructionPrefix + `Summarize this note concisely:\n\n${content}`;
const response = await aIServiceClient.generateCompletion({
prompt,
provider: 1,
temperature: 0.3,
maxTokens: 200,
});
if (response.text) {
actions.insertText(`\n\n**Summary:** ${response.text}`);
toast.success("Summary generated!");
}
} catch (error) {
console.error("AI summarize failed:", error);
toast.error("Failed to generate summary");
}
};
const handleAIImprove = async (actions: EditorRefActions) => {
try {
const selectedText = actions.getSelectedContent();
const fullContent = actions.getContent();
const textToImprove = selectedText || fullContent;
if (!textToImprove.trim()) {
toast.error("Nothing to improve");
return;
}
// Get custom instruction if exists
const customInstruction = localStorage.getItem("ai-custom-instruction");
const instructionPrefix = customInstruction ? `${customInstruction}\n\n` : '';
const prompt = instructionPrefix + `Improve the grammar, clarity, and flow of this text:\n\n${textToImprove}`;
const response = await aIServiceClient.generateCompletion({
prompt,
provider: 1,
temperature: 0.4,
maxTokens: 1000,
});
if (response.text) {
if (selectedText) {
const cursorPos = actions.getCursorPosition();
actions.removeText(cursorPos - selectedText.length, selectedText.length);
actions.insertText(response.text);
} else {
actions.setContent(response.text);
}
toast.success("Text improved!");
}
} catch (error) {
console.error("AI improve failed:", error);
toast.error("Failed to improve text");
}
};
const handleCommandAutocomplete = async (cmd: (typeof commands)[0], word: string, index: number, actions: EditorRefActions) => {
// Handle AI commands specially
if (cmd.name.startsWith('ai')) {
actions.removeText(index, word.length);
switch (cmd.name) {
case 'aicomplete':
await handleAICompletion(actions);
break;
case 'aisummary':
await handleAISummarize(actions);
break;
case 'aiimprove':
await handleAIImprove(actions);
break;
}
return;
}
// Handle regular commands
actions.removeText(index, word.length);
actions.insertText(cmd.run());
// Position cursor relative to insertion point, if specified
if (cmd.cursorOffset) {
actions.setCursorPosition(index + cmd.cursorOffset);
}
};
const { position, suggestions, selectedIndex, isVisible, handleItemSelect } = useSuggestions({
editorRef,
editorActions,
triggerChar: "/",
items: commands,
filterItems: (items, query) => (!query ? items : items.filter((cmd) => cmd.name.toLowerCase().startsWith(query))),
onAutocomplete: handleCommandAutocomplete,
});
if (!isVisible || !position) return null;
return (
<SuggestionsPopup
position={position}
suggestions={suggestions}
selectedIndex={selectedIndex}
onItemSelect={handleItemSelect}
getItemKey={(cmd) => cmd.name}
renderItem={(cmd) => (
<span className="tracking-wide flex items-center">
<span className="text-muted-foreground">/</span>
{cmd.name}
{cmd.name.startsWith('ai') && (
<BotIcon className="w-3 h-3 ml-2 text-muted-foreground" />
)}
{cmd.description && (
<span className="ml-2 text-xs text-muted-foreground truncate">
{cmd.description}
</span>
)}
</span>
)}
/>
);
};
export default SlashCommands;

View File

@@ -0,0 +1,49 @@
import { ReactNode, useEffect, useRef } from "react";
import { cn } from "@/lib/utils";
import { Position } from "./useSuggestions";
interface SuggestionsPopupProps<T> {
position: Position;
suggestions: T[];
selectedIndex: number;
onItemSelect: (item: T) => void;
renderItem: (item: T, isSelected: boolean) => ReactNode;
getItemKey: (item: T, index: number) => string;
}
const POPUP_STYLES = {
container:
"z-20 absolute p-1 mt-1 -ml-2 max-w-48 max-h-60 rounded border bg-popover text-popover-foreground shadow-lg font-mono flex flex-col overflow-y-auto overflow-x-hidden",
item: "rounded p-1 px-2 w-full text-sm cursor-pointer transition-colors select-none hover:bg-accent hover:text-accent-foreground",
};
export function SuggestionsPopup<T>({
position,
suggestions,
selectedIndex,
onItemSelect,
renderItem,
getItemKey,
}: SuggestionsPopupProps<T>) {
const containerRef = useRef<HTMLDivElement>(null);
const selectedItemRef = useRef<HTMLDivElement>(null);
useEffect(() => {
selectedItemRef.current?.scrollIntoView({ block: "nearest", behavior: "smooth" });
}, [selectedIndex]);
return (
<div ref={containerRef} className={POPUP_STYLES.container} style={{ left: position.left, top: position.top + position.height }}>
{suggestions.map((item, i) => (
<div
key={getItemKey(item, i)}
ref={i === selectedIndex ? selectedItemRef : null}
onMouseDown={() => onItemSelect(item)}
className={cn(POPUP_STYLES.item, i === selectedIndex && "bg-accent text-accent-foreground")}
>
{renderItem(item, i === selectedIndex)}
</div>
))}
</div>
);
}

View File

@@ -0,0 +1,50 @@
import { useMemo } from "react";
import { matchPath } from "react-router-dom";
import OverflowTip from "@/components/kit/OverflowTip";
import { useTagCounts } from "@/hooks/useUserQueries";
import { Routes } from "@/router";
import type { TagSuggestionsProps } from "../types";
import { SuggestionsPopup } from "./SuggestionsPopup";
import { useSuggestions } from "./useSuggestions";
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(tagCount)
.sort((a, b) => b[1] - a[1] || a[0].localeCompare(b[0]))
.map(([tag]) => tag);
}, [tagCount]);
const { position, suggestions, selectedIndex, isVisible, handleItemSelect } = useSuggestions({
editorRef,
editorActions,
triggerChar: "#",
items: sortedTags,
filterItems: (items, query) => (!query ? items : items.filter((tag) => tag.toLowerCase().includes(query))),
onAutocomplete: (tag, word, index, actions) => {
actions.removeText(index, word.length);
actions.insertText(`#${tag} `);
},
});
if (!isVisible || !position) return null;
return (
<SuggestionsPopup
position={position}
suggestions={suggestions}
selectedIndex={selectedIndex}
onItemSelect={handleItemSelect}
getItemKey={(tag) => tag}
renderItem={(tag) => (
<OverflowTip>
<span className="text-muted-foreground mr-1">#</span>
{tag}
</OverflowTip>
)}
/>
);
}

View File

@@ -0,0 +1,56 @@
export interface Command {
name: string;
run: () => string;
cursorOffset?: number;
description?: string;
icon?: string;
}
export const editorCommands: Command[] = [
{
name: "todo",
run: () => "- [ ] ",
cursorOffset: 6,
description: "Add a todo item",
},
{
name: "code",
run: () => "```\n\n```",
cursorOffset: 4,
description: "Insert code block",
},
{
name: "link",
run: () => "[text](url)",
cursorOffset: 1,
description: "Insert link",
},
{
name: "table",
run: () => "| Header | Header |\n| ------ | ------ |\n| Cell | Cell |",
cursorOffset: 1,
description: "Insert table",
},
{
name: "imgpos",
run: () => "[img:attachment-id|align=center|width=50%]",
cursorOffset: 5,
description: "Position image with ID",
},
// AI Commands
{
name: "aicomplete",
run: () => "",
description: "AI complete text",
},
{
name: "aisummary",
run: () => "",
description: "AI summarize note",
},
{
name: "aiimprove",
run: () => "",
description: "AI improve writing",
},
];

View File

@@ -0,0 +1,221 @@
import { forwardRef, useCallback, useEffect, useImperativeHandle, useMemo, useRef } from "react";
import getCaretCoordinates from "textarea-caret";
import { cn } from "@/lib/utils";
import { EDITOR_HEIGHT } from "../constants";
import type { EditorProps } from "../types";
import { editorCommands } from "./commands";
import SlashCommands from "./SlashCommands";
import TagSuggestions from "./TagSuggestions";
import { useListCompletion } from "./useListCompletion";
export interface EditorRefActions {
getEditor: () => HTMLTextAreaElement | null;
focus: () => void;
scrollToCursor: () => void;
insertText: (text: string, prefix?: string, suffix?: string) => void;
removeText: (start: number, length: number) => void;
setContent: (text: string) => void;
getContent: () => string;
getSelectedContent: () => string;
getCursorPosition: () => number;
setCursorPosition: (startPos: number, endPos?: number) => void;
getCursorLineNumber: () => number;
getLine: (lineNumber: number) => string;
setLine: (lineNumber: number, text: string) => void;
}
const Editor = forwardRef(function Editor(props: EditorProps, ref: React.ForwardedRef<EditorRefActions>) {
const {
className,
initialContent,
placeholder,
onPaste,
onContentChange: handleContentChangeCallback,
isFocusMode,
isInIME = false,
onCompositionStart,
onCompositionEnd,
} = 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]);
const scrollToCaret = useCallback((options: { force?: boolean } = {}) => {
const editor = editorRef.current;
if (!editor) return;
const { force = false } = options;
const caret = getCaretCoordinates(editor, editor.selectionEnd);
if (force) {
editor.scrollTop = Math.max(0, caret.top - editor.clientHeight / 2);
return;
}
const lineHeight = parseFloat(getComputedStyle(editor).lineHeight) || 24;
const viewportBottom = editor.scrollTop + editor.clientHeight;
// Scroll if cursor is near or beyond bottom edge (within 2 lines)
if (caret.top + lineHeight * 2 > viewportBottom) {
editor.scrollTop = Math.max(0, caret.top - editor.clientHeight / 2);
}
}, []);
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
}, []);
// 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 editorActions: EditorRefActions = useMemo(
() => ({
getEditor: () => editorRef.current,
focus: () => editorRef.current?.focus(),
scrollToCursor: () => {
scrollToCaret({ force: true });
},
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.setSelectionRange(start, 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 editor = editorRef.current;
if (!editor) return;
// setSelectionRange requires valid arguments; default to startPos if endPos is undefined
const endPosition = endPos !== undefined && !Number.isNaN(endPos) ? endPos : startPos;
editor.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, scrollToCaret],
);
useImperativeHandle(ref, () => editorActions, [editorActions]);
// Also expose the textarea ref for external access
useEffect(() => {
if (editorRef.current) {
// Make textarea accessible through editor actions if needed
(editorActions as any).textarea = editorRef.current;
}
}, [editorActions]);
const handleEditorInput = useCallback(() => {
if (editorRef.current) {
handleContentChangeCallback(editorRef.current.value);
updateEditorHeight();
// Auto-scroll to keep cursor visible when typing
// See: https://github.com/usememos/memos/issues/5469
scrollToCaret();
}
}, [handleContentChangeCallback, updateEditorHeight, scrollToCaret]);
// Auto-complete markdown lists when pressing Enter
useListCompletion({
editorRef,
editorActions,
isInIME,
});
return (
<div
className={cn(
"flex flex-col justify-start items-start relative w-full bg-inherit",
// Focus mode: flex-1 to grow and fill space; Normal: h-auto with max-height
isFocusMode ? "flex-1" : `h-auto ${EDITOR_HEIGHT.normal}`,
className,
)}
>
<textarea
className={cn(
"w-full text-base resize-none overflow-x-hidden overflow-y-auto bg-transparent outline-none placeholder:opacity-70 whitespace-pre-wrap wrap-break-word",
// Focus mode: flex-1 h-0 to grow within flex container; Normal: h-full to fill wrapper
isFocusMode ? "flex-1 h-0" : "h-full",
)}
rows={1}
placeholder={placeholder}
ref={editorRef}
onPaste={onPaste}
onInput={handleEditorInput}
onCompositionStart={onCompositionStart}
onCompositionEnd={onCompositionEnd}
></textarea>
<TagSuggestions editorRef={editorRef} editorActions={ref} />
<SlashCommands editorRef={editorRef} editorActions={ref} commands={editorCommands} />
</div>
);
});
export default Editor;

View File

@@ -0,0 +1,70 @@
import type { EditorRefActions } from "./index";
const SHORTCUTS = {
BOLD: { key: "b", delimiter: "**" },
ITALIC: { key: "i", delimiter: "*" },
LINK: { key: "k" },
} as const;
const URL_PLACEHOLDER = "url";
const URL_REGEX = /^https?:\/\/[^\s]+$/;
const LINK_OFFSET = 3; // Length of "]()"
export function handleMarkdownShortcuts(event: React.KeyboardEvent, editor: EditorRefActions): void {
const key = event.key.toLowerCase();
if (key === SHORTCUTS.BOLD.key) {
event.preventDefault();
toggleTextStyle(editor, SHORTCUTS.BOLD.delimiter);
} else if (key === SHORTCUTS.ITALIC.key) {
event.preventDefault();
toggleTextStyle(editor, SHORTCUTS.ITALIC.delimiter);
} else if (key === SHORTCUTS.LINK.key) {
event.preventDefault();
insertHyperlink(editor);
}
}
export function insertHyperlink(editor: EditorRefActions, url?: string): void {
const cursorPosition = editor.getCursorPosition();
const selectedContent = editor.getSelectedContent();
const isUrlSelected = !url && URL_REGEX.test(selectedContent.trim());
if (isUrlSelected) {
editor.insertText(`[](${selectedContent})`);
editor.setCursorPosition(cursorPosition + 1, cursorPosition + 1);
return;
}
const href = url ?? URL_PLACEHOLDER;
editor.insertText(`[${selectedContent}](${href})`);
if (href === URL_PLACEHOLDER) {
const urlStart = cursorPosition + selectedContent.length + LINK_OFFSET;
editor.setCursorPosition(urlStart, urlStart + href.length);
}
}
function toggleTextStyle(editor: EditorRefActions, delimiter: string): void {
const cursorPosition = editor.getCursorPosition();
const selectedContent = editor.getSelectedContent();
const isStyled = selectedContent.startsWith(delimiter) && selectedContent.endsWith(delimiter);
if (isStyled) {
const unstyled = selectedContent.slice(delimiter.length, -delimiter.length);
editor.insertText(unstyled);
editor.setCursorPosition(cursorPosition, cursorPosition + unstyled.length);
} else {
editor.insertText(`${delimiter}${selectedContent}${delimiter}`);
editor.setCursorPosition(cursorPosition + delimiter.length, cursorPosition + delimiter.length + selectedContent.length);
}
}
export function hyperlinkHighlightedText(editor: EditorRefActions, url: string): void {
const selectedContent = editor.getSelectedContent();
const cursorPosition = editor.getCursorPosition();
editor.insertText(`[${selectedContent}](${url})`);
const newPosition = cursorPosition + selectedContent.length + url.length + 4;
editor.setCursorPosition(newPosition, newPosition);
}

View File

@@ -0,0 +1,82 @@
import { useEffect, useRef } from "react";
import { detectLastListItem, generateListContinuation } from "@/utils/markdown-list-detection";
import { EditorRefActions } from ".";
interface UseListCompletionOptions {
editorRef: React.RefObject<HTMLTextAreaElement>;
editorActions: EditorRefActions;
isInIME: boolean;
}
// Patterns to detect empty list items
const EMPTY_LIST_PATTERNS = [
/^(\s*)([-*+])\s*$/, // Empty unordered list
/^(\s*)([-*+])\s+\[([ xX])\]\s*$/, // Empty task list
/^(\s*)(\d+)[.)]\s*$/, // Empty ordered list
];
const isEmptyListItem = (line: string) => EMPTY_LIST_PATTERNS.some((pattern) => pattern.test(line));
export function useListCompletion({ editorRef, editorActions, isInIME }: UseListCompletionOptions) {
const isInIMERef = useRef(isInIME);
isInIMERef.current = isInIME;
const editorActionsRef = useRef(editorActions);
editorActionsRef.current = editorActions;
// Track when composition ends to handle Safari race condition
// Safari fires keydown(Enter) immediately after compositionend, while Chrome doesn't
// See: https://github.com/usememos/memos/issues/5469
const lastCompositionEndRef = useRef(0);
useEffect(() => {
const editor = editorRef.current;
if (!editor) return;
const handleCompositionEnd = () => {
lastCompositionEndRef.current = Date.now();
};
const handleKeyDown = (event: KeyboardEvent) => {
if (event.key !== "Enter" || isInIMERef.current || event.shiftKey || event.ctrlKey || event.metaKey || event.altKey) {
return;
}
// Safari fix: Ignore Enter key within 100ms of composition end
// This prevents double-enter behavior when confirming IME input in lists
if (Date.now() - lastCompositionEndRef.current < 100) {
return;
}
const actions = editorActionsRef.current;
const cursorPosition = actions.getCursorPosition();
const contentBeforeCursor = actions.getContent().substring(0, cursorPosition);
const listInfo = detectLastListItem(contentBeforeCursor);
if (!listInfo.type) return;
event.preventDefault();
const lines = contentBeforeCursor.split("\n");
const currentLine = lines[lines.length - 1];
if (isEmptyListItem(currentLine)) {
const lineStartPos = cursorPosition - currentLine.length;
actions.removeText(lineStartPos, currentLine.length);
} else {
const continuation = generateListContinuation(listInfo);
actions.insertText("\n" + continuation);
// Auto-scroll to keep cursor visible after inserting list item
setTimeout(() => actions.scrollToCursor(), 0);
}
};
editor.addEventListener("compositionend", handleCompositionEnd);
editor.addEventListener("keydown", handleKeyDown);
return () => {
editor.removeEventListener("compositionend", handleCompositionEnd);
editor.removeEventListener("keydown", handleKeyDown);
};
}, []);
}

View File

@@ -0,0 +1,158 @@
import { useEffect, useRef, useState } from "react";
import getCaretCoordinates from "textarea-caret";
import { EditorRefActions } from ".";
export interface Position {
left: number;
top: number;
height: number;
}
export interface UseSuggestionsOptions<T> {
editorRef: React.RefObject<HTMLTextAreaElement>;
editorActions: React.ForwardedRef<EditorRefActions>;
triggerChar: string;
items: T[];
filterItems: (items: T[], searchQuery: string) => T[];
onAutocomplete: (item: T, word: string, startIndex: number, actions: EditorRefActions) => void;
}
export interface UseSuggestionsReturn<T> {
position: Position | null;
suggestions: T[];
selectedIndex: number;
isVisible: boolean;
handleItemSelect: (item: T) => void;
}
export function useSuggestions<T>({
editorRef,
editorActions,
triggerChar,
items,
filterItems,
onAutocomplete,
}: UseSuggestionsOptions<T>): UseSuggestionsReturn<T> {
const [position, setPosition] = useState<Position | null>(null);
const [selectedIndex, setSelectedIndex] = useState(0);
const isProcessingRef = useRef(false);
const selectedRef = useRef(selectedIndex);
selectedRef.current = selectedIndex;
const getCurrentWord = (): [word: string, startIndex: number] => {
const editor = editorRef.current;
if (!editor) return ["", 0];
const cursorPos = editor.selectionEnd;
const before = editor.value.slice(0, cursorPos).match(/\S*$/) || { 0: "", index: cursorPos };
const after = editor.value.slice(cursorPos).match(/^\S*/) || { 0: "" };
return [before[0] + after[0], before.index ?? cursorPos];
};
const hide = () => setPosition(null);
const suggestionsRef = useRef<T[]>([]);
suggestionsRef.current = (() => {
const [word] = getCurrentWord();
if (!word.startsWith(triggerChar)) return [];
const searchQuery = word.slice(triggerChar.length).toLowerCase();
return filterItems(items, searchQuery);
})();
const isVisibleRef = useRef(false);
isVisibleRef.current = !!(position && suggestionsRef.current.length > 0);
const handleAutocomplete = (item: T) => {
if (!editorActions || !("current" in editorActions) || !editorActions.current) {
console.warn("useSuggestions: editorActions not available");
return;
}
isProcessingRef.current = true;
const [word, index] = getCurrentWord();
onAutocomplete(item, word, index, editorActions.current);
hide();
// Re-enable input handling after all DOM operations complete
queueMicrotask(() => {
isProcessingRef.current = false;
});
};
const handleNavigation = (e: KeyboardEvent, selected: number, suggestionsCount: number) => {
if (e.code === "ArrowDown") {
setSelectedIndex((selected + 1) % suggestionsCount);
e.preventDefault();
e.stopPropagation();
} else if (e.code === "ArrowUp") {
setSelectedIndex((selected - 1 + suggestionsCount) % suggestionsCount);
e.preventDefault();
e.stopPropagation();
}
};
const handleKeyDown = (e: KeyboardEvent) => {
if (!isVisibleRef.current) return;
const suggestions = suggestionsRef.current;
const selected = selectedRef.current;
if (["Escape", "ArrowLeft", "ArrowRight"].includes(e.code)) {
hide();
return;
}
if (["ArrowDown", "ArrowUp"].includes(e.code)) {
handleNavigation(e, selected, suggestions.length);
return;
}
if (["Enter", "Tab"].includes(e.code)) {
handleAutocomplete(suggestions[selected]);
e.preventDefault();
e.stopImmediatePropagation();
}
};
const handleInput = () => {
if (isProcessingRef.current) return;
const editor = editorRef.current;
if (!editor) return;
setSelectedIndex(0);
const [word, index] = getCurrentWord();
const currentChar = editor.value[editor.selectionEnd];
const isActive = word.startsWith(triggerChar) && currentChar !== triggerChar;
if (isActive) {
const coords = getCaretCoordinates(editor, index);
coords.top -= editor.scrollTop;
setPosition(coords);
} else {
hide();
}
};
useEffect(() => {
const editor = editorRef.current;
if (!editor) return;
const handlers = { click: hide, blur: hide, keydown: handleKeyDown, input: handleInput };
Object.entries(handlers).forEach(([event, handler]) => {
editor.addEventListener(event, handler as EventListener);
});
return () => {
Object.entries(handlers).forEach(([event, handler]) => {
editor.removeEventListener(event, handler as EventListener);
});
};
}, []);
return {
position,
suggestions: suggestionsRef.current,
selectedIndex,
isVisible: isVisibleRef.current,
handleItemSelect: handleAutocomplete,
};
}

View File

@@ -0,0 +1,111 @@
# Image Positioning Feature Implementation
## Overview
This implementation adds support for precise image positioning in Memos using ID-based tags with customizable parameters.
## Features Implemented
### 1. Core Data Structures
- **PositionedImage interface**: Tracks positioning parameters for each image
- **Enhanced EditorState**: Added `positionedImages` array to track positioned images
- **New Actions**: CRUD operations for positioned images
### 2. UI Components
- **ImagePositionDialog**: Modal dialog for configuring image positioning
- **PositionedImageView**: Component that renders positioned images with proper styling
- **Toolbar Integration**: "Position Image" button added to the insert menu
### 3. State Management
- Added positioned images to editor state
- Created actions for adding/removing/updating positioned images
- Extended reducer to handle positioned image operations
### 4. Markdown Syntax
Supports the syntax: `[img:attachment-id|param=value|param=value]`
#### Supported Parameters:
- `align`: left | center | right
- `width`: CSS width value (e.g., 50%, 300px)
- `height`: CSS height value (e.g., 200px, auto)
- `float`: left | right
- `margin`: CSS margin value (e.g., 1rem 0, 10px)
- `alt`: alternative text for accessibility
## Implementation Details
### File Structure
```
web/src/components/MemoEditor/
├── components/
│ ├── ImagePositionDialog.tsx # Dialog for positioning configuration
│ └── PositionedImageView.tsx # Component for rendering positioned images
├── state/
│ ├── actions.ts # Added positioned image actions
│ ├── reducer.ts # Added positioned image reducers
│ └── types.ts # Added PositionedImage interface
├── Toolbar/
│ └── InsertMenu.tsx # Added "Position Image" menu item
├── types/
│ └── attachment.ts # Added PositionedImage interface
└── utils/remark-plugins/
└── remark-image-position.ts # Markdown parser for image positioning tags
```
### Key Components
#### ImagePositionDialog
- Provides UI for selecting images and configuring positioning
- Generates appropriate markdown tags
- Inserts tags at cursor position in the editor
#### PositionedImageView
- Renders images with configured positioning
- Resolves attachment URLs from IDs
- Provides fallback for missing attachments
- Applies CSS styling based on parameters
#### Remark Plugin
- Parses `[img:id|...]` syntax in markdown
- Converts tags to AST nodes for proper rendering
- Preserves positioning parameters for the renderer
## Usage Workflow
1. **Upload Image**: User uploads image through normal process
2. **Open Positioning Dialog**: Click "Position Image" in toolbar
3. **Configure Positioning**: Select image and set parameters
4. **Insert Tag**: System generates and inserts markdown tag
5. **Render**: Image displays with specified positioning
## Examples
```markdown
# Basic centered image
[img:upload-1234567890|align=center]
# Left-aligned with specific width
[img:upload-1234567890|align=left|width=300px]
# Floated right with margin
[img:upload-1234567890|float=right|width=40%|margin=1rem]
# Full customization
[img:upload-1234567890|align=center|width=80%|height=200px|margin=2rem 0|alt=Beautiful landscape]
```
## Benefits
1. **Precise Control**: Fine-grained positioning control over images
2. **Non-Destructive**: Images remain in attachments, only positioning changes
3. **Backward Compatible**: Works alongside existing markdown images
4. **SEO Friendly**: Supports alt text for accessibility
5. **Flexible**: Supports various CSS positioning techniques
## Future Enhancements
- Drag & drop positioning interface
- Preset positioning templates
- Responsive positioning adjustments
- Image caption support
- Lightbox integration for positioned images

View File

@@ -0,0 +1,73 @@
# MemoEditor Architecture
## Overview
MemoEditor uses a three-layer architecture for better separation of concerns and testability.
## Architecture
```
┌─────────────────────────────────────────┐
│ Presentation Layer (Components) │
│ - EditorToolbar, EditorContent, etc. │
└─────────────────┬───────────────────────┘
┌─────────────────▼───────────────────────┐
│ State Layer (Reducer + Context) │
│ - state/, useEditorContext() │
└─────────────────┬───────────────────────┘
┌─────────────────▼───────────────────────┐
│ Service Layer (Business Logic) │
│ - services/ (pure functions) │
└─────────────────────────────────────────┘
```
## Directory Structure
```
MemoEditor/
├── state/ # State management (reducer, actions, context)
├── services/ # Business logic (pure functions)
├── components/ # UI components
├── hooks/ # React hooks (utilities)
├── Editor/ # Core editor component
├── Toolbar/ # Toolbar components
├── constants.ts
└── types/
```
## Key Concepts
### State Management
Uses `useReducer` + Context for predictable state transitions. All state changes go through action creators.
### Services
Pure TypeScript functions containing business logic. No React hooks, easy to test.
### Components
Thin presentation components that dispatch actions and render UI.
## Usage
```typescript
import MemoEditor from "@/components/MemoEditor";
<MemoEditor
memoName="memos/123"
onConfirm={(name) => console.log('Saved:', name)}
onCancel={() => console.log('Cancelled')}
/>
```
## Testing
Services are pure functions - easy to unit test without React.
```typescript
const state = mockEditorState();
const result = await memoService.save(state, { memoName: 'memos/123' });
```

View File

@@ -0,0 +1,231 @@
import { LatLng } from "leaflet";
import { uniqBy } from "lodash-es";
import { FileIcon, ImageIcon, LinkIcon, LoaderIcon, type LucideIcon, MapPinIcon, Maximize2Icon, MoreHorizontalIcon, PlusIcon } from "lucide-react";
import { useCallback, useEffect, useMemo, useState } from "react";
import { useDebounce } from "react-use";
import { useReverseGeocoding } from "@/components/map";
import { Button } from "@/components/ui/button";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuSub,
DropdownMenuSubContent,
DropdownMenuSubTrigger,
DropdownMenuTrigger,
useDropdownMenuSubHoverDelay,
} from "@/components/ui/dropdown-menu";
import type { MemoRelation } from "@/types/proto/api/v1/memo_service_pb";
import { useTranslate } from "@/utils/i18n";
import { ImagePositionDialog, LinkMemoDialog, LocationDialog } from "../components";
import { useFileUpload, useLinkMemo, useLocation } from "../hooks";
import { useEditorContext } from "../state";
import type { InsertMenuProps } from "../types";
import type { LocalFile } from "../types/attachment";
const InsertMenu = (props: InsertMenuProps) => {
const t = useTranslate();
const { state, actions, dispatch } = useEditorContext();
const { location: initialLocation, onLocationChange, onToggleFocusMode, isUploading: isUploadingProp } = props;
const [linkDialogOpen, setLinkDialogOpen] = useState(false);
const [locationDialogOpen, setLocationDialogOpen] = useState(false);
const [imagePositionDialogOpen, setImagePositionDialogOpen] = useState(false);
const [moreSubmenuOpen, setMoreSubmenuOpen] = useState(false);
const { handleTriggerEnter, handleTriggerLeave, handleContentEnter, handleContentLeave } = useDropdownMenuSubHoverDelay(
150,
setMoreSubmenuOpen,
);
const { fileInputRef, selectingFlag, handleFileInputChange, handleUploadClick } = useFileUpload((newFiles: LocalFile[]) => {
newFiles.forEach((file) => dispatch(actions.addLocalFile(file)));
});
const linkMemo = useLinkMemo({
isOpen: linkDialogOpen,
currentMemoName: props.memoName,
existingRelations: state.metadata.relations,
onAddRelation: (relation: MemoRelation) => {
dispatch(actions.setMetadata({ relations: uniqBy([...state.metadata.relations, relation], (r) => r.relatedMemo?.name) }));
setLinkDialogOpen(false);
},
});
const location = useLocation(props.location);
const [debouncedPosition, setDebouncedPosition] = useState<LatLng | undefined>(undefined);
useDebounce(
() => {
setDebouncedPosition(location.state.position);
},
1000,
[location.state.position],
);
const { data: displayName } = useReverseGeocoding(debouncedPosition?.lat, debouncedPosition?.lng);
useEffect(() => {
if (displayName) {
location.setPlaceholder(displayName);
}
}, [displayName]);
const isUploading = selectingFlag || isUploadingProp;
const handleOpenLinkDialog = useCallback(() => {
setLinkDialogOpen(true);
}, []);
const handleImagePositionClick = useCallback(() => {
setImagePositionDialogOpen(true);
}, []);
const handleLocationClick = useCallback(() => {
setLocationDialogOpen(true);
if (!initialLocation && !location.locationInitialized) {
if (navigator.geolocation) {
navigator.geolocation.getCurrentPosition(
(position) => {
location.handlePositionChange(new LatLng(position.coords.latitude, position.coords.longitude));
},
(error) => {
console.error("Geolocation error:", error);
},
);
}
}
}, [initialLocation, location]);
const handleLocationConfirm = useCallback(() => {
const newLocation = location.getLocation();
if (newLocation) {
onLocationChange(newLocation);
setLocationDialogOpen(false);
}
}, [location, onLocationChange]);
const handleLocationCancel = useCallback(() => {
location.reset();
setLocationDialogOpen(false);
}, [location]);
const handlePositionChange = useCallback(
(position: LatLng) => {
location.handlePositionChange(position);
},
[location],
);
const handleToggleFocusMode = useCallback(() => {
onToggleFocusMode?.();
setMoreSubmenuOpen(false);
}, [onToggleFocusMode]);
const menuItems = useMemo(
() =>
[
{
key: "upload",
label: t("common.upload"),
icon: FileIcon,
onClick: handleUploadClick,
},
{
key: "link",
label: t("tooltip.link-memo"),
icon: LinkIcon,
onClick: handleOpenLinkDialog,
},
{
key: "location",
label: t("tooltip.select-location"),
icon: MapPinIcon,
onClick: handleLocationClick,
},
{
key: "image-position",
label: "Position Image",
icon: ImageIcon,
onClick: handleImagePositionClick,
},
] satisfies Array<{ key: string; label: string; icon: LucideIcon; onClick: () => void }>,
[handleLocationClick, handleOpenLinkDialog, handleUploadClick, handleImagePositionClick, t],
);
return (
<>
<DropdownMenu modal={false}>
<DropdownMenuTrigger asChild>
<Button variant="outline" size="icon" className="shadow-none" disabled={isUploading}>
{isUploading ? <LoaderIcon className="size-4 animate-spin" /> : <PlusIcon className="size-4" />}
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="start">
{menuItems.map((item) => (
<DropdownMenuItem key={item.key} onClick={item.onClick}>
<item.icon className="w-4 h-4" />
{item.label}
</DropdownMenuItem>
))}
{/* View submenu with Focus Mode */}
<DropdownMenuSub open={moreSubmenuOpen} onOpenChange={setMoreSubmenuOpen}>
<DropdownMenuSubTrigger onPointerEnter={handleTriggerEnter} onPointerLeave={handleTriggerLeave}>
<MoreHorizontalIcon className="w-4 h-4" />
{t("common.more")}
</DropdownMenuSubTrigger>
<DropdownMenuSubContent onPointerEnter={handleContentEnter} onPointerLeave={handleContentLeave}>
<DropdownMenuItem onClick={handleToggleFocusMode}>
<Maximize2Icon className="w-4 h-4" />
{t("editor.focus-mode")}
</DropdownMenuItem>
</DropdownMenuSubContent>
</DropdownMenuSub>
<div className="px-2 py-1 text-xs text-muted-foreground opacity-80">{t("editor.slash-commands")}</div>
</DropdownMenuContent>
</DropdownMenu>
{/* Hidden file input */}
<input
className="hidden"
ref={fileInputRef}
disabled={isUploading}
onChange={handleFileInputChange}
type="file"
multiple={true}
accept="*"
/>
<LinkMemoDialog
open={linkDialogOpen}
onOpenChange={setLinkDialogOpen}
searchText={linkMemo.searchText}
onSearchChange={linkMemo.setSearchText}
filteredMemos={linkMemo.filteredMemos}
isFetching={linkMemo.isFetching}
onSelectMemo={linkMemo.addMemoRelation}
/>
<LocationDialog
open={locationDialogOpen}
onOpenChange={setLocationDialogOpen}
state={location.state}
locationInitialized={location.locationInitialized}
onPositionChange={handlePositionChange}
onUpdateCoordinate={location.updateCoordinate}
onPlaceholderChange={location.setPlaceholder}
onCancel={handleLocationCancel}
onConfirm={handleLocationConfirm}
/>
<ImagePositionDialog
open={imagePositionDialogOpen}
onOpenChange={setImagePositionDialogOpen}
attachments={state.metadata.attachments}
/>
</>
);
};
export default InsertMenu;

View File

@@ -0,0 +1,42 @@
import { CheckIcon, ChevronDownIcon } from "lucide-react";
import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger } from "@/components/ui/dropdown-menu";
import VisibilityIcon from "@/components/VisibilityIcon";
import { Visibility } from "@/types/proto/api/v1/memo_service_pb";
import { useTranslate } from "@/utils/i18n";
import type { VisibilitySelectorProps } from "../types";
const VisibilitySelector = (props: VisibilitySelectorProps) => {
const { value, onChange } = props;
const t = useTranslate();
const visibilityOptions = [
{ value: Visibility.PRIVATE, label: t("memo.visibility.private") },
{ value: Visibility.PROTECTED, label: t("memo.visibility.protected") },
{ value: Visibility.PUBLIC, label: t("memo.visibility.public") },
] as const;
const currentLabel = visibilityOptions.find((option) => option.value === value)?.label || "";
return (
<DropdownMenu onOpenChange={props.onOpenChange}>
<DropdownMenuTrigger asChild>
<button className="inline-flex items-center px-2 text-sm text-muted-foreground opacity-80 hover:opacity-100 transition-colors">
<VisibilityIcon visibility={value} className="opacity-60 mr-1.5" />
<span>{currentLabel}</span>
<ChevronDownIcon className="ml-0.5 w-4 h-4 opacity-60" />
</button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
{visibilityOptions.map((option) => (
<DropdownMenuItem key={option.value} className="cursor-pointer gap-2" onClick={() => onChange(option.value)}>
<VisibilityIcon visibility={option.value} />
<span className="flex-1">{option.label}</span>
{value === option.value && <CheckIcon className="w-4 h-4 text-primary" />}
</DropdownMenuItem>
))}
</DropdownMenuContent>
</DropdownMenu>
);
};
export default VisibilitySelector;

View File

@@ -0,0 +1,3 @@
// Toolbar components for MemoEditor
export { default as InsertMenu } from "./InsertMenu";
export { default as VisibilitySelector } from "./VisibilitySelector";

View File

@@ -0,0 +1,319 @@
import { BotIcon, ChevronDownIcon, MessageSquareIcon, PencilIcon, PlusIcon, SparklesIcon } from "lucide-react";
import { useEffect, useRef, useState } from "react";
import { toast } from "react-hot-toast";
import { Button } from "@/components/ui/button";
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
DialogTrigger,
} from "@/components/ui/dialog";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import { Textarea } from "@/components/ui/textarea";
import { cn } from "@/lib/utils";
import { aIServiceClient } from "@/connect";
import { GROQ_MODELS, OLLAMA_MODELS } from "@/types/proto/api/v1/ai_service_pb";
interface AIInstructionDialogProps {
disabled?: boolean;
editorRef: React.MutableRefObject<any>;
open?: boolean;
onOpenChange?: (open: boolean) => void;
onGeneratingChange?: (generating: boolean) => void;
}
const AIInstructionDialog = ({ disabled = false, editorRef, open: controlledOpen, onOpenChange, onGeneratingChange }: AIInstructionDialogProps) => {
const [open, setOpen] = useState(false);
const isOpen = controlledOpen ?? open;
const setIsOpen = onOpenChange ?? setOpen;
const [instruction, setInstruction] = useState("");
const [selectedProvider, setSelectedProvider] = useState<"groq" | "ollama">("groq");
const [selectedModel, setSelectedModel] = useState(GROQ_MODELS[0].id);
const [ollamaModels, setOllamaModels] = useState(OLLAMA_MODELS);
const [ollamaModelsLoading, setOllamaModelsLoading] = useState(false);
const [mode, setMode] = useState<"append" | "replace">("append");
const [autoTag, setAutoTag] = useState(true);
const [spellCheck, setSpellCheck] = useState(true);
const textareaRef = useRef<HTMLTextAreaElement>(null);
const currentModels = selectedProvider === "groq" ? GROQ_MODELS : ollamaModels;
const selectedModelName = currentModels.find((m) => m.id === selectedModel)?.name ?? selectedModel;
const providerName = selectedProvider === "groq" ? "Groq" : "Ollama";
// Fetch Ollama models when provider is selected
useEffect(() => {
if (selectedProvider === "ollama") {
setOllamaModelsLoading(true);
aIServiceClient.listOllamaModels()
.then((models) => {
if (models.length > 0) {
setOllamaModels(models);
// Set default model to first available model if current selection is not in the list
if (!models.some(m => m.id === selectedModel)) {
setSelectedModel(models[0].id);
}
} else {
// Fallback to static list if no models found
setOllamaModels(OLLAMA_MODELS);
}
})
.catch((error) => {
console.warn("Failed to fetch Ollama models:", error);
// Fallback to static list on error
setOllamaModels(OLLAMA_MODELS);
})
.finally(() => {
setOllamaModelsLoading(false);
});
}
}, [selectedProvider]);
const handleSubmit = async () => {
if (!instruction.trim()) return;
if (!editorRef.current) {
toast.error("Editor not available");
return;
}
const prompt = instruction;
const editor = editorRef.current;
const fullContent = editor.getContent();
// Close dialog immediately
setIsOpen(false);
setInstruction("");
// Show loading state in toolbar
onGeneratingChange?.(true);
try {
let contextPrompt: string;
if (mode === "replace") {
contextPrompt = fullContent
? `${prompt}\n\nRewrite the following note based on the instruction above. Return ONLY the rewritten note content, no extra commentary:\n\n${fullContent}`
: prompt;
} else {
const selectedText = editor.getSelectedContent?.();
contextPrompt = selectedText
? `${prompt}\n\nSelected text: "${selectedText}"\n\nFull note: ${fullContent}`
: fullContent
? `${prompt}\n\nNote content: ${fullContent}`
: prompt;
}
const response = await aIServiceClient.generateCompletion({
prompt: contextPrompt,
provider: selectedProvider === "groq" ? 1 : 2,
model: selectedModel,
temperature: 0.7,
maxTokens: 2000,
autoTag,
spellCheck,
});
if (response.text) {
if (mode === "replace") {
editor.setContent(response.text);
} else {
editor.insertText(`\n\n${response.text}`);
}
}
} catch (error) {
console.error("AI failed:", error);
toast.error(error instanceof Error ? error.message : "AI request failed");
} finally {
onGeneratingChange?.(false);
}
};
const handleKeyDown = (e: React.KeyboardEvent) => {
if (e.key === "Enter" && !e.shiftKey) {
e.preventDefault();
handleSubmit();
}
};
const handleOpenChange = (val: boolean) => {
setIsOpen(val);
if (val) {
setTimeout(() => textareaRef.current?.focus(), 50);
}
};
return (
<Dialog open={isOpen} onOpenChange={handleOpenChange}>
<DialogTrigger asChild>
<Button
variant="ghost"
size="icon"
className="shadow-none"
disabled={disabled}
title="Ask AI"
>
<MessageSquareIcon className="size-4" />
</Button>
</DialogTrigger>
<DialogContent className="sm:max-w-2xl p-0 overflow-hidden">
<DialogHeader className="px-4 pt-4 pb-2">
<DialogTitle className="flex items-center gap-2 text-base">
<BotIcon className="size-4" />
Ask AI
</DialogTitle>
</DialogHeader>
<div className="px-4 pb-4">
<Textarea
ref={textareaRef}
placeholder="Ask AI anything... (Enter to send, Shift+Enter for newline)"
value={instruction}
onChange={(e) => setInstruction(e.target.value)}
onKeyDown={handleKeyDown}
className="resize-none min-h-[100px] text-sm border-0 shadow-none focus-visible:ring-0 p-0"
autoFocus
/>
<div className="flex flex-col sm:flex-row sm:items-center justify-between mt-3 gap-3">
{/* Left: provider selector + model selector + mode toggle + feature toggles */}
<div className="flex flex-wrap items-center gap-1.5">
{/* Provider selector */}
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="outline" size="sm" className="gap-1 text-xs h-7 px-2">
{providerName}
<ChevronDownIcon className="size-3" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="start">
<DropdownMenuItem
onClick={() => {
setSelectedProvider("groq");
setSelectedModel(GROQ_MODELS[0].id);
}}
className={selectedProvider === "groq" ? "font-medium" : ""}
>
Groq
</DropdownMenuItem>
<DropdownMenuItem
onClick={() => {
setSelectedProvider("ollama");
setSelectedModel(OLLAMA_MODELS[0].id);
}}
className={selectedProvider === "ollama" ? "font-medium" : ""}
>
Ollama
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
{/* Model selector */}
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
variant="outline"
size="sm"
className="gap-1 text-xs h-7 px-2"
disabled={ollamaModelsLoading && selectedProvider === "ollama"}
>
{ollamaModelsLoading && selectedProvider === "ollama" ? (
<>
<div className="size-3 animate-spin rounded-full border border-current border-t-transparent" />
Loading...
</>
) : (
<>
{selectedModelName}
<ChevronDownIcon className="size-3" />
</>
)}
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="start">
{currentModels.map((model) => (
<DropdownMenuItem
key={model.id}
onClick={() => setSelectedModel(model.id)}
className={selectedModel === model.id ? "font-medium" : ""}
>
{model.name}
</DropdownMenuItem>
))}
</DropdownMenuContent>
</DropdownMenu>
{/* Mode toggle */}
<div className="flex rounded-md border border-border overflow-hidden text-xs h-7">
<button
type="button"
onClick={() => setMode("append")}
className={cn(
"flex items-center gap-1 px-2 transition-colors",
mode === "append"
? "bg-primary text-primary-foreground"
: "bg-background text-muted-foreground hover:bg-muted",
)}
title="Append AI output after note"
>
<PlusIcon className="size-3" />
Append
</button>
<button
type="button"
onClick={() => setMode("replace")}
className={cn(
"flex items-center gap-1 px-2 transition-colors border-l border-border",
mode === "replace"
? "bg-primary text-primary-foreground"
: "bg-background text-muted-foreground hover:bg-muted",
)}
title="Replace entire note with AI output"
>
<PencilIcon className="size-3" />
Replace
</button>
</div>
{/* Feature toggles */}
<div className="flex items-center gap-1">
<label className="flex items-center gap-1 text-xs cursor-pointer">
<input
type="checkbox"
checked={autoTag}
onChange={(e) => setAutoTag(e.target.checked)}
className="rounded border-border text-primary focus:ring-primary size-3.5"
/>
<span className="text-muted-foreground">Auto-tag</span>
</label>
<label className="flex items-center gap-1 text-xs cursor-pointer">
<input
type="checkbox"
checked={spellCheck}
onChange={(e) => setSpellCheck(e.target.checked)}
className="rounded border-border text-primary focus:ring-primary size-3.5"
/>
<span className="text-muted-foreground">Spell check</span>
</label>
</div>
</div>
<Button
onClick={handleSubmit}
disabled={!instruction.trim()}
size="sm"
className="gap-1.5"
>
<SparklesIcon className="size-3.5" />
Send
</Button>
</div>
</div>
</DialogContent>
</Dialog>
);
};
export default AIInstructionDialog;

View File

@@ -0,0 +1,216 @@
import { BotIcon, SparklesIcon, WandIcon } from "lucide-react";
import { useState } from "react";
import { toast } from "react-hot-toast";
import { Button } from "@/components/ui/button";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import { aIServiceClient } from "@/connect";
import type { EditorRefActions } from "../Editor";
import AIInstructionDialog from "./AIInstructionDialog";
interface AIToolbarProps {
editorRef: React.MutableRefObject<EditorRefActions | null>;
disabled?: boolean;
}
const AIToolbar = ({ editorRef, disabled = false }: AIToolbarProps) => {
const [isGenerating, setIsGenerating] = useState(false);
const handleAICompletion = async () => {
if (!editorRef.current) return;
const editor = editorRef.current;
try {
setIsGenerating(true);
const selectedText = editor.getSelectedContent();
const fullContent = editor.getContent();
// Get custom instruction if exists
const customInstruction = localStorage.getItem("ai-custom-instruction");
const instructionPrefix = customInstruction ? `${customInstruction}\n\n` : '';
// If text is selected, use it as context for completion
const prompt = instructionPrefix + (selectedText
? `Complete or improve this text: "${selectedText}"\n\nContext: ${fullContent}`
: `Continue writing this note: ${fullContent}`);
const response = await aIServiceClient.generateCompletion({
prompt,
provider: 1, // Groq as default
temperature: 0.7,
maxTokens: 500,
});
if (response.text) {
if (selectedText) {
// Replace selected text with completion
const cursorPos = editor.getCursorPosition();
editor.removeText(cursorPos - selectedText.length, selectedText.length);
editor.insertText(response.text);
} else {
// Append completion to end
editor.insertText(response.text);
}
toast.success("AI completion added!");
}
} catch (error) {
console.error("AI completion failed:", error);
// Handle authentication errors
if (error instanceof Error && error.message.includes("UNAUTHORIZED")) {
toast.error("Authentication required. Please refresh the page or log in again.");
} else {
toast.error("Failed to generate AI completion");
}
} finally {
setIsGenerating(false);
}
};
const handleAISummarize = async () => {
if (!editorRef.current) return;
const editor = editorRef.current;
try {
setIsGenerating(true);
const content = editor.getContent();
if (!content.trim()) {
toast.error("Nothing to summarize");
return;
}
// Get custom instruction if exists
const customInstruction = localStorage.getItem("ai-custom-instruction");
const instructionPrefix = customInstruction ? `${customInstruction}\n\n` : '';
const prompt = instructionPrefix + `Summarize this note concisely:\n\n${content}`;
const response = await aIServiceClient.generateCompletion({
prompt,
provider: 1, // Groq as default
temperature: 0.3,
maxTokens: 200,
});
if (response.text) {
// Insert summary at cursor position
editor.insertText(`\n\n**Summary:** ${response.text}`);
toast.success("Summary generated!");
}
} catch (error) {
console.error("AI summarize failed:", error);
// Handle authentication errors
if (error instanceof Error && error.message.includes("UNAUTHORIZED")) {
toast.error("Authentication required. Please refresh the page or log in again.");
} else {
toast.error("Failed to generate summary");
}
} finally {
setIsGenerating(false);
}
};
const handleAIImprove = async () => {
if (!editorRef.current) return;
const editor = editorRef.current;
try {
setIsGenerating(true);
const selectedText = editor.getSelectedContent();
const fullContent = editor.getContent();
const textToImprove = selectedText || fullContent;
if (!textToImprove.trim()) {
toast.error("Nothing to improve");
return;
}
// Get custom instruction if exists
const customInstruction = localStorage.getItem("ai-custom-instruction");
const instructionPrefix = customInstruction ? `${customInstruction}\n\n` : '';
const prompt = instructionPrefix + `Improve the grammar, clarity, and flow of this text:\n\n${textToImprove}`;
const response = await aIServiceClient.generateCompletion({
prompt,
provider: 1, // Groq as default
temperature: 0.4,
maxTokens: 1000,
});
if (response.text) {
if (selectedText) {
// Replace selected text with improved version
const cursorPos = editor.getCursorPosition();
editor.removeText(cursorPos - selectedText.length, selectedText.length);
editor.insertText(response.text);
} else {
// Replace entire content
editor.setContent(response.text);
}
toast.success("Text improved!");
}
} catch (error) {
console.error("AI improve failed:", error);
// Handle authentication errors
if (error instanceof Error && error.message.includes("UNAUTHORIZED")) {
toast.error("Authentication required. Please refresh the page or log in again.");
} else {
toast.error("Failed to improve text");
}
} finally {
setIsGenerating(false);
}
};
return (
<div className="flex items-center gap-2">
<DropdownMenu modal={false}>
<DropdownMenuTrigger asChild>
<Button
variant="outline"
size="icon"
className="shadow-none"
disabled={disabled || isGenerating}
>
{isGenerating ? (
<SparklesIcon className="size-4 animate-pulse" />
) : (
<BotIcon className="size-4" />
)}
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="start">
<DropdownMenuItem onClick={handleAICompletion}>
<WandIcon className="w-4 h-4 mr-2" />
Complete Text
</DropdownMenuItem>
<DropdownMenuItem onClick={handleAISummarize}>
<SparklesIcon className="w-4 h-4 mr-2" />
Summarize
</DropdownMenuItem>
<DropdownMenuItem onClick={handleAIImprove}>
<BotIcon className="w-4 h-4 mr-2" />
Improve Writing
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
<AIInstructionDialog
disabled={disabled}
editorRef={editorRef}
onGeneratingChange={(generating) => setIsGenerating(generating)}
/>
</div>
);
};
export default AIToolbar;

View File

@@ -0,0 +1,169 @@
import { ChevronDownIcon, ChevronUpIcon, FileIcon, PaperclipIcon, XIcon } from "lucide-react";
import type { FC } from "react";
import { cn } from "@/lib/utils";
import type { Attachment } from "@/types/proto/api/v1/attachment_service_pb";
import { formatFileSize, getFileTypeLabel } from "@/utils/format";
import type { LocalFile } from "../types/attachment";
import { toAttachmentItems } from "../types/attachment";
interface AttachmentListProps {
attachments: Attachment[];
localFiles?: LocalFile[];
onAttachmentsChange?: (attachments: Attachment[]) => void;
onRemoveLocalFile?: (previewUrl: string) => void;
}
const AttachmentItemCard: FC<{
item: ReturnType<typeof toAttachmentItems>[0];
onRemove?: () => void;
onMoveUp?: () => void;
onMoveDown?: () => void;
canMoveUp?: boolean;
canMoveDown?: boolean;
}> = ({ item, onRemove, onMoveUp, onMoveDown, canMoveUp = true, canMoveDown = true }) => {
const { category, filename, thumbnailUrl, mimeType, size } = item;
const fileTypeLabel = getFileTypeLabel(mimeType);
const fileSizeLabel = size ? formatFileSize(size) : undefined;
return (
<div className="relative flex items-center gap-1.5 px-1.5 py-1 rounded border border-transparent hover:border-border hover:bg-accent/20 transition-all">
<div className="shrink-0 w-6 h-6 rounded overflow-hidden bg-muted/40 flex items-center justify-center">
{category === "image" && thumbnailUrl ? (
<img src={thumbnailUrl} alt="" className="w-full h-full object-cover" />
) : (
<FileIcon className="w-3.5 h-3.5 text-muted-foreground" />
)}
</div>
<div className="flex-1 min-w-0 flex flex-col sm:flex-row sm:items-baseline gap-0.5 sm:gap-1.5">
<span className="text-xs truncate" title={filename}>
{filename}
</span>
<div className="flex items-center gap-1 text-[11px] text-muted-foreground shrink-0">
<span>{fileTypeLabel}</span>
{fileSizeLabel && (
<>
<span className="text-muted-foreground/50 hidden sm:inline"></span>
<span className="hidden sm:inline">{fileSizeLabel}</span>
</>
)}
</div>
</div>
<div className="shrink-0 flex items-center gap-0.5">
{onMoveUp && (
<button
type="button"
onClick={onMoveUp}
disabled={!canMoveUp}
className={cn(
"p-0.5 rounded hover:bg-accent active:bg-accent transition-colors touch-manipulation",
!canMoveUp && "opacity-20 cursor-not-allowed hover:bg-transparent",
)}
title="Move up"
aria-label="Move attachment up"
>
<ChevronUpIcon className="w-3 h-3 text-muted-foreground" />
</button>
)}
{onMoveDown && (
<button
type="button"
onClick={onMoveDown}
disabled={!canMoveDown}
className={cn(
"p-0.5 rounded hover:bg-accent active:bg-accent transition-colors touch-manipulation",
!canMoveDown && "opacity-20 cursor-not-allowed hover:bg-transparent",
)}
title="Move down"
aria-label="Move attachment down"
>
<ChevronDownIcon className="w-3 h-3 text-muted-foreground" />
</button>
)}
{onRemove && (
<button
type="button"
onClick={onRemove}
className="p-0.5 rounded hover:bg-destructive/10 active:bg-destructive/10 transition-colors ml-0.5 touch-manipulation"
title="Remove"
aria-label="Remove attachment"
>
<XIcon className="w-3 h-3 text-muted-foreground hover:text-destructive" />
</button>
)}
</div>
</div>
);
};
const AttachmentList: FC<AttachmentListProps> = ({ attachments, localFiles = [], onAttachmentsChange, onRemoveLocalFile }) => {
if (attachments.length === 0 && localFiles.length === 0) {
return null;
}
const items = toAttachmentItems(attachments, localFiles);
const handleMoveUp = (index: number) => {
if (index === 0 || !onAttachmentsChange) return;
const newAttachments = [...attachments];
[newAttachments[index - 1], newAttachments[index]] = [newAttachments[index], newAttachments[index - 1]];
onAttachmentsChange(newAttachments);
};
const handleMoveDown = (index: number) => {
if (index === attachments.length - 1 || !onAttachmentsChange) return;
const newAttachments = [...attachments];
[newAttachments[index], newAttachments[index + 1]] = [newAttachments[index + 1], newAttachments[index]];
onAttachmentsChange(newAttachments);
};
const handleRemoveAttachment = (name: string) => {
if (onAttachmentsChange) {
onAttachmentsChange(attachments.filter((attachment) => attachment.name !== name));
}
};
const handleRemoveItem = (item: (typeof items)[0]) => {
if (item.isLocal) {
onRemoveLocalFile?.(item.id);
} else {
handleRemoveAttachment(item.id);
}
};
return (
<div className="w-full rounded-lg border border-border bg-muted/20 overflow-hidden">
<div className="flex items-center gap-1.5 px-2 py-1 border-b border-border bg-muted/30">
<PaperclipIcon className="w-3.5 h-3.5 text-muted-foreground" />
<span className="text-xs text-foreground">Attachments ({items.length})</span>
</div>
<div className="p-1 sm:p-1.5 flex flex-col gap-0.5">
{items.map((item) => {
const isLocalFile = item.isLocal;
const attachmentIndex = isLocalFile ? -1 : attachments.findIndex((a) => a.name === item.id);
return (
<AttachmentItemCard
key={item.id}
item={item}
onRemove={() => handleRemoveItem(item)}
onMoveUp={!isLocalFile ? () => handleMoveUp(attachmentIndex) : undefined}
onMoveDown={!isLocalFile ? () => handleMoveDown(attachmentIndex) : undefined}
canMoveUp={!isLocalFile && attachmentIndex > 0}
canMoveDown={!isLocalFile && attachmentIndex < attachments.length - 1}
/>
);
})}
</div>
</div>
);
};
export default AttachmentList;

View File

@@ -0,0 +1,87 @@
import { forwardRef, useRef, useCallback } from "react";
import Editor, { type EditorRefActions } from "../Editor";
import { useBlobUrls, useDragAndDrop } from "../hooks";
import { useEditorContext } from "../state";
import type { EditorContentProps } from "../types";
import type { LocalFile } from "../types/attachment";
import AIToolbar from "./AIToolbar";
export const EditorContent = forwardRef<EditorRefActions, EditorContentProps>(({ placeholder }, ref) => {
const { state, actions, dispatch } = useEditorContext();
const { createBlobUrl } = useBlobUrls();
const { dragHandlers } = useDragAndDrop((files: FileList) => {
const localFiles: LocalFile[] = Array.from(files).map((file) => ({
file,
previewUrl: createBlobUrl(file),
}));
localFiles.forEach((localFile) => dispatch(actions.addLocalFile(localFile)));
});
const handleCompositionStart = () => {
dispatch(actions.setComposing(true));
};
const handleCompositionEnd = () => {
dispatch(actions.setComposing(false));
};
const handleContentChange = (content: string) => {
dispatch(actions.updateContent(content));
};
const handlePaste = (event: React.ClipboardEvent<Element>) => {
const clipboard = event.clipboardData;
if (!clipboard) return;
const files: File[] = [];
if (clipboard.items && clipboard.items.length > 0) {
for (const item of Array.from(clipboard.items)) {
if (item.kind !== "file") continue;
const file = item.getAsFile();
if (file) files.push(file);
}
} else if (clipboard.files && clipboard.files.length > 0) {
files.push(...Array.from(clipboard.files));
}
if (files.length === 0) return;
const localFiles: LocalFile[] = files.map((file) => ({
file,
previewUrl: createBlobUrl(file),
}));
localFiles.forEach((localFile) => dispatch(actions.addLocalFile(localFile)));
event.preventDefault();
};
// Use ref for editor actions to prevent infinite loops
const editorActionsRef = useRef<EditorRefActions | null>(null);
// Stable callback ref — no deps, no re-renders
const editorCallbackRef = useCallback((editorActions: EditorRefActions | null) => {
editorActionsRef.current = editorActions;
}, []);
return (
<div className="w-full flex flex-col flex-1" {...dragHandlers}>
<div className="flex items-center gap-1 mb-2">
<AIToolbar editorRef={editorActionsRef} disabled={state.ui.isLoading.saving} />
</div>
<Editor
ref={editorCallbackRef}
className="memo-editor-content"
initialContent={state.content}
placeholder={placeholder || ""}
isFocusMode={state.ui.isFocusMode}
isInIME={state.ui.isComposing}
onContentChange={handleContentChange}
onPaste={handlePaste}
onCompositionStart={handleCompositionStart}
onCompositionEnd={handleCompositionEnd}
/>
</div>
);
});
EditorContent.displayName = "EditorContent";

View File

@@ -0,0 +1,31 @@
import type { FC } from "react";
import { useEditorContext } from "../state";
import type { EditorMetadataProps } from "../types";
import AttachmentList from "./AttachmentList";
import LocationDisplay from "./LocationDisplay";
import RelationList from "./RelationList";
export const EditorMetadata: FC<EditorMetadataProps> = ({ memoName }) => {
const { state, actions, dispatch } = useEditorContext();
return (
<div className="w-full flex flex-col gap-2">
<AttachmentList
attachments={state.metadata.attachments}
localFiles={state.localFiles}
onAttachmentsChange={(attachments) => dispatch(actions.setMetadata({ attachments }))}
onRemoveLocalFile={(previewUrl) => dispatch(actions.removeLocalFile(previewUrl))}
/>
<RelationList
relations={state.metadata.relations}
onRelationsChange={(relations) => dispatch(actions.setMetadata({ relations }))}
memoName={memoName}
/>
{state.metadata.location && (
<LocationDisplay location={state.metadata.location} onRemove={() => dispatch(actions.setMetadata({ location: undefined }))} />
)}
</div>
);
};

Some files were not shown because too many files have changed in this diff Show More