first commit
Some checks failed
Backend Tests / Static Checks (push) Has been cancelled
Backend Tests / Tests (other) (push) Has been cancelled
Backend Tests / Tests (plugin) (push) Has been cancelled
Backend Tests / Tests (server) (push) Has been cancelled
Backend Tests / Tests (store) (push) Has been cancelled
Build Canary Image / build-frontend (push) Has been cancelled
Build Canary Image / build-push (linux/amd64) (push) Has been cancelled
Build Canary Image / build-push (linux/arm64) (push) Has been cancelled
Build Canary Image / merge (push) Has been cancelled
Frontend Tests / Lint (push) Has been cancelled
Frontend Tests / Build (push) Has been cancelled
Proto Linter / Lint Protos (push) Has been cancelled
Some checks failed
Backend Tests / Static Checks (push) Has been cancelled
Backend Tests / Tests (other) (push) Has been cancelled
Backend Tests / Tests (plugin) (push) Has been cancelled
Backend Tests / Tests (server) (push) Has been cancelled
Backend Tests / Tests (store) (push) Has been cancelled
Build Canary Image / build-frontend (push) Has been cancelled
Build Canary Image / build-push (linux/amd64) (push) Has been cancelled
Build Canary Image / build-push (linux/arm64) (push) Has been cancelled
Build Canary Image / merge (push) Has been cancelled
Frontend Tests / Lint (push) Has been cancelled
Frontend Tests / Build (push) Has been cancelled
Proto Linter / Lint Protos (push) Has been cancelled
This commit is contained in:
7
web/.gitignore
vendored
Normal file
7
web/.gitignore
vendored
Normal file
@@ -0,0 +1,7 @@
|
||||
node_modules
|
||||
.pnpm-store
|
||||
.DS_Store
|
||||
dist
|
||||
dist-ssr
|
||||
*.local
|
||||
src/types/proto/store
|
||||
202
web/biome.json
Normal file
202
web/biome.json
Normal file
@@ -0,0 +1,202 @@
|
||||
{
|
||||
"$schema": "https://biomejs.dev/schemas/2.3.5/schema.json",
|
||||
"vcs": {
|
||||
"enabled": true,
|
||||
"clientKind": "git",
|
||||
"useIgnoreFile": true
|
||||
},
|
||||
"files": {
|
||||
"includes": [
|
||||
"**",
|
||||
"!!**/dist",
|
||||
"!src/types/proto"
|
||||
],
|
||||
"ignoreUnknown": true
|
||||
},
|
||||
"formatter": {
|
||||
"enabled": true,
|
||||
"formatWithErrors": false,
|
||||
"indentStyle": "space",
|
||||
"indentWidth": 2,
|
||||
"lineEnding": "lf",
|
||||
"lineWidth": 140,
|
||||
"attributePosition": "auto",
|
||||
"bracketSameLine": false,
|
||||
"bracketSpacing": true,
|
||||
"expand": "auto",
|
||||
"useEditorconfig": true
|
||||
},
|
||||
"linter": {
|
||||
"enabled": true,
|
||||
"rules": {
|
||||
"recommended": false,
|
||||
"complexity": {
|
||||
"noAdjacentSpacesInRegex": "error",
|
||||
"noExtraBooleanCast": "error",
|
||||
"noUselessCatch": "error",
|
||||
"noUselessEscapeInRegex": "error",
|
||||
"noUselessTypeConstraint": "error"
|
||||
},
|
||||
"correctness": {
|
||||
"noConstAssign": "error",
|
||||
"noConstantCondition": "error",
|
||||
"noEmptyCharacterClassInRegex": "error",
|
||||
"noEmptyPattern": "error",
|
||||
"noGlobalObjectCalls": "error",
|
||||
"noInvalidBuiltinInstantiation": "error",
|
||||
"noInvalidConstructorSuper": "error",
|
||||
"noNonoctalDecimalEscape": "error",
|
||||
"noPrecisionLoss": "error",
|
||||
"noSelfAssign": "error",
|
||||
"noSetterReturn": "error",
|
||||
"noSwitchDeclarations": "error",
|
||||
"noUndeclaredVariables": "error",
|
||||
"noUnreachable": "error",
|
||||
"noUnreachableSuper": "error",
|
||||
"noUnsafeFinally": "error",
|
||||
"noUnsafeOptionalChaining": "error",
|
||||
"noUnusedLabels": "error",
|
||||
"noUnusedPrivateClassMembers": "error",
|
||||
"noUnusedVariables": "error",
|
||||
"useIsNan": "error",
|
||||
"useValidForDirection": "error",
|
||||
"useValidTypeof": "error",
|
||||
"useYield": "error"
|
||||
},
|
||||
"style": {
|
||||
"noCommonJs": "error",
|
||||
"noNamespace": "error",
|
||||
"useArrayLiterals": "error",
|
||||
"useAsConstAssertion": "error",
|
||||
"useBlockStatements": "off"
|
||||
},
|
||||
"suspicious": {
|
||||
"noAsyncPromiseExecutor": "error",
|
||||
"noCatchAssign": "error",
|
||||
"noClassAssign": "error",
|
||||
"noCompareNegZero": "error",
|
||||
"noConstantBinaryExpressions": "error",
|
||||
"noControlCharactersInRegex": "error",
|
||||
"noDebugger": "error",
|
||||
"noDuplicateCase": "error",
|
||||
"noDuplicateClassMembers": "error",
|
||||
"noDuplicateElseIf": "error",
|
||||
"noDuplicateObjectKeys": "error",
|
||||
"noDuplicateParameters": "error",
|
||||
"noEmptyBlockStatements": "off",
|
||||
"noExplicitAny": "error",
|
||||
"noExtraNonNullAssertion": "error",
|
||||
"noFallthroughSwitchClause": "error",
|
||||
"noFunctionAssign": "error",
|
||||
"noGlobalAssign": "error",
|
||||
"noImportAssign": "error",
|
||||
"noIrregularWhitespace": "error",
|
||||
"noMisleadingCharacterClass": "error",
|
||||
"noMisleadingInstantiator": "error",
|
||||
"noNonNullAssertedOptionalChain": "error",
|
||||
"noPrototypeBuiltins": "error",
|
||||
"noRedeclare": "error",
|
||||
"noShadowRestrictedNames": "error",
|
||||
"noSparseArray": "error",
|
||||
"noUnsafeDeclarationMerging": "error",
|
||||
"noUnsafeNegation": "error",
|
||||
"noUselessRegexBackrefs": "error",
|
||||
"noWith": "error",
|
||||
"useGetterReturn": "error",
|
||||
"useNamespaceKeyword": "error"
|
||||
}
|
||||
},
|
||||
"includes": [
|
||||
"**",
|
||||
"!**/dist/**",
|
||||
"!**/node_modules/**",
|
||||
"!src/types/proto/**"
|
||||
]
|
||||
},
|
||||
"javascript": {
|
||||
"formatter": {
|
||||
"jsxQuoteStyle": "double",
|
||||
"quoteProperties": "asNeeded",
|
||||
"trailingCommas": "all",
|
||||
"semicolons": "always",
|
||||
"arrowParentheses": "always",
|
||||
"bracketSameLine": false,
|
||||
"quoteStyle": "double",
|
||||
"attributePosition": "auto",
|
||||
"bracketSpacing": true
|
||||
},
|
||||
"globals": []
|
||||
},
|
||||
"css": {
|
||||
"parser": {
|
||||
"cssModules": false,
|
||||
"allowWrongLineComments": true,
|
||||
"tailwindDirectives": true
|
||||
}
|
||||
},
|
||||
"html": {
|
||||
"formatter": {
|
||||
"indentScriptAndStyle": false,
|
||||
"selfCloseVoidElements": "always"
|
||||
}
|
||||
},
|
||||
"overrides": [
|
||||
{
|
||||
"includes": [
|
||||
"**/*.ts",
|
||||
"**/*.tsx",
|
||||
"**/*.mts",
|
||||
"**/*.cts"
|
||||
],
|
||||
"linter": {
|
||||
"rules": {
|
||||
"complexity": {
|
||||
"noArguments": "error"
|
||||
},
|
||||
"correctness": {
|
||||
"noConstAssign": "off",
|
||||
"noGlobalObjectCalls": "off",
|
||||
"noInvalidBuiltinInstantiation": "off",
|
||||
"noInvalidConstructorSuper": "off",
|
||||
"noSetterReturn": "off",
|
||||
"noUndeclaredVariables": "off",
|
||||
"noUnreachable": "off",
|
||||
"noUnreachableSuper": "off"
|
||||
},
|
||||
"style": {
|
||||
"useConst": "error"
|
||||
},
|
||||
"suspicious": {
|
||||
"noClassAssign": "off",
|
||||
"noDuplicateClassMembers": "off",
|
||||
"noDuplicateObjectKeys": "off",
|
||||
"noDuplicateParameters": "off",
|
||||
"noFunctionAssign": "off",
|
||||
"noImportAssign": "off",
|
||||
"noRedeclare": "off",
|
||||
"noUnsafeNegation": "off",
|
||||
"noVar": "error",
|
||||
"noWith": "off",
|
||||
"useGetterReturn": "off"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"includes": [
|
||||
"src/utils/i18n.ts"
|
||||
],
|
||||
"linter": {
|
||||
"rules": {}
|
||||
}
|
||||
}
|
||||
],
|
||||
"assist": {
|
||||
"enabled": true,
|
||||
"actions": {
|
||||
"source": {
|
||||
"organizeImports": "on"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
21
web/components.json
Normal file
21
web/components.json
Normal file
@@ -0,0 +1,21 @@
|
||||
{
|
||||
"$schema": "https://ui.shadcn.com/schema.json",
|
||||
"style": "new-york",
|
||||
"rsc": false,
|
||||
"tsx": true,
|
||||
"tailwind": {
|
||||
"config": "",
|
||||
"css": "src/index.css",
|
||||
"baseColor": "zinc",
|
||||
"cssVariables": true,
|
||||
"prefix": ""
|
||||
},
|
||||
"aliases": {
|
||||
"components": "@/components",
|
||||
"utils": "@/lib/utils",
|
||||
"ui": "@/components/ui",
|
||||
"lib": "@/lib",
|
||||
"hooks": "@/hooks"
|
||||
},
|
||||
"iconLibrary": "lucide"
|
||||
}
|
||||
55
web/docs/auth-architecture.md
Normal file
55
web/docs/auth-architecture.md
Normal file
@@ -0,0 +1,55 @@
|
||||
# Authentication State Architecture
|
||||
|
||||
## Current Approach: AuthContext
|
||||
|
||||
The application uses **AuthContext** for authentication state management, not React Query's `useCurrentUserQuery`. This is an intentional architectural decision.
|
||||
|
||||
### Why AuthContext Instead of React Query?
|
||||
|
||||
#### 1. **Synchronous Initialization**
|
||||
- AuthContext fetches user data during app initialization (`main.tsx`)
|
||||
- Provides synchronous access to `currentUser` throughout the app
|
||||
- No need to handle loading states in every component
|
||||
|
||||
#### 2. **Single Source of Truth**
|
||||
- User data fetched once on mount
|
||||
- All components get consistent, up-to-date user info
|
||||
- No race conditions from multiple query instances
|
||||
|
||||
#### 3. **Integration with React Query**
|
||||
- AuthContext pre-populates React Query cache after fetch (line 81-82 in `AuthContext.tsx`)
|
||||
- Best of both worlds: synchronous access + cache consistency
|
||||
- React Query hooks like `useNotifications()` can still use the cached user data
|
||||
|
||||
#### 4. **Simpler Component Code**
|
||||
```typescript
|
||||
// With AuthContext (current)
|
||||
const user = useCurrentUser(); // Always returns User | undefined
|
||||
|
||||
// With React Query (alternative)
|
||||
const { data: user, isLoading } = useCurrentUserQuery();
|
||||
if (isLoading) return <Spinner />;
|
||||
// Need loading handling everywhere
|
||||
```
|
||||
|
||||
### When to Use React Query for Auth?
|
||||
|
||||
Consider migrating auth to React Query if:
|
||||
- App needs real-time user profile updates from external sources
|
||||
- Multiple tabs need instant sync
|
||||
- User data changes frequently during a session
|
||||
|
||||
For Memos (a notes app where user profile rarely changes), AuthContext is the right choice.
|
||||
|
||||
### Future Considerations
|
||||
|
||||
The unused `useCurrentUserQuery()` hook in `useUserQueries.ts` is kept for potential future use. If requirements change (e.g., real-time collaboration on user profiles), migration path is clear:
|
||||
|
||||
1. Remove AuthContext
|
||||
2. Use `useCurrentUserQuery()` everywhere
|
||||
3. Handle loading states in components
|
||||
4. Add suspense boundaries if needed
|
||||
|
||||
## Recommendation
|
||||
|
||||
**Keep the current AuthContext approach.** It provides better DX and performance for this use case.
|
||||
20
web/index.html
Normal file
20
web/index.html
Normal file
@@ -0,0 +1,20 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta http-equiv="Cache-Control" content="no-cache, no-store, must-revalidate" />
|
||||
<meta http-equiv="Pragma" content="no-cache" />
|
||||
<meta http-equiv="Expires" content="0" />
|
||||
<link rel="apple-touch-icon" sizes="180x180" href="/apple-touch-icon.png" />
|
||||
<link rel="icon" type="image/webp" href="/logo.webp" />
|
||||
<link rel="manifest" href="/site.webmanifest" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1, user-scalable=no" />
|
||||
<!-- memos.metadata.head -->
|
||||
<title>Memos</title>
|
||||
</head>
|
||||
<body class="text-base w-full min-h-svh">
|
||||
<div id="root" class="relative w-full min-h-full"></div>
|
||||
<script type="module" src="/src/main.tsx"></script>
|
||||
<!-- memos.metadata.body -->
|
||||
</body>
|
||||
</html>
|
||||
8340
web/package-lock.json
generated
Normal file
8340
web/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
99
web/package.json
Normal file
99
web/package.json
Normal file
@@ -0,0 +1,99 @@
|
||||
{
|
||||
"name": "memos",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "vite build",
|
||||
"release": "vite build --mode release --outDir=../server/router/frontend/dist --emptyOutDir",
|
||||
"lint": "tsc --noEmit --skipLibCheck && biome check src",
|
||||
"lint:fix": "biome check --write src",
|
||||
"format": "biome format --write src"
|
||||
},
|
||||
"dependencies": {
|
||||
"@connectrpc/connect": "^2.1.1",
|
||||
"@connectrpc/connect-web": "^2.1.1",
|
||||
"@emotion/react": "^11.14.0",
|
||||
"@emotion/styled": "^11.14.1",
|
||||
"@github/relative-time-element": "^4.5.0",
|
||||
"@radix-ui/react-checkbox": "^1.3.3",
|
||||
"@radix-ui/react-dialog": "^1.1.15",
|
||||
"@radix-ui/react-dropdown-menu": "^2.1.16",
|
||||
"@radix-ui/react-label": "^2.1.8",
|
||||
"@radix-ui/react-popover": "^1.1.15",
|
||||
"@radix-ui/react-radio-group": "^1.3.8",
|
||||
"@radix-ui/react-select": "^2.2.6",
|
||||
"@radix-ui/react-separator": "^1.1.8",
|
||||
"@radix-ui/react-slot": "^1.2.4",
|
||||
"@radix-ui/react-switch": "^1.2.6",
|
||||
"@radix-ui/react-tooltip": "^1.2.8",
|
||||
"@tailwindcss/vite": "^4.1.17",
|
||||
"@tanstack/react-query": "^5.90.12",
|
||||
"@tanstack/react-query-devtools": "^5.91.1",
|
||||
"class-variance-authority": "^0.7.1",
|
||||
"clsx": "^2.1.1",
|
||||
"copy-to-clipboard": "^3.3.3",
|
||||
"dayjs": "^1.11.19",
|
||||
"fuse.js": "^7.1.0",
|
||||
"highlight.js": "^11.11.1",
|
||||
"i18next": "^25.6.3",
|
||||
"katex": "^0.16.27",
|
||||
"leaflet": "^1.9.4",
|
||||
"leaflet.markercluster": "^1.5.3",
|
||||
"lodash-es": "^4.17.21",
|
||||
"lucide-react": "^0.544.0",
|
||||
"mdast-util-from-markdown": "^2.0.2",
|
||||
"mdast-util-gfm": "^3.1.0",
|
||||
"mermaid": "^11.12.1",
|
||||
"micromark-extension-gfm": "^3.0.0",
|
||||
"mime": "^4.1.0",
|
||||
"react": "^18.3.1",
|
||||
"react-dom": "^18.3.1",
|
||||
"react-force-graph-2d": "^1.29.0",
|
||||
"react-hot-toast": "^2.6.0",
|
||||
"react-i18next": "^15.7.4",
|
||||
"react-leaflet": "^4.2.1",
|
||||
"react-leaflet-cluster": "^2.1.0",
|
||||
"react-markdown": "^10.1.0",
|
||||
"react-router-dom": "^7.9.6",
|
||||
"react-use": "^17.6.0",
|
||||
"rehype-katex": "^7.0.1",
|
||||
"rehype-raw": "^7.0.0",
|
||||
"rehype-sanitize": "^6.0.0",
|
||||
"remark-breaks": "^4.0.0",
|
||||
"remark-gfm": "^4.0.1",
|
||||
"remark-math": "^6.0.0",
|
||||
"tailwind-merge": "^3.4.0",
|
||||
"tailwindcss": "^4.1.17",
|
||||
"textarea-caret": "^3.1.0",
|
||||
"unist-util-visit": "^5.0.0",
|
||||
"uuid": "^11.1.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@biomejs/biome": "^2.3.7",
|
||||
"@bufbuild/protobuf": "^2.10.1",
|
||||
"@types/d3": "^7.4.3",
|
||||
"@types/hast": "^3.0.4",
|
||||
"@types/katex": "^0.16.7",
|
||||
"@types/leaflet": "^1.9.21",
|
||||
"@types/lodash-es": "^4.17.12",
|
||||
"@types/mdast": "^4.0.4",
|
||||
"@types/node": "^24.10.1",
|
||||
"@types/qs": "^6.14.0",
|
||||
"@types/react": "^18.3.27",
|
||||
"@types/react-dom": "^18.3.7",
|
||||
"@types/textarea-caret": "^3.0.4",
|
||||
"@types/unist": "^3.0.3",
|
||||
"@types/uuid": "^10.0.0",
|
||||
"@vitejs/plugin-react": "^4.7.0",
|
||||
"long": "^5.3.2",
|
||||
"terser": "^5.44.1",
|
||||
"tw-animate-css": "^1.4.0",
|
||||
"typescript": "^5.9.3",
|
||||
"vite": "^7.2.4"
|
||||
},
|
||||
"pnpm": {
|
||||
"onlyBuiltDependencies": [
|
||||
"esbuild"
|
||||
]
|
||||
}
|
||||
}
|
||||
5949
web/pnpm-lock.yaml
generated
Normal file
5949
web/pnpm-lock.yaml
generated
Normal file
File diff suppressed because it is too large
Load Diff
BIN
web/public/android-chrome-192x192.png
Normal file
BIN
web/public/android-chrome-192x192.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 24 KiB |
BIN
web/public/android-chrome-512x512.png
Normal file
BIN
web/public/android-chrome-512x512.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 138 KiB |
BIN
web/public/apple-touch-icon.png
Normal file
BIN
web/public/apple-touch-icon.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 22 KiB |
BIN
web/public/full-logo.webp
Normal file
BIN
web/public/full-logo.webp
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 25 KiB |
BIN
web/public/logo.webp
Normal file
BIN
web/public/logo.webp
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 36 KiB |
10
web/public/site.webmanifest
Normal file
10
web/public/site.webmanifest
Normal file
@@ -0,0 +1,10 @@
|
||||
{
|
||||
"name": "Memos",
|
||||
"short_name": "Memos",
|
||||
"icons": [
|
||||
{ "src": "/android-chrome-192x192.png", "sizes": "192x192", "type": "image/png" },
|
||||
{ "src": "/android-chrome-512x512.png", "sizes": "512x512", "type": "image/png" }
|
||||
],
|
||||
"display": "standalone",
|
||||
"start_url": "/"
|
||||
}
|
||||
66
web/src/App.tsx
Normal file
66
web/src/App.tsx
Normal file
@@ -0,0 +1,66 @@
|
||||
import { useEffect } from "react";
|
||||
import { Outlet } from "react-router-dom";
|
||||
import { useInstance } from "./contexts/InstanceContext";
|
||||
import { MemoFilterProvider } from "./contexts/MemoFilterContext";
|
||||
import useNavigateTo from "./hooks/useNavigateTo";
|
||||
import { useUserLocale } from "./hooks/useUserLocale";
|
||||
import { useUserTheme } from "./hooks/useUserTheme";
|
||||
import { cleanupExpiredOAuthState } from "./utils/oauth";
|
||||
|
||||
const App = () => {
|
||||
const navigateTo = useNavigateTo();
|
||||
const { profile: instanceProfile, profileLoaded, generalSetting: instanceGeneralSetting } = useInstance();
|
||||
|
||||
// Apply user preferences reactively
|
||||
useUserLocale();
|
||||
useUserTheme();
|
||||
|
||||
// Clean up expired OAuth states on app initialization
|
||||
useEffect(() => {
|
||||
cleanupExpiredOAuthState();
|
||||
}, []);
|
||||
|
||||
// Redirect to sign up page if instance not initialized (no admin account exists yet).
|
||||
// Guard with profileLoaded so a fetch failure doesn't incorrectly trigger the redirect.
|
||||
useEffect(() => {
|
||||
if (profileLoaded && !instanceProfile.admin) {
|
||||
navigateTo("/auth/signup");
|
||||
}
|
||||
}, [profileLoaded, instanceProfile.admin, navigateTo]);
|
||||
|
||||
useEffect(() => {
|
||||
if (instanceGeneralSetting.additionalStyle) {
|
||||
const styleEl = document.createElement("style");
|
||||
styleEl.innerHTML = instanceGeneralSetting.additionalStyle;
|
||||
styleEl.setAttribute("type", "text/css");
|
||||
document.body.insertAdjacentElement("beforeend", styleEl);
|
||||
}
|
||||
}, [instanceGeneralSetting.additionalStyle]);
|
||||
|
||||
useEffect(() => {
|
||||
if (instanceGeneralSetting.additionalScript) {
|
||||
const scriptEl = document.createElement("script");
|
||||
scriptEl.innerHTML = instanceGeneralSetting.additionalScript;
|
||||
document.head.appendChild(scriptEl);
|
||||
}
|
||||
}, [instanceGeneralSetting.additionalScript]);
|
||||
|
||||
// Dynamic update metadata with customized profile
|
||||
useEffect(() => {
|
||||
if (!instanceGeneralSetting.customProfile) {
|
||||
return;
|
||||
}
|
||||
|
||||
document.title = instanceGeneralSetting.customProfile.title;
|
||||
const link = document.querySelector("link[rel~='icon']") as HTMLLinkElement;
|
||||
link.href = instanceGeneralSetting.customProfile.logoUrl || "/logo.webp";
|
||||
}, [instanceGeneralSetting.customProfile]);
|
||||
|
||||
return (
|
||||
<MemoFilterProvider>
|
||||
<Outlet />
|
||||
</MemoFilterProvider>
|
||||
);
|
||||
};
|
||||
|
||||
export default App;
|
||||
129
web/src/auth-state.ts
Normal file
129
web/src/auth-state.ts
Normal file
@@ -0,0 +1,129 @@
|
||||
// Access token storage using localStorage for persistence across tabs and sessions.
|
||||
// Tokens are cleared on logout or expiry.
|
||||
let accessToken: string | null = null;
|
||||
let tokenExpiresAt: Date | null = null;
|
||||
|
||||
const TOKEN_KEY = "memos_access_token";
|
||||
const EXPIRES_KEY = "memos_token_expires_at";
|
||||
|
||||
// BroadcastChannel lets tabs share freshly-refreshed tokens so that only one
|
||||
// tab needs to hit the refresh endpoint. When another tab successfully refreshes
|
||||
// we adopt the new token immediately, avoiding a redundant (and potentially
|
||||
// conflicting) refresh request of our own.
|
||||
const TOKEN_CHANNEL_NAME = "memos_token_sync";
|
||||
|
||||
// Token refresh policy:
|
||||
// - REQUEST_TOKEN_EXPIRY_BUFFER_MS: used for normal API requests.
|
||||
// - FOCUS_TOKEN_EXPIRY_BUFFER_MS: used on tab visibility restore to refresh earlier.
|
||||
export const REQUEST_TOKEN_EXPIRY_BUFFER_MS = 30 * 1000;
|
||||
export const FOCUS_TOKEN_EXPIRY_BUFFER_MS = 2 * 60 * 1000;
|
||||
|
||||
interface TokenBroadcastMessage {
|
||||
token: string;
|
||||
expiresAt: string; // ISO string
|
||||
}
|
||||
|
||||
let tokenChannel: BroadcastChannel | null = null;
|
||||
|
||||
function getTokenChannel(): BroadcastChannel | null {
|
||||
if (tokenChannel) return tokenChannel;
|
||||
try {
|
||||
tokenChannel = new BroadcastChannel(TOKEN_CHANNEL_NAME);
|
||||
tokenChannel.onmessage = (event: MessageEvent<TokenBroadcastMessage>) => {
|
||||
const { token, expiresAt } = event.data ?? {};
|
||||
if (token && expiresAt) {
|
||||
// Another tab refreshed — adopt the token in-memory so we don't
|
||||
// fire our own refresh request.
|
||||
accessToken = token;
|
||||
tokenExpiresAt = new Date(expiresAt);
|
||||
}
|
||||
};
|
||||
} catch {
|
||||
// BroadcastChannel not available (e.g. some privacy modes)
|
||||
tokenChannel = null;
|
||||
}
|
||||
return tokenChannel;
|
||||
}
|
||||
|
||||
// Initialize the channel at module load so the listener is registered
|
||||
// before any token refresh can occur in any tab.
|
||||
getTokenChannel();
|
||||
|
||||
export const getAccessToken = (): string | null => {
|
||||
if (!accessToken) {
|
||||
try {
|
||||
const storedToken = localStorage.getItem(TOKEN_KEY);
|
||||
const storedExpires = localStorage.getItem(EXPIRES_KEY);
|
||||
|
||||
if (storedToken && storedExpires) {
|
||||
const expiresAt = new Date(storedExpires);
|
||||
if (expiresAt > new Date()) {
|
||||
accessToken = storedToken;
|
||||
tokenExpiresAt = expiresAt;
|
||||
}
|
||||
// Do NOT remove expired tokens here. Callers such as InstanceContext.initialize()
|
||||
// run concurrently with AuthContext.initialize() via Promise.all. If we eagerly
|
||||
// delete the expired token from localStorage, hasStoredToken() (called synchronously
|
||||
// inside AuthContext.initialize()) finds nothing and skips the refresh attempt,
|
||||
// logging the user out even when the refresh-token cookie is still valid.
|
||||
// clearAccessToken() handles proper cleanup after a confirmed auth failure or logout.
|
||||
}
|
||||
} catch (e) {
|
||||
// localStorage might not be available (e.g., in some privacy modes)
|
||||
console.warn("Failed to access localStorage:", e);
|
||||
}
|
||||
}
|
||||
return accessToken;
|
||||
};
|
||||
|
||||
export const setAccessToken = (token: string | null, expiresAt?: Date): void => {
|
||||
accessToken = token;
|
||||
tokenExpiresAt = expiresAt || null;
|
||||
|
||||
try {
|
||||
if (token && expiresAt) {
|
||||
localStorage.setItem(TOKEN_KEY, token);
|
||||
localStorage.setItem(EXPIRES_KEY, expiresAt.toISOString());
|
||||
// Broadcast to other tabs so they adopt the new token without refreshing.
|
||||
const msg: TokenBroadcastMessage = { token, expiresAt: expiresAt.toISOString() };
|
||||
getTokenChannel()?.postMessage(msg);
|
||||
} else {
|
||||
localStorage.removeItem(TOKEN_KEY);
|
||||
localStorage.removeItem(EXPIRES_KEY);
|
||||
}
|
||||
} catch (e) {
|
||||
// localStorage might not be available (e.g., in some privacy modes)
|
||||
console.warn("Failed to write to localStorage:", e);
|
||||
}
|
||||
};
|
||||
|
||||
export const isTokenExpired = (bufferMs: number = REQUEST_TOKEN_EXPIRY_BUFFER_MS): boolean => {
|
||||
if (!tokenExpiresAt) return true;
|
||||
// Consider expired with a safety buffer before actual expiry.
|
||||
return new Date() >= new Date(tokenExpiresAt.getTime() - bufferMs);
|
||||
};
|
||||
|
||||
// Returns true if a token exists in localStorage, even if it is expired.
|
||||
// Used to decide whether to attempt GetCurrentUser on app init — if no token
|
||||
// was ever stored, the user is definitively not logged in and there is nothing
|
||||
// to refresh, so we can skip the network round-trip entirely.
|
||||
export const hasStoredToken = (): boolean => {
|
||||
if (accessToken) return true;
|
||||
try {
|
||||
return !!localStorage.getItem(TOKEN_KEY);
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
};
|
||||
|
||||
export const clearAccessToken = (): void => {
|
||||
accessToken = null;
|
||||
tokenExpiresAt = null;
|
||||
|
||||
try {
|
||||
localStorage.removeItem(TOKEN_KEY);
|
||||
localStorage.removeItem(EXPIRES_KEY);
|
||||
} catch (e) {
|
||||
console.warn("Failed to clear localStorage:", e);
|
||||
}
|
||||
};
|
||||
82
web/src/components/ActivityCalendar/CalendarCell.tsx
Normal file
82
web/src/components/ActivityCalendar/CalendarCell.tsx
Normal file
@@ -0,0 +1,82 @@
|
||||
import { memo } from "react";
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { DEFAULT_CELL_SIZE, SMALL_CELL_SIZE } from "./constants";
|
||||
import type { CalendarDayCell, CalendarSize } from "./types";
|
||||
import { getCellIntensityClass } from "./utils";
|
||||
|
||||
export interface CalendarCellProps {
|
||||
day: CalendarDayCell;
|
||||
maxCount: number;
|
||||
tooltipText: string;
|
||||
onClick?: (date: string) => void;
|
||||
size?: CalendarSize;
|
||||
disableTooltip?: boolean;
|
||||
}
|
||||
|
||||
export const CalendarCell = memo((props: CalendarCellProps) => {
|
||||
const { day, maxCount, tooltipText, onClick, size = "default", disableTooltip = false } = props;
|
||||
|
||||
const handleClick = () => {
|
||||
if (day.count > 0 && onClick) {
|
||||
onClick(day.date);
|
||||
}
|
||||
};
|
||||
|
||||
const sizeConfig = size === "small" ? SMALL_CELL_SIZE : DEFAULT_CELL_SIZE;
|
||||
const smallExtraClasses = size === "small" ? `${SMALL_CELL_SIZE.dimensions} min-h-0` : "";
|
||||
|
||||
const baseClasses = cn(
|
||||
"aspect-square w-full flex items-center justify-center text-center transition-all duration-150 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring/40 focus-visible:ring-offset-2 select-none border border-border/10 bg-muted/20",
|
||||
sizeConfig.font,
|
||||
sizeConfig.borderRadius,
|
||||
smallExtraClasses,
|
||||
);
|
||||
const isInteractive = Boolean(onClick && day.count > 0);
|
||||
const ariaLabel = day.isSelected ? `${tooltipText} (selected)` : tooltipText;
|
||||
|
||||
if (!day.isCurrentMonth) {
|
||||
return <div className={cn(baseClasses, "text-muted-foreground/30 bg-transparent border-transparent cursor-default")}>{day.label}</div>;
|
||||
}
|
||||
|
||||
const intensityClass = getCellIntensityClass(day, maxCount);
|
||||
|
||||
const buttonClasses = cn(
|
||||
baseClasses,
|
||||
intensityClass,
|
||||
day.isToday && "ring-2 ring-primary/30 ring-offset-1 font-semibold z-10",
|
||||
day.isSelected && "ring-2 ring-primary ring-offset-1 font-bold z-10",
|
||||
isInteractive ? "cursor-pointer hover:bg-muted/40 hover:border-border/30" : "cursor-default",
|
||||
);
|
||||
|
||||
const button = (
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleClick}
|
||||
tabIndex={isInteractive ? 0 : -1}
|
||||
aria-label={ariaLabel}
|
||||
aria-current={day.isToday ? "date" : undefined}
|
||||
aria-disabled={!isInteractive}
|
||||
className={buttonClasses}
|
||||
>
|
||||
{day.label}
|
||||
</button>
|
||||
);
|
||||
|
||||
const shouldShowTooltip = tooltipText && day.count > 0 && !disableTooltip;
|
||||
|
||||
if (!shouldShowTooltip) {
|
||||
return button;
|
||||
}
|
||||
|
||||
return (
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>{button}</TooltipTrigger>
|
||||
<TooltipContent side="top">
|
||||
<p>{tooltipText}</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
);
|
||||
});
|
||||
|
||||
CalendarCell.displayName = "CalendarCell";
|
||||
76
web/src/components/ActivityCalendar/MonthCalendar.tsx
Normal file
76
web/src/components/ActivityCalendar/MonthCalendar.tsx
Normal file
@@ -0,0 +1,76 @@
|
||||
import { memo, useMemo } from "react";
|
||||
import { useInstance } from "@/contexts/InstanceContext";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { useTranslate } from "@/utils/i18n";
|
||||
import { CalendarCell } from "./CalendarCell";
|
||||
import { useTodayDate, useWeekdayLabels } from "./hooks";
|
||||
import type { CalendarSize, MonthCalendarProps } from "./types";
|
||||
import { useCalendarMatrix } from "./useCalendar";
|
||||
import { getTooltipText } from "./utils";
|
||||
|
||||
const GRID_STYLES: Record<CalendarSize, { gap: string; headerText: string }> = {
|
||||
small: { gap: "gap-1.5", headerText: "text-[10px]" },
|
||||
default: { gap: "gap-2", headerText: "text-xs" },
|
||||
};
|
||||
|
||||
interface WeekdayHeaderProps {
|
||||
weekDays: string[];
|
||||
size: CalendarSize;
|
||||
}
|
||||
|
||||
const WeekdayHeader = memo(({ weekDays, size }: WeekdayHeaderProps) => (
|
||||
<div className={cn("grid grid-cols-7 mb-1", GRID_STYLES[size].gap, GRID_STYLES[size].headerText)} role="row">
|
||||
{weekDays.map((label, index) => (
|
||||
<div
|
||||
key={index}
|
||||
className="flex h-4 items-center justify-center font-medium uppercase tracking-wide text-muted-foreground/60"
|
||||
role="columnheader"
|
||||
aria-label={label}
|
||||
>
|
||||
{label}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
));
|
||||
WeekdayHeader.displayName = "WeekdayHeader";
|
||||
|
||||
export const MonthCalendar = memo((props: MonthCalendarProps) => {
|
||||
const { month, data, maxCount, size = "default", onClick, className, disableTooltips = false } = props;
|
||||
const t = useTranslate();
|
||||
const { generalSetting } = useInstance();
|
||||
const today = useTodayDate();
|
||||
const weekDays = useWeekdayLabels();
|
||||
|
||||
const { weeks, weekDays: rotatedWeekDays } = useCalendarMatrix({
|
||||
month,
|
||||
data,
|
||||
weekDays,
|
||||
weekStartDayOffset: generalSetting.weekStartDayOffset,
|
||||
today,
|
||||
selectedDate: "",
|
||||
});
|
||||
|
||||
const flatDays = useMemo(() => weeks.flatMap((week) => week.days), [weeks]);
|
||||
|
||||
return (
|
||||
<div className={cn("flex flex-col", className)} role="grid" aria-label={`Calendar for ${month}`}>
|
||||
<WeekdayHeader weekDays={rotatedWeekDays} size={size} />
|
||||
|
||||
<div className={cn("grid grid-cols-7", GRID_STYLES[size].gap)} role="rowgroup">
|
||||
{flatDays.map((day) => (
|
||||
<CalendarCell
|
||||
key={day.date}
|
||||
day={day}
|
||||
maxCount={maxCount}
|
||||
tooltipText={getTooltipText(day.count, day.date, t)}
|
||||
onClick={onClick}
|
||||
size={size}
|
||||
disableTooltip={disableTooltips}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
});
|
||||
|
||||
MonthCalendar.displayName = "MonthCalendar";
|
||||
116
web/src/components/ActivityCalendar/YearCalendar.tsx
Normal file
116
web/src/components/ActivityCalendar/YearCalendar.tsx
Normal file
@@ -0,0 +1,116 @@
|
||||
import { ChevronLeftIcon, ChevronRightIcon } from "lucide-react";
|
||||
import { memo, useMemo } from "react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { useTranslate } from "@/utils/i18n";
|
||||
import { getMaxYear, MIN_YEAR } from "./constants";
|
||||
import { MonthCalendar } from "./MonthCalendar";
|
||||
import type { YearCalendarProps } from "./types";
|
||||
import { calculateYearMaxCount, filterDataByYear, generateMonthsForYear, getMonthLabel } from "./utils";
|
||||
|
||||
interface YearNavigationProps {
|
||||
selectedYear: number;
|
||||
currentYear: number;
|
||||
onPrev: () => void;
|
||||
onNext: () => void;
|
||||
onToday: () => void;
|
||||
canGoPrev: boolean;
|
||||
canGoNext: boolean;
|
||||
}
|
||||
|
||||
const YearNavigation = memo(({ selectedYear, currentYear, onPrev, onNext, onToday, canGoPrev, canGoNext }: YearNavigationProps) => {
|
||||
const t = useTranslate();
|
||||
const isCurrentYear = selectedYear === currentYear;
|
||||
|
||||
return (
|
||||
<div className="flex items-center justify-between px-1">
|
||||
<h2 className="text-2xl font-semibold text-foreground tracking-tight">{selectedYear}</h2>
|
||||
|
||||
<nav className="inline-flex items-center gap-0.5 rounded-lg border border-border/30 bg-muted/10 p-0.5" aria-label="Year navigation">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={onPrev}
|
||||
disabled={!canGoPrev}
|
||||
aria-label="Previous year"
|
||||
className="h-7 w-7 p-0 rounded-md text-muted-foreground hover:text-foreground hover:bg-muted/40"
|
||||
>
|
||||
<ChevronLeftIcon className="w-4 h-4" />
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={onToday}
|
||||
disabled={isCurrentYear}
|
||||
aria-label={t("common.today")}
|
||||
className={cn(
|
||||
"h-7 px-2.5 rounded-md text-[10px] font-medium uppercase tracking-wider",
|
||||
isCurrentYear ? "text-muted-foreground/50 cursor-default" : "text-muted-foreground hover:text-foreground hover:bg-muted/40",
|
||||
)}
|
||||
>
|
||||
{t("common.today")}
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={onNext}
|
||||
disabled={!canGoNext}
|
||||
aria-label="Next year"
|
||||
className="h-7 w-7 p-0 rounded-md text-muted-foreground hover:text-foreground hover:bg-muted/40"
|
||||
>
|
||||
<ChevronRightIcon className="w-4 h-4" />
|
||||
</Button>
|
||||
</nav>
|
||||
</div>
|
||||
);
|
||||
});
|
||||
YearNavigation.displayName = "YearNavigation";
|
||||
|
||||
interface MonthCardProps {
|
||||
month: string;
|
||||
data: Record<string, number>;
|
||||
maxCount: number;
|
||||
onDateClick: (date: string) => void;
|
||||
}
|
||||
|
||||
const MonthCard = memo(({ month, data, maxCount, onDateClick }: MonthCardProps) => (
|
||||
<article className="flex flex-col gap-2 rounded-xl border border-border/20 bg-muted/5 p-3 transition-colors hover:bg-muted/10">
|
||||
<header className="text-[10px] font-medium text-muted-foreground/80 uppercase tracking-widest">{getMonthLabel(month)}</header>
|
||||
<MonthCalendar month={month} data={data} maxCount={maxCount} size="small" onClick={onDateClick} disableTooltips />
|
||||
</article>
|
||||
));
|
||||
MonthCard.displayName = "MonthCard";
|
||||
|
||||
export const YearCalendar = memo(({ selectedYear, data, onYearChange, onDateClick, className }: YearCalendarProps) => {
|
||||
const currentYear = useMemo(() => new Date().getFullYear(), []);
|
||||
const yearData = useMemo(() => filterDataByYear(data, selectedYear), [data, selectedYear]);
|
||||
const months = useMemo(() => generateMonthsForYear(selectedYear), [selectedYear]);
|
||||
const yearMaxCount = useMemo(() => calculateYearMaxCount(yearData), [yearData]);
|
||||
|
||||
const canGoPrev = selectedYear > MIN_YEAR;
|
||||
const canGoNext = selectedYear < getMaxYear();
|
||||
|
||||
return (
|
||||
<section className={cn("w-full flex flex-col gap-5 px-4 py-4 select-none", className)} aria-label={`Year ${selectedYear} calendar`}>
|
||||
<YearNavigation
|
||||
selectedYear={selectedYear}
|
||||
currentYear={currentYear}
|
||||
onPrev={() => canGoPrev && onYearChange(selectedYear - 1)}
|
||||
onNext={() => canGoNext && onYearChange(selectedYear + 1)}
|
||||
onToday={() => onYearChange(currentYear)}
|
||||
canGoPrev={canGoPrev}
|
||||
canGoNext={canGoNext}
|
||||
/>
|
||||
|
||||
<div className="grid gap-4 grid-cols-1 sm:grid-cols-2 md:grid-cols-3 lg:grid-cols-4 animate-fade-in">
|
||||
{months.map((month) => (
|
||||
<MonthCard key={month} month={month} data={yearData} maxCount={yearMaxCount} onDateClick={onDateClick} />
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
});
|
||||
|
||||
YearCalendar.displayName = "YearCalendar";
|
||||
35
web/src/components/ActivityCalendar/constants.ts
Normal file
35
web/src/components/ActivityCalendar/constants.ts
Normal file
@@ -0,0 +1,35 @@
|
||||
export const DAYS_IN_WEEK = 7;
|
||||
export const MONTHS_IN_YEAR = 12;
|
||||
export const WEEKEND_DAYS = [0, 6] as const;
|
||||
export const MIN_COUNT = 1;
|
||||
|
||||
export const MIN_YEAR = 1970;
|
||||
export const getMaxYear = () => new Date().getFullYear() + 1;
|
||||
|
||||
export const INTENSITY_THRESHOLDS = {
|
||||
HIGH: 0.75,
|
||||
MEDIUM: 0.5,
|
||||
LOW: 0.25,
|
||||
MINIMAL: 0,
|
||||
} as const;
|
||||
|
||||
export const CELL_STYLES = {
|
||||
HIGH: "bg-primary text-primary-foreground shadow-sm border-transparent",
|
||||
MEDIUM: "bg-primary/85 text-primary-foreground shadow-sm border-transparent",
|
||||
LOW: "bg-primary/70 text-primary-foreground border-transparent",
|
||||
MINIMAL: "bg-primary/50 text-foreground border-transparent",
|
||||
EMPTY: "bg-muted/20 text-muted-foreground hover:bg-muted/30 border-border/10",
|
||||
} as const;
|
||||
|
||||
export const SMALL_CELL_SIZE = {
|
||||
font: "text-[11px]",
|
||||
dimensions: "w-full h-full",
|
||||
borderRadius: "rounded-lg",
|
||||
gap: "gap-1.5",
|
||||
} as const;
|
||||
|
||||
export const DEFAULT_CELL_SIZE = {
|
||||
font: "text-xs",
|
||||
borderRadius: "rounded-lg",
|
||||
gap: "gap-2",
|
||||
} as const;
|
||||
12
web/src/components/ActivityCalendar/hooks.ts
Normal file
12
web/src/components/ActivityCalendar/hooks.ts
Normal file
@@ -0,0 +1,12 @@
|
||||
import dayjs from "dayjs";
|
||||
import { useMemo } from "react";
|
||||
import { useTranslate } from "@/utils/i18n";
|
||||
|
||||
export const useWeekdayLabels = () => {
|
||||
const t = useTranslate();
|
||||
return useMemo(() => [t("days.sun"), t("days.mon"), t("days.tue"), t("days.wed"), t("days.thu"), t("days.fri"), t("days.sat")], [t]);
|
||||
};
|
||||
|
||||
export const useTodayDate = () => {
|
||||
return dayjs().format("YYYY-MM-DD");
|
||||
};
|
||||
4
web/src/components/ActivityCalendar/index.ts
Normal file
4
web/src/components/ActivityCalendar/index.ts
Normal file
@@ -0,0 +1,4 @@
|
||||
export * from "./MonthCalendar";
|
||||
export * from "./types";
|
||||
export * from "./utils";
|
||||
export * from "./YearCalendar";
|
||||
39
web/src/components/ActivityCalendar/types.ts
Normal file
39
web/src/components/ActivityCalendar/types.ts
Normal file
@@ -0,0 +1,39 @@
|
||||
export type CalendarSize = "default" | "small";
|
||||
|
||||
export interface CalendarDayCell {
|
||||
date: string;
|
||||
label: number;
|
||||
count: number;
|
||||
isCurrentMonth: boolean;
|
||||
isToday: boolean;
|
||||
isSelected: boolean;
|
||||
isWeekend: boolean;
|
||||
}
|
||||
|
||||
export interface CalendarDayRow {
|
||||
days: CalendarDayCell[];
|
||||
}
|
||||
|
||||
export interface CalendarMatrixResult {
|
||||
weeks: CalendarDayRow[];
|
||||
weekDays: string[];
|
||||
maxCount: number;
|
||||
}
|
||||
|
||||
export interface MonthCalendarProps {
|
||||
month: string;
|
||||
data: Record<string, number>;
|
||||
maxCount: number;
|
||||
size?: CalendarSize;
|
||||
onClick?: (date: string) => void;
|
||||
className?: string;
|
||||
disableTooltips?: boolean;
|
||||
}
|
||||
|
||||
export interface YearCalendarProps {
|
||||
selectedYear: number;
|
||||
data: Record<string, number>;
|
||||
onYearChange: (year: number) => void;
|
||||
onDateClick: (date: string) => void;
|
||||
className?: string;
|
||||
}
|
||||
94
web/src/components/ActivityCalendar/useCalendar.ts
Normal file
94
web/src/components/ActivityCalendar/useCalendar.ts
Normal file
@@ -0,0 +1,94 @@
|
||||
import dayjs from "dayjs";
|
||||
import { useMemo } from "react";
|
||||
import { DAYS_IN_WEEK, MIN_COUNT, WEEKEND_DAYS } from "./constants";
|
||||
import type { CalendarDayCell, CalendarMatrixResult } from "./types";
|
||||
|
||||
export interface UseCalendarMatrixParams {
|
||||
month: string;
|
||||
data: Record<string, number>;
|
||||
weekDays: string[];
|
||||
weekStartDayOffset: number;
|
||||
today: string;
|
||||
selectedDate: string;
|
||||
}
|
||||
|
||||
const createCalendarDayCell = (
|
||||
current: dayjs.Dayjs,
|
||||
monthKey: string,
|
||||
data: Record<string, number>,
|
||||
today: string,
|
||||
selectedDate: string,
|
||||
): CalendarDayCell => {
|
||||
const isoDate = current.format("YYYY-MM-DD");
|
||||
const isCurrentMonth = current.format("YYYY-MM") === monthKey;
|
||||
const count = data[isoDate] ?? 0;
|
||||
|
||||
return {
|
||||
date: isoDate,
|
||||
label: current.date(),
|
||||
count,
|
||||
isCurrentMonth,
|
||||
isToday: isoDate === today,
|
||||
isSelected: isoDate === selectedDate,
|
||||
isWeekend: WEEKEND_DAYS.includes(current.day() as 0 | 6),
|
||||
};
|
||||
};
|
||||
|
||||
const calculateCalendarBoundaries = (monthStart: dayjs.Dayjs, weekStartDayOffset: number) => {
|
||||
const monthEnd = monthStart.endOf("month");
|
||||
const startOffset = (monthStart.day() - weekStartDayOffset + DAYS_IN_WEEK) % DAYS_IN_WEEK;
|
||||
const endOffset = (weekStartDayOffset + (DAYS_IN_WEEK - 1) - monthEnd.day() + DAYS_IN_WEEK) % DAYS_IN_WEEK;
|
||||
const calendarStart = monthStart.subtract(startOffset, "day");
|
||||
const calendarEnd = monthEnd.add(endOffset, "day");
|
||||
const dayCount = calendarEnd.diff(calendarStart, "day") + 1;
|
||||
|
||||
return { calendarStart, dayCount };
|
||||
};
|
||||
|
||||
/**
|
||||
* Generates a matrix of calendar days for a given month, handling week alignment and data mapping.
|
||||
*/
|
||||
export const useCalendarMatrix = ({
|
||||
month,
|
||||
data,
|
||||
weekDays,
|
||||
weekStartDayOffset,
|
||||
today,
|
||||
selectedDate,
|
||||
}: UseCalendarMatrixParams): CalendarMatrixResult => {
|
||||
return useMemo(() => {
|
||||
// Determine the start of the month and its formatted key (YYYY-MM)
|
||||
const monthStart = dayjs(month).startOf("month");
|
||||
const monthKey = monthStart.format("YYYY-MM");
|
||||
|
||||
// Rotate week labels based on the user's preferred start of the week
|
||||
const rotatedWeekDays = weekDays.slice(weekStartDayOffset).concat(weekDays.slice(0, weekStartDayOffset));
|
||||
|
||||
// Calculate the start and end dates for the calendar grid to ensure full weeks
|
||||
const { calendarStart, dayCount } = calculateCalendarBoundaries(monthStart, weekStartDayOffset);
|
||||
|
||||
const weeks: CalendarMatrixResult["weeks"] = [];
|
||||
let maxCount = 0;
|
||||
|
||||
// Iterate through each day in the calendar grid
|
||||
for (let index = 0; index < dayCount; index += 1) {
|
||||
const current = calendarStart.add(index, "day");
|
||||
const weekIndex = Math.floor(index / DAYS_IN_WEEK);
|
||||
|
||||
if (!weeks[weekIndex]) {
|
||||
weeks[weekIndex] = { days: [] };
|
||||
}
|
||||
|
||||
// Create the day cell object with data and status flags
|
||||
const dayCell = createCalendarDayCell(current, monthKey, data, today, selectedDate);
|
||||
weeks[weekIndex].days.push(dayCell);
|
||||
maxCount = Math.max(maxCount, dayCell.count);
|
||||
}
|
||||
|
||||
return {
|
||||
weeks,
|
||||
weekDays: rotatedWeekDays,
|
||||
maxCount: Math.max(maxCount, MIN_COUNT),
|
||||
};
|
||||
}, [month, data, weekDays, weekStartDayOffset, today, selectedDate]);
|
||||
};
|
||||
72
web/src/components/ActivityCalendar/utils.ts
Normal file
72
web/src/components/ActivityCalendar/utils.ts
Normal file
@@ -0,0 +1,72 @@
|
||||
import dayjs from "dayjs";
|
||||
import isSameOrAfter from "dayjs/plugin/isSameOrAfter";
|
||||
import isSameOrBefore from "dayjs/plugin/isSameOrBefore";
|
||||
import { useTranslate } from "@/utils/i18n";
|
||||
import { CELL_STYLES, INTENSITY_THRESHOLDS, MIN_COUNT, MONTHS_IN_YEAR } from "./constants";
|
||||
import type { CalendarDayCell } from "./types";
|
||||
|
||||
dayjs.extend(isSameOrAfter);
|
||||
dayjs.extend(isSameOrBefore);
|
||||
|
||||
export type TranslateFunction = ReturnType<typeof useTranslate>;
|
||||
|
||||
export const getCellIntensityClass = (day: CalendarDayCell, maxCount: number): string => {
|
||||
if (!day.isCurrentMonth || day.count === 0) {
|
||||
return CELL_STYLES.EMPTY;
|
||||
}
|
||||
|
||||
const ratio = day.count / maxCount;
|
||||
if (ratio > INTENSITY_THRESHOLDS.HIGH) return CELL_STYLES.HIGH;
|
||||
if (ratio > INTENSITY_THRESHOLDS.MEDIUM) return CELL_STYLES.MEDIUM;
|
||||
if (ratio > INTENSITY_THRESHOLDS.LOW) return CELL_STYLES.LOW;
|
||||
return CELL_STYLES.MINIMAL;
|
||||
};
|
||||
|
||||
export const generateMonthsForYear = (year: number): string[] => {
|
||||
return Array.from({ length: MONTHS_IN_YEAR }, (_, i) => dayjs(`${year}-01-01`).add(i, "month").format("YYYY-MM"));
|
||||
};
|
||||
|
||||
export const calculateYearMaxCount = (data: Record<string, number>): number => {
|
||||
let max = 0;
|
||||
for (const count of Object.values(data)) {
|
||||
max = Math.max(max, count);
|
||||
}
|
||||
return Math.max(max, MIN_COUNT);
|
||||
};
|
||||
|
||||
export const getMonthLabel = (month: string): string => {
|
||||
return dayjs(month).format("MMM");
|
||||
};
|
||||
|
||||
export const filterDataByYear = (data: Record<string, number>, year: number): Record<string, number> => {
|
||||
if (!data) return {};
|
||||
|
||||
const filtered: Record<string, number> = {};
|
||||
const yearStart = dayjs(`${year}-01-01`);
|
||||
const yearEnd = dayjs(`${year}-12-31`);
|
||||
|
||||
for (const [dateStr, count] of Object.entries(data)) {
|
||||
const date = dayjs(dateStr);
|
||||
if (date.isSameOrAfter(yearStart, "day") && date.isSameOrBefore(yearEnd, "day")) {
|
||||
filtered[dateStr] = count;
|
||||
}
|
||||
}
|
||||
|
||||
return filtered;
|
||||
};
|
||||
|
||||
export const hasActivityData = (data: Record<string, number>): boolean => {
|
||||
return Object.values(data).some((count) => count > 0);
|
||||
};
|
||||
|
||||
export const getTooltipText = (count: number, date: string, t: TranslateFunction): string => {
|
||||
if (count === 0) {
|
||||
return date;
|
||||
}
|
||||
|
||||
return t("memo.count-memos-in-date", {
|
||||
count,
|
||||
memos: count === 1 ? t("common.memo") : t("common.memos"),
|
||||
date,
|
||||
}).toLowerCase();
|
||||
};
|
||||
108
web/src/components/AttachmentIcon.tsx
Normal file
108
web/src/components/AttachmentIcon.tsx
Normal file
@@ -0,0 +1,108 @@
|
||||
import {
|
||||
BinaryIcon,
|
||||
BookIcon,
|
||||
FileArchiveIcon,
|
||||
FileAudioIcon,
|
||||
FileEditIcon,
|
||||
FileIcon,
|
||||
FileTextIcon,
|
||||
FileVideo2Icon,
|
||||
SheetIcon,
|
||||
} from "lucide-react";
|
||||
import React, { useState } from "react";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { Attachment } from "@/types/proto/api/v1/attachment_service_pb";
|
||||
import { getAttachmentThumbnailUrl, getAttachmentType, getAttachmentUrl } from "@/utils/attachment";
|
||||
import SquareDiv from "./kit/SquareDiv";
|
||||
import PreviewImageDialog from "./PreviewImageDialog";
|
||||
|
||||
interface Props {
|
||||
attachment: Attachment;
|
||||
className?: string;
|
||||
strokeWidth?: number;
|
||||
}
|
||||
|
||||
const AttachmentIcon = (props: Props) => {
|
||||
const { attachment } = props;
|
||||
const [previewImage, setPreviewImage] = useState<{ open: boolean; urls: string[]; index: number }>({
|
||||
open: false,
|
||||
urls: [],
|
||||
index: 0,
|
||||
});
|
||||
const resourceType = getAttachmentType(attachment);
|
||||
const attachmentUrl = getAttachmentUrl(attachment);
|
||||
const className = cn("w-full h-auto", props.className);
|
||||
const strokeWidth = props.strokeWidth;
|
||||
|
||||
const previewResource = () => {
|
||||
window.open(attachmentUrl);
|
||||
};
|
||||
|
||||
const handleImageClick = () => {
|
||||
setPreviewImage({ open: true, urls: [attachmentUrl], index: 0 });
|
||||
};
|
||||
|
||||
if (resourceType === "image/*") {
|
||||
return (
|
||||
<>
|
||||
<SquareDiv className={cn(className, "flex items-center justify-center overflow-clip")}>
|
||||
<img
|
||||
className="min-w-full min-h-full object-cover"
|
||||
src={getAttachmentThumbnailUrl(attachment)}
|
||||
onClick={handleImageClick}
|
||||
onError={(e) => {
|
||||
// Fallback to original image if thumbnail fails
|
||||
const target = e.target as HTMLImageElement;
|
||||
if (target.src.includes("?thumbnail=true")) {
|
||||
console.warn("Thumbnail failed, falling back to original image:", attachmentUrl);
|
||||
target.src = attachmentUrl;
|
||||
}
|
||||
}}
|
||||
decoding="async"
|
||||
loading="lazy"
|
||||
/>
|
||||
</SquareDiv>
|
||||
|
||||
<PreviewImageDialog
|
||||
open={previewImage.open}
|
||||
onOpenChange={(open) => setPreviewImage((prev) => ({ ...prev, open }))}
|
||||
imgUrls={previewImage.urls}
|
||||
initialIndex={previewImage.index}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
const getAttachmentIcon = () => {
|
||||
switch (resourceType) {
|
||||
case "video/*":
|
||||
return <FileVideo2Icon strokeWidth={strokeWidth} className="w-full h-auto" />;
|
||||
case "audio/*":
|
||||
return <FileAudioIcon strokeWidth={strokeWidth} className="w-full h-auto" />;
|
||||
case "text/*":
|
||||
return <FileTextIcon strokeWidth={strokeWidth} className="w-full h-auto" />;
|
||||
case "application/epub+zip":
|
||||
return <BookIcon strokeWidth={strokeWidth} className="w-full h-auto" />;
|
||||
case "application/pdf":
|
||||
return <BookIcon strokeWidth={strokeWidth} className="w-full h-auto" />;
|
||||
case "application/msword":
|
||||
return <FileEditIcon strokeWidth={strokeWidth} className="w-full h-auto" />;
|
||||
case "application/msexcel":
|
||||
return <SheetIcon strokeWidth={strokeWidth} className="w-full h-auto" />;
|
||||
case "application/zip":
|
||||
return <FileArchiveIcon onClick={previewResource} strokeWidth={strokeWidth} className="w-full h-auto" />;
|
||||
case "application/x-java-archive":
|
||||
return <BinaryIcon strokeWidth={strokeWidth} className="w-full h-auto" />;
|
||||
default:
|
||||
return <FileIcon strokeWidth={strokeWidth} className="w-full h-auto" />;
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div onClick={previewResource} className={cn(className, "max-w-16 opacity-50")}>
|
||||
{getAttachmentIcon()}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default React.memo(AttachmentIcon);
|
||||
35
web/src/components/AuthFooter.tsx
Normal file
35
web/src/components/AuthFooter.tsx
Normal file
@@ -0,0 +1,35 @@
|
||||
import { useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { loadLocale } from "@/utils/i18n";
|
||||
import { getInitialTheme, loadTheme, Theme } from "@/utils/theme";
|
||||
import LocaleSelect from "./LocaleSelect";
|
||||
import ThemeSelect from "./ThemeSelect";
|
||||
|
||||
interface Props {
|
||||
className?: string;
|
||||
}
|
||||
|
||||
const AuthFooter = ({ className }: Props) => {
|
||||
const { i18n: i18nInstance } = useTranslation();
|
||||
const currentLocale = i18nInstance.language as Locale;
|
||||
const [currentTheme, setCurrentTheme] = useState(getInitialTheme());
|
||||
|
||||
const handleLocaleChange = (locale: Locale) => {
|
||||
loadLocale(locale);
|
||||
};
|
||||
|
||||
const handleThemeChange = (theme: string) => {
|
||||
loadTheme(theme);
|
||||
setCurrentTheme(theme as Theme);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className={cn("mt-4 flex flex-row items-center justify-center w-full gap-2", className)}>
|
||||
<LocaleSelect value={currentLocale} onChange={handleLocaleChange} />
|
||||
<ThemeSelect value={currentTheme} onValueChange={handleThemeChange} />
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default AuthFooter;
|
||||
114
web/src/components/ChangeMemberPasswordDialog.tsx
Normal file
114
web/src/components/ChangeMemberPasswordDialog.tsx
Normal file
@@ -0,0 +1,114 @@
|
||||
import { useState } from "react";
|
||||
import { toast } from "react-hot-toast";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Dialog, DialogContent, DialogFooter, DialogHeader, DialogTitle } from "@/components/ui/dialog";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { useUpdateUser } from "@/hooks/useUserQueries";
|
||||
import { handleError } from "@/lib/error";
|
||||
import { User } from "@/types/proto/api/v1/user_service_pb";
|
||||
import { useTranslate } from "@/utils/i18n";
|
||||
|
||||
interface Props {
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
user?: User;
|
||||
onSuccess?: () => void;
|
||||
}
|
||||
|
||||
function ChangeMemberPasswordDialog({ open, onOpenChange, user, onSuccess }: Props) {
|
||||
const t = useTranslate();
|
||||
const { mutateAsync: updateUser } = useUpdateUser();
|
||||
const [newPassword, setNewPassword] = useState("");
|
||||
const [newPasswordAgain, setNewPasswordAgain] = useState("");
|
||||
|
||||
const handleCloseBtnClick = () => {
|
||||
onOpenChange(false);
|
||||
};
|
||||
|
||||
const handleNewPasswordChanged = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const text = e.target.value as string;
|
||||
setNewPassword(text);
|
||||
};
|
||||
|
||||
const handleNewPasswordAgainChanged = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const text = e.target.value as string;
|
||||
setNewPasswordAgain(text);
|
||||
};
|
||||
|
||||
const handleSaveBtnClick = async () => {
|
||||
if (!user) return;
|
||||
|
||||
if (newPassword === "" || newPasswordAgain === "") {
|
||||
toast.error(t("message.fill-all"));
|
||||
return;
|
||||
}
|
||||
|
||||
if (newPassword !== newPasswordAgain) {
|
||||
toast.error(t("message.new-password-not-match"));
|
||||
setNewPasswordAgain("");
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
await updateUser({
|
||||
user: {
|
||||
name: user.name,
|
||||
password: newPassword,
|
||||
},
|
||||
updateMask: ["password"],
|
||||
});
|
||||
toast(t("message.password-changed"));
|
||||
onSuccess?.();
|
||||
onOpenChange(false);
|
||||
} catch (error: unknown) {
|
||||
await handleError(error, toast.error, {
|
||||
context: "Change member password",
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
if (!user) return null;
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent className="max-w-md">
|
||||
<DialogHeader>
|
||||
<DialogTitle>
|
||||
{t("setting.account-section.change-password")} ({user.displayName})
|
||||
</DialogTitle>
|
||||
</DialogHeader>
|
||||
<div className="flex flex-col gap-4">
|
||||
<div className="grid gap-2">
|
||||
<Label htmlFor="newPassword">{t("auth.new-password")}</Label>
|
||||
<Input
|
||||
id="newPassword"
|
||||
type="password"
|
||||
placeholder={t("auth.new-password")}
|
||||
value={newPassword}
|
||||
onChange={handleNewPasswordChanged}
|
||||
/>
|
||||
</div>
|
||||
<div className="grid gap-2">
|
||||
<Label htmlFor="newPasswordAgain">{t("auth.repeat-new-password")}</Label>
|
||||
<Input
|
||||
id="newPasswordAgain"
|
||||
type="password"
|
||||
placeholder={t("auth.repeat-new-password")}
|
||||
value={newPasswordAgain}
|
||||
onChange={handleNewPasswordAgainChanged}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button variant="ghost" onClick={handleCloseBtnClick}>
|
||||
{t("common.cancel")}
|
||||
</Button>
|
||||
<Button onClick={handleSaveBtnClick}>{t("common.save")}</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
|
||||
export default ChangeMemberPasswordDialog;
|
||||
131
web/src/components/ConfirmDialog/README.md
Normal file
131
web/src/components/ConfirmDialog/README.md
Normal file
@@ -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.
|
||||
59
web/src/components/ConfirmDialog/index.tsx
Normal file
59
web/src/components/ConfirmDialog/index.tsx
Normal file
@@ -0,0 +1,59 @@
|
||||
import * as React from "react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle } from "@/components/ui/dialog";
|
||||
|
||||
export interface ConfirmDialogProps {
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
title: React.ReactNode;
|
||||
description?: React.ReactNode;
|
||||
confirmLabel: string;
|
||||
cancelLabel: string;
|
||||
onConfirm: () => void | Promise<void>;
|
||||
confirmVariant?: "default" | "destructive";
|
||||
}
|
||||
|
||||
export default function ConfirmDialog({
|
||||
open,
|
||||
onOpenChange,
|
||||
title,
|
||||
description,
|
||||
confirmLabel,
|
||||
cancelLabel,
|
||||
onConfirm,
|
||||
confirmVariant = "default",
|
||||
}: ConfirmDialogProps) {
|
||||
const [loading, setLoading] = React.useState(false);
|
||||
|
||||
const handleConfirm = async () => {
|
||||
try {
|
||||
setLoading(true);
|
||||
await onConfirm();
|
||||
onOpenChange(false);
|
||||
} catch (e) {
|
||||
// Intentionally swallow errors so user can retry; surface via caller's toast/logging
|
||||
console.error("ConfirmDialog error for action:", title, e);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={(o: boolean) => !loading && onOpenChange(o)}>
|
||||
<DialogContent size="sm">
|
||||
<DialogHeader>
|
||||
<DialogTitle>{title}</DialogTitle>
|
||||
{description ? <DialogDescription>{description}</DialogDescription> : null}
|
||||
</DialogHeader>
|
||||
<DialogFooter>
|
||||
<Button variant="ghost" disabled={loading} onClick={() => onOpenChange(false)}>
|
||||
{cancelLabel}
|
||||
</Button>
|
||||
<Button variant={confirmVariant} disabled={loading} onClick={handleConfirm} data-loading={loading}>
|
||||
{confirmLabel}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
183
web/src/components/CreateAccessTokenDialog.tsx
Normal file
183
web/src/components/CreateAccessTokenDialog.tsx
Normal file
@@ -0,0 +1,183 @@
|
||||
import copy from "copy-to-clipboard";
|
||||
import React, { useEffect, useState } from "react";
|
||||
import { toast } from "react-hot-toast";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Dialog, DialogContent, DialogFooter, DialogHeader, DialogTitle } from "@/components/ui/dialog";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { RadioGroup, RadioGroupItem } from "@/components/ui/radio-group";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
import { userServiceClient } from "@/connect";
|
||||
import useCurrentUser from "@/hooks/useCurrentUser";
|
||||
import useLoading from "@/hooks/useLoading";
|
||||
import { handleError } from "@/lib/error";
|
||||
import { CreatePersonalAccessTokenResponse } from "@/types/proto/api/v1/user_service_pb";
|
||||
import { useTranslate } from "@/utils/i18n";
|
||||
|
||||
interface Props {
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
onSuccess: (response: CreatePersonalAccessTokenResponse) => void;
|
||||
}
|
||||
|
||||
interface State {
|
||||
description: string;
|
||||
expiration: number;
|
||||
}
|
||||
|
||||
function CreateAccessTokenDialog({ open, onOpenChange, onSuccess }: Props) {
|
||||
const t = useTranslate();
|
||||
const currentUser = useCurrentUser();
|
||||
const [state, setState] = useState({
|
||||
description: "",
|
||||
expiration: 30, // Default: 30 days
|
||||
});
|
||||
const [createdToken, setCreatedToken] = useState<string | null>(null);
|
||||
const requestState = useLoading(false);
|
||||
|
||||
// Expiration options in days (0 = never expires)
|
||||
const expirationOptions = [
|
||||
{
|
||||
label: t("setting.access-token-section.create-dialog.duration-1m"),
|
||||
value: 30,
|
||||
},
|
||||
{
|
||||
label: "90 Days",
|
||||
value: 90,
|
||||
},
|
||||
{
|
||||
label: t("setting.access-token-section.create-dialog.duration-never"),
|
||||
value: 0,
|
||||
},
|
||||
];
|
||||
|
||||
const setPartialState = (partialState: Partial<State>) => {
|
||||
setState({
|
||||
...state,
|
||||
...partialState,
|
||||
});
|
||||
};
|
||||
|
||||
const handleDescriptionInputChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
setPartialState({
|
||||
description: e.target.value,
|
||||
});
|
||||
};
|
||||
|
||||
const handleRoleInputChange = (value: string) => {
|
||||
setPartialState({
|
||||
expiration: Number(value),
|
||||
});
|
||||
};
|
||||
|
||||
const handleSaveBtnClick = async () => {
|
||||
if (!state.description) {
|
||||
toast.error(t("message.description-is-required"));
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
requestState.setLoading();
|
||||
const response = await userServiceClient.createPersonalAccessToken({
|
||||
parent: currentUser?.name,
|
||||
description: state.description,
|
||||
expiresInDays: state.expiration,
|
||||
});
|
||||
|
||||
requestState.setFinish();
|
||||
onSuccess(response);
|
||||
if (response.token) {
|
||||
setCreatedToken(response.token);
|
||||
} else {
|
||||
onOpenChange(false);
|
||||
}
|
||||
} catch (error: unknown) {
|
||||
handleError(error, toast.error, {
|
||||
context: "Create access token",
|
||||
onError: () => requestState.setError(),
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const handleCopyToken = () => {
|
||||
if (!createdToken) return;
|
||||
copy(createdToken);
|
||||
toast.success(t("message.copied"));
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
setState({
|
||||
description: "",
|
||||
expiration: 30,
|
||||
});
|
||||
setCreatedToken(null);
|
||||
}, [open]);
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent className="max-w-md">
|
||||
<DialogHeader>
|
||||
<DialogTitle>{t("setting.access-token-section.create-dialog.create-access-token")}</DialogTitle>
|
||||
</DialogHeader>
|
||||
{createdToken ? (
|
||||
<div className="flex flex-col gap-4">
|
||||
<div className="grid gap-2">
|
||||
<Label>{t("setting.access-token-section.token")}</Label>
|
||||
<Textarea value={createdToken} readOnly rows={3} className="font-mono text-xs" />
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex flex-col gap-4">
|
||||
<div className="grid gap-2">
|
||||
<Label htmlFor="description">
|
||||
{t("setting.access-token-section.create-dialog.description")} <span className="text-destructive">*</span>
|
||||
</Label>
|
||||
<Input
|
||||
id="description"
|
||||
type="text"
|
||||
placeholder={t("setting.access-token-section.create-dialog.some-description")}
|
||||
value={state.description}
|
||||
onChange={handleDescriptionInputChange}
|
||||
/>
|
||||
</div>
|
||||
<div className="grid gap-2">
|
||||
<Label>
|
||||
{t("setting.access-token-section.create-dialog.expiration")} <span className="text-destructive">*</span>
|
||||
</Label>
|
||||
<RadioGroup value={state.expiration.toString()} onValueChange={handleRoleInputChange} className="flex flex-row gap-4">
|
||||
{expirationOptions.map((option) => (
|
||||
<div key={option.value} className="flex items-center space-x-2">
|
||||
<RadioGroupItem value={option.value.toString()} id={`expiration-${option.value}`} />
|
||||
<Label htmlFor={`expiration-${option.value}`}>{option.label}</Label>
|
||||
</div>
|
||||
))}
|
||||
</RadioGroup>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
<DialogFooter>
|
||||
{createdToken ? (
|
||||
<>
|
||||
<Button variant="ghost" onClick={handleCopyToken}>
|
||||
{t("common.copy")}
|
||||
</Button>
|
||||
<Button onClick={() => onOpenChange(false)}>{t("common.close")}</Button>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Button variant="ghost" disabled={requestState.isLoading} onClick={() => onOpenChange(false)}>
|
||||
{t("common.cancel")}
|
||||
</Button>
|
||||
<Button disabled={requestState.isLoading} onClick={handleSaveBtnClick}>
|
||||
{t("common.create")}
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
|
||||
export default CreateAccessTokenDialog;
|
||||
497
web/src/components/CreateIdentityProviderDialog.tsx
Normal file
497
web/src/components/CreateIdentityProviderDialog.tsx
Normal file
@@ -0,0 +1,497 @@
|
||||
import { create } from "@bufbuild/protobuf";
|
||||
import { FieldMaskSchema } from "@bufbuild/protobuf/wkt";
|
||||
import { useEffect, useState } from "react";
|
||||
import { toast } from "react-hot-toast";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Dialog, DialogContent, DialogFooter, DialogHeader, DialogTitle } from "@/components/ui/dialog";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
||||
import { Separator } from "@/components/ui/separator";
|
||||
import { identityProviderServiceClient } from "@/connect";
|
||||
import { absolutifyLink } from "@/helpers/utils";
|
||||
import { handleError } from "@/lib/error";
|
||||
import {
|
||||
FieldMapping,
|
||||
FieldMappingSchema,
|
||||
IdentityProvider,
|
||||
IdentityProvider_Type,
|
||||
IdentityProviderConfigSchema,
|
||||
IdentityProviderSchema,
|
||||
OAuth2Config,
|
||||
OAuth2ConfigSchema,
|
||||
} from "@/types/proto/api/v1/idp_service_pb";
|
||||
import { useTranslate } from "@/utils/i18n";
|
||||
|
||||
const templateList: IdentityProvider[] = [
|
||||
create(IdentityProviderSchema, {
|
||||
name: "",
|
||||
title: "GitHub",
|
||||
type: IdentityProvider_Type.OAUTH2,
|
||||
identifierFilter: "",
|
||||
config: create(IdentityProviderConfigSchema, {
|
||||
config: {
|
||||
case: "oauth2Config",
|
||||
value: create(OAuth2ConfigSchema, {
|
||||
clientId: "",
|
||||
clientSecret: "",
|
||||
authUrl: "https://github.com/login/oauth/authorize",
|
||||
tokenUrl: "https://github.com/login/oauth/access_token",
|
||||
userInfoUrl: "https://api.github.com/user",
|
||||
scopes: ["read:user"],
|
||||
fieldMapping: create(FieldMappingSchema, {
|
||||
identifier: "login",
|
||||
displayName: "name",
|
||||
email: "email",
|
||||
}),
|
||||
}),
|
||||
},
|
||||
}),
|
||||
}),
|
||||
create(IdentityProviderSchema, {
|
||||
name: "",
|
||||
title: "GitLab",
|
||||
type: IdentityProvider_Type.OAUTH2,
|
||||
identifierFilter: "",
|
||||
config: create(IdentityProviderConfigSchema, {
|
||||
config: {
|
||||
case: "oauth2Config",
|
||||
value: create(OAuth2ConfigSchema, {
|
||||
clientId: "",
|
||||
clientSecret: "",
|
||||
authUrl: "https://gitlab.com/oauth/authorize",
|
||||
tokenUrl: "https://gitlab.com/oauth/token",
|
||||
userInfoUrl: "https://gitlab.com/oauth/userinfo",
|
||||
scopes: ["openid"],
|
||||
fieldMapping: create(FieldMappingSchema, {
|
||||
identifier: "name",
|
||||
displayName: "name",
|
||||
email: "email",
|
||||
}),
|
||||
}),
|
||||
},
|
||||
}),
|
||||
}),
|
||||
create(IdentityProviderSchema, {
|
||||
name: "",
|
||||
title: "Google",
|
||||
type: IdentityProvider_Type.OAUTH2,
|
||||
identifierFilter: "",
|
||||
config: create(IdentityProviderConfigSchema, {
|
||||
config: {
|
||||
case: "oauth2Config",
|
||||
value: create(OAuth2ConfigSchema, {
|
||||
clientId: "",
|
||||
clientSecret: "",
|
||||
authUrl: "https://accounts.google.com/o/oauth2/v2/auth",
|
||||
tokenUrl: "https://oauth2.googleapis.com/token",
|
||||
userInfoUrl: "https://www.googleapis.com/oauth2/v2/userinfo",
|
||||
scopes: ["https://www.googleapis.com/auth/userinfo.email", "https://www.googleapis.com/auth/userinfo.profile"],
|
||||
fieldMapping: create(FieldMappingSchema, {
|
||||
identifier: "email",
|
||||
displayName: "name",
|
||||
email: "email",
|
||||
}),
|
||||
}),
|
||||
},
|
||||
}),
|
||||
}),
|
||||
create(IdentityProviderSchema, {
|
||||
name: "",
|
||||
title: "Custom",
|
||||
type: IdentityProvider_Type.OAUTH2,
|
||||
identifierFilter: "",
|
||||
config: create(IdentityProviderConfigSchema, {
|
||||
config: {
|
||||
case: "oauth2Config",
|
||||
value: create(OAuth2ConfigSchema, {
|
||||
clientId: "",
|
||||
clientSecret: "",
|
||||
authUrl: "",
|
||||
tokenUrl: "",
|
||||
userInfoUrl: "",
|
||||
scopes: [],
|
||||
fieldMapping: create(FieldMappingSchema, {
|
||||
identifier: "",
|
||||
displayName: "",
|
||||
email: "",
|
||||
}),
|
||||
}),
|
||||
},
|
||||
}),
|
||||
}),
|
||||
];
|
||||
|
||||
interface Props {
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
identityProvider?: IdentityProvider;
|
||||
onSuccess?: () => void;
|
||||
}
|
||||
|
||||
function CreateIdentityProviderDialog({ open, onOpenChange, identityProvider, onSuccess }: Props) {
|
||||
const t = useTranslate();
|
||||
const identityProviderTypes = [...new Set(templateList.map((t) => t.type))];
|
||||
const [basicInfo, setBasicInfo] = useState({
|
||||
title: "",
|
||||
identifierFilter: "",
|
||||
});
|
||||
const [type, setType] = useState<IdentityProvider_Type>(IdentityProvider_Type.OAUTH2);
|
||||
const [oauth2Config, setOAuth2Config] = useState<OAuth2Config>(
|
||||
create(OAuth2ConfigSchema, {
|
||||
clientId: "",
|
||||
clientSecret: "",
|
||||
authUrl: "",
|
||||
tokenUrl: "",
|
||||
userInfoUrl: "",
|
||||
scopes: [],
|
||||
fieldMapping: create(FieldMappingSchema, {
|
||||
identifier: "",
|
||||
displayName: "",
|
||||
email: "",
|
||||
}),
|
||||
}),
|
||||
);
|
||||
const [oauth2Scopes, setOAuth2Scopes] = useState<string>("");
|
||||
const [selectedTemplate, setSelectedTemplate] = useState<string>("GitHub");
|
||||
const isCreating = identityProvider === undefined;
|
||||
|
||||
// Reset state when dialog is closed
|
||||
useEffect(() => {
|
||||
if (!open) {
|
||||
// Reset to default state when dialog is closed
|
||||
setBasicInfo({
|
||||
title: "",
|
||||
identifierFilter: "",
|
||||
});
|
||||
setType(IdentityProvider_Type.OAUTH2);
|
||||
setOAuth2Config(
|
||||
create(OAuth2ConfigSchema, {
|
||||
clientId: "",
|
||||
clientSecret: "",
|
||||
authUrl: "",
|
||||
tokenUrl: "",
|
||||
userInfoUrl: "",
|
||||
scopes: [],
|
||||
fieldMapping: create(FieldMappingSchema, {
|
||||
identifier: "",
|
||||
displayName: "",
|
||||
email: "",
|
||||
}),
|
||||
}),
|
||||
);
|
||||
setOAuth2Scopes("");
|
||||
setSelectedTemplate("GitHub");
|
||||
}
|
||||
}, [open]);
|
||||
|
||||
// Load existing identity provider data when editing
|
||||
useEffect(() => {
|
||||
if (open && identityProvider) {
|
||||
setBasicInfo({
|
||||
title: identityProvider.title,
|
||||
identifierFilter: identityProvider.identifierFilter,
|
||||
});
|
||||
setType(identityProvider.type);
|
||||
if (identityProvider.type === IdentityProvider_Type.OAUTH2 && identityProvider.config?.config?.case === "oauth2Config") {
|
||||
const oauth2Config = create(OAuth2ConfigSchema, identityProvider.config.config.value || {});
|
||||
setOAuth2Config(oauth2Config);
|
||||
setOAuth2Scopes(oauth2Config.scopes.join(" "));
|
||||
}
|
||||
}
|
||||
}, [open, identityProvider]);
|
||||
|
||||
// Load template data when creating new IDP
|
||||
useEffect(() => {
|
||||
if (!isCreating || !open) {
|
||||
return;
|
||||
}
|
||||
|
||||
const template = templateList.find((t) => t.title === selectedTemplate);
|
||||
if (template) {
|
||||
setBasicInfo({
|
||||
title: template.title,
|
||||
identifierFilter: template.identifierFilter,
|
||||
});
|
||||
setType(template.type);
|
||||
if (template.type === IdentityProvider_Type.OAUTH2 && template.config?.config?.case === "oauth2Config") {
|
||||
const oauth2Config = create(OAuth2ConfigSchema, template.config.config.value || {});
|
||||
setOAuth2Config(oauth2Config);
|
||||
setOAuth2Scopes(oauth2Config.scopes.join(" "));
|
||||
}
|
||||
}
|
||||
}, [selectedTemplate, isCreating, open]);
|
||||
|
||||
const handleCloseBtnClick = () => {
|
||||
onOpenChange(false);
|
||||
};
|
||||
|
||||
const allowConfirmAction = () => {
|
||||
if (basicInfo.title === "") {
|
||||
return false;
|
||||
}
|
||||
if (type === IdentityProvider_Type.OAUTH2) {
|
||||
if (
|
||||
oauth2Config.clientId === "" ||
|
||||
oauth2Config.authUrl === "" ||
|
||||
oauth2Config.tokenUrl === "" ||
|
||||
oauth2Config.userInfoUrl === "" ||
|
||||
oauth2Scopes === "" ||
|
||||
oauth2Config.fieldMapping?.identifier === ""
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
if (isCreating) {
|
||||
if (oauth2Config.clientSecret === "") {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return true;
|
||||
};
|
||||
|
||||
const handleConfirmBtnClick = async () => {
|
||||
try {
|
||||
if (isCreating) {
|
||||
await identityProviderServiceClient.createIdentityProvider({
|
||||
identityProvider: create(IdentityProviderSchema, {
|
||||
...basicInfo,
|
||||
type: type,
|
||||
config: create(IdentityProviderConfigSchema, {
|
||||
config: {
|
||||
case: "oauth2Config",
|
||||
value: {
|
||||
...oauth2Config,
|
||||
scopes: oauth2Scopes.split(" "),
|
||||
},
|
||||
},
|
||||
}),
|
||||
}),
|
||||
});
|
||||
toast.success(t("setting.sso-section.sso-created", { name: basicInfo.title }));
|
||||
} else {
|
||||
await identityProviderServiceClient.updateIdentityProvider({
|
||||
identityProvider: create(IdentityProviderSchema, {
|
||||
...basicInfo,
|
||||
name: identityProvider!.name,
|
||||
type: type,
|
||||
config: create(IdentityProviderConfigSchema, {
|
||||
config: {
|
||||
case: "oauth2Config",
|
||||
value: {
|
||||
...oauth2Config,
|
||||
scopes: oauth2Scopes.split(" "),
|
||||
},
|
||||
},
|
||||
}),
|
||||
}),
|
||||
updateMask: create(FieldMaskSchema, { paths: ["title", "identifier_filter", "config"] }),
|
||||
});
|
||||
toast.success(t("setting.sso-section.sso-updated", { name: basicInfo.title }));
|
||||
}
|
||||
} catch (error: unknown) {
|
||||
await handleError(error, toast.error, {
|
||||
context: isCreating ? "Create identity provider" : "Update identity provider",
|
||||
});
|
||||
}
|
||||
onSuccess?.();
|
||||
onOpenChange(false);
|
||||
};
|
||||
|
||||
const setPartialOAuth2Config = (state: Partial<OAuth2Config>) => {
|
||||
setOAuth2Config({
|
||||
...oauth2Config,
|
||||
...state,
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent className="max-w-2xl max-h-[80vh] overflow-y-auto">
|
||||
<DialogHeader>
|
||||
<DialogTitle>{t(isCreating ? "setting.sso-section.create-sso" : "setting.sso-section.update-sso")}</DialogTitle>
|
||||
</DialogHeader>
|
||||
<div className="flex flex-col justify-start items-start w-full space-y-4">
|
||||
{isCreating && (
|
||||
<>
|
||||
<p className="mb-1!">{t("common.type")}</p>
|
||||
<Select value={String(type)} onValueChange={(value) => setType(parseInt(value) as unknown as IdentityProvider_Type)}>
|
||||
<SelectTrigger className="w-full mb-4">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{identityProviderTypes.map((kind) => (
|
||||
<SelectItem key={kind} value={String(kind)}>
|
||||
{IdentityProvider_Type[kind] || kind}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<p className="mb-2 text-sm font-medium">{t("setting.sso-section.template")}</p>
|
||||
<Select value={selectedTemplate} onValueChange={(value) => setSelectedTemplate(value)}>
|
||||
<SelectTrigger className="mb-1 h-auto w-full">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{templateList.map((template) => (
|
||||
<SelectItem key={template.title} value={template.title}>
|
||||
{template.title}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<Separator className="my-2" />
|
||||
</>
|
||||
)}
|
||||
<p className="mb-1 text-sm font-medium">
|
||||
{t("common.name")}
|
||||
<span className="text-destructive">*</span>
|
||||
</p>
|
||||
<Input
|
||||
className="mb-2 w-full"
|
||||
placeholder={t("common.name")}
|
||||
value={basicInfo.title}
|
||||
onChange={(e) =>
|
||||
setBasicInfo({
|
||||
...basicInfo,
|
||||
title: e.target.value,
|
||||
})
|
||||
}
|
||||
/>
|
||||
<p className="mb-1 text-sm font-medium">{t("setting.sso-section.identifier-filter")}</p>
|
||||
<Input
|
||||
className="mb-2 w-full"
|
||||
placeholder={t("setting.sso-section.identifier-filter")}
|
||||
value={basicInfo.identifierFilter}
|
||||
onChange={(e) =>
|
||||
setBasicInfo({
|
||||
...basicInfo,
|
||||
identifierFilter: e.target.value,
|
||||
})
|
||||
}
|
||||
/>
|
||||
<Separator className="my-2" />
|
||||
{type === IdentityProvider_Type.OAUTH2 && (
|
||||
<>
|
||||
{isCreating && (
|
||||
<p className="border border-border rounded-md p-2 text-sm w-full mb-2 break-all">
|
||||
{t("setting.sso-section.redirect-url")}: {absolutifyLink("/auth/callback")}
|
||||
</p>
|
||||
)}
|
||||
<p className="mb-1 text-sm font-medium">
|
||||
{t("setting.sso-section.client-id")}
|
||||
<span className="text-destructive">*</span>
|
||||
</p>
|
||||
<Input
|
||||
className="mb-2 w-full"
|
||||
placeholder={t("setting.sso-section.client-id")}
|
||||
value={oauth2Config.clientId}
|
||||
onChange={(e) => setPartialOAuth2Config({ clientId: e.target.value })}
|
||||
/>
|
||||
<p className="mb-1 text-sm font-medium">
|
||||
{t("setting.sso-section.client-secret")}
|
||||
<span className="text-destructive">*</span>
|
||||
</p>
|
||||
<Input
|
||||
className="mb-2 w-full"
|
||||
placeholder={t("setting.sso-section.client-secret")}
|
||||
value={oauth2Config.clientSecret}
|
||||
onChange={(e) => setPartialOAuth2Config({ clientSecret: e.target.value })}
|
||||
/>
|
||||
<p className="mb-1 text-sm font-medium">
|
||||
{t("setting.sso-section.authorization-endpoint")}
|
||||
<span className="text-destructive">*</span>
|
||||
</p>
|
||||
<Input
|
||||
className="mb-2 w-full"
|
||||
placeholder={t("setting.sso-section.authorization-endpoint")}
|
||||
value={oauth2Config.authUrl}
|
||||
onChange={(e) => setPartialOAuth2Config({ authUrl: e.target.value })}
|
||||
/>
|
||||
<p className="mb-1 text-sm font-medium">
|
||||
{t("setting.sso-section.token-endpoint")}
|
||||
<span className="text-destructive">*</span>
|
||||
</p>
|
||||
<Input
|
||||
className="mb-2 w-full"
|
||||
placeholder={t("setting.sso-section.token-endpoint")}
|
||||
value={oauth2Config.tokenUrl}
|
||||
onChange={(e) => setPartialOAuth2Config({ tokenUrl: e.target.value })}
|
||||
/>
|
||||
<p className="mb-1 text-sm font-medium">
|
||||
{t("setting.sso-section.user-endpoint")}
|
||||
<span className="text-destructive">*</span>
|
||||
</p>
|
||||
<Input
|
||||
className="mb-2 w-full"
|
||||
placeholder={t("setting.sso-section.user-endpoint")}
|
||||
value={oauth2Config.userInfoUrl}
|
||||
onChange={(e) => setPartialOAuth2Config({ userInfoUrl: e.target.value })}
|
||||
/>
|
||||
<p className="mb-1 text-sm font-medium">
|
||||
{t("setting.sso-section.scopes")}
|
||||
<span className="text-destructive">*</span>
|
||||
</p>
|
||||
<Input
|
||||
className="mb-2 w-full"
|
||||
placeholder={t("setting.sso-section.scopes")}
|
||||
value={oauth2Scopes}
|
||||
onChange={(e) => setOAuth2Scopes(e.target.value)}
|
||||
/>
|
||||
<Separator className="my-2" />
|
||||
<p className="mb-1 text-sm font-medium">
|
||||
{t("setting.sso-section.identifier")}
|
||||
<span className="text-destructive">*</span>
|
||||
</p>
|
||||
<Input
|
||||
className="mb-2 w-full"
|
||||
placeholder={t("setting.sso-section.identifier")}
|
||||
value={oauth2Config.fieldMapping!.identifier}
|
||||
onChange={(e) =>
|
||||
setPartialOAuth2Config({ fieldMapping: { ...oauth2Config.fieldMapping, identifier: e.target.value } as FieldMapping })
|
||||
}
|
||||
/>
|
||||
<p className="mb-1 text-sm font-medium">{t("setting.sso-section.display-name")}</p>
|
||||
<Input
|
||||
className="mb-2 w-full"
|
||||
placeholder={t("setting.sso-section.display-name")}
|
||||
value={oauth2Config.fieldMapping!.displayName}
|
||||
onChange={(e) =>
|
||||
setPartialOAuth2Config({ fieldMapping: { ...oauth2Config.fieldMapping, displayName: e.target.value } as FieldMapping })
|
||||
}
|
||||
/>
|
||||
<p className="mb-1 text-sm font-medium">{t("common.email")}</p>
|
||||
<Input
|
||||
className="mb-2 w-full"
|
||||
placeholder={t("common.email")}
|
||||
value={oauth2Config.fieldMapping!.email}
|
||||
onChange={(e) =>
|
||||
setPartialOAuth2Config({ fieldMapping: { ...oauth2Config.fieldMapping, email: e.target.value } as FieldMapping })
|
||||
}
|
||||
/>
|
||||
<p className="mb-1 text-sm font-medium">Avatar URL</p>
|
||||
<Input
|
||||
className="mb-2 w-full"
|
||||
placeholder={"Avatar URL"}
|
||||
value={oauth2Config.fieldMapping!.avatarUrl}
|
||||
onChange={(e) =>
|
||||
setPartialOAuth2Config({ fieldMapping: { ...oauth2Config.fieldMapping, avatarUrl: e.target.value } as FieldMapping })
|
||||
}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button variant="ghost" onClick={handleCloseBtnClick}>
|
||||
{t("common.cancel")}
|
||||
</Button>
|
||||
<Button onClick={handleConfirmBtnClick} disabled={!allowConfirmAction()}>
|
||||
{t(isCreating ? "common.create" : "common.update")}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
|
||||
export default CreateIdentityProviderDialog;
|
||||
158
web/src/components/CreateShortcutDialog.tsx
Normal file
158
web/src/components/CreateShortcutDialog.tsx
Normal file
@@ -0,0 +1,158 @@
|
||||
import { create } from "@bufbuild/protobuf";
|
||||
import { FieldMaskSchema } from "@bufbuild/protobuf/wkt";
|
||||
import { useEffect, useState } from "react";
|
||||
import { toast } from "react-hot-toast";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Dialog, DialogContent, DialogFooter, DialogHeader, DialogTitle } from "@/components/ui/dialog";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
import { shortcutServiceClient } from "@/connect";
|
||||
import { useAuth } from "@/contexts/AuthContext";
|
||||
import useCurrentUser from "@/hooks/useCurrentUser";
|
||||
import useLoading from "@/hooks/useLoading";
|
||||
import { handleError } from "@/lib/error";
|
||||
import { Shortcut, ShortcutSchema } from "@/types/proto/api/v1/shortcut_service_pb";
|
||||
import { useTranslate } from "@/utils/i18n";
|
||||
|
||||
interface Props {
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
shortcut?: Shortcut;
|
||||
onSuccess?: () => void;
|
||||
}
|
||||
|
||||
function CreateShortcutDialog({ open, onOpenChange, shortcut: initialShortcut, onSuccess }: Props) {
|
||||
const t = useTranslate();
|
||||
const user = useCurrentUser();
|
||||
const { refetchSettings } = useAuth();
|
||||
const [shortcut, setShortcut] = useState<Shortcut>(
|
||||
create(ShortcutSchema, {
|
||||
name: initialShortcut?.name || "",
|
||||
title: initialShortcut?.title || "",
|
||||
filter: initialShortcut?.filter || "",
|
||||
}),
|
||||
);
|
||||
const requestState = useLoading(false);
|
||||
const isCreating = shortcut.name === "";
|
||||
|
||||
useEffect(() => {
|
||||
setShortcut(
|
||||
create(ShortcutSchema, {
|
||||
name: initialShortcut?.name || "",
|
||||
title: initialShortcut?.title || "",
|
||||
filter: initialShortcut?.filter || "",
|
||||
}),
|
||||
);
|
||||
}, [initialShortcut]);
|
||||
|
||||
const onShortcutTitleChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
setPartialState({
|
||||
title: e.target.value,
|
||||
});
|
||||
};
|
||||
|
||||
const onShortcutFilterChange = (e: React.ChangeEvent<HTMLTextAreaElement>) => {
|
||||
setPartialState({
|
||||
filter: e.target.value,
|
||||
});
|
||||
};
|
||||
|
||||
const setPartialState = (partialState: Partial<Shortcut>) => {
|
||||
setShortcut({
|
||||
...shortcut,
|
||||
...partialState,
|
||||
});
|
||||
};
|
||||
|
||||
const handleSaveBtnClick = async () => {
|
||||
if (!shortcut.title || !shortcut.filter) {
|
||||
toast.error("Title and filter cannot be empty");
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
requestState.setLoading();
|
||||
if (isCreating) {
|
||||
await shortcutServiceClient.createShortcut({
|
||||
parent: user?.name,
|
||||
shortcut: {
|
||||
name: "",
|
||||
title: shortcut.title,
|
||||
filter: shortcut.filter,
|
||||
},
|
||||
});
|
||||
toast.success("Create shortcut successfully");
|
||||
} else {
|
||||
await shortcutServiceClient.updateShortcut({
|
||||
shortcut: {
|
||||
...shortcut,
|
||||
name: initialShortcut!.name,
|
||||
},
|
||||
updateMask: create(FieldMaskSchema, { paths: ["title", "filter"] }),
|
||||
});
|
||||
toast.success("Update shortcut successfully");
|
||||
}
|
||||
await refetchSettings();
|
||||
requestState.setFinish();
|
||||
onSuccess?.();
|
||||
onOpenChange(false);
|
||||
} catch (error: unknown) {
|
||||
await handleError(error, toast.error, {
|
||||
context: isCreating ? "Create shortcut" : "Update shortcut",
|
||||
onError: () => requestState.setError(),
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent className="max-w-md">
|
||||
<DialogHeader>
|
||||
<DialogTitle>{`${isCreating ? t("common.create") : t("common.edit")} ${t("common.shortcuts")}`}</DialogTitle>
|
||||
</DialogHeader>
|
||||
<div className="flex flex-col gap-4">
|
||||
<div className="grid gap-2">
|
||||
<Label htmlFor="title">{t("common.title")}</Label>
|
||||
<Input id="title" type="text" placeholder="" value={shortcut.title} onChange={onShortcutTitleChange} />
|
||||
</div>
|
||||
<div className="grid gap-2">
|
||||
<Label htmlFor="filter">{t("common.filter")}</Label>
|
||||
<Textarea
|
||||
id="filter"
|
||||
rows={3}
|
||||
placeholder={t("common.shortcut-filter")}
|
||||
value={shortcut.filter}
|
||||
onChange={onShortcutFilterChange}
|
||||
/>
|
||||
</div>
|
||||
<div className="text-sm text-muted-foreground">
|
||||
<p className="mb-2">{t("common.learn-more")}:</p>
|
||||
<ul className="list-disc list-inside space-y-1">
|
||||
<li>
|
||||
<a
|
||||
className="text-primary hover:underline"
|
||||
href="https://www.usememos.com/docs/usage/shortcuts"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
Docs - Shortcuts
|
||||
</a>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button variant="ghost" disabled={requestState.isLoading} onClick={() => onOpenChange(false)}>
|
||||
{t("common.cancel")}
|
||||
</Button>
|
||||
<Button disabled={requestState.isLoading} onClick={handleSaveBtnClick}>
|
||||
{t("common.save")}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
|
||||
export default CreateShortcutDialog;
|
||||
150
web/src/components/CreateUserDialog.tsx
Normal file
150
web/src/components/CreateUserDialog.tsx
Normal file
@@ -0,0 +1,150 @@
|
||||
import { create } from "@bufbuild/protobuf";
|
||||
import { FieldMaskSchema } from "@bufbuild/protobuf/wkt";
|
||||
import { useEffect, useState } from "react";
|
||||
import { toast } from "react-hot-toast";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Dialog, DialogContent, DialogFooter, DialogHeader, DialogTitle } from "@/components/ui/dialog";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { RadioGroup, RadioGroupItem } from "@/components/ui/radio-group";
|
||||
import { userServiceClient } from "@/connect";
|
||||
import useLoading from "@/hooks/useLoading";
|
||||
import { handleError } from "@/lib/error";
|
||||
import { User, User_Role, UserSchema } from "@/types/proto/api/v1/user_service_pb";
|
||||
import { useTranslate } from "@/utils/i18n";
|
||||
|
||||
interface Props {
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
user?: User;
|
||||
onSuccess?: () => void;
|
||||
}
|
||||
|
||||
function CreateUserDialog({ open, onOpenChange, user: initialUser, onSuccess }: Props) {
|
||||
const t = useTranslate();
|
||||
const [user, setUser] = useState(
|
||||
create(UserSchema, initialUser ? { name: initialUser.name, username: initialUser.username, role: initialUser.role } : {}),
|
||||
);
|
||||
const requestState = useLoading(false);
|
||||
const isCreating = !initialUser;
|
||||
|
||||
useEffect(() => {
|
||||
if (initialUser) {
|
||||
setUser(create(UserSchema, { name: initialUser.name, username: initialUser.username, role: initialUser.role }));
|
||||
} else {
|
||||
setUser(create(UserSchema, {}));
|
||||
}
|
||||
}, [initialUser]);
|
||||
|
||||
const setPartialUser = (state: Partial<User>) => {
|
||||
setUser({
|
||||
...user,
|
||||
...state,
|
||||
});
|
||||
};
|
||||
|
||||
const handleConfirm = async () => {
|
||||
if (isCreating && (!user.username || !user.password)) {
|
||||
toast.error("Username and password cannot be empty");
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
requestState.setLoading();
|
||||
if (isCreating) {
|
||||
await userServiceClient.createUser({ user });
|
||||
toast.success("Create user successfully");
|
||||
} else {
|
||||
const updateMask = [];
|
||||
if (user.username !== initialUser?.username) {
|
||||
updateMask.push("username");
|
||||
}
|
||||
if (user.password) {
|
||||
updateMask.push("password");
|
||||
}
|
||||
if (user.role !== initialUser?.role) {
|
||||
updateMask.push("role");
|
||||
}
|
||||
const userToUpdate = create(UserSchema, { ...user, name: initialUser?.name ?? user.name });
|
||||
await userServiceClient.updateUser({ user: userToUpdate, updateMask: create(FieldMaskSchema, { paths: updateMask }) });
|
||||
toast.success("Update user successfully");
|
||||
}
|
||||
requestState.setFinish();
|
||||
onSuccess?.();
|
||||
onOpenChange(false);
|
||||
} catch (error: unknown) {
|
||||
handleError(error, toast.error, {
|
||||
context: user ? "Update user" : "Create user",
|
||||
onError: () => requestState.setError(),
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent className="max-w-md">
|
||||
<DialogHeader>
|
||||
<DialogTitle>{`${isCreating ? t("common.create") : t("common.edit")} ${t("common.user")}`}</DialogTitle>
|
||||
</DialogHeader>
|
||||
<div className="flex flex-col gap-4">
|
||||
<div className="grid gap-2">
|
||||
<Label htmlFor="username">{t("common.username")}</Label>
|
||||
<Input
|
||||
id="username"
|
||||
type="text"
|
||||
placeholder={t("common.username")}
|
||||
value={user.username}
|
||||
onChange={(e) =>
|
||||
setPartialUser({
|
||||
username: e.target.value,
|
||||
})
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
<div className="grid gap-2">
|
||||
<Label htmlFor="password">{t("common.password")}</Label>
|
||||
<Input
|
||||
id="password"
|
||||
type="password"
|
||||
placeholder={t("common.password")}
|
||||
autoComplete="off"
|
||||
value={user.password}
|
||||
onChange={(e) =>
|
||||
setPartialUser({
|
||||
password: e.target.value,
|
||||
})
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
<div className="grid gap-2">
|
||||
<Label>{t("common.role")}</Label>
|
||||
<RadioGroup
|
||||
value={String(user.role)}
|
||||
onValueChange={(value) => setPartialUser({ role: Number(value) as User_Role })}
|
||||
className="flex flex-row gap-4"
|
||||
>
|
||||
<div className="flex items-center space-x-2">
|
||||
<RadioGroupItem value={String(User_Role.USER)} id="user" />
|
||||
<Label htmlFor="user">{t("setting.member-section.user")}</Label>
|
||||
</div>
|
||||
<div className="flex items-center space-x-2">
|
||||
<RadioGroupItem value={String(User_Role.ADMIN)} id="admin" />
|
||||
<Label htmlFor="admin">{t("setting.member-section.admin")}</Label>
|
||||
</div>
|
||||
</RadioGroup>
|
||||
</div>
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button variant="ghost" disabled={requestState.isLoading} onClick={() => onOpenChange(false)}>
|
||||
{t("common.cancel")}
|
||||
</Button>
|
||||
<Button disabled={requestState.isLoading} onClick={handleConfirm}>
|
||||
{t("common.confirm")}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
|
||||
export default CreateUserDialog;
|
||||
168
web/src/components/CreateWebhookDialog.tsx
Normal file
168
web/src/components/CreateWebhookDialog.tsx
Normal file
@@ -0,0 +1,168 @@
|
||||
import { create } from "@bufbuild/protobuf";
|
||||
import { FieldMaskSchema } from "@bufbuild/protobuf/wkt";
|
||||
import React, { useEffect, useState } from "react";
|
||||
import { toast } from "react-hot-toast";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Dialog, DialogContent, DialogFooter, DialogHeader, DialogTitle } from "@/components/ui/dialog";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { userServiceClient } from "@/connect";
|
||||
import useCurrentUser from "@/hooks/useCurrentUser";
|
||||
import useLoading from "@/hooks/useLoading";
|
||||
import { handleError } from "@/lib/error";
|
||||
import { useTranslate } from "@/utils/i18n";
|
||||
|
||||
interface Props {
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
webhookName?: string;
|
||||
onSuccess?: () => void;
|
||||
}
|
||||
|
||||
interface State {
|
||||
displayName: string;
|
||||
url: string;
|
||||
}
|
||||
|
||||
function CreateWebhookDialog({ open, onOpenChange, webhookName, onSuccess }: Props) {
|
||||
const t = useTranslate();
|
||||
const currentUser = useCurrentUser();
|
||||
const [state, setState] = useState<State>({
|
||||
displayName: "",
|
||||
url: "",
|
||||
});
|
||||
const requestState = useLoading(false);
|
||||
const isCreating = webhookName === undefined;
|
||||
|
||||
useEffect(() => {
|
||||
if (webhookName && currentUser) {
|
||||
// For editing, we need to get the webhook data
|
||||
// Since we're using user webhooks now, we need to list all webhooks and find the one we want
|
||||
userServiceClient
|
||||
.listUserWebhooks({
|
||||
parent: currentUser.name,
|
||||
})
|
||||
.then((response) => {
|
||||
const webhook = response.webhooks.find((w) => w.name === webhookName);
|
||||
if (webhook) {
|
||||
setState({
|
||||
displayName: webhook.displayName,
|
||||
url: webhook.url,
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
}, [webhookName, currentUser]);
|
||||
|
||||
const setPartialState = (partialState: Partial<State>) => {
|
||||
setState({
|
||||
...state,
|
||||
...partialState,
|
||||
});
|
||||
};
|
||||
|
||||
const handleTitleInputChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
setPartialState({
|
||||
displayName: e.target.value,
|
||||
});
|
||||
};
|
||||
|
||||
const handleUrlInputChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
setPartialState({
|
||||
url: e.target.value,
|
||||
});
|
||||
};
|
||||
|
||||
const handleSaveBtnClick = async () => {
|
||||
if (!state.displayName || !state.url) {
|
||||
toast.error(t("message.fill-all-required-fields"));
|
||||
return;
|
||||
}
|
||||
|
||||
if (!currentUser) {
|
||||
toast.error("User not authenticated");
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
requestState.setLoading();
|
||||
if (isCreating) {
|
||||
await userServiceClient.createUserWebhook({
|
||||
parent: currentUser.name,
|
||||
webhook: {
|
||||
displayName: state.displayName,
|
||||
url: state.url,
|
||||
},
|
||||
});
|
||||
} else {
|
||||
await userServiceClient.updateUserWebhook({
|
||||
webhook: {
|
||||
name: webhookName,
|
||||
displayName: state.displayName,
|
||||
url: state.url,
|
||||
},
|
||||
updateMask: create(FieldMaskSchema, { paths: ["display_name", "url"] }),
|
||||
});
|
||||
}
|
||||
|
||||
onSuccess?.();
|
||||
onOpenChange(false);
|
||||
requestState.setFinish();
|
||||
} catch (error: unknown) {
|
||||
handleError(error, toast.error, {
|
||||
context: webhookName ? "Update webhook" : "Create webhook",
|
||||
onError: () => requestState.setError(),
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent className="max-w-md">
|
||||
<DialogHeader>
|
||||
<DialogTitle>
|
||||
{isCreating
|
||||
? t("setting.webhook-section.create-dialog.create-webhook")
|
||||
: t("setting.webhook-section.create-dialog.edit-webhook")}
|
||||
</DialogTitle>
|
||||
</DialogHeader>
|
||||
<div className="flex flex-col gap-4">
|
||||
<div className="grid gap-2">
|
||||
<Label htmlFor="displayName">
|
||||
{t("setting.webhook-section.create-dialog.title")} <span className="text-destructive">*</span>
|
||||
</Label>
|
||||
<Input
|
||||
id="displayName"
|
||||
type="text"
|
||||
placeholder={t("setting.webhook-section.create-dialog.an-easy-to-remember-name")}
|
||||
value={state.displayName}
|
||||
onChange={handleTitleInputChange}
|
||||
/>
|
||||
</div>
|
||||
<div className="grid gap-2">
|
||||
<Label htmlFor="url">
|
||||
{t("setting.webhook-section.create-dialog.payload-url")} <span className="text-destructive">*</span>
|
||||
</Label>
|
||||
<Input
|
||||
id="url"
|
||||
type="text"
|
||||
placeholder={t("setting.webhook-section.create-dialog.url-example-post-receive")}
|
||||
value={state.url}
|
||||
onChange={handleUrlInputChange}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button variant="ghost" disabled={requestState.isLoading} onClick={() => onOpenChange(false)}>
|
||||
{t("common.cancel")}
|
||||
</Button>
|
||||
<Button disabled={requestState.isLoading} onClick={handleSaveBtnClick}>
|
||||
{t("common.create")}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
|
||||
export default CreateWebhookDialog;
|
||||
43
web/src/components/DateTimeInput.tsx
Normal file
43
web/src/components/DateTimeInput.tsx
Normal file
@@ -0,0 +1,43 @@
|
||||
import dayjs from "dayjs";
|
||||
import toast from "react-hot-toast";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
// must be compatible with JS Date.parse(), we use ISO 8601 (almost)
|
||||
const DATE_TIME_FORMAT = "YYYY-MM-DD HH:mm:ss";
|
||||
|
||||
// convert Date to datetime string.
|
||||
const formatDate = (date: Date): string => {
|
||||
return dayjs(date).format(DATE_TIME_FORMAT);
|
||||
};
|
||||
|
||||
interface Props {
|
||||
value: Date;
|
||||
onChange: (date: Date) => void;
|
||||
}
|
||||
|
||||
const DateTimeInput: React.FC<Props> = ({ value, onChange }) => {
|
||||
return (
|
||||
<input
|
||||
type="datetime-local"
|
||||
className={cn("px-1 bg-transparent rounded text-xs transition-all", "border-transparent outline-none focus:border-border", "border")}
|
||||
defaultValue={formatDate(value)}
|
||||
onBlur={(e) => {
|
||||
const inputValue = e.target.value;
|
||||
if (inputValue) {
|
||||
// note: inputValue must be compatible with JS Date.parse()
|
||||
const date = dayjs(inputValue).toDate();
|
||||
// Check if the date is valid.
|
||||
if (!isNaN(date.getTime())) {
|
||||
onChange(date);
|
||||
} else {
|
||||
toast.error("Invalid datetime format. Use format: 2023-12-31 23:59:59");
|
||||
e.target.value = formatDate(value);
|
||||
}
|
||||
}
|
||||
}}
|
||||
placeholder={DATE_TIME_FORMAT}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
export default DateTimeInput;
|
||||
11
web/src/components/Empty.tsx
Normal file
11
web/src/components/Empty.tsx
Normal file
@@ -0,0 +1,11 @@
|
||||
import { BirdIcon } from "lucide-react";
|
||||
|
||||
const Empty = () => {
|
||||
return (
|
||||
<div className="mx-auto">
|
||||
<BirdIcon strokeWidth={0.5} absoluteStrokeWidth={true} className="w-24 h-auto text-muted-foreground" />
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default Empty;
|
||||
70
web/src/components/ErrorBoundary.tsx
Normal file
70
web/src/components/ErrorBoundary.tsx
Normal file
@@ -0,0 +1,70 @@
|
||||
import { AlertCircle, RefreshCw } from "lucide-react";
|
||||
import { Component, type ErrorInfo, type ReactNode } from "react";
|
||||
import { Button } from "./ui/button";
|
||||
|
||||
interface Props {
|
||||
children: ReactNode;
|
||||
fallback?: ReactNode;
|
||||
}
|
||||
|
||||
interface State {
|
||||
hasError: boolean;
|
||||
error: Error | null;
|
||||
}
|
||||
|
||||
export class ErrorBoundary extends Component<Props, State> {
|
||||
constructor(props: Props) {
|
||||
super(props);
|
||||
this.state = { hasError: false, error: null };
|
||||
}
|
||||
|
||||
static getDerivedStateFromError(error: Error): State {
|
||||
return { hasError: true, error };
|
||||
}
|
||||
|
||||
componentDidCatch(error: Error, errorInfo: ErrorInfo) {
|
||||
console.error("ErrorBoundary caught an error:", error, errorInfo);
|
||||
}
|
||||
|
||||
handleReset = () => {
|
||||
this.setState({ hasError: false, error: null });
|
||||
window.location.reload();
|
||||
};
|
||||
|
||||
render() {
|
||||
if (this.state.hasError) {
|
||||
if (this.props.fallback) {
|
||||
return this.props.fallback;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex items-center justify-center min-h-screen bg-background">
|
||||
<div className="max-w-md w-full p-6 space-y-4">
|
||||
<div className="flex items-center gap-3 text-destructive">
|
||||
<AlertCircle className="w-8 h-8" />
|
||||
<h1 className="text-2xl font-bold">Something went wrong</h1>
|
||||
</div>
|
||||
|
||||
<p className="text-foreground/70">
|
||||
An unexpected error occurred. This could be due to a network issue or a problem with the application.
|
||||
</p>
|
||||
|
||||
{this.state.error && (
|
||||
<details className="bg-muted p-3 rounded-md text-sm">
|
||||
<summary className="cursor-pointer font-medium mb-2">Error details</summary>
|
||||
<pre className="whitespace-pre-wrap break-words text-xs text-foreground/60">{this.state.error.message}</pre>
|
||||
</details>
|
||||
)}
|
||||
|
||||
<Button onClick={this.handleReset} className="w-full gap-2">
|
||||
<RefreshCw className="w-4 h-4" />
|
||||
Reload Application
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return this.props.children;
|
||||
}
|
||||
}
|
||||
232
web/src/components/Inbox/MemoCommentMessage.tsx
Normal file
232
web/src/components/Inbox/MemoCommentMessage.tsx
Normal file
@@ -0,0 +1,232 @@
|
||||
import { create } from "@bufbuild/protobuf";
|
||||
import { FieldMaskSchema, timestampDate } from "@bufbuild/protobuf/wkt";
|
||||
import { CheckIcon, MessageCircleIcon, TrashIcon, XIcon } from "lucide-react";
|
||||
import { useState } from "react";
|
||||
import toast from "react-hot-toast";
|
||||
import UserAvatar from "@/components/UserAvatar";
|
||||
import { activityServiceClient, memoServiceClient, userServiceClient } from "@/connect";
|
||||
import { activityNamePrefix } from "@/helpers/resource-names";
|
||||
import useAsyncEffect from "@/hooks/useAsyncEffect";
|
||||
import useNavigateTo from "@/hooks/useNavigateTo";
|
||||
import { useUser } from "@/hooks/useUserQueries";
|
||||
import { handleError } from "@/lib/error";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { Memo } from "@/types/proto/api/v1/memo_service_pb";
|
||||
import { UserNotification, UserNotification_Status } from "@/types/proto/api/v1/user_service_pb";
|
||||
import { useTranslate } from "@/utils/i18n";
|
||||
|
||||
interface Props {
|
||||
notification: UserNotification;
|
||||
}
|
||||
|
||||
function MemoCommentMessage({ notification }: Props) {
|
||||
const t = useTranslate();
|
||||
const navigateTo = useNavigateTo();
|
||||
const [relatedMemo, setRelatedMemo] = useState<Memo | undefined>(undefined);
|
||||
const [commentMemo, setCommentMemo] = useState<Memo | undefined>(undefined);
|
||||
const [senderName, setSenderName] = useState<string | undefined>(undefined);
|
||||
const [initialized, setInitialized] = useState<boolean>(false);
|
||||
const [hasError, setHasError] = useState<boolean>(false);
|
||||
|
||||
const { data: sender } = useUser(senderName || "", { enabled: !!senderName });
|
||||
|
||||
useAsyncEffect(async () => {
|
||||
if (!notification.activityId) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const activity = await activityServiceClient.getActivity({
|
||||
name: `${activityNamePrefix}${notification.activityId}`,
|
||||
});
|
||||
|
||||
if (activity.payload?.payload?.case === "memoComment") {
|
||||
const memoCommentPayload = activity.payload.payload.value;
|
||||
const memo = await memoServiceClient.getMemo({
|
||||
name: memoCommentPayload.relatedMemo,
|
||||
});
|
||||
setRelatedMemo(memo);
|
||||
|
||||
const comment = await memoServiceClient.getMemo({
|
||||
name: memoCommentPayload.memo,
|
||||
});
|
||||
setCommentMemo(comment);
|
||||
|
||||
setSenderName(notification.sender);
|
||||
setInitialized(true);
|
||||
}
|
||||
} catch (error) {
|
||||
handleError(error, () => {}, {
|
||||
context: "Failed to fetch activity",
|
||||
onError: () => setHasError(true),
|
||||
});
|
||||
return;
|
||||
}
|
||||
}, [notification.activityId]);
|
||||
|
||||
const handleNavigateToMemo = async () => {
|
||||
if (!relatedMemo) {
|
||||
return;
|
||||
}
|
||||
|
||||
navigateTo(`/${relatedMemo.name}`);
|
||||
if (notification.status === UserNotification_Status.UNREAD) {
|
||||
handleArchiveMessage(true);
|
||||
}
|
||||
};
|
||||
|
||||
const handleArchiveMessage = async (silence = false) => {
|
||||
await userServiceClient.updateUserNotification({
|
||||
notification: {
|
||||
name: notification.name,
|
||||
status: UserNotification_Status.ARCHIVED,
|
||||
},
|
||||
updateMask: create(FieldMaskSchema, { paths: ["status"] }),
|
||||
});
|
||||
if (!silence) {
|
||||
toast.success(t("message.archived-successfully"));
|
||||
}
|
||||
};
|
||||
|
||||
const handleDeleteMessage = async () => {
|
||||
await userServiceClient.deleteUserNotification({
|
||||
name: notification.name,
|
||||
});
|
||||
toast.success(t("message.deleted-successfully"));
|
||||
};
|
||||
|
||||
if (!initialized && !hasError) {
|
||||
return (
|
||||
<div className="w-full px-5 py-4 border-b border-border/60 last:border-b-0 bg-muted/10 animate-pulse">
|
||||
<div className="flex items-start gap-3">
|
||||
<div className="w-10 h-10 rounded-full bg-muted/50 shrink-0" />
|
||||
<div className="flex-1 space-y-3">
|
||||
<div className="h-4 bg-muted/50 rounded-md w-2/5" />
|
||||
<div className="h-3 bg-muted/40 rounded-md w-3/4" />
|
||||
<div className="h-20 bg-muted/30 rounded-xl" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (hasError) {
|
||||
return (
|
||||
<div className="w-full px-5 py-4 border-b border-border/60 last:border-b-0 bg-destructive/[0.04] group">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="w-10 h-10 rounded-full bg-destructive/15 flex items-center justify-center shrink-0 ring-1 ring-destructive/20">
|
||||
<XIcon className="w-5 h-5 text-destructive" strokeWidth={2} />
|
||||
</div>
|
||||
<span className="text-sm text-destructive/80 font-medium">{t("inbox.failed-to-load")}</span>
|
||||
</div>
|
||||
<button
|
||||
onClick={handleDeleteMessage}
|
||||
className="p-1.5 hover:bg-destructive/15 rounded-lg transition-all duration-150 opacity-0 group-hover:opacity-100"
|
||||
title={t("common.delete")}
|
||||
>
|
||||
<TrashIcon className="w-4 h-4 text-destructive/70 hover:text-destructive transition-colors" strokeWidth={2} />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const isUnread = notification.status === UserNotification_Status.UNREAD;
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"w-full px-5 py-4 border-b border-border/60 last:border-b-0 transition-all duration-200 group relative",
|
||||
isUnread ? "bg-primary/[0.03] hover:bg-primary/[0.05]" : "hover:bg-muted/30",
|
||||
)}
|
||||
>
|
||||
{/* Unread indicator bar */}
|
||||
{isUnread && <div className="absolute left-0 top-0 bottom-0 w-0.5 bg-gradient-to-b from-primary to-primary/60" />}
|
||||
|
||||
<div className="flex items-start gap-3">
|
||||
{/* Avatar & Icon */}
|
||||
<div className="relative shrink-0">
|
||||
<UserAvatar className="w-10 h-10 ring-1 ring-border/40" avatarUrl={sender?.avatarUrl} />
|
||||
<div
|
||||
className={cn(
|
||||
"absolute -bottom-1 -right-1 w-5 h-5 rounded-full border-2 border-background flex items-center justify-center shadow-md transition-all",
|
||||
isUnread ? "bg-primary text-primary-foreground" : "bg-muted/80 text-muted-foreground",
|
||||
)}
|
||||
>
|
||||
<MessageCircleIcon className="w-2.5 h-2.5" strokeWidth={2.5} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Content */}
|
||||
<div className="flex-1 min-w-0">
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between gap-3 mb-1">
|
||||
<div className="flex items-center gap-1.5 flex-wrap min-w-0">
|
||||
<span className="font-semibold text-sm text-foreground/95">{sender?.displayName || sender?.username}</span>
|
||||
<span className="text-sm text-muted-foreground/80">commented on your memo</span>
|
||||
<span className="text-xs text-muted-foreground/60">
|
||||
{notification.createTime &&
|
||||
timestampDate(notification.createTime)?.toLocaleDateString([], { month: "short", day: "numeric" })}{" "}
|
||||
at{" "}
|
||||
{notification.createTime &&
|
||||
timestampDate(notification.createTime)?.toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" })}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-1 shrink-0">
|
||||
{isUnread ? (
|
||||
<button
|
||||
onClick={() => handleArchiveMessage()}
|
||||
className="p-1.5 hover:bg-primary/10 rounded-lg transition-all duration-150 opacity-0 group-hover:opacity-100"
|
||||
title={t("common.archive")}
|
||||
>
|
||||
<CheckIcon className="w-4 h-4 text-muted-foreground hover:text-primary transition-colors" strokeWidth={2} />
|
||||
</button>
|
||||
) : (
|
||||
<button
|
||||
onClick={handleDeleteMessage}
|
||||
className="p-1.5 hover:bg-destructive/10 rounded-lg transition-all duration-150 opacity-0 group-hover:opacity-100"
|
||||
title={t("common.delete")}
|
||||
>
|
||||
<TrashIcon className="w-4 h-4 text-muted-foreground hover:text-destructive transition-colors" strokeWidth={2} />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Original Memo Snippet */}
|
||||
{relatedMemo && (
|
||||
<div className="pl-3 border-l-2 border-muted-foreground/20 mb-3">
|
||||
<p className="text-sm text-foreground/60 line-clamp-1 leading-relaxed">
|
||||
<span className="text-xs text-muted-foreground/50 font-medium mr-2 uppercase tracking-wide">Original:</span>
|
||||
{relatedMemo.content || <span className="italic text-muted-foreground/40">Empty memo</span>}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Comment Preview */}
|
||||
{commentMemo && (
|
||||
<div
|
||||
onClick={handleNavigateToMemo}
|
||||
className="p-2 sm:p-3 rounded-lg bg-gradient-to-br from-primary/[0.06] to-primary/[0.03] hover:from-primary/[0.1] hover:to-primary/[0.06] cursor-pointer border border-primary/30 hover:border-primary/50 transition-all duration-200 group/comment shadow-sm hover:shadow"
|
||||
>
|
||||
<div className="flex items-start gap-2">
|
||||
<div className="w-5 h-5 flex items-center justify-center shrink-0">
|
||||
<MessageCircleIcon className="w-4 h-4 text-primary" />
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className="text-xs text-primary/60 font-semibold mb-1 uppercase tracking-wider">Comment</p>
|
||||
<p className="text-sm text-foreground/90 line-clamp-2">
|
||||
{commentMemo.content || <span className="italic text-muted-foreground/50">Empty comment</span>}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default MemoCommentMessage;
|
||||
31
web/src/components/LearnMore.tsx
Normal file
31
web/src/components/LearnMore.tsx
Normal file
@@ -0,0 +1,31 @@
|
||||
import { ExternalLinkIcon } from "lucide-react";
|
||||
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/components/ui/tooltip";
|
||||
import { useTranslate } from "@/utils/i18n";
|
||||
|
||||
interface Props {
|
||||
className?: string;
|
||||
url: string;
|
||||
title?: string;
|
||||
}
|
||||
|
||||
const LearnMore: React.FC<Props> = (props: Props) => {
|
||||
const { className, url, title } = props;
|
||||
const t = useTranslate();
|
||||
|
||||
return (
|
||||
<TooltipProvider>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<a className={`text-muted-foreground hover:text-primary ${className}`} href={url} target="_blank">
|
||||
<ExternalLinkIcon className="w-4 h-auto" />
|
||||
</a>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
<p>{title ?? t("common.learn-more")}</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
);
|
||||
};
|
||||
|
||||
export default LearnMore;
|
||||
41
web/src/components/LocaleSelect.tsx
Normal file
41
web/src/components/LocaleSelect.tsx
Normal file
@@ -0,0 +1,41 @@
|
||||
import { GlobeIcon } from "lucide-react";
|
||||
import { FC } from "react";
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
||||
import { locales } from "@/i18n";
|
||||
import { getLocaleDisplayName, loadLocale } from "@/utils/i18n";
|
||||
|
||||
interface Props {
|
||||
value: Locale;
|
||||
onChange: (locale: Locale) => void;
|
||||
}
|
||||
|
||||
const LocaleSelect: FC<Props> = (props: Props) => {
|
||||
const { onChange, value } = props;
|
||||
|
||||
const handleSelectChange = async (locale: Locale) => {
|
||||
// Apply locale globally immediately
|
||||
loadLocale(locale);
|
||||
// Also notify parent component
|
||||
onChange(locale);
|
||||
};
|
||||
|
||||
return (
|
||||
<Select value={value} onValueChange={handleSelectChange}>
|
||||
<SelectTrigger>
|
||||
<div className="flex items-center gap-2">
|
||||
<GlobeIcon className="w-4 h-auto" />
|
||||
<SelectValue placeholder="Select language" />
|
||||
</div>
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{locales.map((locale) => (
|
||||
<SelectItem key={locale} value={locale}>
|
||||
{getLocaleDisplayName(locale)}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
);
|
||||
};
|
||||
|
||||
export default LocaleSelect;
|
||||
34
web/src/components/MasonryView/MasonryColumn.tsx
Normal file
34
web/src/components/MasonryView/MasonryColumn.tsx
Normal file
@@ -0,0 +1,34 @@
|
||||
import { MasonryItem } from "./MasonryItem";
|
||||
import { MasonryColumnProps } from "./types";
|
||||
|
||||
export function MasonryColumn({
|
||||
memoIndices,
|
||||
memoList,
|
||||
renderer,
|
||||
renderContext,
|
||||
onHeightChange,
|
||||
isFirstColumn,
|
||||
prefixElement,
|
||||
prefixElementRef,
|
||||
}: MasonryColumnProps) {
|
||||
return (
|
||||
<div className="min-w-0 mx-auto w-full max-w-2xl">
|
||||
{/* Prefix element (like memo editor) goes in first column */}
|
||||
{isFirstColumn && prefixElement && <div ref={prefixElementRef}>{prefixElement}</div>}
|
||||
|
||||
{/* Render all memos assigned to this column */}
|
||||
{memoIndices?.map((memoIndex) => {
|
||||
const memo = memoList[memoIndex];
|
||||
return memo ? (
|
||||
<MasonryItem
|
||||
key={`${memo.name}-${memo.displayTime}`}
|
||||
memo={memo}
|
||||
renderer={renderer}
|
||||
renderContext={renderContext}
|
||||
onHeightChange={onHeightChange}
|
||||
/>
|
||||
) : null;
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
32
web/src/components/MasonryView/MasonryItem.tsx
Normal file
32
web/src/components/MasonryView/MasonryItem.tsx
Normal file
@@ -0,0 +1,32 @@
|
||||
import { useEffect, useRef } from "react";
|
||||
import { MasonryItemProps } from "./types";
|
||||
|
||||
export function MasonryItem({ memo, renderer, renderContext, onHeightChange }: MasonryItemProps) {
|
||||
const itemRef = useRef<HTMLDivElement>(null);
|
||||
const resizeObserverRef = useRef<ResizeObserver | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (!itemRef.current) return;
|
||||
|
||||
const measureHeight = () => {
|
||||
if (itemRef.current) {
|
||||
const height = itemRef.current.offsetHeight;
|
||||
onHeightChange(memo.name, height);
|
||||
}
|
||||
};
|
||||
|
||||
// Initial measurement
|
||||
measureHeight();
|
||||
|
||||
// Set up ResizeObserver to track dynamic content changes
|
||||
resizeObserverRef.current = new ResizeObserver(measureHeight);
|
||||
resizeObserverRef.current.observe(itemRef.current);
|
||||
|
||||
// Cleanup on unmount
|
||||
return () => {
|
||||
resizeObserverRef.current?.disconnect();
|
||||
};
|
||||
}, [memo.name, onHeightChange]);
|
||||
|
||||
return <div ref={itemRef}>{renderer(memo, renderContext)}</div>;
|
||||
}
|
||||
47
web/src/components/MasonryView/MasonryView.tsx
Normal file
47
web/src/components/MasonryView/MasonryView.tsx
Normal file
@@ -0,0 +1,47 @@
|
||||
import { useMemo, useRef } from "react";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { MasonryColumn } from "./MasonryColumn";
|
||||
import { MasonryViewProps, MemoRenderContext } from "./types";
|
||||
import { useMasonryLayout } from "./useMasonryLayout";
|
||||
|
||||
const MasonryView = ({ memoList, renderer, prefixElement, listMode = false }: MasonryViewProps) => {
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
const prefixElementRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
const { columns, distribution, handleHeightChange } = useMasonryLayout(memoList, listMode, containerRef, prefixElementRef);
|
||||
|
||||
// Create render context: always enable compact mode for list views
|
||||
const renderContext: MemoRenderContext = useMemo(
|
||||
() => ({
|
||||
compact: true,
|
||||
columns,
|
||||
}),
|
||||
[columns],
|
||||
);
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={containerRef}
|
||||
className={cn("w-full grid gap-2")}
|
||||
style={{
|
||||
gridTemplateColumns: `repeat(${columns}, 1fr)`,
|
||||
}}
|
||||
>
|
||||
{Array.from({ length: columns }).map((_, columnIndex) => (
|
||||
<MasonryColumn
|
||||
key={columnIndex}
|
||||
memoIndices={distribution[columnIndex] || []}
|
||||
memoList={memoList}
|
||||
renderer={renderer}
|
||||
renderContext={renderContext}
|
||||
onHeightChange={handleHeightChange}
|
||||
isFirstColumn={columnIndex === 0}
|
||||
prefixElement={prefixElement}
|
||||
prefixElementRef={prefixElementRef}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default MasonryView;
|
||||
116
web/src/components/MasonryView/README.md
Normal file
116
web/src/components/MasonryView/README.md
Normal file
@@ -0,0 +1,116 @@
|
||||
# MasonryView - Height-Based Masonry Layout
|
||||
|
||||
## Overview
|
||||
|
||||
This improved MasonryView component implements a true masonry layout that distributes memo cards based on their actual rendered heights, creating a balanced waterfall-style layout instead of naive sequential distribution.
|
||||
|
||||
## Key Features
|
||||
|
||||
### 1. Height Measurement
|
||||
|
||||
- **MemoItem Wrapper**: Each memo is wrapped in a `MemoItem` component that measures its actual height
|
||||
- **ResizeObserver**: Automatically detects height changes when content changes (e.g., images load, content expands)
|
||||
- **Real-time Updates**: Heights are measured on mount and updated dynamically
|
||||
|
||||
### 2. Smart Distribution Algorithm
|
||||
|
||||
- **Shortest Column First**: Memos are assigned to the column with the smallest total height
|
||||
- **Dynamic Balancing**: As new memos are added or heights change, the layout rebalances
|
||||
- **Prefix Element Support**: Properly accounts for the MemoEditor height in the first column
|
||||
|
||||
### 3. Performance Optimizations
|
||||
|
||||
- **Memoized Callbacks**: `handleHeightChange` is memoized to prevent unnecessary re-renders
|
||||
- **Efficient State Updates**: Only redistributes when necessary (memo list changes, column count changes)
|
||||
- **ResizeObserver Cleanup**: Properly disconnects observers to prevent memory leaks
|
||||
|
||||
## Architecture
|
||||
|
||||
```
|
||||
MasonryView
|
||||
├── State Management
|
||||
│ ├── columns: number of columns based on viewport width
|
||||
│ ├── itemHeights: Map<memoName, height> for each memo
|
||||
│ ├── columnHeights: current total height of each column
|
||||
│ └── distribution: which memos belong to which column
|
||||
├── MemoItem (for each memo)
|
||||
│ ├── Ref for height measurement
|
||||
│ ├── ResizeObserver for dynamic updates
|
||||
│ └── Callback to parent on height changes
|
||||
└── Distribution Algorithm
|
||||
├── Finds shortest column
|
||||
├── Assigns memo to that column
|
||||
└── Updates column height tracking
|
||||
```
|
||||
|
||||
## Usage
|
||||
|
||||
The component maintains the same API as before, so no changes are needed in consuming components:
|
||||
|
||||
```tsx
|
||||
<MasonryView memoList={memos} renderer={(memo) => <MemoView memo={memo} />} prefixElement={<MemoEditor />} listMode={false} />
|
||||
```
|
||||
|
||||
## Benefits vs Previous Implementation
|
||||
|
||||
### Before (Naive)
|
||||
|
||||
- Distributed memos by index: `memo[i % columns]`
|
||||
- No consideration of actual heights
|
||||
- Resulted in unbalanced columns
|
||||
- Static layout that didn't adapt to content
|
||||
|
||||
### After (Height-Based)
|
||||
|
||||
- Distributes memos by actual rendered height
|
||||
- Creates balanced columns with similar total heights
|
||||
- Adapts to dynamic content changes
|
||||
- Smoother visual layout
|
||||
|
||||
## Technical Implementation Details
|
||||
|
||||
### Height Measurement
|
||||
|
||||
```tsx
|
||||
const measureHeight = () => {
|
||||
if (itemRef.current) {
|
||||
const height = itemRef.current.offsetHeight;
|
||||
onHeightChange(memo.name, height);
|
||||
}
|
||||
};
|
||||
```
|
||||
|
||||
### Distribution Algorithm
|
||||
|
||||
```tsx
|
||||
const shortestColumnIndex = columnHeights.reduce(
|
||||
(minIndex, currentHeight, currentIndex) => (currentHeight < columnHeights[minIndex] ? currentIndex : minIndex),
|
||||
0,
|
||||
);
|
||||
```
|
||||
|
||||
### Dynamic Updates
|
||||
|
||||
- **Window Resize**: Recalculates column count and redistributes
|
||||
- **Content Changes**: ResizeObserver triggers height remeasurement
|
||||
- **Memo List Changes**: Redistributes all memos with new ordering
|
||||
|
||||
## Browser Support
|
||||
|
||||
- Modern browsers with ResizeObserver support
|
||||
- Fallback behavior: Falls back to sequential distribution if ResizeObserver is not available
|
||||
- CSS Grid support required for column layout
|
||||
|
||||
## Performance Considerations
|
||||
|
||||
1. **Initial Load**: Slight delay as heights are measured
|
||||
2. **Memory Usage**: Stores height data for each memo
|
||||
3. **Re-renders**: Optimized to only update when necessary
|
||||
4. **Large Lists**: Scales well with proper virtualization (if needed in future)
|
||||
|
||||
## Future Enhancements
|
||||
|
||||
1. **Virtualization**: For very large memo lists
|
||||
2. **Animation**: Smooth transitions when items change position
|
||||
3. **Gap Optimization**: More sophisticated gap handling
|
||||
4. **Estimated Heights**: Faster initial layout with height estimation
|
||||
3
web/src/components/MasonryView/constants.ts
Normal file
3
web/src/components/MasonryView/constants.ts
Normal file
@@ -0,0 +1,3 @@
|
||||
export const MINIMUM_MEMO_VIEWPORT_WIDTH = 512;
|
||||
|
||||
export const REDISTRIBUTION_DEBOUNCE_MS = 100;
|
||||
68
web/src/components/MasonryView/distributeItems.ts
Normal file
68
web/src/components/MasonryView/distributeItems.ts
Normal file
@@ -0,0 +1,68 @@
|
||||
import { Memo } from "@/types/proto/api/v1/memo_service_pb";
|
||||
import { DistributionResult } from "./types";
|
||||
|
||||
export function distributeItemsToColumns(
|
||||
memos: Memo[],
|
||||
columns: number,
|
||||
itemHeights: Map<string, number>,
|
||||
prefixElementHeight: number = 0,
|
||||
): DistributionResult {
|
||||
if (columns === 1) {
|
||||
const totalHeight = memos.reduce((sum, memo) => sum + (itemHeights.get(memo.name) || 0), prefixElementHeight);
|
||||
return {
|
||||
distribution: [Array.from({ length: memos.length }, (_, i) => i)],
|
||||
columnHeights: [totalHeight],
|
||||
};
|
||||
}
|
||||
|
||||
const distribution: number[][] = Array.from({ length: columns }, () => []);
|
||||
const columnHeights: number[] = Array(columns).fill(0);
|
||||
const columnCounts: number[] = Array(columns).fill(0);
|
||||
|
||||
if (prefixElementHeight > 0) {
|
||||
columnHeights[0] = prefixElementHeight;
|
||||
}
|
||||
|
||||
let startIndex = 0;
|
||||
|
||||
if (memos.length > 0) {
|
||||
const firstMemoHeight = itemHeights.get(memos[0].name) || 0;
|
||||
distribution[0].push(0);
|
||||
columnHeights[0] += firstMemoHeight;
|
||||
columnCounts[0] += 1;
|
||||
startIndex = 1;
|
||||
}
|
||||
|
||||
for (let i = startIndex; i < memos.length; i++) {
|
||||
const memo = memos[i];
|
||||
const height = itemHeights.get(memo.name) || 0;
|
||||
|
||||
const shortestColumnIndex = findShortestColumnIndex(columnHeights, columnCounts);
|
||||
|
||||
distribution[shortestColumnIndex].push(i);
|
||||
columnHeights[shortestColumnIndex] += height;
|
||||
columnCounts[shortestColumnIndex] += 1;
|
||||
}
|
||||
|
||||
return { distribution, columnHeights };
|
||||
}
|
||||
|
||||
function findShortestColumnIndex(columnHeights: number[], columnCounts: number[]): number {
|
||||
let minIndex = 0;
|
||||
let minHeight = columnHeights[0];
|
||||
|
||||
for (let i = 1; i < columnHeights.length; i++) {
|
||||
const currentHeight = columnHeights[i];
|
||||
if (currentHeight < minHeight) {
|
||||
minHeight = currentHeight;
|
||||
minIndex = i;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (currentHeight === minHeight && columnCounts[i] < columnCounts[minIndex]) {
|
||||
minIndex = i;
|
||||
}
|
||||
}
|
||||
|
||||
return minIndex;
|
||||
}
|
||||
22
web/src/components/MasonryView/index.ts
Normal file
22
web/src/components/MasonryView/index.ts
Normal file
@@ -0,0 +1,22 @@
|
||||
// Main component
|
||||
|
||||
// Constants
|
||||
export { MINIMUM_MEMO_VIEWPORT_WIDTH, REDISTRIBUTION_DEBOUNCE_MS } from "./constants";
|
||||
// Utilities
|
||||
export { distributeItemsToColumns } from "./distributeItems";
|
||||
// Sub-components (exported for testing or advanced usage)
|
||||
export { MasonryColumn } from "./MasonryColumn";
|
||||
export { MasonryItem } from "./MasonryItem";
|
||||
export { default } from "./MasonryView";
|
||||
|
||||
// Types
|
||||
export type {
|
||||
DistributionResult,
|
||||
MasonryColumnProps,
|
||||
MasonryItemProps,
|
||||
MasonryViewProps,
|
||||
MemoRenderContext,
|
||||
MemoWithHeight,
|
||||
} from "./types";
|
||||
// Hooks
|
||||
export { useMasonryLayout } from "./useMasonryLayout";
|
||||
41
web/src/components/MasonryView/types.ts
Normal file
41
web/src/components/MasonryView/types.ts
Normal file
@@ -0,0 +1,41 @@
|
||||
import { Memo } from "@/types/proto/api/v1/memo_service_pb";
|
||||
|
||||
export interface MemoRenderContext {
|
||||
compact: boolean;
|
||||
columns: number;
|
||||
}
|
||||
|
||||
export interface MasonryViewProps {
|
||||
memoList: Memo[];
|
||||
renderer: (memo: Memo, context?: MemoRenderContext) => JSX.Element;
|
||||
prefixElement?: JSX.Element;
|
||||
listMode?: boolean;
|
||||
}
|
||||
|
||||
export interface MasonryItemProps {
|
||||
memo: Memo;
|
||||
renderer: (memo: Memo, context?: MemoRenderContext) => JSX.Element;
|
||||
renderContext: MemoRenderContext;
|
||||
onHeightChange: (memoName: string, height: number) => void;
|
||||
}
|
||||
|
||||
export interface MasonryColumnProps {
|
||||
memoIndices: number[];
|
||||
memoList: Memo[];
|
||||
renderer: (memo: Memo, context?: MemoRenderContext) => JSX.Element;
|
||||
renderContext: MemoRenderContext;
|
||||
onHeightChange: (memoName: string, height: number) => void;
|
||||
isFirstColumn: boolean;
|
||||
prefixElement?: JSX.Element;
|
||||
prefixElementRef?: React.RefObject<HTMLDivElement>;
|
||||
}
|
||||
|
||||
export interface DistributionResult {
|
||||
distribution: number[][];
|
||||
columnHeights: number[];
|
||||
}
|
||||
|
||||
export interface MemoWithHeight {
|
||||
index: number;
|
||||
height: number;
|
||||
}
|
||||
106
web/src/components/MasonryView/useMasonryLayout.ts
Normal file
106
web/src/components/MasonryView/useMasonryLayout.ts
Normal file
@@ -0,0 +1,106 @@
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
import { Memo } from "@/types/proto/api/v1/memo_service_pb";
|
||||
import { MINIMUM_MEMO_VIEWPORT_WIDTH, REDISTRIBUTION_DEBOUNCE_MS } from "./constants";
|
||||
import { distributeItemsToColumns } from "./distributeItems";
|
||||
|
||||
export function useMasonryLayout(
|
||||
memoList: Memo[],
|
||||
listMode: boolean,
|
||||
containerRef: React.RefObject<HTMLDivElement>,
|
||||
prefixElementRef: React.RefObject<HTMLDivElement>,
|
||||
) {
|
||||
const [columns, setColumns] = useState(1);
|
||||
const [itemHeights, setItemHeights] = useState<Map<string, number>>(new Map());
|
||||
const [distribution, setDistribution] = useState<number[][]>([[]]);
|
||||
|
||||
const redistributionTimeoutRef = useRef<number | null>(null);
|
||||
const itemHeightsRef = useRef<Map<string, number>>(itemHeights);
|
||||
|
||||
useEffect(() => {
|
||||
itemHeightsRef.current = itemHeights;
|
||||
}, [itemHeights]);
|
||||
|
||||
const calculateColumns = useCallback(() => {
|
||||
if (!containerRef.current || listMode) return 1;
|
||||
|
||||
const containerWidth = containerRef.current.offsetWidth;
|
||||
const scale = containerWidth / MINIMUM_MEMO_VIEWPORT_WIDTH;
|
||||
return scale >= 1.2 ? Math.ceil(scale) : 1;
|
||||
}, [containerRef, listMode]);
|
||||
|
||||
const redistributeMemos = useCallback(() => {
|
||||
const prefixHeight = prefixElementRef.current?.offsetHeight || 0;
|
||||
setDistribution(() => {
|
||||
const { distribution: newDistribution } = distributeItemsToColumns(memoList, columns, itemHeightsRef.current, prefixHeight);
|
||||
return newDistribution;
|
||||
});
|
||||
}, [memoList, columns, prefixElementRef]);
|
||||
|
||||
const debouncedRedistribute = useCallback(
|
||||
(newItemHeights: Map<string, number>) => {
|
||||
if (redistributionTimeoutRef.current) {
|
||||
clearTimeout(redistributionTimeoutRef.current);
|
||||
}
|
||||
|
||||
redistributionTimeoutRef.current = window.setTimeout(() => {
|
||||
const prefixHeight = prefixElementRef.current?.offsetHeight || 0;
|
||||
setDistribution(() => {
|
||||
const { distribution: newDistribution } = distributeItemsToColumns(memoList, columns, newItemHeights, prefixHeight);
|
||||
return newDistribution;
|
||||
});
|
||||
}, REDISTRIBUTION_DEBOUNCE_MS);
|
||||
},
|
||||
[memoList, columns, prefixElementRef],
|
||||
);
|
||||
|
||||
const handleHeightChange = useCallback(
|
||||
(memoName: string, height: number) => {
|
||||
setItemHeights((prevHeights) => {
|
||||
const newItemHeights = new Map(prevHeights);
|
||||
const previousHeight = prevHeights.get(memoName);
|
||||
|
||||
if (previousHeight === height) {
|
||||
return prevHeights;
|
||||
}
|
||||
|
||||
newItemHeights.set(memoName, height);
|
||||
debouncedRedistribute(newItemHeights);
|
||||
return newItemHeights;
|
||||
});
|
||||
},
|
||||
[debouncedRedistribute],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
const handleResize = () => {
|
||||
if (!containerRef.current) return;
|
||||
|
||||
const newColumns = calculateColumns();
|
||||
if (newColumns !== columns) {
|
||||
setColumns(newColumns);
|
||||
}
|
||||
};
|
||||
|
||||
handleResize();
|
||||
window.addEventListener("resize", handleResize);
|
||||
return () => window.removeEventListener("resize", handleResize);
|
||||
}, [calculateColumns, columns, containerRef]);
|
||||
|
||||
useEffect(() => {
|
||||
redistributeMemos();
|
||||
}, [columns, memoList, redistributeMemos]);
|
||||
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
if (redistributionTimeoutRef.current) {
|
||||
clearTimeout(redistributionTimeoutRef.current);
|
||||
}
|
||||
};
|
||||
}, []);
|
||||
|
||||
return {
|
||||
columns,
|
||||
distribution,
|
||||
handleHeightChange,
|
||||
};
|
||||
}
|
||||
135
web/src/components/MemoActionMenu/MemoActionMenu.tsx
Normal file
135
web/src/components/MemoActionMenu/MemoActionMenu.tsx
Normal file
@@ -0,0 +1,135 @@
|
||||
import {
|
||||
ArchiveIcon,
|
||||
ArchiveRestoreIcon,
|
||||
BookmarkMinusIcon,
|
||||
BookmarkPlusIcon,
|
||||
CopyIcon,
|
||||
Edit3Icon,
|
||||
FileTextIcon,
|
||||
LinkIcon,
|
||||
MoreVerticalIcon,
|
||||
TrashIcon,
|
||||
} from "lucide-react";
|
||||
import { useState } from "react";
|
||||
import ConfirmDialog from "@/components/ConfirmDialog";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuSub,
|
||||
DropdownMenuSubContent,
|
||||
DropdownMenuSubTrigger,
|
||||
DropdownMenuTrigger,
|
||||
} from "@/components/ui/dropdown-menu";
|
||||
import { State } from "@/types/proto/api/v1/common_pb";
|
||||
import { useTranslate } from "@/utils/i18n";
|
||||
import { useMemoActionHandlers } from "./hooks";
|
||||
import type { MemoActionMenuProps } from "./types";
|
||||
|
||||
const MemoActionMenu = (props: MemoActionMenuProps) => {
|
||||
const { memo, readonly } = props;
|
||||
const t = useTranslate();
|
||||
|
||||
// Dialog state
|
||||
const [deleteDialogOpen, setDeleteDialogOpen] = useState(false);
|
||||
|
||||
// Derived state
|
||||
const isComment = Boolean(memo.parent);
|
||||
const isArchived = memo.state === State.ARCHIVED;
|
||||
|
||||
// Action handlers
|
||||
const {
|
||||
handleTogglePinMemoBtnClick,
|
||||
handleEditMemoClick,
|
||||
handleToggleMemoStatusClick,
|
||||
handleCopyLink,
|
||||
handleCopyContent,
|
||||
handleDeleteMemoClick,
|
||||
confirmDeleteMemo,
|
||||
} = useMemoActionHandlers({
|
||||
memo,
|
||||
onEdit: props.onEdit,
|
||||
setDeleteDialogOpen,
|
||||
});
|
||||
|
||||
return (
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button variant="ghost" size="icon" className="size-4">
|
||||
<MoreVerticalIcon className="text-muted-foreground" />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end" sideOffset={2}>
|
||||
{/* Edit actions (non-readonly, non-archived) */}
|
||||
{!readonly && !isArchived && (
|
||||
<>
|
||||
{!isComment && (
|
||||
<DropdownMenuItem onClick={handleTogglePinMemoBtnClick}>
|
||||
{memo.pinned ? <BookmarkMinusIcon className="w-4 h-auto" /> : <BookmarkPlusIcon className="w-4 h-auto" />}
|
||||
{memo.pinned ? t("common.unpin") : t("common.pin")}
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
<DropdownMenuItem onClick={handleEditMemoClick}>
|
||||
<Edit3Icon className="w-4 h-auto" />
|
||||
{t("common.edit")}
|
||||
</DropdownMenuItem>
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* Copy submenu (non-archived) */}
|
||||
{!isArchived && (
|
||||
<DropdownMenuSub>
|
||||
<DropdownMenuSubTrigger>
|
||||
<CopyIcon className="w-4 h-auto" />
|
||||
{t("common.copy")}
|
||||
</DropdownMenuSubTrigger>
|
||||
<DropdownMenuSubContent>
|
||||
<DropdownMenuItem onClick={handleCopyLink}>
|
||||
<LinkIcon className="w-4 h-auto" />
|
||||
{t("memo.copy-link")}
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem onClick={handleCopyContent}>
|
||||
<FileTextIcon className="w-4 h-auto" />
|
||||
{t("memo.copy-content")}
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuSubContent>
|
||||
</DropdownMenuSub>
|
||||
)}
|
||||
|
||||
{/* Write actions (non-readonly) */}
|
||||
{!readonly && (
|
||||
<>
|
||||
{/* Archive/Restore (non-comment) */}
|
||||
{!isComment && (
|
||||
<DropdownMenuItem onClick={handleToggleMemoStatusClick}>
|
||||
{isArchived ? <ArchiveRestoreIcon className="w-4 h-auto" /> : <ArchiveIcon className="w-4 h-auto" />}
|
||||
{isArchived ? t("common.restore") : t("common.archive")}
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
|
||||
{/* Delete */}
|
||||
<DropdownMenuItem onClick={handleDeleteMemoClick}>
|
||||
<TrashIcon className="w-4 h-auto" />
|
||||
{t("common.delete")}
|
||||
</DropdownMenuItem>
|
||||
</>
|
||||
)}
|
||||
</DropdownMenuContent>
|
||||
|
||||
{/* Delete confirmation dialog */}
|
||||
<ConfirmDialog
|
||||
open={deleteDialogOpen}
|
||||
onOpenChange={setDeleteDialogOpen}
|
||||
title={t("memo.delete-confirm")}
|
||||
confirmLabel={t("common.delete")}
|
||||
description={t("memo.delete-confirm-description")}
|
||||
cancelLabel={t("common.cancel")}
|
||||
onConfirm={confirmDeleteMemo}
|
||||
confirmVariant="destructive"
|
||||
/>
|
||||
</DropdownMenu>
|
||||
);
|
||||
};
|
||||
|
||||
export default MemoActionMenu;
|
||||
126
web/src/components/MemoActionMenu/hooks.ts
Normal file
126
web/src/components/MemoActionMenu/hooks.ts
Normal file
@@ -0,0 +1,126 @@
|
||||
import { useQueryClient } from "@tanstack/react-query";
|
||||
import copy from "copy-to-clipboard";
|
||||
import { useCallback } from "react";
|
||||
import toast from "react-hot-toast";
|
||||
import { useLocation } from "react-router-dom";
|
||||
import { useInstance } from "@/contexts/InstanceContext";
|
||||
import { memoKeys, useDeleteMemo, useUpdateMemo } from "@/hooks/useMemoQueries";
|
||||
import useNavigateTo from "@/hooks/useNavigateTo";
|
||||
import { userKeys } from "@/hooks/useUserQueries";
|
||||
import { handleError } from "@/lib/error";
|
||||
import { State } from "@/types/proto/api/v1/common_pb";
|
||||
import type { Memo } from "@/types/proto/api/v1/memo_service_pb";
|
||||
import { useTranslate } from "@/utils/i18n";
|
||||
|
||||
interface UseMemoActionHandlersOptions {
|
||||
memo: Memo;
|
||||
onEdit?: () => void;
|
||||
setDeleteDialogOpen: (open: boolean) => void;
|
||||
}
|
||||
|
||||
export const useMemoActionHandlers = ({ memo, onEdit, setDeleteDialogOpen }: UseMemoActionHandlersOptions) => {
|
||||
const t = useTranslate();
|
||||
const location = useLocation();
|
||||
const navigateTo = useNavigateTo();
|
||||
const queryClient = useQueryClient();
|
||||
const { profile } = useInstance();
|
||||
const { mutateAsync: updateMemo } = useUpdateMemo();
|
||||
const { mutateAsync: deleteMemo } = useDeleteMemo();
|
||||
const isInMemoDetailPage = location.pathname.startsWith(`/${memo.name}`);
|
||||
|
||||
const memoUpdatedCallback = useCallback(() => {
|
||||
// Invalidate user stats to trigger refetch
|
||||
queryClient.invalidateQueries({ queryKey: userKeys.stats() });
|
||||
}, [queryClient]);
|
||||
|
||||
const handleTogglePinMemoBtnClick = useCallback(async () => {
|
||||
try {
|
||||
await updateMemo({
|
||||
update: {
|
||||
name: memo.name,
|
||||
pinned: !memo.pinned,
|
||||
},
|
||||
updateMask: ["pinned"],
|
||||
});
|
||||
} catch {
|
||||
// do nothing
|
||||
}
|
||||
}, [memo.name, memo.pinned, updateMemo]);
|
||||
|
||||
const handleEditMemoClick = useCallback(() => {
|
||||
onEdit?.();
|
||||
}, [onEdit]);
|
||||
|
||||
const handleToggleMemoStatusClick = useCallback(async () => {
|
||||
const isArchiving = memo.state !== State.ARCHIVED;
|
||||
const state = memo.state === State.ARCHIVED ? State.NORMAL : State.ARCHIVED;
|
||||
const message = memo.state === State.ARCHIVED ? t("message.restored-successfully") : t("message.archived-successfully");
|
||||
|
||||
try {
|
||||
await updateMemo({
|
||||
update: {
|
||||
name: memo.name,
|
||||
state,
|
||||
},
|
||||
updateMask: ["state"],
|
||||
});
|
||||
toast.success(message);
|
||||
} catch (error: unknown) {
|
||||
handleError(error, toast.error, {
|
||||
context: `${isArchiving ? "Archive" : "Restore"} memo`,
|
||||
fallbackMessage: "An error occurred",
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (isInMemoDetailPage) {
|
||||
navigateTo(memo.state === State.ARCHIVED ? "/" : "/archived");
|
||||
}
|
||||
memoUpdatedCallback();
|
||||
}, [memo.name, memo.state, t, isInMemoDetailPage, navigateTo, memoUpdatedCallback, updateMemo]);
|
||||
|
||||
const handleCopyLink = useCallback(() => {
|
||||
let host = profile.instanceUrl;
|
||||
if (host === "") {
|
||||
host = window.location.origin;
|
||||
}
|
||||
copy(`${host}/${memo.name}`);
|
||||
toast.success(t("message.succeed-copy-link"));
|
||||
}, [memo.name, t, profile.instanceUrl]);
|
||||
|
||||
const handleCopyContent = useCallback(() => {
|
||||
copy(memo.content);
|
||||
toast.success(t("message.succeed-copy-content"));
|
||||
}, [memo.content, t]);
|
||||
|
||||
const handleDeleteMemoClick = useCallback(() => {
|
||||
setDeleteDialogOpen(true);
|
||||
}, [setDeleteDialogOpen]);
|
||||
|
||||
const confirmDeleteMemo = useCallback(async () => {
|
||||
try {
|
||||
await deleteMemo(memo.name);
|
||||
} catch (error: unknown) {
|
||||
handleError(error, toast.error, { context: "Delete memo", fallbackMessage: "An error occurred" });
|
||||
return;
|
||||
}
|
||||
toast.success(t("message.deleted-successfully"));
|
||||
if (memo.parent) {
|
||||
queryClient.invalidateQueries({ queryKey: memoKeys.comments(memo.parent) });
|
||||
}
|
||||
if (isInMemoDetailPage) {
|
||||
navigateTo("/");
|
||||
}
|
||||
memoUpdatedCallback();
|
||||
}, [memo.name, memo.parent, t, isInMemoDetailPage, navigateTo, memoUpdatedCallback, deleteMemo, queryClient]);
|
||||
|
||||
return {
|
||||
handleTogglePinMemoBtnClick,
|
||||
handleEditMemoClick,
|
||||
handleToggleMemoStatusClick,
|
||||
handleCopyLink,
|
||||
handleCopyContent,
|
||||
handleDeleteMemoClick,
|
||||
confirmDeleteMemo,
|
||||
};
|
||||
};
|
||||
3
web/src/components/MemoActionMenu/index.ts
Normal file
3
web/src/components/MemoActionMenu/index.ts
Normal file
@@ -0,0 +1,3 @@
|
||||
export { useMemoActionHandlers } from "./hooks";
|
||||
export { default, default as MemoActionMenu } from "./MemoActionMenu";
|
||||
export type { MemoActionMenuProps, UseMemoActionHandlersReturn } from "./types";
|
||||
20
web/src/components/MemoActionMenu/types.ts
Normal file
20
web/src/components/MemoActionMenu/types.ts
Normal file
@@ -0,0 +1,20 @@
|
||||
import type { Memo } from "@/types/proto/api/v1/memo_service_pb";
|
||||
|
||||
export interface MemoActionMenuProps {
|
||||
memo: Memo;
|
||||
readonly?: boolean;
|
||||
className?: string;
|
||||
onEdit?: () => void;
|
||||
}
|
||||
|
||||
export interface UseMemoActionHandlersReturn {
|
||||
handleTogglePinMemoBtnClick: () => Promise<void>;
|
||||
handleEditMemoClick: () => void;
|
||||
handleToggleMemoStatusClick: () => Promise<void>;
|
||||
handleCopyLink: () => void;
|
||||
handleCopyContent: () => void;
|
||||
handleDeleteMemoClick: () => void;
|
||||
confirmDeleteMemo: () => Promise<void>;
|
||||
handleRemoveCompletedTaskListItemsClick: () => void;
|
||||
confirmRemoveCompletedTaskListItems: () => Promise<void>;
|
||||
}
|
||||
36
web/src/components/MemoAttachment.tsx
Normal file
36
web/src/components/MemoAttachment.tsx
Normal file
@@ -0,0 +1,36 @@
|
||||
import { Attachment } from "@/types/proto/api/v1/attachment_service_pb";
|
||||
import { getAttachmentUrl, isMidiFile } from "@/utils/attachment";
|
||||
import AttachmentIcon from "./AttachmentIcon";
|
||||
|
||||
interface Props {
|
||||
attachment: Attachment;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
const MemoAttachment: React.FC<Props> = (props: Props) => {
|
||||
const { className, attachment } = props;
|
||||
const attachmentUrl = getAttachmentUrl(attachment);
|
||||
|
||||
const handlePreviewBtnClick = () => {
|
||||
window.open(attachmentUrl);
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`w-auto flex flex-row justify-start items-center text-muted-foreground hover:text-foreground hover:bg-accent rounded px-2 py-1 transition-colors ${className}`}
|
||||
>
|
||||
{attachment.type.startsWith("audio") && !isMidiFile(attachment.type) ? (
|
||||
<audio src={attachmentUrl} controls></audio>
|
||||
) : (
|
||||
<>
|
||||
<AttachmentIcon className="w-4! h-4! mr-1" attachment={attachment} />
|
||||
<span className="text-sm max-w-[256px] truncate cursor-pointer" onClick={handlePreviewBtnClick}>
|
||||
{attachment.filename}
|
||||
</span>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default MemoAttachment;
|
||||
156
web/src/components/MemoContent/CodeBlock.tsx
Normal file
156
web/src/components/MemoContent/CodeBlock.tsx
Normal file
@@ -0,0 +1,156 @@
|
||||
import copy from "copy-to-clipboard";
|
||||
import hljs from "highlight.js";
|
||||
import { CheckIcon, CopyIcon } from "lucide-react";
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import { useAuth } from "@/contexts/AuthContext";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { getThemeWithFallback, resolveTheme } from "@/utils/theme";
|
||||
import { MermaidBlock } from "./MermaidBlock";
|
||||
import type { ReactMarkdownProps } from "./markdown/types";
|
||||
import { extractCodeContent, extractLanguage } from "./utils";
|
||||
|
||||
interface CodeBlockProps extends ReactMarkdownProps {
|
||||
children?: React.ReactNode;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export const CodeBlock = ({ children, className, node: _node, ...props }: CodeBlockProps) => {
|
||||
const { userGeneralSetting } = useAuth();
|
||||
const [copied, setCopied] = useState(false);
|
||||
|
||||
const codeElement = children as React.ReactElement;
|
||||
const codeClassName = codeElement?.props?.className || "";
|
||||
const codeContent = extractCodeContent(children);
|
||||
const language = extractLanguage(codeClassName);
|
||||
|
||||
// If it's a mermaid block, render with MermaidBlock component
|
||||
if (language === "mermaid") {
|
||||
return (
|
||||
<pre className="relative">
|
||||
<MermaidBlock className={cn(className)} {...props}>
|
||||
{children}
|
||||
</MermaidBlock>
|
||||
</pre>
|
||||
);
|
||||
}
|
||||
|
||||
const theme = getThemeWithFallback(userGeneralSetting?.theme);
|
||||
const resolvedTheme = resolveTheme(theme);
|
||||
const isDarkTheme = resolvedTheme.includes("dark");
|
||||
|
||||
// Dynamically load highlight.js theme based on app theme
|
||||
useEffect(() => {
|
||||
const dynamicImportStyle = async () => {
|
||||
// Remove any existing highlight.js style
|
||||
const existingStyle = document.querySelector("style[data-hljs-theme]");
|
||||
if (existingStyle) {
|
||||
existingStyle.remove();
|
||||
}
|
||||
|
||||
try {
|
||||
const cssModule = isDarkTheme
|
||||
? await import("highlight.js/styles/github-dark-dimmed.css?inline")
|
||||
: await import("highlight.js/styles/github.css?inline");
|
||||
|
||||
// Create and inject the style
|
||||
const style = document.createElement("style");
|
||||
style.textContent = cssModule.default;
|
||||
style.setAttribute("data-hljs-theme", isDarkTheme ? "dark" : "light");
|
||||
document.head.appendChild(style);
|
||||
} catch (error) {
|
||||
console.warn("Failed to load highlight.js theme:", error);
|
||||
}
|
||||
};
|
||||
|
||||
dynamicImportStyle();
|
||||
}, [resolvedTheme, isDarkTheme]);
|
||||
|
||||
// Highlight code using highlight.js
|
||||
const highlightedCode = useMemo(() => {
|
||||
try {
|
||||
const lang = hljs.getLanguage(language);
|
||||
if (lang) {
|
||||
return hljs.highlight(codeContent, {
|
||||
language: language,
|
||||
}).value;
|
||||
}
|
||||
} catch {
|
||||
// Skip error and use default highlighted code.
|
||||
}
|
||||
|
||||
// Escape any HTML entities when rendering original content.
|
||||
return Object.assign(document.createElement("span"), {
|
||||
textContent: codeContent,
|
||||
}).innerHTML;
|
||||
}, [language, codeContent]);
|
||||
|
||||
const handleCopy = async () => {
|
||||
try {
|
||||
// Try native clipboard API first (requires HTTPS or localhost)
|
||||
if (navigator.clipboard && window.isSecureContext) {
|
||||
await navigator.clipboard.writeText(codeContent);
|
||||
setCopied(true);
|
||||
setTimeout(() => setCopied(false), 2000);
|
||||
} else {
|
||||
// Fallback to copy-to-clipboard library for non-secure contexts
|
||||
const success = copy(codeContent);
|
||||
if (success) {
|
||||
setCopied(true);
|
||||
setTimeout(() => setCopied(false), 2000);
|
||||
} else {
|
||||
console.error("Failed to copy code");
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
// If native API fails, try fallback
|
||||
console.warn("Native clipboard failed, using fallback:", err);
|
||||
const success = copy(codeContent);
|
||||
if (success) {
|
||||
setCopied(true);
|
||||
setTimeout(() => setCopied(false), 2000);
|
||||
} else {
|
||||
console.error("Failed to copy code:", err);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<pre className="relative my-2 rounded-lg border border-border bg-muted/20 overflow-hidden">
|
||||
{/* Header with language label and copy button */}
|
||||
<div className="flex items-center justify-between px-2 py-1 border-b border-border bg-muted/30">
|
||||
<span className="text-xs text-foreground select-none">{language || "text"}</span>
|
||||
<button
|
||||
onClick={handleCopy}
|
||||
className={cn(
|
||||
"inline-flex items-center gap-1 px-1.5 py-0.5 rounded text-xs",
|
||||
"transition-colors duration-200",
|
||||
"hover:bg-accent active:scale-95",
|
||||
copied ? "text-primary" : "text-muted-foreground hover:text-foreground",
|
||||
)}
|
||||
aria-label={copied ? "Copied" : "Copy code"}
|
||||
title={copied ? "Copied!" : "Copy code"}
|
||||
>
|
||||
{copied ? (
|
||||
<>
|
||||
<CheckIcon className="w-3.5 h-3.5" />
|
||||
<span>Copied</span>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<CopyIcon className="w-3.5 h-3.5" />
|
||||
<span>Copy</span>
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Code content */}
|
||||
<div className="overflow-x-auto">
|
||||
<code
|
||||
className={cn("block px-3 py-2 text-sm leading-relaxed", `language-${language}`)}
|
||||
dangerouslySetInnerHTML={{ __html: highlightedCode }}
|
||||
/>
|
||||
</div>
|
||||
</pre>
|
||||
);
|
||||
};
|
||||
36
web/src/components/MemoContent/ConditionalComponent.tsx
Normal file
36
web/src/components/MemoContent/ConditionalComponent.tsx
Normal file
@@ -0,0 +1,36 @@
|
||||
import type { Element } from "hast";
|
||||
import React from "react";
|
||||
import { isTagElement, isTaskListItemElement } from "@/types/markdown";
|
||||
|
||||
/**
|
||||
* Creates a conditional component that renders different components
|
||||
* based on AST node type detection
|
||||
*
|
||||
* @param CustomComponent - Custom component to render when condition matches
|
||||
* @param DefaultComponent - Default component/element to render otherwise
|
||||
* @param condition - Function to test AST node
|
||||
* @returns Conditional wrapper component
|
||||
*/
|
||||
export const createConditionalComponent = <P extends Record<string, unknown>>(
|
||||
CustomComponent: React.ComponentType<P>,
|
||||
DefaultComponent: React.ComponentType<P> | keyof JSX.IntrinsicElements,
|
||||
condition: (node: Element) => boolean,
|
||||
) => {
|
||||
return (props: P & { node?: Element }) => {
|
||||
const { node, ...restProps } = props;
|
||||
|
||||
// Check AST node to determine which component to use
|
||||
if (node && condition(node)) {
|
||||
return <CustomComponent {...(restProps as P)} node={node} />;
|
||||
}
|
||||
|
||||
// Render default component/element
|
||||
if (typeof DefaultComponent === "string") {
|
||||
return React.createElement(DefaultComponent, restProps);
|
||||
}
|
||||
return <DefaultComponent {...(restProps as P)} />;
|
||||
};
|
||||
};
|
||||
|
||||
// Re-export type guards for convenience
|
||||
export { isTagElement as isTagNode, isTaskListItemElement as isTaskListItemNode };
|
||||
93
web/src/components/MemoContent/MermaidBlock.tsx
Normal file
93
web/src/components/MemoContent/MermaidBlock.tsx
Normal file
@@ -0,0 +1,93 @@
|
||||
import mermaid from "mermaid";
|
||||
import { useEffect, useMemo, useRef, useState } from "react";
|
||||
import { useAuth } from "@/contexts/AuthContext";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { getThemeWithFallback, resolveTheme, setupSystemThemeListener } from "@/utils/theme";
|
||||
import { extractCodeContent } from "./utils";
|
||||
|
||||
interface MermaidBlockProps {
|
||||
children?: React.ReactNode;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
const getMermaidTheme = (appTheme: string): "default" | "dark" => {
|
||||
return appTheme === "default-dark" ? "dark" : "default";
|
||||
};
|
||||
|
||||
export const MermaidBlock = ({ children, className }: MermaidBlockProps) => {
|
||||
const { userGeneralSetting } = useAuth();
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
const [svg, setSvg] = useState<string>("");
|
||||
const [error, setError] = useState<string>("");
|
||||
const [systemThemeChange, setSystemThemeChange] = useState(0);
|
||||
|
||||
const codeContent = extractCodeContent(children);
|
||||
|
||||
// Get theme preference (reactive via AuthContext)
|
||||
// Falls back to localStorage or system preference if no user setting
|
||||
const themePreference = getThemeWithFallback(userGeneralSetting?.theme);
|
||||
|
||||
// Resolve theme to actual value (handles "system" theme + system theme changes)
|
||||
const currentTheme = useMemo(() => resolveTheme(themePreference), [themePreference, systemThemeChange]);
|
||||
|
||||
// Listen for OS theme changes when using "system" theme preference
|
||||
useEffect(() => {
|
||||
if (themePreference !== "system") {
|
||||
return;
|
||||
}
|
||||
|
||||
return setupSystemThemeListener(() => {
|
||||
setSystemThemeChange((prev) => prev + 1);
|
||||
});
|
||||
}, [themePreference]);
|
||||
|
||||
// Render Mermaid diagram when content or theme changes
|
||||
useEffect(() => {
|
||||
if (!codeContent || !containerRef.current) {
|
||||
return;
|
||||
}
|
||||
|
||||
const renderDiagram = async () => {
|
||||
try {
|
||||
const id = `mermaid-${Math.random().toString(36).substring(7)}`;
|
||||
const mermaidTheme = getMermaidTheme(currentTheme);
|
||||
|
||||
mermaid.initialize({
|
||||
startOnLoad: false,
|
||||
theme: mermaidTheme,
|
||||
securityLevel: "strict",
|
||||
fontFamily: "inherit",
|
||||
});
|
||||
|
||||
const { svg: renderedSvg } = await mermaid.render(id, codeContent);
|
||||
setSvg(renderedSvg);
|
||||
setError("");
|
||||
} catch (err) {
|
||||
console.error("Failed to render mermaid diagram:", err);
|
||||
setError(err instanceof Error ? err.message : "Failed to render diagram");
|
||||
}
|
||||
};
|
||||
|
||||
renderDiagram();
|
||||
}, [codeContent, currentTheme]);
|
||||
|
||||
// If there's an error, fall back to showing the code
|
||||
if (error) {
|
||||
return (
|
||||
<div className="w-full">
|
||||
<div className="text-sm text-destructive mb-2">Mermaid Error: {error}</div>
|
||||
<pre className={className}>
|
||||
<code className="language-mermaid">{codeContent}</code>
|
||||
</pre>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={containerRef}
|
||||
className={cn("mermaid-diagram w-full flex justify-center items-center my-2 overflow-x-auto", className)}
|
||||
dangerouslySetInnerHTML={{ __html: svg }}
|
||||
/>
|
||||
);
|
||||
};
|
||||
130
web/src/components/MemoContent/PositionedImageView.tsx
Normal file
130
web/src/components/MemoContent/PositionedImageView.tsx
Normal file
@@ -0,0 +1,130 @@
|
||||
import { useMemo } from "react";
|
||||
import type { Attachment } from "@/types/proto/api/v1/attachment_service_pb";
|
||||
import { getAttachmentUrl } from "@/utils/attachment";
|
||||
|
||||
interface PositionedImageViewProps {
|
||||
imageId: string;
|
||||
className?: string;
|
||||
style?: React.CSSProperties | { cssText: string };
|
||||
alt?: string;
|
||||
}
|
||||
|
||||
// Simplified positioned image view for memo display (read-only)
|
||||
export const PositionedImageView = ({ imageId, className, style, alt }: PositionedImageViewProps) => {
|
||||
// Debug: log component props
|
||||
// console.log('PositionedImageView props:', { imageId, style });
|
||||
// Construct the URL directly using Memos standard URL structure
|
||||
// Format: /file/{attachment-name}/{filename}
|
||||
const imageUrl = useMemo(() => {
|
||||
// Extract filename from imageId if it contains path info
|
||||
// For simple IDs, we'll use a generic filename
|
||||
const filename = imageId.includes('/') ? imageId.split('/').pop() || 'image' : 'image.jpg';
|
||||
return `${window.location.origin}/file/${imageId}/${filename}`;
|
||||
}, [imageId]);
|
||||
|
||||
const containerStyle = useMemo(() => {
|
||||
const baseStyles: React.CSSProperties = {
|
||||
display: 'block',
|
||||
};
|
||||
|
||||
// Handle different style input types
|
||||
if (style) {
|
||||
if ('cssText' in style) {
|
||||
// Handle string-based styles
|
||||
const styleStr = style.cssText;
|
||||
if (styleStr.includes('text-align: left')) baseStyles.textAlign = 'left';
|
||||
if (styleStr.includes('text-align: center')) baseStyles.textAlign = 'center';
|
||||
if (styleStr.includes('text-align: right')) baseStyles.textAlign = 'right';
|
||||
if (styleStr.includes('float: left')) baseStyles.float = 'left';
|
||||
if (styleStr.includes('float: right')) baseStyles.float = 'right';
|
||||
|
||||
// Handle margin - only set if not already defined
|
||||
if (styleStr.includes('margin:') && !('margin' in baseStyles)) {
|
||||
const marginMatch = styleStr.match(/margin:\s*([^;]+)/);
|
||||
if (marginMatch) baseStyles.margin = marginMatch[1].trim();
|
||||
}
|
||||
|
||||
// Set default margin-bottom only if no margin is specified
|
||||
if (!styleStr.includes('margin:') && !('margin' in baseStyles) && !('marginBottom' in baseStyles)) {
|
||||
baseStyles.marginBottom = '1rem';
|
||||
}
|
||||
} else {
|
||||
// Handle CSSProperties object
|
||||
Object.assign(baseStyles, style);
|
||||
|
||||
// Set default margin-bottom only if no margin properties exist
|
||||
if (!('margin' in baseStyles) && !('marginBottom' in baseStyles)) {
|
||||
baseStyles.marginBottom = '1rem';
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return baseStyles;
|
||||
}, [style]);
|
||||
|
||||
const imageStyle = useMemo(() => {
|
||||
const baseStyles: React.CSSProperties = {
|
||||
maxWidth: '100%',
|
||||
height: 'auto',
|
||||
borderRadius: '0.5rem',
|
||||
};
|
||||
|
||||
// Extract width/height from style if present
|
||||
if (style) {
|
||||
if ('cssText' in style) {
|
||||
const styleStr = style.cssText;
|
||||
const widthMatch = styleStr.match(/width:\s*([^;]+)/);
|
||||
const heightMatch = styleStr.match(/height:\s*([^;]+)/);
|
||||
|
||||
if (widthMatch) baseStyles.width = widthMatch[1].trim();
|
||||
if (heightMatch) baseStyles.height = heightMatch[1].trim();
|
||||
}
|
||||
}
|
||||
|
||||
return baseStyles;
|
||||
}, [style]);
|
||||
|
||||
// Debug: log image URL
|
||||
// console.log('Image URL:', imageUrl);
|
||||
|
||||
// Fallback to showing the tag if image URL cannot be constructed
|
||||
if (!imageUrl || imageUrl.includes('undefined')) {
|
||||
return (
|
||||
<span className="inline-block bg-yellow-100 text-yellow-800 px-1 rounded text-sm">
|
||||
[img:{imageId}]
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`positioned-image-wrapper ${className || ''}`}
|
||||
style={containerStyle}
|
||||
data-img-id={imageId}
|
||||
>
|
||||
<img
|
||||
src={imageUrl}
|
||||
alt={alt || `Image ${imageId}`}
|
||||
style={imageStyle}
|
||||
className="rounded-lg shadow-sm"
|
||||
loading="lazy"
|
||||
onError={(e) => {
|
||||
// Fallback if image fails to load
|
||||
const target = e.target as HTMLImageElement;
|
||||
target.style.display = 'none';
|
||||
const fallback = document.createElement('span');
|
||||
fallback.className = 'inline-block bg-red-100 text-red-800 px-1 rounded text-sm';
|
||||
fallback.textContent = `[img:${imageId}] (failed to load)`;
|
||||
target.parentNode?.appendChild(fallback);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
// Alternative approach: Create a hook to get attachments for the current memo
|
||||
export const useMemoAttachments = () => {
|
||||
// This would need to be implemented based on how memos are loaded
|
||||
// For now, return empty array
|
||||
return [];
|
||||
};
|
||||
83
web/src/components/MemoContent/Table.tsx
Normal file
83
web/src/components/MemoContent/Table.tsx
Normal file
@@ -0,0 +1,83 @@
|
||||
import { cn } from "@/lib/utils";
|
||||
import type { ReactMarkdownProps } from "./markdown/types";
|
||||
|
||||
interface TableProps extends React.HTMLAttributes<HTMLTableElement>, ReactMarkdownProps {
|
||||
children: React.ReactNode;
|
||||
}
|
||||
|
||||
export const Table = ({ children, className, node: _node, ...props }: TableProps) => {
|
||||
return (
|
||||
<div className="w-full overflow-x-auto rounded-lg border border-border my-2">
|
||||
<table className={cn("w-full border-collapse text-sm", className)} {...props}>
|
||||
{children}
|
||||
</table>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
interface TableHeadProps extends React.HTMLAttributes<HTMLTableSectionElement>, ReactMarkdownProps {
|
||||
children: React.ReactNode;
|
||||
}
|
||||
|
||||
export const TableHead = ({ children, className, node: _node, ...props }: TableHeadProps) => {
|
||||
return (
|
||||
<thead className={cn("bg-accent/50", className)} {...props}>
|
||||
{children}
|
||||
</thead>
|
||||
);
|
||||
};
|
||||
|
||||
interface TableBodyProps extends React.HTMLAttributes<HTMLTableSectionElement>, ReactMarkdownProps {
|
||||
children: React.ReactNode;
|
||||
}
|
||||
|
||||
export const TableBody = ({ children, className, node: _node, ...props }: TableBodyProps) => {
|
||||
return (
|
||||
<tbody className={cn("divide-y divide-border", className)} {...props}>
|
||||
{children}
|
||||
</tbody>
|
||||
);
|
||||
};
|
||||
|
||||
interface TableRowProps extends React.HTMLAttributes<HTMLTableRowElement>, ReactMarkdownProps {
|
||||
children: React.ReactNode;
|
||||
}
|
||||
|
||||
export const TableRow = ({ children, className, node: _node, ...props }: TableRowProps) => {
|
||||
return (
|
||||
<tr className={cn("transition-colors hover:bg-muted/30", className)} {...props}>
|
||||
{children}
|
||||
</tr>
|
||||
);
|
||||
};
|
||||
|
||||
interface TableHeaderCellProps extends React.ThHTMLAttributes<HTMLTableCellElement>, ReactMarkdownProps {
|
||||
children: React.ReactNode;
|
||||
}
|
||||
|
||||
export const TableHeaderCell = ({ children, className, node: _node, ...props }: TableHeaderCellProps) => {
|
||||
return (
|
||||
<th
|
||||
className={cn(
|
||||
"px-3 py-2 text-left text-xs font-semibold uppercase tracking-wider text-muted-foreground",
|
||||
"border-b-2 border-border",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
</th>
|
||||
);
|
||||
};
|
||||
|
||||
interface TableCellProps extends React.TdHTMLAttributes<HTMLTableCellElement>, ReactMarkdownProps {
|
||||
children: React.ReactNode;
|
||||
}
|
||||
|
||||
export const TableCell = ({ children, className, node: _node, ...props }: TableCellProps) => {
|
||||
return (
|
||||
<td className={cn("px-3 py-2 text-left", className)} {...props}>
|
||||
{children}
|
||||
</td>
|
||||
);
|
||||
};
|
||||
59
web/src/components/MemoContent/Tag.tsx
Normal file
59
web/src/components/MemoContent/Tag.tsx
Normal file
@@ -0,0 +1,59 @@
|
||||
import type { Element } from "hast";
|
||||
import { useLocation } from "react-router-dom";
|
||||
import { type MemoFilter, stringifyFilters, useMemoFilterContext } from "@/contexts/MemoFilterContext";
|
||||
import useNavigateTo from "@/hooks/useNavigateTo";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { Routes } from "@/router";
|
||||
import { useMemoViewContext } from "../MemoView/MemoViewContext";
|
||||
|
||||
interface TagProps extends React.HTMLAttributes<HTMLSpanElement> {
|
||||
node?: Element; // AST node from react-markdown
|
||||
"data-tag"?: string;
|
||||
children?: React.ReactNode;
|
||||
}
|
||||
|
||||
export const Tag: React.FC<TagProps> = ({ "data-tag": dataTag, children, className, ...props }) => {
|
||||
const { parentPage } = useMemoViewContext();
|
||||
const location = useLocation();
|
||||
const navigateTo = useNavigateTo();
|
||||
const { getFiltersByFactor, removeFilter, addFilter } = useMemoFilterContext();
|
||||
|
||||
const tag = dataTag || "";
|
||||
|
||||
const handleTagClick = (e: React.MouseEvent) => {
|
||||
e.stopPropagation();
|
||||
|
||||
// If the tag is clicked in a memo detail page, we should navigate to the memo list page.
|
||||
if (location.pathname.startsWith("/m")) {
|
||||
const pathname = parentPage || Routes.ROOT;
|
||||
const searchParams = new URLSearchParams();
|
||||
|
||||
searchParams.set("filter", stringifyFilters([{ factor: "tagSearch", value: tag }]));
|
||||
navigateTo(`${pathname}?${searchParams.toString()}`);
|
||||
return;
|
||||
}
|
||||
|
||||
const isActive = getFiltersByFactor("tagSearch").some((filter: MemoFilter) => filter.value === tag);
|
||||
if (isActive) {
|
||||
removeFilter((f: MemoFilter) => f.factor === "tagSearch" && f.value === tag);
|
||||
} else {
|
||||
// Remove all existing tag filters first, then add the new one
|
||||
removeFilter((f: MemoFilter) => f.factor === "tagSearch");
|
||||
addFilter({
|
||||
factor: "tagSearch",
|
||||
value: tag,
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<span
|
||||
className={cn("inline-block w-auto text-primary cursor-pointer transition-colors hover:text-primary/80", className)}
|
||||
data-tag={tag}
|
||||
{...props}
|
||||
onClick={handleTagClick}
|
||||
>
|
||||
{children}
|
||||
</span>
|
||||
);
|
||||
};
|
||||
70
web/src/components/MemoContent/TaskListItem.tsx
Normal file
70
web/src/components/MemoContent/TaskListItem.tsx
Normal file
@@ -0,0 +1,70 @@
|
||||
import { useRef } from "react";
|
||||
import { Checkbox } from "@/components/ui/checkbox";
|
||||
import { useUpdateMemo } from "@/hooks/useMemoQueries";
|
||||
import { toggleTaskAtIndex } from "@/utils/markdown-manipulation";
|
||||
import { useMemoViewContext, useMemoViewDerived } from "../MemoView/MemoViewContext";
|
||||
import { TASK_LIST_ITEM_CLASS } from "./constants";
|
||||
import type { ReactMarkdownProps } from "./markdown/types";
|
||||
|
||||
interface TaskListItemProps extends React.InputHTMLAttributes<HTMLInputElement>, ReactMarkdownProps {
|
||||
checked?: boolean;
|
||||
}
|
||||
|
||||
export const TaskListItem: React.FC<TaskListItemProps> = ({ checked, node: _node, ...props }) => {
|
||||
const { memo } = useMemoViewContext();
|
||||
const { readonly } = useMemoViewDerived();
|
||||
const checkboxRef = useRef<HTMLButtonElement>(null);
|
||||
const { mutate: updateMemo } = useUpdateMemo();
|
||||
|
||||
const handleChange = async (newChecked: boolean) => {
|
||||
// Don't update if readonly or no memo
|
||||
if (readonly || !memo) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Find the task index by walking up the DOM
|
||||
const listItem = checkboxRef.current?.closest("li.task-list-item");
|
||||
if (!listItem) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Get task index from data attribute, or calculate by counting
|
||||
const taskIndexStr = listItem.getAttribute("data-task-index");
|
||||
let taskIndex = 0;
|
||||
|
||||
if (taskIndexStr !== null) {
|
||||
taskIndex = parseInt(taskIndexStr);
|
||||
} else {
|
||||
// Fallback: Calculate index by counting all task list items in the entire memo
|
||||
// We need to search from the root memo content container, not just the nearest list
|
||||
// to ensure nested tasks are counted in document order
|
||||
let searchRoot = listItem.closest("[data-memo-content]");
|
||||
|
||||
// If memo content container not found, search from document body
|
||||
if (!searchRoot) {
|
||||
searchRoot = document.body;
|
||||
}
|
||||
|
||||
const allTaskItems = searchRoot.querySelectorAll(`li.${TASK_LIST_ITEM_CLASS}`);
|
||||
for (let i = 0; i < allTaskItems.length; i++) {
|
||||
if (allTaskItems[i] === listItem) {
|
||||
taskIndex = i;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Update memo content using the string manipulation utility
|
||||
const newContent = toggleTaskAtIndex(memo.content, taskIndex, newChecked);
|
||||
updateMemo({
|
||||
update: {
|
||||
name: memo.name,
|
||||
content: newContent,
|
||||
},
|
||||
updateMask: ["content"],
|
||||
});
|
||||
};
|
||||
|
||||
// Override the disabled prop from remark-gfm (which defaults to true)
|
||||
return <Checkbox ref={checkboxRef} checked={checked} disabled={readonly} onCheckedChange={handleChange} className={props.className} />;
|
||||
};
|
||||
80
web/src/components/MemoContent/constants.ts
Normal file
80
web/src/components/MemoContent/constants.ts
Normal file
@@ -0,0 +1,80 @@
|
||||
import { defaultSchema } from "rehype-sanitize";
|
||||
|
||||
// Class names added by remark-gfm for task lists
|
||||
export const TASK_LIST_CLASS = "contains-task-list";
|
||||
export const TASK_LIST_ITEM_CLASS = "task-list-item";
|
||||
|
||||
// Compact mode display settings
|
||||
export const COMPACT_MODE_CONFIG = {
|
||||
maxHeightVh: 60, // 60% of viewport height
|
||||
gradientHeight: "h-24", // Tailwind class for gradient overlay
|
||||
} as const;
|
||||
|
||||
export const getMaxDisplayHeight = () => window.innerHeight * (COMPACT_MODE_CONFIG.maxHeightVh / 100);
|
||||
|
||||
export const COMPACT_STATES: Record<"ALL" | "SNIPPET", { textKey: string; next: "ALL" | "SNIPPET" }> = {
|
||||
ALL: { textKey: "memo.show-more", next: "SNIPPET" },
|
||||
SNIPPET: { textKey: "memo.show-less", next: "ALL" },
|
||||
};
|
||||
|
||||
/**
|
||||
* Sanitization schema for markdown HTML content.
|
||||
* Extends the default schema to allow:
|
||||
* - KaTeX math rendering elements (MathML tags)
|
||||
* - KaTeX-specific attributes (className, style, aria-*, data-*)
|
||||
* - Safe HTML elements for rich content
|
||||
* - iframe embeds for trusted video providers (YouTube, Vimeo, etc.)
|
||||
*
|
||||
* This prevents XSS attacks while preserving math rendering functionality.
|
||||
*/
|
||||
export const SANITIZE_SCHEMA = {
|
||||
...defaultSchema,
|
||||
attributes: {
|
||||
...defaultSchema.attributes,
|
||||
div: [...(defaultSchema.attributes?.div || []), "className", ["data*"]],
|
||||
span: [...(defaultSchema.attributes?.span || []), "className", "style", ["aria*"], ["data*"]],
|
||||
img: [...(defaultSchema.attributes?.img || []), "className", "style", ["data*"]],
|
||||
// iframe attributes for video embeds
|
||||
iframe: ["src", "width", "height", "frameborder", "allowfullscreen", "allow", "title", "referrerpolicy", "loading"],
|
||||
// MathML attributes for KaTeX rendering
|
||||
annotation: ["encoding"],
|
||||
math: ["xmlns"],
|
||||
mi: [],
|
||||
mn: [],
|
||||
mo: [],
|
||||
mrow: [],
|
||||
mspace: [],
|
||||
mstyle: [],
|
||||
msup: [],
|
||||
msub: [],
|
||||
msubsup: [],
|
||||
mfrac: [],
|
||||
mtext: [],
|
||||
semantics: [],
|
||||
},
|
||||
tagNames: [
|
||||
...(defaultSchema.tagNames || []),
|
||||
// iframe for video embeds
|
||||
"iframe",
|
||||
// MathML elements for KaTeX math rendering
|
||||
"math",
|
||||
"annotation",
|
||||
"semantics",
|
||||
"mi",
|
||||
"mn",
|
||||
"mo",
|
||||
"mrow",
|
||||
"mspace",
|
||||
"mstyle",
|
||||
"msup",
|
||||
"msub",
|
||||
"msubsup",
|
||||
"mfrac",
|
||||
"mtext",
|
||||
],
|
||||
protocols: {
|
||||
...defaultSchema.protocols,
|
||||
// Allow HTTPS iframe embeds only for security
|
||||
iframe: { src: ["https"] },
|
||||
},
|
||||
};
|
||||
28
web/src/components/MemoContent/hooks.ts
Normal file
28
web/src/components/MemoContent/hooks.ts
Normal file
@@ -0,0 +1,28 @@
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
import { COMPACT_STATES, getMaxDisplayHeight } from "./constants";
|
||||
import type { ContentCompactView } from "./types";
|
||||
|
||||
export const useCompactMode = (enabled: boolean) => {
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
const [mode, setMode] = useState<ContentCompactView | undefined>(undefined);
|
||||
|
||||
useEffect(() => {
|
||||
if (!enabled || !containerRef.current) return;
|
||||
const maxHeight = getMaxDisplayHeight();
|
||||
if (containerRef.current.getBoundingClientRect().height > maxHeight) {
|
||||
setMode("ALL");
|
||||
}
|
||||
}, [enabled]);
|
||||
|
||||
const toggle = useCallback(() => {
|
||||
if (!mode) return;
|
||||
setMode(COMPACT_STATES[mode].next);
|
||||
}, [mode]);
|
||||
|
||||
return { containerRef, mode, toggle };
|
||||
};
|
||||
|
||||
export const useCompactLabel = (mode: ContentCompactView | undefined, t: (key: string) => string): string => {
|
||||
if (!mode) return "";
|
||||
return t(COMPACT_STATES[mode].textKey);
|
||||
};
|
||||
148
web/src/components/MemoContent/index.tsx
Normal file
148
web/src/components/MemoContent/index.tsx
Normal file
@@ -0,0 +1,148 @@
|
||||
import type { Element } from "hast";
|
||||
import { ChevronDown, ChevronUp } from "lucide-react";
|
||||
import { memo } from "react";
|
||||
import ReactMarkdown from "react-markdown";
|
||||
import rehypeKatex from "rehype-katex";
|
||||
import rehypeRaw from "rehype-raw";
|
||||
import rehypeSanitize from "rehype-sanitize";
|
||||
import remarkBreaks from "remark-breaks";
|
||||
import remarkGfm from "remark-gfm";
|
||||
import remarkMath from "remark-math";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { useTranslate } from "@/utils/i18n";
|
||||
import { remarkDisableSetext } from "@/utils/remark-plugins/remark-disable-setext";
|
||||
import { remarkPreserveType } from "@/utils/remark-plugins/remark-preserve-type";
|
||||
import { remarkTag } from "@/utils/remark-plugins/remark-tag";
|
||||
import { remarkImagePosition } from "@/utils/remark-plugins/remark-image-position";
|
||||
import { PositionedImageView } from "./PositionedImageView";
|
||||
import { CodeBlock } from "./CodeBlock";
|
||||
import { isTagNode, isTaskListItemNode } from "./ConditionalComponent";
|
||||
import { COMPACT_MODE_CONFIG, SANITIZE_SCHEMA } from "./constants";
|
||||
import { useCompactLabel, useCompactMode } from "./hooks";
|
||||
import { Blockquote, Heading, HorizontalRule, Image, InlineCode, Link, List, ListItem, Paragraph } from "./markdown";
|
||||
import { Table, TableBody, TableCell, TableHead, TableHeaderCell, TableRow } from "./Table";
|
||||
import { Tag } from "./Tag";
|
||||
import { TaskListItem } from "./TaskListItem";
|
||||
import type { MemoContentProps } from "./types";
|
||||
|
||||
const MemoContent = (props: MemoContentProps) => {
|
||||
const { className, contentClassName, content, onClick, onDoubleClick } = props;
|
||||
const t = useTranslate();
|
||||
const {
|
||||
containerRef: memoContentContainerRef,
|
||||
mode: showCompactMode,
|
||||
toggle: toggleCompactMode,
|
||||
} = useCompactMode(Boolean(props.compact));
|
||||
|
||||
const compactLabel = useCompactLabel(showCompactMode, t as (key: string) => string);
|
||||
|
||||
return (
|
||||
<div className={`w-full flex flex-col justify-start items-start text-foreground ${className || ""}`}>
|
||||
<div
|
||||
ref={memoContentContainerRef}
|
||||
data-memo-content
|
||||
className={cn(
|
||||
"relative w-full max-w-full wrap-break-word text-base leading-6",
|
||||
"[&>*:last-child]:mb-0",
|
||||
showCompactMode === "ALL" && "overflow-hidden",
|
||||
contentClassName,
|
||||
)}
|
||||
style={showCompactMode === "ALL" ? { maxHeight: `${COMPACT_MODE_CONFIG.maxHeightVh}vh` } : undefined}
|
||||
onMouseUp={onClick}
|
||||
onDoubleClick={onDoubleClick}
|
||||
>
|
||||
<ReactMarkdown
|
||||
remarkPlugins={[remarkDisableSetext, remarkMath, remarkGfm, remarkBreaks, remarkTag, remarkPreserveType, remarkImagePosition]}
|
||||
rehypePlugins={[rehypeRaw, [rehypeSanitize, SANITIZE_SCHEMA], rehypeKatex]}
|
||||
remarkRehypeOptions={{ allowDangerousHtml: true }}
|
||||
components={{
|
||||
// Child components consume from MemoViewContext directly
|
||||
input: ((inputProps: React.ComponentProps<"input"> & { node?: Element }) => {
|
||||
if (inputProps.node && isTaskListItemNode(inputProps.node)) {
|
||||
return <TaskListItem {...inputProps} />;
|
||||
}
|
||||
return <input {...inputProps} />;
|
||||
}) as React.ComponentType<React.ComponentProps<"input">>,
|
||||
span: ((spanProps: React.ComponentProps<"span"> & { node?: Element }) => {
|
||||
const { node, ...rest } = spanProps;
|
||||
if (node && isTagNode(node)) {
|
||||
return <Tag {...spanProps} />;
|
||||
}
|
||||
return <span {...rest} />;
|
||||
}) as React.ComponentType<React.ComponentProps<"span">>,
|
||||
div: ((divProps: React.ComponentProps<"div"> & { node?: Element }) => {
|
||||
const { node, ...rest } = divProps;
|
||||
// Handle positioned image containers
|
||||
if (node && node.properties && 'data-img-id' in node.properties) {
|
||||
const imageId = node.properties['data-img-id'] as string;
|
||||
// Pass along style information for positioning
|
||||
const style = node.properties.style as string || '';
|
||||
return <PositionedImageView imageId={imageId} style={{ cssText: style }} />;
|
||||
}
|
||||
// Debug: log other div nodes to see structure
|
||||
// console.log('Div node:', node);
|
||||
return <div {...rest} />;
|
||||
}) as React.ComponentType<React.ComponentProps<"div">>,
|
||||
// Headings
|
||||
h1: ({ children }) => <Heading level={1}>{children}</Heading>,
|
||||
h2: ({ children }) => <Heading level={2}>{children}</Heading>,
|
||||
h3: ({ children }) => <Heading level={3}>{children}</Heading>,
|
||||
h4: ({ children }) => <Heading level={4}>{children}</Heading>,
|
||||
h5: ({ children }) => <Heading level={5}>{children}</Heading>,
|
||||
h6: ({ children }) => <Heading level={6}>{children}</Heading>,
|
||||
// Block elements
|
||||
p: ({ children }) => <Paragraph>{children}</Paragraph>,
|
||||
blockquote: ({ children }) => <Blockquote>{children}</Blockquote>,
|
||||
hr: () => <HorizontalRule />,
|
||||
// Lists
|
||||
ul: ({ children, ...props }) => <List {...props}>{children}</List>,
|
||||
ol: ({ children, ...props }) => (
|
||||
<List ordered {...props}>
|
||||
{children}
|
||||
</List>
|
||||
),
|
||||
li: ({ children, ...props }) => <ListItem {...props}>{children}</ListItem>,
|
||||
// Inline elements
|
||||
a: ({ children, ...props }) => <Link {...props}>{children}</Link>,
|
||||
code: ({ children }) => <InlineCode>{children}</InlineCode>,
|
||||
img: ({ ...props }) => <Image {...props} />,
|
||||
// Code blocks
|
||||
pre: CodeBlock,
|
||||
// Tables
|
||||
table: ({ children }) => <Table>{children}</Table>,
|
||||
thead: ({ children }) => <TableHead>{children}</TableHead>,
|
||||
tbody: ({ children }) => <TableBody>{children}</TableBody>,
|
||||
tr: ({ children }) => <TableRow>{children}</TableRow>,
|
||||
th: ({ children, ...props }) => <TableHeaderCell {...props}>{children}</TableHeaderCell>,
|
||||
td: ({ children, ...props }) => <TableCell {...props}>{children}</TableCell>,
|
||||
}}
|
||||
>
|
||||
{content}
|
||||
</ReactMarkdown>
|
||||
{showCompactMode === "ALL" && (
|
||||
<div
|
||||
className={cn(
|
||||
"absolute inset-x-0 bottom-0 pointer-events-none",
|
||||
COMPACT_MODE_CONFIG.gradientHeight,
|
||||
"bg-linear-to-t from-background from-0% via-background/60 via-40% to-transparent to-100%",
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
{showCompactMode !== undefined && (
|
||||
<div className="relative w-full mt-2">
|
||||
<button
|
||||
type="button"
|
||||
className="group inline-flex items-center gap-1 px-2 py-1 text-xs text-muted-foreground hover:text-foreground transition-colors"
|
||||
onClick={toggleCompactMode}
|
||||
>
|
||||
<span>{compactLabel}</span>
|
||||
{showCompactMode === "ALL" ? <ChevronDown className="w-3 h-3" /> : <ChevronUp className="w-3 h-3" />}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default memo(MemoContent);
|
||||
17
web/src/components/MemoContent/markdown/Blockquote.tsx
Normal file
17
web/src/components/MemoContent/markdown/Blockquote.tsx
Normal file
@@ -0,0 +1,17 @@
|
||||
import { cn } from "@/lib/utils";
|
||||
import type { ReactMarkdownProps } from "./types";
|
||||
|
||||
interface BlockquoteProps extends React.BlockquoteHTMLAttributes<HTMLQuoteElement>, ReactMarkdownProps {
|
||||
children: React.ReactNode;
|
||||
}
|
||||
|
||||
/**
|
||||
* Blockquote component with left border accent
|
||||
*/
|
||||
export const Blockquote = ({ children, className, node: _node, ...props }: BlockquoteProps) => {
|
||||
return (
|
||||
<blockquote className={cn("my-0 mb-2 border-l-4 border-primary/30 pl-3 text-muted-foreground italic", className)} {...props}>
|
||||
{children}
|
||||
</blockquote>
|
||||
);
|
||||
};
|
||||
30
web/src/components/MemoContent/markdown/Heading.tsx
Normal file
30
web/src/components/MemoContent/markdown/Heading.tsx
Normal file
@@ -0,0 +1,30 @@
|
||||
import { cn } from "@/lib/utils";
|
||||
import type { ReactMarkdownProps } from "./types";
|
||||
|
||||
interface HeadingProps extends React.HTMLAttributes<HTMLHeadingElement>, ReactMarkdownProps {
|
||||
level: 1 | 2 | 3 | 4 | 5 | 6;
|
||||
children: React.ReactNode;
|
||||
}
|
||||
|
||||
/**
|
||||
* Heading component for h1-h6 elements
|
||||
* Renders semantic heading levels with consistent styling
|
||||
*/
|
||||
export const Heading = ({ level, children, className, node: _node, ...props }: HeadingProps) => {
|
||||
const Component = `h${level}` as const;
|
||||
|
||||
const levelClasses = {
|
||||
1: "text-3xl font-bold border-b border-border pb-2",
|
||||
2: "text-2xl font-semibold border-b border-border pb-1.5",
|
||||
3: "text-xl font-semibold",
|
||||
4: "text-lg font-semibold",
|
||||
5: "text-base font-semibold",
|
||||
6: "text-base font-medium text-muted-foreground",
|
||||
};
|
||||
|
||||
return (
|
||||
<Component className={cn("mt-3 mb-2 leading-tight", levelClasses[level], className)} {...props}>
|
||||
{children}
|
||||
</Component>
|
||||
);
|
||||
};
|
||||
11
web/src/components/MemoContent/markdown/HorizontalRule.tsx
Normal file
11
web/src/components/MemoContent/markdown/HorizontalRule.tsx
Normal file
@@ -0,0 +1,11 @@
|
||||
import { cn } from "@/lib/utils";
|
||||
import type { ReactMarkdownProps } from "./types";
|
||||
|
||||
interface HorizontalRuleProps extends React.HTMLAttributes<HTMLHRElement>, ReactMarkdownProps {}
|
||||
|
||||
/**
|
||||
* Horizontal rule separator
|
||||
*/
|
||||
export const HorizontalRule = ({ className, node: _node, ...props }: HorizontalRuleProps) => {
|
||||
return <hr className={cn("my-2 h-0 border-0 border-b border-border", className)} {...props} />;
|
||||
};
|
||||
12
web/src/components/MemoContent/markdown/Image.tsx
Normal file
12
web/src/components/MemoContent/markdown/Image.tsx
Normal file
@@ -0,0 +1,12 @@
|
||||
import { cn } from "@/lib/utils";
|
||||
import type { ReactMarkdownProps } from "./types";
|
||||
|
||||
interface ImageProps extends React.ImgHTMLAttributes<HTMLImageElement>, ReactMarkdownProps {}
|
||||
|
||||
/**
|
||||
* Image component for markdown images
|
||||
* Responsive with rounded corners
|
||||
*/
|
||||
export const Image = ({ className, alt, node: _node, ...props }: ImageProps) => {
|
||||
return <img className={cn("max-w-full h-auto rounded-lg my-2", className)} alt={alt} {...props} />;
|
||||
};
|
||||
17
web/src/components/MemoContent/markdown/InlineCode.tsx
Normal file
17
web/src/components/MemoContent/markdown/InlineCode.tsx
Normal file
@@ -0,0 +1,17 @@
|
||||
import { cn } from "@/lib/utils";
|
||||
import type { ReactMarkdownProps } from "./types";
|
||||
|
||||
interface InlineCodeProps extends React.HTMLAttributes<HTMLElement>, ReactMarkdownProps {
|
||||
children: React.ReactNode;
|
||||
}
|
||||
|
||||
/**
|
||||
* Inline code component with background and monospace font
|
||||
*/
|
||||
export const InlineCode = ({ children, className, node: _node, ...props }: InlineCodeProps) => {
|
||||
return (
|
||||
<code className={cn("font-mono text-sm bg-muted px-1 py-0.5 rounded-md", className)} {...props}>
|
||||
{children}
|
||||
</code>
|
||||
);
|
||||
};
|
||||
27
web/src/components/MemoContent/markdown/Link.tsx
Normal file
27
web/src/components/MemoContent/markdown/Link.tsx
Normal file
@@ -0,0 +1,27 @@
|
||||
import { cn } from "@/lib/utils";
|
||||
import type { ReactMarkdownProps } from "./types";
|
||||
|
||||
interface LinkProps extends React.AnchorHTMLAttributes<HTMLAnchorElement>, ReactMarkdownProps {
|
||||
children: React.ReactNode;
|
||||
}
|
||||
|
||||
/**
|
||||
* Link component for external links
|
||||
* Opens in new tab with security attributes
|
||||
*/
|
||||
export const Link = ({ children, className, href, node: _node, ...props }: LinkProps) => {
|
||||
return (
|
||||
<a
|
||||
href={href}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className={cn(
|
||||
"text-primary underline decoration-primary/50 underline-offset-2 transition-colors hover:decoration-primary",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
</a>
|
||||
);
|
||||
};
|
||||
71
web/src/components/MemoContent/markdown/List.tsx
Normal file
71
web/src/components/MemoContent/markdown/List.tsx
Normal file
@@ -0,0 +1,71 @@
|
||||
import { cn } from "@/lib/utils";
|
||||
import { TASK_LIST_CLASS, TASK_LIST_ITEM_CLASS } from "../constants";
|
||||
import type { ReactMarkdownProps } from "./types";
|
||||
|
||||
interface ListProps extends React.HTMLAttributes<HTMLUListElement | HTMLOListElement>, ReactMarkdownProps {
|
||||
ordered?: boolean;
|
||||
children: React.ReactNode;
|
||||
}
|
||||
|
||||
/**
|
||||
* List component for both regular and task lists (GFM)
|
||||
* Detects task lists via the "contains-task-list" class added by remark-gfm
|
||||
*/
|
||||
export const List = ({ ordered, children, className, node: _node, ...domProps }: ListProps) => {
|
||||
const Component = ordered ? "ol" : "ul";
|
||||
const isTaskList = className?.includes(TASK_LIST_CLASS);
|
||||
|
||||
return (
|
||||
<Component
|
||||
className={cn(
|
||||
"my-0 mb-2 list-outside",
|
||||
isTaskList
|
||||
? // Task list: no bullets, nested lists get left margin for indentation
|
||||
"list-none [&_ul.contains-task-list]:ml-6"
|
||||
: // Regular list: standard padding and list style
|
||||
cn("pl-6", ordered ? "list-decimal" : "list-disc"),
|
||||
className,
|
||||
)}
|
||||
{...domProps}
|
||||
>
|
||||
{children}
|
||||
</Component>
|
||||
);
|
||||
};
|
||||
|
||||
interface ListItemProps extends React.LiHTMLAttributes<HTMLLIElement>, ReactMarkdownProps {
|
||||
children: React.ReactNode;
|
||||
}
|
||||
|
||||
/**
|
||||
* List item component for both regular and task list items
|
||||
* Detects task items via the "task-list-item" class added by remark-gfm
|
||||
* Applies specialized styling for task checkboxes
|
||||
*/
|
||||
export const ListItem = ({ children, className, node: _node, ...domProps }: ListItemProps) => {
|
||||
const isTaskListItem = className?.includes(TASK_LIST_ITEM_CLASS);
|
||||
|
||||
if (isTaskListItem) {
|
||||
return (
|
||||
<li
|
||||
className={cn(
|
||||
"mt-0.5 leading-6 list-none",
|
||||
// Checkbox styling: margin and alignment
|
||||
"[&>button]:mr-2 [&>button]:align-middle",
|
||||
// Inline paragraph for task text
|
||||
"[&>p]:inline [&>p]:m-0",
|
||||
className,
|
||||
)}
|
||||
{...domProps}
|
||||
>
|
||||
{children}
|
||||
</li>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<li className={cn("mt-0.5 leading-6", className)} {...domProps}>
|
||||
{children}
|
||||
</li>
|
||||
);
|
||||
};
|
||||
17
web/src/components/MemoContent/markdown/Paragraph.tsx
Normal file
17
web/src/components/MemoContent/markdown/Paragraph.tsx
Normal file
@@ -0,0 +1,17 @@
|
||||
import { cn } from "@/lib/utils";
|
||||
import type { ReactMarkdownProps } from "./types";
|
||||
|
||||
interface ParagraphProps extends React.HTMLAttributes<HTMLParagraphElement>, ReactMarkdownProps {
|
||||
children: React.ReactNode;
|
||||
}
|
||||
|
||||
/**
|
||||
* Paragraph component with compact spacing
|
||||
*/
|
||||
export const Paragraph = ({ children, className, node: _node, ...props }: ParagraphProps) => {
|
||||
return (
|
||||
<p className={cn("my-0 mb-2 leading-6", className)} {...props}>
|
||||
{children}
|
||||
</p>
|
||||
);
|
||||
};
|
||||
97
web/src/components/MemoContent/markdown/README.md
Normal file
97
web/src/components/MemoContent/markdown/README.md
Normal file
@@ -0,0 +1,97 @@
|
||||
# Markdown Components
|
||||
|
||||
Modern, type-safe React components for rendering markdown content via react-markdown.
|
||||
|
||||
## Architecture
|
||||
|
||||
### Component-Based Rendering
|
||||
Following patterns from popular AI chat apps (ChatGPT, Claude, Perplexity), we use React components instead of CSS selectors for markdown rendering. This provides:
|
||||
|
||||
- **Type Safety**: Full TypeScript support with proper prop types
|
||||
- **Maintainability**: Components are easier to test, modify, and understand
|
||||
- **Performance**: No CSS specificity conflicts, cleaner DOM
|
||||
- **Modularity**: Each element is independently styled and documented
|
||||
|
||||
### Type System
|
||||
|
||||
All components extend `ReactMarkdownProps` which includes the AST `node` prop passed by react-markdown. This is explicitly destructured as `node: _node` to:
|
||||
1. Filter it from DOM props (avoids `node="[object Object]"` in HTML)
|
||||
2. Keep it available for advanced use cases (e.g., detecting task lists)
|
||||
3. Maintain type safety without `as any` casts
|
||||
|
||||
### GFM Task Lists
|
||||
|
||||
Task lists (from remark-gfm) are handled by:
|
||||
- **Detection**: `contains-task-list` and `task-list-item` classes from remark-gfm
|
||||
- **Styling**: Tailwind utilities with arbitrary variants for nested elements
|
||||
- **Checkboxes**: Custom `TaskListItem` component with Radix UI checkbox
|
||||
- **Interactivity**: Updates memo content via `toggleTaskAtIndex` utility
|
||||
|
||||
### Component Patterns
|
||||
|
||||
Each component follows this structure:
|
||||
```tsx
|
||||
import { cn } from "@/lib/utils";
|
||||
import type { ReactMarkdownProps } from "./types";
|
||||
|
||||
interface ComponentProps extends React.HTMLAttributes<HTMLElement>, ReactMarkdownProps {
|
||||
children?: React.ReactNode;
|
||||
// component-specific props
|
||||
}
|
||||
|
||||
/**
|
||||
* JSDoc description
|
||||
*/
|
||||
export const Component = ({ children, className, node: _node, ...props }: ComponentProps) => {
|
||||
return (
|
||||
<element className={cn("base-classes", className)} {...props}>
|
||||
{children}
|
||||
</element>
|
||||
);
|
||||
};
|
||||
```
|
||||
|
||||
## Components
|
||||
|
||||
| Component | Element | Purpose |
|
||||
|-----------|---------|---------|
|
||||
| `Heading` | h1-h6 | Semantic headings with level-based styling |
|
||||
| `Paragraph` | p | Compact paragraphs with consistent spacing |
|
||||
| `Link` | a | External links with security attributes |
|
||||
| `List` | ul/ol | Regular and GFM task lists |
|
||||
| `ListItem` | li | List items with task checkbox support |
|
||||
| `Blockquote` | blockquote | Quotes with left border accent |
|
||||
| `InlineCode` | code | Inline code with background |
|
||||
| `Image` | img | Responsive images with rounded corners |
|
||||
| `HorizontalRule` | hr | Section separators |
|
||||
|
||||
## Styling Approach
|
||||
|
||||
- **Tailwind CSS**: All styling uses Tailwind utilities
|
||||
- **Design Tokens**: Colors use CSS variables (e.g., `--primary`, `--muted-foreground`)
|
||||
- **Responsive**: Max-width constraints, responsive images
|
||||
- **Accessibility**: Semantic HTML, proper ARIA attributes via Radix UI
|
||||
|
||||
## Integration
|
||||
|
||||
Components are mapped to HTML elements in `MemoContent/index.tsx`:
|
||||
|
||||
```tsx
|
||||
<ReactMarkdown
|
||||
components={{
|
||||
h1: ({ children }) => <Heading level={1}>{children}</Heading>,
|
||||
p: ({ children, ...props }) => <Paragraph {...props}>{children}</Paragraph>,
|
||||
// ... more mappings
|
||||
}}
|
||||
>
|
||||
{content}
|
||||
</ReactMarkdown>
|
||||
```
|
||||
|
||||
## Future Enhancements
|
||||
|
||||
- [ ] Syntax highlighting themes for code blocks
|
||||
- [ ] Table sorting/filtering interactions
|
||||
- [ ] Image lightbox/zoom functionality
|
||||
- [ ] Collapsible sections for long content
|
||||
- [ ] Copy button for code blocks
|
||||
8
web/src/components/MemoContent/markdown/index.ts
Normal file
8
web/src/components/MemoContent/markdown/index.ts
Normal file
@@ -0,0 +1,8 @@
|
||||
export { Blockquote } from "./Blockquote";
|
||||
export { Heading } from "./Heading";
|
||||
export { HorizontalRule } from "./HorizontalRule";
|
||||
export { Image } from "./Image";
|
||||
export { InlineCode } from "./InlineCode";
|
||||
export { Link } from "./Link";
|
||||
export { List, ListItem } from "./List";
|
||||
export { Paragraph } from "./Paragraph";
|
||||
9
web/src/components/MemoContent/markdown/types.ts
Normal file
9
web/src/components/MemoContent/markdown/types.ts
Normal file
@@ -0,0 +1,9 @@
|
||||
import type { Element } from "hast";
|
||||
|
||||
/**
|
||||
* Props passed by react-markdown to custom components
|
||||
* Includes the AST node for advanced use cases
|
||||
*/
|
||||
export interface ReactMarkdownProps {
|
||||
node?: Element;
|
||||
}
|
||||
12
web/src/components/MemoContent/types.ts
Normal file
12
web/src/components/MemoContent/types.ts
Normal file
@@ -0,0 +1,12 @@
|
||||
import type React from "react";
|
||||
|
||||
export interface MemoContentProps {
|
||||
content: string;
|
||||
compact?: boolean;
|
||||
className?: string;
|
||||
contentClassName?: string;
|
||||
onClick?: (e: React.MouseEvent) => void;
|
||||
onDoubleClick?: (e: React.MouseEvent) => void;
|
||||
}
|
||||
|
||||
export type ContentCompactView = "ALL" | "SNIPPET";
|
||||
25
web/src/components/MemoContent/utils.ts
Normal file
25
web/src/components/MemoContent/utils.ts
Normal file
@@ -0,0 +1,25 @@
|
||||
import type React from "react";
|
||||
|
||||
/**
|
||||
* Extracts code content from a react-markdown code element.
|
||||
* Handles the nested structure where code is passed as children.
|
||||
*
|
||||
* @param children - The children prop from react-markdown (typically a code element)
|
||||
* @returns The extracted code content as a string with trailing newline removed
|
||||
*/
|
||||
export const extractCodeContent = (children: React.ReactNode): string => {
|
||||
const codeElement = children as React.ReactElement;
|
||||
return String(codeElement?.props?.children || "").replace(/\n$/, "");
|
||||
};
|
||||
|
||||
/**
|
||||
* Extracts the language identifier from a code block's className.
|
||||
* react-markdown uses the format "language-xxx" for code blocks.
|
||||
*
|
||||
* @param className - The className string from a code element
|
||||
* @returns The language identifier, or empty string if none found
|
||||
*/
|
||||
export const extractLanguage = (className: string): string => {
|
||||
const match = /language-(\w+)/.exec(className);
|
||||
return match ? match[1] : "";
|
||||
};
|
||||
101
web/src/components/MemoDetailSidebar/MemoDetailSidebar.tsx
Normal file
101
web/src/components/MemoDetailSidebar/MemoDetailSidebar.tsx
Normal file
@@ -0,0 +1,101 @@
|
||||
import { create } from "@bufbuild/protobuf";
|
||||
import { timestampDate } from "@bufbuild/protobuf/wkt";
|
||||
import { isEqual } from "lodash-es";
|
||||
import { CheckCircleIcon, Code2Icon, HashIcon, LinkIcon } from "lucide-react";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { Memo, Memo_PropertySchema, MemoRelation_Type } from "@/types/proto/api/v1/memo_service_pb";
|
||||
import { useTranslate } from "@/utils/i18n";
|
||||
import MemoRelationForceGraph from "../MemoRelationForceGraph";
|
||||
|
||||
interface Props {
|
||||
memo: Memo;
|
||||
className?: string;
|
||||
parentPage?: string;
|
||||
}
|
||||
|
||||
const SectionLabel = ({ children }: { children: React.ReactNode }) => (
|
||||
<p className="text-xs font-medium text-muted-foreground/50 uppercase tracking-wider">{children}</p>
|
||||
);
|
||||
|
||||
const MemoDetailSidebar = ({ memo, className, parentPage }: Props) => {
|
||||
const t = useTranslate();
|
||||
const property = create(Memo_PropertySchema, memo.property || {});
|
||||
const hasSpecialProperty = property.hasLink || property.hasTaskList || property.hasCode;
|
||||
const hasReferenceRelations = memo.relations.some((r) => r.type === MemoRelation_Type.REFERENCE);
|
||||
|
||||
return (
|
||||
<aside className={cn("relative w-full h-auto max-h-screen overflow-auto flex flex-col gap-5", className)}>
|
||||
{hasReferenceRelations && (
|
||||
<div className="w-full space-y-2">
|
||||
<div className="flex items-center gap-1.5">
|
||||
<SectionLabel>{t("common.relations")}</SectionLabel>
|
||||
<span className="text-xs text-muted-foreground/30">(Beta)</span>
|
||||
</div>
|
||||
<div className="relative w-full h-36 border border-border rounded-lg bg-muted overflow-hidden">
|
||||
<MemoRelationForceGraph className="w-full h-full" memo={memo} parentPage={parentPage} />
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="w-full space-y-1">
|
||||
<SectionLabel>{t("common.created-at")}</SectionLabel>
|
||||
<p className="text-sm text-foreground/70">{memo.createTime ? timestampDate(memo.createTime).toLocaleString() : "—"}</p>
|
||||
</div>
|
||||
|
||||
{!isEqual(memo.createTime, memo.updateTime) && (
|
||||
<div className="w-full space-y-1">
|
||||
<SectionLabel>{t("common.last-updated-at")}</SectionLabel>
|
||||
<p className="text-sm text-foreground/70">{memo.updateTime ? timestampDate(memo.updateTime).toLocaleString() : "—"}</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{hasSpecialProperty && (
|
||||
<div className="w-full space-y-2">
|
||||
<SectionLabel>{t("common.properties")}</SectionLabel>
|
||||
<div className="flex flex-wrap gap-1.5">
|
||||
{property.hasLink && (
|
||||
<span className="inline-flex items-center gap-1.5 px-2 py-1 rounded-md border border-border/60 bg-muted/60 text-xs text-muted-foreground">
|
||||
<LinkIcon className="w-3.5 h-3.5" />
|
||||
{t("memo.links")}
|
||||
</span>
|
||||
)}
|
||||
{property.hasTaskList && (
|
||||
<span className="inline-flex items-center gap-1.5 px-2 py-1 rounded-md border border-border/60 bg-muted/60 text-xs text-muted-foreground">
|
||||
<CheckCircleIcon className="w-3.5 h-3.5" />
|
||||
{t("memo.to-do")}
|
||||
</span>
|
||||
)}
|
||||
{property.hasCode && (
|
||||
<span className="inline-flex items-center gap-1.5 px-2 py-1 rounded-md border border-border/60 bg-muted/60 text-xs text-muted-foreground">
|
||||
<Code2Icon className="w-3.5 h-3.5" />
|
||||
{t("memo.code")}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{memo.tags.length > 0 && (
|
||||
<div className="w-full space-y-2">
|
||||
<div className="flex items-center gap-1.5">
|
||||
<SectionLabel>{t("common.tags")}</SectionLabel>
|
||||
<span className="text-xs text-muted-foreground/30">({memo.tags.length})</span>
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-1.5">
|
||||
{memo.tags.map((tag) => (
|
||||
<span
|
||||
key={tag}
|
||||
className="inline-flex items-center gap-1 px-1 rounded-md border border-border/60 bg-muted/60 text-sm text-muted-foreground hover:bg-muted hover:text-foreground/80 transition-colors cursor-pointer"
|
||||
>
|
||||
<HashIcon className="w-3 h-3 opacity-50" />
|
||||
{tag}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</aside>
|
||||
);
|
||||
};
|
||||
|
||||
export default MemoDetailSidebar;
|
||||
@@ -0,0 +1,36 @@
|
||||
import { GanttChartIcon } from "lucide-react";
|
||||
import { useEffect, useState } from "react";
|
||||
import { useLocation } from "react-router-dom";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Sheet, SheetContent, SheetTrigger } from "@/components/ui/sheet";
|
||||
import { Memo } from "@/types/proto/api/v1/memo_service_pb";
|
||||
import MemoDetailSidebar from "./MemoDetailSidebar";
|
||||
|
||||
interface Props {
|
||||
memo: Memo;
|
||||
parentPage?: string;
|
||||
}
|
||||
|
||||
const MemoDetailSidebarDrawer = ({ memo, parentPage }: Props) => {
|
||||
const location = useLocation();
|
||||
const [open, setOpen] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
setOpen(false);
|
||||
}, [location.pathname]);
|
||||
|
||||
return (
|
||||
<Sheet open={open} onOpenChange={setOpen}>
|
||||
<SheetTrigger asChild>
|
||||
<Button variant="ghost" size="sm" className="px-2">
|
||||
<GanttChartIcon className="w-5 h-auto text-muted-foreground" />
|
||||
</Button>
|
||||
</SheetTrigger>
|
||||
<SheetContent side="right" className="w-full sm:w-80 px-4 bg-background">
|
||||
<MemoDetailSidebar className="py-4" memo={memo} parentPage={parentPage} />
|
||||
</SheetContent>
|
||||
</Sheet>
|
||||
);
|
||||
};
|
||||
|
||||
export default MemoDetailSidebarDrawer;
|
||||
4
web/src/components/MemoDetailSidebar/index.ts
Normal file
4
web/src/components/MemoDetailSidebar/index.ts
Normal file
@@ -0,0 +1,4 @@
|
||||
import MemoDetailSidebar from "./MemoDetailSidebar";
|
||||
import MemoDetailSidebarDrawer from "./MemoDetailSidebarDrawer";
|
||||
|
||||
export { MemoDetailSidebar, MemoDetailSidebarDrawer };
|
||||
61
web/src/components/MemoDisplaySettingMenu.tsx
Normal file
61
web/src/components/MemoDisplaySettingMenu.tsx
Normal file
@@ -0,0 +1,61 @@
|
||||
import { Settings2Icon } from "lucide-react";
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
||||
import { useView } from "@/contexts/ViewContext";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { useTranslate } from "@/utils/i18n";
|
||||
import { Popover, PopoverContent, PopoverTrigger } from "./ui/popover";
|
||||
|
||||
interface Props {
|
||||
className?: string;
|
||||
}
|
||||
|
||||
function MemoDisplaySettingMenu({ className }: Props) {
|
||||
const t = useTranslate();
|
||||
const { orderByTimeAsc, layout, toggleSortOrder, setLayout } = useView();
|
||||
const isApplying = orderByTimeAsc !== false || layout !== "LIST";
|
||||
|
||||
return (
|
||||
<Popover>
|
||||
<PopoverTrigger className={cn(className, isApplying ? "text-primary bg-primary/10 rounded" : "opacity-40")}>
|
||||
<Settings2Icon className="w-4 h-auto shrink-0" />
|
||||
</PopoverTrigger>
|
||||
<PopoverContent align="end" alignOffset={-12} sideOffset={14}>
|
||||
<div className="flex flex-col gap-2 p-1">
|
||||
<div className="w-full flex flex-row justify-between items-center">
|
||||
<span className="text-sm shrink-0 mr-3 text-foreground">{t("memo.direction")}</span>
|
||||
<Select
|
||||
value={orderByTimeAsc.toString()}
|
||||
onValueChange={(value) => {
|
||||
if ((value === "true") !== orderByTimeAsc) {
|
||||
toggleSortOrder();
|
||||
}
|
||||
}}
|
||||
>
|
||||
<SelectTrigger size="sm">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="false">{t("memo.direction-desc")}</SelectItem>
|
||||
<SelectItem value="true">{t("memo.direction-asc")}</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div className="w-full flex flex-row justify-between items-center">
|
||||
<span className="text-sm shrink-0 mr-3 text-foreground">{t("common.layout")}</span>
|
||||
<Select value={layout} onValueChange={(value) => setLayout(value as "LIST" | "MASONRY")}>
|
||||
<SelectTrigger size="sm">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="LIST">{t("memo.list")}</SelectItem>
|
||||
<SelectItem value="MASONRY">{t("memo.masonry")}</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
);
|
||||
}
|
||||
|
||||
export default MemoDisplaySettingMenu;
|
||||
181
web/src/components/MemoEditor/Editor/SlashCommands.tsx
Normal file
181
web/src/components/MemoEditor/Editor/SlashCommands.tsx
Normal file
@@ -0,0 +1,181 @@
|
||||
import { BotIcon } from "lucide-react";
|
||||
import { toast } from "react-hot-toast";
|
||||
import { aIServiceClient } from "@/connect";
|
||||
import type { SlashCommandsProps } from "../types";
|
||||
import type { EditorRefActions } from ".";
|
||||
import { SuggestionsPopup } from "./SuggestionsPopup";
|
||||
import { useSuggestions } from "./useSuggestions";
|
||||
|
||||
const SlashCommands = ({ editorRef, editorActions, commands }: SlashCommandsProps) => {
|
||||
const handleAICompletion = async (actions: EditorRefActions) => {
|
||||
try {
|
||||
const selectedText = actions.getSelectedContent();
|
||||
const fullContent = actions.getContent();
|
||||
|
||||
// Get custom instruction if exists
|
||||
const customInstruction = localStorage.getItem("ai-custom-instruction");
|
||||
const instructionPrefix = customInstruction ? `${customInstruction}\n\n` : '';
|
||||
|
||||
const prompt = instructionPrefix + (selectedText
|
||||
? `Complete or improve this text: "${selectedText}"\n\nContext: ${fullContent}`
|
||||
: `Continue writing this note: ${fullContent}`);
|
||||
|
||||
const response = await aIServiceClient.generateCompletion({
|
||||
prompt,
|
||||
provider: 1,
|
||||
temperature: 0.7,
|
||||
maxTokens: 500,
|
||||
});
|
||||
|
||||
if (response.text) {
|
||||
if (selectedText) {
|
||||
const cursorPos = actions.getCursorPosition();
|
||||
actions.removeText(cursorPos - selectedText.length, selectedText.length);
|
||||
actions.insertText(response.text);
|
||||
} else {
|
||||
actions.insertText(response.text);
|
||||
}
|
||||
toast.success("AI completion added!");
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("AI completion failed:", error);
|
||||
toast.error("Failed to generate AI completion");
|
||||
}
|
||||
};
|
||||
|
||||
const handleAISummarize = async (actions: EditorRefActions) => {
|
||||
try {
|
||||
const content = actions.getContent();
|
||||
if (!content.trim()) {
|
||||
toast.error("Nothing to summarize");
|
||||
return;
|
||||
}
|
||||
|
||||
// Get custom instruction if exists
|
||||
const customInstruction = localStorage.getItem("ai-custom-instruction");
|
||||
const instructionPrefix = customInstruction ? `${customInstruction}\n\n` : '';
|
||||
|
||||
const prompt = instructionPrefix + `Summarize this note concisely:\n\n${content}`;
|
||||
|
||||
const response = await aIServiceClient.generateCompletion({
|
||||
prompt,
|
||||
provider: 1,
|
||||
temperature: 0.3,
|
||||
maxTokens: 200,
|
||||
});
|
||||
|
||||
if (response.text) {
|
||||
actions.insertText(`\n\n**Summary:** ${response.text}`);
|
||||
toast.success("Summary generated!");
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("AI summarize failed:", error);
|
||||
toast.error("Failed to generate summary");
|
||||
}
|
||||
};
|
||||
|
||||
const handleAIImprove = async (actions: EditorRefActions) => {
|
||||
try {
|
||||
const selectedText = actions.getSelectedContent();
|
||||
const fullContent = actions.getContent();
|
||||
|
||||
const textToImprove = selectedText || fullContent;
|
||||
if (!textToImprove.trim()) {
|
||||
toast.error("Nothing to improve");
|
||||
return;
|
||||
}
|
||||
|
||||
// Get custom instruction if exists
|
||||
const customInstruction = localStorage.getItem("ai-custom-instruction");
|
||||
const instructionPrefix = customInstruction ? `${customInstruction}\n\n` : '';
|
||||
|
||||
const prompt = instructionPrefix + `Improve the grammar, clarity, and flow of this text:\n\n${textToImprove}`;
|
||||
|
||||
const response = await aIServiceClient.generateCompletion({
|
||||
prompt,
|
||||
provider: 1,
|
||||
temperature: 0.4,
|
||||
maxTokens: 1000,
|
||||
});
|
||||
|
||||
if (response.text) {
|
||||
if (selectedText) {
|
||||
const cursorPos = actions.getCursorPosition();
|
||||
actions.removeText(cursorPos - selectedText.length, selectedText.length);
|
||||
actions.insertText(response.text);
|
||||
} else {
|
||||
actions.setContent(response.text);
|
||||
}
|
||||
toast.success("Text improved!");
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("AI improve failed:", error);
|
||||
toast.error("Failed to improve text");
|
||||
}
|
||||
};
|
||||
|
||||
const handleCommandAutocomplete = async (cmd: (typeof commands)[0], word: string, index: number, actions: EditorRefActions) => {
|
||||
// Handle AI commands specially
|
||||
if (cmd.name.startsWith('ai')) {
|
||||
actions.removeText(index, word.length);
|
||||
|
||||
switch (cmd.name) {
|
||||
case 'aicomplete':
|
||||
await handleAICompletion(actions);
|
||||
break;
|
||||
case 'aisummary':
|
||||
await handleAISummarize(actions);
|
||||
break;
|
||||
case 'aiimprove':
|
||||
await handleAIImprove(actions);
|
||||
break;
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// Handle regular commands
|
||||
actions.removeText(index, word.length);
|
||||
actions.insertText(cmd.run());
|
||||
// Position cursor relative to insertion point, if specified
|
||||
if (cmd.cursorOffset) {
|
||||
actions.setCursorPosition(index + cmd.cursorOffset);
|
||||
}
|
||||
};
|
||||
|
||||
const { position, suggestions, selectedIndex, isVisible, handleItemSelect } = useSuggestions({
|
||||
editorRef,
|
||||
editorActions,
|
||||
triggerChar: "/",
|
||||
items: commands,
|
||||
filterItems: (items, query) => (!query ? items : items.filter((cmd) => cmd.name.toLowerCase().startsWith(query))),
|
||||
onAutocomplete: handleCommandAutocomplete,
|
||||
});
|
||||
|
||||
if (!isVisible || !position) return null;
|
||||
|
||||
return (
|
||||
<SuggestionsPopup
|
||||
position={position}
|
||||
suggestions={suggestions}
|
||||
selectedIndex={selectedIndex}
|
||||
onItemSelect={handleItemSelect}
|
||||
getItemKey={(cmd) => cmd.name}
|
||||
renderItem={(cmd) => (
|
||||
<span className="tracking-wide flex items-center">
|
||||
<span className="text-muted-foreground">/</span>
|
||||
{cmd.name}
|
||||
{cmd.name.startsWith('ai') && (
|
||||
<BotIcon className="w-3 h-3 ml-2 text-muted-foreground" />
|
||||
)}
|
||||
{cmd.description && (
|
||||
<span className="ml-2 text-xs text-muted-foreground truncate">
|
||||
{cmd.description}
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
)}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
export default SlashCommands;
|
||||
49
web/src/components/MemoEditor/Editor/SuggestionsPopup.tsx
Normal file
49
web/src/components/MemoEditor/Editor/SuggestionsPopup.tsx
Normal file
@@ -0,0 +1,49 @@
|
||||
import { ReactNode, useEffect, useRef } from "react";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { Position } from "./useSuggestions";
|
||||
|
||||
interface SuggestionsPopupProps<T> {
|
||||
position: Position;
|
||||
suggestions: T[];
|
||||
selectedIndex: number;
|
||||
onItemSelect: (item: T) => void;
|
||||
renderItem: (item: T, isSelected: boolean) => ReactNode;
|
||||
getItemKey: (item: T, index: number) => string;
|
||||
}
|
||||
|
||||
const POPUP_STYLES = {
|
||||
container:
|
||||
"z-20 absolute p-1 mt-1 -ml-2 max-w-48 max-h-60 rounded border bg-popover text-popover-foreground shadow-lg font-mono flex flex-col overflow-y-auto overflow-x-hidden",
|
||||
item: "rounded p-1 px-2 w-full text-sm cursor-pointer transition-colors select-none hover:bg-accent hover:text-accent-foreground",
|
||||
};
|
||||
|
||||
export function SuggestionsPopup<T>({
|
||||
position,
|
||||
suggestions,
|
||||
selectedIndex,
|
||||
onItemSelect,
|
||||
renderItem,
|
||||
getItemKey,
|
||||
}: SuggestionsPopupProps<T>) {
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
const selectedItemRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
selectedItemRef.current?.scrollIntoView({ block: "nearest", behavior: "smooth" });
|
||||
}, [selectedIndex]);
|
||||
|
||||
return (
|
||||
<div ref={containerRef} className={POPUP_STYLES.container} style={{ left: position.left, top: position.top + position.height }}>
|
||||
{suggestions.map((item, i) => (
|
||||
<div
|
||||
key={getItemKey(item, i)}
|
||||
ref={i === selectedIndex ? selectedItemRef : null}
|
||||
onMouseDown={() => onItemSelect(item)}
|
||||
className={cn(POPUP_STYLES.item, i === selectedIndex && "bg-accent text-accent-foreground")}
|
||||
>
|
||||
{renderItem(item, i === selectedIndex)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
50
web/src/components/MemoEditor/Editor/TagSuggestions.tsx
Normal file
50
web/src/components/MemoEditor/Editor/TagSuggestions.tsx
Normal file
@@ -0,0 +1,50 @@
|
||||
import { useMemo } from "react";
|
||||
import { matchPath } from "react-router-dom";
|
||||
import OverflowTip from "@/components/kit/OverflowTip";
|
||||
import { useTagCounts } from "@/hooks/useUserQueries";
|
||||
import { Routes } from "@/router";
|
||||
import type { TagSuggestionsProps } from "../types";
|
||||
import { SuggestionsPopup } from "./SuggestionsPopup";
|
||||
import { useSuggestions } from "./useSuggestions";
|
||||
|
||||
export default function TagSuggestions({ editorRef, editorActions }: TagSuggestionsProps) {
|
||||
// On explore page, show all users' tags; otherwise show current user's tags
|
||||
const isExplorePage = Boolean(matchPath(Routes.EXPLORE, window.location.pathname));
|
||||
const { data: tagCount = {} } = useTagCounts(!isExplorePage);
|
||||
|
||||
const sortedTags = useMemo(() => {
|
||||
return Object.entries(tagCount)
|
||||
.sort((a, b) => b[1] - a[1] || a[0].localeCompare(b[0]))
|
||||
.map(([tag]) => tag);
|
||||
}, [tagCount]);
|
||||
|
||||
const { position, suggestions, selectedIndex, isVisible, handleItemSelect } = useSuggestions({
|
||||
editorRef,
|
||||
editorActions,
|
||||
triggerChar: "#",
|
||||
items: sortedTags,
|
||||
filterItems: (items, query) => (!query ? items : items.filter((tag) => tag.toLowerCase().includes(query))),
|
||||
onAutocomplete: (tag, word, index, actions) => {
|
||||
actions.removeText(index, word.length);
|
||||
actions.insertText(`#${tag} `);
|
||||
},
|
||||
});
|
||||
|
||||
if (!isVisible || !position) return null;
|
||||
|
||||
return (
|
||||
<SuggestionsPopup
|
||||
position={position}
|
||||
suggestions={suggestions}
|
||||
selectedIndex={selectedIndex}
|
||||
onItemSelect={handleItemSelect}
|
||||
getItemKey={(tag) => tag}
|
||||
renderItem={(tag) => (
|
||||
<OverflowTip>
|
||||
<span className="text-muted-foreground mr-1">#</span>
|
||||
{tag}
|
||||
</OverflowTip>
|
||||
)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
56
web/src/components/MemoEditor/Editor/commands.ts
Normal file
56
web/src/components/MemoEditor/Editor/commands.ts
Normal file
@@ -0,0 +1,56 @@
|
||||
export interface Command {
|
||||
name: string;
|
||||
run: () => string;
|
||||
cursorOffset?: number;
|
||||
description?: string;
|
||||
icon?: string;
|
||||
}
|
||||
|
||||
export const editorCommands: Command[] = [
|
||||
{
|
||||
name: "todo",
|
||||
run: () => "- [ ] ",
|
||||
cursorOffset: 6,
|
||||
description: "Add a todo item",
|
||||
},
|
||||
{
|
||||
name: "code",
|
||||
run: () => "```\n\n```",
|
||||
cursorOffset: 4,
|
||||
description: "Insert code block",
|
||||
},
|
||||
{
|
||||
name: "link",
|
||||
run: () => "[text](url)",
|
||||
cursorOffset: 1,
|
||||
description: "Insert link",
|
||||
},
|
||||
{
|
||||
name: "table",
|
||||
run: () => "| Header | Header |\n| ------ | ------ |\n| Cell | Cell |",
|
||||
cursorOffset: 1,
|
||||
description: "Insert table",
|
||||
},
|
||||
{
|
||||
name: "imgpos",
|
||||
run: () => "[img:attachment-id|align=center|width=50%]",
|
||||
cursorOffset: 5,
|
||||
description: "Position image with ID",
|
||||
},
|
||||
// AI Commands
|
||||
{
|
||||
name: "aicomplete",
|
||||
run: () => "",
|
||||
description: "AI complete text",
|
||||
},
|
||||
{
|
||||
name: "aisummary",
|
||||
run: () => "",
|
||||
description: "AI summarize note",
|
||||
},
|
||||
{
|
||||
name: "aiimprove",
|
||||
run: () => "",
|
||||
description: "AI improve writing",
|
||||
},
|
||||
];
|
||||
221
web/src/components/MemoEditor/Editor/index.tsx
Normal file
221
web/src/components/MemoEditor/Editor/index.tsx
Normal file
@@ -0,0 +1,221 @@
|
||||
import { forwardRef, useCallback, useEffect, useImperativeHandle, useMemo, useRef } from "react";
|
||||
import getCaretCoordinates from "textarea-caret";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { EDITOR_HEIGHT } from "../constants";
|
||||
import type { EditorProps } from "../types";
|
||||
import { editorCommands } from "./commands";
|
||||
import SlashCommands from "./SlashCommands";
|
||||
import TagSuggestions from "./TagSuggestions";
|
||||
import { useListCompletion } from "./useListCompletion";
|
||||
|
||||
export interface EditorRefActions {
|
||||
getEditor: () => HTMLTextAreaElement | null;
|
||||
focus: () => void;
|
||||
scrollToCursor: () => void;
|
||||
insertText: (text: string, prefix?: string, suffix?: string) => void;
|
||||
removeText: (start: number, length: number) => void;
|
||||
setContent: (text: string) => void;
|
||||
getContent: () => string;
|
||||
getSelectedContent: () => string;
|
||||
getCursorPosition: () => number;
|
||||
setCursorPosition: (startPos: number, endPos?: number) => void;
|
||||
getCursorLineNumber: () => number;
|
||||
getLine: (lineNumber: number) => string;
|
||||
setLine: (lineNumber: number, text: string) => void;
|
||||
}
|
||||
|
||||
const Editor = forwardRef(function Editor(props: EditorProps, ref: React.ForwardedRef<EditorRefActions>) {
|
||||
const {
|
||||
className,
|
||||
initialContent,
|
||||
placeholder,
|
||||
onPaste,
|
||||
onContentChange: handleContentChangeCallback,
|
||||
isFocusMode,
|
||||
isInIME = false,
|
||||
onCompositionStart,
|
||||
onCompositionEnd,
|
||||
} = props;
|
||||
const editorRef = useRef<HTMLTextAreaElement>(null);
|
||||
|
||||
const updateEditorHeight = useCallback(() => {
|
||||
if (editorRef.current) {
|
||||
editorRef.current.style.height = "auto";
|
||||
editorRef.current.style.height = `${editorRef.current.scrollHeight ?? 0}px`;
|
||||
}
|
||||
}, []);
|
||||
|
||||
const updateContent = useCallback(() => {
|
||||
if (editorRef.current) {
|
||||
handleContentChangeCallback(editorRef.current.value);
|
||||
updateEditorHeight();
|
||||
}
|
||||
}, [handleContentChangeCallback, updateEditorHeight]);
|
||||
|
||||
const scrollToCaret = useCallback((options: { force?: boolean } = {}) => {
|
||||
const editor = editorRef.current;
|
||||
if (!editor) return;
|
||||
|
||||
const { force = false } = options;
|
||||
const caret = getCaretCoordinates(editor, editor.selectionEnd);
|
||||
|
||||
if (force) {
|
||||
editor.scrollTop = Math.max(0, caret.top - editor.clientHeight / 2);
|
||||
return;
|
||||
}
|
||||
|
||||
const lineHeight = parseFloat(getComputedStyle(editor).lineHeight) || 24;
|
||||
const viewportBottom = editor.scrollTop + editor.clientHeight;
|
||||
// Scroll if cursor is near or beyond bottom edge (within 2 lines)
|
||||
if (caret.top + lineHeight * 2 > viewportBottom) {
|
||||
editor.scrollTop = Math.max(0, caret.top - editor.clientHeight / 2);
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (editorRef.current && initialContent) {
|
||||
editorRef.current.value = initialContent;
|
||||
handleContentChangeCallback(initialContent);
|
||||
updateEditorHeight();
|
||||
}
|
||||
// Only run once on mount to set initial content
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, []);
|
||||
|
||||
// Update editor when content is externally changed (e.g., reset after save)
|
||||
useEffect(() => {
|
||||
if (editorRef.current && editorRef.current.value !== initialContent) {
|
||||
editorRef.current.value = initialContent;
|
||||
updateEditorHeight();
|
||||
}
|
||||
}, [initialContent, updateEditorHeight]);
|
||||
|
||||
const editorActions: EditorRefActions = useMemo(
|
||||
() => ({
|
||||
getEditor: () => editorRef.current,
|
||||
focus: () => editorRef.current?.focus(),
|
||||
scrollToCursor: () => {
|
||||
scrollToCaret({ force: true });
|
||||
},
|
||||
insertText: (content = "", prefix = "", suffix = "") => {
|
||||
const editor = editorRef.current;
|
||||
if (!editor) return;
|
||||
|
||||
const cursorPos = editor.selectionStart;
|
||||
const endPos = editor.selectionEnd;
|
||||
const prev = editor.value;
|
||||
const actual = content || prev.slice(cursorPos, endPos);
|
||||
editor.value = prev.slice(0, cursorPos) + prefix + actual + suffix + prev.slice(endPos);
|
||||
|
||||
editor.focus();
|
||||
editor.setSelectionRange(cursorPos + prefix.length + actual.length, cursorPos + prefix.length + actual.length);
|
||||
updateContent();
|
||||
},
|
||||
removeText: (start: number, length: number) => {
|
||||
const editor = editorRef.current;
|
||||
if (!editor) return;
|
||||
|
||||
editor.value = editor.value.slice(0, start) + editor.value.slice(start + length);
|
||||
editor.focus();
|
||||
editor.setSelectionRange(start, start);
|
||||
updateContent();
|
||||
},
|
||||
setContent: (text: string) => {
|
||||
const editor = editorRef.current;
|
||||
if (editor) {
|
||||
editor.value = text;
|
||||
updateContent();
|
||||
}
|
||||
},
|
||||
getContent: () => editorRef.current?.value ?? "",
|
||||
getCursorPosition: () => editorRef.current?.selectionStart ?? 0,
|
||||
getSelectedContent: () => {
|
||||
const editor = editorRef.current;
|
||||
if (!editor) return "";
|
||||
return editor.value.slice(editor.selectionStart, editor.selectionEnd);
|
||||
},
|
||||
setCursorPosition: (startPos: number, endPos?: number) => {
|
||||
const editor = editorRef.current;
|
||||
if (!editor) return;
|
||||
// setSelectionRange requires valid arguments; default to startPos if endPos is undefined
|
||||
const endPosition = endPos !== undefined && !Number.isNaN(endPos) ? endPos : startPos;
|
||||
editor.setSelectionRange(startPos, endPosition);
|
||||
},
|
||||
getCursorLineNumber: () => {
|
||||
const editor = editorRef.current;
|
||||
if (!editor) return 0;
|
||||
const lines = editor.value.slice(0, editor.selectionStart).split("\n");
|
||||
return lines.length - 1;
|
||||
},
|
||||
getLine: (lineNumber: number) => editorRef.current?.value.split("\n")[lineNumber] ?? "",
|
||||
setLine: (lineNumber: number, text: string) => {
|
||||
const editor = editorRef.current;
|
||||
if (!editor) return;
|
||||
const lines = editor.value.split("\n");
|
||||
lines[lineNumber] = text;
|
||||
editor.value = lines.join("\n");
|
||||
editor.focus();
|
||||
updateContent();
|
||||
},
|
||||
}),
|
||||
[updateContent, scrollToCaret],
|
||||
);
|
||||
|
||||
useImperativeHandle(ref, () => editorActions, [editorActions]);
|
||||
|
||||
// Also expose the textarea ref for external access
|
||||
useEffect(() => {
|
||||
if (editorRef.current) {
|
||||
// Make textarea accessible through editor actions if needed
|
||||
(editorActions as any).textarea = editorRef.current;
|
||||
}
|
||||
}, [editorActions]);
|
||||
|
||||
const handleEditorInput = useCallback(() => {
|
||||
if (editorRef.current) {
|
||||
handleContentChangeCallback(editorRef.current.value);
|
||||
updateEditorHeight();
|
||||
|
||||
// Auto-scroll to keep cursor visible when typing
|
||||
// See: https://github.com/usememos/memos/issues/5469
|
||||
scrollToCaret();
|
||||
}
|
||||
}, [handleContentChangeCallback, updateEditorHeight, scrollToCaret]);
|
||||
|
||||
// Auto-complete markdown lists when pressing Enter
|
||||
useListCompletion({
|
||||
editorRef,
|
||||
editorActions,
|
||||
isInIME,
|
||||
});
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"flex flex-col justify-start items-start relative w-full bg-inherit",
|
||||
// Focus mode: flex-1 to grow and fill space; Normal: h-auto with max-height
|
||||
isFocusMode ? "flex-1" : `h-auto ${EDITOR_HEIGHT.normal}`,
|
||||
className,
|
||||
)}
|
||||
>
|
||||
<textarea
|
||||
className={cn(
|
||||
"w-full text-base resize-none overflow-x-hidden overflow-y-auto bg-transparent outline-none placeholder:opacity-70 whitespace-pre-wrap wrap-break-word",
|
||||
// Focus mode: flex-1 h-0 to grow within flex container; Normal: h-full to fill wrapper
|
||||
isFocusMode ? "flex-1 h-0" : "h-full",
|
||||
)}
|
||||
rows={1}
|
||||
placeholder={placeholder}
|
||||
ref={editorRef}
|
||||
onPaste={onPaste}
|
||||
onInput={handleEditorInput}
|
||||
onCompositionStart={onCompositionStart}
|
||||
onCompositionEnd={onCompositionEnd}
|
||||
></textarea>
|
||||
<TagSuggestions editorRef={editorRef} editorActions={ref} />
|
||||
<SlashCommands editorRef={editorRef} editorActions={ref} commands={editorCommands} />
|
||||
</div>
|
||||
);
|
||||
});
|
||||
|
||||
export default Editor;
|
||||
70
web/src/components/MemoEditor/Editor/shortcuts.ts
Normal file
70
web/src/components/MemoEditor/Editor/shortcuts.ts
Normal file
@@ -0,0 +1,70 @@
|
||||
import type { EditorRefActions } from "./index";
|
||||
|
||||
const SHORTCUTS = {
|
||||
BOLD: { key: "b", delimiter: "**" },
|
||||
ITALIC: { key: "i", delimiter: "*" },
|
||||
LINK: { key: "k" },
|
||||
} as const;
|
||||
|
||||
const URL_PLACEHOLDER = "url";
|
||||
const URL_REGEX = /^https?:\/\/[^\s]+$/;
|
||||
const LINK_OFFSET = 3; // Length of "]()"
|
||||
|
||||
export function handleMarkdownShortcuts(event: React.KeyboardEvent, editor: EditorRefActions): void {
|
||||
const key = event.key.toLowerCase();
|
||||
if (key === SHORTCUTS.BOLD.key) {
|
||||
event.preventDefault();
|
||||
toggleTextStyle(editor, SHORTCUTS.BOLD.delimiter);
|
||||
} else if (key === SHORTCUTS.ITALIC.key) {
|
||||
event.preventDefault();
|
||||
toggleTextStyle(editor, SHORTCUTS.ITALIC.delimiter);
|
||||
} else if (key === SHORTCUTS.LINK.key) {
|
||||
event.preventDefault();
|
||||
insertHyperlink(editor);
|
||||
}
|
||||
}
|
||||
|
||||
export function insertHyperlink(editor: EditorRefActions, url?: string): void {
|
||||
const cursorPosition = editor.getCursorPosition();
|
||||
const selectedContent = editor.getSelectedContent();
|
||||
const isUrlSelected = !url && URL_REGEX.test(selectedContent.trim());
|
||||
|
||||
if (isUrlSelected) {
|
||||
editor.insertText(`[](${selectedContent})`);
|
||||
editor.setCursorPosition(cursorPosition + 1, cursorPosition + 1);
|
||||
return;
|
||||
}
|
||||
|
||||
const href = url ?? URL_PLACEHOLDER;
|
||||
editor.insertText(`[${selectedContent}](${href})`);
|
||||
|
||||
if (href === URL_PLACEHOLDER) {
|
||||
const urlStart = cursorPosition + selectedContent.length + LINK_OFFSET;
|
||||
editor.setCursorPosition(urlStart, urlStart + href.length);
|
||||
}
|
||||
}
|
||||
|
||||
function toggleTextStyle(editor: EditorRefActions, delimiter: string): void {
|
||||
const cursorPosition = editor.getCursorPosition();
|
||||
const selectedContent = editor.getSelectedContent();
|
||||
const isStyled = selectedContent.startsWith(delimiter) && selectedContent.endsWith(delimiter);
|
||||
|
||||
if (isStyled) {
|
||||
const unstyled = selectedContent.slice(delimiter.length, -delimiter.length);
|
||||
editor.insertText(unstyled);
|
||||
editor.setCursorPosition(cursorPosition, cursorPosition + unstyled.length);
|
||||
} else {
|
||||
editor.insertText(`${delimiter}${selectedContent}${delimiter}`);
|
||||
editor.setCursorPosition(cursorPosition + delimiter.length, cursorPosition + delimiter.length + selectedContent.length);
|
||||
}
|
||||
}
|
||||
|
||||
export function hyperlinkHighlightedText(editor: EditorRefActions, url: string): void {
|
||||
const selectedContent = editor.getSelectedContent();
|
||||
const cursorPosition = editor.getCursorPosition();
|
||||
|
||||
editor.insertText(`[${selectedContent}](${url})`);
|
||||
|
||||
const newPosition = cursorPosition + selectedContent.length + url.length + 4;
|
||||
editor.setCursorPosition(newPosition, newPosition);
|
||||
}
|
||||
82
web/src/components/MemoEditor/Editor/useListCompletion.ts
Normal file
82
web/src/components/MemoEditor/Editor/useListCompletion.ts
Normal file
@@ -0,0 +1,82 @@
|
||||
import { useEffect, useRef } from "react";
|
||||
import { detectLastListItem, generateListContinuation } from "@/utils/markdown-list-detection";
|
||||
import { EditorRefActions } from ".";
|
||||
|
||||
interface UseListCompletionOptions {
|
||||
editorRef: React.RefObject<HTMLTextAreaElement>;
|
||||
editorActions: EditorRefActions;
|
||||
isInIME: boolean;
|
||||
}
|
||||
|
||||
// Patterns to detect empty list items
|
||||
const EMPTY_LIST_PATTERNS = [
|
||||
/^(\s*)([-*+])\s*$/, // Empty unordered list
|
||||
/^(\s*)([-*+])\s+\[([ xX])\]\s*$/, // Empty task list
|
||||
/^(\s*)(\d+)[.)]\s*$/, // Empty ordered list
|
||||
];
|
||||
|
||||
const isEmptyListItem = (line: string) => EMPTY_LIST_PATTERNS.some((pattern) => pattern.test(line));
|
||||
|
||||
export function useListCompletion({ editorRef, editorActions, isInIME }: UseListCompletionOptions) {
|
||||
const isInIMERef = useRef(isInIME);
|
||||
isInIMERef.current = isInIME;
|
||||
|
||||
const editorActionsRef = useRef(editorActions);
|
||||
editorActionsRef.current = editorActions;
|
||||
|
||||
// Track when composition ends to handle Safari race condition
|
||||
// Safari fires keydown(Enter) immediately after compositionend, while Chrome doesn't
|
||||
// See: https://github.com/usememos/memos/issues/5469
|
||||
const lastCompositionEndRef = useRef(0);
|
||||
|
||||
useEffect(() => {
|
||||
const editor = editorRef.current;
|
||||
if (!editor) return;
|
||||
|
||||
const handleCompositionEnd = () => {
|
||||
lastCompositionEndRef.current = Date.now();
|
||||
};
|
||||
|
||||
const handleKeyDown = (event: KeyboardEvent) => {
|
||||
if (event.key !== "Enter" || isInIMERef.current || event.shiftKey || event.ctrlKey || event.metaKey || event.altKey) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Safari fix: Ignore Enter key within 100ms of composition end
|
||||
// This prevents double-enter behavior when confirming IME input in lists
|
||||
if (Date.now() - lastCompositionEndRef.current < 100) {
|
||||
return;
|
||||
}
|
||||
|
||||
const actions = editorActionsRef.current;
|
||||
const cursorPosition = actions.getCursorPosition();
|
||||
const contentBeforeCursor = actions.getContent().substring(0, cursorPosition);
|
||||
const listInfo = detectLastListItem(contentBeforeCursor);
|
||||
|
||||
if (!listInfo.type) return;
|
||||
|
||||
event.preventDefault();
|
||||
|
||||
const lines = contentBeforeCursor.split("\n");
|
||||
const currentLine = lines[lines.length - 1];
|
||||
|
||||
if (isEmptyListItem(currentLine)) {
|
||||
const lineStartPos = cursorPosition - currentLine.length;
|
||||
actions.removeText(lineStartPos, currentLine.length);
|
||||
} else {
|
||||
const continuation = generateListContinuation(listInfo);
|
||||
actions.insertText("\n" + continuation);
|
||||
|
||||
// Auto-scroll to keep cursor visible after inserting list item
|
||||
setTimeout(() => actions.scrollToCursor(), 0);
|
||||
}
|
||||
};
|
||||
|
||||
editor.addEventListener("compositionend", handleCompositionEnd);
|
||||
editor.addEventListener("keydown", handleKeyDown);
|
||||
return () => {
|
||||
editor.removeEventListener("compositionend", handleCompositionEnd);
|
||||
editor.removeEventListener("keydown", handleKeyDown);
|
||||
};
|
||||
}, []);
|
||||
}
|
||||
158
web/src/components/MemoEditor/Editor/useSuggestions.ts
Normal file
158
web/src/components/MemoEditor/Editor/useSuggestions.ts
Normal file
@@ -0,0 +1,158 @@
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import getCaretCoordinates from "textarea-caret";
|
||||
import { EditorRefActions } from ".";
|
||||
|
||||
export interface Position {
|
||||
left: number;
|
||||
top: number;
|
||||
height: number;
|
||||
}
|
||||
|
||||
export interface UseSuggestionsOptions<T> {
|
||||
editorRef: React.RefObject<HTMLTextAreaElement>;
|
||||
editorActions: React.ForwardedRef<EditorRefActions>;
|
||||
triggerChar: string;
|
||||
items: T[];
|
||||
filterItems: (items: T[], searchQuery: string) => T[];
|
||||
onAutocomplete: (item: T, word: string, startIndex: number, actions: EditorRefActions) => void;
|
||||
}
|
||||
|
||||
export interface UseSuggestionsReturn<T> {
|
||||
position: Position | null;
|
||||
suggestions: T[];
|
||||
selectedIndex: number;
|
||||
isVisible: boolean;
|
||||
handleItemSelect: (item: T) => void;
|
||||
}
|
||||
|
||||
export function useSuggestions<T>({
|
||||
editorRef,
|
||||
editorActions,
|
||||
triggerChar,
|
||||
items,
|
||||
filterItems,
|
||||
onAutocomplete,
|
||||
}: UseSuggestionsOptions<T>): UseSuggestionsReturn<T> {
|
||||
const [position, setPosition] = useState<Position | null>(null);
|
||||
const [selectedIndex, setSelectedIndex] = useState(0);
|
||||
const isProcessingRef = useRef(false);
|
||||
|
||||
const selectedRef = useRef(selectedIndex);
|
||||
selectedRef.current = selectedIndex;
|
||||
|
||||
const getCurrentWord = (): [word: string, startIndex: number] => {
|
||||
const editor = editorRef.current;
|
||||
if (!editor) return ["", 0];
|
||||
const cursorPos = editor.selectionEnd;
|
||||
const before = editor.value.slice(0, cursorPos).match(/\S*$/) || { 0: "", index: cursorPos };
|
||||
const after = editor.value.slice(cursorPos).match(/^\S*/) || { 0: "" };
|
||||
return [before[0] + after[0], before.index ?? cursorPos];
|
||||
};
|
||||
|
||||
const hide = () => setPosition(null);
|
||||
|
||||
const suggestionsRef = useRef<T[]>([]);
|
||||
suggestionsRef.current = (() => {
|
||||
const [word] = getCurrentWord();
|
||||
if (!word.startsWith(triggerChar)) return [];
|
||||
const searchQuery = word.slice(triggerChar.length).toLowerCase();
|
||||
return filterItems(items, searchQuery);
|
||||
})();
|
||||
|
||||
const isVisibleRef = useRef(false);
|
||||
isVisibleRef.current = !!(position && suggestionsRef.current.length > 0);
|
||||
|
||||
const handleAutocomplete = (item: T) => {
|
||||
if (!editorActions || !("current" in editorActions) || !editorActions.current) {
|
||||
console.warn("useSuggestions: editorActions not available");
|
||||
return;
|
||||
}
|
||||
isProcessingRef.current = true;
|
||||
const [word, index] = getCurrentWord();
|
||||
onAutocomplete(item, word, index, editorActions.current);
|
||||
hide();
|
||||
// Re-enable input handling after all DOM operations complete
|
||||
queueMicrotask(() => {
|
||||
isProcessingRef.current = false;
|
||||
});
|
||||
};
|
||||
|
||||
const handleNavigation = (e: KeyboardEvent, selected: number, suggestionsCount: number) => {
|
||||
if (e.code === "ArrowDown") {
|
||||
setSelectedIndex((selected + 1) % suggestionsCount);
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
} else if (e.code === "ArrowUp") {
|
||||
setSelectedIndex((selected - 1 + suggestionsCount) % suggestionsCount);
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
}
|
||||
};
|
||||
|
||||
const handleKeyDown = (e: KeyboardEvent) => {
|
||||
if (!isVisibleRef.current) return;
|
||||
|
||||
const suggestions = suggestionsRef.current;
|
||||
const selected = selectedRef.current;
|
||||
|
||||
if (["Escape", "ArrowLeft", "ArrowRight"].includes(e.code)) {
|
||||
hide();
|
||||
return;
|
||||
}
|
||||
|
||||
if (["ArrowDown", "ArrowUp"].includes(e.code)) {
|
||||
handleNavigation(e, selected, suggestions.length);
|
||||
return;
|
||||
}
|
||||
|
||||
if (["Enter", "Tab"].includes(e.code)) {
|
||||
handleAutocomplete(suggestions[selected]);
|
||||
e.preventDefault();
|
||||
e.stopImmediatePropagation();
|
||||
}
|
||||
};
|
||||
|
||||
const handleInput = () => {
|
||||
if (isProcessingRef.current) return;
|
||||
|
||||
const editor = editorRef.current;
|
||||
if (!editor) return;
|
||||
|
||||
setSelectedIndex(0);
|
||||
const [word, index] = getCurrentWord();
|
||||
const currentChar = editor.value[editor.selectionEnd];
|
||||
const isActive = word.startsWith(triggerChar) && currentChar !== triggerChar;
|
||||
|
||||
if (isActive) {
|
||||
const coords = getCaretCoordinates(editor, index);
|
||||
coords.top -= editor.scrollTop;
|
||||
setPosition(coords);
|
||||
} else {
|
||||
hide();
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
const editor = editorRef.current;
|
||||
if (!editor) return;
|
||||
|
||||
const handlers = { click: hide, blur: hide, keydown: handleKeyDown, input: handleInput };
|
||||
Object.entries(handlers).forEach(([event, handler]) => {
|
||||
editor.addEventListener(event, handler as EventListener);
|
||||
});
|
||||
|
||||
return () => {
|
||||
Object.entries(handlers).forEach(([event, handler]) => {
|
||||
editor.removeEventListener(event, handler as EventListener);
|
||||
});
|
||||
};
|
||||
}, []);
|
||||
|
||||
return {
|
||||
position,
|
||||
suggestions: suggestionsRef.current,
|
||||
selectedIndex,
|
||||
isVisible: isVisibleRef.current,
|
||||
handleItemSelect: handleAutocomplete,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,111 @@
|
||||
# Image Positioning Feature Implementation
|
||||
|
||||
## Overview
|
||||
|
||||
This implementation adds support for precise image positioning in Memos using ID-based tags with customizable parameters.
|
||||
|
||||
## Features Implemented
|
||||
|
||||
### 1. Core Data Structures
|
||||
- **PositionedImage interface**: Tracks positioning parameters for each image
|
||||
- **Enhanced EditorState**: Added `positionedImages` array to track positioned images
|
||||
- **New Actions**: CRUD operations for positioned images
|
||||
|
||||
### 2. UI Components
|
||||
- **ImagePositionDialog**: Modal dialog for configuring image positioning
|
||||
- **PositionedImageView**: Component that renders positioned images with proper styling
|
||||
- **Toolbar Integration**: "Position Image" button added to the insert menu
|
||||
|
||||
### 3. State Management
|
||||
- Added positioned images to editor state
|
||||
- Created actions for adding/removing/updating positioned images
|
||||
- Extended reducer to handle positioned image operations
|
||||
|
||||
### 4. Markdown Syntax
|
||||
Supports the syntax: `[img:attachment-id|param=value|param=value]`
|
||||
|
||||
#### Supported Parameters:
|
||||
- `align`: left | center | right
|
||||
- `width`: CSS width value (e.g., 50%, 300px)
|
||||
- `height`: CSS height value (e.g., 200px, auto)
|
||||
- `float`: left | right
|
||||
- `margin`: CSS margin value (e.g., 1rem 0, 10px)
|
||||
- `alt`: alternative text for accessibility
|
||||
|
||||
## Implementation Details
|
||||
|
||||
### File Structure
|
||||
```
|
||||
web/src/components/MemoEditor/
|
||||
├── components/
|
||||
│ ├── ImagePositionDialog.tsx # Dialog for positioning configuration
|
||||
│ └── PositionedImageView.tsx # Component for rendering positioned images
|
||||
├── state/
|
||||
│ ├── actions.ts # Added positioned image actions
|
||||
│ ├── reducer.ts # Added positioned image reducers
|
||||
│ └── types.ts # Added PositionedImage interface
|
||||
├── Toolbar/
|
||||
│ └── InsertMenu.tsx # Added "Position Image" menu item
|
||||
├── types/
|
||||
│ └── attachment.ts # Added PositionedImage interface
|
||||
└── utils/remark-plugins/
|
||||
└── remark-image-position.ts # Markdown parser for image positioning tags
|
||||
```
|
||||
|
||||
### Key Components
|
||||
|
||||
#### ImagePositionDialog
|
||||
- Provides UI for selecting images and configuring positioning
|
||||
- Generates appropriate markdown tags
|
||||
- Inserts tags at cursor position in the editor
|
||||
|
||||
#### PositionedImageView
|
||||
- Renders images with configured positioning
|
||||
- Resolves attachment URLs from IDs
|
||||
- Provides fallback for missing attachments
|
||||
- Applies CSS styling based on parameters
|
||||
|
||||
#### Remark Plugin
|
||||
- Parses `[img:id|...]` syntax in markdown
|
||||
- Converts tags to AST nodes for proper rendering
|
||||
- Preserves positioning parameters for the renderer
|
||||
|
||||
## Usage Workflow
|
||||
|
||||
1. **Upload Image**: User uploads image through normal process
|
||||
2. **Open Positioning Dialog**: Click "Position Image" in toolbar
|
||||
3. **Configure Positioning**: Select image and set parameters
|
||||
4. **Insert Tag**: System generates and inserts markdown tag
|
||||
5. **Render**: Image displays with specified positioning
|
||||
|
||||
## Examples
|
||||
|
||||
```markdown
|
||||
# Basic centered image
|
||||
[img:upload-1234567890|align=center]
|
||||
|
||||
# Left-aligned with specific width
|
||||
[img:upload-1234567890|align=left|width=300px]
|
||||
|
||||
# Floated right with margin
|
||||
[img:upload-1234567890|float=right|width=40%|margin=1rem]
|
||||
|
||||
# Full customization
|
||||
[img:upload-1234567890|align=center|width=80%|height=200px|margin=2rem 0|alt=Beautiful landscape]
|
||||
```
|
||||
|
||||
## Benefits
|
||||
|
||||
1. **Precise Control**: Fine-grained positioning control over images
|
||||
2. **Non-Destructive**: Images remain in attachments, only positioning changes
|
||||
3. **Backward Compatible**: Works alongside existing markdown images
|
||||
4. **SEO Friendly**: Supports alt text for accessibility
|
||||
5. **Flexible**: Supports various CSS positioning techniques
|
||||
|
||||
## Future Enhancements
|
||||
|
||||
- Drag & drop positioning interface
|
||||
- Preset positioning templates
|
||||
- Responsive positioning adjustments
|
||||
- Image caption support
|
||||
- Lightbox integration for positioned images
|
||||
73
web/src/components/MemoEditor/README.md
Normal file
73
web/src/components/MemoEditor/README.md
Normal file
@@ -0,0 +1,73 @@
|
||||
# MemoEditor Architecture
|
||||
|
||||
## Overview
|
||||
|
||||
MemoEditor uses a three-layer architecture for better separation of concerns and testability.
|
||||
|
||||
## Architecture
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────┐
|
||||
│ Presentation Layer (Components) │
|
||||
│ - EditorToolbar, EditorContent, etc. │
|
||||
└─────────────────┬───────────────────────┘
|
||||
│
|
||||
┌─────────────────▼───────────────────────┐
|
||||
│ State Layer (Reducer + Context) │
|
||||
│ - state/, useEditorContext() │
|
||||
└─────────────────┬───────────────────────┘
|
||||
│
|
||||
┌─────────────────▼───────────────────────┐
|
||||
│ Service Layer (Business Logic) │
|
||||
│ - services/ (pure functions) │
|
||||
└─────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
## Directory Structure
|
||||
|
||||
```
|
||||
MemoEditor/
|
||||
├── state/ # State management (reducer, actions, context)
|
||||
├── services/ # Business logic (pure functions)
|
||||
├── components/ # UI components
|
||||
├── hooks/ # React hooks (utilities)
|
||||
├── Editor/ # Core editor component
|
||||
├── Toolbar/ # Toolbar components
|
||||
├── constants.ts
|
||||
└── types/
|
||||
```
|
||||
|
||||
## Key Concepts
|
||||
|
||||
### State Management
|
||||
|
||||
Uses `useReducer` + Context for predictable state transitions. All state changes go through action creators.
|
||||
|
||||
### Services
|
||||
|
||||
Pure TypeScript functions containing business logic. No React hooks, easy to test.
|
||||
|
||||
### Components
|
||||
|
||||
Thin presentation components that dispatch actions and render UI.
|
||||
|
||||
## Usage
|
||||
|
||||
```typescript
|
||||
import MemoEditor from "@/components/MemoEditor";
|
||||
|
||||
<MemoEditor
|
||||
memoName="memos/123"
|
||||
onConfirm={(name) => console.log('Saved:', name)}
|
||||
onCancel={() => console.log('Cancelled')}
|
||||
/>
|
||||
```
|
||||
|
||||
## Testing
|
||||
|
||||
Services are pure functions - easy to unit test without React.
|
||||
|
||||
```typescript
|
||||
const state = mockEditorState();
|
||||
const result = await memoService.save(state, { memoName: 'memos/123' });
|
||||
```
|
||||
231
web/src/components/MemoEditor/Toolbar/InsertMenu.tsx
Normal file
231
web/src/components/MemoEditor/Toolbar/InsertMenu.tsx
Normal file
@@ -0,0 +1,231 @@
|
||||
import { LatLng } from "leaflet";
|
||||
import { uniqBy } from "lodash-es";
|
||||
import { FileIcon, ImageIcon, LinkIcon, LoaderIcon, type LucideIcon, MapPinIcon, Maximize2Icon, MoreHorizontalIcon, PlusIcon } from "lucide-react";
|
||||
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||
import { useDebounce } from "react-use";
|
||||
import { useReverseGeocoding } from "@/components/map";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuSub,
|
||||
DropdownMenuSubContent,
|
||||
DropdownMenuSubTrigger,
|
||||
DropdownMenuTrigger,
|
||||
useDropdownMenuSubHoverDelay,
|
||||
} from "@/components/ui/dropdown-menu";
|
||||
import type { MemoRelation } from "@/types/proto/api/v1/memo_service_pb";
|
||||
import { useTranslate } from "@/utils/i18n";
|
||||
import { ImagePositionDialog, LinkMemoDialog, LocationDialog } from "../components";
|
||||
import { useFileUpload, useLinkMemo, useLocation } from "../hooks";
|
||||
import { useEditorContext } from "../state";
|
||||
import type { InsertMenuProps } from "../types";
|
||||
import type { LocalFile } from "../types/attachment";
|
||||
|
||||
const InsertMenu = (props: InsertMenuProps) => {
|
||||
const t = useTranslate();
|
||||
const { state, actions, dispatch } = useEditorContext();
|
||||
const { location: initialLocation, onLocationChange, onToggleFocusMode, isUploading: isUploadingProp } = props;
|
||||
|
||||
const [linkDialogOpen, setLinkDialogOpen] = useState(false);
|
||||
const [locationDialogOpen, setLocationDialogOpen] = useState(false);
|
||||
const [imagePositionDialogOpen, setImagePositionDialogOpen] = useState(false);
|
||||
const [moreSubmenuOpen, setMoreSubmenuOpen] = useState(false);
|
||||
|
||||
const { handleTriggerEnter, handleTriggerLeave, handleContentEnter, handleContentLeave } = useDropdownMenuSubHoverDelay(
|
||||
150,
|
||||
setMoreSubmenuOpen,
|
||||
);
|
||||
|
||||
const { fileInputRef, selectingFlag, handleFileInputChange, handleUploadClick } = useFileUpload((newFiles: LocalFile[]) => {
|
||||
newFiles.forEach((file) => dispatch(actions.addLocalFile(file)));
|
||||
});
|
||||
|
||||
const linkMemo = useLinkMemo({
|
||||
isOpen: linkDialogOpen,
|
||||
currentMemoName: props.memoName,
|
||||
existingRelations: state.metadata.relations,
|
||||
onAddRelation: (relation: MemoRelation) => {
|
||||
dispatch(actions.setMetadata({ relations: uniqBy([...state.metadata.relations, relation], (r) => r.relatedMemo?.name) }));
|
||||
setLinkDialogOpen(false);
|
||||
},
|
||||
});
|
||||
|
||||
const location = useLocation(props.location);
|
||||
|
||||
const [debouncedPosition, setDebouncedPosition] = useState<LatLng | undefined>(undefined);
|
||||
|
||||
useDebounce(
|
||||
() => {
|
||||
setDebouncedPosition(location.state.position);
|
||||
},
|
||||
1000,
|
||||
[location.state.position],
|
||||
);
|
||||
|
||||
const { data: displayName } = useReverseGeocoding(debouncedPosition?.lat, debouncedPosition?.lng);
|
||||
|
||||
useEffect(() => {
|
||||
if (displayName) {
|
||||
location.setPlaceholder(displayName);
|
||||
}
|
||||
}, [displayName]);
|
||||
|
||||
const isUploading = selectingFlag || isUploadingProp;
|
||||
|
||||
const handleOpenLinkDialog = useCallback(() => {
|
||||
setLinkDialogOpen(true);
|
||||
}, []);
|
||||
|
||||
const handleImagePositionClick = useCallback(() => {
|
||||
setImagePositionDialogOpen(true);
|
||||
}, []);
|
||||
|
||||
const handleLocationClick = useCallback(() => {
|
||||
setLocationDialogOpen(true);
|
||||
if (!initialLocation && !location.locationInitialized) {
|
||||
if (navigator.geolocation) {
|
||||
navigator.geolocation.getCurrentPosition(
|
||||
(position) => {
|
||||
location.handlePositionChange(new LatLng(position.coords.latitude, position.coords.longitude));
|
||||
},
|
||||
(error) => {
|
||||
console.error("Geolocation error:", error);
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
}, [initialLocation, location]);
|
||||
|
||||
const handleLocationConfirm = useCallback(() => {
|
||||
const newLocation = location.getLocation();
|
||||
if (newLocation) {
|
||||
onLocationChange(newLocation);
|
||||
setLocationDialogOpen(false);
|
||||
}
|
||||
}, [location, onLocationChange]);
|
||||
|
||||
const handleLocationCancel = useCallback(() => {
|
||||
location.reset();
|
||||
setLocationDialogOpen(false);
|
||||
}, [location]);
|
||||
|
||||
const handlePositionChange = useCallback(
|
||||
(position: LatLng) => {
|
||||
location.handlePositionChange(position);
|
||||
},
|
||||
[location],
|
||||
);
|
||||
|
||||
const handleToggleFocusMode = useCallback(() => {
|
||||
onToggleFocusMode?.();
|
||||
setMoreSubmenuOpen(false);
|
||||
}, [onToggleFocusMode]);
|
||||
|
||||
const menuItems = useMemo(
|
||||
() =>
|
||||
[
|
||||
{
|
||||
key: "upload",
|
||||
label: t("common.upload"),
|
||||
icon: FileIcon,
|
||||
onClick: handleUploadClick,
|
||||
},
|
||||
{
|
||||
key: "link",
|
||||
label: t("tooltip.link-memo"),
|
||||
icon: LinkIcon,
|
||||
onClick: handleOpenLinkDialog,
|
||||
},
|
||||
{
|
||||
key: "location",
|
||||
label: t("tooltip.select-location"),
|
||||
icon: MapPinIcon,
|
||||
onClick: handleLocationClick,
|
||||
},
|
||||
{
|
||||
key: "image-position",
|
||||
label: "Position Image",
|
||||
icon: ImageIcon,
|
||||
onClick: handleImagePositionClick,
|
||||
},
|
||||
] satisfies Array<{ key: string; label: string; icon: LucideIcon; onClick: () => void }>,
|
||||
[handleLocationClick, handleOpenLinkDialog, handleUploadClick, handleImagePositionClick, t],
|
||||
);
|
||||
|
||||
return (
|
||||
<>
|
||||
<DropdownMenu modal={false}>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button variant="outline" size="icon" className="shadow-none" disabled={isUploading}>
|
||||
{isUploading ? <LoaderIcon className="size-4 animate-spin" /> : <PlusIcon className="size-4" />}
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="start">
|
||||
{menuItems.map((item) => (
|
||||
<DropdownMenuItem key={item.key} onClick={item.onClick}>
|
||||
<item.icon className="w-4 h-4" />
|
||||
{item.label}
|
||||
</DropdownMenuItem>
|
||||
))}
|
||||
{/* View submenu with Focus Mode */}
|
||||
<DropdownMenuSub open={moreSubmenuOpen} onOpenChange={setMoreSubmenuOpen}>
|
||||
<DropdownMenuSubTrigger onPointerEnter={handleTriggerEnter} onPointerLeave={handleTriggerLeave}>
|
||||
<MoreHorizontalIcon className="w-4 h-4" />
|
||||
{t("common.more")}
|
||||
</DropdownMenuSubTrigger>
|
||||
<DropdownMenuSubContent onPointerEnter={handleContentEnter} onPointerLeave={handleContentLeave}>
|
||||
<DropdownMenuItem onClick={handleToggleFocusMode}>
|
||||
<Maximize2Icon className="w-4 h-4" />
|
||||
{t("editor.focus-mode")}
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuSubContent>
|
||||
</DropdownMenuSub>
|
||||
<div className="px-2 py-1 text-xs text-muted-foreground opacity-80">{t("editor.slash-commands")}</div>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
|
||||
{/* Hidden file input */}
|
||||
<input
|
||||
className="hidden"
|
||||
ref={fileInputRef}
|
||||
disabled={isUploading}
|
||||
onChange={handleFileInputChange}
|
||||
type="file"
|
||||
multiple={true}
|
||||
accept="*"
|
||||
/>
|
||||
|
||||
<LinkMemoDialog
|
||||
open={linkDialogOpen}
|
||||
onOpenChange={setLinkDialogOpen}
|
||||
searchText={linkMemo.searchText}
|
||||
onSearchChange={linkMemo.setSearchText}
|
||||
filteredMemos={linkMemo.filteredMemos}
|
||||
isFetching={linkMemo.isFetching}
|
||||
onSelectMemo={linkMemo.addMemoRelation}
|
||||
/>
|
||||
|
||||
<LocationDialog
|
||||
open={locationDialogOpen}
|
||||
onOpenChange={setLocationDialogOpen}
|
||||
state={location.state}
|
||||
locationInitialized={location.locationInitialized}
|
||||
onPositionChange={handlePositionChange}
|
||||
onUpdateCoordinate={location.updateCoordinate}
|
||||
onPlaceholderChange={location.setPlaceholder}
|
||||
onCancel={handleLocationCancel}
|
||||
onConfirm={handleLocationConfirm}
|
||||
/>
|
||||
|
||||
<ImagePositionDialog
|
||||
open={imagePositionDialogOpen}
|
||||
onOpenChange={setImagePositionDialogOpen}
|
||||
attachments={state.metadata.attachments}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default InsertMenu;
|
||||
42
web/src/components/MemoEditor/Toolbar/VisibilitySelector.tsx
Normal file
42
web/src/components/MemoEditor/Toolbar/VisibilitySelector.tsx
Normal file
@@ -0,0 +1,42 @@
|
||||
import { CheckIcon, ChevronDownIcon } from "lucide-react";
|
||||
import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger } from "@/components/ui/dropdown-menu";
|
||||
import VisibilityIcon from "@/components/VisibilityIcon";
|
||||
import { Visibility } from "@/types/proto/api/v1/memo_service_pb";
|
||||
import { useTranslate } from "@/utils/i18n";
|
||||
import type { VisibilitySelectorProps } from "../types";
|
||||
|
||||
const VisibilitySelector = (props: VisibilitySelectorProps) => {
|
||||
const { value, onChange } = props;
|
||||
const t = useTranslate();
|
||||
|
||||
const visibilityOptions = [
|
||||
{ value: Visibility.PRIVATE, label: t("memo.visibility.private") },
|
||||
{ value: Visibility.PROTECTED, label: t("memo.visibility.protected") },
|
||||
{ value: Visibility.PUBLIC, label: t("memo.visibility.public") },
|
||||
] as const;
|
||||
|
||||
const currentLabel = visibilityOptions.find((option) => option.value === value)?.label || "";
|
||||
|
||||
return (
|
||||
<DropdownMenu onOpenChange={props.onOpenChange}>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<button className="inline-flex items-center px-2 text-sm text-muted-foreground opacity-80 hover:opacity-100 transition-colors">
|
||||
<VisibilityIcon visibility={value} className="opacity-60 mr-1.5" />
|
||||
<span>{currentLabel}</span>
|
||||
<ChevronDownIcon className="ml-0.5 w-4 h-4 opacity-60" />
|
||||
</button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end">
|
||||
{visibilityOptions.map((option) => (
|
||||
<DropdownMenuItem key={option.value} className="cursor-pointer gap-2" onClick={() => onChange(option.value)}>
|
||||
<VisibilityIcon visibility={option.value} />
|
||||
<span className="flex-1">{option.label}</span>
|
||||
{value === option.value && <CheckIcon className="w-4 h-4 text-primary" />}
|
||||
</DropdownMenuItem>
|
||||
))}
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
);
|
||||
};
|
||||
|
||||
export default VisibilitySelector;
|
||||
3
web/src/components/MemoEditor/Toolbar/index.ts
Normal file
3
web/src/components/MemoEditor/Toolbar/index.ts
Normal file
@@ -0,0 +1,3 @@
|
||||
// Toolbar components for MemoEditor
|
||||
export { default as InsertMenu } from "./InsertMenu";
|
||||
export { default as VisibilitySelector } from "./VisibilitySelector";
|
||||
319
web/src/components/MemoEditor/components/AIInstructionDialog.tsx
Normal file
319
web/src/components/MemoEditor/components/AIInstructionDialog.tsx
Normal file
@@ -0,0 +1,319 @@
|
||||
import { BotIcon, ChevronDownIcon, MessageSquareIcon, PencilIcon, PlusIcon, SparklesIcon } from "lucide-react";
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { toast } from "react-hot-toast";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogTrigger,
|
||||
} from "@/components/ui/dialog";
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuTrigger,
|
||||
} from "@/components/ui/dropdown-menu";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { aIServiceClient } from "@/connect";
|
||||
import { GROQ_MODELS, OLLAMA_MODELS } from "@/types/proto/api/v1/ai_service_pb";
|
||||
|
||||
interface AIInstructionDialogProps {
|
||||
disabled?: boolean;
|
||||
editorRef: React.MutableRefObject<any>;
|
||||
open?: boolean;
|
||||
onOpenChange?: (open: boolean) => void;
|
||||
onGeneratingChange?: (generating: boolean) => void;
|
||||
}
|
||||
|
||||
const AIInstructionDialog = ({ disabled = false, editorRef, open: controlledOpen, onOpenChange, onGeneratingChange }: AIInstructionDialogProps) => {
|
||||
const [open, setOpen] = useState(false);
|
||||
const isOpen = controlledOpen ?? open;
|
||||
const setIsOpen = onOpenChange ?? setOpen;
|
||||
|
||||
const [instruction, setInstruction] = useState("");
|
||||
const [selectedProvider, setSelectedProvider] = useState<"groq" | "ollama">("groq");
|
||||
const [selectedModel, setSelectedModel] = useState(GROQ_MODELS[0].id);
|
||||
const [ollamaModels, setOllamaModels] = useState(OLLAMA_MODELS);
|
||||
const [ollamaModelsLoading, setOllamaModelsLoading] = useState(false);
|
||||
const [mode, setMode] = useState<"append" | "replace">("append");
|
||||
const [autoTag, setAutoTag] = useState(true);
|
||||
const [spellCheck, setSpellCheck] = useState(true);
|
||||
const textareaRef = useRef<HTMLTextAreaElement>(null);
|
||||
|
||||
const currentModels = selectedProvider === "groq" ? GROQ_MODELS : ollamaModels;
|
||||
const selectedModelName = currentModels.find((m) => m.id === selectedModel)?.name ?? selectedModel;
|
||||
const providerName = selectedProvider === "groq" ? "Groq" : "Ollama";
|
||||
|
||||
// Fetch Ollama models when provider is selected
|
||||
useEffect(() => {
|
||||
if (selectedProvider === "ollama") {
|
||||
setOllamaModelsLoading(true);
|
||||
aIServiceClient.listOllamaModels()
|
||||
.then((models) => {
|
||||
if (models.length > 0) {
|
||||
setOllamaModels(models);
|
||||
// Set default model to first available model if current selection is not in the list
|
||||
if (!models.some(m => m.id === selectedModel)) {
|
||||
setSelectedModel(models[0].id);
|
||||
}
|
||||
} else {
|
||||
// Fallback to static list if no models found
|
||||
setOllamaModels(OLLAMA_MODELS);
|
||||
}
|
||||
})
|
||||
.catch((error) => {
|
||||
console.warn("Failed to fetch Ollama models:", error);
|
||||
// Fallback to static list on error
|
||||
setOllamaModels(OLLAMA_MODELS);
|
||||
})
|
||||
.finally(() => {
|
||||
setOllamaModelsLoading(false);
|
||||
});
|
||||
}
|
||||
}, [selectedProvider]);
|
||||
|
||||
const handleSubmit = async () => {
|
||||
if (!instruction.trim()) return;
|
||||
if (!editorRef.current) {
|
||||
toast.error("Editor not available");
|
||||
return;
|
||||
}
|
||||
|
||||
const prompt = instruction;
|
||||
const editor = editorRef.current;
|
||||
const fullContent = editor.getContent();
|
||||
|
||||
// Close dialog immediately
|
||||
setIsOpen(false);
|
||||
setInstruction("");
|
||||
|
||||
// Show loading state in toolbar
|
||||
onGeneratingChange?.(true);
|
||||
|
||||
try {
|
||||
let contextPrompt: string;
|
||||
if (mode === "replace") {
|
||||
contextPrompt = fullContent
|
||||
? `${prompt}\n\nRewrite the following note based on the instruction above. Return ONLY the rewritten note content, no extra commentary:\n\n${fullContent}`
|
||||
: prompt;
|
||||
} else {
|
||||
const selectedText = editor.getSelectedContent?.();
|
||||
contextPrompt = selectedText
|
||||
? `${prompt}\n\nSelected text: "${selectedText}"\n\nFull note: ${fullContent}`
|
||||
: fullContent
|
||||
? `${prompt}\n\nNote content: ${fullContent}`
|
||||
: prompt;
|
||||
}
|
||||
|
||||
const response = await aIServiceClient.generateCompletion({
|
||||
prompt: contextPrompt,
|
||||
provider: selectedProvider === "groq" ? 1 : 2,
|
||||
model: selectedModel,
|
||||
temperature: 0.7,
|
||||
maxTokens: 2000,
|
||||
autoTag,
|
||||
spellCheck,
|
||||
});
|
||||
|
||||
if (response.text) {
|
||||
if (mode === "replace") {
|
||||
editor.setContent(response.text);
|
||||
} else {
|
||||
editor.insertText(`\n\n${response.text}`);
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("AI failed:", error);
|
||||
toast.error(error instanceof Error ? error.message : "AI request failed");
|
||||
} finally {
|
||||
onGeneratingChange?.(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleKeyDown = (e: React.KeyboardEvent) => {
|
||||
if (e.key === "Enter" && !e.shiftKey) {
|
||||
e.preventDefault();
|
||||
handleSubmit();
|
||||
}
|
||||
};
|
||||
|
||||
const handleOpenChange = (val: boolean) => {
|
||||
setIsOpen(val);
|
||||
if (val) {
|
||||
setTimeout(() => textareaRef.current?.focus(), 50);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog open={isOpen} onOpenChange={handleOpenChange}>
|
||||
<DialogTrigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="shadow-none"
|
||||
disabled={disabled}
|
||||
title="Ask AI"
|
||||
>
|
||||
<MessageSquareIcon className="size-4" />
|
||||
</Button>
|
||||
</DialogTrigger>
|
||||
<DialogContent className="sm:max-w-2xl p-0 overflow-hidden">
|
||||
<DialogHeader className="px-4 pt-4 pb-2">
|
||||
<DialogTitle className="flex items-center gap-2 text-base">
|
||||
<BotIcon className="size-4" />
|
||||
Ask AI
|
||||
</DialogTitle>
|
||||
</DialogHeader>
|
||||
<div className="px-4 pb-4">
|
||||
<Textarea
|
||||
ref={textareaRef}
|
||||
placeholder="Ask AI anything... (Enter to send, Shift+Enter for newline)"
|
||||
value={instruction}
|
||||
onChange={(e) => setInstruction(e.target.value)}
|
||||
onKeyDown={handleKeyDown}
|
||||
className="resize-none min-h-[100px] text-sm border-0 shadow-none focus-visible:ring-0 p-0"
|
||||
autoFocus
|
||||
/>
|
||||
<div className="flex flex-col sm:flex-row sm:items-center justify-between mt-3 gap-3">
|
||||
{/* Left: provider selector + model selector + mode toggle + feature toggles */}
|
||||
<div className="flex flex-wrap items-center gap-1.5">
|
||||
{/* Provider selector */}
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button variant="outline" size="sm" className="gap-1 text-xs h-7 px-2">
|
||||
{providerName}
|
||||
<ChevronDownIcon className="size-3" />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="start">
|
||||
<DropdownMenuItem
|
||||
onClick={() => {
|
||||
setSelectedProvider("groq");
|
||||
setSelectedModel(GROQ_MODELS[0].id);
|
||||
}}
|
||||
className={selectedProvider === "groq" ? "font-medium" : ""}
|
||||
>
|
||||
Groq
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem
|
||||
onClick={() => {
|
||||
setSelectedProvider("ollama");
|
||||
setSelectedModel(OLLAMA_MODELS[0].id);
|
||||
}}
|
||||
className={selectedProvider === "ollama" ? "font-medium" : ""}
|
||||
>
|
||||
Ollama
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
|
||||
{/* Model selector */}
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="gap-1 text-xs h-7 px-2"
|
||||
disabled={ollamaModelsLoading && selectedProvider === "ollama"}
|
||||
>
|
||||
{ollamaModelsLoading && selectedProvider === "ollama" ? (
|
||||
<>
|
||||
<div className="size-3 animate-spin rounded-full border border-current border-t-transparent" />
|
||||
Loading...
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
{selectedModelName}
|
||||
<ChevronDownIcon className="size-3" />
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="start">
|
||||
{currentModels.map((model) => (
|
||||
<DropdownMenuItem
|
||||
key={model.id}
|
||||
onClick={() => setSelectedModel(model.id)}
|
||||
className={selectedModel === model.id ? "font-medium" : ""}
|
||||
>
|
||||
{model.name}
|
||||
</DropdownMenuItem>
|
||||
))}
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
|
||||
{/* Mode toggle */}
|
||||
<div className="flex rounded-md border border-border overflow-hidden text-xs h-7">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setMode("append")}
|
||||
className={cn(
|
||||
"flex items-center gap-1 px-2 transition-colors",
|
||||
mode === "append"
|
||||
? "bg-primary text-primary-foreground"
|
||||
: "bg-background text-muted-foreground hover:bg-muted",
|
||||
)}
|
||||
title="Append AI output after note"
|
||||
>
|
||||
<PlusIcon className="size-3" />
|
||||
Append
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setMode("replace")}
|
||||
className={cn(
|
||||
"flex items-center gap-1 px-2 transition-colors border-l border-border",
|
||||
mode === "replace"
|
||||
? "bg-primary text-primary-foreground"
|
||||
: "bg-background text-muted-foreground hover:bg-muted",
|
||||
)}
|
||||
title="Replace entire note with AI output"
|
||||
>
|
||||
<PencilIcon className="size-3" />
|
||||
Replace
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Feature toggles */}
|
||||
<div className="flex items-center gap-1">
|
||||
<label className="flex items-center gap-1 text-xs cursor-pointer">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={autoTag}
|
||||
onChange={(e) => setAutoTag(e.target.checked)}
|
||||
className="rounded border-border text-primary focus:ring-primary size-3.5"
|
||||
/>
|
||||
<span className="text-muted-foreground">Auto-tag</span>
|
||||
</label>
|
||||
<label className="flex items-center gap-1 text-xs cursor-pointer">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={spellCheck}
|
||||
onChange={(e) => setSpellCheck(e.target.checked)}
|
||||
className="rounded border-border text-primary focus:ring-primary size-3.5"
|
||||
/>
|
||||
<span className="text-muted-foreground">Spell check</span>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Button
|
||||
onClick={handleSubmit}
|
||||
disabled={!instruction.trim()}
|
||||
size="sm"
|
||||
className="gap-1.5"
|
||||
>
|
||||
<SparklesIcon className="size-3.5" />
|
||||
Send
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
};
|
||||
|
||||
export default AIInstructionDialog;
|
||||
216
web/src/components/MemoEditor/components/AIToolbar.tsx
Normal file
216
web/src/components/MemoEditor/components/AIToolbar.tsx
Normal file
@@ -0,0 +1,216 @@
|
||||
import { BotIcon, SparklesIcon, WandIcon } from "lucide-react";
|
||||
import { useState } from "react";
|
||||
import { toast } from "react-hot-toast";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuTrigger,
|
||||
} from "@/components/ui/dropdown-menu";
|
||||
import { aIServiceClient } from "@/connect";
|
||||
import type { EditorRefActions } from "../Editor";
|
||||
import AIInstructionDialog from "./AIInstructionDialog";
|
||||
|
||||
interface AIToolbarProps {
|
||||
editorRef: React.MutableRefObject<EditorRefActions | null>;
|
||||
disabled?: boolean;
|
||||
}
|
||||
|
||||
const AIToolbar = ({ editorRef, disabled = false }: AIToolbarProps) => {
|
||||
const [isGenerating, setIsGenerating] = useState(false);
|
||||
|
||||
const handleAICompletion = async () => {
|
||||
if (!editorRef.current) return;
|
||||
const editor = editorRef.current;
|
||||
|
||||
try {
|
||||
setIsGenerating(true);
|
||||
|
||||
const selectedText = editor.getSelectedContent();
|
||||
const fullContent = editor.getContent();
|
||||
|
||||
// Get custom instruction if exists
|
||||
const customInstruction = localStorage.getItem("ai-custom-instruction");
|
||||
const instructionPrefix = customInstruction ? `${customInstruction}\n\n` : '';
|
||||
|
||||
// If text is selected, use it as context for completion
|
||||
const prompt = instructionPrefix + (selectedText
|
||||
? `Complete or improve this text: "${selectedText}"\n\nContext: ${fullContent}`
|
||||
: `Continue writing this note: ${fullContent}`);
|
||||
|
||||
const response = await aIServiceClient.generateCompletion({
|
||||
prompt,
|
||||
provider: 1, // Groq as default
|
||||
temperature: 0.7,
|
||||
maxTokens: 500,
|
||||
});
|
||||
|
||||
if (response.text) {
|
||||
if (selectedText) {
|
||||
// Replace selected text with completion
|
||||
const cursorPos = editor.getCursorPosition();
|
||||
editor.removeText(cursorPos - selectedText.length, selectedText.length);
|
||||
editor.insertText(response.text);
|
||||
} else {
|
||||
// Append completion to end
|
||||
editor.insertText(response.text);
|
||||
}
|
||||
toast.success("AI completion added!");
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("AI completion failed:", error);
|
||||
|
||||
// Handle authentication errors
|
||||
if (error instanceof Error && error.message.includes("UNAUTHORIZED")) {
|
||||
toast.error("Authentication required. Please refresh the page or log in again.");
|
||||
} else {
|
||||
toast.error("Failed to generate AI completion");
|
||||
}
|
||||
} finally {
|
||||
setIsGenerating(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleAISummarize = async () => {
|
||||
if (!editorRef.current) return;
|
||||
const editor = editorRef.current;
|
||||
|
||||
try {
|
||||
setIsGenerating(true);
|
||||
|
||||
const content = editor.getContent();
|
||||
if (!content.trim()) {
|
||||
toast.error("Nothing to summarize");
|
||||
return;
|
||||
}
|
||||
|
||||
// Get custom instruction if exists
|
||||
const customInstruction = localStorage.getItem("ai-custom-instruction");
|
||||
const instructionPrefix = customInstruction ? `${customInstruction}\n\n` : '';
|
||||
|
||||
const prompt = instructionPrefix + `Summarize this note concisely:\n\n${content}`;
|
||||
|
||||
const response = await aIServiceClient.generateCompletion({
|
||||
prompt,
|
||||
provider: 1, // Groq as default
|
||||
temperature: 0.3,
|
||||
maxTokens: 200,
|
||||
});
|
||||
|
||||
if (response.text) {
|
||||
// Insert summary at cursor position
|
||||
editor.insertText(`\n\n**Summary:** ${response.text}`);
|
||||
toast.success("Summary generated!");
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("AI summarize failed:", error);
|
||||
|
||||
// Handle authentication errors
|
||||
if (error instanceof Error && error.message.includes("UNAUTHORIZED")) {
|
||||
toast.error("Authentication required. Please refresh the page or log in again.");
|
||||
} else {
|
||||
toast.error("Failed to generate summary");
|
||||
}
|
||||
} finally {
|
||||
setIsGenerating(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleAIImprove = async () => {
|
||||
if (!editorRef.current) return;
|
||||
const editor = editorRef.current;
|
||||
|
||||
try {
|
||||
setIsGenerating(true);
|
||||
|
||||
const selectedText = editor.getSelectedContent();
|
||||
const fullContent = editor.getContent();
|
||||
|
||||
const textToImprove = selectedText || fullContent;
|
||||
if (!textToImprove.trim()) {
|
||||
toast.error("Nothing to improve");
|
||||
return;
|
||||
}
|
||||
|
||||
// Get custom instruction if exists
|
||||
const customInstruction = localStorage.getItem("ai-custom-instruction");
|
||||
const instructionPrefix = customInstruction ? `${customInstruction}\n\n` : '';
|
||||
|
||||
const prompt = instructionPrefix + `Improve the grammar, clarity, and flow of this text:\n\n${textToImprove}`;
|
||||
|
||||
const response = await aIServiceClient.generateCompletion({
|
||||
prompt,
|
||||
provider: 1, // Groq as default
|
||||
temperature: 0.4,
|
||||
maxTokens: 1000,
|
||||
});
|
||||
|
||||
if (response.text) {
|
||||
if (selectedText) {
|
||||
// Replace selected text with improved version
|
||||
const cursorPos = editor.getCursorPosition();
|
||||
editor.removeText(cursorPos - selectedText.length, selectedText.length);
|
||||
editor.insertText(response.text);
|
||||
} else {
|
||||
// Replace entire content
|
||||
editor.setContent(response.text);
|
||||
}
|
||||
toast.success("Text improved!");
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("AI improve failed:", error);
|
||||
|
||||
// Handle authentication errors
|
||||
if (error instanceof Error && error.message.includes("UNAUTHORIZED")) {
|
||||
toast.error("Authentication required. Please refresh the page or log in again.");
|
||||
} else {
|
||||
toast.error("Failed to improve text");
|
||||
}
|
||||
} finally {
|
||||
setIsGenerating(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex items-center gap-2">
|
||||
<DropdownMenu modal={false}>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="icon"
|
||||
className="shadow-none"
|
||||
disabled={disabled || isGenerating}
|
||||
>
|
||||
{isGenerating ? (
|
||||
<SparklesIcon className="size-4 animate-pulse" />
|
||||
) : (
|
||||
<BotIcon className="size-4" />
|
||||
)}
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="start">
|
||||
<DropdownMenuItem onClick={handleAICompletion}>
|
||||
<WandIcon className="w-4 h-4 mr-2" />
|
||||
Complete Text
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem onClick={handleAISummarize}>
|
||||
<SparklesIcon className="w-4 h-4 mr-2" />
|
||||
Summarize
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem onClick={handleAIImprove}>
|
||||
<BotIcon className="w-4 h-4 mr-2" />
|
||||
Improve Writing
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
<AIInstructionDialog
|
||||
disabled={disabled}
|
||||
editorRef={editorRef}
|
||||
onGeneratingChange={(generating) => setIsGenerating(generating)}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default AIToolbar;
|
||||
169
web/src/components/MemoEditor/components/AttachmentList.tsx
Normal file
169
web/src/components/MemoEditor/components/AttachmentList.tsx
Normal file
@@ -0,0 +1,169 @@
|
||||
import { ChevronDownIcon, ChevronUpIcon, FileIcon, PaperclipIcon, XIcon } from "lucide-react";
|
||||
import type { FC } from "react";
|
||||
import { cn } from "@/lib/utils";
|
||||
import type { Attachment } from "@/types/proto/api/v1/attachment_service_pb";
|
||||
import { formatFileSize, getFileTypeLabel } from "@/utils/format";
|
||||
import type { LocalFile } from "../types/attachment";
|
||||
import { toAttachmentItems } from "../types/attachment";
|
||||
|
||||
interface AttachmentListProps {
|
||||
attachments: Attachment[];
|
||||
localFiles?: LocalFile[];
|
||||
onAttachmentsChange?: (attachments: Attachment[]) => void;
|
||||
onRemoveLocalFile?: (previewUrl: string) => void;
|
||||
}
|
||||
|
||||
const AttachmentItemCard: FC<{
|
||||
item: ReturnType<typeof toAttachmentItems>[0];
|
||||
onRemove?: () => void;
|
||||
onMoveUp?: () => void;
|
||||
onMoveDown?: () => void;
|
||||
canMoveUp?: boolean;
|
||||
canMoveDown?: boolean;
|
||||
}> = ({ item, onRemove, onMoveUp, onMoveDown, canMoveUp = true, canMoveDown = true }) => {
|
||||
const { category, filename, thumbnailUrl, mimeType, size } = item;
|
||||
const fileTypeLabel = getFileTypeLabel(mimeType);
|
||||
const fileSizeLabel = size ? formatFileSize(size) : undefined;
|
||||
|
||||
return (
|
||||
<div className="relative flex items-center gap-1.5 px-1.5 py-1 rounded border border-transparent hover:border-border hover:bg-accent/20 transition-all">
|
||||
<div className="shrink-0 w-6 h-6 rounded overflow-hidden bg-muted/40 flex items-center justify-center">
|
||||
{category === "image" && thumbnailUrl ? (
|
||||
<img src={thumbnailUrl} alt="" className="w-full h-full object-cover" />
|
||||
) : (
|
||||
<FileIcon className="w-3.5 h-3.5 text-muted-foreground" />
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex-1 min-w-0 flex flex-col sm:flex-row sm:items-baseline gap-0.5 sm:gap-1.5">
|
||||
<span className="text-xs truncate" title={filename}>
|
||||
{filename}
|
||||
</span>
|
||||
|
||||
<div className="flex items-center gap-1 text-[11px] text-muted-foreground shrink-0">
|
||||
<span>{fileTypeLabel}</span>
|
||||
{fileSizeLabel && (
|
||||
<>
|
||||
<span className="text-muted-foreground/50 hidden sm:inline">•</span>
|
||||
<span className="hidden sm:inline">{fileSizeLabel}</span>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="shrink-0 flex items-center gap-0.5">
|
||||
{onMoveUp && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onMoveUp}
|
||||
disabled={!canMoveUp}
|
||||
className={cn(
|
||||
"p-0.5 rounded hover:bg-accent active:bg-accent transition-colors touch-manipulation",
|
||||
!canMoveUp && "opacity-20 cursor-not-allowed hover:bg-transparent",
|
||||
)}
|
||||
title="Move up"
|
||||
aria-label="Move attachment up"
|
||||
>
|
||||
<ChevronUpIcon className="w-3 h-3 text-muted-foreground" />
|
||||
</button>
|
||||
)}
|
||||
|
||||
{onMoveDown && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onMoveDown}
|
||||
disabled={!canMoveDown}
|
||||
className={cn(
|
||||
"p-0.5 rounded hover:bg-accent active:bg-accent transition-colors touch-manipulation",
|
||||
!canMoveDown && "opacity-20 cursor-not-allowed hover:bg-transparent",
|
||||
)}
|
||||
title="Move down"
|
||||
aria-label="Move attachment down"
|
||||
>
|
||||
<ChevronDownIcon className="w-3 h-3 text-muted-foreground" />
|
||||
</button>
|
||||
)}
|
||||
|
||||
{onRemove && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onRemove}
|
||||
className="p-0.5 rounded hover:bg-destructive/10 active:bg-destructive/10 transition-colors ml-0.5 touch-manipulation"
|
||||
title="Remove"
|
||||
aria-label="Remove attachment"
|
||||
>
|
||||
<XIcon className="w-3 h-3 text-muted-foreground hover:text-destructive" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const AttachmentList: FC<AttachmentListProps> = ({ attachments, localFiles = [], onAttachmentsChange, onRemoveLocalFile }) => {
|
||||
if (attachments.length === 0 && localFiles.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const items = toAttachmentItems(attachments, localFiles);
|
||||
|
||||
const handleMoveUp = (index: number) => {
|
||||
if (index === 0 || !onAttachmentsChange) return;
|
||||
|
||||
const newAttachments = [...attachments];
|
||||
[newAttachments[index - 1], newAttachments[index]] = [newAttachments[index], newAttachments[index - 1]];
|
||||
onAttachmentsChange(newAttachments);
|
||||
};
|
||||
|
||||
const handleMoveDown = (index: number) => {
|
||||
if (index === attachments.length - 1 || !onAttachmentsChange) return;
|
||||
|
||||
const newAttachments = [...attachments];
|
||||
[newAttachments[index], newAttachments[index + 1]] = [newAttachments[index + 1], newAttachments[index]];
|
||||
onAttachmentsChange(newAttachments);
|
||||
};
|
||||
|
||||
const handleRemoveAttachment = (name: string) => {
|
||||
if (onAttachmentsChange) {
|
||||
onAttachmentsChange(attachments.filter((attachment) => attachment.name !== name));
|
||||
}
|
||||
};
|
||||
|
||||
const handleRemoveItem = (item: (typeof items)[0]) => {
|
||||
if (item.isLocal) {
|
||||
onRemoveLocalFile?.(item.id);
|
||||
} else {
|
||||
handleRemoveAttachment(item.id);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="w-full rounded-lg border border-border bg-muted/20 overflow-hidden">
|
||||
<div className="flex items-center gap-1.5 px-2 py-1 border-b border-border bg-muted/30">
|
||||
<PaperclipIcon className="w-3.5 h-3.5 text-muted-foreground" />
|
||||
<span className="text-xs text-foreground">Attachments ({items.length})</span>
|
||||
</div>
|
||||
|
||||
<div className="p-1 sm:p-1.5 flex flex-col gap-0.5">
|
||||
{items.map((item) => {
|
||||
const isLocalFile = item.isLocal;
|
||||
const attachmentIndex = isLocalFile ? -1 : attachments.findIndex((a) => a.name === item.id);
|
||||
|
||||
return (
|
||||
<AttachmentItemCard
|
||||
key={item.id}
|
||||
item={item}
|
||||
onRemove={() => handleRemoveItem(item)}
|
||||
onMoveUp={!isLocalFile ? () => handleMoveUp(attachmentIndex) : undefined}
|
||||
onMoveDown={!isLocalFile ? () => handleMoveDown(attachmentIndex) : undefined}
|
||||
canMoveUp={!isLocalFile && attachmentIndex > 0}
|
||||
canMoveDown={!isLocalFile && attachmentIndex < attachments.length - 1}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default AttachmentList;
|
||||
87
web/src/components/MemoEditor/components/EditorContent.tsx
Normal file
87
web/src/components/MemoEditor/components/EditorContent.tsx
Normal file
@@ -0,0 +1,87 @@
|
||||
import { forwardRef, useRef, useCallback } from "react";
|
||||
import Editor, { type EditorRefActions } from "../Editor";
|
||||
import { useBlobUrls, useDragAndDrop } from "../hooks";
|
||||
import { useEditorContext } from "../state";
|
||||
import type { EditorContentProps } from "../types";
|
||||
import type { LocalFile } from "../types/attachment";
|
||||
import AIToolbar from "./AIToolbar";
|
||||
|
||||
export const EditorContent = forwardRef<EditorRefActions, EditorContentProps>(({ placeholder }, ref) => {
|
||||
const { state, actions, dispatch } = useEditorContext();
|
||||
const { createBlobUrl } = useBlobUrls();
|
||||
|
||||
const { dragHandlers } = useDragAndDrop((files: FileList) => {
|
||||
const localFiles: LocalFile[] = Array.from(files).map((file) => ({
|
||||
file,
|
||||
previewUrl: createBlobUrl(file),
|
||||
}));
|
||||
localFiles.forEach((localFile) => dispatch(actions.addLocalFile(localFile)));
|
||||
});
|
||||
|
||||
const handleCompositionStart = () => {
|
||||
dispatch(actions.setComposing(true));
|
||||
};
|
||||
|
||||
const handleCompositionEnd = () => {
|
||||
dispatch(actions.setComposing(false));
|
||||
};
|
||||
|
||||
const handleContentChange = (content: string) => {
|
||||
dispatch(actions.updateContent(content));
|
||||
};
|
||||
|
||||
const handlePaste = (event: React.ClipboardEvent<Element>) => {
|
||||
const clipboard = event.clipboardData;
|
||||
if (!clipboard) return;
|
||||
|
||||
const files: File[] = [];
|
||||
if (clipboard.items && clipboard.items.length > 0) {
|
||||
for (const item of Array.from(clipboard.items)) {
|
||||
if (item.kind !== "file") continue;
|
||||
const file = item.getAsFile();
|
||||
if (file) files.push(file);
|
||||
}
|
||||
} else if (clipboard.files && clipboard.files.length > 0) {
|
||||
files.push(...Array.from(clipboard.files));
|
||||
}
|
||||
|
||||
if (files.length === 0) return;
|
||||
|
||||
const localFiles: LocalFile[] = files.map((file) => ({
|
||||
file,
|
||||
previewUrl: createBlobUrl(file),
|
||||
}));
|
||||
localFiles.forEach((localFile) => dispatch(actions.addLocalFile(localFile)));
|
||||
event.preventDefault();
|
||||
};
|
||||
|
||||
// Use ref for editor actions to prevent infinite loops
|
||||
const editorActionsRef = useRef<EditorRefActions | null>(null);
|
||||
|
||||
// Stable callback ref — no deps, no re-renders
|
||||
const editorCallbackRef = useCallback((editorActions: EditorRefActions | null) => {
|
||||
editorActionsRef.current = editorActions;
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div className="w-full flex flex-col flex-1" {...dragHandlers}>
|
||||
<div className="flex items-center gap-1 mb-2">
|
||||
<AIToolbar editorRef={editorActionsRef} disabled={state.ui.isLoading.saving} />
|
||||
</div>
|
||||
<Editor
|
||||
ref={editorCallbackRef}
|
||||
className="memo-editor-content"
|
||||
initialContent={state.content}
|
||||
placeholder={placeholder || ""}
|
||||
isFocusMode={state.ui.isFocusMode}
|
||||
isInIME={state.ui.isComposing}
|
||||
onContentChange={handleContentChange}
|
||||
onPaste={handlePaste}
|
||||
onCompositionStart={handleCompositionStart}
|
||||
onCompositionEnd={handleCompositionEnd}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
});
|
||||
|
||||
EditorContent.displayName = "EditorContent";
|
||||
31
web/src/components/MemoEditor/components/EditorMetadata.tsx
Normal file
31
web/src/components/MemoEditor/components/EditorMetadata.tsx
Normal file
@@ -0,0 +1,31 @@
|
||||
import type { FC } from "react";
|
||||
import { useEditorContext } from "../state";
|
||||
import type { EditorMetadataProps } from "../types";
|
||||
import AttachmentList from "./AttachmentList";
|
||||
import LocationDisplay from "./LocationDisplay";
|
||||
import RelationList from "./RelationList";
|
||||
|
||||
export const EditorMetadata: FC<EditorMetadataProps> = ({ memoName }) => {
|
||||
const { state, actions, dispatch } = useEditorContext();
|
||||
|
||||
return (
|
||||
<div className="w-full flex flex-col gap-2">
|
||||
<AttachmentList
|
||||
attachments={state.metadata.attachments}
|
||||
localFiles={state.localFiles}
|
||||
onAttachmentsChange={(attachments) => dispatch(actions.setMetadata({ attachments }))}
|
||||
onRemoveLocalFile={(previewUrl) => dispatch(actions.removeLocalFile(previewUrl))}
|
||||
/>
|
||||
|
||||
<RelationList
|
||||
relations={state.metadata.relations}
|
||||
onRelationsChange={(relations) => dispatch(actions.setMetadata({ relations }))}
|
||||
memoName={memoName}
|
||||
/>
|
||||
|
||||
{state.metadata.location && (
|
||||
<LocationDisplay location={state.metadata.location} onRemove={() => dispatch(actions.setMetadata({ location: undefined }))} />
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user