chore: import upstream snapshot with attribution
Backend Tests / Tests (other) (push) Failing after 1s
Backend Tests / Static Checks (push) Failing after 2s
Backend Tests / Tests (internal) (push) Failing after 2s
Backend Tests / Tests (server) (push) Failing after 1s
Backend Tests / Tests (store) (push) Failing after 1s
Build Canary Image / build-frontend (push) Failing after 1s
Frontend Tests / Lint (push) Failing after 1s
Build Canary Image / build-push (linux/amd64) (push) Has been skipped
Build Canary Image / build-push (linux/arm64) (push) Has been skipped
Build Canary Image / merge (push) Has been skipped
Frontend Tests / Build (push) Failing after 1s
Release Please / release-please (push) Failing after 0s
Proto Linter / Lint Protos (push) Failing after 2s

This commit is contained in:
wehub-resource-sync
2026-07-13 12:02:24 +08:00
commit 4cddfcf2f3
1040 changed files with 200957 additions and 0 deletions
+7
View File
@@ -0,0 +1,7 @@
node_modules
.pnpm-store
.DS_Store
dist
dist-ssr
*.local
src/types/proto/store
+202
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
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"
}
+55
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.
+24
View File
@@ -0,0 +1,24 @@
<!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" />
<meta name="theme-color" content="#faf9f5" />
<meta name="mobile-web-app-capable" content="yes" />
<meta name="apple-mobile-web-app-capable" content="yes" />
<meta name="apple-mobile-web-app-status-bar-style" content="default" />
<!-- 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>
+114
View File
@@ -0,0 +1,114 @@
{
"name": "memos",
"private": true,
"packageManager": "pnpm@11.0.1",
"engines": {
"node": ">=24"
},
"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",
"test": "vitest run",
"test:watch": "vitest",
"test:coverage": "vitest run --coverage"
},
"dependencies": {
"@codemirror/autocomplete": "^6.20.3",
"@codemirror/commands": "^6.10.4",
"@codemirror/lang-markdown": "^6.5.0",
"@codemirror/language": "^6.12.4",
"@codemirror/state": "^6.7.1",
"@codemirror/view": "^6.43.6",
"@connectrpc/connect": "^2.1.1",
"@connectrpc/connect-web": "^2.1.1",
"@github/relative-time-element": "^5.0.0",
"@lezer/highlight": "^1.2.3",
"@lezer/markdown": "^1.6.4",
"@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.2.4",
"@tanstack/react-query": "^5.100.9",
"@tanstack/react-query-devtools": "^5.100.9",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"copy-to-clipboard": "^4.0.2",
"dayjs": "^1.11.20",
"fuse.js": "^7.3.0",
"highlight.js": "^11.11.1",
"html-to-image": "^1.11.13",
"i18next": "^26.0.10",
"katex": "^0.16.45",
"leaflet": "^1.9.4",
"leaflet.markercluster": "^1.5.3",
"lodash-es": "^4.18.1",
"lucide-react": "^1.14.0",
"mdast-util-from-markdown": "^2.0.3",
"mdast-util-gfm": "^3.1.0",
"mermaid": "^11.14.0",
"micromark-extension-gfm": "^3.0.0",
"mime": "^4.1.0",
"react": "^19.2.6",
"react-dom": "^19.2.6",
"react-hot-toast": "^2.6.0",
"react-i18next": "^17.0.7",
"react-leaflet": "^5.0.0",
"react-leaflet-cluster": "^4.1.3",
"react-markdown": "^10.1.0",
"react-router-dom": "^7.15.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.5.0",
"tailwindcss": "^4.2.4",
"unist-util-visit": "^5.1.0",
"uuid": "^14.0.0"
},
"devDependencies": {
"@babel/core": "7.29.0",
"@babel/plugin-transform-runtime": "7.29.0",
"@babel/runtime": "7.29.2",
"@biomejs/biome": "^2.4.14",
"@bufbuild/protobuf": "^2.12.0",
"@rolldown/plugin-babel": "0.2.3",
"@testing-library/jest-dom": "^6.9.1",
"@testing-library/react": "^16.3.2",
"@types/babel__core": "7.20.5",
"@types/d3": "^7.4.3",
"@types/hast": "^3.0.4",
"@types/katex": "^0.16.8",
"@types/leaflet": "^1.9.21",
"@types/lodash-es": "^4.17.12",
"@types/mdast": "^4.0.4",
"@types/node": "^24.12.2",
"@types/qs": "^6.15.1",
"@types/react": "^19.2.14",
"@types/react-dom": "^19.2.3",
"@types/unist": "^3.0.3",
"@vitejs/plugin-react": "^6.0.1",
"babel-plugin-react-compiler": "1.0.0",
"baseline-browser-mapping": "^2.10.27",
"jsdom": "^29.1.1",
"long": "^5.3.2",
"terser": "^5.47.1",
"tw-animate-css": "^1.4.0",
"typescript": "^7.0.2",
"vite": "^8.0.11",
"vitest": "^4.1.5"
}
}
+6258
View File
File diff suppressed because it is too large Load Diff
+2
View File
@@ -0,0 +1,2 @@
allowBuilds:
esbuild: true
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

Binary file not shown.

After

Width:  |  Height:  |  Size: 25 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 36 KiB

