chore: import upstream snapshot with attribution
Backend Tests / Tests (other) (push) Failing after 1s
Backend Tests / Static Checks (push) Failing after 2s
Backend Tests / Tests (internal) (push) Failing after 2s
Backend Tests / Tests (server) (push) Failing after 1s
Backend Tests / Tests (store) (push) Failing after 1s
Build Canary Image / build-frontend (push) Failing after 1s
Frontend Tests / Lint (push) Failing after 1s
Build Canary Image / build-push (linux/amd64) (push) Has been skipped
Build Canary Image / build-push (linux/arm64) (push) Has been skipped
Build Canary Image / merge (push) Has been skipped
Frontend Tests / Build (push) Failing after 1s
Release Please / release-please (push) Failing after 0s
Proto Linter / Lint Protos (push) Failing after 2s
Backend Tests / Tests (other) (push) Failing after 1s
Backend Tests / Static Checks (push) Failing after 2s
Backend Tests / Tests (internal) (push) Failing after 2s
Backend Tests / Tests (server) (push) Failing after 1s
Backend Tests / Tests (store) (push) Failing after 1s
Build Canary Image / build-frontend (push) Failing after 1s
Frontend Tests / Lint (push) Failing after 1s
Build Canary Image / build-push (linux/amd64) (push) Has been skipped
Build Canary Image / build-push (linux/arm64) (push) Has been skipped
Build Canary Image / merge (push) Has been skipped
Frontend Tests / Build (push) Failing after 1s
Release Please / release-please (push) Failing after 0s
Proto Linter / Lint Protos (push) Failing after 2s
This commit is contained in:
@@ -0,0 +1,7 @@
|
||||
node_modules
|
||||
.pnpm-store
|
||||
.DS_Store
|
||||
dist
|
||||
dist-ssr
|
||||
*.local
|
||||
src/types/proto/store
|
||||
+202
@@ -0,0 +1,202 @@
|
||||
{
|
||||
"$schema": "https://biomejs.dev/schemas/2.3.5/schema.json",
|
||||
"vcs": {
|
||||
"enabled": true,
|
||||
"clientKind": "git",
|
||||
"useIgnoreFile": true
|
||||
},
|
||||
"files": {
|
||||
"includes": [
|
||||
"**",
|
||||
"!!**/dist",
|
||||
"!src/types/proto"
|
||||
],
|
||||
"ignoreUnknown": true
|
||||
},
|
||||
"formatter": {
|
||||
"enabled": true,
|
||||
"formatWithErrors": false,
|
||||
"indentStyle": "space",
|
||||
"indentWidth": 2,
|
||||
"lineEnding": "lf",
|
||||
"lineWidth": 140,
|
||||
"attributePosition": "auto",
|
||||
"bracketSameLine": false,
|
||||
"bracketSpacing": true,
|
||||
"expand": "auto",
|
||||
"useEditorconfig": true
|
||||
},
|
||||
"linter": {
|
||||
"enabled": true,
|
||||
"rules": {
|
||||
"recommended": false,
|
||||
"complexity": {
|
||||
"noAdjacentSpacesInRegex": "error",
|
||||
"noExtraBooleanCast": "error",
|
||||
"noUselessCatch": "error",
|
||||
"noUselessEscapeInRegex": "error",
|
||||
"noUselessTypeConstraint": "error"
|
||||
},
|
||||
"correctness": {
|
||||
"noConstAssign": "error",
|
||||
"noConstantCondition": "error",
|
||||
"noEmptyCharacterClassInRegex": "error",
|
||||
"noEmptyPattern": "error",
|
||||
"noGlobalObjectCalls": "error",
|
||||
"noInvalidBuiltinInstantiation": "error",
|
||||
"noInvalidConstructorSuper": "error",
|
||||
"noNonoctalDecimalEscape": "error",
|
||||
"noPrecisionLoss": "error",
|
||||
"noSelfAssign": "error",
|
||||
"noSetterReturn": "error",
|
||||
"noSwitchDeclarations": "error",
|
||||
"noUndeclaredVariables": "error",
|
||||
"noUnreachable": "error",
|
||||
"noUnreachableSuper": "error",
|
||||
"noUnsafeFinally": "error",
|
||||
"noUnsafeOptionalChaining": "error",
|
||||
"noUnusedLabels": "error",
|
||||
"noUnusedPrivateClassMembers": "error",
|
||||
"noUnusedVariables": "error",
|
||||
"useIsNan": "error",
|
||||
"useValidForDirection": "error",
|
||||
"useValidTypeof": "error",
|
||||
"useYield": "error"
|
||||
},
|
||||
"style": {
|
||||
"noCommonJs": "error",
|
||||
"noNamespace": "error",
|
||||
"useArrayLiterals": "error",
|
||||
"useAsConstAssertion": "error",
|
||||
"useBlockStatements": "off"
|
||||
},
|
||||
"suspicious": {
|
||||
"noAsyncPromiseExecutor": "error",
|
||||
"noCatchAssign": "error",
|
||||
"noClassAssign": "error",
|
||||
"noCompareNegZero": "error",
|
||||
"noConstantBinaryExpressions": "error",
|
||||
"noControlCharactersInRegex": "error",
|
||||
"noDebugger": "error",
|
||||
"noDuplicateCase": "error",
|
||||
"noDuplicateClassMembers": "error",
|
||||
"noDuplicateElseIf": "error",
|
||||
"noDuplicateObjectKeys": "error",
|
||||
"noDuplicateParameters": "error",
|
||||
"noEmptyBlockStatements": "off",
|
||||
"noExplicitAny": "error",
|
||||
"noExtraNonNullAssertion": "error",
|
||||
"noFallthroughSwitchClause": "error",
|
||||
"noFunctionAssign": "error",
|
||||
"noGlobalAssign": "error",
|
||||
"noImportAssign": "error",
|
||||
"noIrregularWhitespace": "error",
|
||||
"noMisleadingCharacterClass": "error",
|
||||
"noMisleadingInstantiator": "error",
|
||||
"noNonNullAssertedOptionalChain": "error",
|
||||
"noPrototypeBuiltins": "error",
|
||||
"noRedeclare": "error",
|
||||
"noShadowRestrictedNames": "error",
|
||||
"noSparseArray": "error",
|
||||
"noUnsafeDeclarationMerging": "error",
|
||||
"noUnsafeNegation": "error",
|
||||
"noUselessRegexBackrefs": "error",
|
||||
"noWith": "error",
|
||||
"useGetterReturn": "error",
|
||||
"useNamespaceKeyword": "error"
|
||||
}
|
||||
},
|
||||
"includes": [
|
||||
"**",
|
||||
"!**/dist/**",
|
||||
"!**/node_modules/**",
|
||||
"!src/types/proto/**"
|
||||
]
|
||||
},
|
||||
"javascript": {
|
||||
"formatter": {
|
||||
"jsxQuoteStyle": "double",
|
||||
"quoteProperties": "asNeeded",
|
||||
"trailingCommas": "all",
|
||||
"semicolons": "always",
|
||||
"arrowParentheses": "always",
|
||||
"bracketSameLine": false,
|
||||
"quoteStyle": "double",
|
||||
"attributePosition": "auto",
|
||||
"bracketSpacing": true
|
||||
},
|
||||
"globals": []
|
||||
},
|
||||
"css": {
|
||||
"parser": {
|
||||
"cssModules": false,
|
||||
"allowWrongLineComments": true,
|
||||
"tailwindDirectives": true
|
||||
}
|
||||
},
|
||||
"html": {
|
||||
"formatter": {
|
||||
"indentScriptAndStyle": false,
|
||||
"selfCloseVoidElements": "always"
|
||||
}
|
||||
},
|
||||
"overrides": [
|
||||
{
|
||||
"includes": [
|
||||
"**/*.ts",
|
||||
"**/*.tsx",
|
||||
"**/*.mts",
|
||||
"**/*.cts"
|
||||
],
|
||||
"linter": {
|
||||
"rules": {
|
||||
"complexity": {
|
||||
"noArguments": "error"
|
||||
},
|
||||
"correctness": {
|
||||
"noConstAssign": "off",
|
||||
"noGlobalObjectCalls": "off",
|
||||
"noInvalidBuiltinInstantiation": "off",
|
||||
"noInvalidConstructorSuper": "off",
|
||||
"noSetterReturn": "off",
|
||||
"noUndeclaredVariables": "off",
|
||||
"noUnreachable": "off",
|
||||
"noUnreachableSuper": "off"
|
||||
},
|
||||
"style": {
|
||||
"useConst": "error"
|
||||
},
|
||||
"suspicious": {
|
||||
"noClassAssign": "off",
|
||||
"noDuplicateClassMembers": "off",
|
||||
"noDuplicateObjectKeys": "off",
|
||||
"noDuplicateParameters": "off",
|
||||
"noFunctionAssign": "off",
|
||||
"noImportAssign": "off",
|
||||
"noRedeclare": "off",
|
||||
"noUnsafeNegation": "off",
|
||||
"noVar": "error",
|
||||
"noWith": "off",
|
||||
"useGetterReturn": "off"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"includes": [
|
||||
"src/utils/i18n.ts"
|
||||
],
|
||||
"linter": {
|
||||
"rules": {}
|
||||
}
|
||||
}
|
||||
],
|
||||
"assist": {
|
||||
"enabled": true,
|
||||
"actions": {
|
||||
"source": {
|
||||
"organizeImports": "on"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
{
|
||||
"$schema": "https://ui.shadcn.com/schema.json",
|
||||
"style": "new-york",
|
||||
"rsc": false,
|
||||
"tsx": true,
|
||||
"tailwind": {
|
||||
"config": "",
|
||||
"css": "src/index.css",
|
||||
"baseColor": "zinc",
|
||||
"cssVariables": true,
|
||||
"prefix": ""
|
||||
},
|
||||
"aliases": {
|
||||
"components": "@/components",
|
||||
"utils": "@/lib/utils",
|
||||
"ui": "@/components/ui",
|
||||
"lib": "@/lib",
|
||||
"hooks": "@/hooks"
|
||||
},
|
||||
"iconLibrary": "lucide"
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
# Authentication State Architecture
|
||||
|
||||
## Current Approach: AuthContext
|
||||
|
||||
The application uses **AuthContext** for authentication state management, not React Query's `useCurrentUserQuery`. This is an intentional architectural decision.
|
||||
|
||||
### Why AuthContext Instead of React Query?
|
||||
|
||||
#### 1. **Synchronous Initialization**
|
||||
- AuthContext fetches user data during app initialization (`main.tsx`)
|
||||
- Provides synchronous access to `currentUser` throughout the app
|
||||
- No need to handle loading states in every component
|
||||
|
||||
#### 2. **Single Source of Truth**
|
||||
- User data fetched once on mount
|
||||
- All components get consistent, up-to-date user info
|
||||
- No race conditions from multiple query instances
|
||||
|
||||
#### 3. **Integration with React Query**
|
||||
- AuthContext pre-populates React Query cache after fetch (line 81-82 in `AuthContext.tsx`)
|
||||
- Best of both worlds: synchronous access + cache consistency
|
||||
- React Query hooks like `useNotifications()` can still use the cached user data
|
||||
|
||||
#### 4. **Simpler Component Code**
|
||||
```typescript
|
||||
// With AuthContext (current)
|
||||
const user = useCurrentUser(); // Always returns User | undefined
|
||||
|
||||
// With React Query (alternative)
|
||||
const { data: user, isLoading } = useCurrentUserQuery();
|
||||
if (isLoading) return <Spinner />;
|
||||
// Need loading handling everywhere
|
||||
```
|
||||
|
||||
### When to Use React Query for Auth?
|
||||
|
||||
Consider migrating auth to React Query if:
|
||||
- App needs real-time user profile updates from external sources
|
||||
- Multiple tabs need instant sync
|
||||
- User data changes frequently during a session
|
||||
|
||||
For Memos (a notes app where user profile rarely changes), AuthContext is the right choice.
|
||||
|
||||
### Future Considerations
|
||||
|
||||
The unused `useCurrentUserQuery()` hook in `useUserQueries.ts` is kept for potential future use. If requirements change (e.g., real-time collaboration on user profiles), migration path is clear:
|
||||
|
||||
1. Remove AuthContext
|
||||
2. Use `useCurrentUserQuery()` everywhere
|
||||
3. Handle loading states in components
|
||||
4. Add suspense boundaries if needed
|
||||
|
||||
## Recommendation
|
||||
|
||||
**Keep the current AuthContext approach.** It provides better DX and performance for this use case.
|
||||
@@ -0,0 +1,24 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta http-equiv="Cache-Control" content="no-cache, no-store, must-revalidate" />
|
||||
<meta http-equiv="Pragma" content="no-cache" />
|
||||
<meta http-equiv="Expires" content="0" />
|
||||
<link rel="apple-touch-icon" sizes="180x180" href="/apple-touch-icon.png" />
|
||||
<link rel="icon" type="image/webp" href="/logo.webp" />
|
||||
<link rel="manifest" href="/site.webmanifest" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1, user-scalable=no" />
|
||||
<meta name="theme-color" content="#faf9f5" />
|
||||
<meta name="mobile-web-app-capable" content="yes" />
|
||||
<meta name="apple-mobile-web-app-capable" content="yes" />
|
||||
<meta name="apple-mobile-web-app-status-bar-style" content="default" />
|
||||
<!-- memos.metadata.head -->
|
||||
<title>Memos</title>
|
||||
</head>
|
||||
<body class="text-base w-full min-h-svh">
|
||||
<div id="root" class="relative w-full min-h-full"></div>
|
||||
<script type="module" src="/src/main.tsx"></script>
|
||||
<!-- memos.metadata.body -->
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,114 @@
|
||||
{
|
||||
"name": "memos",
|
||||
"private": true,
|
||||
"packageManager": "pnpm@11.0.1",
|
||||
"engines": {
|
||||
"node": ">=24"
|
||||
},
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "vite build",
|
||||
"release": "vite build --mode release --outDir=../server/router/frontend/dist --emptyOutDir",
|
||||
"lint": "tsc --noEmit --skipLibCheck && biome check src",
|
||||
"lint:fix": "biome check --write src",
|
||||
"format": "biome format --write src",
|
||||
"test": "vitest run",
|
||||
"test:watch": "vitest",
|
||||
"test:coverage": "vitest run --coverage"
|
||||
},
|
||||
"dependencies": {
|
||||
"@codemirror/autocomplete": "^6.20.3",
|
||||
"@codemirror/commands": "^6.10.4",
|
||||
"@codemirror/lang-markdown": "^6.5.0",
|
||||
"@codemirror/language": "^6.12.4",
|
||||
"@codemirror/state": "^6.7.1",
|
||||
"@codemirror/view": "^6.43.6",
|
||||
"@connectrpc/connect": "^2.1.1",
|
||||
"@connectrpc/connect-web": "^2.1.1",
|
||||
"@github/relative-time-element": "^5.0.0",
|
||||
"@lezer/highlight": "^1.2.3",
|
||||
"@lezer/markdown": "^1.6.4",
|
||||
"@radix-ui/react-checkbox": "^1.3.3",
|
||||
"@radix-ui/react-dialog": "^1.1.15",
|
||||
"@radix-ui/react-dropdown-menu": "^2.1.16",
|
||||
"@radix-ui/react-label": "^2.1.8",
|
||||
"@radix-ui/react-popover": "^1.1.15",
|
||||
"@radix-ui/react-radio-group": "^1.3.8",
|
||||
"@radix-ui/react-select": "^2.2.6",
|
||||
"@radix-ui/react-separator": "^1.1.8",
|
||||
"@radix-ui/react-slot": "^1.2.4",
|
||||
"@radix-ui/react-switch": "^1.2.6",
|
||||
"@radix-ui/react-tooltip": "^1.2.8",
|
||||
"@tailwindcss/vite": "^4.2.4",
|
||||
"@tanstack/react-query": "^5.100.9",
|
||||
"@tanstack/react-query-devtools": "^5.100.9",
|
||||
"class-variance-authority": "^0.7.1",
|
||||
"clsx": "^2.1.1",
|
||||
"copy-to-clipboard": "^4.0.2",
|
||||
"dayjs": "^1.11.20",
|
||||
"fuse.js": "^7.3.0",
|
||||
"highlight.js": "^11.11.1",
|
||||
"html-to-image": "^1.11.13",
|
||||
"i18next": "^26.0.10",
|
||||
"katex": "^0.16.45",
|
||||
"leaflet": "^1.9.4",
|
||||
"leaflet.markercluster": "^1.5.3",
|
||||
"lodash-es": "^4.18.1",
|
||||
"lucide-react": "^1.14.0",
|
||||
"mdast-util-from-markdown": "^2.0.3",
|
||||
"mdast-util-gfm": "^3.1.0",
|
||||
"mermaid": "^11.14.0",
|
||||
"micromark-extension-gfm": "^3.0.0",
|
||||
"mime": "^4.1.0",
|
||||
"react": "^19.2.6",
|
||||
"react-dom": "^19.2.6",
|
||||
"react-hot-toast": "^2.6.0",
|
||||
"react-i18next": "^17.0.7",
|
||||
"react-leaflet": "^5.0.0",
|
||||
"react-leaflet-cluster": "^4.1.3",
|
||||
"react-markdown": "^10.1.0",
|
||||
"react-router-dom": "^7.15.0",
|
||||
"rehype-katex": "^7.0.1",
|
||||
"rehype-raw": "^7.0.0",
|
||||
"rehype-sanitize": "^6.0.0",
|
||||
"remark-breaks": "^4.0.0",
|
||||
"remark-gfm": "^4.0.1",
|
||||
"remark-math": "^6.0.0",
|
||||
"tailwind-merge": "^3.5.0",
|
||||
"tailwindcss": "^4.2.4",
|
||||
"unist-util-visit": "^5.1.0",
|
||||
"uuid": "^14.0.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@babel/core": "7.29.0",
|
||||
"@babel/plugin-transform-runtime": "7.29.0",
|
||||
"@babel/runtime": "7.29.2",
|
||||
"@biomejs/biome": "^2.4.14",
|
||||
"@bufbuild/protobuf": "^2.12.0",
|
||||
"@rolldown/plugin-babel": "0.2.3",
|
||||
"@testing-library/jest-dom": "^6.9.1",
|
||||
"@testing-library/react": "^16.3.2",
|
||||
"@types/babel__core": "7.20.5",
|
||||
"@types/d3": "^7.4.3",
|
||||
"@types/hast": "^3.0.4",
|
||||
"@types/katex": "^0.16.8",
|
||||
"@types/leaflet": "^1.9.21",
|
||||
"@types/lodash-es": "^4.17.12",
|
||||
"@types/mdast": "^4.0.4",
|
||||
"@types/node": "^24.12.2",
|
||||
"@types/qs": "^6.15.1",
|
||||
"@types/react": "^19.2.14",
|
||||
"@types/react-dom": "^19.2.3",
|
||||
"@types/unist": "^3.0.3",
|
||||
"@vitejs/plugin-react": "^6.0.1",
|
||||
"babel-plugin-react-compiler": "1.0.0",
|
||||
"baseline-browser-mapping": "^2.10.27",
|
||||
"jsdom": "^29.1.1",
|
||||
"long": "^5.3.2",
|
||||
"terser": "^5.47.1",
|
||||
"tw-animate-css": "^1.4.0",
|
||||
"typescript": "^7.0.2",
|
||||
"vite": "^8.0.11",
|
||||
"vitest": "^4.1.5"
|
||||
}
|
||||
}
|
||||
Generated
+6258
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,2 @@
|
||||
allowBuilds:
|
||||
esbuild: true
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 24 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 138 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 22 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 25 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 36 KiB |
@@ -0,0 +1,14 @@
|
||||
{
|
||||
"name": "Memos",
|
||||
"short_name": "Memos",
|
||||
"description": "An open-source, self-hosted note-taking tool. Capture thoughts instantly. Own them completely.",
|
||||
"icons": [
|
||||
{ "src": "/android-chrome-192x192.png", "sizes": "192x192", "type": "image/png" },
|
||||
{ "src": "/android-chrome-512x512.png", "sizes": "512x512", "type": "image/png" }
|
||||
],
|
||||
"display": "standalone",
|
||||
"scope": "/",
|
||||
"start_url": "/",
|
||||
"theme_color": "#faf9f5",
|
||||
"background_color": "#faf9f5"
|
||||
}
|
||||
@@ -0,0 +1,68 @@
|
||||
import { useEffect } from "react";
|
||||
import { Outlet } from "react-router-dom";
|
||||
import { useInstance } from "./contexts/InstanceContext";
|
||||
import { MemoFilterProvider } from "./contexts/MemoFilterContext";
|
||||
import useNavigateTo from "./hooks/useNavigateTo";
|
||||
import { useUserLocale } from "./hooks/useUserLocale";
|
||||
import { useUserTheme } from "./hooks/useUserTheme";
|
||||
import { cleanupExpiredOAuthState } from "./utils/oauth";
|
||||
|
||||
const App = () => {
|
||||
const navigateTo = useNavigateTo();
|
||||
const { profile: instanceProfile, profileLoaded, generalSetting: instanceGeneralSetting } = useInstance();
|
||||
|
||||
// Apply user preferences reactively
|
||||
useUserLocale();
|
||||
useUserTheme();
|
||||
|
||||
// Clean up expired OAuth states on app initialization
|
||||
useEffect(() => {
|
||||
cleanupExpiredOAuthState();
|
||||
}, []);
|
||||
|
||||
// Redirect to sign up page if the instance needs initial setup (no users yet).
|
||||
// needsSetup is used instead of a missing admin so an instance that has lost its
|
||||
// admins isn't mistaken for a fresh install (which would create a normal user).
|
||||
// Guard with profileLoaded so a fetch failure doesn't incorrectly trigger the redirect.
|
||||
useEffect(() => {
|
||||
if (profileLoaded && instanceProfile.needsSetup) {
|
||||
navigateTo("/auth/signup");
|
||||
}
|
||||
}, [profileLoaded, instanceProfile.needsSetup, navigateTo]);
|
||||
|
||||
useEffect(() => {
|
||||
if (instanceGeneralSetting.additionalStyle) {
|
||||
const styleEl = document.createElement("style");
|
||||
styleEl.innerHTML = instanceGeneralSetting.additionalStyle;
|
||||
styleEl.setAttribute("type", "text/css");
|
||||
document.body.insertAdjacentElement("beforeend", styleEl);
|
||||
}
|
||||
}, [instanceGeneralSetting.additionalStyle]);
|
||||
|
||||
useEffect(() => {
|
||||
if (instanceGeneralSetting.additionalScript) {
|
||||
const scriptEl = document.createElement("script");
|
||||
scriptEl.innerHTML = instanceGeneralSetting.additionalScript;
|
||||
document.head.appendChild(scriptEl);
|
||||
}
|
||||
}, [instanceGeneralSetting.additionalScript]);
|
||||
|
||||
// Dynamic update metadata with customized profile
|
||||
useEffect(() => {
|
||||
if (!instanceGeneralSetting.customProfile) {
|
||||
return;
|
||||
}
|
||||
|
||||
document.title = instanceGeneralSetting.customProfile.title;
|
||||
const link = document.querySelector("link[rel~='icon']") as HTMLLinkElement;
|
||||
link.href = instanceGeneralSetting.customProfile.logoUrl || "/logo.webp";
|
||||
}, [instanceGeneralSetting.customProfile]);
|
||||
|
||||
return (
|
||||
<MemoFilterProvider>
|
||||
<Outlet />
|
||||
</MemoFilterProvider>
|
||||
);
|
||||
};
|
||||
|
||||
export default App;
|
||||
@@ -0,0 +1,128 @@
|
||||
// Access token storage using localStorage for persistence across tabs and sessions.
|
||||
// Tokens are cleared on logout or expiry.
|
||||
let accessToken: string | null = null;
|
||||
let tokenExpiresAt: Date | null = null;
|
||||
|
||||
const TOKEN_KEY = "memos_access_token";
|
||||
const EXPIRES_KEY = "memos_token_expires_at";
|
||||
|
||||
// BroadcastChannel lets tabs share freshly-refreshed tokens so that only one
|
||||
// tab needs to hit the refresh endpoint. When another tab successfully refreshes
|
||||
// we adopt the new token immediately, avoiding a redundant (and potentially
|
||||
// conflicting) refresh request of our own.
|
||||
const TOKEN_CHANNEL_NAME = "memos_token_sync";
|
||||
|
||||
// Token refresh policy:
|
||||
// - REQUEST_TOKEN_EXPIRY_BUFFER_MS: used for normal API requests.
|
||||
// - FOCUS_TOKEN_EXPIRY_BUFFER_MS: used on tab visibility restore to refresh earlier.
|
||||
export const REQUEST_TOKEN_EXPIRY_BUFFER_MS = 30 * 1000;
|
||||
export const FOCUS_TOKEN_EXPIRY_BUFFER_MS = 2 * 60 * 1000;
|
||||
|
||||
interface TokenBroadcastMessage {
|
||||
token: string;
|
||||
expiresAt: string; // ISO string
|
||||
}
|
||||
|
||||
let tokenChannel: BroadcastChannel | null = null;
|
||||
|
||||
function getTokenChannel(): BroadcastChannel | null {
|
||||
if (tokenChannel) return tokenChannel;
|
||||
try {
|
||||
tokenChannel = new BroadcastChannel(TOKEN_CHANNEL_NAME);
|
||||
tokenChannel.onmessage = (event: MessageEvent<TokenBroadcastMessage>) => {
|
||||
const { token, expiresAt } = event.data ?? {};
|
||||
if (token && expiresAt) {
|
||||
// Another tab refreshed — adopt the token in-memory so we don't
|
||||
// fire our own refresh request.
|
||||
accessToken = token;
|
||||
tokenExpiresAt = new Date(expiresAt);
|
||||
}
|
||||
};
|
||||
} catch {
|
||||
// BroadcastChannel not available (e.g. some privacy modes)
|
||||
tokenChannel = null;
|
||||
}
|
||||
return tokenChannel;
|
||||
}
|
||||
|
||||
// Initialize the channel at module load so the listener is registered
|
||||
// before any token refresh can occur in any tab.
|
||||
getTokenChannel();
|
||||
|
||||
export const getAccessToken = (): string | null => {
|
||||
if (!accessToken) {
|
||||
try {
|
||||
const storedToken = localStorage.getItem(TOKEN_KEY);
|
||||
const storedExpires = localStorage.getItem(EXPIRES_KEY);
|
||||
|
||||
if (storedToken && storedExpires) {
|
||||
const expiresAt = new Date(storedExpires);
|
||||
if (expiresAt > new Date()) {
|
||||
accessToken = storedToken;
|
||||
tokenExpiresAt = expiresAt;
|
||||
}
|
||||
// Do NOT remove expired tokens here. getRequestToken() in connect.ts calls
|
||||
// hasStoredToken() to decide whether to attempt a refresh — if we eagerly delete
|
||||
// the expired token, it returns null immediately, skipping the refresh and sending
|
||||
// the request without credentials.
|
||||
// clearAccessToken() handles proper cleanup after a confirmed auth failure or logout.
|
||||
}
|
||||
} catch (e) {
|
||||
// localStorage might not be available (e.g., in some privacy modes)
|
||||
console.warn("Failed to access localStorage:", e);
|
||||
}
|
||||
}
|
||||
return accessToken;
|
||||
};
|
||||
|
||||
export const setAccessToken = (token: string | null, expiresAt?: Date): void => {
|
||||
accessToken = token;
|
||||
tokenExpiresAt = expiresAt || null;
|
||||
|
||||
try {
|
||||
if (token && expiresAt) {
|
||||
localStorage.setItem(TOKEN_KEY, token);
|
||||
localStorage.setItem(EXPIRES_KEY, expiresAt.toISOString());
|
||||
// Broadcast to other tabs so they adopt the new token without refreshing.
|
||||
const msg: TokenBroadcastMessage = { token, expiresAt: expiresAt.toISOString() };
|
||||
getTokenChannel()?.postMessage(msg);
|
||||
} else {
|
||||
localStorage.removeItem(TOKEN_KEY);
|
||||
localStorage.removeItem(EXPIRES_KEY);
|
||||
}
|
||||
} catch (e) {
|
||||
// localStorage might not be available (e.g., in some privacy modes)
|
||||
console.warn("Failed to write to localStorage:", e);
|
||||
}
|
||||
};
|
||||
|
||||
export const isTokenExpired = (bufferMs: number = REQUEST_TOKEN_EXPIRY_BUFFER_MS): boolean => {
|
||||
if (!tokenExpiresAt) return true;
|
||||
// Consider expired with a safety buffer before actual expiry.
|
||||
return new Date() >= new Date(tokenExpiresAt.getTime() - bufferMs);
|
||||
};
|
||||
|
||||
// Returns true if a token exists in localStorage, even if it is expired.
|
||||
// Used to decide whether to attempt GetCurrentUser on app init — if no token
|
||||
// was ever stored, the user is definitively not logged in and there is nothing
|
||||
// to refresh, so we can skip the network round-trip entirely.
|
||||
export const hasStoredToken = (): boolean => {
|
||||
if (accessToken) return true;
|
||||
try {
|
||||
return !!localStorage.getItem(TOKEN_KEY);
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
};
|
||||
|
||||
export const clearAccessToken = (): void => {
|
||||
accessToken = null;
|
||||
tokenExpiresAt = null;
|
||||
|
||||
try {
|
||||
localStorage.removeItem(TOKEN_KEY);
|
||||
localStorage.removeItem(EXPIRES_KEY);
|
||||
} catch (e) {
|
||||
console.warn("Failed to clear localStorage:", e);
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,82 @@
|
||||
import { memo } from "react";
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { DEFAULT_CELL_SIZE, SMALL_CELL_SIZE } from "./constants";
|
||||
import type { CalendarDayCell, CalendarSize } from "./types";
|
||||
import { getCalendarCellStateClass, getCellIntensityClass } from "./utils";
|
||||
|
||||
export interface CalendarCellProps {
|
||||
day: CalendarDayCell;
|
||||
maxCount: number;
|
||||
tooltipText: string;
|
||||
onClick?: (date: string) => void;
|
||||
size?: CalendarSize;
|
||||
disableTooltip?: boolean;
|
||||
}
|
||||
|
||||
export const CalendarCell = memo((props: CalendarCellProps) => {
|
||||
const { day, maxCount, tooltipText, onClick, size = "default", disableTooltip = false } = props;
|
||||
|
||||
const handleClick = () => {
|
||||
if (onClick) {
|
||||
onClick(day.date);
|
||||
}
|
||||
};
|
||||
|
||||
const sizeConfig = size === "small" ? SMALL_CELL_SIZE : DEFAULT_CELL_SIZE;
|
||||
const smallExtraClasses = size === "small" ? `${SMALL_CELL_SIZE.dimensions} min-h-0` : "";
|
||||
|
||||
const baseClasses = cn(
|
||||
"aspect-square w-full flex items-center justify-center text-center transition-all duration-150 select-none border border-border/10 bg-muted/20",
|
||||
sizeConfig.font,
|
||||
sizeConfig.borderRadius,
|
||||
smallExtraClasses,
|
||||
);
|
||||
const isInteractive = Boolean(onClick);
|
||||
const ariaLabel = day.isSelected ? `${tooltipText} (selected)` : tooltipText;
|
||||
|
||||
if (!day.isCurrentMonth) {
|
||||
return <div className={cn(baseClasses, "text-muted-foreground/30 bg-transparent border-transparent cursor-default")}>{day.label}</div>;
|
||||
}
|
||||
|
||||
const intensityClass = getCellIntensityClass(day, maxCount);
|
||||
|
||||
const buttonClasses = cn(
|
||||
"h-auto p-0",
|
||||
baseClasses,
|
||||
intensityClass,
|
||||
getCalendarCellStateClass(day),
|
||||
isInteractive ? "cursor-pointer hover:bg-muted/40 hover:border-border/30" : "cursor-default",
|
||||
);
|
||||
|
||||
const button = (
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleClick}
|
||||
tabIndex={isInteractive ? 0 : -1}
|
||||
aria-label={ariaLabel}
|
||||
aria-current={day.isToday ? "date" : undefined}
|
||||
aria-disabled={!isInteractive}
|
||||
className={buttonClasses}
|
||||
>
|
||||
{day.label}
|
||||
</button>
|
||||
);
|
||||
|
||||
const shouldShowTooltip = tooltipText && !disableTooltip;
|
||||
|
||||
if (!shouldShowTooltip) {
|
||||
return button;
|
||||
}
|
||||
|
||||
return (
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>{button}</TooltipTrigger>
|
||||
<TooltipContent side="top">
|
||||
<p>{tooltipText}</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
);
|
||||
});
|
||||
|
||||
CalendarCell.displayName = "CalendarCell";
|
||||
@@ -0,0 +1,87 @@
|
||||
import { memo, useMemo } from "react";
|
||||
import { useInstance } from "@/contexts/InstanceContext";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { useTranslate } from "@/utils/i18n";
|
||||
import { CalendarCell } from "./CalendarCell";
|
||||
import { useTodayDate, useWeekdayLabels } from "./hooks";
|
||||
import type { CalendarSize, MonthCalendarProps } from "./types";
|
||||
import { useCalendarMatrix } from "./useCalendar";
|
||||
import { getTooltipText } from "./utils";
|
||||
|
||||
const GRID_STYLES: Record<CalendarSize, { gap: string; headerText: string }> = {
|
||||
small: { gap: "gap-1.5", headerText: "text-[10px]" },
|
||||
default: { gap: "gap-2", headerText: "text-xs" },
|
||||
};
|
||||
|
||||
interface WeekdayHeaderProps {
|
||||
weekDays: string[];
|
||||
size: CalendarSize;
|
||||
}
|
||||
|
||||
const WeekdayHeader = memo(({ weekDays, size }: WeekdayHeaderProps) => (
|
||||
<div className={cn("grid grid-cols-7 mb-1", GRID_STYLES[size].gap, GRID_STYLES[size].headerText)} role="row">
|
||||
{weekDays.map((label, index) => (
|
||||
<div
|
||||
key={index}
|
||||
className="flex h-4 items-center justify-center uppercase tracking-wide text-muted-foreground/60"
|
||||
role="columnheader"
|
||||
aria-label={label}
|
||||
>
|
||||
{label}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
));
|
||||
WeekdayHeader.displayName = "WeekdayHeader";
|
||||
|
||||
export const MonthCalendar = memo((props: MonthCalendarProps) => {
|
||||
const {
|
||||
month,
|
||||
data,
|
||||
maxCount,
|
||||
size = "default",
|
||||
onClick,
|
||||
selectedDate,
|
||||
className,
|
||||
disableTooltips = false,
|
||||
timeBasis = "create_time",
|
||||
} = props;
|
||||
const t = useTranslate();
|
||||
const { generalSetting } = useInstance();
|
||||
const today = useTodayDate();
|
||||
const weekDays = useWeekdayLabels();
|
||||
const gridStyle = GRID_STYLES[size];
|
||||
|
||||
const { weeks, weekDays: rotatedWeekDays } = useCalendarMatrix({
|
||||
month,
|
||||
data,
|
||||
weekDays,
|
||||
weekStartDayOffset: generalSetting.weekStartDayOffset,
|
||||
today,
|
||||
selectedDate: selectedDate ?? "",
|
||||
});
|
||||
|
||||
const flatDays = useMemo(() => weeks.flatMap((week) => week.days), [weeks]);
|
||||
|
||||
return (
|
||||
<div className={cn("flex flex-col", className)} role="grid" aria-label={`Calendar for ${month}`}>
|
||||
<WeekdayHeader weekDays={rotatedWeekDays} size={size} />
|
||||
|
||||
<div className={cn("grid grid-cols-7", gridStyle.gap)} role="rowgroup">
|
||||
{flatDays.map((day) => (
|
||||
<CalendarCell
|
||||
key={day.date}
|
||||
day={day}
|
||||
maxCount={maxCount}
|
||||
tooltipText={getTooltipText(day.count, day.date, t, timeBasis)}
|
||||
onClick={onClick}
|
||||
size={size}
|
||||
disableTooltip={disableTooltips}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
});
|
||||
|
||||
MonthCalendar.displayName = "MonthCalendar";
|
||||
@@ -0,0 +1,118 @@
|
||||
import { ChevronLeftIcon, ChevronRightIcon } from "lucide-react";
|
||||
import { memo, useMemo } from "react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import type { MemoTimeBasis } from "@/contexts/ViewContext";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { useTranslate } from "@/utils/i18n";
|
||||
import { getMaxYear, MIN_YEAR } from "./constants";
|
||||
import { MonthCalendar } from "./MonthCalendar";
|
||||
import type { CalendarData, YearCalendarProps } from "./types";
|
||||
import { calculateMaxCount, filterDataByYear, generateMonthsForYear, getMonthLabel } from "./utils";
|
||||
|
||||
interface YearNavigationProps {
|
||||
selectedYear: number;
|
||||
currentYear: number;
|
||||
onPrev: () => void;
|
||||
onNext: () => void;
|
||||
onToday: () => void;
|
||||
canGoPrev: boolean;
|
||||
canGoNext: boolean;
|
||||
}
|
||||
|
||||
const YearNavigation = memo(({ selectedYear, currentYear, onPrev, onNext, onToday, canGoPrev, canGoNext }: YearNavigationProps) => {
|
||||
const t = useTranslate();
|
||||
const isCurrentYear = selectedYear === currentYear;
|
||||
|
||||
return (
|
||||
<div className="flex items-center justify-between px-1">
|
||||
<h2 className="text-2xl font-semibold text-foreground tracking-tight">{selectedYear}</h2>
|
||||
|
||||
<nav className="inline-flex items-center gap-0.5 rounded-lg border border-border/30 bg-muted/10 p-0.5" aria-label="Year navigation">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={onPrev}
|
||||
disabled={!canGoPrev}
|
||||
aria-label="Previous year"
|
||||
className="h-7 w-7 p-0 rounded-md text-muted-foreground hover:text-foreground hover:bg-muted/40"
|
||||
>
|
||||
<ChevronLeftIcon className="w-4 h-4" />
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={onToday}
|
||||
disabled={isCurrentYear}
|
||||
aria-label={t("common.today")}
|
||||
className={cn(
|
||||
"h-7 px-2.5 rounded-md text-[10px] font-medium uppercase tracking-wider",
|
||||
isCurrentYear ? "text-muted-foreground/50 cursor-default" : "text-muted-foreground hover:text-foreground hover:bg-muted/40",
|
||||
)}
|
||||
>
|
||||
{t("common.today")}
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={onNext}
|
||||
disabled={!canGoNext}
|
||||
aria-label="Next year"
|
||||
className="h-7 w-7 p-0 rounded-md text-muted-foreground hover:text-foreground hover:bg-muted/40"
|
||||
>
|
||||
<ChevronRightIcon className="w-4 h-4" />
|
||||
</Button>
|
||||
</nav>
|
||||
</div>
|
||||
);
|
||||
});
|
||||
YearNavigation.displayName = "YearNavigation";
|
||||
|
||||
interface MonthCardProps {
|
||||
month: string;
|
||||
data: CalendarData;
|
||||
maxCount: number;
|
||||
onDateClick: (date: string) => void;
|
||||
timeBasis?: MemoTimeBasis;
|
||||
}
|
||||
|
||||
const MonthCard = memo(({ month, data, maxCount, onDateClick, timeBasis }: MonthCardProps) => (
|
||||
<article className="flex flex-col gap-2 rounded-xl border border-border/20 bg-muted/5 p-3 transition-colors hover:bg-muted/10">
|
||||
<header className="text-[10px] font-medium text-muted-foreground/80 uppercase tracking-widest">{getMonthLabel(month)}</header>
|
||||
<MonthCalendar month={month} data={data} maxCount={maxCount} size="small" onClick={onDateClick} disableTooltips timeBasis={timeBasis} />
|
||||
</article>
|
||||
));
|
||||
MonthCard.displayName = "MonthCard";
|
||||
|
||||
export const YearCalendar = memo(({ selectedYear, data, onYearChange, onDateClick, className, timeBasis }: YearCalendarProps) => {
|
||||
const currentYear = useMemo(() => new Date().getFullYear(), []);
|
||||
const yearData = useMemo(() => filterDataByYear(data, selectedYear), [data, selectedYear]);
|
||||
const months = useMemo(() => generateMonthsForYear(selectedYear), [selectedYear]);
|
||||
const yearMaxCount = useMemo(() => calculateMaxCount(yearData), [yearData]);
|
||||
|
||||
const canGoPrev = selectedYear > MIN_YEAR;
|
||||
const canGoNext = selectedYear < getMaxYear();
|
||||
|
||||
return (
|
||||
<section className={cn("w-full flex flex-col gap-5 px-4 py-4 select-none", className)} aria-label={`Year ${selectedYear} calendar`}>
|
||||
<YearNavigation
|
||||
selectedYear={selectedYear}
|
||||
currentYear={currentYear}
|
||||
onPrev={() => canGoPrev && onYearChange(selectedYear - 1)}
|
||||
onNext={() => canGoNext && onYearChange(selectedYear + 1)}
|
||||
onToday={() => onYearChange(currentYear)}
|
||||
canGoPrev={canGoPrev}
|
||||
canGoNext={canGoNext}
|
||||
/>
|
||||
|
||||
<div className="grid gap-4 grid-cols-1 sm:grid-cols-2 md:grid-cols-3 lg:grid-cols-4 animate-fade-in">
|
||||
{months.map((month) => (
|
||||
<MonthCard key={month} month={month} data={yearData} maxCount={yearMaxCount} onDateClick={onDateClick} timeBasis={timeBasis} />
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
});
|
||||
|
||||
YearCalendar.displayName = "YearCalendar";
|
||||
@@ -0,0 +1,34 @@
|
||||
export const DAYS_IN_WEEK = 7;
|
||||
export const MONTHS_IN_YEAR = 12;
|
||||
export const MIN_COUNT = 1;
|
||||
|
||||
export const MIN_YEAR = 1970;
|
||||
export const getMaxYear = () => new Date().getFullYear() + 1;
|
||||
|
||||
export const INTENSITY_THRESHOLDS = {
|
||||
HIGH: 0.75,
|
||||
MEDIUM: 0.5,
|
||||
LOW: 0.25,
|
||||
MINIMAL: 0,
|
||||
} as const;
|
||||
|
||||
export const CELL_STYLES = {
|
||||
HIGH: "bg-primary text-primary-foreground shadow-sm border-transparent",
|
||||
MEDIUM: "bg-primary/85 text-primary-foreground shadow-sm border-transparent",
|
||||
LOW: "bg-primary/70 text-primary-foreground border-transparent",
|
||||
MINIMAL: "bg-primary/50 text-foreground border-transparent",
|
||||
EMPTY: "bg-muted/20 text-muted-foreground hover:bg-muted/30 border-border/10",
|
||||
} as const;
|
||||
|
||||
export const SMALL_CELL_SIZE = {
|
||||
font: "text-[11px]",
|
||||
dimensions: "w-full h-full",
|
||||
borderRadius: "rounded-lg",
|
||||
gap: "gap-1.5",
|
||||
} as const;
|
||||
|
||||
export const DEFAULT_CELL_SIZE = {
|
||||
font: "text-xs",
|
||||
borderRadius: "rounded-lg",
|
||||
gap: "gap-2",
|
||||
} as const;
|
||||
@@ -0,0 +1,23 @@
|
||||
import dayjs from "dayjs";
|
||||
import { useMemo } from "react";
|
||||
import { useTranslate } from "@/utils/i18n";
|
||||
|
||||
export const useWeekdayLabels = () => {
|
||||
const t = useTranslate();
|
||||
return useMemo(
|
||||
() => [
|
||||
t("common.days.sun"),
|
||||
t("common.days.mon"),
|
||||
t("common.days.tue"),
|
||||
t("common.days.wed"),
|
||||
t("common.days.thu"),
|
||||
t("common.days.fri"),
|
||||
t("common.days.sat"),
|
||||
],
|
||||
[t],
|
||||
);
|
||||
};
|
||||
|
||||
export const useTodayDate = () => {
|
||||
return dayjs().format("YYYY-MM-DD");
|
||||
};
|
||||
@@ -0,0 +1,4 @@
|
||||
export * from "./MonthCalendar";
|
||||
export * from "./types";
|
||||
export * from "./utils";
|
||||
export * from "./YearCalendar";
|
||||
@@ -0,0 +1,43 @@
|
||||
import type { MemoTimeBasis } from "@/contexts/ViewContext";
|
||||
|
||||
export type CalendarSize = "default" | "small";
|
||||
export type CalendarData = Record<string, number>;
|
||||
|
||||
export interface CalendarDayCell {
|
||||
date: string;
|
||||
label: number;
|
||||
count: number;
|
||||
isCurrentMonth: boolean;
|
||||
isToday: boolean;
|
||||
isSelected: boolean;
|
||||
}
|
||||
|
||||
export interface CalendarDayRow {
|
||||
days: CalendarDayCell[];
|
||||
}
|
||||
|
||||
export interface CalendarMatrixResult {
|
||||
weeks: CalendarDayRow[];
|
||||
weekDays: string[];
|
||||
}
|
||||
|
||||
export interface MonthCalendarProps {
|
||||
month: string;
|
||||
data: CalendarData;
|
||||
maxCount: number;
|
||||
size?: CalendarSize;
|
||||
onClick?: (date: string) => void;
|
||||
selectedDate?: string;
|
||||
className?: string;
|
||||
disableTooltips?: boolean;
|
||||
timeBasis?: MemoTimeBasis;
|
||||
}
|
||||
|
||||
export interface YearCalendarProps {
|
||||
selectedYear: number;
|
||||
data: CalendarData;
|
||||
onYearChange: (year: number) => void;
|
||||
onDateClick: (date: string) => void;
|
||||
className?: string;
|
||||
timeBasis?: MemoTimeBasis;
|
||||
}
|
||||
@@ -0,0 +1,90 @@
|
||||
import dayjs from "dayjs";
|
||||
import { useMemo } from "react";
|
||||
import { DAYS_IN_WEEK } from "./constants";
|
||||
import type { CalendarData, CalendarDayCell, CalendarMatrixResult } from "./types";
|
||||
|
||||
export interface UseCalendarMatrixParams {
|
||||
month: string;
|
||||
data: CalendarData;
|
||||
weekDays: string[];
|
||||
weekStartDayOffset: number;
|
||||
today: string;
|
||||
selectedDate: string;
|
||||
}
|
||||
|
||||
const createCalendarDayCell = (
|
||||
current: dayjs.Dayjs,
|
||||
monthKey: string,
|
||||
data: CalendarData,
|
||||
today: string,
|
||||
selectedDate: string,
|
||||
): CalendarDayCell => {
|
||||
const isoDate = current.format("YYYY-MM-DD");
|
||||
const isCurrentMonth = current.format("YYYY-MM") === monthKey;
|
||||
const count = data[isoDate] ?? 0;
|
||||
|
||||
return {
|
||||
date: isoDate,
|
||||
label: current.date(),
|
||||
count,
|
||||
isCurrentMonth,
|
||||
isToday: isoDate === today,
|
||||
isSelected: isoDate === selectedDate,
|
||||
};
|
||||
};
|
||||
|
||||
const calculateCalendarBoundaries = (monthStart: dayjs.Dayjs, weekStartDayOffset: number) => {
|
||||
const monthEnd = monthStart.endOf("month");
|
||||
const startOffset = (monthStart.day() - weekStartDayOffset + DAYS_IN_WEEK) % DAYS_IN_WEEK;
|
||||
const endOffset = (weekStartDayOffset + (DAYS_IN_WEEK - 1) - monthEnd.day() + DAYS_IN_WEEK) % DAYS_IN_WEEK;
|
||||
const calendarStart = monthStart.subtract(startOffset, "day");
|
||||
const calendarEnd = monthEnd.add(endOffset, "day");
|
||||
const dayCount = calendarEnd.diff(calendarStart, "day") + 1;
|
||||
|
||||
return { calendarStart, dayCount };
|
||||
};
|
||||
|
||||
/**
|
||||
* Generates a matrix of calendar days for a given month, handling week alignment and data mapping.
|
||||
*/
|
||||
export const useCalendarMatrix = ({
|
||||
month,
|
||||
data,
|
||||
weekDays,
|
||||
weekStartDayOffset,
|
||||
today,
|
||||
selectedDate,
|
||||
}: UseCalendarMatrixParams): CalendarMatrixResult => {
|
||||
return useMemo(() => {
|
||||
// Determine the start of the month and its formatted key (YYYY-MM)
|
||||
const monthStart = dayjs(month).startOf("month");
|
||||
const monthKey = monthStart.format("YYYY-MM");
|
||||
|
||||
// Rotate week labels based on the user's preferred start of the week
|
||||
const rotatedWeekDays = weekDays.slice(weekStartDayOffset).concat(weekDays.slice(0, weekStartDayOffset));
|
||||
|
||||
// Calculate the start and end dates for the calendar grid to ensure full weeks
|
||||
const { calendarStart, dayCount } = calculateCalendarBoundaries(monthStart, weekStartDayOffset);
|
||||
|
||||
const weeks: CalendarMatrixResult["weeks"] = [];
|
||||
|
||||
// Iterate through each day in the calendar grid
|
||||
for (let index = 0; index < dayCount; index += 1) {
|
||||
const current = calendarStart.add(index, "day");
|
||||
const weekIndex = Math.floor(index / DAYS_IN_WEEK);
|
||||
|
||||
if (!weeks[weekIndex]) {
|
||||
weeks[weekIndex] = { days: [] };
|
||||
}
|
||||
|
||||
// Create the day cell object with data and status flags
|
||||
const dayCell = createCalendarDayCell(current, monthKey, data, today, selectedDate);
|
||||
weeks[weekIndex].days.push(dayCell);
|
||||
}
|
||||
|
||||
return {
|
||||
weeks,
|
||||
weekDays: rotatedWeekDays,
|
||||
};
|
||||
}, [month, data, weekDays, weekStartDayOffset, today, selectedDate]);
|
||||
};
|
||||
@@ -0,0 +1,75 @@
|
||||
import dayjs from "dayjs";
|
||||
import isSameOrAfter from "dayjs/plugin/isSameOrAfter";
|
||||
import isSameOrBefore from "dayjs/plugin/isSameOrBefore";
|
||||
import type { MemoTimeBasis } from "@/contexts/ViewContext";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { useTranslate } from "@/utils/i18n";
|
||||
import { CELL_STYLES, INTENSITY_THRESHOLDS, MIN_COUNT, MONTHS_IN_YEAR } from "./constants";
|
||||
import type { CalendarData, CalendarDayCell } from "./types";
|
||||
|
||||
dayjs.extend(isSameOrAfter);
|
||||
dayjs.extend(isSameOrBefore);
|
||||
|
||||
export type TranslateFunction = ReturnType<typeof useTranslate>;
|
||||
|
||||
export const getCellIntensityClass = (day: CalendarDayCell, maxCount: number): string => {
|
||||
if (!day.isCurrentMonth || day.count === 0) {
|
||||
return CELL_STYLES.EMPTY;
|
||||
}
|
||||
|
||||
const ratio = day.count / maxCount;
|
||||
if (ratio > INTENSITY_THRESHOLDS.HIGH) return CELL_STYLES.HIGH;
|
||||
if (ratio > INTENSITY_THRESHOLDS.MEDIUM) return CELL_STYLES.MEDIUM;
|
||||
if (ratio > INTENSITY_THRESHOLDS.LOW) return CELL_STYLES.LOW;
|
||||
return CELL_STYLES.MINIMAL;
|
||||
};
|
||||
|
||||
export const getCalendarCellStateClass = (day: Pick<CalendarDayCell, "isToday" | "isSelected">): string => {
|
||||
return cn(day.isToday && "font-semibold z-10", day.isSelected && "font-bold z-10");
|
||||
};
|
||||
|
||||
export const generateMonthsForYear = (year: number): string[] => {
|
||||
return Array.from({ length: MONTHS_IN_YEAR }, (_, i) => dayjs(`${year}-01-01`).add(i, "month").format("YYYY-MM"));
|
||||
};
|
||||
|
||||
export const calculateMaxCount = (data: CalendarData): number => {
|
||||
let max = 0;
|
||||
for (const count of Object.values(data)) {
|
||||
max = Math.max(max, count);
|
||||
}
|
||||
return Math.max(max, MIN_COUNT);
|
||||
};
|
||||
|
||||
export const getMonthLabel = (month: string): string => {
|
||||
return dayjs(month).format("MMM");
|
||||
};
|
||||
|
||||
export const filterDataByYear = (data: Record<string, number>, year: number): Record<string, number> => {
|
||||
if (!data) return {};
|
||||
|
||||
const filtered: Record<string, number> = {};
|
||||
const yearStart = dayjs(`${year}-01-01`);
|
||||
const yearEnd = dayjs(`${year}-12-31`);
|
||||
|
||||
for (const [dateStr, count] of Object.entries(data)) {
|
||||
const date = dayjs(dateStr);
|
||||
if (date.isSameOrAfter(yearStart, "day") && date.isSameOrBefore(yearEnd, "day")) {
|
||||
filtered[dateStr] = count;
|
||||
}
|
||||
}
|
||||
|
||||
return filtered;
|
||||
};
|
||||
|
||||
export const getTooltipText = (count: number, date: string, t: TranslateFunction, timeBasis: MemoTimeBasis = "create_time"): string => {
|
||||
if (count === 0) {
|
||||
return date;
|
||||
}
|
||||
|
||||
const key = timeBasis === "update_time" ? "memo.count-memos-updated-in-date" : "memo.count-memos-in-date";
|
||||
return t(key, {
|
||||
count,
|
||||
memos: count === 1 ? t("common.memo") : t("common.memos"),
|
||||
date,
|
||||
}).toLowerCase();
|
||||
};
|
||||
@@ -0,0 +1,108 @@
|
||||
import {
|
||||
BinaryIcon,
|
||||
BookIcon,
|
||||
FileArchiveIcon,
|
||||
FileAudioIcon,
|
||||
FileEditIcon,
|
||||
FileIcon,
|
||||
FileTextIcon,
|
||||
FileVideo2Icon,
|
||||
SheetIcon,
|
||||
} from "lucide-react";
|
||||
import React, { useState } from "react";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { Attachment } from "@/types/proto/api/v1/attachment_service_pb";
|
||||
import { getAttachmentThumbnailUrl, getAttachmentType, getAttachmentUrl } from "@/utils/attachment";
|
||||
import SquareDiv from "./kit/SquareDiv";
|
||||
import PreviewImageDialog from "./PreviewImageDialog";
|
||||
|
||||
interface Props {
|
||||
attachment: Attachment;
|
||||
className?: string;
|
||||
strokeWidth?: number;
|
||||
}
|
||||
|
||||
const AttachmentIcon = (props: Props) => {
|
||||
const { attachment } = props;
|
||||
const [previewImage, setPreviewImage] = useState<{ open: boolean; urls: string[]; index: number }>({
|
||||
open: false,
|
||||
urls: [],
|
||||
index: 0,
|
||||
});
|
||||
const resourceType = getAttachmentType(attachment);
|
||||
const attachmentUrl = getAttachmentUrl(attachment);
|
||||
const className = cn("w-full h-auto", props.className);
|
||||
const strokeWidth = props.strokeWidth;
|
||||
|
||||
const previewResource = () => {
|
||||
window.open(attachmentUrl);
|
||||
};
|
||||
|
||||
const handleImageClick = () => {
|
||||
setPreviewImage({ open: true, urls: [attachmentUrl], index: 0 });
|
||||
};
|
||||
|
||||
if (resourceType === "image/*") {
|
||||
return (
|
||||
<>
|
||||
<SquareDiv className={cn(className, "flex items-center justify-center overflow-clip")}>
|
||||
<img
|
||||
className="min-w-full min-h-full object-cover"
|
||||
src={getAttachmentThumbnailUrl(attachment)}
|
||||
onClick={handleImageClick}
|
||||
onError={(e) => {
|
||||
// Fallback to original image if thumbnail fails
|
||||
const target = e.target as HTMLImageElement;
|
||||
if (target.src.includes("?thumbnail=true")) {
|
||||
console.warn("Thumbnail failed, falling back to original image:", attachmentUrl);
|
||||
target.src = attachmentUrl;
|
||||
}
|
||||
}}
|
||||
decoding="async"
|
||||
loading="lazy"
|
||||
/>
|
||||
</SquareDiv>
|
||||
|
||||
<PreviewImageDialog
|
||||
open={previewImage.open}
|
||||
onOpenChange={(open) => setPreviewImage((prev) => ({ ...prev, open }))}
|
||||
imgUrls={previewImage.urls}
|
||||
initialIndex={previewImage.index}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
const getAttachmentIcon = () => {
|
||||
switch (resourceType) {
|
||||
case "video/*":
|
||||
return <FileVideo2Icon strokeWidth={strokeWidth} className="w-full h-auto" />;
|
||||
case "audio/*":
|
||||
return <FileAudioIcon strokeWidth={strokeWidth} className="w-full h-auto" />;
|
||||
case "text/*":
|
||||
return <FileTextIcon strokeWidth={strokeWidth} className="w-full h-auto" />;
|
||||
case "application/epub+zip":
|
||||
return <BookIcon strokeWidth={strokeWidth} className="w-full h-auto" />;
|
||||
case "application/pdf":
|
||||
return <BookIcon strokeWidth={strokeWidth} className="w-full h-auto" />;
|
||||
case "application/msword":
|
||||
return <FileEditIcon strokeWidth={strokeWidth} className="w-full h-auto" />;
|
||||
case "application/msexcel":
|
||||
return <SheetIcon strokeWidth={strokeWidth} className="w-full h-auto" />;
|
||||
case "application/zip":
|
||||
return <FileArchiveIcon onClick={previewResource} strokeWidth={strokeWidth} className="w-full h-auto" />;
|
||||
case "application/x-java-archive":
|
||||
return <BinaryIcon strokeWidth={strokeWidth} className="w-full h-auto" />;
|
||||
default:
|
||||
return <FileIcon strokeWidth={strokeWidth} className="w-full h-auto" />;
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div onClick={previewResource} className={cn(className, "max-w-16 opacity-50")}>
|
||||
{getAttachmentIcon()}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default React.memo(AttachmentIcon);
|
||||
@@ -0,0 +1,126 @@
|
||||
import { FileAudioIcon, FileIcon, PlayIcon } from "lucide-react";
|
||||
import AudioAttachmentItem from "@/components/MemoMetadata/Attachment/AudioAttachmentItem";
|
||||
import VideoPoster from "@/components/VideoPoster";
|
||||
import type { AttachmentLibraryListItem } from "@/hooks/useAttachmentLibrary";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { getAttachmentThumbnailUrl, getAttachmentType, isMotionAttachment } from "@/utils/attachment";
|
||||
import { AttachmentMetadataLine, AttachmentOpenButton, AttachmentSourceChip } from "./AttachmentLibraryPrimitives";
|
||||
|
||||
const AttachmentThumb = ({ item, className }: { item: AttachmentLibraryListItem; className?: string }) => {
|
||||
const type = getAttachmentType(item.attachment);
|
||||
const isMotion = isMotionAttachment(item.attachment);
|
||||
|
||||
if (type === "image/*" || isMotion) {
|
||||
return (
|
||||
<div className={cn("overflow-hidden rounded-xl bg-muted/35", className)}>
|
||||
<img
|
||||
src={getAttachmentThumbnailUrl(item.attachment)}
|
||||
alt={item.attachment.filename}
|
||||
className="h-full w-full object-cover"
|
||||
loading="lazy"
|
||||
decoding="async"
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (type === "video/*") {
|
||||
return (
|
||||
<div className={cn("relative overflow-hidden rounded-xl bg-muted/35", className)}>
|
||||
<VideoPoster sourceUrl={item.sourceUrl} alt={item.attachment.filename} className="h-full w-full object-cover" />
|
||||
<span className="absolute bottom-2 right-2 inline-flex h-7 w-7 items-center justify-center rounded-full bg-background/85 text-foreground shadow-sm">
|
||||
<PlayIcon className="h-3.5 w-3.5 fill-current" />
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={cn("flex items-center justify-center rounded-xl bg-muted/45 text-muted-foreground", className)}>
|
||||
{type === "audio/*" ? <FileAudioIcon className="h-5 w-5" /> : <FileIcon className="h-5 w-5" />}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export const AttachmentDocumentRows = ({ items }: { items: AttachmentLibraryListItem[] }) => {
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
{items.map((item) => (
|
||||
<article
|
||||
key={item.attachment.name}
|
||||
className="flex items-center gap-2.5 rounded-[18px] border border-border/60 bg-background/90 p-3 shadow-sm shadow-black/[0.02]"
|
||||
>
|
||||
<div className="flex h-10 w-10 shrink-0 items-center justify-center rounded-lg bg-muted/45 text-muted-foreground">
|
||||
<FileIcon className="h-4.5 w-4.5" />
|
||||
</div>
|
||||
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="truncate text-sm font-medium text-foreground" title={item.attachment.filename}>
|
||||
{item.attachment.filename}
|
||||
</div>
|
||||
<div className="mt-0.5 flex flex-wrap items-center gap-1.5">
|
||||
<AttachmentMetadataLine className="min-w-0 max-w-full" items={[item.fileTypeLabel, item.fileSizeLabel, item.createdLabel]} />
|
||||
<AttachmentSourceChip memoName={item.memoName} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<AttachmentOpenButton href={item.sourceUrl} />
|
||||
</article>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export const AttachmentAudioRows = ({ items }: { items: AttachmentLibraryListItem[] }) => {
|
||||
return (
|
||||
<div className="space-y-2.5">
|
||||
{items.map((item) => (
|
||||
<article
|
||||
key={item.attachment.name}
|
||||
className="rounded-[18px] border border-border/60 bg-background/90 p-2.5 shadow-sm shadow-black/[0.02]"
|
||||
>
|
||||
<AudioAttachmentItem
|
||||
filename={item.attachment.filename}
|
||||
sourceUrl={item.sourceUrl}
|
||||
mimeType={item.attachment.type}
|
||||
size={Number(item.attachment.size)}
|
||||
/>
|
||||
<div className="mt-2.5 flex items-center justify-between gap-2 border-t border-border/60 px-0.5 pt-2.5">
|
||||
<div className="min-w-0 flex flex-wrap items-center gap-1.5">
|
||||
<AttachmentMetadataLine className="min-w-0 max-w-full" items={[item.createdLabel]} />
|
||||
<AttachmentSourceChip memoName={item.memoName} />
|
||||
</div>
|
||||
<AttachmentOpenButton href={item.sourceUrl} />
|
||||
</div>
|
||||
</article>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export const AttachmentUnusedRows = ({ items }: { items: AttachmentLibraryListItem[] }) => {
|
||||
return (
|
||||
<div className="space-y-2.5">
|
||||
{items.map((item) => (
|
||||
<article
|
||||
key={item.attachment.name}
|
||||
className="flex items-center gap-2.5 rounded-[18px] border border-warning/30 bg-warning/5 p-3 shadow-sm shadow-black/[0.02]"
|
||||
>
|
||||
<AttachmentThumb item={item} className="h-10 w-10 shrink-0" />
|
||||
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="truncate text-sm font-medium text-foreground" title={item.attachment.filename}>
|
||||
{item.attachment.filename}
|
||||
</div>
|
||||
<div className="mt-0.5 flex flex-wrap items-center gap-1.5">
|
||||
<AttachmentMetadataLine className="min-w-0 max-w-full" items={[item.fileTypeLabel, item.fileSizeLabel, item.createdLabel]} />
|
||||
<AttachmentSourceChip unlinkedLabelKey="attachment-library.labels.not-linked" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<AttachmentOpenButton className="text-warning/90 hover:text-warning" href={item.sourceUrl} />
|
||||
</article>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,57 @@
|
||||
import { FileAudioIcon, FileStackIcon, ImageIcon } from "lucide-react";
|
||||
import type { ComponentType } from "react";
|
||||
import type { AttachmentLibraryTab } from "@/hooks/useAttachmentLibrary";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { useTranslate } from "@/utils/i18n";
|
||||
|
||||
interface AttachmentLibraryEmptyStateProps {
|
||||
className?: string;
|
||||
tab: AttachmentLibraryTab;
|
||||
}
|
||||
|
||||
const EMPTY_STATE_CONFIG: Record<
|
||||
AttachmentLibraryTab,
|
||||
{
|
||||
descriptionKey: "attachment-library.empty.audio" | "attachment-library.empty.documents" | "attachment-library.empty.media";
|
||||
icon: ComponentType<{ className?: string }>;
|
||||
titleKey: "attachment-library.tabs.audio" | "attachment-library.tabs.documents" | "attachment-library.tabs.media";
|
||||
}
|
||||
> = {
|
||||
audio: {
|
||||
descriptionKey: "attachment-library.empty.audio",
|
||||
icon: FileAudioIcon,
|
||||
titleKey: "attachment-library.tabs.audio",
|
||||
},
|
||||
documents: {
|
||||
descriptionKey: "attachment-library.empty.documents",
|
||||
icon: FileStackIcon,
|
||||
titleKey: "attachment-library.tabs.documents",
|
||||
},
|
||||
media: {
|
||||
descriptionKey: "attachment-library.empty.media",
|
||||
icon: ImageIcon,
|
||||
titleKey: "attachment-library.tabs.media",
|
||||
},
|
||||
};
|
||||
|
||||
const AttachmentLibraryEmptyState = ({ className, tab }: AttachmentLibraryEmptyStateProps) => {
|
||||
const t = useTranslate();
|
||||
const { descriptionKey, icon: Icon, titleKey } = EMPTY_STATE_CONFIG[tab];
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"flex min-h-[18rem] flex-col items-center justify-center rounded-[28px] border border-dashed border-border/70 bg-background/80 px-6 py-16 text-center",
|
||||
className,
|
||||
)}
|
||||
>
|
||||
<div className="flex h-16 w-16 items-center justify-center rounded-full bg-muted/45 text-muted-foreground">
|
||||
<Icon className="h-7 w-7" />
|
||||
</div>
|
||||
<div className="mt-5 text-sm font-medium text-foreground">{t(titleKey)}</div>
|
||||
<p className="mt-2 max-w-sm text-sm leading-6 text-muted-foreground">{t(descriptionKey)}</p>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default AttachmentLibraryEmptyState;
|
||||
@@ -0,0 +1,87 @@
|
||||
import { ExternalLinkIcon } from "lucide-react";
|
||||
import { Link } from "react-router-dom";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { useTranslate } from "@/utils/i18n";
|
||||
|
||||
interface AttachmentMetadataLineProps {
|
||||
className?: string;
|
||||
items: Array<string | undefined>;
|
||||
}
|
||||
|
||||
interface AttachmentSourceChipProps {
|
||||
memoName?: string;
|
||||
unlinkedLabelKey?: "attachment-library.labels.not-linked" | "attachment-library.labels.unused";
|
||||
}
|
||||
|
||||
interface AttachmentOpenButtonProps {
|
||||
className?: string;
|
||||
href: string;
|
||||
}
|
||||
|
||||
export const AttachmentMetadataLine = ({ className, items }: AttachmentMetadataLineProps) => {
|
||||
const visibleItems = items.filter((item): item is string => Boolean(item));
|
||||
|
||||
if (visibleItems.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"flex items-center gap-1.5 overflow-x-auto whitespace-nowrap text-xs text-muted-foreground [-ms-overflow-style:none] [scrollbar-width:none] [&::-webkit-scrollbar]:hidden",
|
||||
className,
|
||||
)}
|
||||
>
|
||||
{visibleItems.map((item, index) => (
|
||||
<span key={`${item}-${index}`} className="contents">
|
||||
{index > 0 && <span className="shrink-0 text-muted-foreground/50">•</span>}
|
||||
<span className="shrink-0">{item}</span>
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export const AttachmentSourceChip = ({
|
||||
memoName,
|
||||
unlinkedLabelKey = "attachment-library.labels.not-linked",
|
||||
}: AttachmentSourceChipProps) => {
|
||||
const t = useTranslate();
|
||||
|
||||
if (!memoName) {
|
||||
return (
|
||||
<Badge variant="warning" shape="pill" className="px-1.5 py-0.5 text-[11px]">
|
||||
{t(unlinkedLabelKey)}
|
||||
</Badge>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Link
|
||||
to={`/${memoName}`}
|
||||
className="inline-flex max-w-full items-center truncate rounded-full border border-border/60 bg-muted/30 px-1.5 py-0.5 text-[11px] text-muted-foreground hover:bg-muted/50"
|
||||
>
|
||||
<span className="truncate">{t("attachment-library.labels.memo")}</span>
|
||||
</Link>
|
||||
);
|
||||
};
|
||||
|
||||
export const AttachmentOpenButton = ({ className, href }: AttachmentOpenButtonProps) => {
|
||||
const t = useTranslate();
|
||||
|
||||
return (
|
||||
<Button
|
||||
asChild
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className={cn("size-7 shrink-0 rounded-full text-muted-foreground hover:text-foreground", className)}
|
||||
>
|
||||
<a href={href} target="_blank" rel="noreferrer">
|
||||
<ExternalLinkIcon className="h-3.5 w-3.5" />
|
||||
<span className="sr-only">{t("attachment-library.actions.open")}</span>
|
||||
</a>
|
||||
</Button>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,77 @@
|
||||
import { LoaderCircleIcon } from "lucide-react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { useTranslate } from "@/utils/i18n";
|
||||
|
||||
interface AttachmentLibraryErrorStateProps {
|
||||
error?: Error;
|
||||
onRetry: () => void;
|
||||
}
|
||||
|
||||
interface AttachmentLibrarySkeletonGridProps {
|
||||
count?: number;
|
||||
}
|
||||
|
||||
interface AttachmentLibraryUnusedPanelProps {
|
||||
count: number;
|
||||
isDeleting: boolean;
|
||||
isExpanded: boolean;
|
||||
onDelete: () => void;
|
||||
onToggle: () => void;
|
||||
}
|
||||
|
||||
export const AttachmentLibrarySkeletonGrid = ({ count = 8 }: AttachmentLibrarySkeletonGridProps) => {
|
||||
return (
|
||||
<div className="grid grid-cols-2 gap-3 sm:gap-4 lg:grid-cols-3 xl:grid-cols-4">
|
||||
{Array.from({ length: count }).map((_, index) => (
|
||||
<div key={index} className="overflow-hidden rounded-[20px] border border-border/60 bg-background/90">
|
||||
<div className="aspect-[5/4] animate-pulse bg-muted/50" />
|
||||
<div className="space-y-2.5 p-3">
|
||||
<div className="h-4 w-2/3 animate-pulse rounded bg-muted/50" />
|
||||
<div className="h-3 w-1/2 animate-pulse rounded bg-muted/40" />
|
||||
<div className="h-7 w-full animate-pulse rounded bg-muted/40" />
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export const AttachmentLibraryErrorState = ({ error, onRetry }: AttachmentLibraryErrorStateProps) => {
|
||||
const t = useTranslate();
|
||||
|
||||
return (
|
||||
<div className="rounded-[20px] border border-destructive/30 bg-destructive/5 p-6 text-center">
|
||||
<p className="text-sm text-muted-foreground">{error?.message ?? t("attachment-library.errors.load")}</p>
|
||||
<Button className="mt-4 rounded-full" onClick={onRetry}>
|
||||
{t("attachment-library.actions.retry")}
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export const AttachmentLibraryUnusedPanel = ({ count, isDeleting, isExpanded, onDelete, onToggle }: AttachmentLibraryUnusedPanelProps) => {
|
||||
const t = useTranslate();
|
||||
|
||||
return (
|
||||
<div className="rounded-2xl border border-warning/30 bg-warning/5 p-4">
|
||||
<div className="flex flex-col gap-3 md:flex-row md:items-center md:justify-between">
|
||||
<div className="min-w-0">
|
||||
<div className="text-sm font-medium text-foreground">
|
||||
{t("attachment-library.unused.title")} ({count})
|
||||
</div>
|
||||
<p className="mt-1 text-sm text-muted-foreground">{t("attachment-library.unused.description")}</p>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<Button type="button" variant="outline" className="rounded-full border-warning/40 bg-background/80 px-3" onClick={onToggle}>
|
||||
{isExpanded ? t("common.close") : t("attachment-library.labels.unused")}
|
||||
</Button>
|
||||
<Button variant="destructive" className="rounded-full" onClick={onDelete} disabled={isDeleting}>
|
||||
{isDeleting ? <LoaderCircleIcon className="h-4 w-4 animate-spin" /> : null}
|
||||
{t("resource.delete-all-unused")}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,64 @@
|
||||
import { FileAudioIcon, FileStackIcon, ImageIcon } from "lucide-react";
|
||||
import type { ComponentType } from "react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import type { AttachmentLibraryStats, AttachmentLibraryTab } from "@/hooks/useAttachmentLibrary";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { useTranslate } from "@/utils/i18n";
|
||||
|
||||
interface AttachmentLibraryToolbarProps {
|
||||
activeTab: AttachmentLibraryTab;
|
||||
onTabChange: (tab: AttachmentLibraryTab) => void;
|
||||
stats: AttachmentLibraryStats;
|
||||
}
|
||||
|
||||
const TAB_CONFIG: Array<{
|
||||
key: AttachmentLibraryTab;
|
||||
labelKey: "media" | "documents" | "audio";
|
||||
icon: ComponentType<{ className?: string }>;
|
||||
count: (stats: AttachmentLibraryStats) => number;
|
||||
}> = [
|
||||
{ key: "media", labelKey: "media", icon: ImageIcon, count: (stats) => stats.media },
|
||||
{ key: "audio", labelKey: "audio", icon: FileAudioIcon, count: (stats) => stats.audio },
|
||||
{ key: "documents", labelKey: "documents", icon: FileStackIcon, count: (stats) => stats.documents },
|
||||
];
|
||||
|
||||
const AttachmentLibraryToolbar = ({ activeTab, onTabChange, stats }: AttachmentLibraryToolbarProps) => {
|
||||
const t = useTranslate();
|
||||
|
||||
return (
|
||||
<div className="-mx-1 overflow-x-auto px-1 [-ms-overflow-style:none] [scrollbar-width:none] [&::-webkit-scrollbar]:hidden">
|
||||
<div className="flex min-w-max items-center gap-1.5">
|
||||
{TAB_CONFIG.map((tab) => {
|
||||
const Icon = tab.icon;
|
||||
const isActive = activeTab === tab.key;
|
||||
|
||||
return (
|
||||
<Button
|
||||
key={tab.key}
|
||||
type="button"
|
||||
variant="ghost"
|
||||
className={cn(
|
||||
"h-9 rounded-md px-2.5 text-sm font-medium sm:px-3",
|
||||
isActive ? "bg-muted/60 text-foreground shadow-none" : "text-muted-foreground hover:bg-muted/40 hover:text-foreground",
|
||||
)}
|
||||
onClick={() => onTabChange(tab.key)}
|
||||
>
|
||||
<Icon className="h-4 w-4" />
|
||||
<span>{t(`attachment-library.tabs.${tab.labelKey}`)}</span>
|
||||
<span
|
||||
className={cn(
|
||||
"rounded-full px-1.5 py-0.5 text-[11px]",
|
||||
isActive ? "bg-background text-muted-foreground" : "bg-muted/50 text-muted-foreground",
|
||||
)}
|
||||
>
|
||||
{tab.count(stats)}
|
||||
</span>
|
||||
</Button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default AttachmentLibraryToolbar;
|
||||
@@ -0,0 +1,97 @@
|
||||
import { PlayIcon } from "lucide-react";
|
||||
import MotionPhotoPreview from "@/components/MotionPhotoPreview";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import VideoPoster from "@/components/VideoPoster";
|
||||
import type { AttachmentLibraryMediaItem, AttachmentLibraryMonthGroup } from "@/hooks/useAttachmentLibrary";
|
||||
import { useTranslate } from "@/utils/i18n";
|
||||
import { AttachmentMetadataLine, AttachmentOpenButton } from "./AttachmentLibraryPrimitives";
|
||||
|
||||
interface AttachmentMediaGridProps {
|
||||
groups: AttachmentLibraryMonthGroup[];
|
||||
onPreview: (itemId: string) => void;
|
||||
}
|
||||
|
||||
const AttachmentMediaCard = ({ item, onPreview }: { item: AttachmentLibraryMediaItem; onPreview: () => void }) => {
|
||||
const t = useTranslate();
|
||||
|
||||
return (
|
||||
<article className="overflow-hidden rounded-[20px] border border-border/60 bg-background/90 shadow-sm shadow-black/[0.03]">
|
||||
<div className="relative block w-full cursor-pointer text-left" onClick={onPreview}>
|
||||
<div className="relative aspect-[5/4] overflow-hidden bg-muted/40">
|
||||
{item.kind === "video" ? (
|
||||
<>
|
||||
<VideoPoster
|
||||
sourceUrl={item.sourceUrl}
|
||||
posterUrl={item.posterUrl}
|
||||
alt={item.filename}
|
||||
className="h-full w-full object-cover"
|
||||
/>
|
||||
<div className="absolute inset-0 bg-linear-to-t from-black/35 via-black/5 to-transparent" />
|
||||
<span className="absolute bottom-2.5 right-2.5 inline-flex h-8 w-8 items-center justify-center rounded-full bg-background/85 text-foreground shadow-sm backdrop-blur-sm">
|
||||
<PlayIcon className="h-3.5 w-3.5 fill-current" />
|
||||
</span>
|
||||
</>
|
||||
) : item.kind === "motion" ? (
|
||||
<MotionPhotoPreview
|
||||
posterUrl={item.posterUrl}
|
||||
motionUrl={item.previewItem.kind === "motion" ? item.previewItem.motionUrl : item.sourceUrl}
|
||||
alt={item.filename}
|
||||
presentationTimestampUs={item.previewItem.kind === "motion" ? item.previewItem.presentationTimestampUs : undefined}
|
||||
containerClassName="h-full w-full"
|
||||
mediaClassName="h-full w-full object-cover"
|
||||
badgeClassName="left-3 top-3"
|
||||
/>
|
||||
) : (
|
||||
<img src={item.posterUrl} alt={item.filename} className="h-full w-full object-cover" loading="lazy" decoding="async" />
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-2 p-3">
|
||||
<div className="flex items-start justify-between gap-2">
|
||||
<div className="min-w-0 truncate text-sm font-medium leading-5 text-foreground" title={item.filename}>
|
||||
{item.filename}
|
||||
</div>
|
||||
|
||||
{item.kind === "motion" && (
|
||||
<Badge variant="outline" className="rounded-full border-border/60 bg-background/70 px-1.5 py-0.5 text-[11px]">
|
||||
{t("attachment-library.labels.live")}
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<AttachmentMetadataLine
|
||||
className="min-w-0 flex-1"
|
||||
items={[item.fileTypeLabel, item.createdLabel !== "—" ? item.createdLabel : undefined]}
|
||||
/>
|
||||
|
||||
<AttachmentOpenButton href={item.sourceUrl} />
|
||||
</div>
|
||||
</div>
|
||||
</article>
|
||||
);
|
||||
};
|
||||
|
||||
const AttachmentMediaGrid = ({ groups, onPreview }: AttachmentMediaGridProps) => {
|
||||
return (
|
||||
<div className="flex flex-col gap-6 sm:gap-8">
|
||||
{groups.map((group) => (
|
||||
<section key={group.key} className="space-y-2.5 sm:space-y-3">
|
||||
<div className="flex items-center gap-2.5">
|
||||
<div className="text-xs font-medium uppercase tracking-[0.24em] text-muted-foreground">{group.label}</div>
|
||||
<div className="h-px flex-1 bg-border/70" />
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-3 sm:gap-4 lg:grid-cols-3 xl:grid-cols-4">
|
||||
{group.items.map((item) => (
|
||||
<AttachmentMediaCard key={item.id} item={item} onPreview={() => onPreview(item.previewItem.id)} />
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default AttachmentMediaGrid;
|
||||
@@ -0,0 +1,6 @@
|
||||
export { AttachmentAudioRows, AttachmentDocumentRows, AttachmentUnusedRows } from "./AttachmentFileRows";
|
||||
export { default as AttachmentLibraryEmptyState } from "./AttachmentLibraryEmptyState";
|
||||
export { AttachmentMetadataLine, AttachmentOpenButton, AttachmentSourceChip } from "./AttachmentLibraryPrimitives";
|
||||
export { AttachmentLibraryErrorState, AttachmentLibrarySkeletonGrid, AttachmentLibraryUnusedPanel } from "./AttachmentLibraryStates";
|
||||
export { default as AttachmentLibraryToolbar } from "./AttachmentLibraryToolbar";
|
||||
export { default as AttachmentMediaGrid } from "./AttachmentMediaGrid";
|
||||
@@ -0,0 +1,35 @@
|
||||
import { useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { loadLocale } from "@/utils/i18n";
|
||||
import { getInitialTheme, loadTheme, Theme } from "@/utils/theme";
|
||||
import LocaleSelect from "./LocaleSelect";
|
||||
import ThemeSelect from "./ThemeSelect";
|
||||
|
||||
interface Props {
|
||||
className?: string;
|
||||
}
|
||||
|
||||
const AuthFooter = ({ className }: Props) => {
|
||||
const { i18n: i18nInstance } = useTranslation();
|
||||
const currentLocale = i18nInstance.language as Locale;
|
||||
const [currentTheme, setCurrentTheme] = useState(getInitialTheme());
|
||||
|
||||
const handleLocaleChange = (locale: Locale) => {
|
||||
loadLocale(locale);
|
||||
};
|
||||
|
||||
const handleThemeChange = (theme: string) => {
|
||||
loadTheme(theme);
|
||||
setCurrentTheme(theme as Theme);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className={cn("mx-auto mt-4 grid w-full max-w-xs grid-cols-2 gap-2", className)}>
|
||||
<LocaleSelect value={currentLocale} onChange={handleLocaleChange} className="w-full" />
|
||||
<ThemeSelect value={currentTheme} onValueChange={handleThemeChange} className="w-full" compact />
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default AuthFooter;
|
||||
@@ -0,0 +1,114 @@
|
||||
import { useState } from "react";
|
||||
import { toast } from "react-hot-toast";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Dialog, DialogContent, DialogFooter, DialogHeader, DialogTitle } from "@/components/ui/dialog";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { useUpdateUser } from "@/hooks/useUserQueries";
|
||||
import { handleError } from "@/lib/error";
|
||||
import { User } from "@/types/proto/api/v1/user_service_pb";
|
||||
import { useTranslate } from "@/utils/i18n";
|
||||
|
||||
interface Props {
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
user?: User;
|
||||
onSuccess?: () => void;
|
||||
}
|
||||
|
||||
function ChangeMemberPasswordDialog({ open, onOpenChange, user, onSuccess }: Props) {
|
||||
const t = useTranslate();
|
||||
const { mutateAsync: updateUser } = useUpdateUser();
|
||||
const [newPassword, setNewPassword] = useState("");
|
||||
const [newPasswordAgain, setNewPasswordAgain] = useState("");
|
||||
|
||||
const handleCloseBtnClick = () => {
|
||||
onOpenChange(false);
|
||||
};
|
||||
|
||||
const handleNewPasswordChanged = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const text = e.target.value as string;
|
||||
setNewPassword(text);
|
||||
};
|
||||
|
||||
const handleNewPasswordAgainChanged = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const text = e.target.value as string;
|
||||
setNewPasswordAgain(text);
|
||||
};
|
||||
|
||||
const handleSaveBtnClick = async () => {
|
||||
if (!user) return;
|
||||
|
||||
if (newPassword === "" || newPasswordAgain === "") {
|
||||
toast.error(t("message.fill-all"));
|
||||
return;
|
||||
}
|
||||
|
||||
if (newPassword !== newPasswordAgain) {
|
||||
toast.error(t("message.new-password-not-match"));
|
||||
setNewPasswordAgain("");
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
await updateUser({
|
||||
user: {
|
||||
name: user.name,
|
||||
password: newPassword,
|
||||
},
|
||||
updateMask: ["password"],
|
||||
});
|
||||
toast(t("message.password-changed"));
|
||||
onSuccess?.();
|
||||
onOpenChange(false);
|
||||
} catch (error: unknown) {
|
||||
await handleError(error, toast.error, {
|
||||
context: "Change member password",
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
if (!user) return null;
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent className="max-w-md">
|
||||
<DialogHeader>
|
||||
<DialogTitle>
|
||||
{t("setting.account.change-password")} ({user.displayName})
|
||||
</DialogTitle>
|
||||
</DialogHeader>
|
||||
<div className="flex flex-col gap-4">
|
||||
<div className="grid gap-2">
|
||||
<Label htmlFor="newPassword">{t("auth.new-password")}</Label>
|
||||
<Input
|
||||
id="newPassword"
|
||||
type="password"
|
||||
placeholder={t("auth.new-password")}
|
||||
value={newPassword}
|
||||
onChange={handleNewPasswordChanged}
|
||||
/>
|
||||
</div>
|
||||
<div className="grid gap-2">
|
||||
<Label htmlFor="newPasswordAgain">{t("auth.repeat-new-password")}</Label>
|
||||
<Input
|
||||
id="newPasswordAgain"
|
||||
type="password"
|
||||
placeholder={t("auth.repeat-new-password")}
|
||||
value={newPasswordAgain}
|
||||
onChange={handleNewPasswordAgainChanged}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button variant="ghost" onClick={handleCloseBtnClick}>
|
||||
{t("common.cancel")}
|
||||
</Button>
|
||||
<Button onClick={handleSaveBtnClick}>{t("common.save")}</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
|
||||
export default ChangeMemberPasswordDialog;
|
||||
@@ -0,0 +1,73 @@
|
||||
import { ChevronDown, ChevronUp } from "lucide-react";
|
||||
import { type ReactNode, useEffect, useRef, useState } from "react";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { useTranslate } from "@/utils/i18n";
|
||||
|
||||
// A collapsed section shows this much content; anything taller folds behind a fade.
|
||||
export const CLAMP_PREVIEW_HEIGHT_PX = 360;
|
||||
// Only fold when content is taller than this, so cards barely over the preview aren't
|
||||
// clamped for the sake of a few hidden pixels.
|
||||
export const CLAMP_TRIGGER_HEIGHT_PX = 420;
|
||||
|
||||
interface ClampedSectionProps {
|
||||
/** When false, children render untouched with no measurement. */
|
||||
enabled: boolean;
|
||||
children: ReactNode;
|
||||
}
|
||||
|
||||
/**
|
||||
* The one truncation mechanism for compact cards: measure the content, and when it is
|
||||
* tall enough, collapse it to a fixed-height preview with a fade and a Show more/less
|
||||
* toggle. The inner div is never clamped, so observing it keeps the measurement live
|
||||
* while images and embeds load.
|
||||
*/
|
||||
const ClampedSection = ({ enabled, children }: ClampedSectionProps) => {
|
||||
const t = useTranslate();
|
||||
const measureRef = useRef<HTMLDivElement>(null);
|
||||
const [clamped, setClamped] = useState(false);
|
||||
const [expanded, setExpanded] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
const el = measureRef.current;
|
||||
if (!enabled || !el) {
|
||||
setClamped(false);
|
||||
return;
|
||||
}
|
||||
const check = () => setClamped(el.offsetHeight > CLAMP_TRIGGER_HEIGHT_PX);
|
||||
check();
|
||||
if (typeof ResizeObserver === "undefined") return;
|
||||
const observer = new ResizeObserver(check);
|
||||
observer.observe(el);
|
||||
return () => observer.disconnect();
|
||||
}, [enabled]);
|
||||
|
||||
const collapsed = clamped && !expanded;
|
||||
|
||||
return (
|
||||
<>
|
||||
<div
|
||||
className={cn("relative w-full", collapsed && "overflow-hidden")}
|
||||
style={collapsed ? { maxHeight: CLAMP_PREVIEW_HEIGHT_PX } : undefined}
|
||||
>
|
||||
<div ref={measureRef} className="w-full flex flex-col justify-start items-start gap-2">
|
||||
{children}
|
||||
</div>
|
||||
{collapsed && (
|
||||
<div className="pointer-events-none absolute inset-x-0 bottom-0 h-12 bg-linear-to-t from-card from-0% via-card/60 via-40% to-transparent to-100%" />
|
||||
)}
|
||||
</div>
|
||||
{clamped && (
|
||||
<button
|
||||
type="button"
|
||||
className="inline-flex items-center gap-1 px-2 py-1 text-xs text-muted-foreground hover:text-foreground transition-colors"
|
||||
onClick={() => setExpanded((prev) => !prev)}
|
||||
>
|
||||
<span>{t(collapsed ? "memo.show-more" : "memo.show-less")}</span>
|
||||
{collapsed ? <ChevronDown className="w-3 h-3" /> : <ChevronUp className="w-3 h-3" />}
|
||||
</button>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default ClampedSection;
|
||||
@@ -0,0 +1,290 @@
|
||||
import { type ReactNode, useCallback, useEffect, useLayoutEffect, useRef, useState } from "react";
|
||||
|
||||
interface ColumnGridProps<T> {
|
||||
items: T[];
|
||||
/** Stable identity for each item; also used as the React key. */
|
||||
getKey: (item: T) => string;
|
||||
renderItem: (item: T) => ReactNode;
|
||||
/** Estimated rendered height in px, used only to choose a column deterministically. */
|
||||
estimateHeight?: (item: T, context: { columnWidth: number }) => number;
|
||||
/** Optional node packed as the very first tile (e.g. the note composer). */
|
||||
leading?: ReactNode;
|
||||
/** Key that must land at the top of column one (e.g. a just-created memo), not the shortest column. */
|
||||
priorityKey?: string;
|
||||
/** Upper bound on the column count; 0 or undefined means as many as fit. */
|
||||
maxColumns?: number;
|
||||
/** Cap on each column's width in px; leftover space centers the grid. */
|
||||
maxColumnWidth?: number;
|
||||
}
|
||||
|
||||
const LEADING_KEY = "__grid_leading__";
|
||||
|
||||
const GRID_MIN_COLUMN_WIDTH = 260;
|
||||
export const GRID_GAP = 12;
|
||||
|
||||
// The single source of truth for how many columns fit a given width. Callers use it to detect a
|
||||
// one-column layout and fall back to a plain flow list instead of a degenerate one-column grid.
|
||||
export const columnCountForWidth = (width: number): number =>
|
||||
Math.max(1, Math.floor((width + GRID_GAP) / (GRID_MIN_COLUMN_WIDTH + GRID_GAP)));
|
||||
|
||||
const shortestColumn = (heights: number[]): number => {
|
||||
let index = 0;
|
||||
for (let i = 1; i < heights.length; i++) {
|
||||
if (heights[i] < heights[index]) {
|
||||
index = i;
|
||||
}
|
||||
}
|
||||
return index;
|
||||
};
|
||||
|
||||
export const assignColumnsByEstimatedHeight = ({
|
||||
keys,
|
||||
columnCount,
|
||||
getEstimatedHeight,
|
||||
pinnedKeys,
|
||||
}: {
|
||||
keys: string[];
|
||||
columnCount: number;
|
||||
getEstimatedHeight: (key: string) => number;
|
||||
pinnedKeys?: ReadonlySet<string>;
|
||||
}): Map<string, number> => {
|
||||
const totals = new Array<number>(columnCount).fill(0);
|
||||
const columns = new Map<string, number>();
|
||||
|
||||
for (const key of keys) {
|
||||
const column = pinnedKeys?.has(key) ? 0 : shortestColumn(totals);
|
||||
columns.set(key, column);
|
||||
totals[column] += Math.max(0, getEstimatedHeight(key));
|
||||
}
|
||||
|
||||
return columns;
|
||||
};
|
||||
|
||||
/**
|
||||
* Absolute-positioned column grid (Google-Keep-style packing). Cards keep their document
|
||||
* order and are only translated into place, so appending pages or reordering the list
|
||||
* never remounts a card — preserving its state and avoiding flashes.
|
||||
*
|
||||
* Columns are assigned from deterministic estimated heights. Real measured heights only decide
|
||||
* each card's final y offset inside its assigned column, so late image/comment growth moves cards
|
||||
* down within that column without reshuffling cards across columns.
|
||||
*/
|
||||
function ColumnGrid<T>({
|
||||
items,
|
||||
getKey,
|
||||
renderItem,
|
||||
estimateHeight,
|
||||
leading,
|
||||
priorityKey,
|
||||
maxColumns,
|
||||
maxColumnWidth,
|
||||
}: ColumnGridProps<T>) {
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
const itemRefs = useRef<Map<string, HTMLDivElement>>(new Map());
|
||||
const refCallbacks = useRef<Map<string, (el: HTMLDivElement | null) => void>>(new Map());
|
||||
const resizeObserverRef = useRef<ResizeObserver | null>(null);
|
||||
const rafRef = useRef<number | null>(null);
|
||||
const lastWidthRef = useRef(0);
|
||||
const [containerHeight, setContainerHeight] = useState<number | undefined>(undefined);
|
||||
|
||||
// Measure each card once (widths written in one pass, heights read in the next so the
|
||||
// browser reflows once), assign cards by estimated height, then translate every card to its
|
||||
// column's measured running offset.
|
||||
const relayout = useCallback(() => {
|
||||
const container = containerRef.current;
|
||||
if (!container) return;
|
||||
|
||||
const width = container.clientWidth;
|
||||
// What fits (each column >= the minimum width), then the user's max-columns ceiling on top.
|
||||
const fit = columnCountForWidth(width);
|
||||
const count = maxColumns && maxColumns > 0 ? Math.min(fit, maxColumns) : fit;
|
||||
// Whole-pixel columns that fill the row, clamped to maxColumnWidth so few columns on a
|
||||
// wide screen stay readable instead of stretching.
|
||||
let columnWidth = count > 1 ? Math.floor((width - GRID_GAP * (count - 1)) / count) : width;
|
||||
if (maxColumnWidth != null) columnWidth = Math.min(columnWidth, maxColumnWidth);
|
||||
// Center the packed columns in whatever width the clamp leaves over.
|
||||
const offsetX = Math.floor((width - (columnWidth * count + GRID_GAP * (count - 1))) / 2);
|
||||
|
||||
// Ordered by feed position: the leading tile (composer) first, then items.
|
||||
const ordered: { key: string; el: HTMLDivElement }[] = [];
|
||||
const leadingEl = itemRefs.current.get(LEADING_KEY);
|
||||
if (leadingEl) ordered.push({ key: LEADING_KEY, el: leadingEl });
|
||||
const itemByKey = new Map<string, T>();
|
||||
for (const item of items) {
|
||||
const key = getKey(item);
|
||||
itemByKey.set(key, item);
|
||||
const el = itemRefs.current.get(key);
|
||||
if (el) ordered.push({ key, el });
|
||||
}
|
||||
|
||||
// Pass 1 (writes): fix each card's width and drop its own bottom margin so spacing is
|
||||
// owned entirely by `gap`. `firstElementChild` is the item's rendered root. Items are
|
||||
// expected to bound their own height — the grid never clips content itself.
|
||||
for (const { el } of ordered) {
|
||||
el.style.width = `${columnWidth}px`;
|
||||
const child = el.firstElementChild;
|
||||
if (child instanceof HTMLElement) {
|
||||
child.style.marginBottom = "0px";
|
||||
}
|
||||
}
|
||||
|
||||
// Pass 2 (reads): height of every card, measured once after the width writes so the
|
||||
// browser reflows a single time. We read the inner element, not the absolutely-positioned
|
||||
// wrapper (whose block-formatting context would fold in margins).
|
||||
const heightByKey = new Map<string, number>();
|
||||
for (const { key, el } of ordered) {
|
||||
const child = el.firstElementChild;
|
||||
heightByKey.set(key, child instanceof HTMLElement ? child.offsetHeight : el.offsetHeight);
|
||||
}
|
||||
const heightOf = (key: string) => heightByKey.get(key) ?? 0;
|
||||
|
||||
const pinnedKeys = new Set<string>([LEADING_KEY]);
|
||||
if (priorityKey) {
|
||||
pinnedKeys.add(priorityKey);
|
||||
}
|
||||
const columnOf = assignColumnsByEstimatedHeight({
|
||||
keys: ordered.map((entry) => entry.key),
|
||||
columnCount: count,
|
||||
pinnedKeys,
|
||||
getEstimatedHeight: (key) => {
|
||||
const item = itemByKey.get(key);
|
||||
return item && estimateHeight ? estimateHeight(item, { columnWidth }) : heightOf(key);
|
||||
},
|
||||
});
|
||||
|
||||
const columnY = new Array<number>(count).fill(0);
|
||||
const pos = new Map<string, { x: number; y: number }>();
|
||||
for (const { key } of ordered) {
|
||||
const col = columnOf.get(key) ?? 0;
|
||||
const x = offsetX + col * (columnWidth + GRID_GAP);
|
||||
const y = columnY[col];
|
||||
pos.set(key, { x, y });
|
||||
columnY[col] = y + heightOf(key) + GRID_GAP;
|
||||
}
|
||||
|
||||
// Apply the chosen positions without transitions. Relayouts may be caused by late media or
|
||||
// comment preview height changes, and those should never visibly animate cards across the wall.
|
||||
for (const { key, el } of ordered) {
|
||||
const target = pos.get(key);
|
||||
if (!target) continue;
|
||||
// The leading tile (the note composer) is positioned with left/top rather than a
|
||||
// transform so it never becomes the containing block for its own position:fixed
|
||||
// descendants. A transform (or will-change:transform) here would trap the editor's
|
||||
// focus-mode overlay — which is meant to cover the viewport — inside this column tile.
|
||||
// The leading tile is pinned to column one's top and only shifts horizontally on
|
||||
// resize (which snaps anyway), so it loses no animation by skipping the transform.
|
||||
if (key === LEADING_KEY) {
|
||||
el.style.transition = "none";
|
||||
el.style.transform = "";
|
||||
el.style.left = `${target.x}px`;
|
||||
el.style.top = `${target.y}px`;
|
||||
continue;
|
||||
}
|
||||
el.style.transition = "none";
|
||||
el.style.transform = `translate3d(${target.x}px, ${target.y}px, 0)`;
|
||||
}
|
||||
|
||||
setContainerHeight(Math.max(0, ...columnY.map((h) => h - GRID_GAP)));
|
||||
}, [items, getKey, estimateHeight, priorityKey, maxColumns, maxColumnWidth]);
|
||||
|
||||
// Keep a stable reference so observer callbacks always run the latest layout.
|
||||
const relayoutRef = useRef(relayout);
|
||||
relayoutRef.current = relayout;
|
||||
|
||||
const scheduleRelayout = useCallback(() => {
|
||||
if (rafRef.current != null) return;
|
||||
rafRef.current = requestAnimationFrame(() => {
|
||||
rafRef.current = null;
|
||||
relayoutRef.current();
|
||||
});
|
||||
}, []);
|
||||
|
||||
// Re-layout on container resize (sidebar toggles, window resize, breakpoints).
|
||||
// Only width matters — height changes are our own (we set the container height),
|
||||
// so ignoring them avoids a feedback loop of redundant re-packs.
|
||||
useEffect(() => {
|
||||
const container = containerRef.current;
|
||||
if (!container || typeof ResizeObserver === "undefined") return;
|
||||
lastWidthRef.current = container.clientWidth;
|
||||
const observer = new ResizeObserver((entries) => {
|
||||
const nextWidth = Math.round(entries[0]?.contentRect.width ?? container.clientWidth);
|
||||
if (nextWidth !== lastWidthRef.current) {
|
||||
lastWidthRef.current = nextWidth;
|
||||
scheduleRelayout();
|
||||
}
|
||||
});
|
||||
observer.observe(container);
|
||||
return () => observer.disconnect();
|
||||
}, [scheduleRelayout]);
|
||||
|
||||
// A single observer watches every card so late-loading images (which change a
|
||||
// card's height) trigger a re-pack.
|
||||
useEffect(() => {
|
||||
if (typeof ResizeObserver === "undefined") return;
|
||||
const observer = new ResizeObserver(() => scheduleRelayout());
|
||||
resizeObserverRef.current = observer;
|
||||
for (const el of itemRefs.current.values()) observer.observe(el);
|
||||
return () => {
|
||||
observer.disconnect();
|
||||
resizeObserverRef.current = null;
|
||||
};
|
||||
}, [scheduleRelayout]);
|
||||
|
||||
// Re-pack after DOM updates (items added/removed/reordered), before paint.
|
||||
useLayoutEffect(() => {
|
||||
relayout();
|
||||
}, [relayout]);
|
||||
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
if (rafRef.current != null) cancelAnimationFrame(rafRef.current);
|
||||
};
|
||||
}, []);
|
||||
|
||||
const getItemRef = (key: string) => {
|
||||
const cached = refCallbacks.current.get(key);
|
||||
if (cached) return cached;
|
||||
const callback = (el: HTMLDivElement | null) => {
|
||||
const map = itemRefs.current;
|
||||
const previous = map.get(key);
|
||||
if (previous && resizeObserverRef.current) resizeObserverRef.current.unobserve(previous);
|
||||
if (el) {
|
||||
map.set(key, el);
|
||||
resizeObserverRef.current?.observe(el);
|
||||
} else {
|
||||
map.delete(key);
|
||||
refCallbacks.current.delete(key);
|
||||
}
|
||||
};
|
||||
refCallbacks.current.set(key, callback);
|
||||
return callback;
|
||||
};
|
||||
|
||||
return (
|
||||
<div ref={containerRef} className="relative w-full" style={{ height: containerHeight }}>
|
||||
{leading != null && (
|
||||
// Positioned with left/top (see relayout), and deliberately WITHOUT
|
||||
// transform/will-change so it never establishes a containing block that would trap
|
||||
// the composer's focus-mode overlay inside this tile.
|
||||
<div key={LEADING_KEY} ref={getItemRef(LEADING_KEY)} className="absolute top-0 left-0">
|
||||
{leading}
|
||||
</div>
|
||||
)}
|
||||
{items.map((item) => {
|
||||
const key = getKey(item);
|
||||
return (
|
||||
<div
|
||||
key={key}
|
||||
ref={getItemRef(key)}
|
||||
className="absolute top-0 left-0 transition-transform duration-200 ease-out motion-reduce:transition-none"
|
||||
style={{ willChange: "transform" }}
|
||||
>
|
||||
{renderItem(item)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default ColumnGrid;
|
||||
@@ -0,0 +1,4 @@
|
||||
import ColumnGrid from "./ColumnGrid";
|
||||
|
||||
export { assignColumnsByEstimatedHeight, columnCountForWidth, GRID_GAP } from "./ColumnGrid";
|
||||
export default ColumnGrid;
|
||||
@@ -0,0 +1,131 @@
|
||||
# ConfirmDialog - Accessible Confirmation Dialog
|
||||
|
||||
## Overview
|
||||
|
||||
`ConfirmDialog` standardizes confirmation flows across the app. It replaces ad‑hoc `window.confirm` usage with an accessible, themeable dialog that supports asynchronous operations.
|
||||
|
||||
## Key Features
|
||||
|
||||
### 1. Accessibility & UX
|
||||
- Uses shared `Dialog` primitives (focus trap, ARIA roles)
|
||||
- Blocks dismissal while async confirm is pending
|
||||
- Clear separation of title (action) vs description (context)
|
||||
|
||||
### 2. Async-Aware
|
||||
- Accepts sync or async `onConfirm`
|
||||
- Auto-closes on resolve; remains open on error for retry / toast
|
||||
|
||||
### 3. Internationalization Ready
|
||||
- All labels / text provided by caller through i18n hook
|
||||
- Supports interpolation for dynamic context
|
||||
|
||||
### 4. Minimal Surface, Easy Extension
|
||||
- Lightweight API (few required props)
|
||||
- Style hook via `.container` class (SCSS module)
|
||||
|
||||
## Architecture
|
||||
|
||||
```
|
||||
ConfirmDialog
|
||||
├── State: loading (tracks pending confirm action)
|
||||
├── Dialog primitives: Header (title + description), Footer (buttons)
|
||||
└── External control: parent owns open state via onOpenChange
|
||||
```
|
||||
|
||||
## Usage
|
||||
|
||||
```tsx
|
||||
import { useTranslate } from "@/utils/i18n";
|
||||
import ConfirmDialog from "@/components/ConfirmDialog";
|
||||
|
||||
const t = useTranslate();
|
||||
|
||||
<ConfirmDialog
|
||||
open={open}
|
||||
onOpenChange={setOpen}
|
||||
title={t("memo.delete-confirm")}
|
||||
description={t("memo.delete-confirm-description")}
|
||||
confirmLabel={t("common.delete")}
|
||||
cancelLabel={t("common.cancel")}
|
||||
onConfirm={handleDelete}
|
||||
confirmVariant="destructive"
|
||||
/>;
|
||||
```
|
||||
|
||||
## Props
|
||||
|
||||
| Prop | Type | Required | Acceptable Values |
|
||||
|------|------|----------|------------------|
|
||||
| `open` | `boolean` | Yes | `true` (visible) / `false` (hidden) |
|
||||
| `onOpenChange` | `(open: boolean) => void` | Yes | Callback receiving next state; should update parent state |
|
||||
| `title` | `React.ReactNode` | Yes | Short localized action summary (text / node) |
|
||||
| `description` | `React.ReactNode` | No | Optional contextual message |
|
||||
| `confirmLabel` | `string` | Yes | Non-empty localized action text (1–2 words) |
|
||||
| `cancelLabel` | `string` | Yes | Localized cancel label |
|
||||
| `onConfirm` | `() => void | Promise<void>` | Yes | Sync or async handler; resolve = close, reject = stay open |
|
||||
| `confirmVariant` | `"default" | "destructive"` | No | Defaults to `"default"`; use `"destructive"` for irreversible actions |
|
||||
|
||||
## Benefits vs Previous Implementation
|
||||
|
||||
### Before (window.confirm / ad‑hoc dialogs)
|
||||
- Blocking native prompt, inconsistent styling
|
||||
- No async progress handling
|
||||
- No rich formatting
|
||||
- Hard to localize consistently
|
||||
|
||||
### After (ConfirmDialog)
|
||||
- Unified styling + accessibility semantics
|
||||
- Async-safe with loading state shielding
|
||||
- Plain description flexibility
|
||||
- i18n-first via externalized labels
|
||||
|
||||
## Technical Implementation Details
|
||||
|
||||
### Async Handling
|
||||
```tsx
|
||||
const handleConfirm = async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
await onConfirm(); // resolve -> close
|
||||
onOpenChange(false);
|
||||
} catch (e) {
|
||||
console.error(e); // remain open for retry
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
```
|
||||
|
||||
### Close Guard
|
||||
```tsx
|
||||
<Dialog open={open} onOpenChange={(next) => !loading && onOpenChange(next)} />
|
||||
```
|
||||
|
||||
## Browser / Environment Support
|
||||
- Works anywhere the existing `Dialog` primitives work (modern browsers)
|
||||
- No ResizeObserver / layout dependencies
|
||||
|
||||
## Performance Considerations
|
||||
1. Minimal renders: loading state toggles once per confirm attempt
|
||||
2. No portal churn—relies on underlying dialog infra
|
||||
|
||||
## Future Enhancements
|
||||
1. Severity icon / header accent
|
||||
2. Auto-focus destructive button toggle
|
||||
3. Secondary action (e.g. "Archive" vs "Delete")
|
||||
4. Built-in retry / error slot
|
||||
5. Optional checkbox confirmation ("I understand the consequences")
|
||||
6. Motion/animation tokens integration
|
||||
|
||||
## Styling
|
||||
The `ConfirmDialog.module.scss` file provides a `.container` hook. It currently only hosts a harmless custom property so the stylesheet is non-empty. Add real layout or variant tokens there instead of inline styles.
|
||||
|
||||
## Internationalization
|
||||
All visible strings must come from the translation system. Use `useTranslate()` and pass localized values into props. Separate keys for title/description.
|
||||
|
||||
## Error Handling
|
||||
Errors thrown in `onConfirm` are caught and logged. The dialog stays open so the caller can surface a toast or inline message and allow retry. (Consider routing serious errors to a higher-level handler.)
|
||||
|
||||
---
|
||||
|
||||
If you extend this component, update this README to keep usage discoverable.
|
||||
@@ -0,0 +1,59 @@
|
||||
import * as React from "react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle } from "@/components/ui/dialog";
|
||||
|
||||
export interface ConfirmDialogProps {
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
title: React.ReactNode;
|
||||
description?: React.ReactNode;
|
||||
confirmLabel: string;
|
||||
cancelLabel: string;
|
||||
onConfirm: () => void | Promise<void>;
|
||||
confirmVariant?: "default" | "destructive";
|
||||
}
|
||||
|
||||
export default function ConfirmDialog({
|
||||
open,
|
||||
onOpenChange,
|
||||
title,
|
||||
description,
|
||||
confirmLabel,
|
||||
cancelLabel,
|
||||
onConfirm,
|
||||
confirmVariant = "default",
|
||||
}: ConfirmDialogProps) {
|
||||
const [loading, setLoading] = React.useState(false);
|
||||
|
||||
const handleConfirm = async () => {
|
||||
try {
|
||||
setLoading(true);
|
||||
await onConfirm();
|
||||
onOpenChange(false);
|
||||
} catch (e) {
|
||||
// Intentionally swallow errors so user can retry; surface via caller's toast/logging
|
||||
console.error("ConfirmDialog error for action:", title, e);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={(o: boolean) => !loading && onOpenChange(o)}>
|
||||
<DialogContent size="sm">
|
||||
<DialogHeader>
|
||||
<DialogTitle>{title}</DialogTitle>
|
||||
{description ? <DialogDescription>{description}</DialogDescription> : null}
|
||||
</DialogHeader>
|
||||
<DialogFooter>
|
||||
<Button variant="ghost" disabled={loading} onClick={() => onOpenChange(false)}>
|
||||
{cancelLabel}
|
||||
</Button>
|
||||
<Button variant={confirmVariant} disabled={loading} onClick={handleConfirm} data-loading={loading}>
|
||||
{confirmLabel}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,183 @@
|
||||
import copy from "copy-to-clipboard";
|
||||
import React, { useEffect, useState } from "react";
|
||||
import { toast } from "react-hot-toast";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Dialog, DialogContent, DialogFooter, DialogHeader, DialogTitle } from "@/components/ui/dialog";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { RadioGroup, RadioGroupItem } from "@/components/ui/radio-group";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
import { userServiceClient } from "@/connect";
|
||||
import useCurrentUser from "@/hooks/useCurrentUser";
|
||||
import useLoading from "@/hooks/useLoading";
|
||||
import { handleError } from "@/lib/error";
|
||||
import { CreatePersonalAccessTokenResponse } from "@/types/proto/api/v1/user_service_pb";
|
||||
import { useTranslate } from "@/utils/i18n";
|
||||
|
||||
interface Props {
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
onSuccess: (response: CreatePersonalAccessTokenResponse) => void;
|
||||
}
|
||||
|
||||
interface State {
|
||||
description: string;
|
||||
expiration: number;
|
||||
}
|
||||
|
||||
function CreateAccessTokenDialog({ open, onOpenChange, onSuccess }: Props) {
|
||||
const t = useTranslate();
|
||||
const currentUser = useCurrentUser();
|
||||
const [state, setState] = useState({
|
||||
description: "",
|
||||
expiration: 30, // Default: 30 days
|
||||
});
|
||||
const [createdToken, setCreatedToken] = useState<string | null>(null);
|
||||
const requestState = useLoading(false);
|
||||
|
||||
// Expiration options in days (0 = never expires)
|
||||
const expirationOptions = [
|
||||
{
|
||||
label: t("setting.access-token.create-dialog.duration-1m"),
|
||||
value: 30,
|
||||
},
|
||||
{
|
||||
label: "90 Days",
|
||||
value: 90,
|
||||
},
|
||||
{
|
||||
label: t("setting.access-token.create-dialog.duration-never"),
|
||||
value: 0,
|
||||
},
|
||||
];
|
||||
|
||||
const setPartialState = (partialState: Partial<State>) => {
|
||||
setState({
|
||||
...state,
|
||||
...partialState,
|
||||
});
|
||||
};
|
||||
|
||||
const handleDescriptionInputChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
setPartialState({
|
||||
description: e.target.value,
|
||||
});
|
||||
};
|
||||
|
||||
const handleRoleInputChange = (value: string) => {
|
||||
setPartialState({
|
||||
expiration: Number(value),
|
||||
});
|
||||
};
|
||||
|
||||
const handleSaveBtnClick = async () => {
|
||||
if (!state.description) {
|
||||
toast.error(t("message.description-is-required"));
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
requestState.setLoading();
|
||||
const response = await userServiceClient.createPersonalAccessToken({
|
||||
parent: currentUser?.name,
|
||||
description: state.description,
|
||||
expiresInDays: state.expiration,
|
||||
});
|
||||
|
||||
requestState.setFinish();
|
||||
onSuccess(response);
|
||||
if (response.token) {
|
||||
setCreatedToken(response.token);
|
||||
} else {
|
||||
onOpenChange(false);
|
||||
}
|
||||
} catch (error: unknown) {
|
||||
handleError(error, toast.error, {
|
||||
context: "Create access token",
|
||||
onError: () => requestState.setError(),
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const handleCopyToken = () => {
|
||||
if (!createdToken) return;
|
||||
copy(createdToken);
|
||||
toast.success(t("message.copied"));
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
setState({
|
||||
description: "",
|
||||
expiration: 30,
|
||||
});
|
||||
setCreatedToken(null);
|
||||
}, [open]);
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent className="max-w-md">
|
||||
<DialogHeader>
|
||||
<DialogTitle>{t("setting.access-token.create-dialog.create-access-token")}</DialogTitle>
|
||||
</DialogHeader>
|
||||
{createdToken ? (
|
||||
<div className="flex flex-col gap-4">
|
||||
<div className="grid gap-2">
|
||||
<Label>{t("setting.access-token.token")}</Label>
|
||||
<Textarea value={createdToken} readOnly rows={3} className="font-mono text-xs" />
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex flex-col gap-4">
|
||||
<div className="grid gap-2">
|
||||
<Label htmlFor="description">
|
||||
{t("setting.access-token.create-dialog.description")} <span className="text-destructive">*</span>
|
||||
</Label>
|
||||
<Input
|
||||
id="description"
|
||||
type="text"
|
||||
placeholder={t("setting.access-token.create-dialog.some-description")}
|
||||
value={state.description}
|
||||
onChange={handleDescriptionInputChange}
|
||||
/>
|
||||
</div>
|
||||
<div className="grid gap-2">
|
||||
<Label>
|
||||
{t("setting.access-token.create-dialog.expiration")} <span className="text-destructive">*</span>
|
||||
</Label>
|
||||
<RadioGroup value={state.expiration.toString()} onValueChange={handleRoleInputChange} className="flex flex-row gap-4">
|
||||
{expirationOptions.map((option) => (
|
||||
<div key={option.value} className="flex items-center space-x-2">
|
||||
<RadioGroupItem value={option.value.toString()} id={`expiration-${option.value}`} />
|
||||
<Label htmlFor={`expiration-${option.value}`}>{option.label}</Label>
|
||||
</div>
|
||||
))}
|
||||
</RadioGroup>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
<DialogFooter>
|
||||
{createdToken ? (
|
||||
<>
|
||||
<Button variant="ghost" onClick={handleCopyToken}>
|
||||
{t("common.copy")}
|
||||
</Button>
|
||||
<Button onClick={() => onOpenChange(false)}>{t("common.close")}</Button>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Button variant="ghost" disabled={requestState.isLoading} onClick={() => onOpenChange(false)}>
|
||||
{t("common.cancel")}
|
||||
</Button>
|
||||
<Button disabled={requestState.isLoading} onClick={handleSaveBtnClick}>
|
||||
{t("common.create")}
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
|
||||
export default CreateAccessTokenDialog;
|
||||
@@ -0,0 +1,615 @@
|
||||
import { create } from "@bufbuild/protobuf";
|
||||
import { FieldMaskSchema } from "@bufbuild/protobuf/wkt";
|
||||
import { type ReactNode, useEffect, useState } from "react";
|
||||
import { toast } from "react-hot-toast";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle } from "@/components/ui/dialog";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
||||
import { identityProviderServiceClient } from "@/connect";
|
||||
import { absolutifyLink } from "@/lib/browser";
|
||||
import { handleError } from "@/lib/error";
|
||||
import {
|
||||
FieldMapping,
|
||||
FieldMappingSchema,
|
||||
IdentityProvider,
|
||||
IdentityProvider_Type,
|
||||
IdentityProviderConfigSchema,
|
||||
IdentityProviderSchema,
|
||||
OAuth2Config,
|
||||
OAuth2ConfigSchema,
|
||||
} from "@/types/proto/api/v1/idp_service_pb";
|
||||
import { useTranslate } from "@/utils/i18n";
|
||||
|
||||
const DEFAULT_TEMPLATE = "GitHub";
|
||||
|
||||
const templateList: IdentityProvider[] = [
|
||||
create(IdentityProviderSchema, {
|
||||
name: "",
|
||||
title: "GitHub",
|
||||
type: IdentityProvider_Type.OAUTH2,
|
||||
identifierFilter: "",
|
||||
config: create(IdentityProviderConfigSchema, {
|
||||
config: {
|
||||
case: "oauth2Config",
|
||||
value: create(OAuth2ConfigSchema, {
|
||||
clientId: "",
|
||||
clientSecret: "",
|
||||
authUrl: "https://github.com/login/oauth/authorize",
|
||||
tokenUrl: "https://github.com/login/oauth/access_token",
|
||||
userInfoUrl: "https://api.github.com/user",
|
||||
scopes: ["read:user"],
|
||||
fieldMapping: create(FieldMappingSchema, {
|
||||
identifier: "login",
|
||||
displayName: "name",
|
||||
email: "email",
|
||||
}),
|
||||
}),
|
||||
},
|
||||
}),
|
||||
}),
|
||||
create(IdentityProviderSchema, {
|
||||
name: "",
|
||||
title: "GitLab",
|
||||
type: IdentityProvider_Type.OAUTH2,
|
||||
identifierFilter: "",
|
||||
config: create(IdentityProviderConfigSchema, {
|
||||
config: {
|
||||
case: "oauth2Config",
|
||||
value: create(OAuth2ConfigSchema, {
|
||||
clientId: "",
|
||||
clientSecret: "",
|
||||
authUrl: "https://gitlab.com/oauth/authorize",
|
||||
tokenUrl: "https://gitlab.com/oauth/token",
|
||||
userInfoUrl: "https://gitlab.com/oauth/userinfo",
|
||||
scopes: ["openid"],
|
||||
fieldMapping: create(FieldMappingSchema, {
|
||||
identifier: "name",
|
||||
displayName: "name",
|
||||
email: "email",
|
||||
}),
|
||||
}),
|
||||
},
|
||||
}),
|
||||
}),
|
||||
create(IdentityProviderSchema, {
|
||||
name: "",
|
||||
title: "Google",
|
||||
type: IdentityProvider_Type.OAUTH2,
|
||||
identifierFilter: "",
|
||||
config: create(IdentityProviderConfigSchema, {
|
||||
config: {
|
||||
case: "oauth2Config",
|
||||
value: create(OAuth2ConfigSchema, {
|
||||
clientId: "",
|
||||
clientSecret: "",
|
||||
authUrl: "https://accounts.google.com/o/oauth2/v2/auth",
|
||||
tokenUrl: "https://oauth2.googleapis.com/token",
|
||||
userInfoUrl: "https://www.googleapis.com/oauth2/v2/userinfo",
|
||||
scopes: ["https://www.googleapis.com/auth/userinfo.email", "https://www.googleapis.com/auth/userinfo.profile"],
|
||||
fieldMapping: create(FieldMappingSchema, {
|
||||
identifier: "email",
|
||||
displayName: "name",
|
||||
email: "email",
|
||||
}),
|
||||
}),
|
||||
},
|
||||
}),
|
||||
}),
|
||||
create(IdentityProviderSchema, {
|
||||
name: "",
|
||||
title: "Custom",
|
||||
type: IdentityProvider_Type.OAUTH2,
|
||||
identifierFilter: "",
|
||||
config: create(IdentityProviderConfigSchema, {
|
||||
config: {
|
||||
case: "oauth2Config",
|
||||
value: create(OAuth2ConfigSchema, {
|
||||
clientId: "",
|
||||
clientSecret: "",
|
||||
authUrl: "",
|
||||
tokenUrl: "",
|
||||
userInfoUrl: "",
|
||||
scopes: [],
|
||||
fieldMapping: create(FieldMappingSchema, {
|
||||
identifier: "",
|
||||
displayName: "",
|
||||
email: "",
|
||||
}),
|
||||
}),
|
||||
},
|
||||
}),
|
||||
}),
|
||||
];
|
||||
|
||||
interface Props {
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
identityProvider?: IdentityProvider;
|
||||
onSuccess?: () => void;
|
||||
}
|
||||
|
||||
interface BasicInfoState {
|
||||
title: string;
|
||||
identifier: string;
|
||||
identifierFilter: string;
|
||||
}
|
||||
|
||||
function createEmptyFieldMapping(): FieldMapping {
|
||||
return create(FieldMappingSchema, {
|
||||
identifier: "",
|
||||
displayName: "",
|
||||
email: "",
|
||||
avatarUrl: "",
|
||||
});
|
||||
}
|
||||
|
||||
function createEmptyOAuth2Config(): OAuth2Config {
|
||||
return create(OAuth2ConfigSchema, {
|
||||
clientId: "",
|
||||
clientSecret: "",
|
||||
authUrl: "",
|
||||
tokenUrl: "",
|
||||
userInfoUrl: "",
|
||||
scopes: [],
|
||||
fieldMapping: createEmptyFieldMapping(),
|
||||
});
|
||||
}
|
||||
|
||||
function createEmptyBasicInfo(): BasicInfoState {
|
||||
return {
|
||||
title: "",
|
||||
identifier: "",
|
||||
identifierFilter: "",
|
||||
};
|
||||
}
|
||||
|
||||
function sanitizeIdentifier(value: string): string {
|
||||
return value
|
||||
.toLowerCase()
|
||||
.replace(/[^a-z0-9-]/g, "-")
|
||||
.replace(/--+/g, "-")
|
||||
.replace(/^-+|-+$/g, "");
|
||||
}
|
||||
|
||||
function normalizeScopes(value: string): string[] {
|
||||
return value
|
||||
.split(/\s+/)
|
||||
.map((scope) => scope.trim())
|
||||
.filter(Boolean);
|
||||
}
|
||||
|
||||
function buildDialogStateFromTemplate(templateName: string) {
|
||||
const template = templateList.find((item) => item.title === templateName) ?? templateList[0];
|
||||
const oauth2Config =
|
||||
template.type === IdentityProvider_Type.OAUTH2 && template.config?.config.case === "oauth2Config"
|
||||
? create(OAuth2ConfigSchema, template.config.config.value)
|
||||
: createEmptyOAuth2Config();
|
||||
|
||||
return {
|
||||
basicInfo: {
|
||||
title: template.title,
|
||||
identifier: sanitizeIdentifier(template.title),
|
||||
identifierFilter: template.identifierFilter,
|
||||
},
|
||||
type: template.type,
|
||||
oauth2Config,
|
||||
oauth2Scopes: oauth2Config.scopes.join(" "),
|
||||
};
|
||||
}
|
||||
|
||||
function buildDialogStateFromProvider(identityProvider: IdentityProvider) {
|
||||
const oauth2Config =
|
||||
identityProvider.type === IdentityProvider_Type.OAUTH2 && identityProvider.config?.config.case === "oauth2Config"
|
||||
? create(OAuth2ConfigSchema, identityProvider.config.config.value)
|
||||
: createEmptyOAuth2Config();
|
||||
|
||||
return {
|
||||
basicInfo: {
|
||||
title: identityProvider.title,
|
||||
identifier: "",
|
||||
identifierFilter: identityProvider.identifierFilter,
|
||||
},
|
||||
type: identityProvider.type,
|
||||
oauth2Config,
|
||||
oauth2Scopes: oauth2Config.scopes.join(" "),
|
||||
};
|
||||
}
|
||||
|
||||
function FormSection({ title, description, children }: { title: string; description?: string; children: ReactNode }) {
|
||||
return (
|
||||
<section className="space-y-4 rounded-lg border bg-muted/20 p-4">
|
||||
<div className="space-y-1">
|
||||
<h3 className="text-sm font-semibold text-foreground">{title}</h3>
|
||||
{description ? <p className="text-xs text-muted-foreground">{description}</p> : null}
|
||||
</div>
|
||||
<div className="space-y-4">{children}</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
function FormField({
|
||||
label,
|
||||
required = false,
|
||||
description,
|
||||
children,
|
||||
}: {
|
||||
label: string;
|
||||
required?: boolean;
|
||||
description?: string;
|
||||
children: ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
<Label>
|
||||
{label}
|
||||
{required ? <span className="text-destructive">*</span> : null}
|
||||
</Label>
|
||||
{children}
|
||||
{description ? <p className="text-xs text-muted-foreground">{description}</p> : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function CreateIdentityProviderDialog({ open, onOpenChange, identityProvider, onSuccess }: Props) {
|
||||
const t = useTranslate();
|
||||
const identityProviderTypes = [...new Set(templateList.map((template) => template.type))];
|
||||
const [basicInfo, setBasicInfo] = useState<BasicInfoState>(createEmptyBasicInfo);
|
||||
const [type, setType] = useState<IdentityProvider_Type>(IdentityProvider_Type.OAUTH2);
|
||||
const [oauth2Config, setOAuth2Config] = useState<OAuth2Config>(createEmptyOAuth2Config);
|
||||
const [oauth2Scopes, setOAuth2Scopes] = useState<string>("");
|
||||
const [selectedTemplate, setSelectedTemplate] = useState<string>(DEFAULT_TEMPLATE);
|
||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||
const isCreating = identityProvider === undefined;
|
||||
const oauth2FieldMapping = oauth2Config.fieldMapping ?? createEmptyFieldMapping();
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) {
|
||||
setSelectedTemplate(DEFAULT_TEMPLATE);
|
||||
setBasicInfo(createEmptyBasicInfo());
|
||||
setType(IdentityProvider_Type.OAUTH2);
|
||||
setOAuth2Config(createEmptyOAuth2Config());
|
||||
setOAuth2Scopes("");
|
||||
setIsSubmitting(false);
|
||||
return;
|
||||
}
|
||||
|
||||
const nextState = isCreating ? buildDialogStateFromTemplate(selectedTemplate) : buildDialogStateFromProvider(identityProvider!);
|
||||
setBasicInfo(nextState.basicInfo);
|
||||
setType(nextState.type);
|
||||
setOAuth2Config(nextState.oauth2Config);
|
||||
setOAuth2Scopes(nextState.oauth2Scopes);
|
||||
}, [open, isCreating, identityProvider, selectedTemplate]);
|
||||
|
||||
const handleDialogClose = (nextOpen: boolean) => {
|
||||
if (isSubmitting && !nextOpen) {
|
||||
return;
|
||||
}
|
||||
onOpenChange(nextOpen);
|
||||
};
|
||||
|
||||
const handleCloseBtnClick = () => {
|
||||
if (isSubmitting) {
|
||||
return;
|
||||
}
|
||||
handleDialogClose(false);
|
||||
};
|
||||
|
||||
const allowConfirmAction = () => {
|
||||
if (basicInfo.title.trim() === "") {
|
||||
return false;
|
||||
}
|
||||
if (isCreating && basicInfo.identifier.trim() === "") {
|
||||
return false;
|
||||
}
|
||||
if (type === IdentityProvider_Type.OAUTH2) {
|
||||
if (
|
||||
oauth2Config.clientId.trim() === "" ||
|
||||
oauth2Config.authUrl.trim() === "" ||
|
||||
oauth2Config.tokenUrl.trim() === "" ||
|
||||
oauth2Config.userInfoUrl.trim() === "" ||
|
||||
normalizeScopes(oauth2Scopes).length === 0 ||
|
||||
oauth2FieldMapping.identifier.trim() === ""
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
if (isCreating && oauth2Config.clientSecret.trim() === "") {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
return !isSubmitting;
|
||||
};
|
||||
|
||||
const handleConfirmBtnClick = async () => {
|
||||
setIsSubmitting(true);
|
||||
const normalizedScopes = normalizeScopes(oauth2Scopes);
|
||||
|
||||
try {
|
||||
if (isCreating) {
|
||||
await identityProviderServiceClient.createIdentityProvider({
|
||||
identityProviderId: basicInfo.identifier,
|
||||
identityProvider: create(IdentityProviderSchema, {
|
||||
title: basicInfo.title.trim(),
|
||||
identifierFilter: basicInfo.identifierFilter.trim(),
|
||||
type,
|
||||
config: create(IdentityProviderConfigSchema, {
|
||||
config: {
|
||||
case: "oauth2Config",
|
||||
value: {
|
||||
...oauth2Config,
|
||||
scopes: normalizedScopes,
|
||||
},
|
||||
},
|
||||
}),
|
||||
}),
|
||||
});
|
||||
toast.success(t("setting.sso.sso-created", { name: basicInfo.title }));
|
||||
} else {
|
||||
await identityProviderServiceClient.updateIdentityProvider({
|
||||
identityProvider: create(IdentityProviderSchema, {
|
||||
name: identityProvider!.name,
|
||||
title: basicInfo.title.trim(),
|
||||
identifierFilter: basicInfo.identifierFilter.trim(),
|
||||
type,
|
||||
config: create(IdentityProviderConfigSchema, {
|
||||
config: {
|
||||
case: "oauth2Config",
|
||||
value: {
|
||||
...oauth2Config,
|
||||
scopes: normalizedScopes,
|
||||
},
|
||||
},
|
||||
}),
|
||||
}),
|
||||
updateMask: create(FieldMaskSchema, { paths: ["title", "identifier_filter", "config"] }),
|
||||
});
|
||||
toast.success(t("setting.sso.sso-updated", { name: basicInfo.title }));
|
||||
}
|
||||
} catch (error: unknown) {
|
||||
setIsSubmitting(false);
|
||||
await handleError(error, toast.error, {
|
||||
context: isCreating ? "Create identity provider" : "Update identity provider",
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
setIsSubmitting(false);
|
||||
onSuccess?.();
|
||||
handleDialogClose(false);
|
||||
};
|
||||
|
||||
const setPartialOAuth2Config = (state: Partial<OAuth2Config>) => {
|
||||
setOAuth2Config((current) => ({
|
||||
...current,
|
||||
...state,
|
||||
}));
|
||||
};
|
||||
|
||||
const setPartialFieldMapping = (state: Partial<FieldMapping>) => {
|
||||
setPartialOAuth2Config({
|
||||
fieldMapping: {
|
||||
...oauth2FieldMapping,
|
||||
...state,
|
||||
} as FieldMapping,
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={handleDialogClose}>
|
||||
<DialogContent size="2xl">
|
||||
<DialogHeader>
|
||||
<DialogTitle>{t(isCreating ? "setting.sso.create-sso" : "setting.sso.update-sso")}</DialogTitle>
|
||||
<DialogDescription>
|
||||
{t(isCreating ? "setting.sso.create-sso-description" : "setting.sso.update-sso-description")}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="space-y-4">
|
||||
<FormSection title={t("setting.sso.basic-settings")} description={t("setting.sso.basic-settings-description")}>
|
||||
{isCreating ? (
|
||||
<div className="grid gap-4 md:grid-cols-2">
|
||||
<FormField label={t("common.type")} required>
|
||||
<Select value={String(type)} onValueChange={(value) => setType(Number(value) as IdentityProvider_Type)}>
|
||||
<SelectTrigger>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{identityProviderTypes.map((kind) => (
|
||||
<SelectItem key={kind} value={String(kind)}>
|
||||
{IdentityProvider_Type[kind] || kind}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</FormField>
|
||||
|
||||
<FormField label={t("setting.sso.template")} required description={t("setting.sso.template-description")}>
|
||||
<Select value={selectedTemplate} onValueChange={setSelectedTemplate}>
|
||||
<SelectTrigger>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{templateList.map((template) => (
|
||||
<SelectItem key={template.title} value={template.title}>
|
||||
{template.title}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</FormField>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<div className="grid gap-4 md:grid-cols-2">
|
||||
{isCreating ? (
|
||||
<FormField label={t("setting.sso.provider-id")} required description={t("setting.sso.provider-id-description")}>
|
||||
<Input
|
||||
className="font-mono"
|
||||
placeholder="e.g. github, okta-corp"
|
||||
maxLength={32}
|
||||
value={basicInfo.identifier}
|
||||
onChange={(e) =>
|
||||
setBasicInfo((current) => ({
|
||||
...current,
|
||||
identifier: sanitizeIdentifier(e.target.value),
|
||||
}))
|
||||
}
|
||||
/>
|
||||
</FormField>
|
||||
) : null}
|
||||
|
||||
<FormField label={t("common.name")} required>
|
||||
<Input
|
||||
placeholder={t("common.name")}
|
||||
value={basicInfo.title}
|
||||
onChange={(e) =>
|
||||
setBasicInfo((current) => ({
|
||||
...current,
|
||||
title: e.target.value,
|
||||
}))
|
||||
}
|
||||
/>
|
||||
</FormField>
|
||||
</div>
|
||||
|
||||
<FormField label={t("setting.sso.identifier-filter")} description={t("setting.sso.identifier-filter-description")}>
|
||||
<Input
|
||||
placeholder={t("setting.sso.identifier-filter")}
|
||||
value={basicInfo.identifierFilter}
|
||||
onChange={(e) =>
|
||||
setBasicInfo((current) => ({
|
||||
...current,
|
||||
identifierFilter: e.target.value,
|
||||
}))
|
||||
}
|
||||
/>
|
||||
</FormField>
|
||||
</FormSection>
|
||||
|
||||
{type === IdentityProvider_Type.OAUTH2 ? (
|
||||
<>
|
||||
<FormSection title={t("setting.sso.oauth-configuration")} description={t("setting.sso.oauth-configuration-description")}>
|
||||
<div className="rounded-md border bg-background px-3 py-3">
|
||||
<p className="text-xs font-medium uppercase tracking-wide text-muted-foreground">{t("setting.sso.redirect-url")}</p>
|
||||
<p className="mt-2 break-all font-mono text-xs text-foreground sm:text-sm">{absolutifyLink("/auth/callback")}</p>
|
||||
<p className="mt-2 text-xs text-muted-foreground">{t("setting.sso.redirect-url-description")}</p>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-4 md:grid-cols-2">
|
||||
<FormField label={t("setting.sso.client-id")} required>
|
||||
<Input
|
||||
placeholder={t("setting.sso.client-id")}
|
||||
value={oauth2Config.clientId}
|
||||
onChange={(e) => setPartialOAuth2Config({ clientId: e.target.value })}
|
||||
/>
|
||||
</FormField>
|
||||
|
||||
<FormField
|
||||
label={t("setting.sso.client-secret")}
|
||||
required={isCreating}
|
||||
description={isCreating ? undefined : t("setting.sso.client-secret-optional-description")}
|
||||
>
|
||||
<Input
|
||||
type="password"
|
||||
autoComplete="off"
|
||||
placeholder={t("setting.sso.client-secret")}
|
||||
value={oauth2Config.clientSecret}
|
||||
onChange={(e) => setPartialOAuth2Config({ clientSecret: e.target.value })}
|
||||
/>
|
||||
</FormField>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-4 md:grid-cols-2">
|
||||
<FormField label={t("setting.sso.authorization-endpoint")} required>
|
||||
<Input
|
||||
placeholder={t("setting.sso.authorization-endpoint")}
|
||||
value={oauth2Config.authUrl}
|
||||
onChange={(e) => setPartialOAuth2Config({ authUrl: e.target.value })}
|
||||
/>
|
||||
</FormField>
|
||||
|
||||
<FormField label={t("setting.sso.token-endpoint")} required>
|
||||
<Input
|
||||
placeholder={t("setting.sso.token-endpoint")}
|
||||
value={oauth2Config.tokenUrl}
|
||||
onChange={(e) => setPartialOAuth2Config({ tokenUrl: e.target.value })}
|
||||
/>
|
||||
</FormField>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-4 md:grid-cols-2">
|
||||
<FormField label={t("setting.sso.user-endpoint")} required>
|
||||
<Input
|
||||
placeholder={t("setting.sso.user-endpoint")}
|
||||
value={oauth2Config.userInfoUrl}
|
||||
onChange={(e) => setPartialOAuth2Config({ userInfoUrl: e.target.value })}
|
||||
/>
|
||||
</FormField>
|
||||
|
||||
<FormField label={t("setting.sso.scopes")} required description={t("setting.sso.scopes-description")}>
|
||||
<Input placeholder={t("setting.sso.scopes")} value={oauth2Scopes} onChange={(e) => setOAuth2Scopes(e.target.value)} />
|
||||
</FormField>
|
||||
</div>
|
||||
</FormSection>
|
||||
|
||||
<FormSection title={t("setting.sso.field-mapping")} description={t("setting.sso.field-mapping-description")}>
|
||||
<div className="grid gap-4 md:grid-cols-2">
|
||||
<FormField
|
||||
label={t("setting.sso.identifier")}
|
||||
required
|
||||
description={t("setting.sso.field-mapping-identifier-description")}
|
||||
>
|
||||
<Input
|
||||
placeholder={t("setting.sso.identifier")}
|
||||
value={oauth2FieldMapping.identifier}
|
||||
onChange={(e) => setPartialFieldMapping({ identifier: e.target.value })}
|
||||
/>
|
||||
</FormField>
|
||||
|
||||
<FormField label={t("setting.sso.display-name")}>
|
||||
<Input
|
||||
placeholder={t("setting.sso.display-name")}
|
||||
value={oauth2FieldMapping.displayName}
|
||||
onChange={(e) => setPartialFieldMapping({ displayName: e.target.value })}
|
||||
/>
|
||||
</FormField>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-4 md:grid-cols-2">
|
||||
<FormField label={t("common.email")}>
|
||||
<Input
|
||||
placeholder={t("common.email")}
|
||||
value={oauth2FieldMapping.email}
|
||||
onChange={(e) => setPartialFieldMapping({ email: e.target.value })}
|
||||
/>
|
||||
</FormField>
|
||||
|
||||
<FormField label={t("setting.sso.avatar-url")}>
|
||||
<Input
|
||||
placeholder={t("setting.sso.avatar-url")}
|
||||
value={oauth2FieldMapping.avatarUrl}
|
||||
onChange={(e) => setPartialFieldMapping({ avatarUrl: e.target.value })}
|
||||
/>
|
||||
</FormField>
|
||||
</div>
|
||||
</FormSection>
|
||||
</>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
<DialogFooter>
|
||||
<Button variant="ghost" onClick={handleCloseBtnClick} disabled={isSubmitting}>
|
||||
{t("common.cancel")}
|
||||
</Button>
|
||||
<Button onClick={handleConfirmBtnClick} disabled={!allowConfirmAction()}>
|
||||
{t(isCreating ? "common.create" : "common.update")}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
|
||||
export default CreateIdentityProviderDialog;
|
||||
@@ -0,0 +1,150 @@
|
||||
import { create } from "@bufbuild/protobuf";
|
||||
import { FieldMaskSchema } from "@bufbuild/protobuf/wkt";
|
||||
import { useEffect, useState } from "react";
|
||||
import { toast } from "react-hot-toast";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Dialog, DialogContent, DialogFooter, DialogHeader, DialogTitle } from "@/components/ui/dialog";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { RadioGroup, RadioGroupItem } from "@/components/ui/radio-group";
|
||||
import { userServiceClient } from "@/connect";
|
||||
import useLoading from "@/hooks/useLoading";
|
||||
import { handleError } from "@/lib/error";
|
||||
import { User, User_Role, UserSchema } from "@/types/proto/api/v1/user_service_pb";
|
||||
import { useTranslate } from "@/utils/i18n";
|
||||
|
||||
interface Props {
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
user?: User;
|
||||
onSuccess?: () => void;
|
||||
}
|
||||
|
||||
function CreateUserDialog({ open, onOpenChange, user: initialUser, onSuccess }: Props) {
|
||||
const t = useTranslate();
|
||||
const [user, setUser] = useState(
|
||||
create(UserSchema, initialUser ? { name: initialUser.name, username: initialUser.username, role: initialUser.role } : {}),
|
||||
);
|
||||
const requestState = useLoading(false);
|
||||
const isCreating = !initialUser;
|
||||
|
||||
useEffect(() => {
|
||||
if (initialUser) {
|
||||
setUser(create(UserSchema, { name: initialUser.name, username: initialUser.username, role: initialUser.role }));
|
||||
} else {
|
||||
setUser(create(UserSchema, {}));
|
||||
}
|
||||
}, [initialUser]);
|
||||
|
||||
const setPartialUser = (state: Partial<User>) => {
|
||||
setUser({
|
||||
...user,
|
||||
...state,
|
||||
});
|
||||
};
|
||||
|
||||
const handleConfirm = async () => {
|
||||
if (isCreating && (!user.username || !user.password)) {
|
||||
toast.error("Username and password cannot be empty");
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
requestState.setLoading();
|
||||
if (isCreating) {
|
||||
await userServiceClient.createUser({ user });
|
||||
toast.success("Create user successfully");
|
||||
} else {
|
||||
const updateMask = [];
|
||||
if (user.username !== initialUser?.username) {
|
||||
updateMask.push("username");
|
||||
}
|
||||
if (user.password) {
|
||||
updateMask.push("password");
|
||||
}
|
||||
if (user.role !== initialUser?.role) {
|
||||
updateMask.push("role");
|
||||
}
|
||||
const userToUpdate = create(UserSchema, { ...user, name: initialUser?.name ?? user.name });
|
||||
await userServiceClient.updateUser({ user: userToUpdate, updateMask: create(FieldMaskSchema, { paths: updateMask }) });
|
||||
toast.success("Update user successfully");
|
||||
}
|
||||
requestState.setFinish();
|
||||
onSuccess?.();
|
||||
onOpenChange(false);
|
||||
} catch (error: unknown) {
|
||||
handleError(error, toast.error, {
|
||||
context: user ? "Update user" : "Create user",
|
||||
onError: () => requestState.setError(),
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent className="max-w-md">
|
||||
<DialogHeader>
|
||||
<DialogTitle>{`${isCreating ? t("common.create") : t("common.edit")} ${t("common.user")}`}</DialogTitle>
|
||||
</DialogHeader>
|
||||
<div className="flex flex-col gap-4">
|
||||
<div className="grid gap-2">
|
||||
<Label htmlFor="username">{t("common.username")}</Label>
|
||||
<Input
|
||||
id="username"
|
||||
type="text"
|
||||
placeholder={t("common.username")}
|
||||
value={user.username}
|
||||
onChange={(e) =>
|
||||
setPartialUser({
|
||||
username: e.target.value,
|
||||
})
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
<div className="grid gap-2">
|
||||
<Label htmlFor="password">{t("common.password")}</Label>
|
||||
<Input
|
||||
id="password"
|
||||
type="password"
|
||||
placeholder={t("common.password")}
|
||||
autoComplete="off"
|
||||
value={user.password}
|
||||
onChange={(e) =>
|
||||
setPartialUser({
|
||||
password: e.target.value,
|
||||
})
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
<div className="grid gap-2">
|
||||
<Label>{t("common.role")}</Label>
|
||||
<RadioGroup
|
||||
value={String(user.role)}
|
||||
onValueChange={(value) => setPartialUser({ role: Number(value) as User_Role })}
|
||||
className="flex flex-row gap-4"
|
||||
>
|
||||
<div className="flex items-center space-x-2">
|
||||
<RadioGroupItem value={String(User_Role.USER)} id="user" />
|
||||
<Label htmlFor="user">{t("setting.member.user")}</Label>
|
||||
</div>
|
||||
<div className="flex items-center space-x-2">
|
||||
<RadioGroupItem value={String(User_Role.ADMIN)} id="admin" />
|
||||
<Label htmlFor="admin">{t("setting.member.admin")}</Label>
|
||||
</div>
|
||||
</RadioGroup>
|
||||
</div>
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button variant="ghost" disabled={requestState.isLoading} onClick={() => onOpenChange(false)}>
|
||||
{t("common.cancel")}
|
||||
</Button>
|
||||
<Button disabled={requestState.isLoading} onClick={handleConfirm}>
|
||||
{t("common.confirm")}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
|
||||
export default CreateUserDialog;
|
||||
@@ -0,0 +1,281 @@
|
||||
import { create } from "@bufbuild/protobuf";
|
||||
import { FieldMaskSchema } from "@bufbuild/protobuf/wkt";
|
||||
import copy from "copy-to-clipboard";
|
||||
import { CheckIcon, CopyIcon, EyeIcon } from "lucide-react";
|
||||
import React, { useEffect, useState } from "react";
|
||||
import { toast } from "react-hot-toast";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Dialog, DialogContent, DialogFooter, DialogHeader, DialogTitle } from "@/components/ui/dialog";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { userServiceClient } from "@/connect";
|
||||
import useCurrentUser from "@/hooks/useCurrentUser";
|
||||
import useLoading from "@/hooks/useLoading";
|
||||
import { handleError } from "@/lib/error";
|
||||
import { useTranslate } from "@/utils/i18n";
|
||||
|
||||
interface Props {
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
webhookName?: string;
|
||||
onSuccess?: () => void;
|
||||
}
|
||||
|
||||
interface State {
|
||||
displayName: string;
|
||||
url: string;
|
||||
}
|
||||
|
||||
function CreateWebhookDialog({ open, onOpenChange, webhookName, onSuccess }: Props) {
|
||||
const t = useTranslate();
|
||||
const currentUser = useCurrentUser();
|
||||
const isCreating = webhookName === undefined;
|
||||
const [state, setState] = useState<State>({ displayName: "", url: "" });
|
||||
const requestState = useLoading(false);
|
||||
const secretState = useLoading(false);
|
||||
const [hasExistingSecret, setHasExistingSecret] = useState(false);
|
||||
const [revealedSecret, setRevealedSecret] = useState<string | undefined>(undefined);
|
||||
const [createdSecret, setCreatedSecret] = useState<string | undefined>(undefined);
|
||||
const [secretCopied, setSecretCopied] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (webhookName && currentUser) {
|
||||
userServiceClient
|
||||
.listUserWebhooks({
|
||||
parent: currentUser.name,
|
||||
})
|
||||
.then((response) => {
|
||||
const webhook = response.webhooks.find((w) => w.name === webhookName);
|
||||
if (webhook) {
|
||||
setState({ displayName: webhook.displayName, url: webhook.url });
|
||||
setHasExistingSecret(webhook.signingSecretSet);
|
||||
}
|
||||
});
|
||||
}
|
||||
}, [webhookName, currentUser]);
|
||||
|
||||
useEffect(() => {
|
||||
if (open && isCreating) {
|
||||
setState({ displayName: "", url: "" });
|
||||
setHasExistingSecret(false);
|
||||
setRevealedSecret(undefined);
|
||||
setCreatedSecret(undefined);
|
||||
setSecretCopied(false);
|
||||
}
|
||||
}, [open, isCreating]);
|
||||
|
||||
const handleTitleInputChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
setState((prev) => ({ ...prev, displayName: e.target.value }));
|
||||
};
|
||||
|
||||
const handleUrlInputChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
setState((prev) => ({ ...prev, url: e.target.value }));
|
||||
};
|
||||
|
||||
const handleCopySecret = (secret: string) => {
|
||||
copy(secret);
|
||||
setSecretCopied(true);
|
||||
setTimeout(() => setSecretCopied(false), 2000);
|
||||
};
|
||||
|
||||
const handleRevealSecret = async () => {
|
||||
if (!webhookName) return;
|
||||
try {
|
||||
secretState.setLoading();
|
||||
const { signingSecret } = await userServiceClient.getUserWebhookSigningSecret({ name: webhookName });
|
||||
setRevealedSecret(signingSecret);
|
||||
secretState.setFinish();
|
||||
} catch (error: unknown) {
|
||||
handleError(error, toast.error, { context: "Reveal signing secret", onError: () => secretState.setError() });
|
||||
}
|
||||
};
|
||||
|
||||
// Lets a pre-existing webhook that has no secret adopt one. The secret is
|
||||
// persisted immediately and revealed so the user can copy it once.
|
||||
const handleGenerateSecret = async () => {
|
||||
if (!webhookName) return;
|
||||
const bytes = crypto.getRandomValues(new Uint8Array(32));
|
||||
const secret = "whsec_" + btoa(String.fromCharCode(...bytes));
|
||||
try {
|
||||
secretState.setLoading();
|
||||
await userServiceClient.updateUserWebhook({
|
||||
webhook: { name: webhookName, signingSecret: secret },
|
||||
updateMask: create(FieldMaskSchema, { paths: ["signing_secret"] }),
|
||||
});
|
||||
setHasExistingSecret(true);
|
||||
setRevealedSecret(secret);
|
||||
handleCopySecret(secret);
|
||||
secretState.setFinish();
|
||||
} catch (error: unknown) {
|
||||
handleError(error, toast.error, { context: "Generate signing secret", onError: () => secretState.setError() });
|
||||
}
|
||||
};
|
||||
|
||||
// When closing after a successful create, refresh the parent list regardless of how the dialog is dismissed.
|
||||
const handleOpenChange = (next: boolean) => {
|
||||
if (!next && createdSecret !== undefined) {
|
||||
onSuccess?.();
|
||||
}
|
||||
onOpenChange(next);
|
||||
};
|
||||
|
||||
const handleSaveBtnClick = async () => {
|
||||
if (!state.displayName || !state.url) {
|
||||
toast.error(t("message.fill-all-required-fields"));
|
||||
return;
|
||||
}
|
||||
|
||||
if (!currentUser) {
|
||||
toast.error("User not authenticated");
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
requestState.setLoading();
|
||||
if (isCreating) {
|
||||
// The signing secret is generated server-side; reveal it once so the user can copy it without reopening.
|
||||
const created = await userServiceClient.createUserWebhook({
|
||||
parent: currentUser.name,
|
||||
webhook: { displayName: state.displayName, url: state.url },
|
||||
});
|
||||
let secret: string | undefined;
|
||||
try {
|
||||
const response = await userServiceClient.getUserWebhookSigningSecret({ name: created.name });
|
||||
secret = response.signingSecret;
|
||||
} catch {
|
||||
// Reveal failed — the secret is still set and can be revealed later from the edit dialog.
|
||||
}
|
||||
requestState.setFinish();
|
||||
if (secret !== undefined) {
|
||||
setCreatedSecret(secret);
|
||||
return;
|
||||
}
|
||||
onSuccess?.();
|
||||
onOpenChange(false);
|
||||
return;
|
||||
}
|
||||
|
||||
await userServiceClient.updateUserWebhook({
|
||||
webhook: { name: webhookName, displayName: state.displayName, url: state.url },
|
||||
updateMask: create(FieldMaskSchema, { paths: ["display_name", "url"] }),
|
||||
});
|
||||
onSuccess?.();
|
||||
onOpenChange(false);
|
||||
requestState.setFinish();
|
||||
} catch (error: unknown) {
|
||||
handleError(error, toast.error, {
|
||||
context: webhookName ? "Update webhook" : "Create webhook",
|
||||
onError: () => requestState.setError(),
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const renderSecretField = (secret: string) => (
|
||||
<div className="flex items-center gap-2">
|
||||
<Input readOnly type="text" value={secret} className="flex-1 font-mono text-xs" />
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="icon"
|
||||
onClick={() => handleCopySecret(secret)}
|
||||
aria-label={t("setting.webhook.create-dialog.copy-secret")}
|
||||
>
|
||||
{secretCopied ? <CheckIcon className="h-4 w-4 text-success" /> : <CopyIcon className="h-4 w-4" />}
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
|
||||
const renderSigningSecret = () => {
|
||||
if (isCreating) {
|
||||
return <span className="text-xs text-muted-foreground">{t("setting.webhook.create-dialog.signing-secret-auto-note")}</span>;
|
||||
}
|
||||
if (revealedSecret !== undefined) {
|
||||
return renderSecretField(revealedSecret);
|
||||
}
|
||||
if (hasExistingSecret) {
|
||||
return (
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="flex-1 text-xs text-muted-foreground">{t("setting.webhook.create-dialog.signing-secret-configured")}</span>
|
||||
<Button type="button" variant="outline" size="sm" onClick={handleRevealSecret} disabled={secretState.isLoading}>
|
||||
<EyeIcon className="mr-1 h-3.5 w-3.5" />
|
||||
{t("setting.webhook.create-dialog.reveal-secret")}
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="flex-1 text-xs text-muted-foreground">{t("setting.webhook.create-dialog.signing-secret-not-configured")}</span>
|
||||
<Button type="button" variant="outline" size="sm" onClick={handleGenerateSecret} disabled={secretState.isLoading}>
|
||||
{t("setting.webhook.create-dialog.generate-secret")}
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={handleOpenChange}>
|
||||
<DialogContent className="max-w-md">
|
||||
<DialogHeader>
|
||||
<DialogTitle>
|
||||
{isCreating ? t("setting.webhook.create-dialog.create-webhook") : t("setting.webhook.create-dialog.edit-webhook")}
|
||||
</DialogTitle>
|
||||
</DialogHeader>
|
||||
{createdSecret !== undefined ? (
|
||||
<div className="grid gap-2">
|
||||
<Label>{t("setting.webhook.create-dialog.signing-secret")}</Label>
|
||||
<span className="text-xs text-muted-foreground">{t("setting.webhook.create-dialog.signing-secret-created-note")}</span>
|
||||
{renderSecretField(createdSecret)}
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex flex-col gap-4">
|
||||
<div className="grid gap-2">
|
||||
<Label htmlFor="displayName">
|
||||
{t("setting.webhook.create-dialog.title")} <span className="text-destructive">*</span>
|
||||
</Label>
|
||||
<Input
|
||||
id="displayName"
|
||||
type="text"
|
||||
placeholder={t("setting.webhook.create-dialog.an-easy-to-remember-name")}
|
||||
value={state.displayName}
|
||||
onChange={handleTitleInputChange}
|
||||
/>
|
||||
</div>
|
||||
<div className="grid gap-2">
|
||||
<Label htmlFor="url">
|
||||
{t("setting.webhook.create-dialog.payload-url")} <span className="text-destructive">*</span>
|
||||
</Label>
|
||||
<Input
|
||||
id="url"
|
||||
type="text"
|
||||
placeholder={t("setting.webhook.create-dialog.url-example-post-receive")}
|
||||
value={state.url}
|
||||
onChange={handleUrlInputChange}
|
||||
/>
|
||||
</div>
|
||||
<div className="grid gap-2">
|
||||
<Label>{t("setting.webhook.create-dialog.signing-secret")}</Label>
|
||||
{renderSigningSecret()}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
<DialogFooter>
|
||||
{createdSecret !== undefined ? (
|
||||
<Button onClick={() => handleOpenChange(false)}>{t("common.close")}</Button>
|
||||
) : (
|
||||
<>
|
||||
<Button variant="ghost" disabled={requestState.isLoading} onClick={() => onOpenChange(false)}>
|
||||
{t("common.cancel")}
|
||||
</Button>
|
||||
<Button disabled={requestState.isLoading} onClick={handleSaveBtnClick}>
|
||||
{isCreating ? t("common.create") : t("common.save")}
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
|
||||
export default CreateWebhookDialog;
|
||||
@@ -0,0 +1,43 @@
|
||||
import dayjs from "dayjs";
|
||||
import toast from "react-hot-toast";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
// must be compatible with JS Date.parse(), we use ISO 8601 (almost)
|
||||
const DATE_TIME_FORMAT = "YYYY-MM-DD HH:mm:ss";
|
||||
|
||||
// convert Date to datetime string.
|
||||
const formatDate = (date: Date): string => {
|
||||
return dayjs(date).format(DATE_TIME_FORMAT);
|
||||
};
|
||||
|
||||
interface Props {
|
||||
value: Date;
|
||||
onChange: (date: Date) => void;
|
||||
}
|
||||
|
||||
const DateTimeInput: React.FC<Props> = ({ value, onChange }) => {
|
||||
return (
|
||||
<input
|
||||
type="datetime-local"
|
||||
className={cn("px-1 bg-transparent rounded text-xs transition-all", "border-transparent outline-none focus:border-border", "border")}
|
||||
defaultValue={formatDate(value)}
|
||||
onBlur={(e) => {
|
||||
const inputValue = e.target.value;
|
||||
if (inputValue) {
|
||||
// note: inputValue must be compatible with JS Date.parse()
|
||||
const date = dayjs(inputValue).toDate();
|
||||
// Check if the date is valid.
|
||||
if (!isNaN(date.getTime())) {
|
||||
onChange(date);
|
||||
} else {
|
||||
toast.error("Invalid datetime format. Use format: 2023-12-31 23:59:59");
|
||||
e.target.value = formatDate(value);
|
||||
}
|
||||
}
|
||||
}}
|
||||
placeholder={DATE_TIME_FORMAT}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
export default DateTimeInput;
|
||||
@@ -0,0 +1,103 @@
|
||||
import { AlertCircle, RefreshCw } from "lucide-react";
|
||||
import { Component, type ErrorInfo, type ReactNode } from "react";
|
||||
import { useRouteError } from "react-router-dom";
|
||||
import { Button } from "./ui/button";
|
||||
|
||||
interface Props {
|
||||
children: ReactNode;
|
||||
fallback?: ReactNode;
|
||||
}
|
||||
|
||||
interface State {
|
||||
hasError: boolean;
|
||||
error: Error | null;
|
||||
}
|
||||
|
||||
export class ErrorBoundary extends Component<Props, State> {
|
||||
constructor(props: Props) {
|
||||
super(props);
|
||||
this.state = { hasError: false, error: null };
|
||||
}
|
||||
|
||||
static getDerivedStateFromError(error: Error): State {
|
||||
return { hasError: true, error };
|
||||
}
|
||||
|
||||
componentDidCatch(error: Error, errorInfo: ErrorInfo) {
|
||||
console.error("ErrorBoundary caught an error:", error, errorInfo);
|
||||
}
|
||||
|
||||
handleReset = () => {
|
||||
this.setState({ hasError: false, error: null });
|
||||
window.location.reload();
|
||||
};
|
||||
|
||||
render() {
|
||||
if (this.state.hasError) {
|
||||
if (this.props.fallback) {
|
||||
return this.props.fallback;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex items-center justify-center min-h-screen bg-background">
|
||||
<div className="max-w-md w-full p-6 space-y-4">
|
||||
<div className="flex items-center gap-3 text-destructive">
|
||||
<AlertCircle className="w-8 h-8" />
|
||||
<h1 className="text-2xl font-bold">Something went wrong</h1>
|
||||
</div>
|
||||
|
||||
<p className="text-foreground/70">
|
||||
An unexpected error occurred. This could be due to a network issue or a problem with the application.
|
||||
</p>
|
||||
|
||||
{this.state.error && (
|
||||
<details className="bg-muted p-3 rounded-md text-sm">
|
||||
<summary className="cursor-pointer font-medium mb-2">Error details</summary>
|
||||
<pre className="whitespace-pre-wrap break-words text-xs text-foreground/60">{this.state.error.message}</pre>
|
||||
</details>
|
||||
)}
|
||||
|
||||
<Button onClick={this.handleReset} className="w-full gap-2">
|
||||
<RefreshCw className="w-4 h-4" />
|
||||
Reload Application
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return this.props.children;
|
||||
}
|
||||
}
|
||||
|
||||
// React Router errorElement for route-level errors (e.g., failed chunk loads after redeployment).
|
||||
export function ChunkLoadErrorFallback() {
|
||||
const error = useRouteError() as Error | undefined;
|
||||
|
||||
return (
|
||||
<div className="flex items-center justify-center min-h-screen bg-background">
|
||||
<div className="max-w-md w-full p-6 space-y-4">
|
||||
<div className="flex items-center gap-3 text-destructive">
|
||||
<AlertCircle className="w-8 h-8" />
|
||||
<h1 className="text-2xl font-bold">Something went wrong</h1>
|
||||
</div>
|
||||
|
||||
<p className="text-foreground/70">
|
||||
An unexpected error occurred. This could be due to a network issue or an application update. Reloading usually fixes it.
|
||||
</p>
|
||||
|
||||
{error?.message && (
|
||||
<details className="bg-muted p-3 rounded-md text-sm">
|
||||
<summary className="cursor-pointer font-medium mb-2">Error details</summary>
|
||||
<pre className="whitespace-pre-wrap break-words text-xs text-foreground/60">{error.message}</pre>
|
||||
</details>
|
||||
)}
|
||||
|
||||
<Button onClick={() => window.location.reload()} className="w-full gap-2">
|
||||
<RefreshCw className="w-4 h-4" />
|
||||
Reload Application
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,158 @@
|
||||
import { create } from "@bufbuild/protobuf";
|
||||
import { FieldMaskSchema, timestampDate } from "@bufbuild/protobuf/wkt";
|
||||
import { CheckIcon, MessageCircleIcon, TrashIcon, XIcon } from "lucide-react";
|
||||
import toast from "react-hot-toast";
|
||||
import UserAvatar from "@/components/UserAvatar";
|
||||
import { userServiceClient } from "@/connect";
|
||||
import useNavigateTo from "@/hooks/useNavigateTo";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { UserNotification, UserNotification_Status } from "@/types/proto/api/v1/user_service_pb";
|
||||
import { useTranslate } from "@/utils/i18n";
|
||||
|
||||
interface Props {
|
||||
notification: UserNotification;
|
||||
}
|
||||
|
||||
function MemoCommentMessage({ notification }: Props) {
|
||||
const t = useTranslate();
|
||||
const navigateTo = useNavigateTo();
|
||||
const commentPayload = notification.payload?.case === "memoComment" ? notification.payload.value : undefined;
|
||||
const sender = notification.senderUser;
|
||||
|
||||
const handleArchiveMessage = async (silence = false) => {
|
||||
await userServiceClient.updateUserNotification({
|
||||
notification: {
|
||||
name: notification.name,
|
||||
status: UserNotification_Status.ARCHIVED,
|
||||
},
|
||||
updateMask: create(FieldMaskSchema, { paths: ["status"] }),
|
||||
});
|
||||
if (!silence) {
|
||||
toast.success(t("message.archived-successfully"));
|
||||
}
|
||||
};
|
||||
|
||||
const handleDeleteMessage = async () => {
|
||||
await userServiceClient.deleteUserNotification({
|
||||
name: notification.name,
|
||||
});
|
||||
toast.success(t("message.deleted-successfully"));
|
||||
};
|
||||
|
||||
if (!commentPayload) {
|
||||
return (
|
||||
<div className="w-full px-5 py-4 border-b border-border/60 last:border-b-0 bg-destructive/[0.04] group">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="w-10 h-10 rounded-full bg-destructive/15 flex items-center justify-center shrink-0 ring-1 ring-destructive/20">
|
||||
<XIcon className="w-5 h-5 text-destructive" strokeWidth={2} />
|
||||
</div>
|
||||
<span className="text-sm text-destructive/80 font-medium">{t("inbox.failed-to-load")}</span>
|
||||
</div>
|
||||
<button
|
||||
onClick={handleDeleteMessage}
|
||||
className="p-1.5 hover:bg-destructive/15 rounded-lg transition-all duration-150 opacity-0 group-hover:opacity-100"
|
||||
title={t("common.delete")}
|
||||
>
|
||||
<TrashIcon className="w-4 h-4 text-destructive/70 hover:text-destructive transition-colors" strokeWidth={2} />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const isUnread = notification.status === UserNotification_Status.UNREAD;
|
||||
|
||||
const handleNavigateToMemo = async () => {
|
||||
navigateTo(`/${commentPayload.relatedMemo}`);
|
||||
if (isUnread) {
|
||||
await handleArchiveMessage(true);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"w-full px-5 py-4 border-b border-border/60 last:border-b-0 transition-all duration-200 group relative",
|
||||
isUnread ? "bg-primary/[0.03] hover:bg-primary/[0.05]" : "hover:bg-muted/30",
|
||||
)}
|
||||
>
|
||||
{isUnread && <div className="absolute left-0 top-0 bottom-0 w-0.5 bg-gradient-to-b from-primary to-primary/60" />}
|
||||
|
||||
<div className="flex items-start gap-3">
|
||||
<div className="relative shrink-0">
|
||||
<UserAvatar className="w-10 h-10 ring-1 ring-border/40" avatarUrl={sender?.avatarUrl} />
|
||||
<div
|
||||
className={cn(
|
||||
"absolute -bottom-1 -right-1 w-5 h-5 rounded-full border-2 border-background flex items-center justify-center shadow-md transition-all",
|
||||
isUnread ? "bg-primary text-primary-foreground" : "bg-muted/80 text-muted-foreground",
|
||||
)}
|
||||
>
|
||||
<MessageCircleIcon className="w-2.5 h-2.5" strokeWidth={2.5} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center justify-between gap-3 mb-1">
|
||||
<div className="flex items-center gap-1.5 flex-wrap min-w-0">
|
||||
<span className="font-semibold text-sm text-foreground/95">{sender?.displayName || sender?.username}</span>
|
||||
<span className="text-sm text-muted-foreground/80">commented on your memo</span>
|
||||
<span className="text-xs text-muted-foreground/60">
|
||||
{notification.createTime &&
|
||||
timestampDate(notification.createTime)?.toLocaleDateString([], { month: "short", day: "numeric" })}{" "}
|
||||
at{" "}
|
||||
{notification.createTime &&
|
||||
timestampDate(notification.createTime)?.toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" })}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-1 shrink-0">
|
||||
{isUnread ? (
|
||||
<button
|
||||
onClick={() => handleArchiveMessage()}
|
||||
className="p-1.5 hover:bg-primary/10 rounded-lg transition-all duration-150 opacity-0 group-hover:opacity-100"
|
||||
title={t("common.archive")}
|
||||
>
|
||||
<CheckIcon className="w-4 h-4 text-muted-foreground hover:text-primary transition-colors" strokeWidth={2} />
|
||||
</button>
|
||||
) : (
|
||||
<button
|
||||
onClick={handleDeleteMessage}
|
||||
className="p-1.5 hover:bg-destructive/10 rounded-lg transition-all duration-150 opacity-0 group-hover:opacity-100"
|
||||
title={t("common.delete")}
|
||||
>
|
||||
<TrashIcon className="w-4 h-4 text-muted-foreground hover:text-destructive transition-colors" strokeWidth={2} />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="pl-3 border-l-2 border-muted-foreground/20 mb-3">
|
||||
<p className="text-sm text-foreground/60 line-clamp-1 leading-relaxed">
|
||||
<span className="text-xs text-muted-foreground/50 font-medium mr-2 uppercase tracking-wide">Original:</span>
|
||||
{commentPayload.relatedMemoSnippet || <span className="italic text-muted-foreground/40">Empty memo</span>}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div
|
||||
onClick={handleNavigateToMemo}
|
||||
className="p-2 sm:p-3 rounded-lg bg-gradient-to-br from-primary/[0.06] to-primary/[0.03] hover:from-primary/[0.1] hover:to-primary/[0.06] cursor-pointer border border-primary/30 hover:border-primary/50 transition-all duration-200 group/comment shadow-sm hover:shadow"
|
||||
>
|
||||
<div className="flex items-start gap-2">
|
||||
<div className="w-5 h-5 flex items-center justify-center shrink-0">
|
||||
<MessageCircleIcon className="w-4 h-4 text-primary" />
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className="text-xs text-primary/60 font-semibold mb-1 uppercase tracking-wider">Comment</p>
|
||||
<p className="text-sm text-foreground/90 line-clamp-2">
|
||||
{commentPayload.memoSnippet || <span className="italic text-muted-foreground/50">Empty comment</span>}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default MemoCommentMessage;
|
||||
@@ -0,0 +1,164 @@
|
||||
import { create } from "@bufbuild/protobuf";
|
||||
import { FieldMaskSchema, timestampDate } from "@bufbuild/protobuf/wkt";
|
||||
import { AtSignIcon, CheckIcon, MessageSquareIcon, TrashIcon, XIcon } from "lucide-react";
|
||||
import toast from "react-hot-toast";
|
||||
import UserAvatar from "@/components/UserAvatar";
|
||||
import { userServiceClient } from "@/connect";
|
||||
import useNavigateTo from "@/hooks/useNavigateTo";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { UserNotification, UserNotification_Status } from "@/types/proto/api/v1/user_service_pb";
|
||||
import { useTranslate } from "@/utils/i18n";
|
||||
|
||||
interface Props {
|
||||
notification: UserNotification;
|
||||
}
|
||||
|
||||
function MemoMentionMessage({ notification }: Props) {
|
||||
const t = useTranslate();
|
||||
const navigateTo = useNavigateTo();
|
||||
const mentionPayload = notification.payload?.case === "memoMention" ? notification.payload.value : undefined;
|
||||
const sender = notification.senderUser;
|
||||
|
||||
const handleArchiveMessage = async (silence = false) => {
|
||||
await userServiceClient.updateUserNotification({
|
||||
notification: {
|
||||
name: notification.name,
|
||||
status: UserNotification_Status.ARCHIVED,
|
||||
},
|
||||
updateMask: create(FieldMaskSchema, { paths: ["status"] }),
|
||||
});
|
||||
if (!silence) {
|
||||
toast.success(t("message.archived-successfully"));
|
||||
}
|
||||
};
|
||||
|
||||
const handleDeleteMessage = async () => {
|
||||
await userServiceClient.deleteUserNotification({
|
||||
name: notification.name,
|
||||
});
|
||||
toast.success(t("message.deleted-successfully"));
|
||||
};
|
||||
|
||||
if (!mentionPayload) {
|
||||
return (
|
||||
<div className="w-full px-5 py-4 border-b border-border/60 last:border-b-0 bg-destructive/[0.04] group">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="w-10 h-10 rounded-full bg-destructive/15 flex items-center justify-center shrink-0 ring-1 ring-destructive/20">
|
||||
<XIcon className="w-5 h-5 text-destructive" strokeWidth={2} />
|
||||
</div>
|
||||
<span className="text-sm text-destructive/80 font-medium">{t("inbox.failed-to-load")}</span>
|
||||
</div>
|
||||
<button
|
||||
onClick={handleDeleteMessage}
|
||||
className="p-1.5 hover:bg-destructive/15 rounded-lg transition-all duration-150 opacity-0 group-hover:opacity-100"
|
||||
title={t("common.delete")}
|
||||
>
|
||||
<TrashIcon className="w-4 h-4 text-destructive/70 hover:text-destructive transition-colors" strokeWidth={2} />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const isUnread = notification.status === UserNotification_Status.UNREAD;
|
||||
const isCommentMention = Boolean(mentionPayload.relatedMemo);
|
||||
const targetName = mentionPayload.relatedMemo || mentionPayload.memo;
|
||||
|
||||
const handleNavigate = async () => {
|
||||
navigateTo(`/${targetName}`);
|
||||
if (isUnread) {
|
||||
await handleArchiveMessage(true);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"w-full px-5 py-4 border-b border-border/60 last:border-b-0 transition-all duration-200 group relative",
|
||||
isUnread ? "bg-primary/[0.03] hover:bg-primary/[0.05]" : "hover:bg-muted/30",
|
||||
)}
|
||||
>
|
||||
{isUnread && <div className="absolute left-0 top-0 bottom-0 w-0.5 bg-gradient-to-b from-primary to-primary/60" />}
|
||||
|
||||
<div className="flex items-start gap-3">
|
||||
<div className="relative shrink-0">
|
||||
<UserAvatar className="w-10 h-10 ring-1 ring-border/40" avatarUrl={sender?.avatarUrl} />
|
||||
<div
|
||||
className={cn(
|
||||
"absolute -bottom-1 -right-1 w-5 h-5 rounded-full border-2 border-background flex items-center justify-center shadow-md transition-all",
|
||||
isUnread ? "bg-primary text-primary-foreground" : "bg-muted/80 text-muted-foreground",
|
||||
)}
|
||||
>
|
||||
<AtSignIcon className="w-2.5 h-2.5" strokeWidth={2.5} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center justify-between gap-3 mb-1">
|
||||
<div className="flex items-center gap-1.5 flex-wrap min-w-0">
|
||||
<span className="font-semibold text-sm text-foreground/95">{sender?.displayName || sender?.username}</span>
|
||||
<span className="text-sm text-muted-foreground/80">mentioned you {isCommentMention ? "in a comment" : "in a memo"}</span>
|
||||
<span className="text-xs text-muted-foreground/60">
|
||||
{notification.createTime &&
|
||||
timestampDate(notification.createTime)?.toLocaleDateString([], { month: "short", day: "numeric" })}{" "}
|
||||
at{" "}
|
||||
{notification.createTime &&
|
||||
timestampDate(notification.createTime)?.toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" })}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-1 shrink-0">
|
||||
{isUnread ? (
|
||||
<button
|
||||
onClick={() => handleArchiveMessage()}
|
||||
className="p-1.5 hover:bg-primary/10 rounded-lg transition-all duration-150 opacity-0 group-hover:opacity-100"
|
||||
title={t("common.archive")}
|
||||
>
|
||||
<CheckIcon className="w-4 h-4 text-muted-foreground hover:text-primary transition-colors" strokeWidth={2} />
|
||||
</button>
|
||||
) : (
|
||||
<button
|
||||
onClick={handleDeleteMessage}
|
||||
className="p-1.5 hover:bg-destructive/10 rounded-lg transition-all duration-150 opacity-0 group-hover:opacity-100"
|
||||
title={t("common.delete")}
|
||||
>
|
||||
<TrashIcon className="w-4 h-4 text-muted-foreground hover:text-destructive transition-colors" strokeWidth={2} />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{mentionPayload.relatedMemo && (
|
||||
<div className="pl-3 border-l-2 border-muted-foreground/20 mb-3">
|
||||
<p className="text-sm text-foreground/60 line-clamp-1 leading-relaxed">
|
||||
<span className="text-xs text-muted-foreground/50 font-medium mr-2 uppercase tracking-wide">Memo:</span>
|
||||
{mentionPayload.relatedMemoSnippet || <span className="italic text-muted-foreground/40">Empty memo</span>}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div
|
||||
onClick={handleNavigate}
|
||||
className="p-2 sm:p-3 rounded-lg bg-gradient-to-br from-primary/[0.06] to-primary/[0.03] hover:from-primary/[0.1] hover:to-primary/[0.06] cursor-pointer border border-primary/30 hover:border-primary/50 transition-all duration-200 group/comment shadow-sm hover:shadow"
|
||||
>
|
||||
<div className="flex items-start gap-2">
|
||||
<div className="w-5 h-5 flex items-center justify-center shrink-0">
|
||||
<MessageSquareIcon className="w-4 h-4 text-primary" />
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className="text-xs text-primary/60 font-semibold mb-1 uppercase tracking-wider">
|
||||
{isCommentMention ? "Comment" : "Memo"}
|
||||
</p>
|
||||
<p className="text-sm text-foreground/90 line-clamp-2">
|
||||
{mentionPayload.memoSnippet || <span className="italic text-muted-foreground/50">Empty memo</span>}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default MemoMentionMessage;
|
||||
@@ -0,0 +1,31 @@
|
||||
import { ExternalLinkIcon } from "lucide-react";
|
||||
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/components/ui/tooltip";
|
||||
import { useTranslate } from "@/utils/i18n";
|
||||
|
||||
interface Props {
|
||||
className?: string;
|
||||
url: string;
|
||||
title?: string;
|
||||
}
|
||||
|
||||
const LearnMore: React.FC<Props> = (props: Props) => {
|
||||
const { className, url, title } = props;
|
||||
const t = useTranslate();
|
||||
|
||||
return (
|
||||
<TooltipProvider>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<a className={`text-muted-foreground hover:text-primary ${className}`} href={url} target="_blank">
|
||||
<ExternalLinkIcon className="w-4 h-auto" />
|
||||
</a>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
<p>{title ?? t("common.learn-more")}</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
);
|
||||
};
|
||||
|
||||
export default LearnMore;
|
||||
@@ -0,0 +1,117 @@
|
||||
import { CheckIcon, ChevronDownIcon, GlobeIcon, SearchIcon } from "lucide-react";
|
||||
import { useMemo, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
|
||||
import { locales } from "@/i18n";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { getLocaleDisplayName, localeMatchesSearch, useTranslate } from "@/utils/i18n";
|
||||
|
||||
const MISSING_LANGUAGE_FEEDBACK_URL =
|
||||
"https://github.com/usememos/memos/issues/new?title=Missing%20language%20support&body=Please%20add%20support%20for%20this%20language%3A%0A%0A-%20Language%3A%20";
|
||||
|
||||
interface LocaleSearchListProps {
|
||||
value: Locale;
|
||||
onChange: (locale: Locale) => void;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export const LocaleSearchList = (props: LocaleSearchListProps) => {
|
||||
const { value, onChange, className } = props;
|
||||
const t = useTranslate();
|
||||
const { i18n } = useTranslation();
|
||||
const [query, setQuery] = useState("");
|
||||
const filteredLocales = useMemo(
|
||||
() => locales.filter((locale) => localeMatchesSearch(locale, query, i18n.language)),
|
||||
[i18n.language, query],
|
||||
);
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"flex max-h-[min(24rem,calc(100vh-2rem))] w-[var(--radix-popover-trigger-width)] min-w-48 max-w-[calc(100vw-2rem)] flex-col",
|
||||
className,
|
||||
)}
|
||||
>
|
||||
<div className="relative shrink-0 p-1">
|
||||
<SearchIcon className="absolute top-1/2 left-3 size-4 -translate-y-1/2 text-muted-foreground" />
|
||||
<Input
|
||||
value={query}
|
||||
onChange={(event) => setQuery(event.target.value)}
|
||||
onKeyDown={(event) => event.stopPropagation()}
|
||||
className="pl-8"
|
||||
placeholder={`${t("common.search")} ${t("common.language").toLowerCase()}`}
|
||||
aria-label={t("common.language")}
|
||||
/>
|
||||
</div>
|
||||
<div className="mt-1 min-h-0 flex-1 touch-pan-y overflow-y-auto p-1" role="listbox" aria-label={t("common.language")}>
|
||||
{filteredLocales.map((locale) => (
|
||||
<div
|
||||
key={locale}
|
||||
role="option"
|
||||
aria-selected={value === locale}
|
||||
className={cn(
|
||||
"flex h-8 w-full cursor-pointer items-center gap-2 rounded-sm px-2 text-left text-sm outline-hidden transition-colors hover:bg-accent hover:text-accent-foreground focus:bg-accent focus:text-accent-foreground",
|
||||
value === locale && "bg-accent/60",
|
||||
)}
|
||||
onClick={() => onChange(locale)}
|
||||
>
|
||||
<span className="flex min-w-0 flex-1 items-baseline gap-1.5">
|
||||
<span className="truncate">{getLocaleDisplayName(locale)}</span>
|
||||
<span className="shrink-0 text-xs text-muted-foreground">{locale}</span>
|
||||
</span>
|
||||
{value === locale ? <CheckIcon className="size-4 shrink-0 text-primary" /> : <span className="size-4 shrink-0" />}
|
||||
</div>
|
||||
))}
|
||||
{filteredLocales.length === 0 && (
|
||||
<div className="px-2 py-6 text-center text-sm">
|
||||
<a
|
||||
href={MISSING_LANGUAGE_FEEDBACK_URL}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
className="text-primary underline-offset-4 hover:underline"
|
||||
>
|
||||
{t("locale-picker.no-language-feedback")}
|
||||
</a>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
interface LocalePickerProps {
|
||||
value: Locale;
|
||||
onChange: (locale: Locale) => void;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
const LocalePicker = (props: LocalePickerProps) => {
|
||||
const { value, onChange, className } = props;
|
||||
const [open, setOpen] = useState(false);
|
||||
|
||||
const handleChange = (locale: Locale) => {
|
||||
onChange(locale);
|
||||
setOpen(false);
|
||||
};
|
||||
|
||||
return (
|
||||
<Popover open={open} onOpenChange={setOpen}>
|
||||
<PopoverTrigger asChild>
|
||||
<Button type="button" variant="outline" className={cn("w-full justify-between", className)}>
|
||||
<span className="flex min-w-0 flex-1 items-center gap-2">
|
||||
<GlobeIcon className="size-4 shrink-0 text-muted-foreground" />
|
||||
<span className="truncate">{getLocaleDisplayName(value)}</span>
|
||||
</span>
|
||||
<ChevronDownIcon className="size-4 shrink-0 text-muted-foreground" />
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent align="start" className="p-0">
|
||||
<LocaleSearchList value={value} onChange={handleChange} />
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
);
|
||||
};
|
||||
|
||||
export default LocalePicker;
|
||||
@@ -0,0 +1,24 @@
|
||||
import { FC } from "react";
|
||||
import LocalePicker from "@/components/LocalePicker";
|
||||
import { loadLocale } from "@/utils/i18n";
|
||||
|
||||
interface Props {
|
||||
value: Locale;
|
||||
onChange: (locale: Locale) => void;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
const LocaleSelect: FC<Props> = (props: Props) => {
|
||||
const { className, onChange, value } = props;
|
||||
|
||||
const handleSelectChange = async (locale: Locale) => {
|
||||
// Apply locale globally immediately
|
||||
loadLocale(locale);
|
||||
// Also notify parent component
|
||||
onChange(locale);
|
||||
};
|
||||
|
||||
return <LocalePicker value={value} onChange={handleSelectChange} className={className} />;
|
||||
};
|
||||
|
||||
export default LocaleSelect;
|
||||
@@ -0,0 +1,165 @@
|
||||
import {
|
||||
ArchiveIcon,
|
||||
ArchiveRestoreIcon,
|
||||
BookmarkMinusIcon,
|
||||
BookmarkPlusIcon,
|
||||
CheckCheckIcon,
|
||||
CopyIcon,
|
||||
Edit3Icon,
|
||||
FileTextIcon,
|
||||
LinkIcon,
|
||||
ListChecksIcon,
|
||||
ListRestartIcon,
|
||||
MoreVerticalIcon,
|
||||
TrashIcon,
|
||||
} from "lucide-react";
|
||||
import { useState } from "react";
|
||||
import ConfirmDialog from "@/components/ConfirmDialog";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuSub,
|
||||
DropdownMenuSubContent,
|
||||
DropdownMenuSubTrigger,
|
||||
DropdownMenuTrigger,
|
||||
} from "@/components/ui/dropdown-menu";
|
||||
import { State } from "@/types/proto/api/v1/common_pb";
|
||||
import { useTranslate } from "@/utils/i18n";
|
||||
import { countTasks } from "@/utils/markdown-manipulation";
|
||||
import { useMemoActionHandlers } from "./hooks";
|
||||
import type { MemoActionMenuProps } from "./types";
|
||||
|
||||
const MemoActionMenu = (props: MemoActionMenuProps) => {
|
||||
const { memo, readonly } = props;
|
||||
const t = useTranslate();
|
||||
|
||||
// Dialog state
|
||||
const [deleteDialogOpen, setDeleteDialogOpen] = useState(false);
|
||||
|
||||
// Derived state
|
||||
const isComment = Boolean(memo.parent);
|
||||
const isArchived = memo.state === State.ARCHIVED;
|
||||
const taskStats = countTasks(memo.content);
|
||||
const canMutateTasks = !readonly && !isArchived && taskStats.total > 0;
|
||||
const hasOpenTasks = taskStats.completed < taskStats.total;
|
||||
const hasCompletedTasks = taskStats.completed > 0;
|
||||
|
||||
// Action handlers
|
||||
const {
|
||||
handleTogglePinMemoBtnClick,
|
||||
handleEditMemoClick,
|
||||
handleToggleMemoStatusClick,
|
||||
handleCopyLink,
|
||||
handleCopyContent,
|
||||
handleCheckAllTaskListItemsClick,
|
||||
handleUncheckAllTaskListItemsClick,
|
||||
handleDeleteMemoClick,
|
||||
confirmDeleteMemo,
|
||||
} = useMemoActionHandlers({
|
||||
memo,
|
||||
onEdit: props.onEdit,
|
||||
setDeleteDialogOpen,
|
||||
});
|
||||
|
||||
return (
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button variant="ghost" size="icon" className="size-4">
|
||||
<MoreVerticalIcon className="text-muted-foreground" />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end" sideOffset={2}>
|
||||
{/* Edit actions (non-readonly, non-archived) */}
|
||||
{!readonly && !isArchived && (
|
||||
<>
|
||||
{!isComment && (
|
||||
<DropdownMenuItem onClick={handleTogglePinMemoBtnClick}>
|
||||
{memo.pinned ? <BookmarkMinusIcon className="w-4 h-auto" /> : <BookmarkPlusIcon className="w-4 h-auto" />}
|
||||
{memo.pinned ? t("common.unpin") : t("common.pin")}
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
<DropdownMenuItem onClick={handleEditMemoClick}>
|
||||
<Edit3Icon className="w-4 h-auto" />
|
||||
{t("common.edit")}
|
||||
</DropdownMenuItem>
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* Copy submenu (non-archived) */}
|
||||
{!isArchived && (
|
||||
<DropdownMenuSub>
|
||||
<DropdownMenuSubTrigger>
|
||||
<CopyIcon className="w-4 h-auto" />
|
||||
{t("common.copy")}
|
||||
</DropdownMenuSubTrigger>
|
||||
<DropdownMenuSubContent>
|
||||
<DropdownMenuItem onClick={handleCopyLink}>
|
||||
<LinkIcon className="w-4 h-auto" />
|
||||
{t("memo.copy-link")}
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem onClick={handleCopyContent}>
|
||||
<FileTextIcon className="w-4 h-auto" />
|
||||
{t("memo.copy-content")}
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuSubContent>
|
||||
</DropdownMenuSub>
|
||||
)}
|
||||
|
||||
{/* Task submenu (writable task memos) */}
|
||||
{canMutateTasks && (
|
||||
<DropdownMenuSub>
|
||||
<DropdownMenuSubTrigger>
|
||||
<ListChecksIcon className="w-4 h-auto" />
|
||||
{t("memo.task-actions.title")}
|
||||
</DropdownMenuSubTrigger>
|
||||
<DropdownMenuSubContent>
|
||||
<DropdownMenuItem disabled={!hasOpenTasks} onClick={handleCheckAllTaskListItemsClick}>
|
||||
<CheckCheckIcon className="w-4 h-auto" />
|
||||
{t("memo.task-actions.check-all")}
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem disabled={!hasCompletedTasks} onClick={handleUncheckAllTaskListItemsClick}>
|
||||
<ListRestartIcon className="w-4 h-auto" />
|
||||
{t("memo.task-actions.uncheck-all")}
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuSubContent>
|
||||
</DropdownMenuSub>
|
||||
)}
|
||||
|
||||
{/* Write actions (non-readonly) */}
|
||||
{!readonly && (
|
||||
<>
|
||||
{/* Archive/Restore (non-comment) */}
|
||||
{!isComment && (
|
||||
<DropdownMenuItem onClick={handleToggleMemoStatusClick}>
|
||||
{isArchived ? <ArchiveRestoreIcon className="w-4 h-auto" /> : <ArchiveIcon className="w-4 h-auto" />}
|
||||
{isArchived ? t("common.restore") : t("common.archive")}
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
|
||||
{/* Delete */}
|
||||
<DropdownMenuItem onClick={handleDeleteMemoClick}>
|
||||
<TrashIcon className="w-4 h-auto" />
|
||||
{t("common.delete")}
|
||||
</DropdownMenuItem>
|
||||
</>
|
||||
)}
|
||||
</DropdownMenuContent>
|
||||
|
||||
{/* Delete confirmation dialog */}
|
||||
<ConfirmDialog
|
||||
open={deleteDialogOpen}
|
||||
onOpenChange={setDeleteDialogOpen}
|
||||
title={t("memo.delete-confirm")}
|
||||
confirmLabel={t("common.delete")}
|
||||
description={t("memo.delete-confirm-description")}
|
||||
cancelLabel={t("common.cancel")}
|
||||
onConfirm={confirmDeleteMemo}
|
||||
confirmVariant="destructive"
|
||||
/>
|
||||
</DropdownMenu>
|
||||
);
|
||||
};
|
||||
|
||||
export default MemoActionMenu;
|
||||
@@ -0,0 +1,137 @@
|
||||
import { DownloadIcon, ImageIcon, Loader2Icon, Share2Icon } from "lucide-react";
|
||||
import { useCallback, useMemo, useRef, useState } from "react";
|
||||
import { toast } from "react-hot-toast";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle } from "@/components/ui/dialog";
|
||||
import { useTranslate } from "@/utils/i18n";
|
||||
import { useMemoViewContext } from "../MemoView/MemoViewContext";
|
||||
import MemoShareImagePreview from "./MemoShareImagePreview";
|
||||
import {
|
||||
buildMemoShareImageFileName,
|
||||
createMemoShareImageBlob,
|
||||
getMemoShareDialogWidth,
|
||||
getMemoSharePreviewWidth,
|
||||
getMemoShareRenderWidth,
|
||||
} from "./memoShareImage";
|
||||
|
||||
interface MemoShareImageDialogProps {
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
}
|
||||
|
||||
const MemoShareImageDialog = ({ open, onOpenChange }: MemoShareImageDialogProps) => {
|
||||
const t = useTranslate();
|
||||
const { memo, cardWidth } = useMemoViewContext();
|
||||
const previewRef = useRef<HTMLDivElement>(null);
|
||||
const [isRendering, setIsRendering] = useState(false);
|
||||
|
||||
const previewWidth = useMemo(() => getMemoSharePreviewWidth(cardWidth), [cardWidth]);
|
||||
const dialogWidth = useMemo(() => getMemoShareDialogWidth(previewWidth), [previewWidth]);
|
||||
const previewRenderWidth = useMemo(() => getMemoShareRenderWidth(previewWidth, dialogWidth), [dialogWidth, previewWidth]);
|
||||
|
||||
const createShareBlob = useCallback(async () => {
|
||||
const preview = previewRef.current;
|
||||
if (!preview) {
|
||||
throw new Error("Preview is not ready");
|
||||
}
|
||||
|
||||
return createMemoShareImageBlob(preview);
|
||||
}, []);
|
||||
|
||||
const handleDownload = useCallback(async () => {
|
||||
setIsRendering(true);
|
||||
try {
|
||||
const blob = await createShareBlob();
|
||||
const url = URL.createObjectURL(blob);
|
||||
const anchor = document.createElement("a");
|
||||
anchor.href = url;
|
||||
anchor.download = buildMemoShareImageFileName(memo.name);
|
||||
anchor.click();
|
||||
URL.revokeObjectURL(url);
|
||||
toast.success(t("memo.share.image-downloaded"));
|
||||
} catch {
|
||||
toast.error(t("memo.share.image-download-failed"));
|
||||
} finally {
|
||||
setIsRendering(false);
|
||||
}
|
||||
}, [createShareBlob, memo.name, t]);
|
||||
|
||||
const handleNativeShare = useCallback(async () => {
|
||||
if (typeof navigator.share !== "function") {
|
||||
return;
|
||||
}
|
||||
|
||||
setIsRendering(true);
|
||||
try {
|
||||
const blob = await createShareBlob();
|
||||
const file = new File([blob], buildMemoShareImageFileName(memo.name), { type: "image/png" });
|
||||
if (typeof navigator.canShare === "function" && !navigator.canShare({ files: [file] })) {
|
||||
toast.error(t("memo.share.image-share-failed"));
|
||||
return;
|
||||
}
|
||||
|
||||
await navigator.share({
|
||||
files: [file],
|
||||
title: memo.content.slice(0, 60),
|
||||
});
|
||||
} catch (error) {
|
||||
if (!(error instanceof DOMException && error.name === "AbortError")) {
|
||||
toast.error(t("memo.share.image-share-failed"));
|
||||
}
|
||||
} finally {
|
||||
setIsRendering(false);
|
||||
}
|
||||
}, [createShareBlob, memo.content, memo.name, t]);
|
||||
|
||||
const supportsNativeShare =
|
||||
typeof navigator !== "undefined" && typeof navigator.share === "function" && typeof navigator.canShare === "function";
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent
|
||||
size="full"
|
||||
className="min-h-0 overflow-hidden !gap-0 !p-0 md:w-auto md:max-w-none"
|
||||
style={{ width: `${dialogWidth}px` }}
|
||||
>
|
||||
<div className="flex min-h-0 flex-1 flex-col overflow-hidden">
|
||||
<DialogHeader className="shrink-0 border-b border-border/60 px-4 py-3 sm:px-5">
|
||||
<DialogTitle className="flex items-center gap-2 text-base font-medium">
|
||||
<ImageIcon className="h-4 w-4 text-muted-foreground" />
|
||||
{t("memo.share.image-title")}
|
||||
</DialogTitle>
|
||||
<DialogDescription className="text-xs">{t("memo.share.image-description", { width: previewRenderWidth })}</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="relative flex min-h-0 flex-1 items-start justify-center overflow-auto bg-muted/20 px-4 py-3 sm:px-5 sm:py-4">
|
||||
<MemoShareImagePreview ref={previewRef} width={previewRenderWidth} />
|
||||
</div>
|
||||
|
||||
<DialogFooter className="shrink-0 border-t border-border/60 px-4 py-3 sm:px-5">
|
||||
{supportsNativeShare && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
className="text-muted-foreground hover:bg-muted/60 hover:text-foreground"
|
||||
onClick={handleNativeShare}
|
||||
disabled={isRendering}
|
||||
>
|
||||
{isRendering ? <Loader2Icon className="mr-2 h-4 w-4 animate-spin" /> : <Share2Icon className="mr-2 h-4 w-4" />}
|
||||
{t("memo.share.image-share")}
|
||||
</Button>
|
||||
)}
|
||||
<Button
|
||||
variant="outline"
|
||||
className="border-border/70 text-muted-foreground hover:bg-muted/60 hover:text-foreground"
|
||||
onClick={handleDownload}
|
||||
disabled={isRendering}
|
||||
>
|
||||
{isRendering ? <Loader2Icon className="mr-2 h-4 w-4 animate-spin" /> : <DownloadIcon className="mr-2 h-4 w-4" />}
|
||||
{t("memo.share.image-download")}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
};
|
||||
|
||||
export default MemoShareImageDialog;
|
||||
@@ -0,0 +1,87 @@
|
||||
import { forwardRef, useMemo } from "react";
|
||||
import MemoContent from "@/components/MemoContent";
|
||||
import UserAvatar from "@/components/UserAvatar";
|
||||
import i18n from "@/i18n";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { useTranslate } from "@/utils/i18n";
|
||||
import { useMemoViewContext } from "../MemoView/MemoViewContext";
|
||||
import { buildMemoShareImagePreviewModel } from "./memoShareImagePreviewModel";
|
||||
|
||||
const MemoShareImagePreview = forwardRef<HTMLDivElement, { width: number }>(({ width }, ref) => {
|
||||
const t = useTranslate();
|
||||
const { memo, creator, blurred, showBlurredContent } = useMemoViewContext();
|
||||
const fallbackDisplayName = t("common.memo");
|
||||
const locale = i18n.language;
|
||||
|
||||
const preview = useMemo(
|
||||
() =>
|
||||
buildMemoShareImagePreviewModel({
|
||||
memo,
|
||||
creator,
|
||||
fallbackDisplayName,
|
||||
locale,
|
||||
}),
|
||||
[creator, fallbackDisplayName, locale, memo],
|
||||
);
|
||||
|
||||
return (
|
||||
<div ref={ref} className="overflow-hidden rounded-xl border border-border/50 bg-background p-2 sm:p-2.5" style={{ width }}>
|
||||
<div className="overflow-hidden rounded-lg border border-border/60 bg-background p-4 sm:p-5">
|
||||
<div className="flex items-start gap-3">
|
||||
<div className="flex min-w-0 items-center gap-2.5">
|
||||
<UserAvatar avatarUrl={preview.avatarUrl} className="h-8 w-8 rounded-xl" />
|
||||
<div className="min-w-0">
|
||||
<div className="truncate text-[13px] font-semibold text-foreground">{preview.displayName}</div>
|
||||
{preview.formattedDisplayTime && <div className="truncate text-xs text-muted-foreground">{preview.formattedDisplayTime}</div>}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-4">
|
||||
<div className={cn("pointer-events-none", blurred && !showBlurredContent && "blur-lg")}>
|
||||
<MemoContent content={memo.content} compact={false} contentClassName="text-[14px] leading-6.5 sm:text-[15px]" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{preview.visualItems.length > 0 && (
|
||||
<div className={cn("mt-4 grid gap-1.5", preview.visualItems.length === 1 ? "grid-cols-1" : "grid-cols-2")}>
|
||||
{preview.visualItems.slice(0, 4).map((item, index) => (
|
||||
<div
|
||||
key={item.id}
|
||||
className={cn(
|
||||
"relative overflow-hidden rounded-md border border-border/70 bg-muted/30",
|
||||
preview.visualItems.length === 1 ? "aspect-[4/3]" : "aspect-square",
|
||||
preview.visualItems.length === 3 && index === 0 && "col-span-2 aspect-[2.2/1]",
|
||||
)}
|
||||
>
|
||||
<img src={item.posterUrl} alt={item.filename} className="h-full w-full object-cover" loading="eager" decoding="async" />
|
||||
{index === 3 && preview.visualItems.length > 4 && (
|
||||
<div className="absolute inset-0 flex items-center justify-center bg-foreground/35 text-lg font-semibold text-background">
|
||||
+{preview.visualItems.length - 4}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{preview.footerBadges.length > 0 && (
|
||||
<div className="mt-4 flex flex-wrap items-center gap-1.5">
|
||||
{preview.footerBadges.map((badge) => (
|
||||
<span
|
||||
key={badge.type}
|
||||
className="inline-flex rounded-full border border-border/70 bg-muted/55 px-2 py-0.5 text-[11px] text-muted-foreground"
|
||||
>
|
||||
{badge.count} {t("common.attachments").toLowerCase()}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
});
|
||||
|
||||
MemoShareImagePreview.displayName = "MemoShareImagePreview";
|
||||
|
||||
export default MemoShareImagePreview;
|
||||
@@ -0,0 +1,163 @@
|
||||
import { useQueryClient } from "@tanstack/react-query";
|
||||
import copy from "copy-to-clipboard";
|
||||
import { useCallback } from "react";
|
||||
import toast from "react-hot-toast";
|
||||
import { useLocation } from "react-router-dom";
|
||||
import { useInstance } from "@/contexts/InstanceContext";
|
||||
import { memoKeys, useDeleteMemo, useUpdateMemo } from "@/hooks/useMemoQueries";
|
||||
import useNavigateTo from "@/hooks/useNavigateTo";
|
||||
import { userKeys } from "@/hooks/useUserQueries";
|
||||
import { handleError } from "@/lib/error";
|
||||
import { ROUTES } from "@/router/routes";
|
||||
import { State } from "@/types/proto/api/v1/common_pb";
|
||||
import type { Memo } from "@/types/proto/api/v1/memo_service_pb";
|
||||
import { useTranslate } from "@/utils/i18n";
|
||||
import { checkAllTasks, uncheckAllTasks } from "@/utils/markdown-task-actions";
|
||||
|
||||
interface UseMemoActionHandlersOptions {
|
||||
memo: Memo;
|
||||
onEdit?: () => void;
|
||||
setDeleteDialogOpen: (open: boolean) => void;
|
||||
}
|
||||
|
||||
export const useMemoActionHandlers = ({ memo, onEdit, setDeleteDialogOpen }: UseMemoActionHandlersOptions) => {
|
||||
const t = useTranslate();
|
||||
const location = useLocation();
|
||||
const navigateTo = useNavigateTo();
|
||||
const queryClient = useQueryClient();
|
||||
const { profile } = useInstance();
|
||||
const { mutateAsync: updateMemo } = useUpdateMemo();
|
||||
const { mutateAsync: deleteMemo } = useDeleteMemo();
|
||||
const isInMemoDetailPage = location.pathname.startsWith(`/${memo.name}`);
|
||||
|
||||
const memoUpdatedCallback = useCallback(() => {
|
||||
// Invalidate user stats to trigger refetch
|
||||
queryClient.invalidateQueries({ queryKey: userKeys.stats() });
|
||||
}, [queryClient]);
|
||||
|
||||
const updateMemoContent = useCallback(
|
||||
async (nextContent: string, context: string) => {
|
||||
if (nextContent === memo.content) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
await updateMemo({
|
||||
update: {
|
||||
name: memo.name,
|
||||
content: nextContent,
|
||||
},
|
||||
updateMask: ["content", "update_time"],
|
||||
});
|
||||
toast.success(t("memo.task-actions.updated"));
|
||||
} catch (error: unknown) {
|
||||
handleError(error, toast.error, {
|
||||
context,
|
||||
fallbackMessage: "An error occurred",
|
||||
});
|
||||
}
|
||||
},
|
||||
[memo.content, memo.name, t, updateMemo],
|
||||
);
|
||||
|
||||
const handleTogglePinMemoBtnClick = useCallback(async () => {
|
||||
try {
|
||||
await updateMemo({
|
||||
update: {
|
||||
name: memo.name,
|
||||
pinned: !memo.pinned,
|
||||
},
|
||||
updateMask: ["pinned"],
|
||||
});
|
||||
} catch {
|
||||
// do nothing
|
||||
}
|
||||
}, [memo.name, memo.pinned, updateMemo]);
|
||||
|
||||
const handleEditMemoClick = useCallback(() => {
|
||||
onEdit?.();
|
||||
}, [onEdit]);
|
||||
|
||||
const handleToggleMemoStatusClick = useCallback(async () => {
|
||||
const isArchiving = memo.state !== State.ARCHIVED;
|
||||
const state = memo.state === State.ARCHIVED ? State.NORMAL : State.ARCHIVED;
|
||||
const message = memo.state === State.ARCHIVED ? t("message.restored-successfully") : t("message.archived-successfully");
|
||||
|
||||
try {
|
||||
await updateMemo({
|
||||
update: {
|
||||
name: memo.name,
|
||||
state,
|
||||
},
|
||||
updateMask: ["state"],
|
||||
});
|
||||
toast.success(message);
|
||||
} catch (error: unknown) {
|
||||
handleError(error, toast.error, {
|
||||
context: `${isArchiving ? "Archive" : "Restore"} memo`,
|
||||
fallbackMessage: "An error occurred",
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (isInMemoDetailPage) {
|
||||
navigateTo(memo.state === State.ARCHIVED ? ROUTES.HOME : ROUTES.ARCHIVED);
|
||||
}
|
||||
memoUpdatedCallback();
|
||||
}, [memo.name, memo.state, t, isInMemoDetailPage, navigateTo, memoUpdatedCallback, updateMemo]);
|
||||
|
||||
const handleCopyLink = useCallback(() => {
|
||||
let host = profile.instanceUrl;
|
||||
if (host === "") {
|
||||
host = window.location.origin;
|
||||
}
|
||||
copy(`${host}/${memo.name}`);
|
||||
toast.success(t("message.succeed-copy-link"));
|
||||
}, [memo.name, t, profile.instanceUrl]);
|
||||
|
||||
const handleCopyContent = useCallback(() => {
|
||||
copy(memo.content);
|
||||
toast.success(t("message.succeed-copy-content"));
|
||||
}, [memo.content, t]);
|
||||
|
||||
const handleCheckAllTaskListItemsClick = useCallback(async () => {
|
||||
await updateMemoContent(checkAllTasks(memo.content), "Check memo task list items");
|
||||
}, [memo.content, updateMemoContent]);
|
||||
|
||||
const handleUncheckAllTaskListItemsClick = useCallback(async () => {
|
||||
await updateMemoContent(uncheckAllTasks(memo.content), "Uncheck memo task list items");
|
||||
}, [memo.content, updateMemoContent]);
|
||||
|
||||
const handleDeleteMemoClick = useCallback(() => {
|
||||
setDeleteDialogOpen(true);
|
||||
}, [setDeleteDialogOpen]);
|
||||
|
||||
const confirmDeleteMemo = useCallback(async () => {
|
||||
try {
|
||||
await deleteMemo(memo.name);
|
||||
} catch (error: unknown) {
|
||||
handleError(error, toast.error, { context: "Delete memo", fallbackMessage: "An error occurred" });
|
||||
return;
|
||||
}
|
||||
toast.success(t("message.deleted-successfully"));
|
||||
if (memo.parent) {
|
||||
queryClient.invalidateQueries({ queryKey: memoKeys.comments(memo.parent) });
|
||||
}
|
||||
if (isInMemoDetailPage) {
|
||||
navigateTo(ROUTES.HOME);
|
||||
}
|
||||
memoUpdatedCallback();
|
||||
}, [memo.name, memo.parent, t, isInMemoDetailPage, navigateTo, memoUpdatedCallback, deleteMemo, queryClient]);
|
||||
|
||||
return {
|
||||
handleTogglePinMemoBtnClick,
|
||||
handleEditMemoClick,
|
||||
handleToggleMemoStatusClick,
|
||||
handleCopyLink,
|
||||
handleCopyContent,
|
||||
handleCheckAllTaskListItemsClick,
|
||||
handleUncheckAllTaskListItemsClick,
|
||||
handleDeleteMemoClick,
|
||||
confirmDeleteMemo,
|
||||
};
|
||||
};
|
||||
@@ -0,0 +1,3 @@
|
||||
export { useMemoActionHandlers } from "./hooks";
|
||||
export { default, default as MemoActionMenu } from "./MemoActionMenu";
|
||||
export type { MemoActionMenuProps } from "./types";
|
||||
@@ -0,0 +1,117 @@
|
||||
import { toBlob } from "html-to-image";
|
||||
|
||||
const WINDOW_HORIZONTAL_MARGIN = 32;
|
||||
const PREVIEW_HORIZONTAL_PADDING_IN_DIALOG = 40;
|
||||
const PREVIEW_WIDTH_BOOST_IN_DIALOG = 48;
|
||||
|
||||
export const MEMO_SHARE_IMAGE_CONFIG = {
|
||||
dialogExtraWidth: 80,
|
||||
maxWidth: 520,
|
||||
minWidth: 260,
|
||||
previewScale: 0.9,
|
||||
viewportMargin: 48,
|
||||
} as const;
|
||||
|
||||
const clamp = (value: number, min: number, max: number) => Math.min(Math.max(value, min), max);
|
||||
|
||||
const isExportableImageUrl = (value?: string) => {
|
||||
if (!value) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (value.startsWith("/") || value.startsWith("data:") || value.startsWith("blob:")) {
|
||||
return true;
|
||||
}
|
||||
|
||||
try {
|
||||
return new URL(value, window.location.origin).origin === window.location.origin;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
};
|
||||
|
||||
const waitForPreviewAssets = async (node: HTMLElement) => {
|
||||
try {
|
||||
await document.fonts?.ready;
|
||||
} catch {
|
||||
// Ignore font loading failures and continue with the best available render.
|
||||
}
|
||||
|
||||
const images = Array.from(node.querySelectorAll("img"));
|
||||
await Promise.all(
|
||||
images.map(
|
||||
(image) =>
|
||||
new Promise<void>((resolve) => {
|
||||
if (image.complete) {
|
||||
resolve();
|
||||
return;
|
||||
}
|
||||
|
||||
image.addEventListener("load", () => resolve(), { once: true });
|
||||
image.addEventListener("error", () => resolve(), { once: true });
|
||||
}),
|
||||
),
|
||||
);
|
||||
};
|
||||
|
||||
export const buildMemoShareImageFileName = (memoName: string) => {
|
||||
const suffix = memoName.split("/").pop() || "memo";
|
||||
return `memo-${suffix}.png`;
|
||||
};
|
||||
|
||||
export const getMemoSharePreviewWidth = (cardWidth: number) => {
|
||||
const viewportWidth =
|
||||
typeof window === "undefined" ? MEMO_SHARE_IMAGE_CONFIG.maxWidth : window.innerWidth - MEMO_SHARE_IMAGE_CONFIG.viewportMargin;
|
||||
const baseWidth = cardWidth || viewportWidth;
|
||||
|
||||
return clamp(
|
||||
Math.round(baseWidth * MEMO_SHARE_IMAGE_CONFIG.previewScale),
|
||||
MEMO_SHARE_IMAGE_CONFIG.minWidth,
|
||||
MEMO_SHARE_IMAGE_CONFIG.maxWidth,
|
||||
);
|
||||
};
|
||||
|
||||
export const getMemoShareDialogWidth = (previewWidth: number) => {
|
||||
const viewportWidth =
|
||||
typeof window === "undefined" ? previewWidth + MEMO_SHARE_IMAGE_CONFIG.dialogExtraWidth : window.innerWidth - WINDOW_HORIZONTAL_MARGIN;
|
||||
return Math.min(previewWidth + MEMO_SHARE_IMAGE_CONFIG.dialogExtraWidth, viewportWidth);
|
||||
};
|
||||
|
||||
export const getMemoShareRenderWidth = (previewWidth: number, dialogWidth: number) => {
|
||||
const maxRenderWidth = Math.max(MEMO_SHARE_IMAGE_CONFIG.minWidth, dialogWidth - PREVIEW_HORIZONTAL_PADDING_IN_DIALOG);
|
||||
return clamp(previewWidth + PREVIEW_WIDTH_BOOST_IN_DIALOG, MEMO_SHARE_IMAGE_CONFIG.minWidth, maxRenderWidth);
|
||||
};
|
||||
|
||||
export const getMemoSharePreviewAvatarUrl = (avatarUrl?: string) => (isExportableImageUrl(avatarUrl) ? avatarUrl : undefined);
|
||||
|
||||
export const createMemoShareImageBlob = async (node: HTMLElement) => {
|
||||
await waitForPreviewAssets(node);
|
||||
|
||||
const rect = node.getBoundingClientRect();
|
||||
const width = Math.ceil(rect.width || node.offsetWidth || node.clientWidth);
|
||||
const height = Math.ceil(rect.height || node.offsetHeight || node.clientHeight);
|
||||
|
||||
const blob = await toBlob(node, {
|
||||
cacheBust: true,
|
||||
height,
|
||||
pixelRatio: Math.max(2, Math.min(window.devicePixelRatio || 1, 3)),
|
||||
width,
|
||||
filter: (currentNode) => {
|
||||
if (!(currentNode instanceof HTMLElement)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
if (currentNode instanceof HTMLImageElement) {
|
||||
return isExportableImageUrl(currentNode.currentSrc || currentNode.src);
|
||||
}
|
||||
|
||||
return !(currentNode instanceof HTMLVideoElement);
|
||||
},
|
||||
});
|
||||
|
||||
if (!blob) {
|
||||
throw new Error("Failed to render image");
|
||||
}
|
||||
|
||||
return blob;
|
||||
};
|
||||
@@ -0,0 +1,58 @@
|
||||
import { timestampDate } from "@bufbuild/protobuf/wkt";
|
||||
import { separateAttachments } from "@/components/MemoMetadata/Attachment/attachmentHelpers";
|
||||
import type { Memo } from "@/types/proto/api/v1/memo_service_pb";
|
||||
import type { User } from "@/types/proto/api/v1/user_service_pb";
|
||||
import { type AttachmentVisualItem, buildAttachmentVisualItems, countLogicalAttachmentItems } from "@/utils/media-item";
|
||||
import { getMemoSharePreviewAvatarUrl } from "./memoShareImage";
|
||||
|
||||
interface BuildMemoShareImagePreviewModelOptions {
|
||||
memo: Memo;
|
||||
creator?: User;
|
||||
fallbackDisplayName: string;
|
||||
locale: string;
|
||||
}
|
||||
|
||||
export interface MemoShareImageAttachmentSummaryBadge {
|
||||
type: "attachment-summary";
|
||||
count: number;
|
||||
}
|
||||
|
||||
export type MemoShareImageFooterBadge = MemoShareImageAttachmentSummaryBadge;
|
||||
|
||||
export interface MemoShareImagePreviewModel {
|
||||
displayName: string;
|
||||
avatarUrl?: string;
|
||||
formattedDisplayTime?: string;
|
||||
visualItems: AttachmentVisualItem[];
|
||||
footerBadges: MemoShareImageFooterBadge[];
|
||||
}
|
||||
|
||||
export const buildMemoShareImagePreviewModel = ({
|
||||
memo,
|
||||
creator,
|
||||
fallbackDisplayName,
|
||||
locale,
|
||||
}: BuildMemoShareImagePreviewModelOptions): MemoShareImagePreviewModel => {
|
||||
const displayName = creator?.displayName || creator?.username || fallbackDisplayName;
|
||||
const avatarUrl = getMemoSharePreviewAvatarUrl(creator?.avatarUrl);
|
||||
const displayTime = memo.createTime ? timestampDate(memo.createTime) : undefined;
|
||||
const formattedDisplayTime = displayTime?.toLocaleString(locale, {
|
||||
dateStyle: "medium",
|
||||
timeStyle: "short",
|
||||
});
|
||||
|
||||
const attachmentGroups = separateAttachments(memo.attachments);
|
||||
const visualItems = buildAttachmentVisualItems(attachmentGroups.visual);
|
||||
const attachmentCount = countLogicalAttachmentItems(memo.attachments);
|
||||
const nonVisualAttachmentCount = Math.max(attachmentCount - visualItems.length, 0);
|
||||
const footerBadges: MemoShareImageFooterBadge[] =
|
||||
nonVisualAttachmentCount > 0 ? [{ type: "attachment-summary", count: attachmentCount }] : [];
|
||||
|
||||
return {
|
||||
displayName,
|
||||
avatarUrl,
|
||||
formattedDisplayTime,
|
||||
visualItems,
|
||||
footerBadges,
|
||||
};
|
||||
};
|
||||
@@ -0,0 +1,8 @@
|
||||
import type { Memo } from "@/types/proto/api/v1/memo_service_pb";
|
||||
|
||||
export interface MemoActionMenuProps {
|
||||
memo: Memo;
|
||||
readonly?: boolean;
|
||||
className?: string;
|
||||
onEdit?: () => void;
|
||||
}
|
||||
@@ -0,0 +1,90 @@
|
||||
import { LoaderCircleIcon, MessageCircleIcon } from "lucide-react";
|
||||
import { useState } from "react";
|
||||
import MemoEditor from "@/components/MemoEditor";
|
||||
import MemoView from "@/components/MemoView";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import useCurrentUser from "@/hooks/useCurrentUser";
|
||||
import { extractMemoIdFromName } from "@/lib/resource-names";
|
||||
import type { Memo } from "@/types/proto/api/v1/memo_service_pb";
|
||||
import { useTranslate } from "@/utils/i18n";
|
||||
|
||||
interface Props {
|
||||
memo: Memo;
|
||||
comments: Memo[];
|
||||
parentPage?: string;
|
||||
hasMoreComments?: boolean;
|
||||
isFetchingMoreComments?: boolean;
|
||||
onLoadMoreComments?: () => void;
|
||||
}
|
||||
|
||||
const MemoCommentSection = ({ memo, comments, parentPage, hasMoreComments, isFetchingMoreComments, onLoadMoreComments }: Props) => {
|
||||
const t = useTranslate();
|
||||
const currentUser = useCurrentUser();
|
||||
const [showEditor, setShowEditor] = useState(false);
|
||||
|
||||
const showCreateButton = currentUser && !showEditor;
|
||||
|
||||
const handleCommentCreated = async (_memoCommentName: string) => {
|
||||
setShowEditor(false);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="pt-8 pb-16 w-full">
|
||||
<h2 id="comments" className="sr-only">
|
||||
{t("memo.comment.self")}
|
||||
</h2>
|
||||
<div className="relative mx-auto grow w-full min-h-full flex flex-col justify-start items-start gap-y-1">
|
||||
{comments.length === 0 ? (
|
||||
showCreateButton && (
|
||||
<div className="w-full flex flex-row justify-center items-center py-6">
|
||||
<Button variant="ghost" onClick={() => setShowEditor(true)}>
|
||||
<span className="text-muted-foreground">{t("memo.comment.write-a-comment")}</span>
|
||||
<MessageCircleIcon className="ml-2 w-5 h-auto text-muted-foreground" />
|
||||
</Button>
|
||||
</div>
|
||||
)
|
||||
) : (
|
||||
<div className="w-full flex flex-row justify-between items-center h-8 pl-3 mb-2">
|
||||
<div className="flex flex-row justify-start items-center">
|
||||
<MessageCircleIcon className="w-5 h-auto text-muted-foreground mr-1" />
|
||||
<span className="text-muted-foreground text-sm">{t("memo.comment.self")}</span>
|
||||
<span className="text-muted-foreground text-sm ml-1">({comments.length})</span>
|
||||
</div>
|
||||
{showCreateButton && (
|
||||
<Button variant="ghost" className="text-muted-foreground" onClick={() => setShowEditor(true)}>
|
||||
{t("memo.comment.write-a-comment")}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
{showEditor && (
|
||||
<div className="w-full mb-2">
|
||||
<MemoEditor
|
||||
cacheKey={`${memo.name}-${memo.updateTime}-comment`}
|
||||
placeholder={t("editor.add-your-comment-here")}
|
||||
parentMemoName={memo.name}
|
||||
autoFocus
|
||||
onConfirm={handleCommentCreated}
|
||||
onCancel={() => setShowEditor(false)}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
{comments.map((comment) => (
|
||||
<div className="w-full" key={`${comment.name}-${comment.updateTime}`} id={extractMemoIdFromName(comment.name)}>
|
||||
<MemoView memo={comment} parentPage={parentPage} showCreator compact />
|
||||
</div>
|
||||
))}
|
||||
{hasMoreComments && (
|
||||
<div className="w-full mt-4 flex justify-center">
|
||||
<Button variant="outline" className="rounded-full px-4" onClick={onLoadMoreComments} disabled={isFetchingMoreComments}>
|
||||
{isFetchingMoreComments && <LoaderCircleIcon className="h-4 w-4 animate-spin" />}
|
||||
{t(isFetchingMoreComments ? "resource.fetching-data" : "memo.load-more")}
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default MemoCommentSection;
|
||||
@@ -0,0 +1,156 @@
|
||||
import copy from "copy-to-clipboard";
|
||||
import hljs from "highlight.js";
|
||||
import { CheckIcon, CopyIcon } from "lucide-react";
|
||||
import { isValidElement, type ReactElement, type ReactNode, useEffect, useMemo, useState } from "react";
|
||||
import { useAuth } from "@/contexts/AuthContext";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { getThemeWithFallback, resolveTheme } from "@/utils/theme";
|
||||
import { MermaidBlock } from "./MermaidBlock";
|
||||
import type { ReactMarkdownProps } from "./markdown/types";
|
||||
import { extractCodeContent, extractLanguage } from "./utils";
|
||||
|
||||
interface CodeBlockProps extends ReactMarkdownProps {
|
||||
children?: ReactNode;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export const CodeBlock = ({ children, className, node: _node, ...props }: CodeBlockProps) => {
|
||||
const { userGeneralSetting } = useAuth();
|
||||
const [copied, setCopied] = useState(false);
|
||||
|
||||
const codeElement = isValidElement(children) ? (children as ReactElement<{ className?: string }>) : null;
|
||||
const codeClassName = codeElement?.props.className || "";
|
||||
const codeContent = extractCodeContent(children);
|
||||
const language = extractLanguage(codeClassName);
|
||||
|
||||
// If it's a mermaid block, render with MermaidBlock component
|
||||
if (language === "mermaid") {
|
||||
return (
|
||||
<pre className="relative">
|
||||
<MermaidBlock className={cn(className)} {...props}>
|
||||
{children}
|
||||
</MermaidBlock>
|
||||
</pre>
|
||||
);
|
||||
}
|
||||
|
||||
const theme = getThemeWithFallback(userGeneralSetting?.theme);
|
||||
const resolvedTheme = resolveTheme(theme);
|
||||
const isDarkTheme = resolvedTheme.includes("dark");
|
||||
|
||||
// Dynamically load highlight.js theme based on app theme
|
||||
useEffect(() => {
|
||||
const dynamicImportStyle = async () => {
|
||||
// Remove any existing highlight.js style
|
||||
const existingStyle = document.querySelector("style[data-hljs-theme]");
|
||||
if (existingStyle) {
|
||||
existingStyle.remove();
|
||||
}
|
||||
|
||||
try {
|
||||
const cssModule = isDarkTheme
|
||||
? await import("highlight.js/styles/github-dark-dimmed.css?inline")
|
||||
: await import("highlight.js/styles/github.css?inline");
|
||||
|
||||
// Create and inject the style
|
||||
const style = document.createElement("style");
|
||||
style.textContent = cssModule.default;
|
||||
style.setAttribute("data-hljs-theme", isDarkTheme ? "dark" : "light");
|
||||
document.head.appendChild(style);
|
||||
} catch (error) {
|
||||
console.warn("Failed to load highlight.js theme:", error);
|
||||
}
|
||||
};
|
||||
|
||||
dynamicImportStyle();
|
||||
}, [resolvedTheme, isDarkTheme]);
|
||||
|
||||
// Highlight code using highlight.js
|
||||
const highlightedCode = useMemo(() => {
|
||||
try {
|
||||
const lang = hljs.getLanguage(language);
|
||||
if (lang) {
|
||||
return hljs.highlight(codeContent, {
|
||||
language: language,
|
||||
}).value;
|
||||
}
|
||||
} catch {
|
||||
// Skip error and use default highlighted code.
|
||||
}
|
||||
|
||||
// Escape any HTML entities when rendering original content.
|
||||
return Object.assign(document.createElement("span"), {
|
||||
textContent: codeContent,
|
||||
}).innerHTML;
|
||||
}, [language, codeContent]);
|
||||
|
||||
const handleCopy = async () => {
|
||||
try {
|
||||
// Try native clipboard API first (requires HTTPS or localhost)
|
||||
if (navigator.clipboard && window.isSecureContext) {
|
||||
await navigator.clipboard.writeText(codeContent);
|
||||
setCopied(true);
|
||||
setTimeout(() => setCopied(false), 2000);
|
||||
} else {
|
||||
// Fallback to copy-to-clipboard library for non-secure contexts
|
||||
const success = await copy(codeContent);
|
||||
if (success) {
|
||||
setCopied(true);
|
||||
setTimeout(() => setCopied(false), 2000);
|
||||
} else {
|
||||
console.error("Failed to copy code");
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
// If native API fails, try fallback
|
||||
console.warn("Native clipboard failed, using fallback:", err);
|
||||
const success = await copy(codeContent);
|
||||
if (success) {
|
||||
setCopied(true);
|
||||
setTimeout(() => setCopied(false), 2000);
|
||||
} else {
|
||||
console.error("Failed to copy code:", err);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<pre className="relative my-2 rounded-lg border border-border bg-muted/20 overflow-hidden">
|
||||
{/* Header with language label and copy button */}
|
||||
<div className="flex items-center justify-between px-2 py-1 border-b border-border bg-muted/30">
|
||||
<span className="text-xs text-foreground select-none">{language || "text"}</span>
|
||||
<button
|
||||
onClick={handleCopy}
|
||||
className={cn(
|
||||
"inline-flex items-center gap-1 px-1.5 py-0.5 rounded text-xs",
|
||||
"transition-colors duration-200",
|
||||
"hover:bg-accent active:scale-95",
|
||||
copied ? "text-primary" : "text-muted-foreground hover:text-foreground",
|
||||
)}
|
||||
aria-label={copied ? "Copied" : "Copy code"}
|
||||
title={copied ? "Copied!" : "Copy code"}
|
||||
>
|
||||
{copied ? (
|
||||
<>
|
||||
<CheckIcon className="w-3.5 h-3.5" />
|
||||
<span>Copied</span>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<CopyIcon className="w-3.5 h-3.5" />
|
||||
<span>Copy</span>
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Code content */}
|
||||
<div className="overflow-x-auto">
|
||||
<code
|
||||
className={cn("block px-3 py-2 text-sm leading-relaxed", `language-${language}`)}
|
||||
dangerouslySetInnerHTML={{ __html: highlightedCode }}
|
||||
/>
|
||||
</div>
|
||||
</pre>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,70 @@
|
||||
import type React from "react";
|
||||
import { useEffect, useState } from "react";
|
||||
import { useLinkMetadata } from "@/hooks/useMemoQueries";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
interface LinkMetadataCardProps {
|
||||
url: string;
|
||||
fallback: React.ReactNode;
|
||||
}
|
||||
|
||||
function getHostname(url: string): string {
|
||||
try {
|
||||
return new URL(url).hostname.replace(/^www\./, "");
|
||||
} catch {
|
||||
return "";
|
||||
}
|
||||
}
|
||||
|
||||
const LinkMetadataCard = ({ url, fallback }: LinkMetadataCardProps) => {
|
||||
const [imageFailed, setImageFailed] = useState(false);
|
||||
const { data: metadata, isSuccess } = useLinkMetadata(url);
|
||||
|
||||
const title = metadata?.title.trim() ?? "";
|
||||
const description = metadata?.description.trim() ?? "";
|
||||
const image = metadata?.image.trim() ?? "";
|
||||
const hostname = getHostname(metadata?.url || url);
|
||||
const hasUsefulMetadata = title !== "" || description !== "";
|
||||
|
||||
useEffect(() => {
|
||||
setImageFailed(false);
|
||||
}, [url, image]);
|
||||
|
||||
if (!isSuccess || !hasUsefulMetadata) {
|
||||
return fallback;
|
||||
}
|
||||
|
||||
return (
|
||||
<a
|
||||
href={url}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className={cn(
|
||||
"group my-0 mb-2 flex w-full max-w-full overflow-hidden rounded-md border border-border bg-muted/20 text-foreground no-underline transition-colors",
|
||||
"hover:border-primary/35 hover:bg-accent/20",
|
||||
)}
|
||||
>
|
||||
<span className="flex min-w-0 flex-1 flex-col gap-0.5 px-2.5 py-2 sm:gap-1 sm:px-3 sm:py-2.5">
|
||||
{hostname && <span className="truncate text-[11px] leading-4 text-muted-foreground sm:text-xs">{hostname}</span>}
|
||||
{title && <span className="line-clamp-2 text-sm font-medium leading-5 text-foreground">{title}</span>}
|
||||
{description && <span className="line-clamp-1 text-xs leading-4 text-muted-foreground sm:line-clamp-2">{description}</span>}
|
||||
</span>
|
||||
{image && !imageFailed && (
|
||||
<span className="flex w-24 shrink-0 items-center border-l border-border/70 bg-muted/40 sm:w-40">
|
||||
<span className="aspect-[1.91/1] w-full overflow-hidden">
|
||||
<img
|
||||
src={image}
|
||||
alt=""
|
||||
className="h-full w-full object-cover transition-transform duration-200 group-hover:scale-[1.01]"
|
||||
loading="lazy"
|
||||
decoding="async"
|
||||
onError={() => setImageFailed(true)}
|
||||
/>
|
||||
</span>
|
||||
</span>
|
||||
)}
|
||||
</a>
|
||||
);
|
||||
};
|
||||
|
||||
export default LinkMetadataCard;
|
||||
@@ -0,0 +1,22 @@
|
||||
import { createContext, useContext, useMemo } from "react";
|
||||
|
||||
interface MarkdownRenderContextValue {
|
||||
blockDepth: number;
|
||||
}
|
||||
|
||||
export const rootMarkdownRenderContext: MarkdownRenderContextValue = {
|
||||
blockDepth: 0,
|
||||
};
|
||||
|
||||
export const MarkdownRenderContext = createContext<MarkdownRenderContextValue>(rootMarkdownRenderContext);
|
||||
|
||||
export const useMarkdownRenderContext = () => {
|
||||
return useContext(MarkdownRenderContext);
|
||||
};
|
||||
|
||||
export const NestedMarkdownRenderContext = ({ children }: { children: React.ReactNode }) => {
|
||||
const { blockDepth } = useMarkdownRenderContext();
|
||||
const value = useMemo<MarkdownRenderContextValue>(() => ({ blockDepth: blockDepth + 1 }), [blockDepth]);
|
||||
|
||||
return <MarkdownRenderContext.Provider value={value}>{children}</MarkdownRenderContext.Provider>;
|
||||
};
|
||||
@@ -0,0 +1,167 @@
|
||||
import type { Element } from "hast";
|
||||
import "katex/dist/katex.min.css";
|
||||
import type { Components } from "react-markdown";
|
||||
import ReactMarkdown from "react-markdown";
|
||||
import rehypeKatex from "rehype-katex";
|
||||
import rehypeRaw from "rehype-raw";
|
||||
import rehypeSanitize from "rehype-sanitize";
|
||||
import remarkBreaks from "remark-breaks";
|
||||
import remarkGfm from "remark-gfm";
|
||||
import remarkMath from "remark-math";
|
||||
import { isMentionElement, isTagElement, isTaskListItemElement } from "@/types/markdown";
|
||||
import { rehypeHeadingId } from "@/utils/rehype-plugins/rehype-heading-id";
|
||||
import { remarkDisableSetext } from "@/utils/remark-plugins/remark-disable-setext";
|
||||
import { remarkMention } from "@/utils/remark-plugins/remark-mention";
|
||||
import { remarkPreserveType } from "@/utils/remark-plugins/remark-preserve-type";
|
||||
import { remarkSplitMixedTaskLists } from "@/utils/remark-plugins/remark-split-mixed-task-lists";
|
||||
import { remarkTag } from "@/utils/remark-plugins/remark-tag";
|
||||
import { CodeBlock } from "./CodeBlock";
|
||||
import { SANITIZE_SCHEMA } from "./constants";
|
||||
import { MarkdownRenderContext, rootMarkdownRenderContext } from "./MarkdownRenderContext";
|
||||
import { Mention } from "./Mention";
|
||||
import { AnchorLink, Blockquote, Heading, HorizontalRule, Image, InlineCode, Link, List, ListItem, Paragraph } from "./markdown";
|
||||
import { Table, TableBody, TableCell, TableHead, TableHeaderCell, TableRow } from "./Table";
|
||||
import { Tag } from "./Tag";
|
||||
import { TaskListItem } from "./TaskListItem";
|
||||
import { TrustedIframe } from "./TrustedIframe";
|
||||
|
||||
interface MemoMarkdownRendererProps {
|
||||
content: string;
|
||||
resolvedMentionUsernames: Set<string>;
|
||||
/** Resource name of the memo (e.g. `memos/abc123`), used to target footnote links at the detail page. */
|
||||
memoName?: string;
|
||||
/** Whether the memo is rendered as a collapsed feed card. */
|
||||
compact?: boolean;
|
||||
}
|
||||
|
||||
function getMentionUsername(node: Element, children?: React.ReactNode): string {
|
||||
const dataMention = node.properties?.["data-mention"];
|
||||
if (typeof dataMention === "string" && dataMention !== "") {
|
||||
return dataMention;
|
||||
}
|
||||
|
||||
const camelDataMention = (node.properties as Record<string, unknown> | undefined)?.dataMention;
|
||||
if (typeof camelDataMention === "string" && camelDataMention !== "") {
|
||||
return camelDataMention;
|
||||
}
|
||||
|
||||
const text = Array.isArray(children) ? children.join("") : children;
|
||||
if (typeof text === "string" && text.startsWith("@")) {
|
||||
return text.slice(1).toLowerCase();
|
||||
}
|
||||
|
||||
return "";
|
||||
}
|
||||
|
||||
export const MemoMarkdownRenderer = ({ content, resolvedMentionUsernames, memoName, compact }: MemoMarkdownRendererProps) => {
|
||||
const markdownComponents: Components = {
|
||||
input: ({ node, ...inputProps }) => {
|
||||
if (node && isTaskListItemElement(node)) {
|
||||
return <TaskListItem {...inputProps} node={node} />;
|
||||
}
|
||||
return <input {...inputProps} />;
|
||||
},
|
||||
span: ({ node, ...spanProps }) => {
|
||||
if (node && isMentionElement(node)) {
|
||||
const username = getMentionUsername(node, spanProps.children);
|
||||
return <Mention {...spanProps} node={node} data-mention={username} resolved={resolvedMentionUsernames.has(username)} />;
|
||||
}
|
||||
if (node && isTagElement(node)) {
|
||||
return <Tag {...spanProps} node={node} />;
|
||||
}
|
||||
return <span {...spanProps} />;
|
||||
},
|
||||
h1: ({ children, ...props }) => (
|
||||
<Heading level={1} {...props}>
|
||||
{children}
|
||||
</Heading>
|
||||
),
|
||||
h2: ({ children, ...props }) => (
|
||||
<Heading level={2} {...props}>
|
||||
{children}
|
||||
</Heading>
|
||||
),
|
||||
h3: ({ children, ...props }) => (
|
||||
<Heading level={3} {...props}>
|
||||
{children}
|
||||
</Heading>
|
||||
),
|
||||
h4: ({ children, ...props }) => (
|
||||
<Heading level={4} {...props}>
|
||||
{children}
|
||||
</Heading>
|
||||
),
|
||||
h5: ({ children, ...props }) => (
|
||||
<Heading level={5} {...props}>
|
||||
{children}
|
||||
</Heading>
|
||||
),
|
||||
h6: ({ children, ...props }) => (
|
||||
<Heading level={6} {...props}>
|
||||
{children}
|
||||
</Heading>
|
||||
),
|
||||
p: ({ children, ...props }) => <Paragraph {...props}>{children}</Paragraph>,
|
||||
blockquote: ({ children, ...props }) => <Blockquote {...props}>{children}</Blockquote>,
|
||||
hr: (props) => <HorizontalRule {...props} />,
|
||||
ul: ({ children, ...props }) => <List {...props}>{children}</List>,
|
||||
ol: ({ children, ...props }) => (
|
||||
<List ordered {...props}>
|
||||
{children}
|
||||
</List>
|
||||
),
|
||||
li: ({ children, ...props }) => <ListItem {...props}>{children}</ListItem>,
|
||||
a: ({ children, href, ...props }) => {
|
||||
// In-page anchors (footnote refs/backrefs, heading links) navigate within the memo rather
|
||||
// than opening a new tab; everything else is treated as an external link.
|
||||
if (typeof href === "string" && href.startsWith("#")) {
|
||||
return (
|
||||
<AnchorLink href={href} memoName={memoName} compact={compact} {...props}>
|
||||
{children}
|
||||
</AnchorLink>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<Link href={href} {...props}>
|
||||
{children}
|
||||
</Link>
|
||||
);
|
||||
},
|
||||
code: ({ children, ...props }) => <InlineCode {...props}>{children}</InlineCode>,
|
||||
iframe: TrustedIframe,
|
||||
img: (props) => <Image {...props} />,
|
||||
pre: CodeBlock,
|
||||
table: ({ children, ...props }) => <Table {...props}>{children}</Table>,
|
||||
thead: ({ children, ...props }) => <TableHead {...props}>{children}</TableHead>,
|
||||
tbody: ({ children, ...props }) => <TableBody {...props}>{children}</TableBody>,
|
||||
tr: ({ children, ...props }) => <TableRow {...props}>{children}</TableRow>,
|
||||
th: ({ children, ...props }) => <TableHeaderCell {...props}>{children}</TableHeaderCell>,
|
||||
td: ({ children, ...props }) => <TableCell {...props}>{children}</TableCell>,
|
||||
};
|
||||
|
||||
return (
|
||||
<MarkdownRenderContext.Provider value={rootMarkdownRenderContext}>
|
||||
<ReactMarkdown
|
||||
remarkPlugins={[
|
||||
remarkDisableSetext,
|
||||
remarkMath,
|
||||
remarkGfm,
|
||||
remarkSplitMixedTaskLists,
|
||||
remarkBreaks,
|
||||
remarkMention,
|
||||
remarkTag,
|
||||
remarkPreserveType,
|
||||
]}
|
||||
rehypePlugins={[
|
||||
rehypeRaw,
|
||||
[rehypeSanitize, SANITIZE_SCHEMA],
|
||||
rehypeHeadingId,
|
||||
[rehypeKatex, { throwOnError: false, strict: false }],
|
||||
]}
|
||||
components={markdownComponents}
|
||||
>
|
||||
{content}
|
||||
</ReactMarkdown>
|
||||
</MarkdownRenderContext.Provider>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,41 @@
|
||||
import type { Element } from "hast";
|
||||
import { mentionStyles } from "@/lib/markdownStyles";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
interface MentionProps extends React.HTMLAttributes<HTMLSpanElement> {
|
||||
node?: Element;
|
||||
"data-mention"?: string;
|
||||
children?: React.ReactNode;
|
||||
resolved?: boolean;
|
||||
}
|
||||
|
||||
export const Mention: React.FC<MentionProps> = ({
|
||||
"data-mention": dataMention,
|
||||
children,
|
||||
className,
|
||||
node: _node,
|
||||
resolved = false,
|
||||
...props
|
||||
}) => {
|
||||
const username = dataMention || "";
|
||||
|
||||
if (!resolved) {
|
||||
return (
|
||||
<span data-mention={username} title={`@${username}`} className={className} {...props}>
|
||||
{children}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<a
|
||||
href={`/u/${username}`}
|
||||
className={cn(mentionStyles.base, "hover:underline", className)}
|
||||
data-mention={username}
|
||||
title={`@${username}`}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
</a>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,41 @@
|
||||
import { createContext, type ReactNode, useContext, useMemo } from "react";
|
||||
import { useUsersByUsernames } from "@/hooks/useUserQueries";
|
||||
import { extractMentionUsernames } from "@/utils/remark-plugins/remark-mention";
|
||||
|
||||
const MentionResolutionContext = createContext<Set<string> | null>(null);
|
||||
|
||||
interface MentionResolutionProviderProps {
|
||||
contents: string[];
|
||||
children: ReactNode;
|
||||
}
|
||||
|
||||
export const MentionResolutionProvider = ({ contents, children }: MentionResolutionProviderProps) => {
|
||||
const mentionUsernames = useMemo(() => Array.from(new Set(contents.flatMap((content) => extractMentionUsernames(content)))), [contents]);
|
||||
const { data: mentionUsers } = useUsersByUsernames(mentionUsernames);
|
||||
const resolvedMentionUsernames = useMemo(() => {
|
||||
if (!mentionUsers) {
|
||||
return new Set<string>();
|
||||
}
|
||||
|
||||
return new Set(Array.from(mentionUsers.entries()).flatMap(([username, user]) => (user ? [username] : [])));
|
||||
}, [mentionUsers]);
|
||||
|
||||
return <MentionResolutionContext.Provider value={resolvedMentionUsernames}>{children}</MentionResolutionContext.Provider>;
|
||||
};
|
||||
|
||||
export function useResolvedMentionUsernames(usernames: string[]) {
|
||||
const sharedResolvedMentionUsernames = useContext(MentionResolutionContext);
|
||||
const shouldUseSharedResolution = sharedResolvedMentionUsernames !== null;
|
||||
const { data: mentionUsers } = useUsersByUsernames(usernames, { enabled: !shouldUseSharedResolution });
|
||||
|
||||
return useMemo(() => {
|
||||
if (sharedResolvedMentionUsernames) {
|
||||
return sharedResolvedMentionUsernames;
|
||||
}
|
||||
if (!mentionUsers) {
|
||||
return new Set<string>();
|
||||
}
|
||||
|
||||
return new Set(Array.from(mentionUsers.entries()).flatMap(([username, user]) => (user ? [username] : [])));
|
||||
}, [sharedResolvedMentionUsernames, mentionUsers]);
|
||||
}
|
||||
@@ -0,0 +1,101 @@
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import { useAuth } from "@/contexts/AuthContext";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { getThemeWithFallback, resolveTheme, setupSystemThemeListener } from "@/utils/theme";
|
||||
import { extractCodeContent } from "./utils";
|
||||
|
||||
interface MermaidBlockProps {
|
||||
children?: React.ReactNode;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
type MermaidTheme = "default" | "dark";
|
||||
|
||||
const toMermaidTheme = (appTheme: string): MermaidTheme => (appTheme === "default-dark" ? "dark" : "default");
|
||||
|
||||
const formatErrorMessage = (err: unknown): string => {
|
||||
const msg = err instanceof Error ? err.message : "Failed to render diagram";
|
||||
if (/no diagram type detected/i.test(msg)) {
|
||||
return `${msg} — check that the diagram type is valid (e.g. sequenceDiagram, classDiagram, erDiagram)`;
|
||||
}
|
||||
return msg;
|
||||
};
|
||||
|
||||
export const MermaidBlock = ({ children, className }: MermaidBlockProps) => {
|
||||
const { userGeneralSetting } = useAuth();
|
||||
const [svg, setSvg] = useState<string>("");
|
||||
const [error, setError] = useState<string>("");
|
||||
const [systemThemeChange, setSystemThemeChange] = useState(0);
|
||||
|
||||
const codeContent = extractCodeContent(children);
|
||||
const themePreference = getThemeWithFallback(userGeneralSetting?.theme);
|
||||
const currentTheme = useMemo(() => resolveTheme(themePreference), [themePreference, systemThemeChange]);
|
||||
|
||||
// Re-resolve theme when OS preference changes (only relevant when using "system" theme)
|
||||
useEffect(() => {
|
||||
if (themePreference !== "system") return;
|
||||
return setupSystemThemeListener(() => setSystemThemeChange((n) => n + 1));
|
||||
}, [themePreference]);
|
||||
|
||||
// Render diagram when content or theme changes
|
||||
useEffect(() => {
|
||||
if (!codeContent) {
|
||||
setSvg("");
|
||||
setError("");
|
||||
return;
|
||||
}
|
||||
|
||||
let cancelled = false;
|
||||
|
||||
const renderDiagram = async () => {
|
||||
try {
|
||||
const { default: mermaid } = await import("mermaid");
|
||||
if (cancelled) return;
|
||||
|
||||
mermaid.initialize({
|
||||
startOnLoad: false,
|
||||
theme: toMermaidTheme(currentTheme),
|
||||
securityLevel: "strict",
|
||||
fontFamily: "inherit",
|
||||
suppressErrorRendering: true,
|
||||
});
|
||||
|
||||
const id = `mermaid-${Math.random().toString(36).substring(7)}`;
|
||||
const { svg: renderedSvg } = await mermaid.render(id, codeContent);
|
||||
if (cancelled) return;
|
||||
|
||||
setSvg(renderedSvg);
|
||||
setError("");
|
||||
} catch (err) {
|
||||
if (cancelled) return;
|
||||
console.error("Failed to render mermaid diagram:", err);
|
||||
setSvg("");
|
||||
setError(formatErrorMessage(err));
|
||||
}
|
||||
};
|
||||
|
||||
renderDiagram();
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [codeContent, currentTheme]);
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<div className="w-full">
|
||||
<div className="text-sm text-destructive mb-2 whitespace-normal break-words">Mermaid Error: {error}</div>
|
||||
<code className="block language-mermaid whitespace-pre text-sm">{codeContent}</code>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (!svg) return null;
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn("mermaid-diagram w-full flex justify-center items-center my-2 overflow-x-auto", className)}
|
||||
dangerouslySetInnerHTML={{ __html: svg }}
|
||||
/>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,77 @@
|
||||
import { cn } from "@/lib/utils";
|
||||
import { NestedMarkdownRenderContext } from "./MarkdownRenderContext";
|
||||
import type { ReactMarkdownProps } from "./markdown/types";
|
||||
|
||||
interface TableProps extends React.HTMLAttributes<HTMLTableElement>, ReactMarkdownProps {
|
||||
children: React.ReactNode;
|
||||
}
|
||||
|
||||
export const Table = ({ children, className, node: _node, ...props }: TableProps) => {
|
||||
return (
|
||||
<div className="my-2 w-full overflow-x-auto rounded-lg border border-border bg-muted/20">
|
||||
<table className={cn("w-full border-collapse text-sm", className)} {...props}>
|
||||
{children}
|
||||
</table>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
interface TableHeadProps extends React.HTMLAttributes<HTMLTableSectionElement>, ReactMarkdownProps {
|
||||
children: React.ReactNode;
|
||||
}
|
||||
|
||||
export const TableHead = ({ children, className, node: _node, ...props }: TableHeadProps) => {
|
||||
return (
|
||||
<thead className={cn("border-b border-border bg-muted/30", className)} {...props}>
|
||||
{children}
|
||||
</thead>
|
||||
);
|
||||
};
|
||||
|
||||
interface TableBodyProps extends React.HTMLAttributes<HTMLTableSectionElement>, ReactMarkdownProps {
|
||||
children: React.ReactNode;
|
||||
}
|
||||
|
||||
export const TableBody = ({ children, className, node: _node, ...props }: TableBodyProps) => {
|
||||
return (
|
||||
<tbody className={cn("divide-y divide-border", className)} {...props}>
|
||||
{children}
|
||||
</tbody>
|
||||
);
|
||||
};
|
||||
|
||||
interface TableRowProps extends React.HTMLAttributes<HTMLTableRowElement>, ReactMarkdownProps {
|
||||
children: React.ReactNode;
|
||||
}
|
||||
|
||||
export const TableRow = ({ children, className, node: _node, ...props }: TableRowProps) => {
|
||||
return (
|
||||
<tr className={cn("transition-colors hover:bg-accent/20", className)} {...props}>
|
||||
{children}
|
||||
</tr>
|
||||
);
|
||||
};
|
||||
|
||||
interface TableHeaderCellProps extends React.ThHTMLAttributes<HTMLTableCellElement>, ReactMarkdownProps {
|
||||
children: React.ReactNode;
|
||||
}
|
||||
|
||||
export const TableHeaderCell = ({ children, className, node: _node, ...props }: TableHeaderCellProps) => {
|
||||
return (
|
||||
<th className={cn("px-2 py-1 text-left align-middle text-sm font-medium text-muted-foreground", className)} {...props}>
|
||||
<NestedMarkdownRenderContext>{children}</NestedMarkdownRenderContext>
|
||||
</th>
|
||||
);
|
||||
};
|
||||
|
||||
interface TableCellProps extends React.TdHTMLAttributes<HTMLTableCellElement>, ReactMarkdownProps {
|
||||
children: React.ReactNode;
|
||||
}
|
||||
|
||||
export const TableCell = ({ children, className, node: _node, ...props }: TableCellProps) => {
|
||||
return (
|
||||
<td className={cn("px-2 py-1 text-left align-middle text-sm", className)} {...props}>
|
||||
<NestedMarkdownRenderContext>{children}</NestedMarkdownRenderContext>
|
||||
</td>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,79 @@
|
||||
import type { Element } from "hast";
|
||||
import { useLocation } from "react-router-dom";
|
||||
import { useAuth } from "@/contexts/AuthContext";
|
||||
import { type MemoFilter, stringifyFilters, useMemoFilterContext } from "@/contexts/MemoFilterContext";
|
||||
import useNavigateTo from "@/hooks/useNavigateTo";
|
||||
import { colorToHex } from "@/lib/color";
|
||||
import { tagStyles } from "@/lib/markdownStyles";
|
||||
import { findTagMetadata } from "@/lib/tag";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { Routes } from "@/router";
|
||||
import { useMemoViewContext } from "../MemoView/MemoViewContext";
|
||||
|
||||
interface TagProps extends React.HTMLAttributes<HTMLSpanElement> {
|
||||
node?: Element; // AST node from react-markdown
|
||||
"data-tag"?: string;
|
||||
children?: React.ReactNode;
|
||||
}
|
||||
|
||||
export const Tag: React.FC<TagProps> = ({ "data-tag": dataTag, children, className, style, node: _node, ...props }) => {
|
||||
const { parentPage } = useMemoViewContext();
|
||||
const location = useLocation();
|
||||
const navigateTo = useNavigateTo();
|
||||
const { getFiltersByFactor, removeFilter, addFilter } = useMemoFilterContext();
|
||||
const { userTagsSetting } = useAuth();
|
||||
|
||||
const tag = dataTag || "";
|
||||
|
||||
// Custom color from user tag metadata. Dynamic hex values must use inline styles
|
||||
// because Tailwind can't scan dynamically constructed class names.
|
||||
// Text uses a darkened variant (40% color + black) for contrast on light backgrounds.
|
||||
const metadata = userTagsSetting ? findTagMetadata(tag, userTagsSetting) : undefined;
|
||||
const bgHex = colorToHex(metadata?.backgroundColor);
|
||||
const tagStyle: React.CSSProperties | undefined = bgHex
|
||||
? {
|
||||
borderColor: bgHex,
|
||||
color: `color-mix(in srgb, ${bgHex} 60%, black)`,
|
||||
backgroundColor: `color-mix(in srgb, ${bgHex} 15%, transparent)`,
|
||||
...style,
|
||||
}
|
||||
: style;
|
||||
|
||||
const handleTagClick = (e: React.MouseEvent) => {
|
||||
e.stopPropagation();
|
||||
|
||||
// If the tag is clicked in a memo detail page, we should navigate to the memo list page.
|
||||
if (location.pathname.startsWith("/m")) {
|
||||
const pathname = parentPage || Routes.HOME;
|
||||
const searchParams = new URLSearchParams();
|
||||
|
||||
searchParams.set("filter", stringifyFilters([{ factor: "tagSearch", value: tag }]));
|
||||
navigateTo(`${pathname}?${searchParams.toString()}`);
|
||||
return;
|
||||
}
|
||||
|
||||
const isActive = getFiltersByFactor("tagSearch").some((filter: MemoFilter) => filter.value === tag);
|
||||
if (isActive) {
|
||||
removeFilter((f: MemoFilter) => f.factor === "tagSearch" && f.value === tag);
|
||||
} else {
|
||||
// Remove all existing tag filters first, then add the new one
|
||||
removeFilter((f: MemoFilter) => f.factor === "tagSearch");
|
||||
addFilter({
|
||||
factor: "tagSearch",
|
||||
value: tag,
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<span
|
||||
className={cn(tagStyles.base, "cursor-pointer transition-opacity hover:opacity-75", !bgHex && tagStyles.defaultColor, className)}
|
||||
style={tagStyle}
|
||||
data-tag={tag}
|
||||
{...props}
|
||||
onClick={handleTagClick}
|
||||
>
|
||||
{children}
|
||||
</span>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,70 @@
|
||||
import { useRef } from "react";
|
||||
import { Checkbox } from "@/components/ui/checkbox";
|
||||
import { useUpdateMemo } from "@/hooks/useMemoQueries";
|
||||
import { toggleTaskAtIndex } from "@/utils/markdown-manipulation";
|
||||
import { useMemoViewContext, useMemoViewDerived } from "../MemoView/MemoViewContext";
|
||||
import { TASK_LIST_ITEM_CLASS } from "./constants";
|
||||
import type { ReactMarkdownProps } from "./markdown/types";
|
||||
|
||||
interface TaskListItemProps extends React.InputHTMLAttributes<HTMLInputElement>, ReactMarkdownProps {
|
||||
checked?: boolean;
|
||||
}
|
||||
|
||||
export const TaskListItem: React.FC<TaskListItemProps> = ({ checked, node: _node, ...props }) => {
|
||||
const { memo } = useMemoViewContext();
|
||||
const { readonly } = useMemoViewDerived();
|
||||
const checkboxRef = useRef<HTMLButtonElement>(null);
|
||||
const { mutate: updateMemo } = useUpdateMemo();
|
||||
|
||||
const handleChange = async (newChecked: boolean) => {
|
||||
// Don't update if readonly or no memo
|
||||
if (readonly || !memo) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Find the task index by walking up the DOM
|
||||
const listItem = checkboxRef.current?.closest("li.task-list-item");
|
||||
if (!listItem) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Get task index from data attribute, or calculate by counting
|
||||
const taskIndexStr = listItem.getAttribute("data-task-index");
|
||||
let taskIndex = 0;
|
||||
|
||||
if (taskIndexStr !== null) {
|
||||
taskIndex = parseInt(taskIndexStr);
|
||||
} else {
|
||||
// Fallback: Calculate index by counting all task list items in the entire memo
|
||||
// We need to search from the root memo content container, not just the nearest list
|
||||
// to ensure nested tasks are counted in document order
|
||||
let searchRoot = listItem.closest("[data-memo-content]");
|
||||
|
||||
// If memo content container not found, search from document body
|
||||
if (!searchRoot) {
|
||||
searchRoot = document.body;
|
||||
}
|
||||
|
||||
const allTaskItems = searchRoot.querySelectorAll(`li.${TASK_LIST_ITEM_CLASS}`);
|
||||
for (let i = 0; i < allTaskItems.length; i++) {
|
||||
if (allTaskItems[i] === listItem) {
|
||||
taskIndex = i;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Update memo content using the string manipulation utility
|
||||
const newContent = toggleTaskAtIndex(memo.content, taskIndex, newChecked);
|
||||
updateMemo({
|
||||
update: {
|
||||
name: memo.name,
|
||||
content: newContent,
|
||||
},
|
||||
updateMask: ["content", "update_time"],
|
||||
});
|
||||
};
|
||||
|
||||
// Override the disabled prop from remark-gfm (which defaults to true)
|
||||
return <Checkbox ref={checkboxRef} checked={checked} disabled={readonly} onCheckedChange={handleChange} className={props.className} />;
|
||||
};
|
||||
@@ -0,0 +1,14 @@
|
||||
import { createElement } from "react";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { isTrustedIframeSrc } from "./constants";
|
||||
|
||||
export const TrustedIframe = (props: React.ComponentProps<"iframe">) => {
|
||||
if (typeof props.src !== "string" || !isTrustedIframeSrc(props.src)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return createElement("iframe", {
|
||||
...props,
|
||||
className: cn("max-w-full rounded-lg border border-border", props.className),
|
||||
});
|
||||
};
|
||||
@@ -0,0 +1,65 @@
|
||||
import { defaultSchema } from "rehype-sanitize";
|
||||
|
||||
// Class names added by remark-gfm for task lists
|
||||
export const TASK_LIST_CLASS = "contains-task-list";
|
||||
export const TASK_LIST_ITEM_CLASS = "task-list-item";
|
||||
|
||||
const TRUSTED_IFRAME_SRC_PATTERNS = [
|
||||
/^https:\/\/www\.youtube\.com\/embed\/[^?#]+(?:\?.*)?$/i,
|
||||
/^https:\/\/www\.youtube-nocookie\.com\/embed\/[^?#]+(?:\?.*)?$/i,
|
||||
/^https:\/\/player\.vimeo\.com\/video\/[^?#]+(?:\?.*)?$/i,
|
||||
/^https:\/\/open\.spotify\.com\/embed\/[^?#]+(?:\?.*)?$/i,
|
||||
/^https:\/\/w\.soundcloud\.com\/player\/?(?:\?.*)?$/i,
|
||||
/^https:\/\/www\.loom\.com\/embed\/[^?#]+(?:\?.*)?$/i,
|
||||
/^https:\/\/www\.google\.com\/maps\/embed(?:\/[^?#]*)?(?:\?.*)?$/i,
|
||||
/^https:\/\/(?:app\.)?diagrams\.net\/(?:[^?#]+)?(?:\?.*)?$/i,
|
||||
/^https:\/\/(?:www\.)?draw\.io\/(?:[^?#]+)?(?:\?.*)?$/i,
|
||||
];
|
||||
|
||||
const KATEX_INLINE_CLASS_NAMES = ["language-math", "math-inline"] as const;
|
||||
const KATEX_BLOCK_CLASS_NAMES = ["language-math", "math-display"] as const;
|
||||
const SPAN_CLASS_NAMES = ["mention", "tag"] as const;
|
||||
const INPUT_ATTRIBUTES = [...(defaultSchema.attributes?.input || []), ["checked", true]] as const;
|
||||
|
||||
export const isTrustedIframeSrc = (src: string): boolean => TRUSTED_IFRAME_SRC_PATTERNS.some((pattern) => pattern.test(src));
|
||||
|
||||
/**
|
||||
* Sanitization schema for markdown HTML content.
|
||||
* Extends the default schema to allow:
|
||||
* - KaTeX marker classes used before trusted KaTeX rendering runs
|
||||
* - Mention/tag metadata generated by trusted remark plugins
|
||||
* - iframe embeds only from trusted video providers
|
||||
*
|
||||
* This prevents XSS attacks while preserving math rendering functionality.
|
||||
*/
|
||||
export const SANITIZE_SCHEMA = {
|
||||
...defaultSchema,
|
||||
// Don't re-prefix `id`/`name` attributes. remark-rehype already namespaces footnote ids with
|
||||
// `user-content-` and emits matching `#user-content-…` hrefs; the default schema's clobbering
|
||||
// would prepend a *second* `user-content-` to the ids only (not the hrefs), breaking in-page
|
||||
// navigation. Leaving ids untouched keeps footnote (and heading) anchors pointing at real targets.
|
||||
clobber: [],
|
||||
attributes: {
|
||||
...defaultSchema.attributes,
|
||||
img: [...(defaultSchema.attributes?.img || []), "height", "width"],
|
||||
input: INPUT_ATTRIBUTES,
|
||||
code: [...(defaultSchema.attributes?.code || []), ["className", ...KATEX_INLINE_CLASS_NAMES, ...KATEX_BLOCK_CLASS_NAMES]],
|
||||
span: [...(defaultSchema.attributes?.span || []), ["className", ...SPAN_CLASS_NAMES], ["aria*"], ["data*"]],
|
||||
iframe: [
|
||||
["src", ...TRUSTED_IFRAME_SRC_PATTERNS],
|
||||
"width",
|
||||
"height",
|
||||
"frameborder",
|
||||
"allowfullscreen",
|
||||
"allow",
|
||||
"title",
|
||||
"referrerpolicy",
|
||||
"loading",
|
||||
],
|
||||
},
|
||||
tagNames: [...(defaultSchema.tagNames || []), "iframe"],
|
||||
protocols: {
|
||||
...defaultSchema.protocols,
|
||||
src: ["https"],
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,49 @@
|
||||
import { memo, useMemo } from "react";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { extractMentionUsernames } from "@/utils/remark-plugins/remark-mention";
|
||||
import { MemoMarkdownRenderer } from "./MemoMarkdownRenderer";
|
||||
import { useResolvedMentionUsernames } from "./MentionResolutionContext";
|
||||
import type { MemoContentProps } from "./types";
|
||||
|
||||
// Stateless markdown renderer. Truncation is not this component's concern — compact cards
|
||||
// are bounded by ClampedSection around the whole memo body; `compact` here only informs
|
||||
// the renderer (e.g. footnote links navigate to the detail page instead of scrolling,
|
||||
// since a collapsed card may hide the target).
|
||||
const MemoContent = (props: MemoContentProps) => {
|
||||
const { className, contentClassName, content, onClick, onDoubleClick } = props;
|
||||
const mentionUsernames = useMemo(() => extractMentionUsernames(content), [content]);
|
||||
const resolvedMentionUsernames = useResolvedMentionUsernames(mentionUsernames);
|
||||
|
||||
return (
|
||||
<div className={`w-full flex flex-col justify-start items-start text-foreground ${className || ""}`}>
|
||||
<div
|
||||
data-memo-content
|
||||
className={cn(
|
||||
"relative w-full max-w-full wrap-break-word text-base leading-6",
|
||||
"[&>*:last-child]:mb-0",
|
||||
"[&_.katex-display]:max-w-full",
|
||||
"[&_.katex-display]:overflow-x-auto",
|
||||
"[&_.katex-display]:overflow-y-hidden",
|
||||
// Footnotes: quiet GitHub-style footer — thin separator, smaller muted text, unobtrusive links.
|
||||
"[&_.footnotes]:mt-4 [&_.footnotes]:border-t [&_.footnotes]:border-border [&_.footnotes]:pt-2",
|
||||
"[&_.footnotes]:text-sm [&_.footnotes]:text-muted-foreground",
|
||||
// GitHub renders footnote ref/backref links without an underline (underline on hover only).
|
||||
"[&_[data-footnote-ref]]:no-underline [&_[data-footnote-ref]:hover]:underline",
|
||||
"[&_.data-footnote-backref]:no-underline [&_.data-footnote-backref:hover]:underline",
|
||||
contentClassName,
|
||||
)}
|
||||
onMouseUp={onClick}
|
||||
onDoubleClick={onDoubleClick}
|
||||
>
|
||||
<MemoMarkdownRenderer
|
||||
content={content}
|
||||
resolvedMentionUsernames={resolvedMentionUsernames}
|
||||
memoName={props.memoName}
|
||||
compact={Boolean(props.compact)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default memo(MemoContent);
|
||||
@@ -0,0 +1,54 @@
|
||||
import { Link } from "react-router-dom";
|
||||
import { markdownStyles } from "@/lib/markdownStyles";
|
||||
import { cn } from "@/lib/utils";
|
||||
import type { ReactMarkdownProps } from "./types";
|
||||
|
||||
interface AnchorLinkProps extends React.AnchorHTMLAttributes<HTMLAnchorElement>, ReactMarkdownProps {
|
||||
href: string;
|
||||
/** Resource name of the enclosing memo (e.g. `memos/abc123`), when known. */
|
||||
memoName?: string;
|
||||
/** Whether the memo is rendered as a collapsed feed card. */
|
||||
compact?: boolean;
|
||||
children: React.ReactNode;
|
||||
}
|
||||
|
||||
/**
|
||||
* Renders in-page anchors (footnote references/backrefs, heading links — any `href="#…"`).
|
||||
*
|
||||
* When the target lives in a fully-rendered memo (detail, share, preview, expanded feed card),
|
||||
* we scroll to it within that memo's own container — scoped so duplicate footnote ids across a
|
||||
* feed can't send us to the wrong memo. When the memo is a collapsed feed card the footnote is
|
||||
* below the fold, so we fall back to navigating to the memo detail page (with the hash), where
|
||||
* MemoDetail scrolls the target into view.
|
||||
*/
|
||||
export const AnchorLink = ({ href, memoName, compact, children, className, node: _node, ...props }: AnchorLinkProps) => {
|
||||
const handleClick = (event: React.MouseEvent<HTMLAnchorElement>) => {
|
||||
if (compact) return; // Let the link navigate to the detail page.
|
||||
const id = decodeURIComponent(href.slice(1));
|
||||
if (!id) return;
|
||||
// Scope the lookup to this memo's own container so duplicate footnote ids elsewhere in a feed
|
||||
// can't steal the scroll.
|
||||
const root = event.currentTarget.closest("[data-memo-content]");
|
||||
const target = root?.querySelector(`#${CSS.escape(id)}`);
|
||||
if (target) {
|
||||
event.preventDefault();
|
||||
target.scrollIntoView({ behavior: "smooth", block: "center" });
|
||||
}
|
||||
};
|
||||
|
||||
const classes = cn(markdownStyles.link, className);
|
||||
|
||||
if (memoName) {
|
||||
return (
|
||||
<Link to={`/${memoName}${href}`} onClick={handleClick} className={classes} {...props}>
|
||||
{children}
|
||||
</Link>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<a href={href} onClick={handleClick} className={classes} {...props}>
|
||||
{children}
|
||||
</a>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,19 @@
|
||||
import { markdownStyles } from "@/lib/markdownStyles";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { NestedMarkdownRenderContext } from "../MarkdownRenderContext";
|
||||
import type { ReactMarkdownProps } from "./types";
|
||||
|
||||
interface BlockquoteProps extends React.BlockquoteHTMLAttributes<HTMLQuoteElement>, ReactMarkdownProps {
|
||||
children: React.ReactNode;
|
||||
}
|
||||
|
||||
/**
|
||||
* Blockquote component with left border accent
|
||||
*/
|
||||
export const Blockquote = ({ children, className, node: _node, ...props }: BlockquoteProps) => {
|
||||
return (
|
||||
<blockquote className={cn(markdownStyles.blockquote, className)} {...props}>
|
||||
<NestedMarkdownRenderContext>{children}</NestedMarkdownRenderContext>
|
||||
</blockquote>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,23 @@
|
||||
import { headingClass } from "@/lib/markdownStyles";
|
||||
import { cn } from "@/lib/utils";
|
||||
import type { ReactMarkdownProps } from "./types";
|
||||
|
||||
interface HeadingProps extends React.HTMLAttributes<HTMLHeadingElement>, ReactMarkdownProps {
|
||||
level: 1 | 2 | 3 | 4 | 5 | 6;
|
||||
children: React.ReactNode;
|
||||
}
|
||||
|
||||
/**
|
||||
* Heading component for h1-h6 elements.
|
||||
* Renders semantic heading levels with consistent styling.
|
||||
* Anchor IDs are assigned by the rehypeHeadingId plugin.
|
||||
*/
|
||||
export const Heading = ({ level, children, className, node: _node, ...props }: HeadingProps) => {
|
||||
const Component = `h${level}` as const;
|
||||
|
||||
return (
|
||||
<Component className={cn(headingClass(level), className)} {...props}>
|
||||
{children}
|
||||
</Component>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,12 @@
|
||||
import { markdownStyles } from "@/lib/markdownStyles";
|
||||
import { cn } from "@/lib/utils";
|
||||
import type { ReactMarkdownProps } from "./types";
|
||||
|
||||
interface HorizontalRuleProps extends React.HTMLAttributes<HTMLHRElement>, ReactMarkdownProps {}
|
||||
|
||||
/**
|
||||
* Horizontal rule separator
|
||||
*/
|
||||
export const HorizontalRule = ({ className, node: _node, ...props }: HorizontalRuleProps) => {
|
||||
return <hr className={cn(markdownStyles.horizontalRule, className)} {...props} />;
|
||||
};
|
||||
@@ -0,0 +1,19 @@
|
||||
import { cn } from "@/lib/utils";
|
||||
import type { ReactMarkdownProps } from "./types";
|
||||
|
||||
interface ImageProps extends React.ImgHTMLAttributes<HTMLImageElement>, ReactMarkdownProps {}
|
||||
|
||||
/**
|
||||
* Image component for markdown images
|
||||
* Responsive with rounded corners
|
||||
*/
|
||||
export const Image = ({ className, alt, node: _node, height, width, style, ...props }: ImageProps) => {
|
||||
return (
|
||||
<img
|
||||
className={cn("max-w-full my-2", !height && "h-auto", className)}
|
||||
alt={alt}
|
||||
style={{ height: height ? `${height}px` : undefined, width: width ? `${width}px` : undefined, ...style }}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,18 @@
|
||||
import { markdownStyles } from "@/lib/markdownStyles";
|
||||
import { cn } from "@/lib/utils";
|
||||
import type { ReactMarkdownProps } from "./types";
|
||||
|
||||
interface InlineCodeProps extends React.HTMLAttributes<HTMLElement>, ReactMarkdownProps {
|
||||
children: React.ReactNode;
|
||||
}
|
||||
|
||||
/**
|
||||
* Inline code component with background and monospace font
|
||||
*/
|
||||
export const InlineCode = ({ children, className, node: _node, ...props }: InlineCodeProps) => {
|
||||
return (
|
||||
<code className={cn(markdownStyles.inlineCode, className)} {...props}>
|
||||
{children}
|
||||
</code>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,19 @@
|
||||
import { markdownStyles } from "@/lib/markdownStyles";
|
||||
import { cn } from "@/lib/utils";
|
||||
import type { ReactMarkdownProps } from "./types";
|
||||
|
||||
interface LinkProps extends React.AnchorHTMLAttributes<HTMLAnchorElement>, ReactMarkdownProps {
|
||||
children: React.ReactNode;
|
||||
}
|
||||
|
||||
/**
|
||||
* Link component for external links
|
||||
* Opens in new tab with security attributes
|
||||
*/
|
||||
export const Link = ({ children, className, href, node: _node, ...props }: LinkProps) => {
|
||||
return (
|
||||
<a href={href} target="_blank" rel="noopener noreferrer" className={cn(markdownStyles.link, className)} {...props}>
|
||||
{children}
|
||||
</a>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,116 @@
|
||||
import { Children, cloneElement, isValidElement, type ReactElement, type ReactNode } from "react";
|
||||
import { markdownStyles } from "@/lib/markdownStyles";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { TASK_LIST_CLASS, TASK_LIST_ITEM_CLASS } from "../constants";
|
||||
import { NestedMarkdownRenderContext } from "../MarkdownRenderContext";
|
||||
import type { ReactMarkdownProps } from "./types";
|
||||
|
||||
interface TaskListChildProps {
|
||||
children?: ReactNode;
|
||||
node?: {
|
||||
tagName?: string;
|
||||
properties?: {
|
||||
type?: unknown;
|
||||
};
|
||||
};
|
||||
type?: string;
|
||||
}
|
||||
|
||||
const isCheckboxInput = (child: ReactNode): child is ReactElement<TaskListChildProps> => {
|
||||
return (
|
||||
isValidElement<TaskListChildProps>(child) && (child.props.type === "checkbox" || child.props.node?.properties?.type === "checkbox")
|
||||
);
|
||||
};
|
||||
|
||||
const isParagraphElement = (child: ReactNode): child is ReactElement<TaskListChildProps> => {
|
||||
return isValidElement<TaskListChildProps>(child) && (child.type === "p" || child.props.node?.tagName === "p");
|
||||
};
|
||||
|
||||
const splitTaskListItemChildren = (children: ReactNode) => {
|
||||
let checkbox: ReactNode;
|
||||
const content: ReactNode[] = [];
|
||||
|
||||
Children.toArray(children).forEach((child) => {
|
||||
if (!checkbox && isCheckboxInput(child)) {
|
||||
checkbox = child;
|
||||
return;
|
||||
}
|
||||
|
||||
if (!checkbox && isParagraphElement(child)) {
|
||||
const paragraphChildren: ReactNode[] = [];
|
||||
|
||||
Children.toArray(child.props.children).forEach((paragraphChild) => {
|
||||
if (!checkbox && isCheckboxInput(paragraphChild)) {
|
||||
checkbox = paragraphChild;
|
||||
return;
|
||||
}
|
||||
paragraphChildren.push(paragraphChild);
|
||||
});
|
||||
|
||||
if (checkbox) {
|
||||
if (paragraphChildren.length > 0) {
|
||||
content.push(cloneElement(child, undefined, ...paragraphChildren));
|
||||
}
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
content.push(child);
|
||||
});
|
||||
|
||||
return { checkbox, content };
|
||||
};
|
||||
|
||||
interface ListProps extends React.HTMLAttributes<HTMLUListElement | HTMLOListElement>, ReactMarkdownProps {
|
||||
ordered?: boolean;
|
||||
children: React.ReactNode;
|
||||
}
|
||||
|
||||
/**
|
||||
* List component for both regular and task lists (GFM)
|
||||
* Detects task lists via the "contains-task-list" class added by remark-gfm
|
||||
*/
|
||||
export const List = ({ ordered, children, className, node: _node, ...domProps }: ListProps) => {
|
||||
const Component = ordered ? "ol" : "ul";
|
||||
const isTaskList = className?.includes(TASK_LIST_CLASS);
|
||||
// Task list indentation is handled by task item grid columns; regular lists
|
||||
// use the shared token (padding + list style).
|
||||
const listClass = isTaskList ? "my-0 mb-2 list-outside list-none" : ordered ? markdownStyles.orderedList : markdownStyles.bulletList;
|
||||
|
||||
return (
|
||||
<Component className={cn(listClass, className)} {...domProps}>
|
||||
{children}
|
||||
</Component>
|
||||
);
|
||||
};
|
||||
|
||||
interface ListItemProps extends React.LiHTMLAttributes<HTMLLIElement>, ReactMarkdownProps {
|
||||
children: React.ReactNode;
|
||||
}
|
||||
|
||||
/**
|
||||
* List item component for both regular and task list items
|
||||
* Detects task items via the "task-list-item" class added by remark-gfm
|
||||
*/
|
||||
export const ListItem = ({ children, className, node: _node, ...domProps }: ListItemProps) => {
|
||||
const isTaskListItem = className?.includes(TASK_LIST_ITEM_CLASS);
|
||||
|
||||
if (isTaskListItem) {
|
||||
const { checkbox, content } = splitTaskListItemChildren(children);
|
||||
|
||||
return (
|
||||
<li className={cn(markdownStyles.taskListItem, className)} {...domProps}>
|
||||
<NestedMarkdownRenderContext>
|
||||
{checkbox}
|
||||
<div className={markdownStyles.taskItemContent}>{content}</div>
|
||||
</NestedMarkdownRenderContext>
|
||||
</li>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<li className={cn(markdownStyles.listItem, className)} {...domProps}>
|
||||
<NestedMarkdownRenderContext>{children}</NestedMarkdownRenderContext>
|
||||
</li>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,63 @@
|
||||
import type { Element } from "hast";
|
||||
import { useLinkPreviewEnabled } from "@/contexts/ViewContext";
|
||||
import { markdownStyles } from "@/lib/markdownStyles";
|
||||
import { cn } from "@/lib/utils";
|
||||
import LinkMetadataCard from "../LinkMetadataCard";
|
||||
import { useMarkdownRenderContext } from "../MarkdownRenderContext";
|
||||
import type { ReactMarkdownProps } from "./types";
|
||||
|
||||
interface ParagraphProps extends React.HTMLAttributes<HTMLParagraphElement>, ReactMarkdownProps {
|
||||
children: React.ReactNode;
|
||||
}
|
||||
|
||||
export function getSingleLinkHref(node?: Element): string | undefined {
|
||||
if (!node || node.tagName !== "p") {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const meaningfulChildren = node.children.filter((child) => {
|
||||
return !(child.type === "text" && child.value.trim() === "");
|
||||
});
|
||||
|
||||
if (meaningfulChildren.length !== 1) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const onlyChild = meaningfulChildren[0];
|
||||
if (onlyChild.type !== "element" || onlyChild.tagName !== "a") {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const href = onlyChild.properties?.href;
|
||||
if (typeof href !== "string") {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const meaningfulLinkChildren = onlyChild.children.filter((child) => {
|
||||
return !(child.type === "text" && child.value.trim() === "");
|
||||
});
|
||||
|
||||
if (meaningfulLinkChildren.length !== 1) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const onlyLinkChild = meaningfulLinkChildren[0];
|
||||
return onlyLinkChild.type === "text" && onlyLinkChild.value === href ? href : undefined;
|
||||
}
|
||||
|
||||
export const Paragraph = ({ children, className, node, ...props }: ParagraphProps) => {
|
||||
const { blockDepth } = useMarkdownRenderContext();
|
||||
const linkPreviewEnabled = useLinkPreviewEnabled();
|
||||
const href = blockDepth === 0 && linkPreviewEnabled ? getSingleLinkHref(node) : undefined;
|
||||
const paragraph = (
|
||||
<p className={cn(markdownStyles.paragraph, className)} {...props}>
|
||||
{children}
|
||||
</p>
|
||||
);
|
||||
|
||||
if (href) {
|
||||
return <LinkMetadataCard url={href} fallback={paragraph} />;
|
||||
}
|
||||
|
||||
return paragraph;
|
||||
};
|
||||
@@ -0,0 +1,19 @@
|
||||
# Markdown Components
|
||||
|
||||
Small React components used by `MemoMarkdownRenderer` to style HTML emitted by `react-markdown`.
|
||||
|
||||
## Responsibilities
|
||||
|
||||
- Keep element styling local to each semantic HTML element.
|
||||
- Strip the `node` prop from DOM output through `ReactMarkdownProps`.
|
||||
- Preserve existing markdown behavior while avoiding structural fixes in CSS.
|
||||
|
||||
## Task Lists
|
||||
|
||||
GFM task lists are normalized before rendering by `remarkSplitMixedTaskLists`.
|
||||
|
||||
- Mixed task/bullet lists are split into separate lists so regular bullets keep bullets.
|
||||
- Single-block split items are rendered as tight list items, preventing accidental `<p>` wrappers.
|
||||
- `ListItem` uses a two-column grid: checkbox/control in the first column and a single task-body wrapper in the second.
|
||||
- Task text, emphasis, links, tags, and nested content stay inside the body wrapper so inline markdown does not become separate grid items.
|
||||
- Loose task items keep paragraph structure inside the task-body wrapper.
|
||||
@@ -0,0 +1,9 @@
|
||||
export { AnchorLink } from "./AnchorLink";
|
||||
export { Blockquote } from "./Blockquote";
|
||||
export { Heading } from "./Heading";
|
||||
export { HorizontalRule } from "./HorizontalRule";
|
||||
export { Image } from "./Image";
|
||||
export { InlineCode } from "./InlineCode";
|
||||
export { Link } from "./Link";
|
||||
export { List, ListItem } from "./List";
|
||||
export { Paragraph } from "./Paragraph";
|
||||
@@ -0,0 +1,9 @@
|
||||
import type { Element } from "hast";
|
||||
|
||||
/**
|
||||
* Props passed by react-markdown to custom components
|
||||
* Includes the AST node for advanced use cases
|
||||
*/
|
||||
export interface ReactMarkdownProps {
|
||||
node?: Element;
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
import type React from "react";
|
||||
|
||||
export interface MemoContentProps {
|
||||
content: string;
|
||||
/** Resource name of the memo (e.g. `memos/abc123`). Enables footnote links to target the memo detail page. */
|
||||
memoName?: string;
|
||||
/** The card renders collapsed (ClampedSection), so footnote links navigate instead of scrolling. */
|
||||
compact?: boolean;
|
||||
className?: string;
|
||||
contentClassName?: string;
|
||||
onClick?: (e: React.MouseEvent) => void;
|
||||
onDoubleClick?: (e: React.MouseEvent) => void;
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
import { isValidElement, type ReactElement, type ReactNode } from "react";
|
||||
|
||||
/**
|
||||
* Extracts code content from a react-markdown code element.
|
||||
* Handles the nested structure where code is passed as children.
|
||||
*
|
||||
* @param children - The children prop from react-markdown (typically a code element)
|
||||
* @returns The extracted code content as a string with trailing newline removed
|
||||
*/
|
||||
export const extractCodeContent = (children: ReactNode): string => {
|
||||
const codeElement = isValidElement(children) ? (children as ReactElement<{ children?: ReactNode }>) : null;
|
||||
return String(codeElement?.props.children || "").replace(/\n$/, "");
|
||||
};
|
||||
|
||||
/**
|
||||
* Extracts the language identifier from a code block's className.
|
||||
* react-markdown uses the format "language-xxx" for code blocks.
|
||||
*
|
||||
* @param className - The className string from a code element
|
||||
* @returns The language identifier, or empty string if none found
|
||||
*/
|
||||
export const extractLanguage = (className: string): string => {
|
||||
const match = /language-(\w+)/.exec(className);
|
||||
return match ? match[1] : "";
|
||||
};
|
||||
@@ -0,0 +1,139 @@
|
||||
import { create } from "@bufbuild/protobuf";
|
||||
import { timestampDate } from "@bufbuild/protobuf/wkt";
|
||||
import { isEqual } from "lodash-es";
|
||||
import { CheckCircleIcon, ChevronRightIcon, Code2Icon, HashIcon, ImageIcon, LinkIcon, type LucideIcon, Share2Icon } from "lucide-react";
|
||||
import { useMemo, useState } from "react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import useCurrentUser from "@/hooks/useCurrentUser";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { Memo, Memo_PropertySchema } from "@/types/proto/api/v1/memo_service_pb";
|
||||
import { type Translations, useTranslate } from "@/utils/i18n";
|
||||
import { extractHeadings } from "@/utils/markdown-manipulation";
|
||||
import { isSuperUser } from "@/utils/user";
|
||||
import MemoOutline from "./MemoOutline";
|
||||
import MemoSharePanel from "./MemoSharePanel";
|
||||
|
||||
interface Props {
|
||||
memo: Memo;
|
||||
className?: string;
|
||||
onShareImageOpen?: () => void;
|
||||
}
|
||||
|
||||
interface PropertyBadge {
|
||||
icon: LucideIcon;
|
||||
labelKey: Translations;
|
||||
}
|
||||
|
||||
const SidebarSection = ({ label, count, children }: { label: string; count?: number; children: React.ReactNode }) => (
|
||||
<div className="w-full space-y-2">
|
||||
<div className="flex items-center gap-1.5">
|
||||
<p className="text-xs font-medium text-muted-foreground/50 uppercase tracking-wider">{label}</p>
|
||||
{count != null && <span className="text-xs text-muted-foreground/30">({count})</span>}
|
||||
</div>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
|
||||
const PROPERTY_BADGE_CLASSES =
|
||||
"inline-flex items-center gap-1.5 px-2 py-1 rounded-md border border-border/60 bg-muted/60 text-xs text-muted-foreground";
|
||||
|
||||
const TAG_BADGE_CLASSES =
|
||||
"inline-flex items-center gap-1 px-1 rounded-md border border-border/60 bg-muted/60 text-sm text-muted-foreground hover:bg-muted hover:text-foreground/80 transition-colors cursor-pointer";
|
||||
|
||||
const SHARE_ACTION_ROW_CLASSES =
|
||||
"h-auto min-h-0 w-full justify-between rounded-none px-2 py-1.5 text-xs font-normal leading-tight text-muted-foreground transition-colors hover:bg-muted/40 hover:text-muted-foreground focus-visible:ring-offset-0 gap-1.5";
|
||||
|
||||
const MemoDetailSidebar = ({ memo, className, onShareImageOpen }: Props) => {
|
||||
const t = useTranslate();
|
||||
const currentUser = useCurrentUser();
|
||||
const [sharePanelOpen, setSharePanelOpen] = useState(false);
|
||||
const property = create(Memo_PropertySchema, memo.property || {});
|
||||
const canManageShares = !memo.parent && (memo.creator === currentUser?.name || isSuperUser(currentUser));
|
||||
const hasUpdated = !isEqual(memo.createTime, memo.updateTime);
|
||||
const headings = useMemo(() => extractHeadings(memo.content), [memo.content]);
|
||||
|
||||
const propertyBadges = useMemo(() => {
|
||||
const badges: PropertyBadge[] = [];
|
||||
if (property.hasLink) badges.push({ icon: LinkIcon, labelKey: "memo.links" });
|
||||
if (property.hasTaskList) badges.push({ icon: CheckCircleIcon, labelKey: "memo.to-do" });
|
||||
if (property.hasCode) badges.push({ icon: Code2Icon, labelKey: "memo.code" });
|
||||
return badges;
|
||||
}, [property.hasLink, property.hasTaskList, property.hasCode]);
|
||||
|
||||
return (
|
||||
<aside className={cn("relative w-full h-auto max-h-screen overflow-auto flex flex-col gap-5", className)}>
|
||||
{headings.length > 0 && (
|
||||
<SidebarSection label={t("memo.outline")}>
|
||||
<MemoOutline headings={headings} />
|
||||
</SidebarSection>
|
||||
)}
|
||||
|
||||
{(canManageShares || onShareImageOpen) && (
|
||||
<SidebarSection label={t("memo.share.section-label")}>
|
||||
<div className="overflow-hidden rounded-md border border-border/50 bg-muted/20">
|
||||
{onShareImageOpen && (
|
||||
<Button variant="ghost" size="sm" className={SHARE_ACTION_ROW_CLASSES} onClick={onShareImageOpen}>
|
||||
<span className="flex min-w-0 flex-1 items-center gap-2">
|
||||
<ImageIcon className="size-3.5 shrink-0 text-muted-foreground/90" />
|
||||
<span className="truncate">{t("memo.share.open-image")}</span>
|
||||
</span>
|
||||
<ChevronRightIcon className="size-3.5 shrink-0 text-muted-foreground/35" />
|
||||
</Button>
|
||||
)}
|
||||
{onShareImageOpen && canManageShares && <div className="border-t border-border/50" />}
|
||||
{canManageShares && (
|
||||
<Button variant="ghost" size="sm" className={SHARE_ACTION_ROW_CLASSES} onClick={() => setSharePanelOpen(true)}>
|
||||
<span className="flex min-w-0 flex-1 items-center gap-2">
|
||||
<Share2Icon className="size-3.5 shrink-0 text-muted-foreground/90" />
|
||||
<span className="truncate">{t("memo.share.open-panel")}</span>
|
||||
</span>
|
||||
<ChevronRightIcon className="size-3.5 shrink-0 text-muted-foreground/35" />
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</SidebarSection>
|
||||
)}
|
||||
|
||||
<SidebarSection label={t("common.created-at")}>
|
||||
<div className="flex flex-col gap-1">
|
||||
<p className="text-sm text-foreground/70">{memo.createTime ? timestampDate(memo.createTime).toLocaleString() : "—"}</p>
|
||||
{hasUpdated && (
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{t("common.last-updated-at")}: {memo.updateTime ? timestampDate(memo.updateTime).toLocaleString() : "—"}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</SidebarSection>
|
||||
|
||||
{propertyBadges.length > 0 && (
|
||||
<SidebarSection label={t("common.properties")}>
|
||||
<div className="flex flex-wrap gap-1.5">
|
||||
{propertyBadges.map(({ icon: Icon, labelKey }) => (
|
||||
<span key={labelKey} className={PROPERTY_BADGE_CLASSES}>
|
||||
<Icon className="w-3.5 h-3.5" />
|
||||
{t(labelKey)}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
</SidebarSection>
|
||||
)}
|
||||
|
||||
{memo.tags.length > 0 && (
|
||||
<SidebarSection label={t("common.tags")} count={memo.tags.length}>
|
||||
<div className="flex flex-wrap gap-1.5">
|
||||
{memo.tags.map((tag) => (
|
||||
<span key={tag} className={TAG_BADGE_CLASSES}>
|
||||
<HashIcon className="w-3 h-3 opacity-50" />
|
||||
{tag}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
</SidebarSection>
|
||||
)}
|
||||
|
||||
{sharePanelOpen && <MemoSharePanel memoName={memo.name} open={sharePanelOpen} onClose={() => setSharePanelOpen(false)} />}
|
||||
</aside>
|
||||
);
|
||||
};
|
||||
|
||||
export default MemoDetailSidebar;
|
||||
@@ -0,0 +1,36 @@
|
||||
import { GanttChartIcon } from "lucide-react";
|
||||
import { useEffect, useState } from "react";
|
||||
import { useLocation } from "react-router-dom";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Sheet, SheetContent, SheetTrigger } from "@/components/ui/sheet";
|
||||
import { Memo } from "@/types/proto/api/v1/memo_service_pb";
|
||||
import MemoDetailSidebar from "./MemoDetailSidebar";
|
||||
|
||||
interface Props {
|
||||
memo: Memo;
|
||||
onShareImageOpen?: () => void;
|
||||
}
|
||||
|
||||
const MemoDetailSidebarDrawer = ({ memo, onShareImageOpen }: Props) => {
|
||||
const location = useLocation();
|
||||
const [open, setOpen] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
setOpen(false);
|
||||
}, [location.pathname]);
|
||||
|
||||
return (
|
||||
<Sheet open={open} onOpenChange={setOpen}>
|
||||
<SheetTrigger asChild>
|
||||
<Button variant="ghost" size="sm" className="px-2">
|
||||
<GanttChartIcon className="w-5 h-auto text-muted-foreground" />
|
||||
</Button>
|
||||
</SheetTrigger>
|
||||
<SheetContent side="right" className="w-full sm:w-80 px-4 bg-background">
|
||||
<MemoDetailSidebar className="py-4" memo={memo} onShareImageOpen={onShareImageOpen} />
|
||||
</SheetContent>
|
||||
</Sheet>
|
||||
);
|
||||
};
|
||||
|
||||
export default MemoDetailSidebarDrawer;
|
||||
@@ -0,0 +1,52 @@
|
||||
import { cn } from "@/lib/utils";
|
||||
import type { HeadingItem } from "@/utils/markdown-manipulation";
|
||||
|
||||
interface MemoOutlineProps {
|
||||
headings: HeadingItem[];
|
||||
}
|
||||
|
||||
const levelIndent: Record<number, string> = {
|
||||
1: "ml-0",
|
||||
2: "ml-3",
|
||||
3: "ml-6",
|
||||
4: "ml-8",
|
||||
};
|
||||
|
||||
/** Outline navigation for memo headings (h1–h4). */
|
||||
const MemoOutline = ({ headings }: MemoOutlineProps) => {
|
||||
const handleClick = (e: React.MouseEvent<HTMLAnchorElement>, slug: string) => {
|
||||
e.preventDefault();
|
||||
const el = document.getElementById(slug);
|
||||
if (el) {
|
||||
el.scrollIntoView({ behavior: "smooth", block: "start" });
|
||||
window.history.replaceState(null, "", `#${slug}`);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<nav className="relative flex flex-col">
|
||||
{headings.map((heading, index) => (
|
||||
<a
|
||||
key={`${heading.slug}-${index}`}
|
||||
href={`#${heading.slug}`}
|
||||
onClick={(e) => handleClick(e, heading.slug)}
|
||||
className={cn(
|
||||
"group relative block py-[5px] pr-1 text-[13px] leading-snug truncate",
|
||||
"text-muted-foreground/60 hover:text-foreground/90",
|
||||
"transition-colors duration-200 ease-out",
|
||||
levelIndent[heading.level],
|
||||
heading.level === 1 && "font-medium text-muted-foreground/80",
|
||||
)}
|
||||
title={heading.text}
|
||||
>
|
||||
<span className="relative">
|
||||
{heading.text}
|
||||
<span className="absolute -bottom-px left-0 h-px w-0 bg-foreground/30 transition-all duration-200 group-hover:w-full" />
|
||||
</span>
|
||||
</a>
|
||||
))}
|
||||
</nav>
|
||||
);
|
||||
};
|
||||
|
||||
export default MemoOutline;
|
||||
@@ -0,0 +1,158 @@
|
||||
import { timestampDate } from "@bufbuild/protobuf/wkt";
|
||||
import { ConnectError } from "@connectrpc/connect";
|
||||
import { CheckIcon, CopyIcon, LinkIcon, Loader2Icon, Trash2Icon } from "lucide-react";
|
||||
import { useState } from "react";
|
||||
import { toast } from "react-hot-toast";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Dialog, DialogContent, DialogHeader, DialogTitle } from "@/components/ui/dialog";
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
||||
import { getShareUrl, useCreateMemoShare, useDeleteMemoShare, useMemoShares } from "@/hooks/useMemoShareQueries";
|
||||
import type { MemoShare } from "@/types/proto/api/v1/memo_service_pb";
|
||||
import { useTranslate } from "@/utils/i18n";
|
||||
|
||||
type ExpiryOption = "never" | "1d" | "7d" | "30d";
|
||||
|
||||
function getExpireDate(option: ExpiryOption): Date | undefined {
|
||||
if (option === "never") return undefined;
|
||||
const d = new Date();
|
||||
if (option === "1d") d.setDate(d.getDate() + 1);
|
||||
else if (option === "7d") d.setDate(d.getDate() + 7);
|
||||
else if (option === "30d") d.setDate(d.getDate() + 30);
|
||||
return d;
|
||||
}
|
||||
|
||||
function formatExpiry(share: MemoShare, t: ReturnType<typeof useTranslate>): string {
|
||||
if (!share.expireTime) return t("memo.share.never-expires");
|
||||
const d = timestampDate(share.expireTime);
|
||||
return t("memo.share.expires-on", { date: d.toLocaleDateString() });
|
||||
}
|
||||
|
||||
interface ShareLinkRowProps {
|
||||
share: MemoShare;
|
||||
memoName: string;
|
||||
}
|
||||
|
||||
function ShareLinkRow({ share, memoName }: ShareLinkRowProps) {
|
||||
const t = useTranslate();
|
||||
const [copied, setCopied] = useState(false);
|
||||
const deleteShare = useDeleteMemoShare();
|
||||
const url = getShareUrl(share);
|
||||
|
||||
const handleCopy = async () => {
|
||||
await navigator.clipboard.writeText(url);
|
||||
setCopied(true);
|
||||
setTimeout(() => setCopied(false), 2000);
|
||||
};
|
||||
|
||||
const handleRevoke = async () => {
|
||||
try {
|
||||
await deleteShare.mutateAsync({ name: share.name, memoName });
|
||||
toast.success(t("memo.share.revoked"));
|
||||
} catch (e) {
|
||||
toast.error((e as ConnectError).message || t("memo.share.revoke-failed"));
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-1 rounded-md border border-border p-3">
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<span className="truncate font-mono text-xs text-muted-foreground">{url}</span>
|
||||
<div className="flex shrink-0 items-center gap-1">
|
||||
<Button variant="ghost" size="icon" className="h-7 w-7" onClick={handleCopy} title={t("memo.share.copy")}>
|
||||
{copied ? <CheckIcon className="h-3.5 w-3.5 text-success" /> : <CopyIcon className="h-3.5 w-3.5" />}
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-7 w-7 text-destructive hover:text-destructive"
|
||||
onClick={handleRevoke}
|
||||
disabled={deleteShare.isPending}
|
||||
title={t("memo.share.revoke")}
|
||||
>
|
||||
<Trash2Icon className="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground">{formatExpiry(share, t)}</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
interface MemoSharePanelProps {
|
||||
open: boolean;
|
||||
onClose: () => void;
|
||||
memoName: string;
|
||||
}
|
||||
|
||||
const MemoSharePanel = ({ open, onClose, memoName }: MemoSharePanelProps) => {
|
||||
const t = useTranslate();
|
||||
const [expiry, setExpiry] = useState<ExpiryOption>("never");
|
||||
const { data: shares = [], isLoading } = useMemoShares(memoName, { enabled: open });
|
||||
const createShare = useCreateMemoShare();
|
||||
|
||||
const handleCreate = async () => {
|
||||
try {
|
||||
await createShare.mutateAsync({ memoName, expireTime: getExpireDate(expiry) });
|
||||
} catch (e) {
|
||||
toast.error((e as ConnectError).message || t("memo.share.create-failed"));
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={(v) => !v && onClose()}>
|
||||
<DialogContent className="sm:max-w-md">
|
||||
<DialogHeader>
|
||||
<DialogTitle className="flex items-center gap-2">
|
||||
<LinkIcon className="h-4 w-4" />
|
||||
{t("memo.share.title")}
|
||||
</DialogTitle>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="flex flex-col gap-4 py-2">
|
||||
{/* Active links */}
|
||||
<div className="flex flex-col gap-2">
|
||||
<p className="text-sm font-medium text-muted-foreground">{t("memo.share.active-links")}</p>
|
||||
{isLoading ? (
|
||||
<Loader2Icon className="h-4 w-4 animate-spin text-muted-foreground" />
|
||||
) : shares.length === 0 ? (
|
||||
<p className="text-sm text-muted-foreground">{t("memo.share.no-links")}</p>
|
||||
) : (
|
||||
<div className="flex flex-col gap-2">
|
||||
{shares.map((share) => (
|
||||
<ShareLinkRow key={share.name} share={share} memoName={memoName} />
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Create new link */}
|
||||
<div className="flex items-center gap-2">
|
||||
<Select value={expiry} onValueChange={(v) => setExpiry(v as ExpiryOption)}>
|
||||
<SelectTrigger className="w-36">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="never">{t("memo.share.expiry-never")}</SelectItem>
|
||||
<SelectItem value="1d">{t("memo.share.expiry-1-day")}</SelectItem>
|
||||
<SelectItem value="7d">{t("memo.share.expiry-7-days")}</SelectItem>
|
||||
<SelectItem value="30d">{t("memo.share.expiry-30-days")}</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<Button onClick={handleCreate} disabled={createShare.isPending} className="flex-1">
|
||||
{createShare.isPending ? (
|
||||
<>
|
||||
<Loader2Icon className="mr-2 h-4 w-4 animate-spin" />
|
||||
{t("memo.share.creating")}
|
||||
</>
|
||||
) : (
|
||||
t("memo.share.create-link")
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
};
|
||||
|
||||
export default MemoSharePanel;
|
||||
@@ -0,0 +1,4 @@
|
||||
import MemoDetailSidebar from "./MemoDetailSidebar";
|
||||
import MemoDetailSidebarDrawer from "./MemoDetailSidebarDrawer";
|
||||
|
||||
export { MemoDetailSidebar, MemoDetailSidebarDrawer };
|
||||
@@ -0,0 +1,151 @@
|
||||
import { Columns2Icon, Columns3Icon, InfinityIcon, type LucideIcon, Rows3Icon, Settings2Icon } from "lucide-react";
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
||||
import { Switch } from "@/components/ui/switch";
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
|
||||
import { MAX_COLUMNS_VALUES, type MemoMaxColumns, useView } from "@/contexts/ViewContext";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { useTranslate } from "@/utils/i18n";
|
||||
import { Popover, PopoverContent, PopoverTrigger } from "./ui/popover";
|
||||
|
||||
interface Props {
|
||||
className?: string;
|
||||
}
|
||||
|
||||
// Keyed by the context's canonical value list, so adding a column option forces an icon and
|
||||
// wording here at compile time. The i18n param is deliberately named `n`, not `count` —
|
||||
// i18next would route `count` through plural-form lookup.
|
||||
const LAYOUT_OPTIONS: Record<MemoMaxColumns, { icon: LucideIcon; key: "layout-list" | "layout-columns" | "layout-auto" }> = {
|
||||
1: { icon: Rows3Icon, key: "layout-list" },
|
||||
2: { icon: Columns2Icon, key: "layout-columns" },
|
||||
3: { icon: Columns3Icon, key: "layout-columns" },
|
||||
0: { icon: InfinityIcon, key: "layout-auto" },
|
||||
};
|
||||
|
||||
function MemoDisplaySettingMenu({ className }: Props) {
|
||||
const t = useTranslate();
|
||||
const {
|
||||
orderByTimeAsc,
|
||||
timeBasis,
|
||||
compactMode,
|
||||
linkPreview,
|
||||
maxColumns,
|
||||
setTimeBasis,
|
||||
toggleSortOrder,
|
||||
setCompactMode,
|
||||
setLinkPreview,
|
||||
setMaxColumns,
|
||||
} = useView();
|
||||
// Multi-column grids always render compact tiles, so the toggle is shown as on and locked
|
||||
// there; it only becomes a real choice at a single column.
|
||||
const compactLocked = maxColumns !== 1;
|
||||
|
||||
return (
|
||||
<Popover>
|
||||
<PopoverTrigger className={cn(className, "opacity-40 hover:opacity-100 transition-opacity")}>
|
||||
<Settings2Icon className="w-4 h-auto shrink-0" />
|
||||
</PopoverTrigger>
|
||||
<PopoverContent align="end" alignOffset={-12} sideOffset={14}>
|
||||
<div className="flex flex-col gap-2 p-1">
|
||||
<div className="w-full flex flex-row justify-between items-center">
|
||||
<span className="text-sm shrink-0 mr-3 text-foreground">{t("memo.shown-time")}</span>
|
||||
<Select value={timeBasis} onValueChange={(value) => setTimeBasis(value === "update_time" ? "update_time" : "create_time")}>
|
||||
<SelectTrigger size="sm" className="w-32">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="create_time">{t("common.created-at")}</SelectItem>
|
||||
<SelectItem value="update_time">{t("common.last-updated-at")}</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div className="w-full flex flex-row justify-between items-center">
|
||||
<span className="text-sm shrink-0 mr-3 text-foreground">{t("memo.order")}</span>
|
||||
<Select
|
||||
value={orderByTimeAsc.toString()}
|
||||
onValueChange={(value) => {
|
||||
if ((value === "true") !== orderByTimeAsc) {
|
||||
toggleSortOrder();
|
||||
}
|
||||
}}
|
||||
>
|
||||
<SelectTrigger size="sm" className="w-32">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="false">{t("memo.newest-first")}</SelectItem>
|
||||
<SelectItem value="true">{t("memo.oldest-first")}</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div className="w-full flex flex-row justify-between items-center">
|
||||
<span className={cn("text-sm shrink-0 mr-3", compactLocked ? "text-muted-foreground" : "text-foreground")}>
|
||||
{t("memo.compact-mode")}
|
||||
</span>
|
||||
<Switch checked={compactLocked || compactMode} onCheckedChange={setCompactMode} disabled={compactLocked} />
|
||||
</div>
|
||||
<div className="w-full flex flex-row justify-between items-center">
|
||||
<span className="text-sm shrink-0 mr-3 text-foreground">{t("memo.link-preview")}</span>
|
||||
<Switch checked={linkPreview} onCheckedChange={setLinkPreview} />
|
||||
</div>
|
||||
<div className="w-full flex flex-row justify-between items-center border-t border-border/50 pt-2">
|
||||
<span className="text-sm shrink-0 mr-3 text-foreground">{t("memo.layout")}</span>
|
||||
{/* A quiet muted track (28px tall, borderless); only the active option carries the accent
|
||||
fill. A radiogroup with roving tabindex, since the options are mutually exclusive. */}
|
||||
<div
|
||||
role="radiogroup"
|
||||
aria-label={t("memo.layout")}
|
||||
className="flex items-center gap-0.5 rounded-lg bg-muted/50 p-0.5"
|
||||
onKeyDown={(event) => {
|
||||
const delta =
|
||||
event.key === "ArrowRight" || event.key === "ArrowDown"
|
||||
? 1
|
||||
: event.key === "ArrowLeft" || event.key === "ArrowUp"
|
||||
? -1
|
||||
: 0;
|
||||
if (delta === 0) return;
|
||||
event.preventDefault();
|
||||
const index = MAX_COLUMNS_VALUES.indexOf(maxColumns);
|
||||
const next = MAX_COLUMNS_VALUES[(index + delta + MAX_COLUMNS_VALUES.length) % MAX_COLUMNS_VALUES.length];
|
||||
setMaxColumns(next);
|
||||
event.currentTarget.querySelector<HTMLButtonElement>(`[data-value="${next}"]`)?.focus();
|
||||
}}
|
||||
>
|
||||
{MAX_COLUMNS_VALUES.map((value) => {
|
||||
const { icon: Icon, key } = LAYOUT_OPTIONS[value];
|
||||
const label = t(`memo.${key}`, { n: value });
|
||||
const active = maxColumns === value;
|
||||
return (
|
||||
<Tooltip key={value}>
|
||||
<TooltipTrigger asChild>
|
||||
<button
|
||||
type="button"
|
||||
role="radio"
|
||||
aria-checked={active}
|
||||
aria-label={label}
|
||||
tabIndex={active ? 0 : -1}
|
||||
data-value={value}
|
||||
onClick={() => setMaxColumns(value)}
|
||||
className={cn(
|
||||
"grid h-6 w-7 place-items-center rounded-md transition-colors",
|
||||
active ? "bg-accent text-accent-foreground" : "text-muted-foreground/70 hover:bg-accent/50 hover:text-foreground",
|
||||
)}
|
||||
>
|
||||
<Icon className="w-3.5 h-3.5" />
|
||||
</button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
<p className="font-medium">{label}</p>
|
||||
<p className="text-primary-foreground/70">{t(`memo.${key}-description`, { n: value })}</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
);
|
||||
}
|
||||
|
||||
export default MemoDisplaySettingMenu;
|
||||
@@ -0,0 +1,37 @@
|
||||
import { EditorSelection, type EditorState } from "@codemirror/state";
|
||||
import { EditorView } from "@codemirror/view";
|
||||
import type { EditorController, FormattingController } from "../types/editorController";
|
||||
|
||||
const isEmptyDoc = (state: EditorState) => state.doc.toString().trim() === "";
|
||||
|
||||
/** Block padding for insertMarkdown: ensure the inserted text is its own block. */
|
||||
function blockPad(before: string, after: string): { prefix: string; suffix: string } {
|
||||
const prefix = before.length === 0 || before.endsWith("\n\n") ? "" : before.endsWith("\n") ? "\n" : "\n\n";
|
||||
const suffix = after.length === 0 || after.startsWith("\n\n") ? "" : after.startsWith("\n") ? "\n" : "\n\n";
|
||||
return { prefix, suffix };
|
||||
}
|
||||
|
||||
export function createController(view: EditorView, formatting: FormattingController): EditorController {
|
||||
return {
|
||||
focus: () => view.focus(),
|
||||
hasFocus: () => view.hasFocus,
|
||||
isEmpty: () => isEmptyDoc(view.state),
|
||||
getMarkdown: () => view.state.doc.toString(),
|
||||
setMarkdown: (markdown) => {
|
||||
view.dispatch({ changes: { from: 0, to: view.state.doc.length, insert: markdown } });
|
||||
},
|
||||
insertMarkdown: (markdown) => {
|
||||
if (!markdown) return;
|
||||
const { from, to } = view.state.selection.main;
|
||||
const doc = view.state.doc.toString();
|
||||
const { prefix, suffix } = blockPad(doc.slice(0, from), doc.slice(to));
|
||||
const insert = prefix + markdown + suffix;
|
||||
const caret = from + insert.length;
|
||||
view.dispatch({ changes: { from, to, insert }, selection: { anchor: caret }, scrollIntoView: true });
|
||||
view.focus();
|
||||
},
|
||||
scrollToCursor: () => view.dispatch({ effects: EditorView.scrollIntoView(view.state.selection.main.head) }),
|
||||
selectAll: () => view.dispatch({ selection: EditorSelection.range(0, view.state.doc.length) }),
|
||||
formatting,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,138 @@
|
||||
/*
|
||||
* Styling for the CodeMirror source editor, in plain CSS with the app's theme
|
||||
* variables — the same model Obsidian / GitHub use for an app-integrated CM6
|
||||
* editor (class-based tokens + CSS, not a CSS-in-JS theme). Co-located with the
|
||||
* component and scoped to its wrapper so nothing leaks to other CM instances.
|
||||
*
|
||||
* Token classes (.cm-md-*) come from the tagHighlighter in theme.ts; heading
|
||||
* line classes (.cm-md-h*) from headingDecorations.ts; #tag/@mention
|
||||
* (.cm-memo-*) from tagMentionDecorations.ts; the rest are CodeMirror's own.
|
||||
*/
|
||||
.memo-editor-content {
|
||||
& .cm-editor {
|
||||
background: transparent;
|
||||
font-size: 1rem;
|
||||
max-height: 50vh;
|
||||
}
|
||||
&[data-focus-mode] .cm-editor {
|
||||
height: 100%;
|
||||
max-height: none;
|
||||
}
|
||||
& .cm-editor.cm-focused {
|
||||
outline: none;
|
||||
}
|
||||
& .cm-content {
|
||||
font-family: inherit;
|
||||
padding: 0;
|
||||
}
|
||||
& .cm-line {
|
||||
padding: 0;
|
||||
}
|
||||
& .cm-scroller {
|
||||
font-family: inherit;
|
||||
line-height: 1.5;
|
||||
overflow: auto;
|
||||
}
|
||||
& .cm-placeholder {
|
||||
color: var(--muted-foreground);
|
||||
opacity: 0.7;
|
||||
}
|
||||
|
||||
/* drawSelection() caret + selection (no base color theme is loaded). */
|
||||
& .cm-cursor,
|
||||
& .cm-dropCursor {
|
||||
border-left-color: var(--foreground);
|
||||
}
|
||||
& .cm-selectionBackground,
|
||||
& .cm-focused .cm-selectionBackground {
|
||||
background: var(--accent);
|
||||
}
|
||||
|
||||
/* Markdown tokens. Markers (#, *, fences, bullets) stay visible but muted
|
||||
(.cm-md-mark); styled text leads. */
|
||||
& .cm-md-strong {
|
||||
font-weight: 700;
|
||||
}
|
||||
& .cm-md-emphasis {
|
||||
font-style: italic;
|
||||
}
|
||||
& .cm-md-strike {
|
||||
text-decoration: line-through;
|
||||
}
|
||||
& .cm-md-code {
|
||||
font-family: var(--font-mono, monospace);
|
||||
color: var(--foreground);
|
||||
}
|
||||
& .cm-md-link {
|
||||
color: var(--primary);
|
||||
text-decoration: underline;
|
||||
}
|
||||
& .cm-md-url {
|
||||
color: var(--muted-foreground);
|
||||
}
|
||||
& .cm-md-quote {
|
||||
color: var(--muted-foreground);
|
||||
font-style: italic;
|
||||
}
|
||||
& .cm-md-mark {
|
||||
color: var(--muted-foreground);
|
||||
}
|
||||
|
||||
/* Heading lines (require a space after the hashes; headingDecorations.ts). */
|
||||
& .cm-md-h1 {
|
||||
font-size: 1.5em;
|
||||
font-weight: 600;
|
||||
line-height: 1.3;
|
||||
}
|
||||
& .cm-md-h2 {
|
||||
font-size: 1.3em;
|
||||
font-weight: 600;
|
||||
}
|
||||
& .cm-md-h3 {
|
||||
font-size: 1.15em;
|
||||
font-weight: 600;
|
||||
}
|
||||
& .cm-md-h4,
|
||||
& .cm-md-h5,
|
||||
& .cm-md-h6 {
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
/* #tag / @mention decorations. */
|
||||
& .cm-memo-tag,
|
||||
& .cm-memo-mention {
|
||||
color: var(--primary);
|
||||
}
|
||||
|
||||
/* Tag autocomplete popover — app surface, not CodeMirror defaults. */
|
||||
& .cm-tooltip.cm-tooltip-autocomplete {
|
||||
background: var(--popover);
|
||||
color: var(--popover-foreground);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 0.5rem;
|
||||
box-shadow: var(--shadow-md);
|
||||
overflow: hidden;
|
||||
font-family: inherit;
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
& .cm-tooltip-autocomplete > ul {
|
||||
font-family: inherit;
|
||||
color: var(--popover-foreground);
|
||||
}
|
||||
& .cm-tooltip-autocomplete > ul > li {
|
||||
padding: 0.25rem 0.5rem;
|
||||
color: var(--popover-foreground);
|
||||
}
|
||||
& .cm-tooltip-autocomplete > ul > li[aria-selected] {
|
||||
background: var(--accent);
|
||||
color: var(--accent-foreground);
|
||||
}
|
||||
& .cm-completionIcon {
|
||||
display: none;
|
||||
}
|
||||
& .cm-completionMatchedText {
|
||||
text-decoration: none;
|
||||
font-weight: 600;
|
||||
color: inherit;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,112 @@
|
||||
import { defaultKeymap, history, historyKeymap, indentWithTab } from "@codemirror/commands";
|
||||
import { markdown } from "@codemirror/lang-markdown";
|
||||
import { indentUnit } from "@codemirror/language";
|
||||
import { Compartment, type Extension } from "@codemirror/state";
|
||||
import { placeholder as cmPlaceholder, drawSelection, dropCursor, EditorView, type KeyBinding, keymap } from "@codemirror/view";
|
||||
import { GFM } from "@lezer/markdown";
|
||||
import { headingDecorations } from "./headingDecorations";
|
||||
import { liftListItem, sinkListItem } from "./listIndent";
|
||||
import { tagAutocomplete } from "./tagAutocomplete";
|
||||
import { tagMentionDecorations } from "./tagMentionDecorations";
|
||||
import { memoEditorTheme } from "./theme";
|
||||
|
||||
// Key bindings layered below the autocomplete keymap so the completion popup's
|
||||
// own Tab/Escape win while it is open. On a list item, Tab/Shift-Tab nest /
|
||||
// outdent it (marker-aware, CommonMark-valid); elsewhere they fall through to
|
||||
// indentWithTab's plain indent. Escape blurs the editor so keyboard users keep
|
||||
// an escape hatch out of the otherwise Tab-trapping editor.
|
||||
const editorKeys: KeyBinding[] = [
|
||||
{
|
||||
key: "Escape",
|
||||
run: (view) => {
|
||||
view.contentDOM.blur();
|
||||
return true;
|
||||
},
|
||||
},
|
||||
{ key: "Tab", run: sinkListItem },
|
||||
{ key: "Shift-Tab", run: liftListItem },
|
||||
];
|
||||
|
||||
export interface EditorExtensionsOptions {
|
||||
placeholder: string;
|
||||
onChange: (markdown: string) => void;
|
||||
onFiles: (files: File[]) => void;
|
||||
onUpdate: () => void;
|
||||
onSubmit: () => void;
|
||||
getTags: () => string[];
|
||||
}
|
||||
|
||||
export const placeholderCompartment = new Compartment();
|
||||
|
||||
function clipboardFiles(event: ClipboardEvent): File[] {
|
||||
const clipboard = event.clipboardData;
|
||||
if (!clipboard) return [];
|
||||
|
||||
const itemFiles = Array.from(clipboard.items)
|
||||
.filter((item) => item.kind === "file")
|
||||
.map((item) => item.getAsFile())
|
||||
.filter((file): file is File => file !== null);
|
||||
return itemFiles.length > 0 ? itemFiles : Array.from(clipboard.files);
|
||||
}
|
||||
|
||||
export function buildEditorExtensions({
|
||||
placeholder,
|
||||
onChange,
|
||||
onFiles,
|
||||
onUpdate,
|
||||
onSubmit,
|
||||
getTags,
|
||||
}: EditorExtensionsOptions): Extension[] {
|
||||
// Submitting must outrank defaultKeymap's own Mod-Enter (insertBlankLine): the save
|
||||
// shortcut ends the memo, it must not also edit the document. Meta and Ctrl are bound
|
||||
// explicitly (not via the platform-dependent Mod-) so Cmd+Enter and Ctrl+Enter both
|
||||
// submit everywhere, matching the historical window-level shortcut.
|
||||
const submit = () => {
|
||||
onSubmit();
|
||||
return true;
|
||||
};
|
||||
const submitKeys: KeyBinding[] = [
|
||||
{ key: "Meta-Enter", run: submit },
|
||||
{ key: "Ctrl-Enter", run: submit },
|
||||
];
|
||||
|
||||
return [
|
||||
// Core editing behavior. These are the pieces from CM6 setup that this memo
|
||||
// editor uses, without enabling multi-cursor selection.
|
||||
history(),
|
||||
drawSelection(),
|
||||
dropCursor(),
|
||||
// Indent with spaces (markdown), matching the 2-space bullet nesting.
|
||||
indentUnit.of(" "),
|
||||
markdown({ extensions: [GFM] }),
|
||||
...memoEditorTheme,
|
||||
EditorView.lineWrapping,
|
||||
placeholderCompartment.of(cmPlaceholder(placeholder)),
|
||||
EditorView.domEventHandlers({
|
||||
paste: (event) => {
|
||||
const files = clipboardFiles(event);
|
||||
if (files.length === 0) return false;
|
||||
onFiles(files);
|
||||
return true;
|
||||
},
|
||||
drop: (event) => {
|
||||
const files = Array.from(event.dataTransfer?.files ?? []);
|
||||
if (files.length === 0) return false;
|
||||
onFiles(files);
|
||||
return true;
|
||||
},
|
||||
}),
|
||||
tagMentionDecorations,
|
||||
headingDecorations,
|
||||
// tagAutocomplete must precede the editing keymap so the completion popup's
|
||||
// Enter/Tab/arrow bindings win while it is open.
|
||||
tagAutocomplete(getTags),
|
||||
keymap.of([...submitKeys, ...editorKeys, indentWithTab, ...defaultKeymap, ...historyKeymap]),
|
||||
EditorView.updateListener.of((u) => {
|
||||
if (u.docChanged) onChange(u.state.doc.toString());
|
||||
// Toolbar active-state depends only on the doc and selection; skip the
|
||||
// getActiveFormats tree walk on focus/viewport/measure-only updates.
|
||||
if (u.docChanged || u.selectionSet) onUpdate();
|
||||
}),
|
||||
];
|
||||
}
|
||||
@@ -0,0 +1,372 @@
|
||||
import { syntaxTree } from "@codemirror/language";
|
||||
import type { EditorState } from "@codemirror/state";
|
||||
import type { EditorView } from "@codemirror/view";
|
||||
import {
|
||||
type ActiveFormatState,
|
||||
type EditorCommandContext,
|
||||
type EditorCommandId,
|
||||
EMPTY_ACTIVE_FORMATS,
|
||||
toToolbarHeadingLevel,
|
||||
} from "../formatting/commands";
|
||||
import type { FormattingController } from "../types/editorController";
|
||||
import { leadingWhitespace, selectedLineNumbers } from "./listIndent";
|
||||
|
||||
type MarkCommand = "bold" | "italic" | "strikethrough" | "code";
|
||||
type ListCommand = "bulletList" | "orderedList" | "taskList";
|
||||
|
||||
// One row per inline mark: the markdown token plus the syntax-tree wrapper and
|
||||
// delimiter node names (verified empirically against the Lezer markdown parser:
|
||||
// StrongEmphasis/Emphasis use `EmphasisMark`, InlineCode uses `CodeMark`, and
|
||||
// GFM strikethrough uses `Strikethrough`/`StrikethroughMark`).
|
||||
// Dispatch, toggling, the delimiter guard, and active-state detection all
|
||||
// derive from this table, so adding a mark is one row here + a catalog entry.
|
||||
const MARKS: Record<MarkCommand, { token: string; wrapper: string; delimiter: string }> = {
|
||||
bold: { token: "**", wrapper: "StrongEmphasis", delimiter: "EmphasisMark" },
|
||||
italic: { token: "*", wrapper: "Emphasis", delimiter: "EmphasisMark" },
|
||||
strikethrough: { token: "~~", wrapper: "Strikethrough", delimiter: "StrikethroughMark" },
|
||||
code: { token: "`", wrapper: "InlineCode", delimiter: "CodeMark" },
|
||||
};
|
||||
const MARK_COMMANDS = Object.keys(MARKS) as MarkCommand[];
|
||||
const isMarkCommand = (command: EditorCommandId): command is MarkCommand => command in MARKS;
|
||||
const WRAPPER_TO_MARK: Record<string, MarkCommand> = Object.fromEntries(MARK_COMMANDS.map((c) => [MARKS[c].wrapper, c]));
|
||||
// A cursor inside one of these means the adjacent token characters belong to
|
||||
// real parsed markup (e.g. between the two `*` of a bold delimiter), not to a
|
||||
// dangling empty pair.
|
||||
const DELIMITER_NODES = new Set(MARK_COMMANDS.map((c) => MARKS[c].delimiter));
|
||||
// Doubled mark tokens (`****`, `~~~~`, …): what a freshly inserted empty pair
|
||||
// looks like. Markdown parses some of them as entirely different constructs
|
||||
// (`~~~~` is a bare tilde code fence), so consumers that would otherwise
|
||||
// believe that construct check here first.
|
||||
const EMPTY_MARK_PAIRS = new Set(MARK_COMMANDS.map((c) => MARKS[c].token + MARKS[c].token));
|
||||
const MAX_EMPTY_PAIR_LENGTH = Math.max(...MARK_COMMANDS.map((c) => 2 * MARKS[c].token.length));
|
||||
|
||||
/** Whether [from, to) is exactly some mark's empty delimiter pair. */
|
||||
function isEmptyMarkPair(state: EditorState, from: number, to: number): boolean {
|
||||
return to - from <= MAX_EMPTY_PAIR_LENGTH && EMPTY_MARK_PAIRS.has(state.sliceDoc(from, to));
|
||||
}
|
||||
|
||||
const LIST_MARKERS: Record<ListCommand, string> = { bulletList: "- ", orderedList: "1. ", taskList: "- [ ] " };
|
||||
|
||||
// Line-mode detection shared by the list toggles and getActiveFormats so the
|
||||
// highlighted state and the toggle-off condition can never disagree. Order
|
||||
// matters: a task line also matches the bullet pattern. Markers follow
|
||||
// listIndent.ts: bullets `-*+`, ordered `1.` / `1)`, content starts after the
|
||||
// marker's trailing whitespace.
|
||||
const TASK_LINE = /^(\s*)[-*+]\s+\[[ xX]\]\s+/;
|
||||
const BULLET_LINE = /^(\s*)[-*+]\s+/;
|
||||
const ORDERED_LINE = /^(\s*)\d+[.)]\s+/;
|
||||
// ATX heading; CommonMark allows up to three leading spaces and requires
|
||||
// whitespace after the hashes — a bare `#` or a `#tag` is intentionally NOT a
|
||||
// heading. Shared with headingDecorations.ts so toolbar state and rendered
|
||||
// heading styling can't drift.
|
||||
export const HEADING_LINE = /^ {0,3}(#{1,6})\s+/;
|
||||
// Region setHeading replaces: an existing heading prefix including its leading
|
||||
// spaces, or just the leading spaces on a non-heading line (the optional group
|
||||
// makes the regex always match).
|
||||
const HEADING_PREFIX = /^ {0,3}(?:#{1,6}\s+)?/;
|
||||
|
||||
type Tree = ReturnType<typeof syntaxTree>;
|
||||
type TreeNode = ReturnType<Tree["resolve"]>;
|
||||
|
||||
/** The node at (pos, side) and its ancestors, innermost first. */
|
||||
function* ancestors(tree: Tree, pos: number, side: -1 | 1): Generator<TreeNode> {
|
||||
for (let n: TreeNode | null = tree.resolve(pos, side); n; n = n.parent) {
|
||||
yield n;
|
||||
}
|
||||
}
|
||||
|
||||
/** Ranges of the direct `name` children of `node`. */
|
||||
function childRanges(node: TreeNode, name: string): { from: number; to: number }[] {
|
||||
const ranges: { from: number; to: number }[] = [];
|
||||
for (let child = node.firstChild; child; child = child.nextSibling) {
|
||||
if (child.name === name) ranges.push({ from: child.from, to: child.to });
|
||||
}
|
||||
return ranges;
|
||||
}
|
||||
|
||||
interface LineListInfo {
|
||||
mode: ListCommand | null;
|
||||
/** Length of leading whitespace. */
|
||||
indent: number;
|
||||
/** Offset within the line where the item content starts (=== indent when mode is null). */
|
||||
markerEnd: number;
|
||||
}
|
||||
|
||||
function lineListInfo(text: string): LineListInfo {
|
||||
const task = TASK_LINE.exec(text);
|
||||
if (task) return { mode: "taskList", indent: task[1].length, markerEnd: task[0].length };
|
||||
const bullet = BULLET_LINE.exec(text);
|
||||
if (bullet) return { mode: "bulletList", indent: bullet[1].length, markerEnd: bullet[0].length };
|
||||
const ordered = ORDERED_LINE.exec(text);
|
||||
if (ordered) return { mode: "orderedList", indent: ordered[1].length, markerEnd: ordered[0].length };
|
||||
const indent = leadingWhitespace(text);
|
||||
return { mode: null, indent, markerEnd: indent };
|
||||
}
|
||||
|
||||
function wrapSelection(view: EditorView, token: string) {
|
||||
const { from, to } = view.state.selection.main;
|
||||
const sel = view.state.sliceDoc(from, to);
|
||||
view.dispatch({
|
||||
changes: { from, to, insert: `${token}${sel}${token}` },
|
||||
selection: { anchor: from + token.length, head: from + token.length + sel.length },
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Delimiter child ranges of the nearest `wrapper` ancestor at the selection,
|
||||
* or null when the selection doesn't sit in one (or it has fewer than
|
||||
* `minMarks` delimiters). Shared by every toggle-off path so they all agree.
|
||||
*
|
||||
* The head probe mirrors getActiveFormats (resolve side -1) so stripping
|
||||
* fires exactly when the toolbar shows the command active. With a non-empty
|
||||
* selection, additionally probe both edges from inside the selection, so a
|
||||
* selection that includes the delimiters (the whole `**text**`) still
|
||||
* resolves into the wrapper regardless of selection direction.
|
||||
*/
|
||||
function findWrappedDelimiters(
|
||||
view: EditorView,
|
||||
wrapper: string,
|
||||
delimiter: string,
|
||||
minMarks: number,
|
||||
): { from: number; to: number }[] | null {
|
||||
const { from, to, head } = view.state.selection.main;
|
||||
const tree = syntaxTree(view.state);
|
||||
const probes: [number, -1 | 1][] =
|
||||
from === to
|
||||
? [[head, -1]]
|
||||
: [
|
||||
[head, -1],
|
||||
[from, 1],
|
||||
[to, -1],
|
||||
];
|
||||
for (const [pos, side] of probes) {
|
||||
for (const n of ancestors(tree, pos, side)) {
|
||||
if (n.name !== wrapper) continue;
|
||||
const marks = childRanges(n, delimiter);
|
||||
if (marks.length >= minMarks) return marks;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Toggle an inline mark (bold/italic/strikethrough/code). When the selection
|
||||
* already sits in the corresponding mark, strip the surrounding delimiter
|
||||
* nodes instead of nesting a new pair. Deleting the actual delimiter child
|
||||
* ranges handles the differing delimiter lengths (`**` vs `` ` ``) automatically.
|
||||
*/
|
||||
function toggleMark(view: EditorView, command: MarkCommand) {
|
||||
const { token, wrapper, delimiter } = MARKS[command];
|
||||
const marks = findWrappedDelimiters(view, wrapper, delimiter, 2);
|
||||
if (marks) {
|
||||
const opening = marks[0];
|
||||
const closing = marks[marks.length - 1];
|
||||
view.dispatch({
|
||||
changes: [
|
||||
{ from: closing.from, to: closing.to, insert: "" },
|
||||
{ from: opening.from, to: opening.to, insert: "" },
|
||||
],
|
||||
});
|
||||
return;
|
||||
}
|
||||
// Empty pair: a cursor sitting between freshly inserted delimiters (`**|**`).
|
||||
// Markdown never parses an empty mark as that mark (bare `****` is a
|
||||
// horizontal rule, `~~~~` a tilde code fence, `` `` `` plain text), so the
|
||||
// tree probe above can't see it — check the text instead. Without this,
|
||||
// re-clicking the button keeps nesting new pairs.
|
||||
const { from, to } = view.state.selection.main;
|
||||
if (
|
||||
from === to &&
|
||||
from >= token.length &&
|
||||
to + token.length <= view.state.doc.length &&
|
||||
view.state.sliceDoc(from - token.length, to + token.length) === token + token
|
||||
) {
|
||||
const delFrom = from - token.length;
|
||||
const delTo = to + token.length;
|
||||
// Deleting is only unsafe when the adjacent tokens belong to parsed markup
|
||||
// reaching beyond the pair itself — e.g. an italic click between the `*`s
|
||||
// of a bold delimiter would destroy that bold. A construct contained
|
||||
// entirely in the deletion range (the `~~~~` the parser reads as an empty
|
||||
// tilde fence) is just this empty pair wearing another node name.
|
||||
const tree = syntaxTree(view.state);
|
||||
const blocking = (n: TreeNode) => {
|
||||
if (!DELIMITER_NODES.has(n.name)) return false;
|
||||
const construct = n.parent ?? n;
|
||||
return construct.from < delFrom || construct.to > delTo;
|
||||
};
|
||||
if (!blocking(tree.resolve(from, -1)) && !blocking(tree.resolve(to, 1))) {
|
||||
view.dispatch({
|
||||
changes: [
|
||||
{ from: delFrom, to: from, insert: "" },
|
||||
{ from: to, to: delTo, insert: "" },
|
||||
],
|
||||
});
|
||||
return;
|
||||
}
|
||||
}
|
||||
wrapSelection(view, token);
|
||||
}
|
||||
|
||||
/**
|
||||
* Toggle a fenced code block. When the selection sits inside one, remove its
|
||||
* fence lines (keeping the content); otherwise wrap the selected lines in a
|
||||
* new ``` fence. Unclosed blocks (opening fence only) lose just that fence.
|
||||
*/
|
||||
function toggleCodeBlock(view: EditorView) {
|
||||
const { state } = view;
|
||||
const { from, to } = state.selection.main;
|
||||
const marks = findWrappedDelimiters(view, "FencedCode", "CodeMark", 1);
|
||||
if (marks) {
|
||||
const openLine = state.doc.lineAt(marks[0].from);
|
||||
// Delete each fence line together with its trailing newline. When the
|
||||
// closing fence is the document's last line there is no trailing newline
|
||||
// to take, so eat the preceding one instead — unless that would overlap
|
||||
// the opening deletion (empty block at end of document).
|
||||
const openEnd = Math.min(openLine.to + 1, state.doc.length);
|
||||
const specs = [{ from: openLine.from, to: openEnd, insert: "" }];
|
||||
if (marks.length >= 2) {
|
||||
const closeLine = state.doc.lineAt(marks[marks.length - 1].from);
|
||||
const closeIsLastLine = closeLine.to === state.doc.length;
|
||||
const closeTo = closeIsLastLine ? closeLine.to : closeLine.to + 1;
|
||||
const closeFrom = closeIsLastLine && closeLine.from - 1 >= openEnd ? closeLine.from - 1 : closeLine.from;
|
||||
specs.push({ from: closeFrom, to: closeTo, insert: "" });
|
||||
}
|
||||
const changes = state.changes(specs);
|
||||
view.dispatch({ changes, selection: state.selection.map(changes) });
|
||||
return;
|
||||
}
|
||||
const lineNumbers = selectedLineNumbers(view);
|
||||
const first = state.doc.line(lineNumbers[0]);
|
||||
const last = state.doc.line(lineNumbers[lineNumbers.length - 1]);
|
||||
const fence = "```";
|
||||
// Both selection ends sit within [first.from, last.to], so they shift by
|
||||
// exactly the opening `\`\`\`\n` — keeping the selection on the content (and
|
||||
// dropping a lone cursor inside the new empty block).
|
||||
view.dispatch({
|
||||
changes: [
|
||||
{ from: first.from, insert: `${fence}\n` },
|
||||
{ from: last.to, insert: `\n${fence}` },
|
||||
],
|
||||
selection: { anchor: from + fence.length + 1, head: to + fence.length + 1 },
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Toggle/convert the list mode of the selected lines. The three list modes are
|
||||
* mutually exclusive line states: when every selected line is already in the
|
||||
* requested mode the markers are removed; otherwise lines are converted to it
|
||||
* (replacing any other list marker, preserving indentation). With a multi-line
|
||||
* selection blank lines are left alone; a single selected blank line still gets
|
||||
* a marker so the "start a list on an empty line" flow works.
|
||||
*/
|
||||
function toggleListLine(view: EditorView, command: ListCommand) {
|
||||
const { state } = view;
|
||||
const lines = selectedLineNumbers(view).map((n) => state.doc.line(n));
|
||||
const nonBlank = lines.filter((line) => line.text.trim() !== "");
|
||||
const targets = lines.length === 1 || nonBlank.length === 0 ? lines : nonBlank;
|
||||
const infos = targets.map((line) => lineListInfo(line.text));
|
||||
const allOn = infos.every((info) => info.mode === command);
|
||||
|
||||
const specs: { from: number; to: number; insert: string }[] = [];
|
||||
for (const [i, line] of targets.entries()) {
|
||||
const { mode, indent, markerEnd } = infos[i];
|
||||
if (allOn) {
|
||||
specs.push({ from: line.from + indent, to: line.from + markerEnd, insert: "" });
|
||||
} else if (mode !== command) {
|
||||
// Lines already in the requested mode keep their marker untouched (a
|
||||
// checked `- [x]` stays checked when the selection is extended).
|
||||
const marker = command === "orderedList" ? `${i + 1}. ` : LIST_MARKERS[command];
|
||||
specs.push({ from: line.from + indent, to: line.from + markerEnd, insert: marker });
|
||||
}
|
||||
}
|
||||
if (specs.length === 0) return;
|
||||
const changes = state.changes(specs);
|
||||
// Map with assoc 1 so a cursor exactly at the insertion point (empty line)
|
||||
// lands after the inserted marker instead of staying at the line start.
|
||||
view.dispatch({ changes, selection: state.selection.map(changes, 1) });
|
||||
}
|
||||
|
||||
function setHeading(view: EditorView, level: number) {
|
||||
const line = view.state.doc.lineAt(view.state.selection.main.head);
|
||||
// Edit only the prefix region (not the whole line) so the cursor keeps its
|
||||
// place in the text instead of being flung to the line start by the mapping.
|
||||
const existing = HEADING_PREFIX.exec(line.text)?.[0].length ?? 0;
|
||||
const insert = level === 0 ? "" : `${"#".repeat(level)} `;
|
||||
const changes = view.state.changes({ from: line.from, to: line.from + existing, insert });
|
||||
view.dispatch({ changes, selection: view.state.selection.map(changes, 1) });
|
||||
}
|
||||
|
||||
/** Unwrap the link the head sits in to its label text. True when one was found. */
|
||||
function unwrapLink(view: EditorView): boolean {
|
||||
const head = view.state.selection.main.head;
|
||||
const tree = syntaxTree(view.state);
|
||||
for (const n of ancestors(tree, head, -1)) {
|
||||
if (n.name !== "Link") continue;
|
||||
const marks = childRanges(n, "LinkMark");
|
||||
// marks[0] is `[`, marks[1] is `]` — the label sits between them.
|
||||
if (marks.length < 2) continue;
|
||||
const label = view.state.sliceDoc(marks[0].to, marks[1].from);
|
||||
const anchor = n.from + Math.max(0, Math.min(label.length, head - marks[0].to));
|
||||
view.dispatch({
|
||||
changes: { from: n.from, to: n.to, insert: label },
|
||||
selection: { anchor },
|
||||
});
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
export function createFormattingController(view: EditorView, listeners: Set<() => void>): FormattingController {
|
||||
return {
|
||||
run(command: EditorCommandId, ctx?: EditorCommandContext) {
|
||||
if (isMarkCommand(command)) return toggleMark(view, command);
|
||||
if (command === "codeBlock") return toggleCodeBlock(view);
|
||||
if (command === "bulletList" || command === "orderedList" || command === "taskList") {
|
||||
return toggleListLine(view, command);
|
||||
}
|
||||
if (command === "heading1") return setHeading(view, 1);
|
||||
if (command === "heading2") return setHeading(view, 2);
|
||||
if (command === "heading3") return setHeading(view, 3);
|
||||
if (command === "paragraph") return setHeading(view, 0);
|
||||
if (command === "link") {
|
||||
// Toggle: inside an existing link, unwrap it to its label.
|
||||
if (unwrapLink(view)) return;
|
||||
const { from, to } = view.state.selection.main;
|
||||
const url = ctx?.url ?? "";
|
||||
// Empty selection: the URL doubles as the label.
|
||||
const label = view.state.sliceDoc(from, to) || url;
|
||||
const insert = `[${label}](${url})`;
|
||||
view.dispatch({ changes: { from, to, insert }, selection: { anchor: from + insert.length } });
|
||||
}
|
||||
},
|
||||
getActiveFormats(): ActiveFormatState {
|
||||
const pos = view.state.selection.main.head;
|
||||
const tree = syntaxTree(view.state);
|
||||
const active: ActiveFormatState = { ...EMPTY_ACTIVE_FORMATS };
|
||||
// Inline marks come from the syntax tree around the cursor.
|
||||
for (const n of ancestors(tree, pos, -1)) {
|
||||
const mark = WRAPPER_TO_MARK[n.name];
|
||||
if (mark) active[mark] = true;
|
||||
else if (n.name === "Link") active.link = true;
|
||||
// The isEmptyMarkPair guard: a fresh empty strikethrough pair
|
||||
// (`~~|~~`) parses as a bare tilde code fence — don't light the
|
||||
// code-block button while the cursor sits in one.
|
||||
else if (n.name === "FencedCode" && !isEmptyMarkPair(view.state, n.from, n.to)) active.codeBlock = true;
|
||||
}
|
||||
// Line modes (lists, headings) come from the same line inspection the
|
||||
// toggles use, keeping highlight and toggle behavior in lockstep.
|
||||
const line = view.state.doc.lineAt(pos).text;
|
||||
const heading = HEADING_LINE.exec(line);
|
||||
if (heading) active.headingLevel = toToolbarHeadingLevel(heading[1].length);
|
||||
const { mode } = lineListInfo(line);
|
||||
if (mode) active[mode] = true;
|
||||
return active;
|
||||
},
|
||||
subscribe(listener: () => void) {
|
||||
listeners.add(listener);
|
||||
return () => {
|
||||
listeners.delete(listener);
|
||||
};
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
import { RangeSetBuilder } from "@codemirror/state";
|
||||
import { Decoration, type DecorationSet, EditorView } from "@codemirror/view";
|
||||
import { HEADING_LINE } from "./formatting";
|
||||
import { viewportDecorations } from "./viewportDecorations";
|
||||
|
||||
const lineDecorations = [1, 2, 3, 4, 5, 6].map((level) => Decoration.line({ class: `cm-md-h${level}` }));
|
||||
|
||||
function build(view: EditorView): DecorationSet {
|
||||
const builder = new RangeSetBuilder<Decoration>();
|
||||
for (const { from, to } of view.visibleRanges) {
|
||||
const startLine = view.state.doc.lineAt(from).number;
|
||||
const endLine = view.state.doc.lineAt(to).number;
|
||||
for (let n = startLine; n <= endLine; n++) {
|
||||
const line = view.state.doc.line(n);
|
||||
const m = HEADING_LINE.exec(line.text);
|
||||
if (m) {
|
||||
builder.add(line.from, line.from, lineDecorations[m[1].length - 1]);
|
||||
}
|
||||
}
|
||||
}
|
||||
return builder.finish();
|
||||
}
|
||||
|
||||
export const headingDecorations = viewportDecorations(build);
|
||||
@@ -0,0 +1,98 @@
|
||||
import { EditorState } from "@codemirror/state";
|
||||
import { placeholder as cmPlaceholder, EditorView } from "@codemirror/view";
|
||||
import { forwardRef, useEffect, useImperativeHandle, useLayoutEffect, useMemo, useRef } from "react";
|
||||
import { useTagCounts } from "@/hooks/useUserQueries";
|
||||
import { cn } from "@/lib/utils";
|
||||
import type { EditorController } from "../types/editorController";
|
||||
import { createController } from "./controller";
|
||||
import "./editor.css";
|
||||
import { buildEditorExtensions, placeholderCompartment } from "./extensions";
|
||||
import { createFormattingController } from "./formatting";
|
||||
|
||||
interface EditorProps {
|
||||
className: string;
|
||||
initialContent: string;
|
||||
placeholder: string;
|
||||
onContentChange: (content: string) => void;
|
||||
onFiles: (files: File[]) => void;
|
||||
/** Invoked by the in-editor save shortcut (Cmd/Ctrl+Enter). */
|
||||
onSubmit: () => void;
|
||||
isFocusMode?: boolean;
|
||||
}
|
||||
|
||||
const Editor = forwardRef(function Editor(props: EditorProps, ref: React.ForwardedRef<EditorController>) {
|
||||
const { className, initialContent, placeholder, onContentChange, onFiles, onSubmit, isFocusMode } = props;
|
||||
const hostRef = useRef<HTMLDivElement>(null);
|
||||
const viewRef = useRef<EditorView | null>(null);
|
||||
const controllerRef = useRef<EditorController | null>(null);
|
||||
const onChangeRef = useRef(onContentChange);
|
||||
onChangeRef.current = onContentChange;
|
||||
const onFilesRef = useRef(onFiles);
|
||||
onFilesRef.current = onFiles;
|
||||
const onSubmitRef = useRef(onSubmit);
|
||||
onSubmitRef.current = onSubmit;
|
||||
const placeholderRef = useRef(placeholder);
|
||||
const listenersRef = useRef(new Set<() => void>());
|
||||
const { data: tagData } = useTagCounts();
|
||||
const tags = useMemo(() => Object.keys(tagData ?? {}), [tagData]);
|
||||
const tagsRef = useRef(tags);
|
||||
tagsRef.current = tags;
|
||||
|
||||
// useLayoutEffect (not useEffect) so the EditorView — and its placeholder —
|
||||
// mount before the browser paints. With useEffect the first painted frame
|
||||
// shows an empty host, then the placeholder pops in (a load flicker).
|
||||
useLayoutEffect(() => {
|
||||
if (!hostRef.current) return;
|
||||
const view = new EditorView({
|
||||
state: EditorState.create({
|
||||
doc: initialContent,
|
||||
extensions: buildEditorExtensions({
|
||||
placeholder,
|
||||
onChange: (md) => onChangeRef.current(md),
|
||||
onFiles: (files) => onFilesRef.current(files),
|
||||
onUpdate: () => listenersRef.current.forEach((l) => l()),
|
||||
onSubmit: () => onSubmitRef.current(),
|
||||
getTags: () => tagsRef.current,
|
||||
}),
|
||||
}),
|
||||
parent: hostRef.current,
|
||||
});
|
||||
viewRef.current = view;
|
||||
controllerRef.current = createController(view, createFormattingController(view, listenersRef.current));
|
||||
return () => {
|
||||
view.destroy();
|
||||
viewRef.current = null;
|
||||
controllerRef.current = null;
|
||||
};
|
||||
// Mount once; external sync handled below.
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, []);
|
||||
|
||||
useLayoutEffect(() => {
|
||||
if (placeholderRef.current === placeholder) return;
|
||||
placeholderRef.current = placeholder;
|
||||
viewRef.current?.dispatch({ effects: placeholderCompartment.reconfigure(cmPlaceholder(placeholder)) });
|
||||
}, [placeholder]);
|
||||
|
||||
useEffect(() => {
|
||||
const view = viewRef.current;
|
||||
if (!view) return;
|
||||
if (view.state.doc.toString() === initialContent) return;
|
||||
view.dispatch({ changes: { from: 0, to: view.state.doc.length, insert: initialContent } });
|
||||
}, [initialContent]);
|
||||
|
||||
// The controller is created in the mount layout effect above, which runs
|
||||
// before this (also layout-phase) handle, so controllerRef.current is set.
|
||||
useImperativeHandle(ref, () => controllerRef.current as EditorController, []);
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn("relative flex w-full flex-col items-start justify-start bg-inherit", isFocusMode && "min-h-0 flex-1", className)}
|
||||
data-focus-mode={isFocusMode || undefined}
|
||||
>
|
||||
<div ref={hostRef} className={cn("w-full text-base", isFocusMode && "min-h-0 flex-1")} />
|
||||
</div>
|
||||
);
|
||||
});
|
||||
|
||||
export default Editor;
|
||||
@@ -0,0 +1,121 @@
|
||||
import type { Text } from "@codemirror/state";
|
||||
import type { EditorView } from "@codemirror/view";
|
||||
|
||||
// A list item line: leading whitespace + bullet (-, *, +) or ordered marker
|
||||
// (1. / 1)) + at least one space.
|
||||
const LIST_ITEM = /^(\s*)(?:[-*+]|\d+[.)])\s/;
|
||||
const ORDERED_ITEM = /^(\s*)(\d+)([.)])(\s+)(.*)$/;
|
||||
// The full marker prefix (indent + marker + trailing spaces); its length is the
|
||||
// column where the item's content — and therefore a nested child — begins.
|
||||
const LIST_PREFIX = /^\s*(?:[-*+]|\d+[.)])\s+/;
|
||||
|
||||
export const leadingWhitespace = (text: string): number => text.length - text.trimStart().length;
|
||||
|
||||
/** Unique line numbers covered by the selection, ascending (also used by formatting.ts). */
|
||||
export function selectedLineNumbers(view: EditorView): number[] {
|
||||
const { doc, selection } = view.state;
|
||||
const nums = new Set<number>();
|
||||
for (const range of selection.ranges) {
|
||||
const last = doc.lineAt(range.to).number;
|
||||
for (let n = doc.lineAt(range.from).number; n <= last; n++) {
|
||||
nums.add(n);
|
||||
}
|
||||
}
|
||||
return [...nums].sort((a, b) => a - b);
|
||||
}
|
||||
|
||||
/** Preceding lines, nearest first, stopping at the first blank line (list end). */
|
||||
function* previousListLines(doc: Text, from: number): Generator<{ text: string; indent: number }> {
|
||||
for (let p = from - 1; p >= 1; p--) {
|
||||
const { text } = doc.line(p);
|
||||
if (text.trim() === "") return;
|
||||
yield { text, indent: leadingWhitespace(text) };
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The number an ordered item should take at `indent`: one more than the nearest
|
||||
* preceding sibling ordered item at the same indent, else 1 (first child of a
|
||||
* new sublist). CommonMark only nests an ordered sublist whose first item is
|
||||
* `1.`, so renumbering — not just re-indenting — is what makes a nested ordered
|
||||
* list actually render as nested.
|
||||
*/
|
||||
function nextOrderedNumber(doc: Text, lineNumber: number, indent: number): number {
|
||||
for (const prev of previousListLines(doc, lineNumber)) {
|
||||
if (prev.indent < indent) break; // reached the parent
|
||||
if (prev.indent === indent) {
|
||||
const ordered = ORDERED_ITEM.exec(prev.text);
|
||||
return ordered ? Number.parseInt(ordered[2], 10) + 1 : 1;
|
||||
}
|
||||
// deeper line — part of a sibling's subtree; keep scanning up
|
||||
}
|
||||
return 1;
|
||||
}
|
||||
|
||||
/** Rewrite a list line to a new indent, renumbering it if it is an ordered item. */
|
||||
function reindented(doc: Text, lineNumber: number, text: string, indent: number): string {
|
||||
const ordered = ORDERED_ITEM.exec(text);
|
||||
if (ordered) {
|
||||
const [, , , delimiter, spaces, content] = ordered;
|
||||
return `${" ".repeat(indent)}${nextOrderedNumber(doc, lineNumber, indent)}${delimiter}${spaces}${content}`;
|
||||
}
|
||||
return " ".repeat(indent) + text.slice(leadingWhitespace(text));
|
||||
}
|
||||
|
||||
/**
|
||||
* Tab on a list item: nest it under the preceding item by aligning its indent to
|
||||
* that item's content column (past the marker) and renumbering ordered items so
|
||||
* the nested list is CommonMark-valid. Returns false when not on a list item so
|
||||
* the caller's default Tab (plain indent) applies.
|
||||
*/
|
||||
export function sinkListItem(view: EditorView): boolean {
|
||||
const { doc } = view.state;
|
||||
const changes: { from: number; to: number; insert: string }[] = [];
|
||||
for (const n of selectedLineNumbers(view)) {
|
||||
const line = doc.line(n);
|
||||
const match = LIST_ITEM.exec(line.text);
|
||||
if (!match) return false;
|
||||
const indent = match[1].length;
|
||||
let target = indent + 2; // fallback when there is no item to nest under
|
||||
for (const prev of previousListLines(doc, n)) {
|
||||
if (prev.indent > indent) continue; // deeper subtree — skip past it
|
||||
const prevPrefix = LIST_PREFIX.exec(prev.text);
|
||||
if (prevPrefix) target = prevPrefix[0].length;
|
||||
break;
|
||||
}
|
||||
if (target > indent) {
|
||||
changes.push({ from: line.from, to: line.to, insert: reindented(doc, n, line.text, target) });
|
||||
}
|
||||
}
|
||||
if (changes.length === 0) return false;
|
||||
view.dispatch({ changes, userEvent: "input.indent" });
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Shift-Tab on a list item: outdent it to its parent's indent (or the margin),
|
||||
* renumbering ordered items for the new level. Returns false when not on an
|
||||
* indented list item so the default outdent applies.
|
||||
*/
|
||||
export function liftListItem(view: EditorView): boolean {
|
||||
const { doc } = view.state;
|
||||
const changes: { from: number; to: number; insert: string }[] = [];
|
||||
for (const n of selectedLineNumbers(view)) {
|
||||
const line = doc.line(n);
|
||||
const match = LIST_ITEM.exec(line.text);
|
||||
if (!match) return false;
|
||||
const indent = match[1].length;
|
||||
if (indent === 0) return false;
|
||||
let target = 0;
|
||||
for (const prev of previousListLines(doc, n)) {
|
||||
if (prev.indent < indent) {
|
||||
target = LIST_ITEM.test(prev.text) ? prev.indent : 0;
|
||||
break;
|
||||
}
|
||||
}
|
||||
changes.push({ from: line.from, to: line.to, insert: reindented(doc, n, line.text, target) });
|
||||
}
|
||||
if (changes.length === 0) return false;
|
||||
view.dispatch({ changes, userEvent: "delete.dedent" });
|
||||
return true;
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user