0.29.1原版
This commit is contained in:
@@ -0,0 +1,7 @@
|
||||
node_modules
|
||||
.pnpm-store
|
||||
.DS_Store
|
||||
dist
|
||||
dist-ssr
|
||||
*.local
|
||||
src/types/proto/store
|
||||
+202
@@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
@@ -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.
|
||||
@@ -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>
|
||||
@@ -0,0 +1,110 @@
|
||||
{
|
||||
"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": {
|
||||
"@connectrpc/connect": "^2.1.1",
|
||||
"@connectrpc/connect-web": "^2.1.1",
|
||||
"@emotion/react": "^11.14.0",
|
||||
"@emotion/styled": "^11.14.1",
|
||||
"@github/relative-time-element": "^5.0.0",
|
||||
"@radix-ui/react-checkbox": "^1.3.3",
|
||||
"@radix-ui/react-dialog": "^1.1.15",
|
||||
"@radix-ui/react-dropdown-menu": "^2.1.16",
|
||||
"@radix-ui/react-label": "^2.1.8",
|
||||
"@radix-ui/react-popover": "^1.1.15",
|
||||
"@radix-ui/react-radio-group": "^1.3.8",
|
||||
"@radix-ui/react-select": "^2.2.6",
|
||||
"@radix-ui/react-separator": "^1.1.8",
|
||||
"@radix-ui/react-slot": "^1.2.4",
|
||||
"@radix-ui/react-switch": "^1.2.6",
|
||||
"@radix-ui/react-tooltip": "^1.2.8",
|
||||
"@tailwindcss/vite": "^4.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",
|
||||
"textarea-caret": "^3.1.0",
|
||||
"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/textarea-caret": "^3.0.4",
|
||||
"@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": "^6.0.3",
|
||||
"vite": "^8.0.11",
|
||||
"vitest": "^4.1.5"
|
||||
}
|
||||
}
|
||||
Generated
+6143
File diff suppressed because it is too large
Load Diff
@@ -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 |
@@ -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"
|
||||
}
|
||||
@@ -0,0 +1,66 @@
|
||||
import { useEffect } from "react";
|
||||
import { Outlet } from "react-router-dom";
|
||||
import { useInstance } from "./contexts/InstanceContext";
|
||||
import { MemoFilterProvider } from "./contexts/MemoFilterContext";
|
||||
import useNavigateTo from "./hooks/useNavigateTo";
|
||||
import { useUserLocale } from "./hooks/useUserLocale";
|
||||
import { useUserTheme } from "./hooks/useUserTheme";
|
||||
import { cleanupExpiredOAuthState } from "./utils/oauth";
|
||||
|
||||
const App = () => {
|
||||
const navigateTo = useNavigateTo();
|
||||
const { profile: instanceProfile, profileLoaded, generalSetting: instanceGeneralSetting } = useInstance();
|
||||
|
||||
// Apply user preferences reactively
|
||||
useUserLocale();
|
||||
useUserTheme();
|
||||
|
||||
// Clean up expired OAuth states on app initialization
|
||||
useEffect(() => {
|
||||
cleanupExpiredOAuthState();
|
||||
}, []);
|
||||
|
||||
// Redirect to sign up page if instance not initialized (no admin account exists yet).
|
||||
// Guard with profileLoaded so a fetch failure doesn't incorrectly trigger the redirect.
|
||||
useEffect(() => {
|
||||
if (profileLoaded && !instanceProfile.admin) {
|
||||
navigateTo("/auth/signup");
|
||||
}
|
||||
}, [profileLoaded, instanceProfile.admin, navigateTo]);
|
||||
|
||||
useEffect(() => {
|
||||
if (instanceGeneralSetting.additionalStyle) {
|
||||
const styleEl = document.createElement("style");
|
||||
styleEl.innerHTML = instanceGeneralSetting.additionalStyle;
|
||||
styleEl.setAttribute("type", "text/css");
|
||||
document.body.insertAdjacentElement("beforeend", styleEl);
|
||||
}
|
||||
}, [instanceGeneralSetting.additionalStyle]);
|
||||
|
||||
useEffect(() => {
|
||||
if (instanceGeneralSetting.additionalScript) {
|
||||
const scriptEl = document.createElement("script");
|
||||
scriptEl.innerHTML = instanceGeneralSetting.additionalScript;
|
||||
document.head.appendChild(scriptEl);
|
||||
}
|
||||
}, [instanceGeneralSetting.additionalScript]);
|
||||
|
||||
// Dynamic update metadata with customized profile
|
||||
useEffect(() => {
|
||||
if (!instanceGeneralSetting.customProfile) {
|
||||
return;
|
||||
}
|
||||
|
||||
document.title = instanceGeneralSetting.customProfile.title;
|
||||
const link = document.querySelector("link[rel~='icon']") as HTMLLinkElement;
|
||||
link.href = instanceGeneralSetting.customProfile.logoUrl || "/logo.webp";
|
||||
}, [instanceGeneralSetting.customProfile]);
|
||||
|
||||
return (
|
||||
<MemoFilterProvider>
|
||||
<Outlet />
|
||||
</MemoFilterProvider>
|
||||
);
|
||||
};
|
||||
|
||||
export default App;
|
||||
@@ -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,81 @@
|
||||
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 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring/40 focus-visible:ring-offset-2 select-none border border-border/10 bg-muted/20",
|
||||
sizeConfig.font,
|
||||
sizeConfig.borderRadius,
|
||||
smallExtraClasses,
|
||||
);
|
||||
const isInteractive = Boolean(onClick);
|
||||
const ariaLabel = day.isSelected ? `${tooltipText} (selected)` : tooltipText;
|
||||
|
||||
if (!day.isCurrentMonth) {
|
||||
return <div className={cn(baseClasses, "text-muted-foreground/30 bg-transparent border-transparent cursor-default")}>{day.label}</div>;
|
||||
}
|
||||
|
||||
const intensityClass = getCellIntensityClass(day, maxCount);
|
||||
|
||||
const buttonClasses = cn(
|
||||
baseClasses,
|
||||
intensityClass,
|
||||
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();
|
||||
};
|
||||
@@ -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,129 @@
|
||||
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-amber-200/70 bg-amber-50/50 p-3 shadow-sm shadow-black/[0.02] dark:border-amber-900/50 dark:bg-amber-950/10"
|
||||
>
|
||||
<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-amber-900/80 hover:text-amber-950 dark:text-amber-100/80 dark:hover:text-amber-50"
|
||||
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,90 @@
|
||||
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="outline"
|
||||
className="rounded-full border-amber-300/70 bg-amber-50/70 px-1.5 py-0.5 text-[11px] text-amber-900 dark:border-amber-700/60 dark:bg-amber-950/20 dark:text-amber-100"
|
||||
>
|
||||
{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-amber-200/70 bg-amber-50/50 p-4 dark:border-amber-900/50 dark:bg-amber-950/10">
|
||||
<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-amber-300/70 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]">
|
||||
<button type="button" 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>
|
||||
</button>
|
||||
|
||||
<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";
|
||||
@@ -0,0 +1,35 @@
|
||||
import { useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { loadLocale } from "@/utils/i18n";
|
||||
import { getInitialTheme, loadTheme, Theme } from "@/utils/theme";
|
||||
import LocaleSelect from "./LocaleSelect";
|
||||
import ThemeSelect from "./ThemeSelect";
|
||||
|
||||
interface Props {
|
||||
className?: string;
|
||||
}
|
||||
|
||||
const AuthFooter = ({ className }: Props) => {
|
||||
const { i18n: i18nInstance } = useTranslation();
|
||||
const currentLocale = i18nInstance.language as Locale;
|
||||
const [currentTheme, setCurrentTheme] = useState(getInitialTheme());
|
||||
|
||||
const handleLocaleChange = (locale: Locale) => {
|
||||
loadLocale(locale);
|
||||
};
|
||||
|
||||
const handleThemeChange = (theme: string) => {
|
||||
loadTheme(theme);
|
||||
setCurrentTheme(theme as Theme);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className={cn("mt-4 flex flex-row items-center justify-center w-full gap-2", className)}>
|
||||
<LocaleSelect value={currentLocale} onChange={handleLocaleChange} />
|
||||
<ThemeSelect value={currentTheme} onValueChange={handleThemeChange} />
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default AuthFooter;
|
||||
@@ -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;
|
||||
@@ -0,0 +1,131 @@
|
||||
# ConfirmDialog - Accessible Confirmation Dialog
|
||||
|
||||
## Overview
|
||||
|
||||
`ConfirmDialog` standardizes confirmation flows across the app. It replaces ad‑hoc `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 (1–2 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 / ad‑hoc 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 "@/helpers/utils";
|
||||
import { handleError } from "@/lib/error";
|
||||
import {
|
||||
FieldMapping,
|
||||
FieldMappingSchema,
|
||||
IdentityProvider,
|
||||
IdentityProvider_Type,
|
||||
IdentityProviderConfigSchema,
|
||||
IdentityProviderSchema,
|
||||
OAuth2Config,
|
||||
OAuth2ConfigSchema,
|
||||
} from "@/types/proto/api/v1/idp_service_pb";
|
||||
import { useTranslate } from "@/utils/i18n";
|
||||
|
||||
const 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;
|
||||
@@ -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;
|
||||
@@ -0,0 +1,166 @@
|
||||
import { create } from "@bufbuild/protobuf";
|
||||
import { FieldMaskSchema } from "@bufbuild/protobuf/wkt";
|
||||
import React, { useEffect, useState } from "react";
|
||||
import { toast } from "react-hot-toast";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Dialog, DialogContent, DialogFooter, DialogHeader, DialogTitle } from "@/components/ui/dialog";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { userServiceClient } from "@/connect";
|
||||
import useCurrentUser from "@/hooks/useCurrentUser";
|
||||
import useLoading from "@/hooks/useLoading";
|
||||
import { handleError } from "@/lib/error";
|
||||
import { useTranslate } from "@/utils/i18n";
|
||||
|
||||
interface Props {
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
webhookName?: string;
|
||||
onSuccess?: () => void;
|
||||
}
|
||||
|
||||
interface State {
|
||||
displayName: string;
|
||||
url: string;
|
||||
}
|
||||
|
||||
function CreateWebhookDialog({ open, onOpenChange, webhookName, onSuccess }: Props) {
|
||||
const t = useTranslate();
|
||||
const currentUser = useCurrentUser();
|
||||
const [state, setState] = useState<State>({
|
||||
displayName: "",
|
||||
url: "",
|
||||
});
|
||||
const requestState = useLoading(false);
|
||||
const isCreating = webhookName === undefined;
|
||||
|
||||
useEffect(() => {
|
||||
if (webhookName && currentUser) {
|
||||
// For editing, we need to get the webhook data
|
||||
// Since we're using user webhooks now, we need to list all webhooks and find the one we want
|
||||
userServiceClient
|
||||
.listUserWebhooks({
|
||||
parent: currentUser.name,
|
||||
})
|
||||
.then((response) => {
|
||||
const webhook = response.webhooks.find((w) => w.name === webhookName);
|
||||
if (webhook) {
|
||||
setState({
|
||||
displayName: webhook.displayName,
|
||||
url: webhook.url,
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
}, [webhookName, currentUser]);
|
||||
|
||||
const setPartialState = (partialState: Partial<State>) => {
|
||||
setState({
|
||||
...state,
|
||||
...partialState,
|
||||
});
|
||||
};
|
||||
|
||||
const handleTitleInputChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
setPartialState({
|
||||
displayName: e.target.value,
|
||||
});
|
||||
};
|
||||
|
||||
const handleUrlInputChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
setPartialState({
|
||||
url: e.target.value,
|
||||
});
|
||||
};
|
||||
|
||||
const handleSaveBtnClick = async () => {
|
||||
if (!state.displayName || !state.url) {
|
||||
toast.error(t("message.fill-all-required-fields"));
|
||||
return;
|
||||
}
|
||||
|
||||
if (!currentUser) {
|
||||
toast.error("User not authenticated");
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
requestState.setLoading();
|
||||
if (isCreating) {
|
||||
await userServiceClient.createUserWebhook({
|
||||
parent: currentUser.name,
|
||||
webhook: {
|
||||
displayName: state.displayName,
|
||||
url: state.url,
|
||||
},
|
||||
});
|
||||
} else {
|
||||
await userServiceClient.updateUserWebhook({
|
||||
webhook: {
|
||||
name: webhookName,
|
||||
displayName: state.displayName,
|
||||
url: state.url,
|
||||
},
|
||||
updateMask: create(FieldMaskSchema, { paths: ["display_name", "url"] }),
|
||||
});
|
||||
}
|
||||
|
||||
onSuccess?.();
|
||||
onOpenChange(false);
|
||||
requestState.setFinish();
|
||||
} catch (error: unknown) {
|
||||
handleError(error, toast.error, {
|
||||
context: webhookName ? "Update webhook" : "Create webhook",
|
||||
onError: () => requestState.setError(),
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent className="max-w-md">
|
||||
<DialogHeader>
|
||||
<DialogTitle>
|
||||
{isCreating ? t("setting.webhook.create-dialog.create-webhook") : t("setting.webhook.create-dialog.edit-webhook")}
|
||||
</DialogTitle>
|
||||
</DialogHeader>
|
||||
<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>
|
||||
<DialogFooter>
|
||||
<Button variant="ghost" disabled={requestState.isLoading} onClick={() => onOpenChange(false)}>
|
||||
{t("common.cancel")}
|
||||
</Button>
|
||||
<Button disabled={requestState.isLoading} onClick={handleSaveBtnClick}>
|
||||
{t("common.create")}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
|
||||
export default CreateWebhookDialog;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -0,0 +1,41 @@
|
||||
import { GlobeIcon } from "lucide-react";
|
||||
import { FC } from "react";
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
||||
import { locales } from "@/i18n";
|
||||
import { getLocaleDisplayName, loadLocale } from "@/utils/i18n";
|
||||
|
||||
interface Props {
|
||||
value: Locale;
|
||||
onChange: (locale: Locale) => void;
|
||||
}
|
||||
|
||||
const LocaleSelect: FC<Props> = (props: Props) => {
|
||||
const { onChange, value } = props;
|
||||
|
||||
const handleSelectChange = async (locale: Locale) => {
|
||||
// Apply locale globally immediately
|
||||
loadLocale(locale);
|
||||
// Also notify parent component
|
||||
onChange(locale);
|
||||
};
|
||||
|
||||
return (
|
||||
<Select value={value} onValueChange={handleSelectChange}>
|
||||
<SelectTrigger>
|
||||
<div className="flex items-center gap-2">
|
||||
<GlobeIcon className="w-4 h-auto" />
|
||||
<SelectValue placeholder="Select language" />
|
||||
</div>
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{locales.map((locale) => (
|
||||
<SelectItem key={locale} value={locale}>
|
||||
{getLocaleDisplayName(locale)}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
);
|
||||
};
|
||||
|
||||
export default LocaleSelect;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
}
|
||||
@@ -0,0 +1,79 @@
|
||||
import { 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 { extractMemoIdFromName } from "@/helpers/resource-names";
|
||||
import useCurrentUser from "@/hooks/useCurrentUser";
|
||||
import type { Memo } from "@/types/proto/api/v1/memo_service_pb";
|
||||
import { useTranslate } from "@/utils/i18n";
|
||||
|
||||
interface Props {
|
||||
memo: Memo;
|
||||
comments: Memo[];
|
||||
parentPage?: string;
|
||||
}
|
||||
|
||||
const MemoCommentSection = ({ memo, comments, parentPage }: 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>
|
||||
))}
|
||||
</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,148 @@
|
||||
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 { 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>;
|
||||
}
|
||||
|
||||
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 }: 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, ...props }) => <Link {...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,40 @@
|
||||
import type { Element } from "hast";
|
||||
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("text-blue-600 underline-offset-2 hover:underline dark:text-blue-400", 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 }}
|
||||
/>
|
||||
);
|
||||
};
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,81 @@
|
||||
import type { Element } from "hast";
|
||||
import { useLocation } from "react-router-dom";
|
||||
import { useInstance } from "@/contexts/InstanceContext";
|
||||
import { type MemoFilter, stringifyFilters, useMemoFilterContext } from "@/contexts/MemoFilterContext";
|
||||
import useNavigateTo from "@/hooks/useNavigateTo";
|
||||
import { colorToHex } from "@/lib/color";
|
||||
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 { tagsSetting } = useInstance();
|
||||
|
||||
const tag = dataTag || "";
|
||||
|
||||
// Custom color from admin 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 bgHex = colorToHex(findTagMetadata(tag, tagsSetting)?.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(
|
||||
"inline-flex items-center align-baseline px-1.5 py-0.5 text-[0.9em] leading-none font-normal rounded-full border cursor-pointer transition-opacity hover:opacity-75",
|
||||
!bgHex && "border-primary text-primary bg-primary/15",
|
||||
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,73 @@
|
||||
import { defaultSchema } from "rehype-sanitize";
|
||||
|
||||
// Class names added by remark-gfm for task lists
|
||||
export const TASK_LIST_CLASS = "contains-task-list";
|
||||
export const TASK_LIST_ITEM_CLASS = "task-list-item";
|
||||
|
||||
// Compact mode display settings
|
||||
export const COMPACT_MODE_CONFIG = {
|
||||
maxHeightVh: 60, // 60% of viewport height
|
||||
gradientHeight: "h-24", // Tailwind class for gradient overlay
|
||||
} as const;
|
||||
|
||||
export const getMaxDisplayHeight = () => window.innerHeight * (COMPACT_MODE_CONFIG.maxHeightVh / 100);
|
||||
|
||||
export const COMPACT_STATES: Record<"ALL" | "SNIPPET", { textKey: string; next: "ALL" | "SNIPPET" }> = {
|
||||
ALL: { textKey: "memo.show-more", next: "SNIPPET" },
|
||||
SNIPPET: { textKey: "memo.show-less", next: "ALL" },
|
||||
};
|
||||
|
||||
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,
|
||||
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"],
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,28 @@
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
import { COMPACT_STATES, getMaxDisplayHeight } from "./constants";
|
||||
import type { ContentCompactView } from "./types";
|
||||
|
||||
export const useCompactMode = (enabled: boolean) => {
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
const [mode, setMode] = useState<ContentCompactView | undefined>(undefined);
|
||||
|
||||
useEffect(() => {
|
||||
if (!enabled || !containerRef.current) return;
|
||||
const maxHeight = getMaxDisplayHeight();
|
||||
if (containerRef.current.getBoundingClientRect().height > maxHeight) {
|
||||
setMode("ALL");
|
||||
}
|
||||
}, [enabled]);
|
||||
|
||||
const toggle = useCallback(() => {
|
||||
if (!mode) return;
|
||||
setMode(COMPACT_STATES[mode].next);
|
||||
}, [mode]);
|
||||
|
||||
return { containerRef, mode, toggle };
|
||||
};
|
||||
|
||||
export const useCompactLabel = (mode: ContentCompactView | undefined, t: (key: string) => string): string => {
|
||||
if (!mode) return "";
|
||||
return t(COMPACT_STATES[mode].textKey);
|
||||
};
|
||||
@@ -0,0 +1,70 @@
|
||||
import { ChevronDown, ChevronUp } from "lucide-react";
|
||||
import { memo, useMemo } from "react";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { useTranslate } from "@/utils/i18n";
|
||||
import { extractMentionUsernames } from "@/utils/remark-plugins/remark-mention";
|
||||
import { COMPACT_MODE_CONFIG } from "./constants";
|
||||
import { useCompactLabel, useCompactMode } from "./hooks";
|
||||
import { MemoMarkdownRenderer } from "./MemoMarkdownRenderer";
|
||||
import { useResolvedMentionUsernames } from "./MentionResolutionContext";
|
||||
import type { MemoContentProps } from "./types";
|
||||
|
||||
const MemoContent = (props: MemoContentProps) => {
|
||||
const { className, contentClassName, content, onClick, onDoubleClick } = props;
|
||||
const t = useTranslate();
|
||||
const {
|
||||
containerRef: memoContentContainerRef,
|
||||
mode: showCompactMode,
|
||||
toggle: toggleCompactMode,
|
||||
} = useCompactMode(Boolean(props.compact));
|
||||
const mentionUsernames = useMemo(() => extractMentionUsernames(content), [content]);
|
||||
const resolvedMentionUsernames = useResolvedMentionUsernames(mentionUsernames);
|
||||
|
||||
const compactLabel = useCompactLabel(showCompactMode, t as (key: string) => string);
|
||||
|
||||
return (
|
||||
<div className={`w-full flex flex-col justify-start items-start text-foreground ${className || ""}`}>
|
||||
<div
|
||||
ref={memoContentContainerRef}
|
||||
data-memo-content
|
||||
className={cn(
|
||||
"relative w-full max-w-full wrap-break-word text-base leading-6",
|
||||
"[&>*:last-child]:mb-0",
|
||||
"[&_.katex-display]:max-w-full",
|
||||
"[&_.katex-display]:overflow-x-auto",
|
||||
"[&_.katex-display]:overflow-y-hidden",
|
||||
showCompactMode === "ALL" && "overflow-hidden",
|
||||
contentClassName,
|
||||
)}
|
||||
style={showCompactMode === "ALL" ? { maxHeight: `${COMPACT_MODE_CONFIG.maxHeightVh}vh` } : undefined}
|
||||
onMouseUp={onClick}
|
||||
onDoubleClick={onDoubleClick}
|
||||
>
|
||||
<MemoMarkdownRenderer content={content} resolvedMentionUsernames={resolvedMentionUsernames} />
|
||||
{showCompactMode === "ALL" && (
|
||||
<div
|
||||
className={cn(
|
||||
"absolute inset-x-0 bottom-0 pointer-events-none",
|
||||
COMPACT_MODE_CONFIG.gradientHeight,
|
||||
"bg-linear-to-t from-background from-0% via-background/60 via-40% to-transparent to-100%",
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
{showCompactMode !== undefined && (
|
||||
<div className="relative w-full mt-2">
|
||||
<button
|
||||
type="button"
|
||||
className="group inline-flex items-center gap-1 px-2 py-1 text-xs text-muted-foreground hover:text-foreground transition-colors"
|
||||
onClick={toggleCompactMode}
|
||||
>
|
||||
<span>{compactLabel}</span>
|
||||
{showCompactMode === "ALL" ? <ChevronDown className="w-3 h-3" /> : <ChevronUp className="w-3 h-3" />}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default memo(MemoContent);
|
||||
@@ -0,0 +1,18 @@
|
||||
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("my-0 mb-2 border-l-4 border-primary/30 pl-3 text-muted-foreground italic", className)} {...props}>
|
||||
<NestedMarkdownRenderContext>{children}</NestedMarkdownRenderContext>
|
||||
</blockquote>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,31 @@
|
||||
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;
|
||||
|
||||
const levelClasses = {
|
||||
1: "text-3xl font-bold border-b border-border pb-2",
|
||||
2: "text-2xl font-semibold border-b border-border pb-1.5",
|
||||
3: "text-xl font-semibold",
|
||||
4: "text-lg font-semibold",
|
||||
5: "text-base font-semibold",
|
||||
6: "text-base font-medium text-muted-foreground",
|
||||
};
|
||||
|
||||
return (
|
||||
<Component className={cn("mt-3 mb-2 leading-tight", levelClasses[level], className)} {...props}>
|
||||
{children}
|
||||
</Component>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,11 @@
|
||||
import { cn } from "@/lib/utils";
|
||||
import type { ReactMarkdownProps } from "./types";
|
||||
|
||||
interface HorizontalRuleProps extends React.HTMLAttributes<HTMLHRElement>, ReactMarkdownProps {}
|
||||
|
||||
/**
|
||||
* Horizontal rule separator
|
||||
*/
|
||||
export const HorizontalRule = ({ className, node: _node, ...props }: HorizontalRuleProps) => {
|
||||
return <hr className={cn("my-2 h-0 border-0 border-b border-border", className)} {...props} />;
|
||||
};
|
||||
@@ -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,17 @@
|
||||
import { cn } from "@/lib/utils";
|
||||
import type { ReactMarkdownProps } from "./types";
|
||||
|
||||
interface InlineCodeProps extends React.HTMLAttributes<HTMLElement>, ReactMarkdownProps {
|
||||
children: React.ReactNode;
|
||||
}
|
||||
|
||||
/**
|
||||
* Inline code component with background and monospace font
|
||||
*/
|
||||
export const InlineCode = ({ children, className, node: _node, ...props }: InlineCodeProps) => {
|
||||
return (
|
||||
<code className={cn("font-mono text-sm bg-muted px-1 py-0.5 rounded-md", className)} {...props}>
|
||||
{children}
|
||||
</code>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,27 @@
|
||||
import { cn } from "@/lib/utils";
|
||||
import type { ReactMarkdownProps } from "./types";
|
||||
|
||||
interface LinkProps extends React.AnchorHTMLAttributes<HTMLAnchorElement>, ReactMarkdownProps {
|
||||
children: React.ReactNode;
|
||||
}
|
||||
|
||||
/**
|
||||
* Link component for external links
|
||||
* Opens in new tab with security attributes
|
||||
*/
|
||||
export const Link = ({ children, className, href, node: _node, ...props }: LinkProps) => {
|
||||
return (
|
||||
<a
|
||||
href={href}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className={cn(
|
||||
"text-primary underline decoration-primary/50 underline-offset-2 transition-colors hover:decoration-primary",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
</a>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,129 @@
|
||||
import { Children, cloneElement, isValidElement, type ReactElement, type ReactNode } from "react";
|
||||
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);
|
||||
|
||||
return (
|
||||
<Component
|
||||
className={cn(
|
||||
"my-0 mb-2 list-outside",
|
||||
isTaskList
|
||||
? // Task list indentation is handled by task item grid columns.
|
||||
"list-none"
|
||||
: // Regular list: standard padding and list style
|
||||
cn("pl-6", ordered ? "list-decimal" : "list-disc"),
|
||||
className,
|
||||
)}
|
||||
{...domProps}
|
||||
>
|
||||
{children}
|
||||
</Component>
|
||||
);
|
||||
};
|
||||
|
||||
interface ListItemProps extends React.LiHTMLAttributes<HTMLLIElement>, ReactMarkdownProps {
|
||||
children: React.ReactNode;
|
||||
}
|
||||
|
||||
/**
|
||||
* List item component for both regular and task list items
|
||||
* Detects task items via the "task-list-item" class added by remark-gfm
|
||||
*/
|
||||
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(
|
||||
"mt-0.5 min-w-0 leading-6 list-none grid grid-cols-[auto_minmax(0,1fr)] items-start gap-x-2 [&>[data-slot=checkbox]]:mt-1",
|
||||
className,
|
||||
)}
|
||||
{...domProps}
|
||||
>
|
||||
<NestedMarkdownRenderContext>
|
||||
{checkbox}
|
||||
<div className="min-w-0 [overflow-wrap:anywhere] [&>*:last-child]:mb-0">{content}</div>
|
||||
</NestedMarkdownRenderContext>
|
||||
</li>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<li className={cn("mt-0.5 leading-6", className)} {...domProps}>
|
||||
<NestedMarkdownRenderContext>{children}</NestedMarkdownRenderContext>
|
||||
</li>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,60 @@
|
||||
import type { Element } from "hast";
|
||||
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 href = blockDepth === 0 ? getSingleLinkHref(node) : undefined;
|
||||
const paragraph = (
|
||||
<p className={cn("my-0 mb-2 leading-6", 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,8 @@
|
||||
export { Blockquote } from "./Blockquote";
|
||||
export { Heading } from "./Heading";
|
||||
export { HorizontalRule } from "./HorizontalRule";
|
||||
export { Image } from "./Image";
|
||||
export { InlineCode } from "./InlineCode";
|
||||
export { Link } from "./Link";
|
||||
export { List, ListItem } from "./List";
|
||||
export { Paragraph } from "./Paragraph";
|
||||
@@ -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;
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
import type React from "react";
|
||||
|
||||
export interface MemoContentProps {
|
||||
content: string;
|
||||
compact?: boolean;
|
||||
className?: string;
|
||||
contentClassName?: string;
|
||||
onClick?: (e: React.MouseEvent) => void;
|
||||
onDoubleClick?: (e: React.MouseEvent) => void;
|
||||
}
|
||||
|
||||
export type ContentCompactView = "ALL" | "SNIPPET";
|
||||
@@ -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 (h1–h4). */
|
||||
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-green-500" /> : <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,61 @@
|
||||
import { Settings2Icon } from "lucide-react";
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
||||
import { useView } from "@/contexts/ViewContext";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { useTranslate } from "@/utils/i18n";
|
||||
import { Popover, PopoverContent, PopoverTrigger } from "./ui/popover";
|
||||
|
||||
interface Props {
|
||||
className?: string;
|
||||
}
|
||||
|
||||
function MemoDisplaySettingMenu({ className }: Props) {
|
||||
const t = useTranslate();
|
||||
const { orderByTimeAsc, timeBasis, setTimeBasis, toggleSortOrder } = useView();
|
||||
const isApplying = orderByTimeAsc !== false || timeBasis !== "create_time";
|
||||
|
||||
return (
|
||||
<Popover>
|
||||
<PopoverTrigger className={cn(className, isApplying ? "text-primary bg-primary/10 rounded" : "opacity-40")}>
|
||||
<Settings2Icon className="w-4 h-auto shrink-0" />
|
||||
</PopoverTrigger>
|
||||
<PopoverContent align="end" alignOffset={-12} sideOffset={14}>
|
||||
<div className="flex flex-col gap-2 p-1">
|
||||
<div className="w-full flex flex-row justify-between items-center">
|
||||
<span className="text-sm shrink-0 mr-3 text-foreground">{t("memo.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>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
);
|
||||
}
|
||||
|
||||
export default MemoDisplaySettingMenu;
|
||||
@@ -0,0 +1,45 @@
|
||||
import type { SlashCommandsProps } from "../types";
|
||||
import type { EditorRefActions } from ".";
|
||||
import { SuggestionsPopup } from "./SuggestionsPopup";
|
||||
import { useSuggestions } from "./useSuggestions";
|
||||
|
||||
const SlashCommands = ({ editorRef, editorActions, commands }: SlashCommandsProps) => {
|
||||
const handleCommandAutocomplete = (cmd: (typeof commands)[0], word: string, index: number, actions: EditorRefActions) => {
|
||||
// Remove trigger char + word, then insert command output
|
||||
actions.removeText(index, word.length);
|
||||
actions.insertText(cmd.run());
|
||||
// Position cursor relative to insertion point, if specified
|
||||
if (cmd.cursorOffset) {
|
||||
actions.setCursorPosition(index + cmd.cursorOffset);
|
||||
}
|
||||
};
|
||||
|
||||
const { position, suggestions, selectedIndex, isVisible, handleItemSelect } = useSuggestions({
|
||||
editorRef,
|
||||
editorActions,
|
||||
triggerChar: "/",
|
||||
items: commands,
|
||||
filterItems: (items, query) => (!query ? items : items.filter((cmd) => cmd.name.toLowerCase().startsWith(query))),
|
||||
onAutocomplete: handleCommandAutocomplete,
|
||||
});
|
||||
|
||||
if (!isVisible || !position) return null;
|
||||
|
||||
return (
|
||||
<SuggestionsPopup
|
||||
position={position}
|
||||
suggestions={suggestions}
|
||||
selectedIndex={selectedIndex}
|
||||
onItemSelect={handleItemSelect}
|
||||
getItemKey={(cmd) => cmd.name}
|
||||
renderItem={(cmd) => (
|
||||
<span className="tracking-wide">
|
||||
<span className="text-muted-foreground">/</span>
|
||||
{cmd.name}
|
||||
</span>
|
||||
)}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
export default SlashCommands;
|
||||
@@ -0,0 +1,49 @@
|
||||
import { ReactNode, useEffect, useRef } from "react";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { Position } from "./useSuggestions";
|
||||
|
||||
interface SuggestionsPopupProps<T> {
|
||||
position: Position;
|
||||
suggestions: T[];
|
||||
selectedIndex: number;
|
||||
onItemSelect: (item: T) => void;
|
||||
renderItem: (item: T, isSelected: boolean) => ReactNode;
|
||||
getItemKey: (item: T, index: number) => string;
|
||||
}
|
||||
|
||||
const POPUP_STYLES = {
|
||||
container:
|
||||
"z-20 absolute p-1 mt-1 -ml-2 max-w-48 max-h-60 rounded border bg-popover text-popover-foreground shadow-lg font-mono flex flex-col overflow-y-auto overflow-x-hidden",
|
||||
item: "rounded p-1 px-2 w-full text-sm cursor-pointer transition-colors select-none hover:bg-accent hover:text-accent-foreground",
|
||||
};
|
||||
|
||||
export function SuggestionsPopup<T>({
|
||||
position,
|
||||
suggestions,
|
||||
selectedIndex,
|
||||
onItemSelect,
|
||||
renderItem,
|
||||
getItemKey,
|
||||
}: SuggestionsPopupProps<T>) {
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
const selectedItemRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
selectedItemRef.current?.scrollIntoView({ block: "nearest", behavior: "smooth" });
|
||||
}, [selectedIndex]);
|
||||
|
||||
return (
|
||||
<div ref={containerRef} className={POPUP_STYLES.container} style={{ left: position.left, top: position.top + position.height }}>
|
||||
{suggestions.map((item, i) => (
|
||||
<div
|
||||
key={getItemKey(item, i)}
|
||||
ref={i === selectedIndex ? selectedItemRef : null}
|
||||
onMouseDown={() => onItemSelect(item)}
|
||||
className={cn(POPUP_STYLES.item, i === selectedIndex && "bg-accent text-accent-foreground")}
|
||||
>
|
||||
{renderItem(item, i === selectedIndex)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
import { useMemo } from "react";
|
||||
import { matchPath } from "react-router-dom";
|
||||
import OverflowTip from "@/components/kit/OverflowTip";
|
||||
import { useTagCounts } from "@/hooks/useUserQueries";
|
||||
import { Routes } from "@/router";
|
||||
import type { TagSuggestionsProps } from "../types";
|
||||
import { SuggestionsPopup } from "./SuggestionsPopup";
|
||||
import { useSuggestions } from "./useSuggestions";
|
||||
|
||||
export default function TagSuggestions({ editorRef, editorActions }: TagSuggestionsProps) {
|
||||
// On explore page, show all users' tags; otherwise show current user's tags
|
||||
const isExplorePage = Boolean(matchPath(Routes.EXPLORE, window.location.pathname));
|
||||
const { data: tagCount = {} } = useTagCounts(!isExplorePage);
|
||||
|
||||
const sortedTags = useMemo(() => {
|
||||
return Object.entries(tagCount)
|
||||
.sort((a, b) => b[1] - a[1] || a[0].localeCompare(b[0]))
|
||||
.map(([tag]) => tag);
|
||||
}, [tagCount]);
|
||||
|
||||
const { position, suggestions, selectedIndex, isVisible, handleItemSelect } = useSuggestions({
|
||||
editorRef,
|
||||
editorActions,
|
||||
triggerChar: "#",
|
||||
items: sortedTags,
|
||||
filterItems: (items, query) => (!query ? items : items.filter((tag) => tag.toLowerCase().includes(query))),
|
||||
onAutocomplete: (tag, word, index, actions) => {
|
||||
actions.removeText(index, word.length);
|
||||
actions.insertText(`#${tag} `);
|
||||
},
|
||||
});
|
||||
|
||||
if (!isVisible || !position) return null;
|
||||
|
||||
return (
|
||||
<SuggestionsPopup
|
||||
position={position}
|
||||
suggestions={suggestions}
|
||||
selectedIndex={selectedIndex}
|
||||
onItemSelect={handleItemSelect}
|
||||
getItemKey={(tag) => tag}
|
||||
renderItem={(tag) => (
|
||||
<OverflowTip>
|
||||
<span className="text-muted-foreground mr-1">#</span>
|
||||
{tag}
|
||||
</OverflowTip>
|
||||
)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
export interface Command {
|
||||
name: string;
|
||||
run: () => string;
|
||||
cursorOffset?: number;
|
||||
}
|
||||
|
||||
export const editorCommands: Command[] = [
|
||||
{
|
||||
name: "todo",
|
||||
run: () => "- [ ] ",
|
||||
cursorOffset: 6,
|
||||
},
|
||||
{
|
||||
name: "code",
|
||||
run: () => "```\n\n```",
|
||||
cursorOffset: 4,
|
||||
},
|
||||
{
|
||||
name: "link",
|
||||
run: () => "[text](url)",
|
||||
cursorOffset: 1,
|
||||
},
|
||||
{
|
||||
name: "table",
|
||||
run: () => "| Header | Header |\n| ------ | ------ |\n| Cell | Cell |",
|
||||
cursorOffset: 1,
|
||||
},
|
||||
];
|
||||
@@ -0,0 +1,236 @@
|
||||
import { type ClipboardEvent, forwardRef, useCallback, useEffect, useImperativeHandle, useMemo, useRef } from "react";
|
||||
import getCaretCoordinates from "textarea-caret";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { EDITOR_HEIGHT } from "../constants";
|
||||
import type { EditorProps } from "../types";
|
||||
import { editorCommands } from "./commands";
|
||||
import SlashCommands from "./SlashCommands";
|
||||
import { getMarkdownLinkForPastedUrl } from "./shortcuts";
|
||||
import TagSuggestions from "./TagSuggestions";
|
||||
import { useListCompletion } from "./useListCompletion";
|
||||
|
||||
export interface EditorRefActions {
|
||||
getEditor: () => HTMLTextAreaElement | null;
|
||||
focus: () => void;
|
||||
scrollToCursor: () => void;
|
||||
insertText: (text: string, prefix?: string, suffix?: string) => void;
|
||||
removeText: (start: number, length: number) => void;
|
||||
setContent: (text: string) => void;
|
||||
getContent: () => string;
|
||||
getSelectedContent: () => string;
|
||||
getCursorPosition: () => number;
|
||||
setCursorPosition: (startPos: number, endPos?: number) => void;
|
||||
getCursorLineNumber: () => number;
|
||||
getLine: (lineNumber: number) => string;
|
||||
setLine: (lineNumber: number, text: string) => void;
|
||||
}
|
||||
|
||||
const Editor = forwardRef(function Editor(props: EditorProps, ref: React.ForwardedRef<EditorRefActions>) {
|
||||
const {
|
||||
className,
|
||||
initialContent,
|
||||
placeholder,
|
||||
onPaste,
|
||||
onContentChange: handleContentChangeCallback,
|
||||
isFocusMode,
|
||||
isInIME = false,
|
||||
onCompositionStart,
|
||||
onCompositionEnd,
|
||||
} = props;
|
||||
const editorRef = useRef<HTMLTextAreaElement>(null);
|
||||
|
||||
const updateEditorHeight = useCallback(() => {
|
||||
if (editorRef.current) {
|
||||
editorRef.current.style.height = "auto";
|
||||
editorRef.current.style.height = `${editorRef.current.scrollHeight ?? 0}px`;
|
||||
}
|
||||
}, []);
|
||||
|
||||
const updateContent = useCallback(() => {
|
||||
if (editorRef.current) {
|
||||
handleContentChangeCallback(editorRef.current.value);
|
||||
updateEditorHeight();
|
||||
}
|
||||
}, [handleContentChangeCallback, updateEditorHeight]);
|
||||
|
||||
const scrollToCaret = useCallback((options: { force?: boolean } = {}) => {
|
||||
const editor = editorRef.current;
|
||||
if (!editor) return;
|
||||
|
||||
const { force = false } = options;
|
||||
const caret = getCaretCoordinates(editor, editor.selectionEnd);
|
||||
|
||||
if (force) {
|
||||
editor.scrollTop = Math.max(0, caret.top - editor.clientHeight / 2);
|
||||
return;
|
||||
}
|
||||
|
||||
const lineHeight = parseFloat(getComputedStyle(editor).lineHeight) || 24;
|
||||
const viewportBottom = editor.scrollTop + editor.clientHeight;
|
||||
// Scroll if cursor is near or beyond bottom edge (within 2 lines)
|
||||
if (caret.top + lineHeight * 2 > viewportBottom) {
|
||||
editor.scrollTop = Math.max(0, caret.top - editor.clientHeight / 2);
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (editorRef.current && initialContent) {
|
||||
editorRef.current.value = initialContent;
|
||||
handleContentChangeCallback(initialContent);
|
||||
updateEditorHeight();
|
||||
}
|
||||
// Only run once on mount to set initial content
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, []);
|
||||
|
||||
// Update editor when content is externally changed (e.g., reset after save)
|
||||
useEffect(() => {
|
||||
if (editorRef.current && editorRef.current.value !== initialContent) {
|
||||
editorRef.current.value = initialContent;
|
||||
updateEditorHeight();
|
||||
}
|
||||
}, [initialContent, updateEditorHeight]);
|
||||
|
||||
const editorActions: EditorRefActions = useMemo(
|
||||
() => ({
|
||||
getEditor: () => editorRef.current,
|
||||
focus: () => editorRef.current?.focus(),
|
||||
scrollToCursor: () => {
|
||||
scrollToCaret({ force: true });
|
||||
},
|
||||
insertText: (content = "", prefix = "", suffix = "") => {
|
||||
const editor = editorRef.current;
|
||||
if (!editor) return;
|
||||
|
||||
const cursorPos = editor.selectionStart;
|
||||
const endPos = editor.selectionEnd;
|
||||
const prev = editor.value;
|
||||
const actual = content || prev.slice(cursorPos, endPos);
|
||||
editor.value = prev.slice(0, cursorPos) + prefix + actual + suffix + prev.slice(endPos);
|
||||
|
||||
editor.focus();
|
||||
editor.setSelectionRange(cursorPos + prefix.length + actual.length, cursorPos + prefix.length + actual.length);
|
||||
updateContent();
|
||||
},
|
||||
removeText: (start: number, length: number) => {
|
||||
const editor = editorRef.current;
|
||||
if (!editor) return;
|
||||
|
||||
editor.value = editor.value.slice(0, start) + editor.value.slice(start + length);
|
||||
editor.focus();
|
||||
editor.setSelectionRange(start, start);
|
||||
updateContent();
|
||||
},
|
||||
setContent: (text: string) => {
|
||||
const editor = editorRef.current;
|
||||
if (editor) {
|
||||
editor.value = text;
|
||||
updateContent();
|
||||
}
|
||||
},
|
||||
getContent: () => editorRef.current?.value ?? "",
|
||||
getCursorPosition: () => editorRef.current?.selectionStart ?? 0,
|
||||
getSelectedContent: () => {
|
||||
const editor = editorRef.current;
|
||||
if (!editor) return "";
|
||||
return editor.value.slice(editor.selectionStart, editor.selectionEnd);
|
||||
},
|
||||
setCursorPosition: (startPos: number, endPos?: number) => {
|
||||
const editor = editorRef.current;
|
||||
if (!editor) return;
|
||||
// setSelectionRange requires valid arguments; default to startPos if endPos is undefined
|
||||
const endPosition = endPos !== undefined && !Number.isNaN(endPos) ? endPos : startPos;
|
||||
editor.setSelectionRange(startPos, endPosition);
|
||||
},
|
||||
getCursorLineNumber: () => {
|
||||
const editor = editorRef.current;
|
||||
if (!editor) return 0;
|
||||
const lines = editor.value.slice(0, editor.selectionStart).split("\n");
|
||||
return lines.length - 1;
|
||||
},
|
||||
getLine: (lineNumber: number) => editorRef.current?.value.split("\n")[lineNumber] ?? "",
|
||||
setLine: (lineNumber: number, text: string) => {
|
||||
const editor = editorRef.current;
|
||||
if (!editor) return;
|
||||
const lines = editor.value.split("\n");
|
||||
lines[lineNumber] = text;
|
||||
editor.value = lines.join("\n");
|
||||
editor.focus();
|
||||
updateContent();
|
||||
},
|
||||
}),
|
||||
[updateContent, scrollToCaret],
|
||||
);
|
||||
|
||||
useImperativeHandle(ref, () => editorActions, [editorActions]);
|
||||
|
||||
const handleEditorInput = useCallback(() => {
|
||||
if (editorRef.current) {
|
||||
handleContentChangeCallback(editorRef.current.value);
|
||||
updateEditorHeight();
|
||||
|
||||
// Auto-scroll to keep cursor visible when typing
|
||||
// See: https://github.com/usememos/memos/issues/5469
|
||||
scrollToCaret();
|
||||
}
|
||||
}, [handleContentChangeCallback, updateEditorHeight, scrollToCaret]);
|
||||
|
||||
// Auto-complete markdown lists when pressing Enter
|
||||
useListCompletion({
|
||||
editorRef,
|
||||
editorActions,
|
||||
isInIME,
|
||||
});
|
||||
|
||||
const handleEditorPaste = useCallback(
|
||||
(event: ClipboardEvent<HTMLTextAreaElement>) => {
|
||||
const editor = editorRef.current;
|
||||
const pastedText = event.clipboardData?.getData("text/plain") || event.clipboardData?.getData("text");
|
||||
const markdownLink = editor ? getMarkdownLinkForPastedUrl(editorActions.getSelectedContent(), pastedText) : undefined;
|
||||
|
||||
if (markdownLink) {
|
||||
event.preventDefault();
|
||||
editorActions.insertText(markdownLink);
|
||||
return;
|
||||
}
|
||||
|
||||
onPaste(event);
|
||||
},
|
||||
[editorActions, onPaste],
|
||||
);
|
||||
|
||||
// Recalculate editor height when focus mode changes
|
||||
useEffect(() => {
|
||||
updateEditorHeight();
|
||||
}, [isFocusMode, updateEditorHeight]);
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"flex flex-col justify-start items-start relative w-full bg-inherit",
|
||||
// Focus mode: flex-1 to grow and fill space; Normal: h-auto with max-height
|
||||
isFocusMode ? "flex-1" : `h-auto ${EDITOR_HEIGHT.normal}`,
|
||||
className,
|
||||
)}
|
||||
>
|
||||
<textarea
|
||||
className={cn(
|
||||
"w-full text-base resize-none overflow-x-hidden overflow-y-auto bg-transparent outline-none placeholder:opacity-70 whitespace-pre-wrap wrap-break-word",
|
||||
// Focus mode: flex-1 h-0 to grow within flex container; Normal: h-full to fill wrapper
|
||||
isFocusMode ? "flex-1 h-0" : "h-full",
|
||||
)}
|
||||
rows={1}
|
||||
placeholder={placeholder}
|
||||
ref={editorRef}
|
||||
onPaste={handleEditorPaste}
|
||||
onInput={handleEditorInput}
|
||||
onCompositionStart={onCompositionStart}
|
||||
onCompositionEnd={onCompositionEnd}
|
||||
></textarea>
|
||||
<TagSuggestions editorRef={editorRef} editorActions={ref} />
|
||||
<SlashCommands editorRef={editorRef} editorActions={ref} commands={editorCommands} />
|
||||
</div>
|
||||
);
|
||||
});
|
||||
|
||||
export default Editor;
|
||||
@@ -0,0 +1,80 @@
|
||||
import type { EditorRefActions } from "./index";
|
||||
|
||||
const SHORTCUTS = {
|
||||
BOLD: { key: "b", delimiter: "**" },
|
||||
ITALIC: { key: "i", delimiter: "*" },
|
||||
LINK: { key: "k" },
|
||||
} as const;
|
||||
|
||||
const URL_PLACEHOLDER = "url";
|
||||
const URL_REGEX = /^https?:\/\/[^\s]+$/;
|
||||
const LINK_OFFSET = 3; // Length of "]()"
|
||||
|
||||
export function handleMarkdownShortcuts(event: React.KeyboardEvent, editor: EditorRefActions): void {
|
||||
const key = event.key.toLowerCase();
|
||||
if (key === SHORTCUTS.BOLD.key) {
|
||||
event.preventDefault();
|
||||
toggleTextStyle(editor, SHORTCUTS.BOLD.delimiter);
|
||||
} else if (key === SHORTCUTS.ITALIC.key) {
|
||||
event.preventDefault();
|
||||
toggleTextStyle(editor, SHORTCUTS.ITALIC.delimiter);
|
||||
} else if (key === SHORTCUTS.LINK.key) {
|
||||
event.preventDefault();
|
||||
insertHyperlink(editor);
|
||||
}
|
||||
}
|
||||
|
||||
export function insertHyperlink(editor: EditorRefActions, url?: string): void {
|
||||
const cursorPosition = editor.getCursorPosition();
|
||||
const selectedContent = editor.getSelectedContent();
|
||||
const isUrlSelected = !url && URL_REGEX.test(selectedContent.trim());
|
||||
|
||||
if (isUrlSelected) {
|
||||
editor.insertText(`[](${selectedContent})`);
|
||||
editor.setCursorPosition(cursorPosition + 1, cursorPosition + 1);
|
||||
return;
|
||||
}
|
||||
|
||||
const href = url ?? URL_PLACEHOLDER;
|
||||
editor.insertText(`[${selectedContent}](${href})`);
|
||||
|
||||
if (href === URL_PLACEHOLDER) {
|
||||
const urlStart = cursorPosition + selectedContent.length + LINK_OFFSET;
|
||||
editor.setCursorPosition(urlStart, urlStart + href.length);
|
||||
}
|
||||
}
|
||||
|
||||
function toggleTextStyle(editor: EditorRefActions, delimiter: string): void {
|
||||
const cursorPosition = editor.getCursorPosition();
|
||||
const selectedContent = editor.getSelectedContent();
|
||||
const isStyled = selectedContent.startsWith(delimiter) && selectedContent.endsWith(delimiter);
|
||||
|
||||
if (isStyled) {
|
||||
const unstyled = selectedContent.slice(delimiter.length, -delimiter.length);
|
||||
editor.insertText(unstyled);
|
||||
editor.setCursorPosition(cursorPosition, cursorPosition + unstyled.length);
|
||||
} else {
|
||||
editor.insertText(`${delimiter}${selectedContent}${delimiter}`);
|
||||
editor.setCursorPosition(cursorPosition + delimiter.length, cursorPosition + delimiter.length + selectedContent.length);
|
||||
}
|
||||
}
|
||||
|
||||
export function hyperlinkHighlightedText(editor: EditorRefActions, url: string): void {
|
||||
const selectedContent = editor.getSelectedContent();
|
||||
const cursorPosition = editor.getCursorPosition();
|
||||
|
||||
editor.insertText(`[${selectedContent}](${url})`);
|
||||
|
||||
const newPosition = cursorPosition + selectedContent.length + url.length + 4;
|
||||
editor.setCursorPosition(newPosition, newPosition);
|
||||
}
|
||||
|
||||
export function getMarkdownLinkForPastedUrl(selectedContent: string, pastedText: string): string | undefined {
|
||||
const url = pastedText.trim();
|
||||
|
||||
if (!selectedContent || !URL_REGEX.test(url) || URL_REGEX.test(selectedContent.trim())) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
return `[${selectedContent}](${url})`;
|
||||
}
|
||||
@@ -0,0 +1,82 @@
|
||||
import { type RefObject, useEffect, useRef } from "react";
|
||||
import { detectLastListItem, generateListContinuation } from "@/utils/markdown-list-detection";
|
||||
import { EditorRefActions } from ".";
|
||||
|
||||
interface UseListCompletionOptions {
|
||||
editorRef: RefObject<HTMLTextAreaElement | null>;
|
||||
editorActions: EditorRefActions;
|
||||
isInIME: boolean;
|
||||
}
|
||||
|
||||
// Patterns to detect empty list items
|
||||
const EMPTY_LIST_PATTERNS = [
|
||||
/^(\s*)([-*+])\s*$/, // Empty unordered list
|
||||
/^(\s*)([-*+])\s+\[([ xX])\]\s*$/, // Empty task list
|
||||
/^(\s*)(\d+)[.)]\s*$/, // Empty ordered list
|
||||
];
|
||||
|
||||
const isEmptyListItem = (line: string) => EMPTY_LIST_PATTERNS.some((pattern) => pattern.test(line));
|
||||
|
||||
export function useListCompletion({ editorRef, editorActions, isInIME }: UseListCompletionOptions) {
|
||||
const isInIMERef = useRef(isInIME);
|
||||
isInIMERef.current = isInIME;
|
||||
|
||||
const editorActionsRef = useRef(editorActions);
|
||||
editorActionsRef.current = editorActions;
|
||||
|
||||
// Track when composition ends to handle Safari race condition
|
||||
// Safari fires keydown(Enter) immediately after compositionend, while Chrome doesn't
|
||||
// See: https://github.com/usememos/memos/issues/5469
|
||||
const lastCompositionEndRef = useRef(0);
|
||||
|
||||
useEffect(() => {
|
||||
const editor = editorRef.current;
|
||||
if (!editor) return;
|
||||
|
||||
const handleCompositionEnd = () => {
|
||||
lastCompositionEndRef.current = Date.now();
|
||||
};
|
||||
|
||||
const handleKeyDown = (event: KeyboardEvent) => {
|
||||
if (event.key !== "Enter" || isInIMERef.current || event.shiftKey || event.ctrlKey || event.metaKey || event.altKey) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Safari fix: Ignore Enter key within 100ms of composition end
|
||||
// This prevents double-enter behavior when confirming IME input in lists
|
||||
if (Date.now() - lastCompositionEndRef.current < 100) {
|
||||
return;
|
||||
}
|
||||
|
||||
const actions = editorActionsRef.current;
|
||||
const cursorPosition = actions.getCursorPosition();
|
||||
const contentBeforeCursor = actions.getContent().substring(0, cursorPosition);
|
||||
const listInfo = detectLastListItem(contentBeforeCursor);
|
||||
|
||||
if (!listInfo.type) return;
|
||||
|
||||
event.preventDefault();
|
||||
|
||||
const lines = contentBeforeCursor.split("\n");
|
||||
const currentLine = lines[lines.length - 1];
|
||||
|
||||
if (isEmptyListItem(currentLine)) {
|
||||
const lineStartPos = cursorPosition - currentLine.length;
|
||||
actions.removeText(lineStartPos, currentLine.length);
|
||||
} else {
|
||||
const continuation = generateListContinuation(listInfo);
|
||||
actions.insertText("\n" + continuation);
|
||||
|
||||
// Auto-scroll to keep cursor visible after inserting list item
|
||||
setTimeout(() => actions.scrollToCursor(), 0);
|
||||
}
|
||||
};
|
||||
|
||||
editor.addEventListener("compositionend", handleCompositionEnd);
|
||||
editor.addEventListener("keydown", handleKeyDown);
|
||||
return () => {
|
||||
editor.removeEventListener("compositionend", handleCompositionEnd);
|
||||
editor.removeEventListener("keydown", handleKeyDown);
|
||||
};
|
||||
}, []);
|
||||
}
|
||||
@@ -0,0 +1,165 @@
|
||||
import { type ForwardedRef, type RefObject, useEffect, useRef, useState } from "react";
|
||||
import getCaretCoordinates from "textarea-caret";
|
||||
import { EditorRefActions } from ".";
|
||||
|
||||
export interface Position {
|
||||
left: number;
|
||||
top: number;
|
||||
height: number;
|
||||
}
|
||||
|
||||
export interface UseSuggestionsOptions<T> {
|
||||
editorRef: RefObject<HTMLTextAreaElement | null>;
|
||||
editorActions: ForwardedRef<EditorRefActions>;
|
||||
triggerChar: string;
|
||||
items: T[];
|
||||
filterItems: (items: T[], searchQuery: string) => T[];
|
||||
onAutocomplete: (item: T, word: string, startIndex: number, actions: EditorRefActions) => void;
|
||||
}
|
||||
|
||||
export interface UseSuggestionsReturn<T> {
|
||||
position: Position | null;
|
||||
suggestions: T[];
|
||||
selectedIndex: number;
|
||||
isVisible: boolean;
|
||||
searchQuery: string;
|
||||
handleItemSelect: (item: T) => void;
|
||||
}
|
||||
|
||||
export function useSuggestions<T>({
|
||||
editorRef,
|
||||
editorActions,
|
||||
triggerChar,
|
||||
items,
|
||||
filterItems,
|
||||
onAutocomplete,
|
||||
}: UseSuggestionsOptions<T>): UseSuggestionsReturn<T> {
|
||||
const [position, setPosition] = useState<Position | null>(null);
|
||||
const [selectedIndex, setSelectedIndex] = useState(0);
|
||||
const isProcessingRef = useRef(false);
|
||||
|
||||
const selectedRef = useRef(selectedIndex);
|
||||
selectedRef.current = selectedIndex;
|
||||
|
||||
const getCurrentWord = (): [word: string, startIndex: number] => {
|
||||
const editor = editorRef.current;
|
||||
if (!editor) return ["", 0];
|
||||
const cursorPos = editor.selectionEnd;
|
||||
const before = editor.value.slice(0, cursorPos).match(/\S*$/) || { 0: "", index: cursorPos };
|
||||
const after = editor.value.slice(cursorPos).match(/^\S*/) || { 0: "" };
|
||||
return [before[0] + after[0], before.index ?? cursorPos];
|
||||
};
|
||||
|
||||
const hide = () => setPosition(null);
|
||||
|
||||
const suggestionsRef = useRef<T[]>([]);
|
||||
const searchQueryRef = useRef("");
|
||||
suggestionsRef.current = (() => {
|
||||
const [word] = getCurrentWord();
|
||||
if (!word.startsWith(triggerChar)) return [];
|
||||
searchQueryRef.current = word.slice(triggerChar.length).toLowerCase();
|
||||
return filterItems(items, searchQueryRef.current);
|
||||
})();
|
||||
if (suggestionsRef.current.length === 0) {
|
||||
const [word] = getCurrentWord();
|
||||
searchQueryRef.current = word.startsWith(triggerChar) ? word.slice(triggerChar.length).toLowerCase() : "";
|
||||
}
|
||||
|
||||
const isVisibleRef = useRef(false);
|
||||
isVisibleRef.current = !!(position && suggestionsRef.current.length > 0);
|
||||
|
||||
const handleAutocomplete = (item: T) => {
|
||||
if (!editorActions || !("current" in editorActions) || !editorActions.current) {
|
||||
console.warn("useSuggestions: editorActions not available");
|
||||
return;
|
||||
}
|
||||
isProcessingRef.current = true;
|
||||
const [word, index] = getCurrentWord();
|
||||
onAutocomplete(item, word, index, editorActions.current);
|
||||
hide();
|
||||
// Re-enable input handling after all DOM operations complete
|
||||
queueMicrotask(() => {
|
||||
isProcessingRef.current = false;
|
||||
});
|
||||
};
|
||||
|
||||
const handleNavigation = (e: KeyboardEvent, selected: number, suggestionsCount: number) => {
|
||||
if (e.code === "ArrowDown") {
|
||||
setSelectedIndex((selected + 1) % suggestionsCount);
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
} else if (e.code === "ArrowUp") {
|
||||
setSelectedIndex((selected - 1 + suggestionsCount) % suggestionsCount);
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
}
|
||||
};
|
||||
|
||||
const handleKeyDown = (e: KeyboardEvent) => {
|
||||
if (!isVisibleRef.current) return;
|
||||
|
||||
const suggestions = suggestionsRef.current;
|
||||
const selected = selectedRef.current;
|
||||
|
||||
if (["Escape", "ArrowLeft", "ArrowRight"].includes(e.code)) {
|
||||
hide();
|
||||
return;
|
||||
}
|
||||
|
||||
if (["ArrowDown", "ArrowUp"].includes(e.code)) {
|
||||
handleNavigation(e, selected, suggestions.length);
|
||||
return;
|
||||
}
|
||||
|
||||
if (["Enter", "Tab"].includes(e.code)) {
|
||||
handleAutocomplete(suggestions[selected]);
|
||||
e.preventDefault();
|
||||
e.stopImmediatePropagation();
|
||||
}
|
||||
};
|
||||
|
||||
const handleInput = () => {
|
||||
if (isProcessingRef.current) return;
|
||||
|
||||
const editor = editorRef.current;
|
||||
if (!editor) return;
|
||||
|
||||
setSelectedIndex(0);
|
||||
const [word, index] = getCurrentWord();
|
||||
const currentChar = editor.value[editor.selectionEnd];
|
||||
const isActive = word.startsWith(triggerChar) && currentChar !== triggerChar;
|
||||
|
||||
if (isActive) {
|
||||
const coords = getCaretCoordinates(editor, index);
|
||||
coords.top -= editor.scrollTop;
|
||||
setPosition(coords);
|
||||
} else {
|
||||
hide();
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
const editor = editorRef.current;
|
||||
if (!editor) return;
|
||||
|
||||
const handlers = { click: hide, blur: hide, keydown: handleKeyDown, input: handleInput };
|
||||
Object.entries(handlers).forEach(([event, handler]) => {
|
||||
editor.addEventListener(event, handler as EventListener);
|
||||
});
|
||||
|
||||
return () => {
|
||||
Object.entries(handlers).forEach(([event, handler]) => {
|
||||
editor.removeEventListener(event, handler as EventListener);
|
||||
});
|
||||
};
|
||||
}, []);
|
||||
|
||||
return {
|
||||
position,
|
||||
suggestions: suggestionsRef.current,
|
||||
selectedIndex,
|
||||
isVisible: isVisibleRef.current,
|
||||
searchQuery: searchQueryRef.current,
|
||||
handleItemSelect: handleAutocomplete,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,73 @@
|
||||
# MemoEditor Architecture
|
||||
|
||||
## Overview
|
||||
|
||||
MemoEditor uses a three-layer architecture for better separation of concerns and testability.
|
||||
|
||||
## Architecture
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────┐
|
||||
│ Presentation Layer (Components) │
|
||||
│ - EditorToolbar, EditorContent, etc. │
|
||||
└─────────────────┬───────────────────────┘
|
||||
│
|
||||
┌─────────────────▼───────────────────────┐
|
||||
│ State Layer (Reducer + Context) │
|
||||
│ - state/, useEditorContext() │
|
||||
└─────────────────┬───────────────────────┘
|
||||
│
|
||||
┌─────────────────▼───────────────────────┐
|
||||
│ Service Layer (Business Logic) │
|
||||
│ - services/ (pure functions) │
|
||||
└─────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
## Directory Structure
|
||||
|
||||
```
|
||||
MemoEditor/
|
||||
├── state/ # State management (reducer, actions, context)
|
||||
├── services/ # Business logic (pure functions)
|
||||
├── components/ # UI components
|
||||
├── hooks/ # React hooks (utilities)
|
||||
├── Editor/ # Core editor component
|
||||
├── Toolbar/ # Toolbar components
|
||||
├── constants.ts
|
||||
└── types/
|
||||
```
|
||||
|
||||
## Key Concepts
|
||||
|
||||
### State Management
|
||||
|
||||
Uses `useReducer` + Context for predictable state transitions. All state changes go through action creators.
|
||||
|
||||
### Services
|
||||
|
||||
Pure TypeScript functions containing business logic. No React hooks, easy to test.
|
||||
|
||||
### Components
|
||||
|
||||
Thin presentation components that dispatch actions and render UI.
|
||||
|
||||
## Usage
|
||||
|
||||
```typescript
|
||||
import MemoEditor from "@/components/MemoEditor";
|
||||
|
||||
<MemoEditor
|
||||
memoName="memos/123"
|
||||
onConfirm={(name) => console.log('Saved:', name)}
|
||||
onCancel={() => console.log('Cancelled')}
|
||||
/>
|
||||
```
|
||||
|
||||
## Testing
|
||||
|
||||
Services are pure functions - easy to unit test without React.
|
||||
|
||||
```typescript
|
||||
const state = mockEditorState();
|
||||
const result = await memoService.save(state, { memoName: 'memos/123' });
|
||||
```
|
||||
@@ -0,0 +1,256 @@
|
||||
import { uniqBy } from "lodash-es";
|
||||
import {
|
||||
FileIcon,
|
||||
ImageIcon,
|
||||
LinkIcon,
|
||||
LoaderIcon,
|
||||
type LucideIcon,
|
||||
MapPinIcon,
|
||||
Maximize2Icon,
|
||||
MicIcon,
|
||||
MoreHorizontalIcon,
|
||||
PlusIcon,
|
||||
} from "lucide-react";
|
||||
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||
import { LinkMemoDialog, LocationDialog } from "@/components/MemoMetadata";
|
||||
import type { MapPoint } from "@/components/map/types";
|
||||
import { useReverseGeocoding } from "@/components/map/useReverseGeocoding";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuSub,
|
||||
DropdownMenuSubContent,
|
||||
DropdownMenuSubTrigger,
|
||||
DropdownMenuTrigger,
|
||||
useDropdownMenuSubHoverDelay,
|
||||
} from "@/components/ui/dropdown-menu";
|
||||
import { useDebouncedEffect } from "@/hooks";
|
||||
import type { MemoRelation } from "@/types/proto/api/v1/memo_service_pb";
|
||||
import { useTranslate } from "@/utils/i18n";
|
||||
import { useFileUpload, useLinkMemo, useLocation } from "../hooks";
|
||||
import { useEditorContext } from "../state";
|
||||
import type { InsertMenuProps } from "../types";
|
||||
import type { LocalFile } from "../types/attachment";
|
||||
|
||||
const InsertMenu = (props: InsertMenuProps) => {
|
||||
const t = useTranslate();
|
||||
const { state, actions, dispatch } = useEditorContext();
|
||||
const { location: initialLocation, onLocationChange, onToggleFocusMode, isUploading: isUploadingProp } = props;
|
||||
|
||||
const [linkDialogOpen, setLinkDialogOpen] = useState(false);
|
||||
const [locationDialogOpen, setLocationDialogOpen] = useState(false);
|
||||
const [moreSubmenuOpen, setMoreSubmenuOpen] = useState(false);
|
||||
|
||||
const { handleTriggerEnter, handleTriggerLeave, handleContentEnter, handleContentLeave } = useDropdownMenuSubHoverDelay(
|
||||
150,
|
||||
setMoreSubmenuOpen,
|
||||
);
|
||||
|
||||
const { fileInputRef, selectingFlag, handleFileInputChange, handleUploadClick } = useFileUpload((newFiles: LocalFile[]) => {
|
||||
newFiles.forEach((file) => dispatch(actions.addLocalFile(file)));
|
||||
});
|
||||
|
||||
const linkMemo = useLinkMemo({
|
||||
isOpen: linkDialogOpen,
|
||||
currentMemoName: props.memoName,
|
||||
existingRelations: state.metadata.relations,
|
||||
onAddRelation: (relation: MemoRelation) => {
|
||||
dispatch(actions.setMetadata({ relations: uniqBy([...state.metadata.relations, relation], (r) => r.relatedMemo?.name) }));
|
||||
setLinkDialogOpen(false);
|
||||
},
|
||||
});
|
||||
|
||||
const location = useLocation(props.location);
|
||||
const {
|
||||
state: locationState,
|
||||
locationInitialized,
|
||||
handlePositionChange: handleLocationPositionChange,
|
||||
getLocation,
|
||||
reset: locationReset,
|
||||
updateCoordinate,
|
||||
setPlaceholder,
|
||||
} = location;
|
||||
|
||||
const [debouncedPosition, setDebouncedPosition] = useState<MapPoint | undefined>(undefined);
|
||||
|
||||
useDebouncedEffect(
|
||||
() => {
|
||||
setDebouncedPosition(locationState.position);
|
||||
},
|
||||
1000,
|
||||
[locationState.position],
|
||||
);
|
||||
|
||||
const { data: displayName } = useReverseGeocoding(debouncedPosition?.lat, debouncedPosition?.lng);
|
||||
|
||||
useEffect(() => {
|
||||
if (displayName) {
|
||||
setPlaceholder(displayName);
|
||||
}
|
||||
}, [displayName, setPlaceholder]);
|
||||
|
||||
const isUploading = selectingFlag || isUploadingProp;
|
||||
|
||||
const handleOpenLinkDialog = useCallback(() => {
|
||||
setLinkDialogOpen(true);
|
||||
}, []);
|
||||
|
||||
const handleLocationClick = useCallback(() => {
|
||||
setLocationDialogOpen(true);
|
||||
if (!initialLocation && !locationInitialized) {
|
||||
if (navigator.geolocation) {
|
||||
navigator.geolocation.getCurrentPosition(
|
||||
(position) => {
|
||||
handleLocationPositionChange({ lat: position.coords.latitude, lng: position.coords.longitude });
|
||||
},
|
||||
(error) => {
|
||||
console.error("Geolocation error:", error);
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
}, [initialLocation, locationInitialized, handleLocationPositionChange]);
|
||||
|
||||
const handleLocationConfirm = useCallback(() => {
|
||||
const newLocation = getLocation();
|
||||
if (newLocation) {
|
||||
onLocationChange(newLocation);
|
||||
setLocationDialogOpen(false);
|
||||
}
|
||||
}, [getLocation, onLocationChange]);
|
||||
|
||||
const handleLocationCancel = useCallback(() => {
|
||||
locationReset();
|
||||
setLocationDialogOpen(false);
|
||||
}, [locationReset]);
|
||||
|
||||
const handleToggleFocusMode = useCallback(() => {
|
||||
onToggleFocusMode?.();
|
||||
setMoreSubmenuOpen(false);
|
||||
}, [onToggleFocusMode]);
|
||||
|
||||
const handleMediaUploadClick = useCallback(() => {
|
||||
handleUploadClick("image/*,video/*");
|
||||
}, [handleUploadClick]);
|
||||
|
||||
const handleFileUploadClick = useCallback(() => {
|
||||
handleUploadClick();
|
||||
}, [handleUploadClick]);
|
||||
|
||||
const menuItems = useMemo(
|
||||
() =>
|
||||
[
|
||||
{
|
||||
key: "upload-media",
|
||||
label: t("attachment-library.tabs.media"),
|
||||
icon: ImageIcon,
|
||||
onClick: handleMediaUploadClick,
|
||||
},
|
||||
{
|
||||
key: "record-audio",
|
||||
label: t("editor.audio-recorder.trigger"),
|
||||
icon: MicIcon,
|
||||
onClick: () => props.onAudioRecorderClick?.(),
|
||||
},
|
||||
{
|
||||
key: "upload-file",
|
||||
label: t("common.file"),
|
||||
icon: FileIcon,
|
||||
onClick: handleFileUploadClick,
|
||||
},
|
||||
{
|
||||
key: "link",
|
||||
label: t("editor.insert-menu.link-memo"),
|
||||
icon: LinkIcon,
|
||||
onClick: handleOpenLinkDialog,
|
||||
},
|
||||
{
|
||||
key: "location",
|
||||
label: t("editor.insert-menu.add-location"),
|
||||
icon: MapPinIcon,
|
||||
onClick: handleLocationClick,
|
||||
},
|
||||
] satisfies Array<{ key: string; label: string; icon: LucideIcon; onClick: () => void }>,
|
||||
[handleFileUploadClick, handleLocationClick, handleMediaUploadClick, handleOpenLinkDialog, props, t],
|
||||
);
|
||||
|
||||
return (
|
||||
<>
|
||||
<DropdownMenu modal={false}>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button variant="outline" size="icon" className="shadow-none" disabled={isUploading}>
|
||||
{isUploading ? <LoaderIcon className="size-4 animate-spin" /> : <PlusIcon className="size-4" />}
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="start">
|
||||
{menuItems.slice(0, 3).map((item) => (
|
||||
<DropdownMenuItem key={item.key} onClick={item.onClick}>
|
||||
<item.icon className="w-4 h-4" />
|
||||
{item.label}
|
||||
</DropdownMenuItem>
|
||||
))}
|
||||
<DropdownMenuSeparator />
|
||||
{menuItems.slice(3).map((item) => (
|
||||
<DropdownMenuItem key={item.key} onClick={item.onClick}>
|
||||
<item.icon className="w-4 h-4" />
|
||||
{item.label}
|
||||
</DropdownMenuItem>
|
||||
))}
|
||||
<DropdownMenuSeparator />
|
||||
{/* View submenu with Focus Mode */}
|
||||
<DropdownMenuSub open={moreSubmenuOpen} onOpenChange={setMoreSubmenuOpen}>
|
||||
<DropdownMenuSubTrigger onPointerEnter={handleTriggerEnter} onPointerLeave={handleTriggerLeave}>
|
||||
<MoreHorizontalIcon className="w-4 h-4" />
|
||||
{t("common.more")}
|
||||
</DropdownMenuSubTrigger>
|
||||
<DropdownMenuSubContent onPointerEnter={handleContentEnter} onPointerLeave={handleContentLeave}>
|
||||
<DropdownMenuItem onClick={handleToggleFocusMode}>
|
||||
<Maximize2Icon className="w-4 h-4" />
|
||||
{t("editor.focus-mode")}
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuSubContent>
|
||||
</DropdownMenuSub>
|
||||
<div className="px-2 py-1 text-xs text-muted-foreground opacity-80">{t("editor.slash-commands")}</div>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
|
||||
{/* Hidden file input */}
|
||||
<input
|
||||
className="hidden"
|
||||
ref={fileInputRef}
|
||||
disabled={isUploading}
|
||||
onChange={handleFileInputChange}
|
||||
type="file"
|
||||
multiple={true}
|
||||
accept="*"
|
||||
/>
|
||||
|
||||
<LinkMemoDialog
|
||||
open={linkDialogOpen}
|
||||
onOpenChange={setLinkDialogOpen}
|
||||
searchText={linkMemo.searchText}
|
||||
onSearchChange={linkMemo.setSearchText}
|
||||
filteredMemos={linkMemo.filteredMemos}
|
||||
isFetching={linkMemo.isFetching}
|
||||
onSelectMemo={linkMemo.addMemoRelation}
|
||||
isAlreadyLinked={linkMemo.isAlreadyLinked}
|
||||
/>
|
||||
|
||||
<LocationDialog
|
||||
open={locationDialogOpen}
|
||||
onOpenChange={setLocationDialogOpen}
|
||||
state={locationState}
|
||||
onPositionChange={handleLocationPositionChange}
|
||||
onUpdateCoordinate={updateCoordinate}
|
||||
onPlaceholderChange={setPlaceholder}
|
||||
onCancel={handleLocationCancel}
|
||||
onConfirm={handleLocationConfirm}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default InsertMenu;
|
||||
@@ -0,0 +1,42 @@
|
||||
import { CheckIcon, ChevronDownIcon } from "lucide-react";
|
||||
import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger } from "@/components/ui/dropdown-menu";
|
||||
import VisibilityIcon from "@/components/VisibilityIcon";
|
||||
import { Visibility } from "@/types/proto/api/v1/memo_service_pb";
|
||||
import { useTranslate } from "@/utils/i18n";
|
||||
import type { VisibilitySelectorProps } from "../types";
|
||||
|
||||
const VisibilitySelector = (props: VisibilitySelectorProps) => {
|
||||
const { value, onChange } = props;
|
||||
const t = useTranslate();
|
||||
|
||||
const visibilityOptions = [
|
||||
{ value: Visibility.PRIVATE, label: t("memo.visibility.private") },
|
||||
{ value: Visibility.PROTECTED, label: t("memo.visibility.protected") },
|
||||
{ value: Visibility.PUBLIC, label: t("memo.visibility.public") },
|
||||
] as const;
|
||||
|
||||
const currentLabel = visibilityOptions.find((option) => option.value === value)?.label || "";
|
||||
|
||||
return (
|
||||
<DropdownMenu onOpenChange={props.onOpenChange}>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<button className="inline-flex items-center px-2 text-sm text-muted-foreground opacity-80 hover:opacity-100 transition-colors">
|
||||
<VisibilityIcon visibility={value} className="opacity-60 mr-1.5" />
|
||||
<span>{currentLabel}</span>
|
||||
<ChevronDownIcon className="ml-0.5 w-4 h-4 opacity-60" />
|
||||
</button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end">
|
||||
{visibilityOptions.map((option) => (
|
||||
<DropdownMenuItem key={option.value} className="cursor-pointer gap-2" onClick={() => onChange(option.value)}>
|
||||
<VisibilityIcon visibility={option.value} />
|
||||
<span className="flex-1">{option.label}</span>
|
||||
{value === option.value && <CheckIcon className="w-4 h-4 text-primary" />}
|
||||
</DropdownMenuItem>
|
||||
))}
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
);
|
||||
};
|
||||
|
||||
export default VisibilitySelector;
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user