+14
View File
@@ -0,0 +1,14 @@
{
"name": "Memos",
"short_name": "Memos",
"description": "An open-source, self-hosted note-taking tool. Capture thoughts instantly. Own them completely.",
"icons": [
{ "src": "/android-chrome-192x192.png", "sizes": "192x192", "type": "image/png" },
{ "src": "/android-chrome-512x512.png", "sizes": "512x512", "type": "image/png" }
],
"display": "standalone",
"scope": "/",
"start_url": "/",
"theme_color": "#faf9f5",
"background_color": "#faf9f5"
}
+68
View File
@@ -0,0 +1,68 @@
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 the instance needs initial setup (no users yet).
// needsSetup is used instead of a missing admin so an instance that has lost its
// admins isn't mistaken for a fresh install (which would create a normal user).
// Guard with profileLoaded so a fetch failure doesn't incorrectly trigger the redirect.
useEffect(() => {
if (profileLoaded && instanceProfile.needsSetup) {
navigateTo("/auth/signup");
}
}, [profileLoaded, instanceProfile.needsSetup, 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;
+128
View File
@@ -0,0 +1,128 @@
// 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. getRequestToken() in connect.ts calls
// hasStoredToken() to decide whether to attempt a refresh — if we eagerly delete
// the expired token, it returns null immediately, skipping the refresh and sending
// the request without credentials.
// 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);
}
};
@@ -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 { getCalendarCellStateClass, 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 (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 select-none border border-border/10 bg-muted/20",
sizeConfig.font,
sizeConfig.borderRadius,
smallExtraClasses,
);
const isInteractive = Boolean(onClick);
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(
"h-auto p-0",
baseClasses,
intensityClass,
getCalendarCellStateClass(day),
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 && !disableTooltip;
if (!shouldShowTooltip) {
return button;
}
return (
<Tooltip>
<TooltipTrigger asChild>{button}</TooltipTrigger>
<TooltipContent side="top">
<p>{tooltipText}</p>
</TooltipContent>
</Tooltip>
);
});
CalendarCell.displayName = "CalendarCell";
@@ -0,0 +1,87 @@
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 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,
selectedDate,
className,
disableTooltips = false,
timeBasis = "create_time",
} = props;
const t = useTranslate();
const { generalSetting } = useInstance();
const today = useTodayDate();
const weekDays = useWeekdayLabels();
const gridStyle = GRID_STYLES[size];
const { weeks, weekDays: rotatedWeekDays } = useCalendarMatrix({
month,
data,
weekDays,
weekStartDayOffset: generalSetting.weekStartDayOffset,
today,
selectedDate: 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", gridStyle.gap)} role="rowgroup">
{flatDays.map((day) => (
<CalendarCell
key={day.date}
day={day}
maxCount={maxCount}
tooltipText={getTooltipText(day.count, day.date, t, timeBasis)}
onClick={onClick}
size={size}
disableTooltip={disableTooltips}
/>
))}
</div>
</div>
);
});
MonthCalendar.displayName = "MonthCalendar";
@@ -0,0 +1,118 @@
import { ChevronLeftIcon, ChevronRightIcon } from "lucide-react";
import { memo, useMemo } from "react";
import { Button } from "@/components/ui/button";
import type { MemoTimeBasis } from "@/contexts/ViewContext";
import { cn } from "@/lib/utils";
import { useTranslate } from "@/utils/i18n";
import { getMaxYear, MIN_YEAR } from "./constants";
import { MonthCalendar } from "./MonthCalendar";
import type { CalendarData, YearCalendarProps } from "./types";
import { calculateMaxCount, 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: CalendarData;
maxCount: number;
onDateClick: (date: string) => void;
timeBasis?: MemoTimeBasis;
}
const MonthCard = memo(({ month, data, maxCount, onDateClick, timeBasis }: 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 timeBasis={timeBasis} />
</article>
));
MonthCard.displayName = "MonthCard";
export const YearCalendar = memo(({ selectedYear, data, onYearChange, onDateClick, className, timeBasis }: YearCalendarProps) => {
const currentYear = useMemo(() => new Date().getFullYear(), []);
const yearData = useMemo(() => filterDataByYear(data, selectedYear), [data, selectedYear]);
const months = useMemo(() => generateMonthsForYear(selectedYear), [selectedYear]);
const yearMaxCount = useMemo(() => calculateMaxCount(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} timeBasis={timeBasis} />
))}
</div>
</section>
);
});
YearCalendar.displayName = "YearCalendar";
@@ -0,0 +1,34 @@
export const DAYS_IN_WEEK = 7;
export const MONTHS_IN_YEAR = 12;
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;
@@ -0,0 +1,23 @@
import dayjs from "dayjs";
import { useMemo } from "react";
import { useTranslate } from "@/utils/i18n";
export const useWeekdayLabels = () => {
const t = useTranslate();
return useMemo(
() => [
t("common.days.sun"),
t("common.days.mon"),
t("common.days.tue"),
t("common.days.wed"),
t("common.days.thu"),
t("common.days.fri"),
t("common.days.sat"),
],
[t],
);
};
export const useTodayDate = () => {
return dayjs().format("YYYY-MM-DD");
};
@@ -0,0 +1,4 @@
export * from "./MonthCalendar";
export * from "./types";
export * from "./utils";
export * from "./YearCalendar";
@@ -0,0 +1,43 @@
import type { MemoTimeBasis } from "@/contexts/ViewContext";
export type CalendarSize = "default" | "small";
export type CalendarData = Record<string, number>;
export interface CalendarDayCell {
date: string;
label: number;
count: number;
isCurrentMonth: boolean;
isToday: boolean;
isSelected: boolean;
}
export interface CalendarDayRow {
days: CalendarDayCell[];
}
export interface CalendarMatrixResult {
weeks: CalendarDayRow[];
weekDays: string[];
}
export interface MonthCalendarProps {
month: string;
data: CalendarData;
maxCount: number;
size?: CalendarSize;
onClick?: (date: string) => void;
selectedDate?: string;
className?: string;
disableTooltips?: boolean;
timeBasis?: MemoTimeBasis;
}
export interface YearCalendarProps {
selectedYear: number;
data: CalendarData;
onYearChange: (year: number) => void;
onDateClick: (date: string) => void;
className?: string;
timeBasis?: MemoTimeBasis;
}
@@ -0,0 +1,90 @@
import dayjs from "dayjs";
import { useMemo } from "react";
import { DAYS_IN_WEEK } from "./constants";
import type { CalendarData, CalendarDayCell, CalendarMatrixResult } from "./types";
export interface UseCalendarMatrixParams {
month: string;
data: CalendarData;
weekDays: string[];
weekStartDayOffset: number;
today: string;
selectedDate: string;
}
const createCalendarDayCell = (
current: dayjs.Dayjs,
monthKey: string,
data: CalendarData,
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,
};
};
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"] = [];
// 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);
}
return {
weeks,
weekDays: rotatedWeekDays,
};
}, [month, data, weekDays, weekStartDayOffset, today, selectedDate]);
};
@@ -0,0 +1,75 @@
import dayjs from "dayjs";
import isSameOrAfter from "dayjs/plugin/isSameOrAfter";
import isSameOrBefore from "dayjs/plugin/isSameOrBefore";
import type { MemoTimeBasis } from "@/contexts/ViewContext";
import { cn } from "@/lib/utils";
import { useTranslate } from "@/utils/i18n";
import { CELL_STYLES, INTENSITY_THRESHOLDS, MIN_COUNT, MONTHS_IN_YEAR } from "./constants";
import type { CalendarData, 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 getCalendarCellStateClass = (day: Pick<CalendarDayCell, "isToday" | "isSelected">): string => {
return cn(day.isToday && "font-semibold z-10", day.isSelected && "font-bold z-10");
};
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 calculateMaxCount = (data: CalendarData): 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 getTooltipText = (count: number, date: string, t: TranslateFunction, timeBasis: MemoTimeBasis = "create_time"): string => {
if (count === 0) {
return date;
}
const key = timeBasis === "update_time" ? "memo.count-memos-updated-in-date" : "memo.count-memos-in-date";
return t(key, {
count,
memos: count === 1 ? t("common.memo") : t("common.memos"),
date,
}).toLowerCase();
};
+108
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);
@@ -0,0 +1,126 @@
import { FileAudioIcon, FileIcon, PlayIcon } from "lucide-react";
import AudioAttachmentItem from "@/components/MemoMetadata/Attachment/AudioAttachmentItem";
import VideoPoster from "@/components/VideoPoster";
import type { AttachmentLibraryListItem } from "@/hooks/useAttachmentLibrary";
import { cn } from "@/lib/utils";
import { getAttachmentThumbnailUrl, getAttachmentType, isMotionAttachment } from "@/utils/attachment";
import { AttachmentMetadataLine, AttachmentOpenButton, AttachmentSourceChip } from "./AttachmentLibraryPrimitives";
const AttachmentThumb = ({ item, className }: { item: AttachmentLibraryListItem; className?: string }) => {
const type = getAttachmentType(item.attachment);
const isMotion = isMotionAttachment(item.attachment);
if (type === "image/*" || isMotion) {
return (
<div className={cn("overflow-hidden rounded-xl bg-muted/35", className)}>
<img
src={getAttachmentThumbnailUrl(item.attachment)}
alt={item.attachment.filename}
className="h-full w-full object-cover"
loading="lazy"
decoding="async"
/>
</div>
);
}
if (type === "video/*") {
return (
<div className={cn("relative overflow-hidden rounded-xl bg-muted/35", className)}>
<VideoPoster sourceUrl={item.sourceUrl} alt={item.attachment.filename} className="h-full w-full object-cover" />
<span className="absolute bottom-2 right-2 inline-flex h-7 w-7 items-center justify-center rounded-full bg-background/85 text-foreground shadow-sm">
<PlayIcon className="h-3.5 w-3.5 fill-current" />
</span>
</div>
);
}
return (
<div className={cn("flex items-center justify-center rounded-xl bg-muted/45 text-muted-foreground", className)}>
{type === "audio/*" ? <FileAudioIcon className="h-5 w-5" /> : <FileIcon className="h-5 w-5" />}
</div>
);
};
export const AttachmentDocumentRows = ({ items }: { items: AttachmentLibraryListItem[] }) => {
return (
<div className="space-y-3">
{items.map((item) => (
<article
key={item.attachment.name}
className="flex items-center gap-2.5 rounded-[18px] border border-border/60 bg-background/90 p-3 shadow-sm shadow-black/[0.02]"
>
<div className="flex h-10 w-10 shrink-0 items-center justify-center rounded-lg bg-muted/45 text-muted-foreground">
<FileIcon className="h-4.5 w-4.5" />
</div>
<div className="min-w-0 flex-1">
<div className="truncate text-sm font-medium text-foreground" title={item.attachment.filename}>
{item.attachment.filename}
</div>
<div className="mt-0.5 flex flex-wrap items-center gap-1.5">
<AttachmentMetadataLine className="min-w-0 max-w-full" items={[item.fileTypeLabel, item.fileSizeLabel, item.createdLabel]} />
<AttachmentSourceChip memoName={item.memoName} />
</div>
</div>
<AttachmentOpenButton href={item.sourceUrl} />
</article>
))}
</div>
);
};
export const AttachmentAudioRows = ({ items }: { items: AttachmentLibraryListItem[] }) => {
return (
<div className="space-y-2.5">
{items.map((item) => (
<article
key={item.attachment.name}
className="rounded-[18px] border border-border/60 bg-background/90 p-2.5 shadow-sm shadow-black/[0.02]"
>
<AudioAttachmentItem
filename={item.attachment.filename}
sourceUrl={item.sourceUrl}
mimeType={item.attachment.type}
size={Number(item.attachment.size)}
/>
<div className="mt-2.5 flex items-center justify-between gap-2 border-t border-border/60 px-0.5 pt-2.5">
<div className="min-w-0 flex flex-wrap items-center gap-1.5">
<AttachmentMetadataLine className="min-w-0 max-w-full" items={[item.createdLabel]} />
<AttachmentSourceChip memoName={item.memoName} />
</div>
<AttachmentOpenButton href={item.sourceUrl} />
</div>
</article>
))}
</div>
);
};
export const AttachmentUnusedRows = ({ items }: { items: AttachmentLibraryListItem[] }) => {
return (
<div className="space-y-2.5">
{items.map((item) => (
<article
key={item.attachment.name}
className="flex items-center gap-2.5 rounded-[18px] border border-warning/30 bg-warning/5 p-3 shadow-sm shadow-black/[0.02]"
>
<AttachmentThumb item={item} className="h-10 w-10 shrink-0" />
<div className="min-w-0 flex-1">
<div className="truncate text-sm font-medium text-foreground" title={item.attachment.filename}>
{item.attachment.filename}
</div>
<div className="mt-0.5 flex flex-wrap items-center gap-1.5">
<AttachmentMetadataLine className="min-w-0 max-w-full" items={[item.fileTypeLabel, item.fileSizeLabel, item.createdLabel]} />
<AttachmentSourceChip unlinkedLabelKey="attachment-library.labels.not-linked" />
</div>
</div>
<AttachmentOpenButton className="text-warning/90 hover:text-warning" href={item.sourceUrl} />
</article>
))}
</div>
);
};
@@ -0,0 +1,57 @@
import { FileAudioIcon, FileStackIcon, ImageIcon } from "lucide-react";
import type { ComponentType } from "react";
import type { AttachmentLibraryTab } from "@/hooks/useAttachmentLibrary";
import { cn } from "@/lib/utils";
import { useTranslate } from "@/utils/i18n";
interface AttachmentLibraryEmptyStateProps {
className?: string;
tab: AttachmentLibraryTab;
}
const EMPTY_STATE_CONFIG: Record<
AttachmentLibraryTab,
{
descriptionKey: "attachment-library.empty.audio" | "attachment-library.empty.documents" | "attachment-library.empty.media";
icon: ComponentType<{ className?: string }>;
titleKey: "attachment-library.tabs.audio" | "attachment-library.tabs.documents" | "attachment-library.tabs.media";
}
> = {
audio: {
descriptionKey: "attachment-library.empty.audio",
icon: FileAudioIcon,
titleKey: "attachment-library.tabs.audio",
},
documents: {
descriptionKey: "attachment-library.empty.documents",
icon: FileStackIcon,
titleKey: "attachment-library.tabs.documents",
},
media: {
descriptionKey: "attachment-library.empty.media",
icon: ImageIcon,
titleKey: "attachment-library.tabs.media",
},
};
const AttachmentLibraryEmptyState = ({ className, tab }: AttachmentLibraryEmptyStateProps) => {
const t = useTranslate();
const { descriptionKey, icon: Icon, titleKey } = EMPTY_STATE_CONFIG[tab];
return (
<div
className={cn(
"flex min-h-[18rem] flex-col items-center justify-center rounded-[28px] border border-dashed border-border/70 bg-background/80 px-6 py-16 text-center",
className,
)}
>
<div className="flex h-16 w-16 items-center justify-center rounded-full bg-muted/45 text-muted-foreground">
<Icon className="h-7 w-7" />
</div>
<div className="mt-5 text-sm font-medium text-foreground">{t(titleKey)}</div>
<p className="mt-2 max-w-sm text-sm leading-6 text-muted-foreground">{t(descriptionKey)}</p>
</div>
);
};
export default AttachmentLibraryEmptyState;
@@ -0,0 +1,87 @@
import { ExternalLinkIcon } from "lucide-react";
import { Link } from "react-router-dom";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { cn } from "@/lib/utils";
import { useTranslate } from "@/utils/i18n";
interface AttachmentMetadataLineProps {
className?: string;
items: Array<string | undefined>;
}
interface AttachmentSourceChipProps {
memoName?: string;
unlinkedLabelKey?: "attachment-library.labels.not-linked" | "attachment-library.labels.unused";
}
interface AttachmentOpenButtonProps {
className?: string;
href: string;
}
export const AttachmentMetadataLine = ({ className, items }: AttachmentMetadataLineProps) => {
const visibleItems = items.filter((item): item is string => Boolean(item));
if (visibleItems.length === 0) {
return null;
}
return (
<div
className={cn(
"flex items-center gap-1.5 overflow-x-auto whitespace-nowrap text-xs text-muted-foreground [-ms-overflow-style:none] [scrollbar-width:none] [&::-webkit-scrollbar]:hidden",
className,
)}
>
{visibleItems.map((item, index) => (
<span key={`${item}-${index}`} className="contents">
{index > 0 && <span className="shrink-0 text-muted-foreground/50"></span>}
<span className="shrink-0">{item}</span>
</span>
))}
</div>
);
};
export const AttachmentSourceChip = ({
memoName,
unlinkedLabelKey = "attachment-library.labels.not-linked",
}: AttachmentSourceChipProps) => {
const t = useTranslate();
if (!memoName) {
return (
<Badge variant="warning" shape="pill" className="px-1.5 py-0.5 text-[11px]">
{t(unlinkedLabelKey)}
</Badge>
);
}
return (
<Link
to={`/${memoName}`}
className="inline-flex max-w-full items-center truncate rounded-full border border-border/60 bg-muted/30 px-1.5 py-0.5 text-[11px] text-muted-foreground hover:bg-muted/50"
>
<span className="truncate">{t("attachment-library.labels.memo")}</span>
</Link>
);
};
export const AttachmentOpenButton = ({ className, href }: AttachmentOpenButtonProps) => {
const t = useTranslate();
return (
<Button
asChild
variant="ghost"
size="icon"
className={cn("size-7 shrink-0 rounded-full text-muted-foreground hover:text-foreground", className)}
>
<a href={href} target="_blank" rel="noreferrer">
<ExternalLinkIcon className="h-3.5 w-3.5" />
<span className="sr-only">{t("attachment-library.actions.open")}</span>
</a>
</Button>
);
};
@@ -0,0 +1,77 @@
import { LoaderCircleIcon } from "lucide-react";
import { Button } from "@/components/ui/button";
import { useTranslate } from "@/utils/i18n";
interface AttachmentLibraryErrorStateProps {
error?: Error;
onRetry: () => void;
}
interface AttachmentLibrarySkeletonGridProps {
count?: number;
}
interface AttachmentLibraryUnusedPanelProps {
count: number;
isDeleting: boolean;
isExpanded: boolean;
onDelete: () => void;
onToggle: () => void;
}
export const AttachmentLibrarySkeletonGrid = ({ count = 8 }: AttachmentLibrarySkeletonGridProps) => {
return (
<div className="grid grid-cols-2 gap-3 sm:gap-4 lg:grid-cols-3 xl:grid-cols-4">
{Array.from({ length: count }).map((_, index) => (
<div key={index} className="overflow-hidden rounded-[20px] border border-border/60 bg-background/90">
<div className="aspect-[5/4] animate-pulse bg-muted/50" />
<div className="space-y-2.5 p-3">
<div className="h-4 w-2/3 animate-pulse rounded bg-muted/50" />
<div className="h-3 w-1/2 animate-pulse rounded bg-muted/40" />
<div className="h-7 w-full animate-pulse rounded bg-muted/40" />
</div>
</div>
))}
</div>
);
};
export const AttachmentLibraryErrorState = ({ error, onRetry }: AttachmentLibraryErrorStateProps) => {
const t = useTranslate();
return (
<div className="rounded-[20px] border border-destructive/30 bg-destructive/5 p-6 text-center">
<p className="text-sm text-muted-foreground">{error?.message ?? t("attachment-library.errors.load")}</p>
<Button className="mt-4 rounded-full" onClick={onRetry}>
{t("attachment-library.actions.retry")}
</Button>
</div>
);
};
export const AttachmentLibraryUnusedPanel = ({ count, isDeleting, isExpanded, onDelete, onToggle }: AttachmentLibraryUnusedPanelProps) => {
const t = useTranslate();
return (
<div className="rounded-2xl border border-warning/30 bg-warning/5 p-4">
<div className="flex flex-col gap-3 md:flex-row md:items-center md:justify-between">
<div className="min-w-0">
<div className="text-sm font-medium text-foreground">
{t("attachment-library.unused.title")} ({count})
</div>
<p className="mt-1 text-sm text-muted-foreground">{t("attachment-library.unused.description")}</p>
</div>
<div className="flex flex-wrap items-center gap-2">
<Button type="button" variant="outline" className="rounded-full border-warning/40 bg-background/80 px-3" onClick={onToggle}>
{isExpanded ? t("common.close") : t("attachment-library.labels.unused")}
</Button>
<Button variant="destructive" className="rounded-full" onClick={onDelete} disabled={isDeleting}>
{isDeleting ? <LoaderCircleIcon className="h-4 w-4 animate-spin" /> : null}
{t("resource.delete-all-unused")}
</Button>
</div>
</div>
</div>
);
};
@@ -0,0 +1,64 @@
import { FileAudioIcon, FileStackIcon, ImageIcon } from "lucide-react";
import type { ComponentType } from "react";
import { Button } from "@/components/ui/button";
import type { AttachmentLibraryStats, AttachmentLibraryTab } from "@/hooks/useAttachmentLibrary";
import { cn } from "@/lib/utils";
import { useTranslate } from "@/utils/i18n";
interface AttachmentLibraryToolbarProps {
activeTab: AttachmentLibraryTab;
onTabChange: (tab: AttachmentLibraryTab) => void;
stats: AttachmentLibraryStats;
}
const TAB_CONFIG: Array<{
key: AttachmentLibraryTab;
labelKey: "media" | "documents" | "audio";
icon: ComponentType<{ className?: string }>;
count: (stats: AttachmentLibraryStats) => number;
}> = [
{ key: "media", labelKey: "media", icon: ImageIcon, count: (stats) => stats.media },
{ key: "audio", labelKey: "audio", icon: FileAudioIcon, count: (stats) => stats.audio },
{ key: "documents", labelKey: "documents", icon: FileStackIcon, count: (stats) => stats.documents },
];
const AttachmentLibraryToolbar = ({ activeTab, onTabChange, stats }: AttachmentLibraryToolbarProps) => {
const t = useTranslate();
return (
<div className="-mx-1 overflow-x-auto px-1 [-ms-overflow-style:none] [scrollbar-width:none] [&::-webkit-scrollbar]:hidden">
<div className="flex min-w-max items-center gap-1.5">
{TAB_CONFIG.map((tab) => {
const Icon = tab.icon;
const isActive = activeTab === tab.key;
return (
<Button
key={tab.key}
type="button"
variant="ghost"
className={cn(
"h-9 rounded-md px-2.5 text-sm font-medium sm:px-3",
isActive ? "bg-muted/60 text-foreground shadow-none" : "text-muted-foreground hover:bg-muted/40 hover:text-foreground",
)}
onClick={() => onTabChange(tab.key)}
>
<Icon className="h-4 w-4" />
<span>{t(`attachment-library.tabs.${tab.labelKey}`)}</span>
<span
className={cn(
"rounded-full px-1.5 py-0.5 text-[11px]",
isActive ? "bg-background text-muted-foreground" : "bg-muted/50 text-muted-foreground",
)}
>
{tab.count(stats)}
</span>
</Button>
);
})}
</div>
</div>
);
};
export default AttachmentLibraryToolbar;
@@ -0,0 +1,97 @@
import { PlayIcon } from "lucide-react";
import MotionPhotoPreview from "@/components/MotionPhotoPreview";
import { Badge } from "@/components/ui/badge";
import VideoPoster from "@/components/VideoPoster";
import type { AttachmentLibraryMediaItem, AttachmentLibraryMonthGroup } from "@/hooks/useAttachmentLibrary";
import { useTranslate } from "@/utils/i18n";
import { AttachmentMetadataLine, AttachmentOpenButton } from "./AttachmentLibraryPrimitives";
interface AttachmentMediaGridProps {
groups: AttachmentLibraryMonthGroup[];
onPreview: (itemId: string) => void;
}
const AttachmentMediaCard = ({ item, onPreview }: { item: AttachmentLibraryMediaItem; onPreview: () => void }) => {
const t = useTranslate();
return (
<article className="overflow-hidden rounded-[20px] border border-border/60 bg-background/90 shadow-sm shadow-black/[0.03]">
<div className="relative block w-full cursor-pointer text-left" onClick={onPreview}>
<div className="relative aspect-[5/4] overflow-hidden bg-muted/40">
{item.kind === "video" ? (
<>
<VideoPoster
sourceUrl={item.sourceUrl}
posterUrl={item.posterUrl}
alt={item.filename}
className="h-full w-full object-cover"
/>
<div className="absolute inset-0 bg-linear-to-t from-black/35 via-black/5 to-transparent" />
<span className="absolute bottom-2.5 right-2.5 inline-flex h-8 w-8 items-center justify-center rounded-full bg-background/85 text-foreground shadow-sm backdrop-blur-sm">
<PlayIcon className="h-3.5 w-3.5 fill-current" />
</span>
</>
) : item.kind === "motion" ? (
<MotionPhotoPreview
posterUrl={item.posterUrl}
motionUrl={item.previewItem.kind === "motion" ? item.previewItem.motionUrl : item.sourceUrl}
alt={item.filename}
presentationTimestampUs={item.previewItem.kind === "motion" ? item.previewItem.presentationTimestampUs : undefined}
containerClassName="h-full w-full"
mediaClassName="h-full w-full object-cover"
badgeClassName="left-3 top-3"
/>
) : (
<img src={item.posterUrl} alt={item.filename} className="h-full w-full object-cover" loading="lazy" decoding="async" />
)}
</div>
</div>
<div className="flex flex-col gap-2 p-3">
<div className="flex items-start justify-between gap-2">
<div className="min-w-0 truncate text-sm font-medium leading-5 text-foreground" title={item.filename}>
{item.filename}
</div>
{item.kind === "motion" && (
<Badge variant="outline" className="rounded-full border-border/60 bg-background/70 px-1.5 py-0.5 text-[11px]">
{t("attachment-library.labels.live")}
</Badge>
)}
</div>
<div className="flex items-center justify-between gap-2">
<AttachmentMetadataLine
className="min-w-0 flex-1"
items={[item.fileTypeLabel, item.createdLabel !== "—" ? item.createdLabel : undefined]}
/>
<AttachmentOpenButton href={item.sourceUrl} />
</div>
</div>
</article>
);
};
const AttachmentMediaGrid = ({ groups, onPreview }: AttachmentMediaGridProps) => {
return (
<div className="flex flex-col gap-6 sm:gap-8">
{groups.map((group) => (
<section key={group.key} className="space-y-2.5 sm:space-y-3">
<div className="flex items-center gap-2.5">
<div className="text-xs font-medium uppercase tracking-[0.24em] text-muted-foreground">{group.label}</div>
<div className="h-px flex-1 bg-border/70" />
</div>
<div className="grid grid-cols-2 gap-3 sm:gap-4 lg:grid-cols-3 xl:grid-cols-4">
{group.items.map((item) => (
<AttachmentMediaCard key={item.id} item={item} onPreview={() => onPreview(item.previewItem.id)} />
))}
</div>
</section>
))}
</div>
);
};
export default AttachmentMediaGrid;
@@ -0,0 +1,6 @@
export { AttachmentAudioRows, AttachmentDocumentRows, AttachmentUnusedRows } from "./AttachmentFileRows";
export { default as AttachmentLibraryEmptyState } from "./AttachmentLibraryEmptyState";
export { AttachmentMetadataLine, AttachmentOpenButton, AttachmentSourceChip } from "./AttachmentLibraryPrimitives";
export { AttachmentLibraryErrorState, AttachmentLibrarySkeletonGrid, AttachmentLibraryUnusedPanel } from "./AttachmentLibraryStates";
export { default as AttachmentLibraryToolbar } from "./AttachmentLibraryToolbar";
export { default as AttachmentMediaGrid } from "./AttachmentMediaGrid";
+35
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("mx-auto mt-4 grid w-full max-w-xs grid-cols-2 gap-2", className)}>
<LocaleSelect value={currentLocale} onChange={handleLocaleChange} className="w-full" />
<ThemeSelect value={currentTheme} onValueChange={handleThemeChange} className="w-full" compact />
</div>
);
};
export default AuthFooter;
@@ -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.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;
+73
View File
@@ -0,0 +1,73 @@
import { ChevronDown, ChevronUp } from "lucide-react";
import { type ReactNode, useEffect, useRef, useState } from "react";
import { cn } from "@/lib/utils";
import { useTranslate } from "@/utils/i18n";
// A collapsed section shows this much content; anything taller folds behind a fade.
export const CLAMP_PREVIEW_HEIGHT_PX = 360;
// Only fold when content is taller than this, so cards barely over the preview aren't
// clamped for the sake of a few hidden pixels.
export const CLAMP_TRIGGER_HEIGHT_PX = 420;
interface ClampedSectionProps {
/** When false, children render untouched with no measurement. */
enabled: boolean;
children: ReactNode;
}
/**
* The one truncation mechanism for compact cards: measure the content, and when it is
* tall enough, collapse it to a fixed-height preview with a fade and a Show more/less
* toggle. The inner div is never clamped, so observing it keeps the measurement live
* while images and embeds load.
*/
const ClampedSection = ({ enabled, children }: ClampedSectionProps) => {
const t = useTranslate();
const measureRef = useRef<HTMLDivElement>(null);
const [clamped, setClamped] = useState(false);
const [expanded, setExpanded] = useState(false);
useEffect(() => {
const el = measureRef.current;
if (!enabled || !el) {
setClamped(false);
return;
}
const check = () => setClamped(el.offsetHeight > CLAMP_TRIGGER_HEIGHT_PX);
check();
if (typeof ResizeObserver === "undefined") return;
const observer = new ResizeObserver(check);
observer.observe(el);
return () => observer.disconnect();
}, [enabled]);
const collapsed = clamped && !expanded;
return (
<>
<div
className={cn("relative w-full", collapsed && "overflow-hidden")}
style={collapsed ? { maxHeight: CLAMP_PREVIEW_HEIGHT_PX } : undefined}
>
<div ref={measureRef} className="w-full flex flex-col justify-start items-start gap-2">
{children}
</div>
{collapsed && (
<div className="pointer-events-none absolute inset-x-0 bottom-0 h-12 bg-linear-to-t from-card from-0% via-card/60 via-40% to-transparent to-100%" />
)}
</div>
{clamped && (
<button
type="button"
className="inline-flex items-center gap-1 px-2 py-1 text-xs text-muted-foreground hover:text-foreground transition-colors"
onClick={() => setExpanded((prev) => !prev)}
>
<span>{t(collapsed ? "memo.show-more" : "memo.show-less")}</span>
{collapsed ? <ChevronDown className="w-3 h-3" /> : <ChevronUp className="w-3 h-3" />}
</button>
)}
</>
);
};
export default ClampedSection;
@@ -0,0 +1,290 @@
import { type ReactNode, useCallback, useEffect, useLayoutEffect, useRef, useState } from "react";
interface ColumnGridProps<T> {
items: T[];
/** Stable identity for each item; also used as the React key. */
getKey: (item: T) => string;
renderItem: (item: T) => ReactNode;
/** Estimated rendered height in px, used only to choose a column deterministically. */
estimateHeight?: (item: T, context: { columnWidth: number }) => number;
/** Optional node packed as the very first tile (e.g. the note composer). */
leading?: ReactNode;
/** Key that must land at the top of column one (e.g. a just-created memo), not the shortest column. */
priorityKey?: string;
/** Upper bound on the column count; 0 or undefined means as many as fit. */
maxColumns?: number;
/** Cap on each column's width in px; leftover space centers the grid. */
maxColumnWidth?: number;
}
const LEADING_KEY = "__grid_leading__";
const GRID_MIN_COLUMN_WIDTH = 260;
export const GRID_GAP = 12;
// The single source of truth for how many columns fit a given width. Callers use it to detect a
// one-column layout and fall back to a plain flow list instead of a degenerate one-column grid.
export const columnCountForWidth = (width: number): number =>
Math.max(1, Math.floor((width + GRID_GAP) / (GRID_MIN_COLUMN_WIDTH + GRID_GAP)));
const shortestColumn = (heights: number[]): number => {
let index = 0;
for (let i = 1; i < heights.length; i++) {
if (heights[i] < heights[index]) {
index = i;
}
}
return index;
};
export const assignColumnsByEstimatedHeight = ({
keys,
columnCount,
getEstimatedHeight,
pinnedKeys,
}: {
keys: string[];
columnCount: number;
getEstimatedHeight: (key: string) => number;
pinnedKeys?: ReadonlySet<string>;
}): Map<string, number> => {
const totals = new Array<number>(columnCount).fill(0);
const columns = new Map<string, number>();
for (const key of keys) {
const column = pinnedKeys?.has(key) ? 0 : shortestColumn(totals);
columns.set(key, column);
totals[column] += Math.max(0, getEstimatedHeight(key));
}
return columns;
};
/**
* Absolute-positioned column grid (Google-Keep-style packing). Cards keep their document
* order and are only translated into place, so appending pages or reordering the list
* never remounts a card — preserving its state and avoiding flashes.
*
* Columns are assigned from deterministic estimated heights. Real measured heights only decide
* each card's final y offset inside its assigned column, so late image/comment growth moves cards
* down within that column without reshuffling cards across columns.
*/
function ColumnGrid<T>({
items,
getKey,
renderItem,
estimateHeight,
leading,
priorityKey,
maxColumns,
maxColumnWidth,
}: ColumnGridProps<T>) {
const containerRef = useRef<HTMLDivElement>(null);
const itemRefs = useRef<Map<string, HTMLDivElement>>(new Map());
const refCallbacks = useRef<Map<string, (el: HTMLDivElement | null) => void>>(new Map());
const resizeObserverRef = useRef<ResizeObserver | null>(null);
const rafRef = useRef<number | null>(null);
const lastWidthRef = useRef(0);
const [containerHeight, setContainerHeight] = useState<number | undefined>(undefined);
// Measure each card once (widths written in one pass, heights read in the next so the
// browser reflows once), assign cards by estimated height, then translate every card to its
// column's measured running offset.
const relayout = useCallback(() => {
const container = containerRef.current;
if (!container) return;
const width = container.clientWidth;
// What fits (each column >= the minimum width), then the user's max-columns ceiling on top.
const fit = columnCountForWidth(width);
const count = maxColumns && maxColumns > 0 ? Math.min(fit, maxColumns) : fit;
// Whole-pixel columns that fill the row, clamped to maxColumnWidth so few columns on a
// wide screen stay readable instead of stretching.
let columnWidth = count > 1 ? Math.floor((width - GRID_GAP * (count - 1)) / count) : width;
if (maxColumnWidth != null) columnWidth = Math.min(columnWidth, maxColumnWidth);
// Center the packed columns in whatever width the clamp leaves over.
const offsetX = Math.floor((width - (columnWidth * count + GRID_GAP * (count - 1))) / 2);
// Ordered by feed position: the leading tile (composer) first, then items.
const ordered: { key: string; el: HTMLDivElement }[] = [];
const leadingEl = itemRefs.current.get(LEADING_KEY);
if (leadingEl) ordered.push({ key: LEADING_KEY, el: leadingEl });
const itemByKey = new Map<string, T>();
for (const item of items) {
const key = getKey(item);
itemByKey.set(key, item);
const el = itemRefs.current.get(key);
if (el) ordered.push({ key, el });
}
// Pass 1 (writes): fix each card's width and drop its own bottom margin so spacing is
// owned entirely by `gap`. `firstElementChild` is the item's rendered root. Items are
// expected to bound their own height — the grid never clips content itself.
for (const { el } of ordered) {
el.style.width = `${columnWidth}px`;
const child = el.firstElementChild;
if (child instanceof HTMLElement) {
child.style.marginBottom = "0px";
}
}
// Pass 2 (reads): height of every card, measured once after the width writes so the
// browser reflows a single time. We read the inner element, not the absolutely-positioned
// wrapper (whose block-formatting context would fold in margins).
const heightByKey = new Map<string, number>();
for (const { key, el } of ordered) {
const child = el.firstElementChild;
heightByKey.set(key, child instanceof HTMLElement ? child.offsetHeight : el.offsetHeight);
}
const heightOf = (key: string) => heightByKey.get(key) ?? 0;
const pinnedKeys = new Set<string>([LEADING_KEY]);
if (priorityKey) {
pinnedKeys.add(priorityKey);
}
const columnOf = assignColumnsByEstimatedHeight({
keys: ordered.map((entry) => entry.key),
columnCount: count,
pinnedKeys,
getEstimatedHeight: (key) => {
const item = itemByKey.get(key);
return item && estimateHeight ? estimateHeight(item, { columnWidth }) : heightOf(key);
},
});
const columnY = new Array<number>(count).fill(0);
const pos = new Map<string, { x: number; y: number }>();
for (const { key } of ordered) {
const col = columnOf.get(key) ?? 0;
const x = offsetX + col * (columnWidth + GRID_GAP);
const y = columnY[col];
pos.set(key, { x, y });
columnY[col] = y + heightOf(key) + GRID_GAP;
}
// Apply the chosen positions without transitions. Relayouts may be caused by late media or
// comment preview height changes, and those should never visibly animate cards across the wall.
for (const { key, el } of ordered) {
const target = pos.get(key);
if (!target) continue;
// The leading tile (the note composer) is positioned with left/top rather than a
// transform so it never becomes the containing block for its own position:fixed
// descendants. A transform (or will-change:transform) here would trap the editor's
// focus-mode overlay — which is meant to cover the viewport — inside this column tile.
// The leading tile is pinned to column one's top and only shifts horizontally on
// resize (which snaps anyway), so it loses no animation by skipping the transform.
if (key === LEADING_KEY) {
el.style.transition = "none";
el.style.transform = "";
el.style.left = `${target.x}px`;
el.style.top = `${target.y}px`;
continue;
}
el.style.transition = "none";
el.style.transform = `translate3d(${target.x}px, ${target.y}px, 0)`;
}
setContainerHeight(Math.max(0, ...columnY.map((h) => h - GRID_GAP)));
}, [items, getKey, estimateHeight, priorityKey, maxColumns, maxColumnWidth]);
// Keep a stable reference so observer callbacks always run the latest layout.
const relayoutRef = useRef(relayout);
relayoutRef.current = relayout;
const scheduleRelayout = useCallback(() => {
if (rafRef.current != null) return;
rafRef.current = requestAnimationFrame(() => {
rafRef.current = null;
relayoutRef.current();
});
}, []);
// Re-layout on container resize (sidebar toggles, window resize, breakpoints).
// Only width matters — height changes are our own (we set the container height),
// so ignoring them avoids a feedback loop of redundant re-packs.
useEffect(() => {
const container = containerRef.current;
if (!container || typeof ResizeObserver === "undefined") return;
lastWidthRef.current = container.clientWidth;
const observer = new ResizeObserver((entries) => {
const nextWidth = Math.round(entries[0]?.contentRect.width ?? container.clientWidth);
if (nextWidth !== lastWidthRef.current) {
lastWidthRef.current = nextWidth;
scheduleRelayout();
}
});
observer.observe(container);
return () => observer.disconnect();
}, [scheduleRelayout]);
// A single observer watches every card so late-loading images (which change a
// card's height) trigger a re-pack.
useEffect(() => {
if (typeof ResizeObserver === "undefined") return;
const observer = new ResizeObserver(() => scheduleRelayout());
resizeObserverRef.current = observer;
for (const el of itemRefs.current.values()) observer.observe(el);
return () => {
observer.disconnect();
resizeObserverRef.current = null;
};
}, [scheduleRelayout]);
// Re-pack after DOM updates (items added/removed/reordered), before paint.
useLayoutEffect(() => {
relayout();
}, [relayout]);
useEffect(() => {
return () => {
if (rafRef.current != null) cancelAnimationFrame(rafRef.current);
};
}, []);
const getItemRef = (key: string) => {
const cached = refCallbacks.current.get(key);
if (cached) return cached;
const callback = (el: HTMLDivElement | null) => {
const map = itemRefs.current;
const previous = map.get(key);
if (previous && resizeObserverRef.current) resizeObserverRef.current.unobserve(previous);
if (el) {
map.set(key, el);
resizeObserverRef.current?.observe(el);
} else {
map.delete(key);
refCallbacks.current.delete(key);
}
};
refCallbacks.current.set(key, callback);
return callback;
};
return (
<div ref={containerRef} className="relative w-full" style={{ height: containerHeight }}>
{leading != null && (
// Positioned with left/top (see relayout), and deliberately WITHOUT
// transform/will-change so it never establishes a containing block that would trap
// the composer's focus-mode overlay inside this tile.
<div key={LEADING_KEY} ref={getItemRef(LEADING_KEY)} className="absolute top-0 left-0">
{leading}
</div>
)}
{items.map((item) => {
const key = getKey(item);
return (
<div
key={key}
ref={getItemRef(key)}
className="absolute top-0 left-0 transition-transform duration-200 ease-out motion-reduce:transition-none"
style={{ willChange: "transform" }}
>
{renderItem(item)}
</div>
);
})}
</div>
);
}
export default ColumnGrid;
+4
View File
@@ -0,0 +1,4 @@
import ColumnGrid from "./ColumnGrid";
export { assignColumnsByEstimatedHeight, columnCountForWidth, GRID_GAP } from "./ColumnGrid";
export default ColumnGrid;
+131
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.
@@ -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>
);
}
@@ -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.create-dialog.duration-1m"),
value: 30,
},
{
label: "90 Days",
value: 90,
},
{
label: t("setting.access-token.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.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.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.create-dialog.description")} <span className="text-destructive">*</span>
</Label>
<Input
id="description"
type="text"
placeholder={t("setting.access-token.create-dialog.some-description")}
value={state.description}
onChange={handleDescriptionInputChange}
/>
</div>
<div className="grid gap-2">
<Label>
{t("setting.access-token.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;
@@ -0,0 +1,615 @@
import { create } from "@bufbuild/protobuf";
import { FieldMaskSchema } from "@bufbuild/protobuf/wkt";
import { type ReactNode, useEffect, useState } from "react";
import { toast } from "react-hot-toast";
import { Button } from "@/components/ui/button";
import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle } from "@/components/ui/dialog";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import { identityProviderServiceClient } from "@/connect";
import { absolutifyLink } from "@/lib/browser";
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 DEFAULT_TEMPLATE = "GitHub";
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;
}
interface BasicInfoState {
title: string;
identifier: string;
identifierFilter: string;
}
function createEmptyFieldMapping(): FieldMapping {
return create(FieldMappingSchema, {
identifier: "",
displayName: "",
email: "",
avatarUrl: "",
});
}
function createEmptyOAuth2Config(): OAuth2Config {
return create(OAuth2ConfigSchema, {
clientId: "",
clientSecret: "",
authUrl: "",
tokenUrl: "",
userInfoUrl: "",
scopes: [],
fieldMapping: createEmptyFieldMapping(),
});
}
function createEmptyBasicInfo(): BasicInfoState {
return {
title: "",
identifier: "",
identifierFilter: "",
};
}
function sanitizeIdentifier(value: string): string {
return value
.toLowerCase()
.replace(/[^a-z0-9-]/g, "-")
.replace(/--+/g, "-")
.replace(/^-+|-+$/g, "");
}
function normalizeScopes(value: string): string[] {
return value
.split(/\s+/)
.map((scope) => scope.trim())
.filter(Boolean);
}
function buildDialogStateFromTemplate(templateName: string) {
const template = templateList.find((item) => item.title === templateName) ?? templateList[0];
const oauth2Config =
template.type === IdentityProvider_Type.OAUTH2 && template.config?.config.case === "oauth2Config"
? create(OAuth2ConfigSchema, template.config.config.value)
: createEmptyOAuth2Config();
return {
basicInfo: {
title: template.title,
identifier: sanitizeIdentifier(template.title),
identifierFilter: template.identifierFilter,
},
type: template.type,
oauth2Config,
oauth2Scopes: oauth2Config.scopes.join(" "),
};
}
function buildDialogStateFromProvider(identityProvider: IdentityProvider) {
const oauth2Config =
identityProvider.type === IdentityProvider_Type.OAUTH2 && identityProvider.config?.config.case === "oauth2Config"
? create(OAuth2ConfigSchema, identityProvider.config.config.value)
: createEmptyOAuth2Config();
return {
basicInfo: {
title: identityProvider.title,
identifier: "",
identifierFilter: identityProvider.identifierFilter,
},
type: identityProvider.type,
oauth2Config,
oauth2Scopes: oauth2Config.scopes.join(" "),
};
}
function FormSection({ title, description, children }: { title: string; description?: string; children: ReactNode }) {
return (
<section className="space-y-4 rounded-lg border bg-muted/20 p-4">
<div className="space-y-1">
<h3 className="text-sm font-semibold text-foreground">{title}</h3>
{description ? <p className="text-xs text-muted-foreground">{description}</p> : null}
</div>
<div className="space-y-4">{children}</div>
</section>
);
}
function FormField({
label,
required = false,
description,
children,
}: {
label: string;
required?: boolean;
description?: string;
children: ReactNode;
}) {
return (
<div className="space-y-2">
<Label>
{label}
{required ? <span className="text-destructive">*</span> : null}
</Label>
{children}
{description ? <p className="text-xs text-muted-foreground">{description}</p> : null}
</div>
);
}
function CreateIdentityProviderDialog({ open, onOpenChange, identityProvider, onSuccess }: Props) {
const t = useTranslate();
const identityProviderTypes = [...new Set(templateList.map((template) => template.type))];
const [basicInfo, setBasicInfo] = useState<BasicInfoState>(createEmptyBasicInfo);
const [type, setType] = useState<IdentityProvider_Type>(IdentityProvider_Type.OAUTH2);
const [oauth2Config, setOAuth2Config] = useState<OAuth2Config>(createEmptyOAuth2Config);
const [oauth2Scopes, setOAuth2Scopes] = useState<string>("");
const [selectedTemplate, setSelectedTemplate] = useState<string>(DEFAULT_TEMPLATE);
const [isSubmitting, setIsSubmitting] = useState(false);
const isCreating = identityProvider === undefined;
const oauth2FieldMapping = oauth2Config.fieldMapping ?? createEmptyFieldMapping();
useEffect(() => {
if (!open) {
setSelectedTemplate(DEFAULT_TEMPLATE);
setBasicInfo(createEmptyBasicInfo());
setType(IdentityProvider_Type.OAUTH2);
setOAuth2Config(createEmptyOAuth2Config());
setOAuth2Scopes("");
setIsSubmitting(false);
return;
}
const nextState = isCreating ? buildDialogStateFromTemplate(selectedTemplate) : buildDialogStateFromProvider(identityProvider!);
setBasicInfo(nextState.basicInfo);
setType(nextState.type);
setOAuth2Config(nextState.oauth2Config);
setOAuth2Scopes(nextState.oauth2Scopes);
}, [open, isCreating, identityProvider, selectedTemplate]);
const handleDialogClose = (nextOpen: boolean) => {
if (isSubmitting && !nextOpen) {
return;
}
onOpenChange(nextOpen);
};
const handleCloseBtnClick = () => {
if (isSubmitting) {
return;
}
handleDialogClose(false);
};
const allowConfirmAction = () => {
if (basicInfo.title.trim() === "") {
return false;
}
if (isCreating && basicInfo.identifier.trim() === "") {
return false;
}
if (type === IdentityProvider_Type.OAUTH2) {
if (
oauth2Config.clientId.trim() === "" ||
oauth2Config.authUrl.trim() === "" ||
oauth2Config.tokenUrl.trim() === "" ||
oauth2Config.userInfoUrl.trim() === "" ||
normalizeScopes(oauth2Scopes).length === 0 ||
oauth2FieldMapping.identifier.trim() === ""
) {
return false;
}
if (isCreating && oauth2Config.clientSecret.trim() === "") {
return false;
}
}
return !isSubmitting;
};
const handleConfirmBtnClick = async () => {
setIsSubmitting(true);
const normalizedScopes = normalizeScopes(oauth2Scopes);
try {
if (isCreating) {
await identityProviderServiceClient.createIdentityProvider({
identityProviderId: basicInfo.identifier,
identityProvider: create(IdentityProviderSchema, {
title: basicInfo.title.trim(),
identifierFilter: basicInfo.identifierFilter.trim(),
type,
config: create(IdentityProviderConfigSchema, {
config: {
case: "oauth2Config",
value: {
...oauth2Config,
scopes: normalizedScopes,
},
},
}),
}),
});
toast.success(t("setting.sso.sso-created", { name: basicInfo.title }));
} else {
await identityProviderServiceClient.updateIdentityProvider({
identityProvider: create(IdentityProviderSchema, {
name: identityProvider!.name,
title: basicInfo.title.trim(),
identifierFilter: basicInfo.identifierFilter.trim(),
type,
config: create(IdentityProviderConfigSchema, {
config: {
case: "oauth2Config",
value: {
...oauth2Config,
scopes: normalizedScopes,
},
},
}),
}),
updateMask: create(FieldMaskSchema, { paths: ["title", "identifier_filter", "config"] }),
});
toast.success(t("setting.sso.sso-updated", { name: basicInfo.title }));
}
} catch (error: unknown) {
setIsSubmitting(false);
await handleError(error, toast.error, {
context: isCreating ? "Create identity provider" : "Update identity provider",
});
return;
}
setIsSubmitting(false);
onSuccess?.();
handleDialogClose(false);
};
const setPartialOAuth2Config = (state: Partial<OAuth2Config>) => {
setOAuth2Config((current) => ({
...current,
...state,
}));
};
const setPartialFieldMapping = (state: Partial<FieldMapping>) => {
setPartialOAuth2Config({
fieldMapping: {
...oauth2FieldMapping,
...state,
} as FieldMapping,
});
};
return (
<Dialog open={open} onOpenChange={handleDialogClose}>
<DialogContent size="2xl">
<DialogHeader>
<DialogTitle>{t(isCreating ? "setting.sso.create-sso" : "setting.sso.update-sso")}</DialogTitle>
<DialogDescription>
{t(isCreating ? "setting.sso.create-sso-description" : "setting.sso.update-sso-description")}
</DialogDescription>
</DialogHeader>
<div className="space-y-4">
<FormSection title={t("setting.sso.basic-settings")} description={t("setting.sso.basic-settings-description")}>
{isCreating ? (
<div className="grid gap-4 md:grid-cols-2">
<FormField label={t("common.type")} required>
<Select value={String(type)} onValueChange={(value) => setType(Number(value) as IdentityProvider_Type)}>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
{identityProviderTypes.map((kind) => (
<SelectItem key={kind} value={String(kind)}>
{IdentityProvider_Type[kind] || kind}
</SelectItem>
))}
</SelectContent>
</Select>
</FormField>
<FormField label={t("setting.sso.template")} required description={t("setting.sso.template-description")}>
<Select value={selectedTemplate} onValueChange={setSelectedTemplate}>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
{templateList.map((template) => (
<SelectItem key={template.title} value={template.title}>
{template.title}
</SelectItem>
))}
</SelectContent>
</Select>
</FormField>
</div>
) : null}
<div className="grid gap-4 md:grid-cols-2">
{isCreating ? (
<FormField label={t("setting.sso.provider-id")} required description={t("setting.sso.provider-id-description")}>
<Input
className="font-mono"
placeholder="e.g. github, okta-corp"
maxLength={32}
value={basicInfo.identifier}
onChange={(e) =>
setBasicInfo((current) => ({
...current,
identifier: sanitizeIdentifier(e.target.value),
}))
}
/>
</FormField>
) : null}
<FormField label={t("common.name")} required>
<Input
placeholder={t("common.name")}
value={basicInfo.title}
onChange={(e) =>
setBasicInfo((current) => ({
...current,
title: e.target.value,
}))
}
/>
</FormField>
</div>
<FormField label={t("setting.sso.identifier-filter")} description={t("setting.sso.identifier-filter-description")}>
<Input
placeholder={t("setting.sso.identifier-filter")}
value={basicInfo.identifierFilter}
onChange={(e) =>
setBasicInfo((current) => ({
...current,
identifierFilter: e.target.value,
}))
}
/>
</FormField>
</FormSection>
{type === IdentityProvider_Type.OAUTH2 ? (
<>
<FormSection title={t("setting.sso.oauth-configuration")} description={t("setting.sso.oauth-configuration-description")}>
<div className="rounded-md border bg-background px-3 py-3">
<p className="text-xs font-medium uppercase tracking-wide text-muted-foreground">{t("setting.sso.redirect-url")}</p>
<p className="mt-2 break-all font-mono text-xs text-foreground sm:text-sm">{absolutifyLink("/auth/callback")}</p>
<p className="mt-2 text-xs text-muted-foreground">{t("setting.sso.redirect-url-description")}</p>
</div>
<div className="grid gap-4 md:grid-cols-2">
<FormField label={t("setting.sso.client-id")} required>
<Input
placeholder={t("setting.sso.client-id")}
value={oauth2Config.clientId}
onChange={(e) => setPartialOAuth2Config({ clientId: e.target.value })}
/>
</FormField>
<FormField
label={t("setting.sso.client-secret")}
required={isCreating}
description={isCreating ? undefined : t("setting.sso.client-secret-optional-description")}
>
<Input
type="password"
autoComplete="off"
placeholder={t("setting.sso.client-secret")}
value={oauth2Config.clientSecret}
onChange={(e) => setPartialOAuth2Config({ clientSecret: e.target.value })}
/>
</FormField>
</div>
<div className="grid gap-4 md:grid-cols-2">
<FormField label={t("setting.sso.authorization-endpoint")} required>
<Input
placeholder={t("setting.sso.authorization-endpoint")}
value={oauth2Config.authUrl}
onChange={(e) => setPartialOAuth2Config({ authUrl: e.target.value })}
/>
</FormField>
<FormField label={t("setting.sso.token-endpoint")} required>
<Input
placeholder={t("setting.sso.token-endpoint")}
value={oauth2Config.tokenUrl}
onChange={(e) => setPartialOAuth2Config({ tokenUrl: e.target.value })}
/>
</FormField>
</div>
<div className="grid gap-4 md:grid-cols-2">
<FormField label={t("setting.sso.user-endpoint")} required>
<Input
placeholder={t("setting.sso.user-endpoint")}
value={oauth2Config.userInfoUrl}
onChange={(e) => setPartialOAuth2Config({ userInfoUrl: e.target.value })}
/>
</FormField>
<FormField label={t("setting.sso.scopes")} required description={t("setting.sso.scopes-description")}>
<Input placeholder={t("setting.sso.scopes")} value={oauth2Scopes} onChange={(e) => setOAuth2Scopes(e.target.value)} />
</FormField>
</div>
</FormSection>
<FormSection title={t("setting.sso.field-mapping")} description={t("setting.sso.field-mapping-description")}>
<div className="grid gap-4 md:grid-cols-2">
<FormField
label={t("setting.sso.identifier")}
required
description={t("setting.sso.field-mapping-identifier-description")}
>
<Input
placeholder={t("setting.sso.identifier")}
value={oauth2FieldMapping.identifier}
onChange={(e) => setPartialFieldMapping({ identifier: e.target.value })}
/>
</FormField>
<FormField label={t("setting.sso.display-name")}>
<Input
placeholder={t("setting.sso.display-name")}
value={oauth2FieldMapping.displayName}
onChange={(e) => setPartialFieldMapping({ displayName: e.target.value })}
/>
</FormField>
</div>
<div className="grid gap-4 md:grid-cols-2">
<FormField label={t("common.email")}>
<Input
placeholder={t("common.email")}
value={oauth2FieldMapping.email}
onChange={(e) => setPartialFieldMapping({ email: e.target.value })}
/>
</FormField>
<FormField label={t("setting.sso.avatar-url")}>
<Input
placeholder={t("setting.sso.avatar-url")}
value={oauth2FieldMapping.avatarUrl}
onChange={(e) => setPartialFieldMapping({ avatarUrl: e.target.value })}
/>
</FormField>
</div>
</FormSection>
</>
) : null}
</div>
<DialogFooter>
<Button variant="ghost" onClick={handleCloseBtnClick} disabled={isSubmitting}>
{t("common.cancel")}
</Button>
<Button onClick={handleConfirmBtnClick} disabled={!allowConfirmAction()}>
{t(isCreating ? "common.create" : "common.update")}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}
export default CreateIdentityProviderDialog;
+150
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.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.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;
+281
View File
@@ -0,0 +1,281 @@
import { create } from "@bufbuild/protobuf";
import { FieldMaskSchema } from "@bufbuild/protobuf/wkt";
import copy from "copy-to-clipboard";
import { CheckIcon, CopyIcon, EyeIcon } from "lucide-react";
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 isCreating = webhookName === undefined;
const [state, setState] = useState<State>({ displayName: "", url: "" });
const requestState = useLoading(false);
const secretState = useLoading(false);
const [hasExistingSecret, setHasExistingSecret] = useState(false);
const [revealedSecret, setRevealedSecret] = useState<string | undefined>(undefined);
const [createdSecret, setCreatedSecret] = useState<string | undefined>(undefined);
const [secretCopied, setSecretCopied] = useState(false);
useEffect(() => {
if (webhookName && currentUser) {
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 });
setHasExistingSecret(webhook.signingSecretSet);
}
});
}
}, [webhookName, currentUser]);
useEffect(() => {
if (open && isCreating) {
setState({ displayName: "", url: "" });
setHasExistingSecret(false);
setRevealedSecret(undefined);
setCreatedSecret(undefined);
setSecretCopied(false);
}
}, [open, isCreating]);
const handleTitleInputChange = (e: React.ChangeEvent<HTMLInputElement>) => {
setState((prev) => ({ ...prev, displayName: e.target.value }));
};
const handleUrlInputChange = (e: React.ChangeEvent<HTMLInputElement>) => {
setState((prev) => ({ ...prev, url: e.target.value }));
};
const handleCopySecret = (secret: string) => {
copy(secret);
setSecretCopied(true);
setTimeout(() => setSecretCopied(false), 2000);
};
const handleRevealSecret = async () => {
if (!webhookName) return;
try {
secretState.setLoading();
const { signingSecret } = await userServiceClient.getUserWebhookSigningSecret({ name: webhookName });
setRevealedSecret(signingSecret);
secretState.setFinish();
} catch (error: unknown) {
handleError(error, toast.error, { context: "Reveal signing secret", onError: () => secretState.setError() });
}
};
// Lets a pre-existing webhook that has no secret adopt one. The secret is
// persisted immediately and revealed so the user can copy it once.
const handleGenerateSecret = async () => {
if (!webhookName) return;
const bytes = crypto.getRandomValues(new Uint8Array(32));
const secret = "whsec_" + btoa(String.fromCharCode(...bytes));
try {
secretState.setLoading();
await userServiceClient.updateUserWebhook({
webhook: { name: webhookName, signingSecret: secret },
updateMask: create(FieldMaskSchema, { paths: ["signing_secret"] }),
});
setHasExistingSecret(true);
setRevealedSecret(secret);
handleCopySecret(secret);
secretState.setFinish();
} catch (error: unknown) {
handleError(error, toast.error, { context: "Generate signing secret", onError: () => secretState.setError() });
}
};
// When closing after a successful create, refresh the parent list regardless of how the dialog is dismissed.
const handleOpenChange = (next: boolean) => {
if (!next && createdSecret !== undefined) {
onSuccess?.();
}
onOpenChange(next);
};
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) {
// The signing secret is generated server-side; reveal it once so the user can copy it without reopening.
const created = await userServiceClient.createUserWebhook({
parent: currentUser.name,
webhook: { displayName: state.displayName, url: state.url },
});
let secret: string | undefined;
try {
const response = await userServiceClient.getUserWebhookSigningSecret({ name: created.name });
secret = response.signingSecret;
} catch {
// Reveal failed — the secret is still set and can be revealed later from the edit dialog.
}
requestState.setFinish();
if (secret !== undefined) {
setCreatedSecret(secret);
return;
}
onSuccess?.();
onOpenChange(false);
return;
}
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(),
});
}
};
const renderSecretField = (secret: string) => (
<div className="flex items-center gap-2">
<Input readOnly type="text" value={secret} className="flex-1 font-mono text-xs" />
<Button
type="button"
variant="outline"
size="icon"
onClick={() => handleCopySecret(secret)}
aria-label={t("setting.webhook.create-dialog.copy-secret")}
>
{secretCopied ? <CheckIcon className="h-4 w-4 text-success" /> : <CopyIcon className="h-4 w-4" />}
</Button>
</div>
);
const renderSigningSecret = () => {
if (isCreating) {
return <span className="text-xs text-muted-foreground">{t("setting.webhook.create-dialog.signing-secret-auto-note")}</span>;
}
if (revealedSecret !== undefined) {
return renderSecretField(revealedSecret);
}
if (hasExistingSecret) {
return (
<div className="flex items-center gap-2">
<span className="flex-1 text-xs text-muted-foreground">{t("setting.webhook.create-dialog.signing-secret-configured")}</span>
<Button type="button" variant="outline" size="sm" onClick={handleRevealSecret} disabled={secretState.isLoading}>
<EyeIcon className="mr-1 h-3.5 w-3.5" />
{t("setting.webhook.create-dialog.reveal-secret")}
</Button>
</div>
);
}
return (
<div className="flex items-center gap-2">
<span className="flex-1 text-xs text-muted-foreground">{t("setting.webhook.create-dialog.signing-secret-not-configured")}</span>
<Button type="button" variant="outline" size="sm" onClick={handleGenerateSecret} disabled={secretState.isLoading}>
{t("setting.webhook.create-dialog.generate-secret")}
</Button>
</div>
);
};
return (
<Dialog open={open} onOpenChange={handleOpenChange}>
<DialogContent className="max-w-md">
<DialogHeader>
<DialogTitle>
{isCreating ? t("setting.webhook.create-dialog.create-webhook") : t("setting.webhook.create-dialog.edit-webhook")}
</DialogTitle>
</DialogHeader>
{createdSecret !== undefined ? (
<div className="grid gap-2">
<Label>{t("setting.webhook.create-dialog.signing-secret")}</Label>
<span className="text-xs text-muted-foreground">{t("setting.webhook.create-dialog.signing-secret-created-note")}</span>
{renderSecretField(createdSecret)}
</div>
) : (
<div className="flex flex-col gap-4">
<div className="grid gap-2">
<Label htmlFor="displayName">
{t("setting.webhook.create-dialog.title")} <span className="text-destructive">*</span>
</Label>
<Input
id="displayName"
type="text"
placeholder={t("setting.webhook.create-dialog.an-easy-to-remember-name")}
value={state.displayName}
onChange={handleTitleInputChange}
/>
</div>
<div className="grid gap-2">
<Label htmlFor="url">
{t("setting.webhook.create-dialog.payload-url")} <span className="text-destructive">*</span>
</Label>
<Input
id="url"
type="text"
placeholder={t("setting.webhook.create-dialog.url-example-post-receive")}
value={state.url}
onChange={handleUrlInputChange}
/>
</div>
<div className="grid gap-2">
<Label>{t("setting.webhook.create-dialog.signing-secret")}</Label>
{renderSigningSecret()}
</div>
</div>
)}
<DialogFooter>
{createdSecret !== undefined ? (
<Button onClick={() => handleOpenChange(false)}>{t("common.close")}</Button>
) : (
<>
<Button variant="ghost" disabled={requestState.isLoading} onClick={() => onOpenChange(false)}>
{t("common.cancel")}
</Button>
<Button disabled={requestState.isLoading} onClick={handleSaveBtnClick}>
{isCreating ? t("common.create") : t("common.save")}
</Button>
</>
)}
</DialogFooter>
</DialogContent>
</Dialog>
);
}
export default CreateWebhookDialog;
+43
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;
+103
View File
@@ -0,0 +1,103 @@
import { AlertCircle, RefreshCw } from "lucide-react";
import { Component, type ErrorInfo, type ReactNode } from "react";
import { useRouteError } from "react-router-dom";
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;
}
}
// React Router errorElement for route-level errors (e.g., failed chunk loads after redeployment).
export function ChunkLoadErrorFallback() {
const error = useRouteError() as Error | undefined;
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 an application update. Reloading usually fixes it.
</p>
{error?.message && (
<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">{error.message}</pre>
</details>
)}
<Button onClick={() => window.location.reload()} className="w-full gap-2">
<RefreshCw className="w-4 h-4" />
Reload Application
</Button>
</div>
</div>
);
}
@@ -0,0 +1,158 @@
import { create } from "@bufbuild/protobuf";
import { FieldMaskSchema, timestampDate } from "@bufbuild/protobuf/wkt";
import { CheckIcon, MessageCircleIcon, TrashIcon, XIcon } from "lucide-react";
import toast from "react-hot-toast";
import UserAvatar from "@/components/UserAvatar";
import { userServiceClient } from "@/connect";
import useNavigateTo from "@/hooks/useNavigateTo";
import { cn } from "@/lib/utils";
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 commentPayload = notification.payload?.case === "memoComment" ? notification.payload.value : undefined;
const sender = notification.senderUser;
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 (!commentPayload) {
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;
const handleNavigateToMemo = async () => {
navigateTo(`/${commentPayload.relatedMemo}`);
if (isUnread) {
await handleArchiveMessage(true);
}
};
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",
)}
>
{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">
<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>
<div className="flex-1 min-w-0">
<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>
<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>
{commentPayload.relatedMemoSnippet || <span className="italic text-muted-foreground/40">Empty memo</span>}
</p>
</div>
<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">
{commentPayload.memoSnippet || <span className="italic text-muted-foreground/50">Empty comment</span>}
</p>
</div>
</div>
</div>
</div>
</div>
</div>
);
}
export default MemoCommentMessage;
@@ -0,0 +1,164 @@
import { create } from "@bufbuild/protobuf";
import { FieldMaskSchema, timestampDate } from "@bufbuild/protobuf/wkt";
import { AtSignIcon, CheckIcon, MessageSquareIcon, TrashIcon, XIcon } from "lucide-react";
import toast from "react-hot-toast";
import UserAvatar from "@/components/UserAvatar";
import { userServiceClient } from "@/connect";
import useNavigateTo from "@/hooks/useNavigateTo";
import { cn } from "@/lib/utils";
import { UserNotification, UserNotification_Status } from "@/types/proto/api/v1/user_service_pb";
import { useTranslate } from "@/utils/i18n";
interface Props {
notification: UserNotification;
}
function MemoMentionMessage({ notification }: Props) {
const t = useTranslate();
const navigateTo = useNavigateTo();
const mentionPayload = notification.payload?.case === "memoMention" ? notification.payload.value : undefined;
const sender = notification.senderUser;
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 (!mentionPayload) {
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;
const isCommentMention = Boolean(mentionPayload.relatedMemo);
const targetName = mentionPayload.relatedMemo || mentionPayload.memo;
const handleNavigate = async () => {
navigateTo(`/${targetName}`);
if (isUnread) {
await handleArchiveMessage(true);
}
};
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",
)}
>
{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">
<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",
)}
>
<AtSignIcon className="w-2.5 h-2.5" strokeWidth={2.5} />
</div>
</div>
<div className="flex-1 min-w-0">
<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">mentioned you {isCommentMention ? "in a comment" : "in a 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>
{mentionPayload.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">Memo:</span>
{mentionPayload.relatedMemoSnippet || <span className="italic text-muted-foreground/40">Empty memo</span>}
</p>
</div>
)}
<div
onClick={handleNavigate}
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">
<MessageSquareIcon 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">
{isCommentMention ? "Comment" : "Memo"}
</p>
<p className="text-sm text-foreground/90 line-clamp-2">
{mentionPayload.memoSnippet || <span className="italic text-muted-foreground/50">Empty memo</span>}
</p>
</div>
</div>
</div>
</div>
</div>
</div>
);
}
export default MemoMentionMessage;
+31
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;
+117
View File
@@ -0,0 +1,117 @@
import { CheckIcon, ChevronDownIcon, GlobeIcon, SearchIcon } from "lucide-react";
import { useMemo, useState } from "react";
import { useTranslation } from "react-i18next";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
import { locales } from "@/i18n";
import { cn } from "@/lib/utils";
import { getLocaleDisplayName, localeMatchesSearch, useTranslate } from "@/utils/i18n";
const MISSING_LANGUAGE_FEEDBACK_URL =
"https://github.com/usememos/memos/issues/new?title=Missing%20language%20support&body=Please%20add%20support%20for%20this%20language%3A%0A%0A-%20Language%3A%20";
interface LocaleSearchListProps {
value: Locale;
onChange: (locale: Locale) => void;
className?: string;
}
export const LocaleSearchList = (props: LocaleSearchListProps) => {
const { value, onChange, className } = props;
const t = useTranslate();
const { i18n } = useTranslation();
const [query, setQuery] = useState("");
const filteredLocales = useMemo(
() => locales.filter((locale) => localeMatchesSearch(locale, query, i18n.language)),
[i18n.language, query],
);
return (
<div
className={cn(
"flex max-h-[min(24rem,calc(100vh-2rem))] w-[var(--radix-popover-trigger-width)] min-w-48 max-w-[calc(100vw-2rem)] flex-col",
className,
)}
>
<div className="relative shrink-0 p-1">
<SearchIcon className="absolute top-1/2 left-3 size-4 -translate-y-1/2 text-muted-foreground" />
<Input
value={query}
onChange={(event) => setQuery(event.target.value)}
onKeyDown={(event) => event.stopPropagation()}
className="pl-8"
placeholder={`${t("common.search")} ${t("common.language").toLowerCase()}`}
aria-label={t("common.language")}
/>
</div>
<div className="mt-1 min-h-0 flex-1 touch-pan-y overflow-y-auto p-1" role="listbox" aria-label={t("common.language")}>
{filteredLocales.map((locale) => (
<div
key={locale}
role="option"
aria-selected={value === locale}
className={cn(
"flex h-8 w-full cursor-pointer items-center gap-2 rounded-sm px-2 text-left text-sm outline-hidden transition-colors hover:bg-accent hover:text-accent-foreground focus:bg-accent focus:text-accent-foreground",
value === locale && "bg-accent/60",
)}
onClick={() => onChange(locale)}
>
<span className="flex min-w-0 flex-1 items-baseline gap-1.5">
<span className="truncate">{getLocaleDisplayName(locale)}</span>
<span className="shrink-0 text-xs text-muted-foreground">{locale}</span>
</span>
{value === locale ? <CheckIcon className="size-4 shrink-0 text-primary" /> : <span className="size-4 shrink-0" />}
</div>
))}
{filteredLocales.length === 0 && (
<div className="px-2 py-6 text-center text-sm">
<a
href={MISSING_LANGUAGE_FEEDBACK_URL}
target="_blank"
rel="noreferrer"
className="text-primary underline-offset-4 hover:underline"
>
{t("locale-picker.no-language-feedback")}
</a>
</div>
)}
</div>
</div>
);
};
interface LocalePickerProps {
value: Locale;
onChange: (locale: Locale) => void;
className?: string;
}
const LocalePicker = (props: LocalePickerProps) => {
const { value, onChange, className } = props;
const [open, setOpen] = useState(false);
const handleChange = (locale: Locale) => {
onChange(locale);
setOpen(false);
};
return (
<Popover open={open} onOpenChange={setOpen}>
<PopoverTrigger asChild>
<Button type="button" variant="outline" className={cn("w-full justify-between", className)}>
<span className="flex min-w-0 flex-1 items-center gap-2">
<GlobeIcon className="size-4 shrink-0 text-muted-foreground" />
<span className="truncate">{getLocaleDisplayName(value)}</span>
</span>
<ChevronDownIcon className="size-4 shrink-0 text-muted-foreground" />
</Button>
</PopoverTrigger>
<PopoverContent align="start" className="p-0">
<LocaleSearchList value={value} onChange={handleChange} />
</PopoverContent>
</Popover>
);
};
export default LocalePicker;
+24
View File
@@ -0,0 +1,24 @@
import { FC } from "react";
import LocalePicker from "@/components/LocalePicker";
import { loadLocale } from "@/utils/i18n";
interface Props {
value: Locale;
onChange: (locale: Locale) => void;
className?: string;
}
const LocaleSelect: FC<Props> = (props: Props) => {
const { className, onChange, value } = props;
const handleSelectChange = async (locale: Locale) => {
// Apply locale globally immediately
loadLocale(locale);
// Also notify parent component
onChange(locale);
};
return <LocalePicker value={value} onChange={handleSelectChange} className={className} />;
};
export default LocaleSelect;
@@ -0,0 +1,165 @@
import {
ArchiveIcon,
ArchiveRestoreIcon,
BookmarkMinusIcon,
BookmarkPlusIcon,
CheckCheckIcon,
CopyIcon,
Edit3Icon,
FileTextIcon,
LinkIcon,
ListChecksIcon,
ListRestartIcon,
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 { countTasks } from "@/utils/markdown-manipulation";
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;
const taskStats = countTasks(memo.content);
const canMutateTasks = !readonly && !isArchived && taskStats.total > 0;
const hasOpenTasks = taskStats.completed < taskStats.total;
const hasCompletedTasks = taskStats.completed > 0;
// Action handlers
const {
handleTogglePinMemoBtnClick,
handleEditMemoClick,
handleToggleMemoStatusClick,
handleCopyLink,
handleCopyContent,
handleCheckAllTaskListItemsClick,
handleUncheckAllTaskListItemsClick,
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>
)}
{/* Task submenu (writable task memos) */}
{canMutateTasks && (
<DropdownMenuSub>
<DropdownMenuSubTrigger>
<ListChecksIcon className="w-4 h-auto" />
{t("memo.task-actions.title")}
</DropdownMenuSubTrigger>
<DropdownMenuSubContent>
<DropdownMenuItem disabled={!hasOpenTasks} onClick={handleCheckAllTaskListItemsClick}>
<CheckCheckIcon className="w-4 h-auto" />
{t("memo.task-actions.check-all")}
</DropdownMenuItem>
<DropdownMenuItem disabled={!hasCompletedTasks} onClick={handleUncheckAllTaskListItemsClick}>
<ListRestartIcon className="w-4 h-auto" />
{t("memo.task-actions.uncheck-all")}
</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;
@@ -0,0 +1,137 @@
import { DownloadIcon, ImageIcon, Loader2Icon, Share2Icon } from "lucide-react";
import { useCallback, useMemo, useRef, useState } from "react";
import { toast } from "react-hot-toast";
import { Button } from "@/components/ui/button";
import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle } from "@/components/ui/dialog";
import { useTranslate } from "@/utils/i18n";
import { useMemoViewContext } from "../MemoView/MemoViewContext";
import MemoShareImagePreview from "./MemoShareImagePreview";
import {
buildMemoShareImageFileName,
createMemoShareImageBlob,
getMemoShareDialogWidth,
getMemoSharePreviewWidth,
getMemoShareRenderWidth,
} from "./memoShareImage";
interface MemoShareImageDialogProps {
open: boolean;
onOpenChange: (open: boolean) => void;
}
const MemoShareImageDialog = ({ open, onOpenChange }: MemoShareImageDialogProps) => {
const t = useTranslate();
const { memo, cardWidth } = useMemoViewContext();
const previewRef = useRef<HTMLDivElement>(null);
const [isRendering, setIsRendering] = useState(false);
const previewWidth = useMemo(() => getMemoSharePreviewWidth(cardWidth), [cardWidth]);
const dialogWidth = useMemo(() => getMemoShareDialogWidth(previewWidth), [previewWidth]);
const previewRenderWidth = useMemo(() => getMemoShareRenderWidth(previewWidth, dialogWidth), [dialogWidth, previewWidth]);
const createShareBlob = useCallback(async () => {
const preview = previewRef.current;
if (!preview) {
throw new Error("Preview is not ready");
}
return createMemoShareImageBlob(preview);
}, []);
const handleDownload = useCallback(async () => {
setIsRendering(true);
try {
const blob = await createShareBlob();
const url = URL.createObjectURL(blob);
const anchor = document.createElement("a");
anchor.href = url;
anchor.download = buildMemoShareImageFileName(memo.name);
anchor.click();
URL.revokeObjectURL(url);
toast.success(t("memo.share.image-downloaded"));
} catch {
toast.error(t("memo.share.image-download-failed"));
} finally {
setIsRendering(false);
}
}, [createShareBlob, memo.name, t]);
const handleNativeShare = useCallback(async () => {
if (typeof navigator.share !== "function") {
return;
}
setIsRendering(true);
try {
const blob = await createShareBlob();
const file = new File([blob], buildMemoShareImageFileName(memo.name), { type: "image/png" });
if (typeof navigator.canShare === "function" && !navigator.canShare({ files: [file] })) {
toast.error(t("memo.share.image-share-failed"));
return;
}
await navigator.share({
files: [file],
title: memo.content.slice(0, 60),
});
} catch (error) {
if (!(error instanceof DOMException && error.name === "AbortError")) {
toast.error(t("memo.share.image-share-failed"));
}
} finally {
setIsRendering(false);
}
}, [createShareBlob, memo.content, memo.name, t]);
const supportsNativeShare =
typeof navigator !== "undefined" && typeof navigator.share === "function" && typeof navigator.canShare === "function";
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent
size="full"
className="min-h-0 overflow-hidden !gap-0 !p-0 md:w-auto md:max-w-none"
style={{ width: `${dialogWidth}px` }}
>
<div className="flex min-h-0 flex-1 flex-col overflow-hidden">
<DialogHeader className="shrink-0 border-b border-border/60 px-4 py-3 sm:px-5">
<DialogTitle className="flex items-center gap-2 text-base font-medium">
<ImageIcon className="h-4 w-4 text-muted-foreground" />
{t("memo.share.image-title")}
</DialogTitle>
<DialogDescription className="text-xs">{t("memo.share.image-description", { width: previewRenderWidth })}</DialogDescription>
</DialogHeader>
<div className="relative flex min-h-0 flex-1 items-start justify-center overflow-auto bg-muted/20 px-4 py-3 sm:px-5 sm:py-4">
<MemoShareImagePreview ref={previewRef} width={previewRenderWidth} />
</div>
<DialogFooter className="shrink-0 border-t border-border/60 px-4 py-3 sm:px-5">
{supportsNativeShare && (
<Button
variant="ghost"
className="text-muted-foreground hover:bg-muted/60 hover:text-foreground"
onClick={handleNativeShare}
disabled={isRendering}
>
{isRendering ? <Loader2Icon className="mr-2 h-4 w-4 animate-spin" /> : <Share2Icon className="mr-2 h-4 w-4" />}
{t("memo.share.image-share")}
</Button>
)}
<Button
variant="outline"
className="border-border/70 text-muted-foreground hover:bg-muted/60 hover:text-foreground"
onClick={handleDownload}
disabled={isRendering}
>
{isRendering ? <Loader2Icon className="mr-2 h-4 w-4 animate-spin" /> : <DownloadIcon className="mr-2 h-4 w-4" />}
{t("memo.share.image-download")}
</Button>
</DialogFooter>
</div>
</DialogContent>
</Dialog>
);
};
export default MemoShareImageDialog;
@@ -0,0 +1,87 @@
import { forwardRef, useMemo } from "react";
import MemoContent from "@/components/MemoContent";
import UserAvatar from "@/components/UserAvatar";
import i18n from "@/i18n";
import { cn } from "@/lib/utils";
import { useTranslate } from "@/utils/i18n";
import { useMemoViewContext } from "../MemoView/MemoViewContext";
import { buildMemoShareImagePreviewModel } from "./memoShareImagePreviewModel";
const MemoShareImagePreview = forwardRef<HTMLDivElement, { width: number }>(({ width }, ref) => {
const t = useTranslate();
const { memo, creator, blurred, showBlurredContent } = useMemoViewContext();
const fallbackDisplayName = t("common.memo");
const locale = i18n.language;
const preview = useMemo(
() =>
buildMemoShareImagePreviewModel({
memo,
creator,
fallbackDisplayName,
locale,
}),
[creator, fallbackDisplayName, locale, memo],
);
return (
<div ref={ref} className="overflow-hidden rounded-xl border border-border/50 bg-background p-2 sm:p-2.5" style={{ width }}>
<div className="overflow-hidden rounded-lg border border-border/60 bg-background p-4 sm:p-5">
<div className="flex items-start gap-3">
<div className="flex min-w-0 items-center gap-2.5">
<UserAvatar avatarUrl={preview.avatarUrl} className="h-8 w-8 rounded-xl" />
<div className="min-w-0">
<div className="truncate text-[13px] font-semibold text-foreground">{preview.displayName}</div>
{preview.formattedDisplayTime && <div className="truncate text-xs text-muted-foreground">{preview.formattedDisplayTime}</div>}
</div>
</div>
</div>
<div className="mt-4">
<div className={cn("pointer-events-none", blurred && !showBlurredContent && "blur-lg")}>
<MemoContent content={memo.content} compact={false} contentClassName="text-[14px] leading-6.5 sm:text-[15px]" />
</div>
</div>
{preview.visualItems.length > 0 && (
<div className={cn("mt-4 grid gap-1.5", preview.visualItems.length === 1 ? "grid-cols-1" : "grid-cols-2")}>
{preview.visualItems.slice(0, 4).map((item, index) => (
<div
key={item.id}
className={cn(
"relative overflow-hidden rounded-md border border-border/70 bg-muted/30",
preview.visualItems.length === 1 ? "aspect-[4/3]" : "aspect-square",
preview.visualItems.length === 3 && index === 0 && "col-span-2 aspect-[2.2/1]",
)}
>
<img src={item.posterUrl} alt={item.filename} className="h-full w-full object-cover" loading="eager" decoding="async" />
{index === 3 && preview.visualItems.length > 4 && (
<div className="absolute inset-0 flex items-center justify-center bg-foreground/35 text-lg font-semibold text-background">
+{preview.visualItems.length - 4}
</div>
)}
</div>
))}
</div>
)}
{preview.footerBadges.length > 0 && (
<div className="mt-4 flex flex-wrap items-center gap-1.5">
{preview.footerBadges.map((badge) => (
<span
key={badge.type}
className="inline-flex rounded-full border border-border/70 bg-muted/55 px-2 py-0.5 text-[11px] text-muted-foreground"
>
{badge.count} {t("common.attachments").toLowerCase()}
</span>
))}
</div>
)}
</div>
</div>
);
});
MemoShareImagePreview.displayName = "MemoShareImagePreview";
export default MemoShareImagePreview;
+163
View File
@@ -0,0 +1,163 @@
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 { ROUTES } from "@/router/routes";
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";
import { checkAllTasks, uncheckAllTasks } from "@/utils/markdown-task-actions";
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 updateMemoContent = useCallback(
async (nextContent: string, context: string) => {
if (nextContent === memo.content) {
return;
}
try {
await updateMemo({
update: {
name: memo.name,
content: nextContent,
},
updateMask: ["content", "update_time"],
});
toast.success(t("memo.task-actions.updated"));
} catch (error: unknown) {
handleError(error, toast.error, {
context,
fallbackMessage: "An error occurred",
});
}
},
[memo.content, memo.name, t, updateMemo],
);
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 ? ROUTES.HOME : ROUTES.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 handleCheckAllTaskListItemsClick = useCallback(async () => {
await updateMemoContent(checkAllTasks(memo.content), "Check memo task list items");
}, [memo.content, updateMemoContent]);
const handleUncheckAllTaskListItemsClick = useCallback(async () => {
await updateMemoContent(uncheckAllTasks(memo.content), "Uncheck memo task list items");
}, [memo.content, updateMemoContent]);
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(ROUTES.HOME);
}
memoUpdatedCallback();
}, [memo.name, memo.parent, t, isInMemoDetailPage, navigateTo, memoUpdatedCallback, deleteMemo, queryClient]);
return {
handleTogglePinMemoBtnClick,
handleEditMemoClick,
handleToggleMemoStatusClick,
handleCopyLink,
handleCopyContent,
handleCheckAllTaskListItemsClick,
handleUncheckAllTaskListItemsClick,
handleDeleteMemoClick,
confirmDeleteMemo,
};
};
@@ -0,0 +1,3 @@
export { useMemoActionHandlers } from "./hooks";
export { default, default as MemoActionMenu } from "./MemoActionMenu";
export type { MemoActionMenuProps } from "./types";
@@ -0,0 +1,117 @@
import { toBlob } from "html-to-image";
const WINDOW_HORIZONTAL_MARGIN = 32;
const PREVIEW_HORIZONTAL_PADDING_IN_DIALOG = 40;
const PREVIEW_WIDTH_BOOST_IN_DIALOG = 48;
export const MEMO_SHARE_IMAGE_CONFIG = {
dialogExtraWidth: 80,
maxWidth: 520,
minWidth: 260,
previewScale: 0.9,
viewportMargin: 48,
} as const;
const clamp = (value: number, min: number, max: number) => Math.min(Math.max(value, min), max);
const isExportableImageUrl = (value?: string) => {
if (!value) {
return false;
}
if (value.startsWith("/") || value.startsWith("data:") || value.startsWith("blob:")) {
return true;
}
try {
return new URL(value, window.location.origin).origin === window.location.origin;
} catch {
return false;
}
};
const waitForPreviewAssets = async (node: HTMLElement) => {
try {
await document.fonts?.ready;
} catch {
// Ignore font loading failures and continue with the best available render.
}
const images = Array.from(node.querySelectorAll("img"));
await Promise.all(
images.map(
(image) =>
new Promise<void>((resolve) => {
if (image.complete) {
resolve();
return;
}
image.addEventListener("load", () => resolve(), { once: true });
image.addEventListener("error", () => resolve(), { once: true });
}),
),
);
};
export const buildMemoShareImageFileName = (memoName: string) => {
const suffix = memoName.split("/").pop() || "memo";
return `memo-${suffix}.png`;
};
export const getMemoSharePreviewWidth = (cardWidth: number) => {
const viewportWidth =
typeof window === "undefined" ? MEMO_SHARE_IMAGE_CONFIG.maxWidth : window.innerWidth - MEMO_SHARE_IMAGE_CONFIG.viewportMargin;
const baseWidth = cardWidth || viewportWidth;
return clamp(
Math.round(baseWidth * MEMO_SHARE_IMAGE_CONFIG.previewScale),
MEMO_SHARE_IMAGE_CONFIG.minWidth,
MEMO_SHARE_IMAGE_CONFIG.maxWidth,
);
};
export const getMemoShareDialogWidth = (previewWidth: number) => {
const viewportWidth =
typeof window === "undefined" ? previewWidth + MEMO_SHARE_IMAGE_CONFIG.dialogExtraWidth : window.innerWidth - WINDOW_HORIZONTAL_MARGIN;
return Math.min(previewWidth + MEMO_SHARE_IMAGE_CONFIG.dialogExtraWidth, viewportWidth);
};
export const getMemoShareRenderWidth = (previewWidth: number, dialogWidth: number) => {
const maxRenderWidth = Math.max(MEMO_SHARE_IMAGE_CONFIG.minWidth, dialogWidth - PREVIEW_HORIZONTAL_PADDING_IN_DIALOG);
return clamp(previewWidth + PREVIEW_WIDTH_BOOST_IN_DIALOG, MEMO_SHARE_IMAGE_CONFIG.minWidth, maxRenderWidth);
};
export const getMemoSharePreviewAvatarUrl = (avatarUrl?: string) => (isExportableImageUrl(avatarUrl) ? avatarUrl : undefined);
export const createMemoShareImageBlob = async (node: HTMLElement) => {
await waitForPreviewAssets(node);
const rect = node.getBoundingClientRect();
const width = Math.ceil(rect.width || node.offsetWidth || node.clientWidth);
const height = Math.ceil(rect.height || node.offsetHeight || node.clientHeight);
const blob = await toBlob(node, {
cacheBust: true,
height,
pixelRatio: Math.max(2, Math.min(window.devicePixelRatio || 1, 3)),
width,
filter: (currentNode) => {
if (!(currentNode instanceof HTMLElement)) {
return true;
}
if (currentNode instanceof HTMLImageElement) {
return isExportableImageUrl(currentNode.currentSrc || currentNode.src);
}
return !(currentNode instanceof HTMLVideoElement);
},
});
if (!blob) {
throw new Error("Failed to render image");
}
return blob;
};
@@ -0,0 +1,58 @@
import { timestampDate } from "@bufbuild/protobuf/wkt";
import { separateAttachments } from "@/components/MemoMetadata/Attachment/attachmentHelpers";
import type { Memo } from "@/types/proto/api/v1/memo_service_pb";
import type { User } from "@/types/proto/api/v1/user_service_pb";
import { type AttachmentVisualItem, buildAttachmentVisualItems, countLogicalAttachmentItems } from "@/utils/media-item";
import { getMemoSharePreviewAvatarUrl } from "./memoShareImage";
interface BuildMemoShareImagePreviewModelOptions {
memo: Memo;
creator?: User;
fallbackDisplayName: string;
locale: string;
}
export interface MemoShareImageAttachmentSummaryBadge {
type: "attachment-summary";
count: number;
}
export type MemoShareImageFooterBadge = MemoShareImageAttachmentSummaryBadge;
export interface MemoShareImagePreviewModel {
displayName: string;
avatarUrl?: string;
formattedDisplayTime?: string;
visualItems: AttachmentVisualItem[];
footerBadges: MemoShareImageFooterBadge[];
}
export const buildMemoShareImagePreviewModel = ({
memo,
creator,
fallbackDisplayName,
locale,
}: BuildMemoShareImagePreviewModelOptions): MemoShareImagePreviewModel => {
const displayName = creator?.displayName || creator?.username || fallbackDisplayName;
const avatarUrl = getMemoSharePreviewAvatarUrl(creator?.avatarUrl);
const displayTime = memo.createTime ? timestampDate(memo.createTime) : undefined;
const formattedDisplayTime = displayTime?.toLocaleString(locale, {
dateStyle: "medium",
timeStyle: "short",
});
const attachmentGroups = separateAttachments(memo.attachments);
const visualItems = buildAttachmentVisualItems(attachmentGroups.visual);
const attachmentCount = countLogicalAttachmentItems(memo.attachments);
const nonVisualAttachmentCount = Math.max(attachmentCount - visualItems.length, 0);
const footerBadges: MemoShareImageFooterBadge[] =
nonVisualAttachmentCount > 0 ? [{ type: "attachment-summary", count: attachmentCount }] : [];
return {
displayName,
avatarUrl,
formattedDisplayTime,
visualItems,
footerBadges,
};
};
@@ -0,0 +1,8 @@
import type { Memo } from "@/types/proto/api/v1/memo_service_pb";
export interface MemoActionMenuProps {
memo: Memo;
readonly?: boolean;
className?: string;
onEdit?: () => void;
}
+90
View File
@@ -0,0 +1,90 @@
import { LoaderCircleIcon, MessageCircleIcon } from "lucide-react";
import { useState } from "react";
import MemoEditor from "@/components/MemoEditor";
import MemoView from "@/components/MemoView";
import { Button } from "@/components/ui/button";
import useCurrentUser from "@/hooks/useCurrentUser";
import { extractMemoIdFromName } from "@/lib/resource-names";
import type { Memo } from "@/types/proto/api/v1/memo_service_pb";
import { useTranslate } from "@/utils/i18n";
interface Props {
memo: Memo;
comments: Memo[];
parentPage?: string;
hasMoreComments?: boolean;
isFetchingMoreComments?: boolean;
onLoadMoreComments?: () => void;
}
const MemoCommentSection = ({ memo, comments, parentPage, hasMoreComments, isFetchingMoreComments, onLoadMoreComments }: Props) => {
const t = useTranslate();
const currentUser = useCurrentUser();
const [showEditor, setShowEditor] = useState(false);
const showCreateButton = currentUser && !showEditor;
const handleCommentCreated = async (_memoCommentName: string) => {
setShowEditor(false);
};
return (
<div className="pt-8 pb-16 w-full">
<h2 id="comments" className="sr-only">
{t("memo.comment.self")}
</h2>
<div className="relative mx-auto grow w-full min-h-full flex flex-col justify-start items-start gap-y-1">
{comments.length === 0 ? (
showCreateButton && (
<div className="w-full flex flex-row justify-center items-center py-6">
<Button variant="ghost" onClick={() => setShowEditor(true)}>
<span className="text-muted-foreground">{t("memo.comment.write-a-comment")}</span>
<MessageCircleIcon className="ml-2 w-5 h-auto text-muted-foreground" />
</Button>
</div>
)
) : (
<div className="w-full flex flex-row justify-between items-center h-8 pl-3 mb-2">
<div className="flex flex-row justify-start items-center">
<MessageCircleIcon className="w-5 h-auto text-muted-foreground mr-1" />
<span className="text-muted-foreground text-sm">{t("memo.comment.self")}</span>
<span className="text-muted-foreground text-sm ml-1">({comments.length})</span>
</div>
{showCreateButton && (
<Button variant="ghost" className="text-muted-foreground" onClick={() => setShowEditor(true)}>
{t("memo.comment.write-a-comment")}
</Button>
)}
</div>
)}
{showEditor && (
<div className="w-full mb-2">
<MemoEditor
cacheKey={`${memo.name}-${memo.updateTime}-comment`}
placeholder={t("editor.add-your-comment-here")}
parentMemoName={memo.name}
autoFocus
onConfirm={handleCommentCreated}
onCancel={() => setShowEditor(false)}
/>
</div>
)}
{comments.map((comment) => (
<div className="w-full" key={`${comment.name}-${comment.updateTime}`} id={extractMemoIdFromName(comment.name)}>
<MemoView memo={comment} parentPage={parentPage} showCreator compact />
</div>
))}
{hasMoreComments && (
<div className="w-full mt-4 flex justify-center">
<Button variant="outline" className="rounded-full px-4" onClick={onLoadMoreComments} disabled={isFetchingMoreComments}>
{isFetchingMoreComments && <LoaderCircleIcon className="h-4 w-4 animate-spin" />}
{t(isFetchingMoreComments ? "resource.fetching-data" : "memo.load-more")}
</Button>
</div>
)}
</div>
</div>
);
};
export default MemoCommentSection;
@@ -0,0 +1,156 @@
import copy from "copy-to-clipboard";
import hljs from "highlight.js";
import { CheckIcon, CopyIcon } from "lucide-react";
import { isValidElement, type ReactElement, type ReactNode, 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?: ReactNode;
className?: string;
}
export const CodeBlock = ({ children, className, node: _node, ...props }: CodeBlockProps) => {
const { userGeneralSetting } = useAuth();
const [copied, setCopied] = useState(false);
const codeElement = isValidElement(children) ? (children as ReactElement<{ className?: string }>) : null;
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 = await 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 = await 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>
);
};
@@ -0,0 +1,70 @@
import type React from "react";
import { useEffect, useState } from "react";
import { useLinkMetadata } from "@/hooks/useMemoQueries";
import { cn } from "@/lib/utils";
interface LinkMetadataCardProps {
url: string;
fallback: React.ReactNode;
}
function getHostname(url: string): string {
try {
return new URL(url).hostname.replace(/^www\./, "");
} catch {
return "";
}
}
const LinkMetadataCard = ({ url, fallback }: LinkMetadataCardProps) => {
const [imageFailed, setImageFailed] = useState(false);
const { data: metadata, isSuccess } = useLinkMetadata(url);
const title = metadata?.title.trim() ?? "";
const description = metadata?.description.trim() ?? "";
const image = metadata?.image.trim() ?? "";
const hostname = getHostname(metadata?.url || url);
const hasUsefulMetadata = title !== "" || description !== "";
useEffect(() => {
setImageFailed(false);
}, [url, image]);
if (!isSuccess || !hasUsefulMetadata) {
return fallback;
}
return (
<a
href={url}
target="_blank"
rel="noopener noreferrer"
className={cn(
"group my-0 mb-2 flex w-full max-w-full overflow-hidden rounded-md border border-border bg-muted/20 text-foreground no-underline transition-colors",
"hover:border-primary/35 hover:bg-accent/20",
)}
>
<span className="flex min-w-0 flex-1 flex-col gap-0.5 px-2.5 py-2 sm:gap-1 sm:px-3 sm:py-2.5">
{hostname && <span className="truncate text-[11px] leading-4 text-muted-foreground sm:text-xs">{hostname}</span>}
{title && <span className="line-clamp-2 text-sm font-medium leading-5 text-foreground">{title}</span>}
{description && <span className="line-clamp-1 text-xs leading-4 text-muted-foreground sm:line-clamp-2">{description}</span>}
</span>
{image && !imageFailed && (
<span className="flex w-24 shrink-0 items-center border-l border-border/70 bg-muted/40 sm:w-40">
<span className="aspect-[1.91/1] w-full overflow-hidden">
<img
src={image}
alt=""
className="h-full w-full object-cover transition-transform duration-200 group-hover:scale-[1.01]"
loading="lazy"
decoding="async"
onError={() => setImageFailed(true)}
/>
</span>
</span>
)}
</a>
);
};
export default LinkMetadataCard;
@@ -0,0 +1,22 @@
import { createContext, useContext, useMemo } from "react";
interface MarkdownRenderContextValue {
blockDepth: number;
}
export const rootMarkdownRenderContext: MarkdownRenderContextValue = {
blockDepth: 0,
};
export const MarkdownRenderContext = createContext<MarkdownRenderContextValue>(rootMarkdownRenderContext);
export const useMarkdownRenderContext = () => {
return useContext(MarkdownRenderContext);
};
export const NestedMarkdownRenderContext = ({ children }: { children: React.ReactNode }) => {
const { blockDepth } = useMarkdownRenderContext();
const value = useMemo<MarkdownRenderContextValue>(() => ({ blockDepth: blockDepth + 1 }), [blockDepth]);
return <MarkdownRenderContext.Provider value={value}>{children}</MarkdownRenderContext.Provider>;
};
@@ -0,0 +1,167 @@
import type { Element } from "hast";
import "katex/dist/katex.min.css";
import type { Components } from "react-markdown";
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 { isMentionElement, isTagElement, isTaskListItemElement } from "@/types/markdown";
import { rehypeHeadingId } from "@/utils/rehype-plugins/rehype-heading-id";
import { remarkDisableSetext } from "@/utils/remark-plugins/remark-disable-setext";
import { remarkMention } from "@/utils/remark-plugins/remark-mention";
import { remarkPreserveType } from "@/utils/remark-plugins/remark-preserve-type";
import { remarkSplitMixedTaskLists } from "@/utils/remark-plugins/remark-split-mixed-task-lists";
import { remarkTag } from "@/utils/remark-plugins/remark-tag";
import { CodeBlock } from "./CodeBlock";
import { SANITIZE_SCHEMA } from "./constants";
import { MarkdownRenderContext, rootMarkdownRenderContext } from "./MarkdownRenderContext";
import { Mention } from "./Mention";
import { AnchorLink, 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 { TrustedIframe } from "./TrustedIframe";
interface MemoMarkdownRendererProps {
content: string;
resolvedMentionUsernames: Set<string>;
/** Resource name of the memo (e.g. `memos/abc123`), used to target footnote links at the detail page. */
memoName?: string;
/** Whether the memo is rendered as a collapsed feed card. */
compact?: boolean;
}
function getMentionUsername(node: Element, children?: React.ReactNode): string {
const dataMention = node.properties?.["data-mention"];
if (typeof dataMention === "string" && dataMention !== "") {
return dataMention;
}
const camelDataMention = (node.properties as Record<string, unknown> | undefined)?.dataMention;
if (typeof camelDataMention === "string" && camelDataMention !== "") {
return camelDataMention;
}
const text = Array.isArray(children) ? children.join("") : children;
if (typeof text === "string" && text.startsWith("@")) {
return text.slice(1).toLowerCase();
}
return "";
}
export const MemoMarkdownRenderer = ({ content, resolvedMentionUsernames, memoName, compact }: MemoMarkdownRendererProps) => {
const markdownComponents: Components = {
input: ({ node, ...inputProps }) => {
if (node && isTaskListItemElement(node)) {
return <TaskListItem {...inputProps} node={node} />;
}
return <input {...inputProps} />;
},
span: ({ node, ...spanProps }) => {
if (node && isMentionElement(node)) {
const username = getMentionUsername(node, spanProps.children);
return <Mention {...spanProps} node={node} data-mention={username} resolved={resolvedMentionUsernames.has(username)} />;
}
if (node && isTagElement(node)) {
return <Tag {...spanProps} node={node} />;
}
return <span {...spanProps} />;
},
h1: ({ children, ...props }) => (
<Heading level={1} {...props}>
{children}
</Heading>
),
h2: ({ children, ...props }) => (
<Heading level={2} {...props}>
{children}
</Heading>
),
h3: ({ children, ...props }) => (
<Heading level={3} {...props}>
{children}
</Heading>
),
h4: ({ children, ...props }) => (
<Heading level={4} {...props}>
{children}
</Heading>
),
h5: ({ children, ...props }) => (
<Heading level={5} {...props}>
{children}
</Heading>
),
h6: ({ children, ...props }) => (
<Heading level={6} {...props}>
{children}
</Heading>
),
p: ({ children, ...props }) => <Paragraph {...props}>{children}</Paragraph>,
blockquote: ({ children, ...props }) => <Blockquote {...props}>{children}</Blockquote>,
hr: (props) => <HorizontalRule {...props} />,
ul: ({ children, ...props }) => <List {...props}>{children}</List>,
ol: ({ children, ...props }) => (
<List ordered {...props}>
{children}
</List>
),
li: ({ children, ...props }) => <ListItem {...props}>{children}</ListItem>,
a: ({ children, href, ...props }) => {
// In-page anchors (footnote refs/backrefs, heading links) navigate within the memo rather
// than opening a new tab; everything else is treated as an external link.
if (typeof href === "string" && href.startsWith("#")) {
return (
<AnchorLink href={href} memoName={memoName} compact={compact} {...props}>
{children}
</AnchorLink>
);
}
return (
<Link href={href} {...props}>
{children}
</Link>
);
},
code: ({ children, ...props }) => <InlineCode {...props}>{children}</InlineCode>,
iframe: TrustedIframe,
img: (props) => <Image {...props} />,
pre: CodeBlock,
table: ({ children, ...props }) => <Table {...props}>{children}</Table>,
thead: ({ children, ...props }) => <TableHead {...props}>{children}</TableHead>,
tbody: ({ children, ...props }) => <TableBody {...props}>{children}</TableBody>,
tr: ({ children, ...props }) => <TableRow {...props}>{children}</TableRow>,
th: ({ children, ...props }) => <TableHeaderCell {...props}>{children}</TableHeaderCell>,
td: ({ children, ...props }) => <TableCell {...props}>{children}</TableCell>,
};
return (
<MarkdownRenderContext.Provider value={rootMarkdownRenderContext}>
<ReactMarkdown
remarkPlugins={[
remarkDisableSetext,
remarkMath,
remarkGfm,
remarkSplitMixedTaskLists,
remarkBreaks,
remarkMention,
remarkTag,
remarkPreserveType,
]}
rehypePlugins={[
rehypeRaw,
[rehypeSanitize, SANITIZE_SCHEMA],
rehypeHeadingId,
[rehypeKatex, { throwOnError: false, strict: false }],
]}
components={markdownComponents}
>
{content}
</ReactMarkdown>
</MarkdownRenderContext.Provider>
);
};
@@ -0,0 +1,41 @@
import type { Element } from "hast";
import { mentionStyles } from "@/lib/markdownStyles";
import { cn } from "@/lib/utils";
interface MentionProps extends React.HTMLAttributes<HTMLSpanElement> {
node?: Element;
"data-mention"?: string;
children?: React.ReactNode;
resolved?: boolean;
}
export const Mention: React.FC<MentionProps> = ({
"data-mention": dataMention,
children,
className,
node: _node,
resolved = false,
...props
}) => {
const username = dataMention || "";
if (!resolved) {
return (
<span data-mention={username} title={`@${username}`} className={className} {...props}>
{children}
</span>
);
}
return (
<a
href={`/u/${username}`}
className={cn(mentionStyles.base, "hover:underline", className)}
data-mention={username}
title={`@${username}`}
{...props}
>
{children}
</a>
);
};
@@ -0,0 +1,41 @@
import { createContext, type ReactNode, useContext, useMemo } from "react";
import { useUsersByUsernames } from "@/hooks/useUserQueries";
import { extractMentionUsernames } from "@/utils/remark-plugins/remark-mention";
const MentionResolutionContext = createContext<Set<string> | null>(null);
interface MentionResolutionProviderProps {
contents: string[];
children: ReactNode;
}
export const MentionResolutionProvider = ({ contents, children }: MentionResolutionProviderProps) => {
const mentionUsernames = useMemo(() => Array.from(new Set(contents.flatMap((content) => extractMentionUsernames(content)))), [contents]);
const { data: mentionUsers } = useUsersByUsernames(mentionUsernames);
const resolvedMentionUsernames = useMemo(() => {
if (!mentionUsers) {
return new Set<string>();
}
return new Set(Array.from(mentionUsers.entries()).flatMap(([username, user]) => (user ? [username] : [])));
}, [mentionUsers]);
return <MentionResolutionContext.Provider value={resolvedMentionUsernames}>{children}</MentionResolutionContext.Provider>;
};
export function useResolvedMentionUsernames(usernames: string[]) {
const sharedResolvedMentionUsernames = useContext(MentionResolutionContext);
const shouldUseSharedResolution = sharedResolvedMentionUsernames !== null;
const { data: mentionUsers } = useUsersByUsernames(usernames, { enabled: !shouldUseSharedResolution });
return useMemo(() => {
if (sharedResolvedMentionUsernames) {
return sharedResolvedMentionUsernames;
}
if (!mentionUsers) {
return new Set<string>();
}
return new Set(Array.from(mentionUsers.entries()).flatMap(([username, user]) => (user ? [username] : [])));
}, [sharedResolvedMentionUsernames, mentionUsers]);
}
@@ -0,0 +1,101 @@
import { useEffect, useMemo, 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;
}
type MermaidTheme = "default" | "dark";
const toMermaidTheme = (appTheme: string): MermaidTheme => (appTheme === "default-dark" ? "dark" : "default");
const formatErrorMessage = (err: unknown): string => {
const msg = err instanceof Error ? err.message : "Failed to render diagram";
if (/no diagram type detected/i.test(msg)) {
return `${msg} — check that the diagram type is valid (e.g. sequenceDiagram, classDiagram, erDiagram)`;
}
return msg;
};
export const MermaidBlock = ({ children, className }: MermaidBlockProps) => {
const { userGeneralSetting } = useAuth();
const [svg, setSvg] = useState<string>("");
const [error, setError] = useState<string>("");
const [systemThemeChange, setSystemThemeChange] = useState(0);
const codeContent = extractCodeContent(children);
const themePreference = getThemeWithFallback(userGeneralSetting?.theme);
const currentTheme = useMemo(() => resolveTheme(themePreference), [themePreference, systemThemeChange]);
// Re-resolve theme when OS preference changes (only relevant when using "system" theme)
useEffect(() => {
if (themePreference !== "system") return;
return setupSystemThemeListener(() => setSystemThemeChange((n) => n + 1));
}, [themePreference]);
// Render diagram when content or theme changes
useEffect(() => {
if (!codeContent) {
setSvg("");
setError("");
return;
}
let cancelled = false;
const renderDiagram = async () => {
try {
const { default: mermaid } = await import("mermaid");
if (cancelled) return;
mermaid.initialize({
startOnLoad: false,
theme: toMermaidTheme(currentTheme),
securityLevel: "strict",
fontFamily: "inherit",
suppressErrorRendering: true,
});
const id = `mermaid-${Math.random().toString(36).substring(7)}`;
const { svg: renderedSvg } = await mermaid.render(id, codeContent);
if (cancelled) return;
setSvg(renderedSvg);
setError("");
} catch (err) {
if (cancelled) return;
console.error("Failed to render mermaid diagram:", err);
setSvg("");
setError(formatErrorMessage(err));
}
};
renderDiagram();
return () => {
cancelled = true;
};
}, [codeContent, currentTheme]);
if (error) {
return (
<div className="w-full">
<div className="text-sm text-destructive mb-2 whitespace-normal break-words">Mermaid Error: {error}</div>
<code className="block language-mermaid whitespace-pre text-sm">{codeContent}</code>
</div>
);
}
if (!svg) return null;
return (
<div
className={cn("mermaid-diagram w-full flex justify-center items-center my-2 overflow-x-auto", className)}
dangerouslySetInnerHTML={{ __html: svg }}
/>
);
};
+77
View File
@@ -0,0 +1,77 @@
import { cn } from "@/lib/utils";
import { NestedMarkdownRenderContext } from "./MarkdownRenderContext";
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="my-2 w-full overflow-x-auto rounded-lg border border-border bg-muted/20">
<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("border-b border-border bg-muted/30", 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-accent/20", 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-2 py-1 text-left align-middle text-sm font-medium text-muted-foreground", className)} {...props}>
<NestedMarkdownRenderContext>{children}</NestedMarkdownRenderContext>
</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-2 py-1 text-left align-middle text-sm", className)} {...props}>
<NestedMarkdownRenderContext>{children}</NestedMarkdownRenderContext>
</td>
);
};
+79
View File
@@ -0,0 +1,79 @@
import type { Element } from "hast";
import { useLocation } from "react-router-dom";
import { useAuth } from "@/contexts/AuthContext";
import { type MemoFilter, stringifyFilters, useMemoFilterContext } from "@/contexts/MemoFilterContext";
import useNavigateTo from "@/hooks/useNavigateTo";
import { colorToHex } from "@/lib/color";
import { tagStyles } from "@/lib/markdownStyles";
import { findTagMetadata } from "@/lib/tag";
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, style, node: _node, ...props }) => {
const { parentPage } = useMemoViewContext();
const location = useLocation();
const navigateTo = useNavigateTo();
const { getFiltersByFactor, removeFilter, addFilter } = useMemoFilterContext();
const { userTagsSetting } = useAuth();
const tag = dataTag || "";
// Custom color from user tag metadata. Dynamic hex values must use inline styles
// because Tailwind can't scan dynamically constructed class names.
// Text uses a darkened variant (40% color + black) for contrast on light backgrounds.
const metadata = userTagsSetting ? findTagMetadata(tag, userTagsSetting) : undefined;
const bgHex = colorToHex(metadata?.backgroundColor);
const tagStyle: React.CSSProperties | undefined = bgHex
? {
borderColor: bgHex,
color: `color-mix(in srgb, ${bgHex} 60%, black)`,
backgroundColor: `color-mix(in srgb, ${bgHex} 15%, transparent)`,
...style,
}
: style;
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.HOME;
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(tagStyles.base, "cursor-pointer transition-opacity hover:opacity-75", !bgHex && tagStyles.defaultColor, className)}
style={tagStyle}
data-tag={tag}
{...props}
onClick={handleTagClick}
>
{children}
</span>
);
};
@@ -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", "update_time"],
});
};
// Override the disabled prop from remark-gfm (which defaults to true)
return <Checkbox ref={checkboxRef} checked={checked} disabled={readonly} onCheckedChange={handleChange} className={props.className} />;
};
@@ -0,0 +1,14 @@
import { createElement } from "react";
import { cn } from "@/lib/utils";
import { isTrustedIframeSrc } from "./constants";
export const TrustedIframe = (props: React.ComponentProps<"iframe">) => {
if (typeof props.src !== "string" || !isTrustedIframeSrc(props.src)) {
return null;
}
return createElement("iframe", {
...props,
className: cn("max-w-full rounded-lg border border-border", props.className),
});
};
@@ -0,0 +1,65 @@
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";
const TRUSTED_IFRAME_SRC_PATTERNS = [
/^https:\/\/www\.youtube\.com\/embed\/[^?#]+(?:\?.*)?$/i,
/^https:\/\/www\.youtube-nocookie\.com\/embed\/[^?#]+(?:\?.*)?$/i,
/^https:\/\/player\.vimeo\.com\/video\/[^?#]+(?:\?.*)?$/i,
/^https:\/\/open\.spotify\.com\/embed\/[^?#]+(?:\?.*)?$/i,
/^https:\/\/w\.soundcloud\.com\/player\/?(?:\?.*)?$/i,
/^https:\/\/www\.loom\.com\/embed\/[^?#]+(?:\?.*)?$/i,
/^https:\/\/www\.google\.com\/maps\/embed(?:\/[^?#]*)?(?:\?.*)?$/i,
/^https:\/\/(?:app\.)?diagrams\.net\/(?:[^?#]+)?(?:\?.*)?$/i,
/^https:\/\/(?:www\.)?draw\.io\/(?:[^?#]+)?(?:\?.*)?$/i,
];
const KATEX_INLINE_CLASS_NAMES = ["language-math", "math-inline"] as const;
const KATEX_BLOCK_CLASS_NAMES = ["language-math", "math-display"] as const;
const SPAN_CLASS_NAMES = ["mention", "tag"] as const;
const INPUT_ATTRIBUTES = [...(defaultSchema.attributes?.input || []), ["checked", true]] as const;
export const isTrustedIframeSrc = (src: string): boolean => TRUSTED_IFRAME_SRC_PATTERNS.some((pattern) => pattern.test(src));
/**
* Sanitization schema for markdown HTML content.
* Extends the default schema to allow:
* - KaTeX marker classes used before trusted KaTeX rendering runs
* - Mention/tag metadata generated by trusted remark plugins
* - iframe embeds only from trusted video providers
*
* This prevents XSS attacks while preserving math rendering functionality.
*/
export const SANITIZE_SCHEMA = {
...defaultSchema,
// Don't re-prefix `id`/`name` attributes. remark-rehype already namespaces footnote ids with
// `user-content-` and emits matching `#user-content-…` hrefs; the default schema's clobbering
// would prepend a *second* `user-content-` to the ids only (not the hrefs), breaking in-page
// navigation. Leaving ids untouched keeps footnote (and heading) anchors pointing at real targets.
clobber: [],
attributes: {
...defaultSchema.attributes,
img: [...(defaultSchema.attributes?.img || []), "height", "width"],
input: INPUT_ATTRIBUTES,
code: [...(defaultSchema.attributes?.code || []), ["className", ...KATEX_INLINE_CLASS_NAMES, ...KATEX_BLOCK_CLASS_NAMES]],
span: [...(defaultSchema.attributes?.span || []), ["className", ...SPAN_CLASS_NAMES], ["aria*"], ["data*"]],
iframe: [
["src", ...TRUSTED_IFRAME_SRC_PATTERNS],
"width",
"height",
"frameborder",
"allowfullscreen",
"allow",
"title",
"referrerpolicy",
"loading",
],
},
tagNames: [...(defaultSchema.tagNames || []), "iframe"],
protocols: {
...defaultSchema.protocols,
src: ["https"],
},
};
+49
View File
@@ -0,0 +1,49 @@
import { memo, useMemo } from "react";
import { cn } from "@/lib/utils";
import { extractMentionUsernames } from "@/utils/remark-plugins/remark-mention";
import { MemoMarkdownRenderer } from "./MemoMarkdownRenderer";
import { useResolvedMentionUsernames } from "./MentionResolutionContext";
import type { MemoContentProps } from "./types";
// Stateless markdown renderer. Truncation is not this component's concern — compact cards
// are bounded by ClampedSection around the whole memo body; `compact` here only informs
// the renderer (e.g. footnote links navigate to the detail page instead of scrolling,
// since a collapsed card may hide the target).
const MemoContent = (props: MemoContentProps) => {
const { className, contentClassName, content, onClick, onDoubleClick } = props;
const mentionUsernames = useMemo(() => extractMentionUsernames(content), [content]);
const resolvedMentionUsernames = useResolvedMentionUsernames(mentionUsernames);
return (
<div className={`w-full flex flex-col justify-start items-start text-foreground ${className || ""}`}>
<div
data-memo-content
className={cn(
"relative w-full max-w-full wrap-break-word text-base leading-6",
"[&>*:last-child]:mb-0",
"[&_.katex-display]:max-w-full",
"[&_.katex-display]:overflow-x-auto",
"[&_.katex-display]:overflow-y-hidden",
// Footnotes: quiet GitHub-style footer — thin separator, smaller muted text, unobtrusive links.
"[&_.footnotes]:mt-4 [&_.footnotes]:border-t [&_.footnotes]:border-border [&_.footnotes]:pt-2",
"[&_.footnotes]:text-sm [&_.footnotes]:text-muted-foreground",
// GitHub renders footnote ref/backref links without an underline (underline on hover only).
"[&_[data-footnote-ref]]:no-underline [&_[data-footnote-ref]:hover]:underline",
"[&_.data-footnote-backref]:no-underline [&_.data-footnote-backref:hover]:underline",
contentClassName,
)}
onMouseUp={onClick}
onDoubleClick={onDoubleClick}
>
<MemoMarkdownRenderer
content={content}
resolvedMentionUsernames={resolvedMentionUsernames}
memoName={props.memoName}
compact={Boolean(props.compact)}
/>
</div>
</div>
);
};
export default memo(MemoContent);
@@ -0,0 +1,54 @@
import { Link } from "react-router-dom";
import { markdownStyles } from "@/lib/markdownStyles";
import { cn } from "@/lib/utils";
import type { ReactMarkdownProps } from "./types";
interface AnchorLinkProps extends React.AnchorHTMLAttributes<HTMLAnchorElement>, ReactMarkdownProps {
href: string;
/** Resource name of the enclosing memo (e.g. `memos/abc123`), when known. */
memoName?: string;
/** Whether the memo is rendered as a collapsed feed card. */
compact?: boolean;
children: React.ReactNode;
}
/**
* Renders in-page anchors (footnote references/backrefs, heading links — any `href="#…"`).
*
* When the target lives in a fully-rendered memo (detail, share, preview, expanded feed card),
* we scroll to it within that memo's own container — scoped so duplicate footnote ids across a
* feed can't send us to the wrong memo. When the memo is a collapsed feed card the footnote is
* below the fold, so we fall back to navigating to the memo detail page (with the hash), where
* MemoDetail scrolls the target into view.
*/
export const AnchorLink = ({ href, memoName, compact, children, className, node: _node, ...props }: AnchorLinkProps) => {
const handleClick = (event: React.MouseEvent<HTMLAnchorElement>) => {
if (compact) return; // Let the link navigate to the detail page.
const id = decodeURIComponent(href.slice(1));
if (!id) return;
// Scope the lookup to this memo's own container so duplicate footnote ids elsewhere in a feed
// can't steal the scroll.
const root = event.currentTarget.closest("[data-memo-content]");
const target = root?.querySelector(`#${CSS.escape(id)}`);
if (target) {
event.preventDefault();
target.scrollIntoView({ behavior: "smooth", block: "center" });
}
};
const classes = cn(markdownStyles.link, className);
if (memoName) {
return (
<Link to={`/${memoName}${href}`} onClick={handleClick} className={classes} {...props}>
{children}
</Link>
);
}
return (
<a href={href} onClick={handleClick} className={classes} {...props}>
{children}
</a>
);
};
@@ -0,0 +1,19 @@
import { markdownStyles } from "@/lib/markdownStyles";
import { cn } from "@/lib/utils";
import { NestedMarkdownRenderContext } from "../MarkdownRenderContext";
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(markdownStyles.blockquote, className)} {...props}>
<NestedMarkdownRenderContext>{children}</NestedMarkdownRenderContext>
</blockquote>
);
};
@@ -0,0 +1,23 @@
import { headingClass } from "@/lib/markdownStyles";
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.
* Anchor IDs are assigned by the rehypeHeadingId plugin.
*/
export const Heading = ({ level, children, className, node: _node, ...props }: HeadingProps) => {
const Component = `h${level}` as const;
return (
<Component className={cn(headingClass(level), className)} {...props}>
{children}
</Component>
);
};
@@ -0,0 +1,12 @@
import { markdownStyles } from "@/lib/markdownStyles";
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(markdownStyles.horizontalRule, className)} {...props} />;
};
@@ -0,0 +1,19 @@
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, height, width, style, ...props }: ImageProps) => {
return (
<img
className={cn("max-w-full my-2", !height && "h-auto", className)}
alt={alt}
style={{ height: height ? `${height}px` : undefined, width: width ? `${width}px` : undefined, ...style }}
{...props}
/>
);
};
@@ -0,0 +1,18 @@
import { markdownStyles } from "@/lib/markdownStyles";
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(markdownStyles.inlineCode, className)} {...props}>
{children}
</code>
);
};
@@ -0,0 +1,19 @@
import { markdownStyles } from "@/lib/markdownStyles";
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(markdownStyles.link, className)} {...props}>
{children}
</a>
);
};
@@ -0,0 +1,116 @@
import { Children, cloneElement, isValidElement, type ReactElement, type ReactNode } from "react";
import { markdownStyles } from "@/lib/markdownStyles";
import { cn } from "@/lib/utils";
import { TASK_LIST_CLASS, TASK_LIST_ITEM_CLASS } from "../constants";
import { NestedMarkdownRenderContext } from "../MarkdownRenderContext";
import type { ReactMarkdownProps } from "./types";
interface TaskListChildProps {
children?: ReactNode;
node?: {
tagName?: string;
properties?: {
type?: unknown;
};
};
type?: string;
}
const isCheckboxInput = (child: ReactNode): child is ReactElement<TaskListChildProps> => {
return (
isValidElement<TaskListChildProps>(child) && (child.props.type === "checkbox" || child.props.node?.properties?.type === "checkbox")
);
};
const isParagraphElement = (child: ReactNode): child is ReactElement<TaskListChildProps> => {
return isValidElement<TaskListChildProps>(child) && (child.type === "p" || child.props.node?.tagName === "p");
};
const splitTaskListItemChildren = (children: ReactNode) => {
let checkbox: ReactNode;
const content: ReactNode[] = [];
Children.toArray(children).forEach((child) => {
if (!checkbox && isCheckboxInput(child)) {
checkbox = child;
return;
}
if (!checkbox && isParagraphElement(child)) {
const paragraphChildren: ReactNode[] = [];
Children.toArray(child.props.children).forEach((paragraphChild) => {
if (!checkbox && isCheckboxInput(paragraphChild)) {
checkbox = paragraphChild;
return;
}
paragraphChildren.push(paragraphChild);
});
if (checkbox) {
if (paragraphChildren.length > 0) {
content.push(cloneElement(child, undefined, ...paragraphChildren));
}
return;
}
}
content.push(child);
});
return { checkbox, content };
};
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);
// Task list indentation is handled by task item grid columns; regular lists
// use the shared token (padding + list style).
const listClass = isTaskList ? "my-0 mb-2 list-outside list-none" : ordered ? markdownStyles.orderedList : markdownStyles.bulletList;
return (
<Component className={cn(listClass, 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
*/
export const ListItem = ({ children, className, node: _node, ...domProps }: ListItemProps) => {
const isTaskListItem = className?.includes(TASK_LIST_ITEM_CLASS);
if (isTaskListItem) {
const { checkbox, content } = splitTaskListItemChildren(children);
return (
<li className={cn(markdownStyles.taskListItem, className)} {...domProps}>
<NestedMarkdownRenderContext>
{checkbox}
<div className={markdownStyles.taskItemContent}>{content}</div>
</NestedMarkdownRenderContext>
</li>
);
}
return (
<li className={cn(markdownStyles.listItem, className)} {...domProps}>
<NestedMarkdownRenderContext>{children}</NestedMarkdownRenderContext>
</li>
);
};
@@ -0,0 +1,63 @@
import type { Element } from "hast";
import { useLinkPreviewEnabled } from "@/contexts/ViewContext";
import { markdownStyles } from "@/lib/markdownStyles";
import { cn } from "@/lib/utils";
import LinkMetadataCard from "../LinkMetadataCard";
import { useMarkdownRenderContext } from "../MarkdownRenderContext";
import type { ReactMarkdownProps } from "./types";
interface ParagraphProps extends React.HTMLAttributes<HTMLParagraphElement>, ReactMarkdownProps {
children: React.ReactNode;
}
export function getSingleLinkHref(node?: Element): string | undefined {
if (!node || node.tagName !== "p") {
return undefined;
}
const meaningfulChildren = node.children.filter((child) => {
return !(child.type === "text" && child.value.trim() === "");
});
if (meaningfulChildren.length !== 1) {
return undefined;
}
const onlyChild = meaningfulChildren[0];
if (onlyChild.type !== "element" || onlyChild.tagName !== "a") {
return undefined;
}
const href = onlyChild.properties?.href;
if (typeof href !== "string") {
return undefined;
}
const meaningfulLinkChildren = onlyChild.children.filter((child) => {
return !(child.type === "text" && child.value.trim() === "");
});
if (meaningfulLinkChildren.length !== 1) {
return undefined;
}
const onlyLinkChild = meaningfulLinkChildren[0];
return onlyLinkChild.type === "text" && onlyLinkChild.value === href ? href : undefined;
}
export const Paragraph = ({ children, className, node, ...props }: ParagraphProps) => {
const { blockDepth } = useMarkdownRenderContext();
const linkPreviewEnabled = useLinkPreviewEnabled();
const href = blockDepth === 0 && linkPreviewEnabled ? getSingleLinkHref(node) : undefined;
const paragraph = (
<p className={cn(markdownStyles.paragraph, className)} {...props}>
{children}
</p>
);
if (href) {
return <LinkMetadataCard url={href} fallback={paragraph} />;
}
return paragraph;
};
@@ -0,0 +1,19 @@
# Markdown Components
Small React components used by `MemoMarkdownRenderer` to style HTML emitted by `react-markdown`.
## Responsibilities
- Keep element styling local to each semantic HTML element.
- Strip the `node` prop from DOM output through `ReactMarkdownProps`.
- Preserve existing markdown behavior while avoiding structural fixes in CSS.
## Task Lists
GFM task lists are normalized before rendering by `remarkSplitMixedTaskLists`.
- Mixed task/bullet lists are split into separate lists so regular bullets keep bullets.
- Single-block split items are rendered as tight list items, preventing accidental `<p>` wrappers.
- `ListItem` uses a two-column grid: checkbox/control in the first column and a single task-body wrapper in the second.
- Task text, emphasis, links, tags, and nested content stay inside the body wrapper so inline markdown does not become separate grid items.
- Loose task items keep paragraph structure inside the task-body wrapper.
@@ -0,0 +1,9 @@
export { AnchorLink } from "./AnchorLink";
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";
@@ -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;
}
+13
View File
@@ -0,0 +1,13 @@
import type React from "react";
export interface MemoContentProps {
content: string;
/** Resource name of the memo (e.g. `memos/abc123`). Enables footnote links to target the memo detail page. */
memoName?: string;
/** The card renders collapsed (ClampedSection), so footnote links navigate instead of scrolling. */
compact?: boolean;
className?: string;
contentClassName?: string;
onClick?: (e: React.MouseEvent) => void;
onDoubleClick?: (e: React.MouseEvent) => void;
}
+25
View File
@@ -0,0 +1,25 @@
import { isValidElement, type ReactElement, type ReactNode } 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: ReactNode): string => {
const codeElement = isValidElement(children) ? (children as ReactElement<{ children?: ReactNode }>) : null;
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] : "";
};
@@ -0,0 +1,139 @@
import { create } from "@bufbuild/protobuf";
import { timestampDate } from "@bufbuild/protobuf/wkt";
import { isEqual } from "lodash-es";
import { CheckCircleIcon, ChevronRightIcon, Code2Icon, HashIcon, ImageIcon, LinkIcon, type LucideIcon, Share2Icon } from "lucide-react";
import { useMemo, useState } from "react";
import { Button } from "@/components/ui/button";
import useCurrentUser from "@/hooks/useCurrentUser";
import { cn } from "@/lib/utils";
import { Memo, Memo_PropertySchema } from "@/types/proto/api/v1/memo_service_pb";
import { type Translations, useTranslate } from "@/utils/i18n";
import { extractHeadings } from "@/utils/markdown-manipulation";
import { isSuperUser } from "@/utils/user";
import MemoOutline from "./MemoOutline";
import MemoSharePanel from "./MemoSharePanel";
interface Props {
memo: Memo;
className?: string;
onShareImageOpen?: () => void;
}
interface PropertyBadge {
icon: LucideIcon;
labelKey: Translations;
}
const SidebarSection = ({ label, count, children }: { label: string; count?: number; children: React.ReactNode }) => (
<div className="w-full space-y-2">
<div className="flex items-center gap-1.5">
<p className="text-xs font-medium text-muted-foreground/50 uppercase tracking-wider">{label}</p>
{count != null && <span className="text-xs text-muted-foreground/30">({count})</span>}
</div>
{children}
</div>
);
const PROPERTY_BADGE_CLASSES =
"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";
const TAG_BADGE_CLASSES =
"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";
const SHARE_ACTION_ROW_CLASSES =
"h-auto min-h-0 w-full justify-between rounded-none px-2 py-1.5 text-xs font-normal leading-tight text-muted-foreground transition-colors hover:bg-muted/40 hover:text-muted-foreground focus-visible:ring-offset-0 gap-1.5";
const MemoDetailSidebar = ({ memo, className, onShareImageOpen }: Props) => {
const t = useTranslate();
const currentUser = useCurrentUser();
const [sharePanelOpen, setSharePanelOpen] = useState(false);
const property = create(Memo_PropertySchema, memo.property || {});
const canManageShares = !memo.parent && (memo.creator === currentUser?.name || isSuperUser(currentUser));
const hasUpdated = !isEqual(memo.createTime, memo.updateTime);
const headings = useMemo(() => extractHeadings(memo.content), [memo.content]);
const propertyBadges = useMemo(() => {
const badges: PropertyBadge[] = [];
if (property.hasLink) badges.push({ icon: LinkIcon, labelKey: "memo.links" });
if (property.hasTaskList) badges.push({ icon: CheckCircleIcon, labelKey: "memo.to-do" });
if (property.hasCode) badges.push({ icon: Code2Icon, labelKey: "memo.code" });
return badges;
}, [property.hasLink, property.hasTaskList, property.hasCode]);
return (
<aside className={cn("relative w-full h-auto max-h-screen overflow-auto flex flex-col gap-5", className)}>
{headings.length > 0 && (
<SidebarSection label={t("memo.outline")}>
<MemoOutline headings={headings} />
</SidebarSection>
)}
{(canManageShares || onShareImageOpen) && (
<SidebarSection label={t("memo.share.section-label")}>
<div className="overflow-hidden rounded-md border border-border/50 bg-muted/20">
{onShareImageOpen && (
<Button variant="ghost" size="sm" className={SHARE_ACTION_ROW_CLASSES} onClick={onShareImageOpen}>
<span className="flex min-w-0 flex-1 items-center gap-2">
<ImageIcon className="size-3.5 shrink-0 text-muted-foreground/90" />
<span className="truncate">{t("memo.share.open-image")}</span>
</span>
<ChevronRightIcon className="size-3.5 shrink-0 text-muted-foreground/35" />
</Button>
)}
{onShareImageOpen && canManageShares && <div className="border-t border-border/50" />}
{canManageShares && (
<Button variant="ghost" size="sm" className={SHARE_ACTION_ROW_CLASSES} onClick={() => setSharePanelOpen(true)}>
<span className="flex min-w-0 flex-1 items-center gap-2">
<Share2Icon className="size-3.5 shrink-0 text-muted-foreground/90" />
<span className="truncate">{t("memo.share.open-panel")}</span>
</span>
<ChevronRightIcon className="size-3.5 shrink-0 text-muted-foreground/35" />
</Button>
)}
</div>
</SidebarSection>
)}
<SidebarSection label={t("common.created-at")}>
<div className="flex flex-col gap-1">
<p className="text-sm text-foreground/70">{memo.createTime ? timestampDate(memo.createTime).toLocaleString() : "—"}</p>
{hasUpdated && (
<p className="text-xs text-muted-foreground">
{t("common.last-updated-at")}: {memo.updateTime ? timestampDate(memo.updateTime).toLocaleString() : "—"}
</p>
)}
</div>
</SidebarSection>
{propertyBadges.length > 0 && (
<SidebarSection label={t("common.properties")}>
<div className="flex flex-wrap gap-1.5">
{propertyBadges.map(({ icon: Icon, labelKey }) => (
<span key={labelKey} className={PROPERTY_BADGE_CLASSES}>
<Icon className="w-3.5 h-3.5" />
{t(labelKey)}
</span>
))}
</div>
</SidebarSection>
)}
{memo.tags.length > 0 && (
<SidebarSection label={t("common.tags")} count={memo.tags.length}>
<div className="flex flex-wrap gap-1.5">
{memo.tags.map((tag) => (
<span key={tag} className={TAG_BADGE_CLASSES}>
<HashIcon className="w-3 h-3 opacity-50" />
{tag}
</span>
))}
</div>
</SidebarSection>
)}
{sharePanelOpen && <MemoSharePanel memoName={memo.name} open={sharePanelOpen} onClose={() => setSharePanelOpen(false)} />}
</aside>
);
};
export default MemoDetailSidebar;
@@ -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;
onShareImageOpen?: () => void;
}
const MemoDetailSidebarDrawer = ({ memo, onShareImageOpen }: 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} onShareImageOpen={onShareImageOpen} />
</SheetContent>
</Sheet>
);
};
export default MemoDetailSidebarDrawer;
@@ -0,0 +1,52 @@
import { cn } from "@/lib/utils";
import type { HeadingItem } from "@/utils/markdown-manipulation";
interface MemoOutlineProps {
headings: HeadingItem[];
}
const levelIndent: Record<number, string> = {
1: "ml-0",
2: "ml-3",
3: "ml-6",
4: "ml-8",
};
/** Outline navigation for memo headings (h1h4). */
const MemoOutline = ({ headings }: MemoOutlineProps) => {
const handleClick = (e: React.MouseEvent<HTMLAnchorElement>, slug: string) => {
e.preventDefault();
const el = document.getElementById(slug);
if (el) {
el.scrollIntoView({ behavior: "smooth", block: "start" });
window.history.replaceState(null, "", `#${slug}`);
}
};
return (
<nav className="relative flex flex-col">
{headings.map((heading, index) => (
<a
key={`${heading.slug}-${index}`}
href={`#${heading.slug}`}
onClick={(e) => handleClick(e, heading.slug)}
className={cn(
"group relative block py-[5px] pr-1 text-[13px] leading-snug truncate",
"text-muted-foreground/60 hover:text-foreground/90",
"transition-colors duration-200 ease-out",
levelIndent[heading.level],
heading.level === 1 && "font-medium text-muted-foreground/80",
)}
title={heading.text}
>
<span className="relative">
{heading.text}
<span className="absolute -bottom-px left-0 h-px w-0 bg-foreground/30 transition-all duration-200 group-hover:w-full" />
</span>
</a>
))}
</nav>
);
};
export default MemoOutline;
@@ -0,0 +1,158 @@
import { timestampDate } from "@bufbuild/protobuf/wkt";
import { ConnectError } from "@connectrpc/connect";
import { CheckIcon, CopyIcon, LinkIcon, Loader2Icon, Trash2Icon } from "lucide-react";
import { useState } from "react";
import { toast } from "react-hot-toast";
import { Button } from "@/components/ui/button";
import { Dialog, DialogContent, DialogHeader, DialogTitle } from "@/components/ui/dialog";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import { getShareUrl, useCreateMemoShare, useDeleteMemoShare, useMemoShares } from "@/hooks/useMemoShareQueries";
import type { MemoShare } from "@/types/proto/api/v1/memo_service_pb";
import { useTranslate } from "@/utils/i18n";
type ExpiryOption = "never" | "1d" | "7d" | "30d";
function getExpireDate(option: ExpiryOption): Date | undefined {
if (option === "never") return undefined;
const d = new Date();
if (option === "1d") d.setDate(d.getDate() + 1);
else if (option === "7d") d.setDate(d.getDate() + 7);
else if (option === "30d") d.setDate(d.getDate() + 30);
return d;
}
function formatExpiry(share: MemoShare, t: ReturnType<typeof useTranslate>): string {
if (!share.expireTime) return t("memo.share.never-expires");
const d = timestampDate(share.expireTime);
return t("memo.share.expires-on", { date: d.toLocaleDateString() });
}
interface ShareLinkRowProps {
share: MemoShare;
memoName: string;
}
function ShareLinkRow({ share, memoName }: ShareLinkRowProps) {
const t = useTranslate();
const [copied, setCopied] = useState(false);
const deleteShare = useDeleteMemoShare();
const url = getShareUrl(share);
const handleCopy = async () => {
await navigator.clipboard.writeText(url);
setCopied(true);
setTimeout(() => setCopied(false), 2000);
};
const handleRevoke = async () => {
try {
await deleteShare.mutateAsync({ name: share.name, memoName });
toast.success(t("memo.share.revoked"));
} catch (e) {
toast.error((e as ConnectError).message || t("memo.share.revoke-failed"));
}
};
return (
<div className="flex flex-col gap-1 rounded-md border border-border p-3">
<div className="flex items-center justify-between gap-2">
<span className="truncate font-mono text-xs text-muted-foreground">{url}</span>
<div className="flex shrink-0 items-center gap-1">
<Button variant="ghost" size="icon" className="h-7 w-7" onClick={handleCopy} title={t("memo.share.copy")}>
{copied ? <CheckIcon className="h-3.5 w-3.5 text-success" /> : <CopyIcon className="h-3.5 w-3.5" />}
</Button>
<Button
variant="ghost"
size="icon"
className="h-7 w-7 text-destructive hover:text-destructive"
onClick={handleRevoke}
disabled={deleteShare.isPending}
title={t("memo.share.revoke")}
>
<Trash2Icon className="h-3.5 w-3.5" />
</Button>
</div>
</div>
<p className="text-xs text-muted-foreground">{formatExpiry(share, t)}</p>
</div>
);
}
interface MemoSharePanelProps {
open: boolean;
onClose: () => void;
memoName: string;
}
const MemoSharePanel = ({ open, onClose, memoName }: MemoSharePanelProps) => {
const t = useTranslate();
const [expiry, setExpiry] = useState<ExpiryOption>("never");
const { data: shares = [], isLoading } = useMemoShares(memoName, { enabled: open });
const createShare = useCreateMemoShare();
const handleCreate = async () => {
try {
await createShare.mutateAsync({ memoName, expireTime: getExpireDate(expiry) });
} catch (e) {
toast.error((e as ConnectError).message || t("memo.share.create-failed"));
}
};
return (
<Dialog open={open} onOpenChange={(v) => !v && onClose()}>
<DialogContent className="sm:max-w-md">
<DialogHeader>
<DialogTitle className="flex items-center gap-2">
<LinkIcon className="h-4 w-4" />
{t("memo.share.title")}
</DialogTitle>
</DialogHeader>
<div className="flex flex-col gap-4 py-2">
{/* Active links */}
<div className="flex flex-col gap-2">
<p className="text-sm font-medium text-muted-foreground">{t("memo.share.active-links")}</p>
{isLoading ? (
<Loader2Icon className="h-4 w-4 animate-spin text-muted-foreground" />
) : shares.length === 0 ? (
<p className="text-sm text-muted-foreground">{t("memo.share.no-links")}</p>
) : (
<div className="flex flex-col gap-2">
{shares.map((share) => (
<ShareLinkRow key={share.name} share={share} memoName={memoName} />
))}
</div>
)}
</div>
{/* Create new link */}
<div className="flex items-center gap-2">
<Select value={expiry} onValueChange={(v) => setExpiry(v as ExpiryOption)}>
<SelectTrigger className="w-36">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="never">{t("memo.share.expiry-never")}</SelectItem>
<SelectItem value="1d">{t("memo.share.expiry-1-day")}</SelectItem>
<SelectItem value="7d">{t("memo.share.expiry-7-days")}</SelectItem>
<SelectItem value="30d">{t("memo.share.expiry-30-days")}</SelectItem>
</SelectContent>
</Select>
<Button onClick={handleCreate} disabled={createShare.isPending} className="flex-1">
{createShare.isPending ? (
<>
<Loader2Icon className="mr-2 h-4 w-4 animate-spin" />
{t("memo.share.creating")}
</>
) : (
t("memo.share.create-link")
)}
</Button>
</div>
</div>
</DialogContent>
</Dialog>
);
};
export default MemoSharePanel;
@@ -0,0 +1,4 @@
import MemoDetailSidebar from "./MemoDetailSidebar";
import MemoDetailSidebarDrawer from "./MemoDetailSidebarDrawer";
export { MemoDetailSidebar, MemoDetailSidebarDrawer };
@@ -0,0 +1,151 @@
import { Columns2Icon, Columns3Icon, InfinityIcon, type LucideIcon, Rows3Icon, Settings2Icon } from "lucide-react";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import { Switch } from "@/components/ui/switch";
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
import { MAX_COLUMNS_VALUES, type MemoMaxColumns, 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;
}
// Keyed by the context's canonical value list, so adding a column option forces an icon and
// wording here at compile time. The i18n param is deliberately named `n`, not `count` —
// i18next would route `count` through plural-form lookup.
const LAYOUT_OPTIONS: Record<MemoMaxColumns, { icon: LucideIcon; key: "layout-list" | "layout-columns" | "layout-auto" }> = {
1: { icon: Rows3Icon, key: "layout-list" },
2: { icon: Columns2Icon, key: "layout-columns" },
3: { icon: Columns3Icon, key: "layout-columns" },
0: { icon: InfinityIcon, key: "layout-auto" },
};
function MemoDisplaySettingMenu({ className }: Props) {
const t = useTranslate();
const {
orderByTimeAsc,
timeBasis,
compactMode,
linkPreview,
maxColumns,
setTimeBasis,
toggleSortOrder,
setCompactMode,
setLinkPreview,
setMaxColumns,
} = useView();
// Multi-column grids always render compact tiles, so the toggle is shown as on and locked
// there; it only becomes a real choice at a single column.
const compactLocked = maxColumns !== 1;
return (
<Popover>
<PopoverTrigger className={cn(className, "opacity-40 hover:opacity-100 transition-opacity")}>
<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.shown-time")}</span>
<Select value={timeBasis} onValueChange={(value) => setTimeBasis(value === "update_time" ? "update_time" : "create_time")}>
<SelectTrigger size="sm" className="w-32">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="create_time">{t("common.created-at")}</SelectItem>
<SelectItem value="update_time">{t("common.last-updated-at")}</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("memo.order")}</span>
<Select
value={orderByTimeAsc.toString()}
onValueChange={(value) => {
if ((value === "true") !== orderByTimeAsc) {
toggleSortOrder();
}
}}
>
<SelectTrigger size="sm" className="w-32">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="false">{t("memo.newest-first")}</SelectItem>
<SelectItem value="true">{t("memo.oldest-first")}</SelectItem>
</SelectContent>
</Select>
</div>
<div className="w-full flex flex-row justify-between items-center">
<span className={cn("text-sm shrink-0 mr-3", compactLocked ? "text-muted-foreground" : "text-foreground")}>
{t("memo.compact-mode")}
</span>
<Switch checked={compactLocked || compactMode} onCheckedChange={setCompactMode} disabled={compactLocked} />
</div>
<div className="w-full flex flex-row justify-between items-center">
<span className="text-sm shrink-0 mr-3 text-foreground">{t("memo.link-preview")}</span>
<Switch checked={linkPreview} onCheckedChange={setLinkPreview} />
</div>
<div className="w-full flex flex-row justify-between items-center border-t border-border/50 pt-2">
<span className="text-sm shrink-0 mr-3 text-foreground">{t("memo.layout")}</span>
{/* A quiet muted track (28px tall, borderless); only the active option carries the accent
fill. A radiogroup with roving tabindex, since the options are mutually exclusive. */}
<div
role="radiogroup"
aria-label={t("memo.layout")}
className="flex items-center gap-0.5 rounded-lg bg-muted/50 p-0.5"
onKeyDown={(event) => {
const delta =
event.key === "ArrowRight" || event.key === "ArrowDown"
? 1
: event.key === "ArrowLeft" || event.key === "ArrowUp"
? -1
: 0;
if (delta === 0) return;
event.preventDefault();
const index = MAX_COLUMNS_VALUES.indexOf(maxColumns);
const next = MAX_COLUMNS_VALUES[(index + delta + MAX_COLUMNS_VALUES.length) % MAX_COLUMNS_VALUES.length];
setMaxColumns(next);
event.currentTarget.querySelector<HTMLButtonElement>(`[data-value="${next}"]`)?.focus();
}}
>
{MAX_COLUMNS_VALUES.map((value) => {
const { icon: Icon, key } = LAYOUT_OPTIONS[value];
const label = t(`memo.${key}`, { n: value });
const active = maxColumns === value;
return (
<Tooltip key={value}>
<TooltipTrigger asChild>
<button
type="button"
role="radio"
aria-checked={active}
aria-label={label}
tabIndex={active ? 0 : -1}
data-value={value}
onClick={() => setMaxColumns(value)}
className={cn(
"grid h-6 w-7 place-items-center rounded-md transition-colors",
active ? "bg-accent text-accent-foreground" : "text-muted-foreground/70 hover:bg-accent/50 hover:text-foreground",
)}
>
<Icon className="w-3.5 h-3.5" />
</button>
</TooltipTrigger>
<TooltipContent>
<p className="font-medium">{label}</p>
<p className="text-primary-foreground/70">{t(`memo.${key}-description`, { n: value })}</p>
</TooltipContent>
</Tooltip>
);
})}
</div>
</div>
</div>
</PopoverContent>
</Popover>
);
}
export default MemoDisplaySettingMenu;
@@ -0,0 +1,37 @@
import { EditorSelection, type EditorState } from "@codemirror/state";
import { EditorView } from "@codemirror/view";
import type { EditorController, FormattingController } from "../types/editorController";
const isEmptyDoc = (state: EditorState) => state.doc.toString().trim() === "";
/** Block padding for insertMarkdown: ensure the inserted text is its own block. */
function blockPad(before: string, after: string): { prefix: string; suffix: string } {
const prefix = before.length === 0 || before.endsWith("\n\n") ? "" : before.endsWith("\n") ? "\n" : "\n\n";
const suffix = after.length === 0 || after.startsWith("\n\n") ? "" : after.startsWith("\n") ? "\n" : "\n\n";
return { prefix, suffix };
}
export function createController(view: EditorView, formatting: FormattingController): EditorController {
return {
focus: () => view.focus(),
hasFocus: () => view.hasFocus,
isEmpty: () => isEmptyDoc(view.state),
getMarkdown: () => view.state.doc.toString(),
setMarkdown: (markdown) => {
view.dispatch({ changes: { from: 0, to: view.state.doc.length, insert: markdown } });
},
insertMarkdown: (markdown) => {
if (!markdown) return;
const { from, to } = view.state.selection.main;
const doc = view.state.doc.toString();
const { prefix, suffix } = blockPad(doc.slice(0, from), doc.slice(to));
const insert = prefix + markdown + suffix;
const caret = from + insert.length;
view.dispatch({ changes: { from, to, insert }, selection: { anchor: caret }, scrollIntoView: true });
view.focus();
},
scrollToCursor: () => view.dispatch({ effects: EditorView.scrollIntoView(view.state.selection.main.head) }),
selectAll: () => view.dispatch({ selection: EditorSelection.range(0, view.state.doc.length) }),
formatting,
};
}
@@ -0,0 +1,138 @@
/*
* Styling for the CodeMirror source editor, in plain CSS with the app's theme
* variables — the same model Obsidian / GitHub use for an app-integrated CM6
* editor (class-based tokens + CSS, not a CSS-in-JS theme). Co-located with the
* component and scoped to its wrapper so nothing leaks to other CM instances.
*
* Token classes (.cm-md-*) come from the tagHighlighter in theme.ts; heading
* line classes (.cm-md-h*) from headingDecorations.ts; #tag/@mention
* (.cm-memo-*) from tagMentionDecorations.ts; the rest are CodeMirror's own.
*/
.memo-editor-content {
& .cm-editor {
background: transparent;
font-size: 1rem;
max-height: 50vh;
}
&[data-focus-mode] .cm-editor {
height: 100%;
max-height: none;
}
& .cm-editor.cm-focused {
outline: none;
}
& .cm-content {
font-family: inherit;
padding: 0;
}
& .cm-line {
padding: 0;
}
& .cm-scroller {
font-family: inherit;
line-height: 1.5;
overflow: auto;
}
& .cm-placeholder {
color: var(--muted-foreground);
opacity: 0.7;
}
/* drawSelection() caret + selection (no base color theme is loaded). */
& .cm-cursor,
& .cm-dropCursor {
border-left-color: var(--foreground);
}
& .cm-selectionBackground,
& .cm-focused .cm-selectionBackground {
background: var(--accent);
}
/* Markdown tokens. Markers (#, *, fences, bullets) stay visible but muted
(.cm-md-mark); styled text leads. */
& .cm-md-strong {
font-weight: 700;
}
& .cm-md-emphasis {
font-style: italic;
}
& .cm-md-strike {
text-decoration: line-through;
}
& .cm-md-code {
font-family: var(--font-mono, monospace);
color: var(--foreground);
}
& .cm-md-link {
color: var(--primary);
text-decoration: underline;
}
& .cm-md-url {
color: var(--muted-foreground);
}
& .cm-md-quote {
color: var(--muted-foreground);
font-style: italic;
}
& .cm-md-mark {
color: var(--muted-foreground);
}
/* Heading lines (require a space after the hashes; headingDecorations.ts). */
& .cm-md-h1 {
font-size: 1.5em;
font-weight: 600;
line-height: 1.3;
}
& .cm-md-h2 {
font-size: 1.3em;
font-weight: 600;
}
& .cm-md-h3 {
font-size: 1.15em;
font-weight: 600;
}
& .cm-md-h4,
& .cm-md-h5,
& .cm-md-h6 {
font-weight: 600;
}
/* #tag / @mention decorations. */
& .cm-memo-tag,
& .cm-memo-mention {
color: var(--primary);
}
/* Tag autocomplete popover — app surface, not CodeMirror defaults. */
& .cm-tooltip.cm-tooltip-autocomplete {
background: var(--popover);
color: var(--popover-foreground);
border: 1px solid var(--border);
border-radius: 0.5rem;
box-shadow: var(--shadow-md);
overflow: hidden;
font-family: inherit;
font-size: 0.9rem;
}
& .cm-tooltip-autocomplete > ul {
font-family: inherit;
color: var(--popover-foreground);
}
& .cm-tooltip-autocomplete > ul > li {
padding: 0.25rem 0.5rem;
color: var(--popover-foreground);
}
& .cm-tooltip-autocomplete > ul > li[aria-selected] {
background: var(--accent);
color: var(--accent-foreground);
}
& .cm-completionIcon {
display: none;
}
& .cm-completionMatchedText {
text-decoration: none;
font-weight: 600;
color: inherit;
}
}
@@ -0,0 +1,112 @@
import { defaultKeymap, history, historyKeymap, indentWithTab } from "@codemirror/commands";
import { markdown } from "@codemirror/lang-markdown";
import { indentUnit } from "@codemirror/language";
import { Compartment, type Extension } from "@codemirror/state";
import { placeholder as cmPlaceholder, drawSelection, dropCursor, EditorView, type KeyBinding, keymap } from "@codemirror/view";
import { GFM } from "@lezer/markdown";
import { headingDecorations } from "./headingDecorations";
import { liftListItem, sinkListItem } from "./listIndent";
import { tagAutocomplete } from "./tagAutocomplete";
import { tagMentionDecorations } from "./tagMentionDecorations";
import { memoEditorTheme } from "./theme";
// Key bindings layered below the autocomplete keymap so the completion popup's
// own Tab/Escape win while it is open. On a list item, Tab/Shift-Tab nest /
// outdent it (marker-aware, CommonMark-valid); elsewhere they fall through to
// indentWithTab's plain indent. Escape blurs the editor so keyboard users keep
// an escape hatch out of the otherwise Tab-trapping editor.
const editorKeys: KeyBinding[] = [
{
key: "Escape",
run: (view) => {
view.contentDOM.blur();
return true;
},
},
{ key: "Tab", run: sinkListItem },
{ key: "Shift-Tab", run: liftListItem },
];
export interface EditorExtensionsOptions {
placeholder: string;
onChange: (markdown: string) => void;
onFiles: (files: File[]) => void;
onUpdate: () => void;
onSubmit: () => void;
getTags: () => string[];
}
export const placeholderCompartment = new Compartment();
function clipboardFiles(event: ClipboardEvent): File[] {
const clipboard = event.clipboardData;
if (!clipboard) return [];
const itemFiles = Array.from(clipboard.items)
.filter((item) => item.kind === "file")
.map((item) => item.getAsFile())
.filter((file): file is File => file !== null);
return itemFiles.length > 0 ? itemFiles : Array.from(clipboard.files);
}
export function buildEditorExtensions({
placeholder,
onChange,
onFiles,
onUpdate,
onSubmit,
getTags,
}: EditorExtensionsOptions): Extension[] {
// Submitting must outrank defaultKeymap's own Mod-Enter (insertBlankLine): the save
// shortcut ends the memo, it must not also edit the document. Meta and Ctrl are bound
// explicitly (not via the platform-dependent Mod-) so Cmd+Enter and Ctrl+Enter both
// submit everywhere, matching the historical window-level shortcut.
const submit = () => {
onSubmit();
return true;
};
const submitKeys: KeyBinding[] = [
{ key: "Meta-Enter", run: submit },
{ key: "Ctrl-Enter", run: submit },
];
return [
// Core editing behavior. These are the pieces from CM6 setup that this memo
// editor uses, without enabling multi-cursor selection.
history(),
drawSelection(),
dropCursor(),
// Indent with spaces (markdown), matching the 2-space bullet nesting.
indentUnit.of(" "),
markdown({ extensions: [GFM] }),
...memoEditorTheme,
EditorView.lineWrapping,
placeholderCompartment.of(cmPlaceholder(placeholder)),
EditorView.domEventHandlers({
paste: (event) => {
const files = clipboardFiles(event);
if (files.length === 0) return false;
onFiles(files);
return true;
},
drop: (event) => {
const files = Array.from(event.dataTransfer?.files ?? []);
if (files.length === 0) return false;
onFiles(files);
return true;
},
}),
tagMentionDecorations,
headingDecorations,
// tagAutocomplete must precede the editing keymap so the completion popup's
// Enter/Tab/arrow bindings win while it is open.
tagAutocomplete(getTags),
keymap.of([...submitKeys, ...editorKeys, indentWithTab, ...defaultKeymap, ...historyKeymap]),
EditorView.updateListener.of((u) => {
if (u.docChanged) onChange(u.state.doc.toString());
// Toolbar active-state depends only on the doc and selection; skip the
// getActiveFormats tree walk on focus/viewport/measure-only updates.
if (u.docChanged || u.selectionSet) onUpdate();
}),
];
}
@@ -0,0 +1,372 @@
import { syntaxTree } from "@codemirror/language";
import type { EditorState } from "@codemirror/state";
import type { EditorView } from "@codemirror/view";
import {
type ActiveFormatState,
type EditorCommandContext,
type EditorCommandId,
EMPTY_ACTIVE_FORMATS,
toToolbarHeadingLevel,
} from "../formatting/commands";
import type { FormattingController } from "../types/editorController";
import { leadingWhitespace, selectedLineNumbers } from "./listIndent";
type MarkCommand = "bold" | "italic" | "strikethrough" | "code";
type ListCommand = "bulletList" | "orderedList" | "taskList";
// One row per inline mark: the markdown token plus the syntax-tree wrapper and
// delimiter node names (verified empirically against the Lezer markdown parser:
// StrongEmphasis/Emphasis use `EmphasisMark`, InlineCode uses `CodeMark`, and
// GFM strikethrough uses `Strikethrough`/`StrikethroughMark`).
// Dispatch, toggling, the delimiter guard, and active-state detection all
// derive from this table, so adding a mark is one row here + a catalog entry.
const MARKS: Record<MarkCommand, { token: string; wrapper: string; delimiter: string }> = {
bold: { token: "**", wrapper: "StrongEmphasis", delimiter: "EmphasisMark" },
italic: { token: "*", wrapper: "Emphasis", delimiter: "EmphasisMark" },
strikethrough: { token: "~~", wrapper: "Strikethrough", delimiter: "StrikethroughMark" },
code: { token: "`", wrapper: "InlineCode", delimiter: "CodeMark" },
};
const MARK_COMMANDS = Object.keys(MARKS) as MarkCommand[];
const isMarkCommand = (command: EditorCommandId): command is MarkCommand => command in MARKS;
const WRAPPER_TO_MARK: Record<string, MarkCommand> = Object.fromEntries(MARK_COMMANDS.map((c) => [MARKS[c].wrapper, c]));
// A cursor inside one of these means the adjacent token characters belong to
// real parsed markup (e.g. between the two `*` of a bold delimiter), not to a
// dangling empty pair.
const DELIMITER_NODES = new Set(MARK_COMMANDS.map((c) => MARKS[c].delimiter));
// Doubled mark tokens (`****`, `~~~~`, …): what a freshly inserted empty pair
// looks like. Markdown parses some of them as entirely different constructs
// (`~~~~` is a bare tilde code fence), so consumers that would otherwise
// believe that construct check here first.
const EMPTY_MARK_PAIRS = new Set(MARK_COMMANDS.map((c) => MARKS[c].token + MARKS[c].token));
const MAX_EMPTY_PAIR_LENGTH = Math.max(...MARK_COMMANDS.map((c) => 2 * MARKS[c].token.length));
/** Whether [from, to) is exactly some mark's empty delimiter pair. */
function isEmptyMarkPair(state: EditorState, from: number, to: number): boolean {
return to - from <= MAX_EMPTY_PAIR_LENGTH && EMPTY_MARK_PAIRS.has(state.sliceDoc(from, to));
}
const LIST_MARKERS: Record<ListCommand, string> = { bulletList: "- ", orderedList: "1. ", taskList: "- [ ] " };
// Line-mode detection shared by the list toggles and getActiveFormats so the
// highlighted state and the toggle-off condition can never disagree. Order
// matters: a task line also matches the bullet pattern. Markers follow
// listIndent.ts: bullets `-*+`, ordered `1.` / `1)`, content starts after the
// marker's trailing whitespace.
const TASK_LINE = /^(\s*)[-*+]\s+\[[ xX]\]\s+/;
const BULLET_LINE = /^(\s*)[-*+]\s+/;
const ORDERED_LINE = /^(\s*)\d+[.)]\s+/;
// ATX heading; CommonMark allows up to three leading spaces and requires
// whitespace after the hashes — a bare `#` or a `#tag` is intentionally NOT a
// heading. Shared with headingDecorations.ts so toolbar state and rendered
// heading styling can't drift.
export const HEADING_LINE = /^ {0,3}(#{1,6})\s+/;
// Region setHeading replaces: an existing heading prefix including its leading
// spaces, or just the leading spaces on a non-heading line (the optional group
// makes the regex always match).
const HEADING_PREFIX = /^ {0,3}(?:#{1,6}\s+)?/;
type Tree = ReturnType<typeof syntaxTree>;
type TreeNode = ReturnType<Tree["resolve"]>;
/** The node at (pos, side) and its ancestors, innermost first. */
function* ancestors(tree: Tree, pos: number, side: -1 | 1): Generator<TreeNode> {
for (let n: TreeNode | null = tree.resolve(pos, side); n; n = n.parent) {
yield n;
}
}
/** Ranges of the direct `name` children of `node`. */
function childRanges(node: TreeNode, name: string): { from: number; to: number }[] {
const ranges: { from: number; to: number }[] = [];
for (let child = node.firstChild; child; child = child.nextSibling) {
if (child.name === name) ranges.push({ from: child.from, to: child.to });
}
return ranges;
}
interface LineListInfo {
mode: ListCommand | null;
/** Length of leading whitespace. */
indent: number;
/** Offset within the line where the item content starts (=== indent when mode is null). */
markerEnd: number;
}
function lineListInfo(text: string): LineListInfo {
const task = TASK_LINE.exec(text);
if (task) return { mode: "taskList", indent: task[1].length, markerEnd: task[0].length };
const bullet = BULLET_LINE.exec(text);
if (bullet) return { mode: "bulletList", indent: bullet[1].length, markerEnd: bullet[0].length };
const ordered = ORDERED_LINE.exec(text);
if (ordered) return { mode: "orderedList", indent: ordered[1].length, markerEnd: ordered[0].length };
const indent = leadingWhitespace(text);
return { mode: null, indent, markerEnd: indent };
}
function wrapSelection(view: EditorView, token: string) {
const { from, to } = view.state.selection.main;
const sel = view.state.sliceDoc(from, to);
view.dispatch({
changes: { from, to, insert: `${token}${sel}${token}` },
selection: { anchor: from + token.length, head: from + token.length + sel.length },
});
}
/**
* Delimiter child ranges of the nearest `wrapper` ancestor at the selection,
* or null when the selection doesn't sit in one (or it has fewer than
* `minMarks` delimiters). Shared by every toggle-off path so they all agree.
*
* The head probe mirrors getActiveFormats (resolve side -1) so stripping
* fires exactly when the toolbar shows the command active. With a non-empty
* selection, additionally probe both edges from inside the selection, so a
* selection that includes the delimiters (the whole `**text**`) still
* resolves into the wrapper regardless of selection direction.
*/
function findWrappedDelimiters(
view: EditorView,
wrapper: string,
delimiter: string,
minMarks: number,
): { from: number; to: number }[] | null {
const { from, to, head } = view.state.selection.main;
const tree = syntaxTree(view.state);
const probes: [number, -1 | 1][] =
from === to
? [[head, -1]]
: [
[head, -1],
[from, 1],
[to, -1],
];
for (const [pos, side] of probes) {
for (const n of ancestors(tree, pos, side)) {
if (n.name !== wrapper) continue;
const marks = childRanges(n, delimiter);
if (marks.length >= minMarks) return marks;
}
}
return null;
}
/**
* Toggle an inline mark (bold/italic/strikethrough/code). When the selection
* already sits in the corresponding mark, strip the surrounding delimiter
* nodes instead of nesting a new pair. Deleting the actual delimiter child
* ranges handles the differing delimiter lengths (`**` vs `` ` ``) automatically.
*/
function toggleMark(view: EditorView, command: MarkCommand) {
const { token, wrapper, delimiter } = MARKS[command];
const marks = findWrappedDelimiters(view, wrapper, delimiter, 2);
if (marks) {
const opening = marks[0];
const closing = marks[marks.length - 1];
view.dispatch({
changes: [
{ from: closing.from, to: closing.to, insert: "" },
{ from: opening.from, to: opening.to, insert: "" },
],
});
return;
}
// Empty pair: a cursor sitting between freshly inserted delimiters (`**|**`).
// Markdown never parses an empty mark as that mark (bare `****` is a
// horizontal rule, `~~~~` a tilde code fence, `` `` `` plain text), so the
// tree probe above can't see it — check the text instead. Without this,
// re-clicking the button keeps nesting new pairs.
const { from, to } = view.state.selection.main;
if (
from === to &&
from >= token.length &&
to + token.length <= view.state.doc.length &&
view.state.sliceDoc(from - token.length, to + token.length) === token + token
) {
const delFrom = from - token.length;
const delTo = to + token.length;
// Deleting is only unsafe when the adjacent tokens belong to parsed markup
// reaching beyond the pair itself — e.g. an italic click between the `*`s
// of a bold delimiter would destroy that bold. A construct contained
// entirely in the deletion range (the `~~~~` the parser reads as an empty
// tilde fence) is just this empty pair wearing another node name.
const tree = syntaxTree(view.state);
const blocking = (n: TreeNode) => {
if (!DELIMITER_NODES.has(n.name)) return false;
const construct = n.parent ?? n;
return construct.from < delFrom || construct.to > delTo;
};
if (!blocking(tree.resolve(from, -1)) && !blocking(tree.resolve(to, 1))) {
view.dispatch({
changes: [
{ from: delFrom, to: from, insert: "" },
{ from: to, to: delTo, insert: "" },
],
});
return;
}
}
wrapSelection(view, token);
}
/**
* Toggle a fenced code block. When the selection sits inside one, remove its
* fence lines (keeping the content); otherwise wrap the selected lines in a
* new ``` fence. Unclosed blocks (opening fence only) lose just that fence.
*/
function toggleCodeBlock(view: EditorView) {
const { state } = view;
const { from, to } = state.selection.main;
const marks = findWrappedDelimiters(view, "FencedCode", "CodeMark", 1);
if (marks) {
const openLine = state.doc.lineAt(marks[0].from);
// Delete each fence line together with its trailing newline. When the
// closing fence is the document's last line there is no trailing newline
// to take, so eat the preceding one instead — unless that would overlap
// the opening deletion (empty block at end of document).
const openEnd = Math.min(openLine.to + 1, state.doc.length);
const specs = [{ from: openLine.from, to: openEnd, insert: "" }];
if (marks.length >= 2) {
const closeLine = state.doc.lineAt(marks[marks.length - 1].from);
const closeIsLastLine = closeLine.to === state.doc.length;
const closeTo = closeIsLastLine ? closeLine.to : closeLine.to + 1;
const closeFrom = closeIsLastLine && closeLine.from - 1 >= openEnd ? closeLine.from - 1 : closeLine.from;
specs.push({ from: closeFrom, to: closeTo, insert: "" });
}
const changes = state.changes(specs);
view.dispatch({ changes, selection: state.selection.map(changes) });
return;
}
const lineNumbers = selectedLineNumbers(view);
const first = state.doc.line(lineNumbers[0]);
const last = state.doc.line(lineNumbers[lineNumbers.length - 1]);
const fence = "```";
// Both selection ends sit within [first.from, last.to], so they shift by
// exactly the opening `\`\`\`\n` — keeping the selection on the content (and
// dropping a lone cursor inside the new empty block).
view.dispatch({
changes: [
{ from: first.from, insert: `${fence}\n` },
{ from: last.to, insert: `\n${fence}` },
],
selection: { anchor: from + fence.length + 1, head: to + fence.length + 1 },
});
}
/**
* Toggle/convert the list mode of the selected lines. The three list modes are
* mutually exclusive line states: when every selected line is already in the
* requested mode the markers are removed; otherwise lines are converted to it
* (replacing any other list marker, preserving indentation). With a multi-line
* selection blank lines are left alone; a single selected blank line still gets
* a marker so the "start a list on an empty line" flow works.
*/
function toggleListLine(view: EditorView, command: ListCommand) {
const { state } = view;
const lines = selectedLineNumbers(view).map((n) => state.doc.line(n));
const nonBlank = lines.filter((line) => line.text.trim() !== "");
const targets = lines.length === 1 || nonBlank.length === 0 ? lines : nonBlank;
const infos = targets.map((line) => lineListInfo(line.text));
const allOn = infos.every((info) => info.mode === command);
const specs: { from: number; to: number; insert: string }[] = [];
for (const [i, line] of targets.entries()) {
const { mode, indent, markerEnd } = infos[i];
if (allOn) {
specs.push({ from: line.from + indent, to: line.from + markerEnd, insert: "" });
} else if (mode !== command) {
// Lines already in the requested mode keep their marker untouched (a
// checked `- [x]` stays checked when the selection is extended).
const marker = command === "orderedList" ? `${i + 1}. ` : LIST_MARKERS[command];
specs.push({ from: line.from + indent, to: line.from + markerEnd, insert: marker });
}
}
if (specs.length === 0) return;
const changes = state.changes(specs);
// Map with assoc 1 so a cursor exactly at the insertion point (empty line)
// lands after the inserted marker instead of staying at the line start.
view.dispatch({ changes, selection: state.selection.map(changes, 1) });
}
function setHeading(view: EditorView, level: number) {
const line = view.state.doc.lineAt(view.state.selection.main.head);
// Edit only the prefix region (not the whole line) so the cursor keeps its
// place in the text instead of being flung to the line start by the mapping.
const existing = HEADING_PREFIX.exec(line.text)?.[0].length ?? 0;
const insert = level === 0 ? "" : `${"#".repeat(level)} `;
const changes = view.state.changes({ from: line.from, to: line.from + existing, insert });
view.dispatch({ changes, selection: view.state.selection.map(changes, 1) });
}
/** Unwrap the link the head sits in to its label text. True when one was found. */
function unwrapLink(view: EditorView): boolean {
const head = view.state.selection.main.head;
const tree = syntaxTree(view.state);
for (const n of ancestors(tree, head, -1)) {
if (n.name !== "Link") continue;
const marks = childRanges(n, "LinkMark");
// marks[0] is `[`, marks[1] is `]` — the label sits between them.
if (marks.length < 2) continue;
const label = view.state.sliceDoc(marks[0].to, marks[1].from);
const anchor = n.from + Math.max(0, Math.min(label.length, head - marks[0].to));
view.dispatch({
changes: { from: n.from, to: n.to, insert: label },
selection: { anchor },
});
return true;
}
return false;
}
export function createFormattingController(view: EditorView, listeners: Set<() => void>): FormattingController {
return {
run(command: EditorCommandId, ctx?: EditorCommandContext) {
if (isMarkCommand(command)) return toggleMark(view, command);
if (command === "codeBlock") return toggleCodeBlock(view);
if (command === "bulletList" || command === "orderedList" || command === "taskList") {
return toggleListLine(view, command);
}
if (command === "heading1") return setHeading(view, 1);
if (command === "heading2") return setHeading(view, 2);
if (command === "heading3") return setHeading(view, 3);
if (command === "paragraph") return setHeading(view, 0);
if (command === "link") {
// Toggle: inside an existing link, unwrap it to its label.
if (unwrapLink(view)) return;
const { from, to } = view.state.selection.main;
const url = ctx?.url ?? "";
// Empty selection: the URL doubles as the label.
const label = view.state.sliceDoc(from, to) || url;
const insert = `[${label}](${url})`;
view.dispatch({ changes: { from, to, insert }, selection: { anchor: from + insert.length } });
}
},
getActiveFormats(): ActiveFormatState {
const pos = view.state.selection.main.head;
const tree = syntaxTree(view.state);
const active: ActiveFormatState = { ...EMPTY_ACTIVE_FORMATS };
// Inline marks come from the syntax tree around the cursor.
for (const n of ancestors(tree, pos, -1)) {
const mark = WRAPPER_TO_MARK[n.name];
if (mark) active[mark] = true;
else if (n.name === "Link") active.link = true;
// The isEmptyMarkPair guard: a fresh empty strikethrough pair
// (`~~|~~`) parses as a bare tilde code fence — don't light the
// code-block button while the cursor sits in one.
else if (n.name === "FencedCode" && !isEmptyMarkPair(view.state, n.from, n.to)) active.codeBlock = true;
}
// Line modes (lists, headings) come from the same line inspection the
// toggles use, keeping highlight and toggle behavior in lockstep.
const line = view.state.doc.lineAt(pos).text;
const heading = HEADING_LINE.exec(line);
if (heading) active.headingLevel = toToolbarHeadingLevel(heading[1].length);
const { mode } = lineListInfo(line);
if (mode) active[mode] = true;
return active;
},
subscribe(listener: () => void) {
listeners.add(listener);
return () => {
listeners.delete(listener);
};
},
};
}
@@ -0,0 +1,24 @@
import { RangeSetBuilder } from "@codemirror/state";
import { Decoration, type DecorationSet, EditorView } from "@codemirror/view";
import { HEADING_LINE } from "./formatting";
import { viewportDecorations } from "./viewportDecorations";
const lineDecorations = [1, 2, 3, 4, 5, 6].map((level) => Decoration.line({ class: `cm-md-h${level}` }));
function build(view: EditorView): DecorationSet {
const builder = new RangeSetBuilder<Decoration>();
for (const { from, to } of view.visibleRanges) {
const startLine = view.state.doc.lineAt(from).number;
const endLine = view.state.doc.lineAt(to).number;
for (let n = startLine; n <= endLine; n++) {
const line = view.state.doc.line(n);
const m = HEADING_LINE.exec(line.text);
if (m) {
builder.add(line.from, line.from, lineDecorations[m[1].length - 1]);
}
}
}
return builder.finish();
}
export const headingDecorations = viewportDecorations(build);
@@ -0,0 +1,98 @@
import { EditorState } from "@codemirror/state";
import { placeholder as cmPlaceholder, EditorView } from "@codemirror/view";
import { forwardRef, useEffect, useImperativeHandle, useLayoutEffect, useMemo, useRef } from "react";
import { useTagCounts } from "@/hooks/useUserQueries";
import { cn } from "@/lib/utils";
import type { EditorController } from "../types/editorController";
import { createController } from "./controller";
import "./editor.css";
import { buildEditorExtensions, placeholderCompartment } from "./extensions";
import { createFormattingController } from "./formatting";
interface EditorProps {
className: string;
initialContent: string;
placeholder: string;
onContentChange: (content: string) => void;
onFiles: (files: File[]) => void;
/** Invoked by the in-editor save shortcut (Cmd/Ctrl+Enter). */
onSubmit: () => void;
isFocusMode?: boolean;
}
const Editor = forwardRef(function Editor(props: EditorProps, ref: React.ForwardedRef<EditorController>) {
const { className, initialContent, placeholder, onContentChange, onFiles, onSubmit, isFocusMode } = props;
const hostRef = useRef<HTMLDivElement>(null);
const viewRef = useRef<EditorView | null>(null);
const controllerRef = useRef<EditorController | null>(null);
const onChangeRef = useRef(onContentChange);
onChangeRef.current = onContentChange;
const onFilesRef = useRef(onFiles);
onFilesRef.current = onFiles;
const onSubmitRef = useRef(onSubmit);
onSubmitRef.current = onSubmit;
const placeholderRef = useRef(placeholder);
const listenersRef = useRef(new Set<() => void>());
const { data: tagData } = useTagCounts();
const tags = useMemo(() => Object.keys(tagData ?? {}), [tagData]);
const tagsRef = useRef(tags);
tagsRef.current = tags;
// useLayoutEffect (not useEffect) so the EditorView — and its placeholder —
// mount before the browser paints. With useEffect the first painted frame
// shows an empty host, then the placeholder pops in (a load flicker).
useLayoutEffect(() => {
if (!hostRef.current) return;
const view = new EditorView({
state: EditorState.create({
doc: initialContent,
extensions: buildEditorExtensions({
placeholder,
onChange: (md) => onChangeRef.current(md),
onFiles: (files) => onFilesRef.current(files),
onUpdate: () => listenersRef.current.forEach((l) => l()),
onSubmit: () => onSubmitRef.current(),
getTags: () => tagsRef.current,
}),
}),
parent: hostRef.current,
});
viewRef.current = view;
controllerRef.current = createController(view, createFormattingController(view, listenersRef.current));
return () => {
view.destroy();
viewRef.current = null;
controllerRef.current = null;
};
// Mount once; external sync handled below.
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
useLayoutEffect(() => {
if (placeholderRef.current === placeholder) return;
placeholderRef.current = placeholder;
viewRef.current?.dispatch({ effects: placeholderCompartment.reconfigure(cmPlaceholder(placeholder)) });
}, [placeholder]);
useEffect(() => {
const view = viewRef.current;
if (!view) return;
if (view.state.doc.toString() === initialContent) return;
view.dispatch({ changes: { from: 0, to: view.state.doc.length, insert: initialContent } });
}, [initialContent]);
// The controller is created in the mount layout effect above, which runs
// before this (also layout-phase) handle, so controllerRef.current is set.
useImperativeHandle(ref, () => controllerRef.current as EditorController, []);
return (
<div
className={cn("relative flex w-full flex-col items-start justify-start bg-inherit", isFocusMode && "min-h-0 flex-1", className)}
data-focus-mode={isFocusMode || undefined}
>
<div ref={hostRef} className={cn("w-full text-base", isFocusMode && "min-h-0 flex-1")} />
</div>
);
});
export default Editor;
@@ -0,0 +1,121 @@
import type { Text } from "@codemirror/state";
import type { EditorView } from "@codemirror/view";
// A list item line: leading whitespace + bullet (-, *, +) or ordered marker
// (1. / 1)) + at least one space.
const LIST_ITEM = /^(\s*)(?:[-*+]|\d+[.)])\s/;
const ORDERED_ITEM = /^(\s*)(\d+)([.)])(\s+)(.*)$/;
// The full marker prefix (indent + marker + trailing spaces); its length is the
// column where the item's content — and therefore a nested child — begins.
const LIST_PREFIX = /^\s*(?:[-*+]|\d+[.)])\s+/;
export const leadingWhitespace = (text: string): number => text.length - text.trimStart().length;
/** Unique line numbers covered by the selection, ascending (also used by formatting.ts). */
export function selectedLineNumbers(view: EditorView): number[] {
const { doc, selection } = view.state;
const nums = new Set<number>();
for (const range of selection.ranges) {
const last = doc.lineAt(range.to).number;
for (let n = doc.lineAt(range.from).number; n <= last; n++) {
nums.add(n);
}
}
return [...nums].sort((a, b) => a - b);
}
/** Preceding lines, nearest first, stopping at the first blank line (list end). */
function* previousListLines(doc: Text, from: number): Generator<{ text: string; indent: number }> {
for (let p = from - 1; p >= 1; p--) {
const { text } = doc.line(p);
if (text.trim() === "") return;
yield { text, indent: leadingWhitespace(text) };
}
}
/**
* The number an ordered item should take at `indent`: one more than the nearest
* preceding sibling ordered item at the same indent, else 1 (first child of a
* new sublist). CommonMark only nests an ordered sublist whose first item is
* `1.`, so renumbering — not just re-indenting — is what makes a nested ordered
* list actually render as nested.
*/
function nextOrderedNumber(doc: Text, lineNumber: number, indent: number): number {
for (const prev of previousListLines(doc, lineNumber)) {
if (prev.indent < indent) break; // reached the parent
if (prev.indent === indent) {
const ordered = ORDERED_ITEM.exec(prev.text);
return ordered ? Number.parseInt(ordered[2], 10) + 1 : 1;
}
// deeper line — part of a sibling's subtree; keep scanning up
}
return 1;
}
/** Rewrite a list line to a new indent, renumbering it if it is an ordered item. */
function reindented(doc: Text, lineNumber: number, text: string, indent: number): string {
const ordered = ORDERED_ITEM.exec(text);
if (ordered) {
const [, , , delimiter, spaces, content] = ordered;
return `${" ".repeat(indent)}${nextOrderedNumber(doc, lineNumber, indent)}${delimiter}${spaces}${content}`;
}
return " ".repeat(indent) + text.slice(leadingWhitespace(text));
}
/**
* Tab on a list item: nest it under the preceding item by aligning its indent to
* that item's content column (past the marker) and renumbering ordered items so
* the nested list is CommonMark-valid. Returns false when not on a list item so
* the caller's default Tab (plain indent) applies.
*/
export function sinkListItem(view: EditorView): boolean {
const { doc } = view.state;
const changes: { from: number; to: number; insert: string }[] = [];
for (const n of selectedLineNumbers(view)) {
const line = doc.line(n);
const match = LIST_ITEM.exec(line.text);
if (!match) return false;
const indent = match[1].length;
let target = indent + 2; // fallback when there is no item to nest under
for (const prev of previousListLines(doc, n)) {
if (prev.indent > indent) continue; // deeper subtree — skip past it
const prevPrefix = LIST_PREFIX.exec(prev.text);
if (prevPrefix) target = prevPrefix[0].length;
break;
}
if (target > indent) {
changes.push({ from: line.from, to: line.to, insert: reindented(doc, n, line.text, target) });
}
}
if (changes.length === 0) return false;
view.dispatch({ changes, userEvent: "input.indent" });
return true;
}
/**
* Shift-Tab on a list item: outdent it to its parent's indent (or the margin),
* renumbering ordered items for the new level. Returns false when not on an
* indented list item so the default outdent applies.
*/
export function liftListItem(view: EditorView): boolean {
const { doc } = view.state;
const changes: { from: number; to: number; insert: string }[] = [];
for (const n of selectedLineNumbers(view)) {
const line = doc.line(n);
const match = LIST_ITEM.exec(line.text);
if (!match) return false;
const indent = match[1].length;
if (indent === 0) return false;
let target = 0;
for (const prev of previousListLines(doc, n)) {
if (prev.indent < indent) {
target = LIST_ITEM.test(prev.text) ? prev.indent : 0;
break;
}
}
changes.push({ from: line.from, to: line.to, insert: reindented(doc, n, line.text, target) });
}
if (changes.length === 0) return false;
view.dispatch({ changes, userEvent: "delete.dedent" });
return true;
}

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