Compare commits
10 Commits
8cf3a1f03a
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 94506aa349 | |||
| d364e5e39f | |||
| 38d2defeb1 | |||
| 324d8c0244 | |||
| 263b400860 | |||
| cbf5ee784f | |||
| 0d817537be | |||
| 1fe9c1021a | |||
| 8d4d389982 | |||
| d36028d67b |
@@ -16,9 +16,6 @@ dist-ssr
|
||||
# env files (can opt-in for committing if needed)
|
||||
.env*
|
||||
|
||||
# turbo
|
||||
.turbo
|
||||
|
||||
# generated i18n build schema
|
||||
.i18n
|
||||
|
||||
|
||||
@@ -0,0 +1,22 @@
|
||||
{
|
||||
"$schema": "./node_modules/oxfmt/configuration_schema.json",
|
||||
"endOfLine": "lf",
|
||||
"ignorePatterns": [
|
||||
".i18n/**",
|
||||
"**/.output/**",
|
||||
"**/coverage/**",
|
||||
"**/dist/**",
|
||||
"apps/web/src/locales/**/messages.ts",
|
||||
"apps/web/src/route-tree.gen.ts"
|
||||
],
|
||||
"printWidth": 80,
|
||||
"semi": false,
|
||||
"singleQuote": false,
|
||||
"sortPackageJson": false,
|
||||
"sortTailwindcss": {
|
||||
"functions": ["cn", "cva"],
|
||||
"stylesheet": "./packages/ui/src/styles/globals.css"
|
||||
},
|
||||
"tabWidth": 2,
|
||||
"trailingComma": "es5"
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
{
|
||||
"$schema": "./node_modules/oxlint/configuration_schema.json",
|
||||
"categories": {
|
||||
"correctness": "error",
|
||||
"perf": "warn",
|
||||
"suspicious": "warn"
|
||||
},
|
||||
"ignorePatterns": [
|
||||
".i18n/**",
|
||||
"**/.output/**",
|
||||
"**/coverage/**",
|
||||
"**/dist/**",
|
||||
"apps/web/src/locales/**/messages.ts",
|
||||
"apps/web/src/route-tree.gen.ts"
|
||||
],
|
||||
"options": {
|
||||
"reportUnusedDisableDirectives": "warn"
|
||||
},
|
||||
"rules": {
|
||||
"react/react-in-jsx-scope": "off",
|
||||
"vitest/expect-expect": "off",
|
||||
"vitest/require-mock-type-parameters": "off"
|
||||
},
|
||||
"plugins": [
|
||||
"eslint",
|
||||
"typescript",
|
||||
"unicorn",
|
||||
"oxc",
|
||||
"react",
|
||||
"import",
|
||||
"vitest"
|
||||
]
|
||||
}
|
||||
@@ -1,6 +0,0 @@
|
||||
dist/
|
||||
node_modules/
|
||||
.turbo/
|
||||
coverage/
|
||||
pnpm-lock.yaml
|
||||
.pnpm-store/
|
||||
-11
@@ -1,11 +0,0 @@
|
||||
{
|
||||
"endOfLine": "lf",
|
||||
"semi": false,
|
||||
"singleQuote": false,
|
||||
"tabWidth": 2,
|
||||
"trailingComma": "es5",
|
||||
"printWidth": 80,
|
||||
"plugins": ["prettier-plugin-tailwindcss"],
|
||||
"tailwindStylesheet": "packages/ui/src/styles/globals.css",
|
||||
"tailwindFunctions": ["cn", "cva"]
|
||||
}
|
||||
@@ -7,8 +7,11 @@
|
||||
"@workspace/blocks/appearance/locales/{locale}",
|
||||
"@workspace/blocks/chats/locales/{locale}",
|
||||
"@workspace/blocks/layout/locales/{locale}",
|
||||
"@workspace/blocks/media/locales/{locale}",
|
||||
"@workspace/blocks/navigation/locales/{locale}",
|
||||
"@workspace/blocks/notifications/locales/{locale}"
|
||||
"@workspace/blocks/notifications/locales/{locale}",
|
||||
"@workspace/blocks/search/locales/{locale}",
|
||||
"@workspace/lexical/locales/{locale}"
|
||||
],
|
||||
"include": ["src", "../../packages/blocks/src", "../../packages/ui/src"],
|
||||
"exclude": ["**/*.test.{ts,tsx}", "**/node_modules/**"]
|
||||
|
||||
@@ -6,7 +6,6 @@
|
||||
"scripts": {
|
||||
"dev": "vite dev",
|
||||
"build": "vite build && tsc -b",
|
||||
"format": "prettier --write \"**/*.{ts,tsx}\"",
|
||||
"i18n": "workspace-i18n",
|
||||
"i18n:compile": "workspace-i18n compile",
|
||||
"i18n:extract": "workspace-i18n extract",
|
||||
@@ -20,8 +19,6 @@
|
||||
"@base-ui/react": "^1.6.0",
|
||||
"@base-ui/utils": "^0.3.1",
|
||||
"@floating-ui/utils": "^0.2.12",
|
||||
"@hugeicons/core-free-icons": "^4.2.3",
|
||||
"@hugeicons/react": "^1.1.9",
|
||||
"@iconify/react": "^6.0.2",
|
||||
"@lingui/core": "^6",
|
||||
"@tanstack/react-form": "^1.33.2",
|
||||
@@ -30,7 +27,9 @@
|
||||
"@tanstack/react-router": "^1.170.18",
|
||||
"@tanstack/react-start": "^1.168.32",
|
||||
"@workspace/blocks": "workspace:*",
|
||||
"@workspace/icons": "workspace:*",
|
||||
"@workspace/i18n": "workspace:*",
|
||||
"@workspace/lexical": "workspace:*",
|
||||
"@workspace/ui": "workspace:*",
|
||||
"country-flag-icons": "^1.6.20",
|
||||
"lucide-react": "^1.27.0",
|
||||
|
||||
@@ -56,7 +56,7 @@ import {
|
||||
UserShield01Icon,
|
||||
Wallet01Icon,
|
||||
WalletCardsIcon,
|
||||
} from "@hugeicons/core-free-icons"
|
||||
} from "@workspace/icons"
|
||||
|
||||
import type { NavigationGroup } from "@workspace/blocks/navigation"
|
||||
|
||||
|
||||
@@ -3,7 +3,7 @@ import {
|
||||
Mail01Icon,
|
||||
Message01Icon,
|
||||
PackageIcon,
|
||||
} from "@hugeicons/core-free-icons"
|
||||
} from "@workspace/icons"
|
||||
import type { QueryFunctionContext } from "@tanstack/react-query"
|
||||
|
||||
import type { Notification } from "@workspace/blocks/notifications"
|
||||
|
||||
@@ -1,48 +1,76 @@
|
||||
import * as React from "react"
|
||||
import { formatForDisplay } from "@tanstack/react-hotkeys"
|
||||
import {
|
||||
LexicalActions,
|
||||
LexicalBubbleToolbar,
|
||||
LexicalContent,
|
||||
LexicalDraggableBlockPlugin,
|
||||
LexicalFixedToolbar,
|
||||
LexicalFooter,
|
||||
LexicalRoot,
|
||||
} from "@workspace/lexical"
|
||||
import { Button } from "@workspace/ui/components/button"
|
||||
import { Slider } from "@workspace/ui/components/slider"
|
||||
import { Switch } from "@workspace/ui/components/switch"
|
||||
|
||||
const initialEditorHtml = [
|
||||
"<h2>Welcome to the Lexical editor</h2>",
|
||||
"<p>This example uses declarative actions to compose reusable editor features.</p>",
|
||||
"<ul><li>Format text with the toolbar</li><li>Insert links, images, videos, lists, dates, and more</li><li>Read the generated HTML beside the editor</li></ul>",
|
||||
].join("")
|
||||
|
||||
export function DashboardPage() {
|
||||
return (
|
||||
<>
|
||||
<img
|
||||
className="h-auto w-full"
|
||||
src="https://pub-c5e31b5cdafb419fb247a8ac2e78df7a.r2.dev/public/assets/background/background-3-blur.webp"
|
||||
/>
|
||||
<div className="flex max-w-md min-w-0 flex-col gap-4 text-sm leading-loose">
|
||||
<div>
|
||||
<h1 className="font-medium">Project ready!</h1>
|
||||
<p>You may now add components and start building.</p>
|
||||
<p>We've already added the button component for you.</p>
|
||||
<Button variant="ghost" className="mt-2">
|
||||
Button
|
||||
</Button>
|
||||
</div>
|
||||
<div>
|
||||
<Switch />
|
||||
<Slider
|
||||
defaultValue={[75]}
|
||||
max={100}
|
||||
step={1}
|
||||
className="mx-auto w-full max-w-xs"
|
||||
/>
|
||||
</div>
|
||||
<div className="font-mono text-xs text-muted-foreground">
|
||||
(Press <kbd>Mod+D</kbd> to toggle dark mode)
|
||||
</div>
|
||||
const [html, setHtml] = React.useState(initialEditorHtml)
|
||||
|
||||
<div>
|
||||
<span className="mr-2">breakpoint:</span>
|
||||
<span className="hidden mobile:inline">mobile</span>
|
||||
<span className="hidden tablet:inline">tablet</span>
|
||||
<span className="hidden desktop:inline">desktop</span>
|
||||
</div>
|
||||
<div>
|
||||
<span className="mr-2">collapsed:</span>
|
||||
<span className="hidden collapsed:inline">true</span>
|
||||
<span className="inline collapsed:hidden">false</span>
|
||||
</div>
|
||||
return (
|
||||
<div className="flex min-w-0 flex-col gap-6 bg-background">
|
||||
<div>
|
||||
<h1 className="text-xl font-medium">Lexical editor</h1>
|
||||
<p className="mt-1 text-sm text-muted-foreground">
|
||||
Actions declare their controls and editor dependencies in one place.
|
||||
</p>
|
||||
</div>
|
||||
</>
|
||||
|
||||
<div className="grid min-w-0 gap-6 xl:grid-cols-[minmax(0,1fr)_minmax(20rem,0.7fr)]">
|
||||
<section className="min-w-0">
|
||||
<div className="mb-2 flex items-center justify-between gap-3">
|
||||
<h2 className="font-medium">Editor</h2>
|
||||
<code className="text-xs text-muted-foreground">
|
||||
useDefaults="full"
|
||||
</code>
|
||||
</div>
|
||||
<LexicalRoot value={html} onChange={setHtml} className="shadow-xs">
|
||||
<LexicalActions useDefaults="full" />
|
||||
<LexicalFixedToolbar />
|
||||
<LexicalContent
|
||||
className="min-h-72"
|
||||
placeholder="Write something…"
|
||||
/>
|
||||
<LexicalDraggableBlockPlugin />
|
||||
<LexicalBubbleToolbar aria-label="Selected text formatting" />
|
||||
<LexicalFooter className="flex items-center justify-between gap-3 text-xs text-muted-foreground">
|
||||
<span>HTML output updates as you type.</span>
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
onClick={() => setHtml("")}
|
||||
>
|
||||
Clear
|
||||
</Button>
|
||||
</LexicalFooter>
|
||||
</LexicalRoot>
|
||||
</section>
|
||||
|
||||
<section className="flex min-w-0 flex-col">
|
||||
<h2 className="mb-2 font-medium">Generated HTML</h2>
|
||||
<pre className="min-h-72 flex-1 overflow-auto rounded-lg border bg-muted/30 p-4 text-xs leading-relaxed break-words whitespace-pre-wrap">
|
||||
{html || "The editor is empty."}
|
||||
</pre>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
<p className="font-mono text-xs text-muted-foreground">
|
||||
Press <kbd>{formatForDisplay("Mod+D")}</kbd> to toggle dark mode.
|
||||
</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
@import "@workspace/ui/globals.css";
|
||||
@import "@workspace/blocks/globals.css";
|
||||
@import "@workspace/lexical/globals.css";
|
||||
|
||||
:root {
|
||||
--ui-content-top-gap: --spacing(2);
|
||||
|
||||
@@ -5,9 +5,8 @@
|
||||
"": {
|
||||
"name": "simple-react-app-kit",
|
||||
"devDependencies": {
|
||||
"prettier": "^3.8.3",
|
||||
"prettier-plugin-tailwindcss": "^0.8.0",
|
||||
"turbo": "^2.9.18",
|
||||
"oxfmt": "^0.61.0",
|
||||
"oxlint": "^1.76.0",
|
||||
"typescript": "~6",
|
||||
},
|
||||
},
|
||||
@@ -18,8 +17,6 @@
|
||||
"@base-ui/react": "^1.6.0",
|
||||
"@base-ui/utils": "^0.3.1",
|
||||
"@floating-ui/utils": "^0.2.12",
|
||||
"@hugeicons/core-free-icons": "^4.2.3",
|
||||
"@hugeicons/react": "^1.1.9",
|
||||
"@iconify/react": "^6.0.2",
|
||||
"@lingui/core": "^6",
|
||||
"@tanstack/react-form": "^1.33.2",
|
||||
@@ -29,6 +26,8 @@
|
||||
"@tanstack/react-start": "^1.168.32",
|
||||
"@workspace/blocks": "workspace:*",
|
||||
"@workspace/i18n": "workspace:*",
|
||||
"@workspace/icons": "workspace:*",
|
||||
"@workspace/lexical": "workspace:*",
|
||||
"@workspace/ui": "workspace:*",
|
||||
"country-flag-icons": "^1.6.20",
|
||||
"lucide-react": "^1.27.0",
|
||||
@@ -58,13 +57,13 @@
|
||||
"@base-ui/react": "^1.6.0",
|
||||
"@base-ui/utils": "^0.3.1",
|
||||
"@floating-ui/utils": "^0.2.12",
|
||||
"@hugeicons/core-free-icons": "^4.2.3",
|
||||
"@hugeicons/react": "^1.1.9",
|
||||
"@tanstack/react-hotkeys": "^0.10.0",
|
||||
"@tanstack/react-query": "^5.101.4",
|
||||
"@tanstack/react-router": "^1.170.18",
|
||||
"@workspace/i18n": "workspace:*",
|
||||
"@workspace/icons": "workspace:*",
|
||||
"@workspace/ui": "workspace:*",
|
||||
"cmdk": "^1.1.1",
|
||||
"lucide-react": "^1.27.0",
|
||||
"react": "^19.2.6",
|
||||
"react-dom": "^19.2.6",
|
||||
@@ -108,6 +107,47 @@
|
||||
"vitest": "^4",
|
||||
},
|
||||
},
|
||||
"packages/icons": {
|
||||
"name": "@workspace/icons",
|
||||
"version": "0.0.0",
|
||||
"dependencies": {
|
||||
"@hugeicons/core-free-icons": "^4.2.3",
|
||||
"@hugeicons/react": "^1.1.9",
|
||||
"react": "^19.2.6",
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/react": "^19",
|
||||
"typescript": "~6",
|
||||
},
|
||||
},
|
||||
"packages/lexical": {
|
||||
"name": "@workspace/lexical",
|
||||
"version": "0.0.0",
|
||||
"dependencies": {
|
||||
"@lexical/extension": "^0.48.0",
|
||||
"@lexical/html": "^0.48.0",
|
||||
"@lexical/link": "^0.48.0",
|
||||
"@lexical/list": "^0.48.0",
|
||||
"@lexical/react": "^0.48.0",
|
||||
"@lexical/rich-text": "^0.48.0",
|
||||
"@lexical/selection": "^0.48.0",
|
||||
"@lexical/utils": "^0.48.0",
|
||||
"@workspace/i18n": "workspace:*",
|
||||
"@workspace/ui": "workspace:*",
|
||||
"lexical": "^0.48.0",
|
||||
"lucide-react": "^1.27.0",
|
||||
"react": "^19.2.6",
|
||||
"react-dom": "^19.2.6",
|
||||
},
|
||||
"devDependencies": {
|
||||
"@testing-library/react": "^16.3.2",
|
||||
"@types/react": "^19",
|
||||
"@types/react-dom": "^19",
|
||||
"jsdom": "^30.0.1",
|
||||
"typescript": "~6",
|
||||
"vitest": "^4.1.10",
|
||||
},
|
||||
},
|
||||
"packages/ui": {
|
||||
"name": "@workspace/ui",
|
||||
"version": "0.0.0",
|
||||
@@ -135,7 +175,6 @@
|
||||
},
|
||||
"devDependencies": {
|
||||
"@tailwindcss/vite": "^4",
|
||||
"@turbo/gen": "^2.9.15",
|
||||
"@types/node": "^24",
|
||||
"@types/react": "^19",
|
||||
"@types/react-dom": "^19",
|
||||
@@ -307,6 +346,8 @@
|
||||
|
||||
"@floating-ui/dom": ["@floating-ui/dom@1.8.0", "", { "dependencies": { "@floating-ui/core": "^1.8.0", "@floating-ui/utils": "^0.2.12" } }, "sha512-yXSrzeHZBTZadLOlfyhCkJHNeLJnHRnRInwdZ40L7ZiaAtrBwoYlsDrX3v5zB1Utk7CLfzcOVnVVWoXEky7Ceg=="],
|
||||
|
||||
"@floating-ui/react": ["@floating-ui/react@0.27.20", "", { "dependencies": { "@floating-ui/react-dom": "^2.1.9", "@floating-ui/utils": "^0.2.12", "tabbable": "^6.0.0" }, "peerDependencies": { "react": ">=17.0.0", "react-dom": ">=17.0.0" } }, "sha512-CMqMy7OaXl9W0eq1Uy7L7i2Y/anPvHmFmESd2CEw0t5YvZhcVCeo4MBevAmswRllX7Y2dEidA4ozGPunLSTQpw=="],
|
||||
|
||||
"@floating-ui/react-dom": ["@floating-ui/react-dom@2.1.9", "", { "dependencies": { "@floating-ui/dom": "^1.8.0" }, "peerDependencies": { "react": ">=16.8.0", "react-dom": ">=16.8.0" } }, "sha512-JDjEFGCpImxDCA7JJKviA0M9+RtmJdj0m/NVU5IMgBK+AmZouAQQ7/+2GLH0GXXY0YMw9oXPB8hKdbPYg5QLYg=="],
|
||||
|
||||
"@floating-ui/utils": ["@floating-ui/utils@0.2.12", "", {}, "sha512-HpCo8tmWzLVad5s2d19EhAz5zqrrQ6s69qd6moPMQvkOuSwDT1YgRfWSVuc4ennqrgv3OHppiOGMQ7oC13yIww=="],
|
||||
@@ -323,38 +364,6 @@
|
||||
|
||||
"@iconify/types": ["@iconify/types@2.0.0", "", {}, "sha512-+wluvCrRhXrhyOmRDJ3q8mux9JkKy5SJ/v8ol2tu4FVjyYvtEzkc/3pK15ET6RKg4b4w4BmTk1+gsCUhf21Ykg=="],
|
||||
|
||||
"@inquirer/ansi": ["@inquirer/ansi@1.0.2", "", {}, "sha512-S8qNSZiYzFd0wAcyG5AXCvUHC5Sr7xpZ9wZ2py9XR88jUz8wooStVx5M6dRzczbBWjic9NP7+rY0Xi7qqK/aMQ=="],
|
||||
|
||||
"@inquirer/checkbox": ["@inquirer/checkbox@4.3.2", "", { "dependencies": { "@inquirer/ansi": "^1.0.2", "@inquirer/core": "^10.3.2", "@inquirer/figures": "^1.0.15", "@inquirer/type": "^3.0.10", "yoctocolors-cjs": "^2.1.3" }, "peerDependencies": { "@types/node": ">=18" }, "optionalPeers": ["@types/node"] }, "sha512-VXukHf0RR1doGe6Sm4F0Em7SWYLTHSsbGfJdS9Ja2bX5/D5uwVOEjr07cncLROdBvmnvCATYEWlHqYmXv2IlQA=="],
|
||||
|
||||
"@inquirer/confirm": ["@inquirer/confirm@5.1.21", "", { "dependencies": { "@inquirer/core": "^10.3.2", "@inquirer/type": "^3.0.10" }, "peerDependencies": { "@types/node": ">=18" }, "optionalPeers": ["@types/node"] }, "sha512-KR8edRkIsUayMXV+o3Gv+q4jlhENF9nMYUZs9PA2HzrXeHI8M5uDag70U7RJn9yyiMZSbtF5/UexBtAVtZGSbQ=="],
|
||||
|
||||
"@inquirer/core": ["@inquirer/core@10.3.2", "", { "dependencies": { "@inquirer/ansi": "^1.0.2", "@inquirer/figures": "^1.0.15", "@inquirer/type": "^3.0.10", "cli-width": "^4.1.0", "mute-stream": "^2.0.0", "signal-exit": "^4.1.0", "wrap-ansi": "^6.2.0", "yoctocolors-cjs": "^2.1.3" }, "peerDependencies": { "@types/node": ">=18" }, "optionalPeers": ["@types/node"] }, "sha512-43RTuEbfP8MbKzedNqBrlhhNKVwoK//vUFNW3Q3vZ88BLcrs4kYpGg+B2mm5p2K/HfygoCxuKwJJiv8PbGmE0A=="],
|
||||
|
||||
"@inquirer/editor": ["@inquirer/editor@4.2.23", "", { "dependencies": { "@inquirer/core": "^10.3.2", "@inquirer/external-editor": "^1.0.3", "@inquirer/type": "^3.0.10" }, "peerDependencies": { "@types/node": ">=18" }, "optionalPeers": ["@types/node"] }, "sha512-aLSROkEwirotxZ1pBaP8tugXRFCxW94gwrQLxXfrZsKkfjOYC1aRvAZuhpJOb5cu4IBTJdsCigUlf2iCOu4ZDQ=="],
|
||||
|
||||
"@inquirer/expand": ["@inquirer/expand@4.0.23", "", { "dependencies": { "@inquirer/core": "^10.3.2", "@inquirer/type": "^3.0.10", "yoctocolors-cjs": "^2.1.3" }, "peerDependencies": { "@types/node": ">=18" }, "optionalPeers": ["@types/node"] }, "sha512-nRzdOyFYnpeYTTR2qFwEVmIWypzdAx/sIkCMeTNTcflFOovfqUk+HcFhQQVBftAh9gmGrpFj6QcGEqrDMDOiew=="],
|
||||
|
||||
"@inquirer/external-editor": ["@inquirer/external-editor@1.0.3", "", { "dependencies": { "chardet": "^2.1.1", "iconv-lite": "^0.7.0" }, "peerDependencies": { "@types/node": ">=18" }, "optionalPeers": ["@types/node"] }, "sha512-RWbSrDiYmO4LbejWY7ttpxczuwQyZLBUyygsA9Nsv95hpzUWwnNTVQmAq3xuh7vNwCp07UTmE5i11XAEExx4RA=="],
|
||||
|
||||
"@inquirer/figures": ["@inquirer/figures@1.0.15", "", {}, "sha512-t2IEY+unGHOzAaVM5Xx6DEWKeXlDDcNPeDyUpsRc6CUhBfU3VQOEl+Vssh7VNp1dR8MdUJBWhuObjXCsVpjN5g=="],
|
||||
|
||||
"@inquirer/input": ["@inquirer/input@4.3.1", "", { "dependencies": { "@inquirer/core": "^10.3.2", "@inquirer/type": "^3.0.10" }, "peerDependencies": { "@types/node": ">=18" }, "optionalPeers": ["@types/node"] }, "sha512-kN0pAM4yPrLjJ1XJBjDxyfDduXOuQHrBB8aLDMueuwUGn+vNpF7Gq7TvyVxx8u4SHlFFj4trmj+a2cbpG4Jn1g=="],
|
||||
|
||||
"@inquirer/number": ["@inquirer/number@3.0.23", "", { "dependencies": { "@inquirer/core": "^10.3.2", "@inquirer/type": "^3.0.10" }, "peerDependencies": { "@types/node": ">=18" }, "optionalPeers": ["@types/node"] }, "sha512-5Smv0OK7K0KUzUfYUXDXQc9jrf8OHo4ktlEayFlelCjwMXz0299Y8OrI+lj7i4gCBY15UObk76q0QtxjzFcFcg=="],
|
||||
|
||||
"@inquirer/password": ["@inquirer/password@4.0.23", "", { "dependencies": { "@inquirer/ansi": "^1.0.2", "@inquirer/core": "^10.3.2", "@inquirer/type": "^3.0.10" }, "peerDependencies": { "@types/node": ">=18" }, "optionalPeers": ["@types/node"] }, "sha512-zREJHjhT5vJBMZX/IUbyI9zVtVfOLiTO66MrF/3GFZYZ7T4YILW5MSkEYHceSii/KtRk+4i3RE7E1CUXA2jHcA=="],
|
||||
|
||||
"@inquirer/prompts": ["@inquirer/prompts@7.10.1", "", { "dependencies": { "@inquirer/checkbox": "^4.3.2", "@inquirer/confirm": "^5.1.21", "@inquirer/editor": "^4.2.23", "@inquirer/expand": "^4.0.23", "@inquirer/input": "^4.3.1", "@inquirer/number": "^3.0.23", "@inquirer/password": "^4.0.23", "@inquirer/rawlist": "^4.1.11", "@inquirer/search": "^3.2.2", "@inquirer/select": "^4.4.2" }, "peerDependencies": { "@types/node": ">=18" }, "optionalPeers": ["@types/node"] }, "sha512-Dx/y9bCQcXLI5ooQ5KyvA4FTgeo2jYj/7plWfV5Ak5wDPKQZgudKez2ixyfz7tKXzcJciTxqLeK7R9HItwiByg=="],
|
||||
|
||||
"@inquirer/rawlist": ["@inquirer/rawlist@4.1.11", "", { "dependencies": { "@inquirer/core": "^10.3.2", "@inquirer/type": "^3.0.10", "yoctocolors-cjs": "^2.1.3" }, "peerDependencies": { "@types/node": ">=18" }, "optionalPeers": ["@types/node"] }, "sha512-+LLQB8XGr3I5LZN/GuAHo+GpDJegQwuPARLChlMICNdwW7OwV2izlCSCxN6cqpL0sMXmbKbFcItJgdQq5EBXTw=="],
|
||||
|
||||
"@inquirer/search": ["@inquirer/search@3.2.2", "", { "dependencies": { "@inquirer/core": "^10.3.2", "@inquirer/figures": "^1.0.15", "@inquirer/type": "^3.0.10", "yoctocolors-cjs": "^2.1.3" }, "peerDependencies": { "@types/node": ">=18" }, "optionalPeers": ["@types/node"] }, "sha512-p2bvRfENXCZdWF/U2BXvnSI9h+tuA8iNqtUKb9UWbmLYCRQxd8WkvwWvYn+3NgYaNwdUkHytJMGG4MMLucI1kA=="],
|
||||
|
||||
"@inquirer/select": ["@inquirer/select@4.4.2", "", { "dependencies": { "@inquirer/ansi": "^1.0.2", "@inquirer/core": "^10.3.2", "@inquirer/figures": "^1.0.15", "@inquirer/type": "^3.0.10", "yoctocolors-cjs": "^2.1.3" }, "peerDependencies": { "@types/node": ">=18" }, "optionalPeers": ["@types/node"] }, "sha512-l4xMuJo55MAe+N7Qr4rX90vypFwCajSakx59qe/tMaC1aEHWLyw68wF4o0A4SLAY4E0nd+Vt+EyskeDIqu1M6w=="],
|
||||
|
||||
"@inquirer/type": ["@inquirer/type@3.0.10", "", { "peerDependencies": { "@types/node": ">=18" }, "optionalPeers": ["@types/node"] }, "sha512-BvziSRxfz5Ov8ch0z/n3oijRSEcEsHnhggm4xFZe93DHcUCTlutlq9Ox4SVENAfcRD22UQq7T/atg9Wr3k09eA=="],
|
||||
|
||||
"@jest/schemas": ["@jest/schemas@29.6.3", "", { "dependencies": { "@sinclair/typebox": "^0.27.8" } }, "sha512-mo5j5X+jIZmJQveBKeS/clAueipV7KgiX1vMgCxam1RNYiqE1w62n0/tJJnHtjW8ZHcQco5gY85jA3mi0L+nSA=="],
|
||||
|
||||
"@jest/types": ["@jest/types@29.6.3", "", { "dependencies": { "@jest/schemas": "^29.6.3", "@types/istanbul-lib-coverage": "^2.0.0", "@types/istanbul-reports": "^3.0.0", "@types/node": "*", "@types/yargs": "^17.0.8", "chalk": "^4.0.0" } }, "sha512-u3UPsIilWKOM3F9CXtrG8LEJmNxwoCQC/XVj4IKYXvvpx7QIi/Kg1LI5uDmDpKlac62NUtX7eLjRh+jVZcLOzw=="],
|
||||
@@ -371,6 +380,52 @@
|
||||
|
||||
"@json-render/core": ["@json-render/core@0.19.0", "", { "dependencies": { "zod": "^4.3.6" } }, "sha512-vvcyZ+10EDZKbEyB1J2kXOGfDaiZR2LurZGSqi2r5STHyKr+Te85DWaBxTwRGgM7U1LtIvNx85BzzjElRKoAIg=="],
|
||||
|
||||
"@lexical/a11y": ["@lexical/a11y@0.48.0", "", { "dependencies": { "@lexical/extension": "0.48.0", "@lexical/utils": "0.48.0", "lexical": "0.48.0" }, "peerDependencies": { "typescript": ">=5.2" }, "optionalPeers": ["typescript"] }, "sha512-18W4ehyipkUim4YVoDZitoH63Om3j6iCN4c84zdqE9RgkWf/PE4rvI/8BHTm6Ni7NkVE14nimXgkpaP5ok15zA=="],
|
||||
|
||||
"@lexical/clipboard": ["@lexical/clipboard@0.48.0", "", { "dependencies": { "@lexical/extension": "0.48.0", "@lexical/html": "0.48.0", "@lexical/internal": "0.48.0", "@lexical/list": "0.48.0", "@lexical/selection": "0.48.0", "@lexical/utils": "0.48.0", "@types/trusted-types": "^2.0.7", "lexical": "0.48.0" }, "peerDependencies": { "typescript": ">=5.2" }, "optionalPeers": ["typescript"] }, "sha512-xO2trk6+yBl8XXa/VNe20kXczmPxFoWtUHjidbBLEtlGBj+mo63pJj6H5o/WlZsoMKmIOJxwxsn2ejrS8G0/7A=="],
|
||||
|
||||
"@lexical/code-core": ["@lexical/code-core@0.48.0", "", { "dependencies": { "@lexical/extension": "0.48.0", "@lexical/html": "0.48.0", "@lexical/internal": "0.48.0", "@lexical/utils": "0.48.0", "lexical": "0.48.0" }, "peerDependencies": { "typescript": ">=5.2" }, "optionalPeers": ["typescript"] }, "sha512-+O1Ge06AuSo6+r8R2Xk6SkWG07H5/e4K/Scw9aqCM/BRjITxgvFTHTNvgQbaobCsP0uBJiwW1+ZGeWAWcBCY4w=="],
|
||||
|
||||
"@lexical/devtools-core": ["@lexical/devtools-core@0.48.0", "", { "dependencies": { "@lexical/html": "0.48.0", "@lexical/link": "0.48.0", "@lexical/mark": "0.48.0", "@lexical/table": "0.48.0", "@lexical/utils": "0.48.0", "lexical": "0.48.0" }, "peerDependencies": { "react": ">=18.x", "react-dom": ">=18.x", "typescript": ">=5.2" }, "optionalPeers": ["typescript"] }, "sha512-4kvKWW6ebgQnJNLXPLmw7dqgSChvzYIBNYtfuR6c48Sw+V/QXQTWqfIUbCIe5X4uG8EEXd5O/udXaJx7GBuP+w=="],
|
||||
|
||||
"@lexical/dragon": ["@lexical/dragon@0.48.0", "", { "dependencies": { "@lexical/extension": "0.48.0", "lexical": "0.48.0" }, "peerDependencies": { "typescript": ">=5.2" }, "optionalPeers": ["typescript"] }, "sha512-uPuu7fVca9vmL/Oz30CRZ7FIPIodwMrTgNsRmV8jE6Qd6a7RNiTW7r3+EhbhIdkLb/sjcWsYyOMyUR1TJAB0wQ=="],
|
||||
|
||||
"@lexical/extension": ["@lexical/extension@0.48.0", "", { "dependencies": { "@lexical/internal": "0.48.0", "@lexical/utils": "0.48.0", "@preact/signals-core": "^1.14.1", "lexical": "0.48.0" }, "peerDependencies": { "typescript": ">=5.2" }, "optionalPeers": ["typescript"] }, "sha512-4uBObgz84mVbQWiumndmIhkuJL0ojHiMwFSvSUM/FCo1YMVIZvpI56blI0y+2Vix/oLui9EgVQSJjjWV4NAszw=="],
|
||||
|
||||
"@lexical/hashtag": ["@lexical/hashtag@0.48.0", "", { "dependencies": { "@lexical/text": "0.48.0", "@lexical/utils": "0.48.0", "lexical": "0.48.0" }, "peerDependencies": { "typescript": ">=5.2" }, "optionalPeers": ["typescript"] }, "sha512-hPQtdnbVoNAFsmfnCGfgY7mDbvk6mIznlCRmIR7tLeQKXqz/0Tb6eH3W24EESk9hAF8wFUYNKWE1/Kb3Hl2vEQ=="],
|
||||
|
||||
"@lexical/history": ["@lexical/history@0.48.0", "", { "dependencies": { "@lexical/extension": "0.48.0", "@lexical/utils": "0.48.0", "lexical": "0.48.0" }, "peerDependencies": { "typescript": ">=5.2" }, "optionalPeers": ["typescript"] }, "sha512-NllvUfO+u3mfi5uC8k2CodwdzeeopFFVtMZ/NMifzFbZFysdWi9m9mqfO46NrEA1rSFOydyefv6oMzC8ULXInA=="],
|
||||
|
||||
"@lexical/html": ["@lexical/html@0.48.0", "", { "dependencies": { "@lexical/extension": "0.48.0", "@lexical/internal": "0.48.0", "@lexical/selection": "0.48.0", "@lexical/utils": "0.48.0", "lexical": "0.48.0" }, "peerDependencies": { "typescript": ">=5.2" }, "optionalPeers": ["typescript"] }, "sha512-uBxlgKl4YgSNEgHJSshdBqtGDzruWdx1ewop+u6faT67qHUdP3P0cUXIrG6NToDWvsL6fzCstAbN76PMER1Pnw=="],
|
||||
|
||||
"@lexical/internal": ["@lexical/internal@0.48.0", "", { "peerDependencies": { "typescript": ">=5.2" }, "optionalPeers": ["typescript"] }, "sha512-sRwg53K7N0ZQ7KNAvcCY38LSwGizbXP1zlR1lIojZp0GoqHWNvR+vL49t1wYXu1nXx3Osf4ilHHm+aGcwq5hTw=="],
|
||||
|
||||
"@lexical/link": ["@lexical/link@0.48.0", "", { "dependencies": { "@lexical/extension": "0.48.0", "@lexical/html": "0.48.0", "@lexical/internal": "0.48.0", "@lexical/utils": "0.48.0", "lexical": "0.48.0" }, "peerDependencies": { "typescript": ">=5.2" }, "optionalPeers": ["typescript"] }, "sha512-E0UDmNLUXs/yMCnnE7hbFO0CvhWghmqa+qqPksFfzLkpMHdPpdS1yg59YEbYoNHLi/DXIu4cFRvpHIEuooxwNg=="],
|
||||
|
||||
"@lexical/list": ["@lexical/list@0.48.0", "", { "dependencies": { "@lexical/extension": "0.48.0", "@lexical/html": "0.48.0", "@lexical/internal": "0.48.0", "@lexical/utils": "0.48.0", "lexical": "0.48.0" }, "peerDependencies": { "typescript": ">=5.2" }, "optionalPeers": ["typescript"] }, "sha512-9Qe/Vur44v9F9enj55SUzf79FVsijcGOQug7SpiIU8ekLr7JNzcilKYBYcZ6etGEo7bqQHsYMHXeJcSBbCI2zA=="],
|
||||
|
||||
"@lexical/mark": ["@lexical/mark@0.48.0", "", { "dependencies": { "@lexical/utils": "0.48.0", "lexical": "0.48.0" }, "peerDependencies": { "typescript": ">=5.2" }, "optionalPeers": ["typescript"] }, "sha512-DTtypWvnYSXyNUxEmsUnh4y0xXkmdk8Y72EZl8WDHcCwjqaLJlUakH7p/TuSJczs3uVSaoJzu0yh7oGkP1Vsvw=="],
|
||||
|
||||
"@lexical/markdown": ["@lexical/markdown@0.48.0", "", { "dependencies": { "@lexical/code-core": "0.48.0", "@lexical/internal": "0.48.0", "@lexical/link": "0.48.0", "@lexical/list": "0.48.0", "@lexical/rich-text": "0.48.0", "@lexical/selection": "0.48.0", "@lexical/text": "0.48.0", "@lexical/utils": "0.48.0", "lexical": "0.48.0" }, "peerDependencies": { "typescript": ">=5.2" }, "optionalPeers": ["typescript"] }, "sha512-1WasBenW4bEsa5xtnycVo8G2hcxIqyYLn3/r98yD2Y+54ZyeFuIQfOeJYhIgCh4YkKpfqyrFwkMQwAOsQDqZjA=="],
|
||||
|
||||
"@lexical/overflow": ["@lexical/overflow@0.48.0", "", { "dependencies": { "lexical": "0.48.0" }, "peerDependencies": { "typescript": ">=5.2" }, "optionalPeers": ["typescript"] }, "sha512-1YEvMz2tW3EbwrON9mjrkjMVl/vdTcPYSn9P1j6mf5gj0LOoLDNI4TbvSD4SViy+TDghxNdG8YdCISyU2b4YKA=="],
|
||||
|
||||
"@lexical/plain-text": ["@lexical/plain-text@0.48.0", "", { "dependencies": { "@lexical/clipboard": "0.48.0", "@lexical/dragon": "0.48.0", "@lexical/extension": "0.48.0", "@lexical/selection": "0.48.0", "@lexical/utils": "0.48.0", "lexical": "0.48.0" }, "peerDependencies": { "typescript": ">=5.2" }, "optionalPeers": ["typescript"] }, "sha512-q4f/4VZKVgCrIW2FhDFR2RII1BU0ljedPgEmJ8XQn1zc+JOFPom8Lp0lV5nyEvZaAJYwM/TfoJ9g2V7ESFKznA=="],
|
||||
|
||||
"@lexical/react": ["@lexical/react@0.48.0", "", { "dependencies": { "@floating-ui/react": "^0.27.19", "@lexical/a11y": "0.48.0", "@lexical/devtools-core": "0.48.0", "@lexical/dragon": "0.48.0", "@lexical/extension": "0.48.0", "@lexical/hashtag": "0.48.0", "@lexical/history": "0.48.0", "@lexical/internal": "0.48.0", "@lexical/link": "0.48.0", "@lexical/list": "0.48.0", "@lexical/mark": "0.48.0", "@lexical/markdown": "0.48.0", "@lexical/overflow": "0.48.0", "@lexical/plain-text": "0.48.0", "@lexical/rich-text": "0.48.0", "@lexical/table": "0.48.0", "@lexical/text": "0.48.0", "@lexical/utils": "0.48.0", "@lexical/yjs": "0.48.0", "lexical": "0.48.0" }, "peerDependencies": { "react": ">=18.x", "react-dom": ">=18.x", "typescript": ">=5.2", "yjs": ">=13.5.22" }, "optionalPeers": ["typescript", "yjs"] }, "sha512-uVh9/QSrbtjLjVbxfJ+sfiMyhUq/rv7H6uBEVDDIw1rkZJSDY1fvf/CX+dyKgwcDFjKQZ8/9i5f9UCVPeQ01hA=="],
|
||||
|
||||
"@lexical/rich-text": ["@lexical/rich-text@0.48.0", "", { "dependencies": { "@lexical/clipboard": "0.48.0", "@lexical/dragon": "0.48.0", "@lexical/extension": "0.48.0", "@lexical/html": "0.48.0", "@lexical/selection": "0.48.0", "@lexical/utils": "0.48.0", "lexical": "0.48.0" }, "peerDependencies": { "typescript": ">=5.2" }, "optionalPeers": ["typescript"] }, "sha512-QMXFnwCKAQ4yzxvx5FwmANcx3K+NaBkGTxAVD8s8pOKDD/U5rzDS1iIvhH6TLWaFp7VzvLmLB+Sl1Ie/RnkaDQ=="],
|
||||
|
||||
"@lexical/selection": ["@lexical/selection@0.48.0", "", { "dependencies": { "@lexical/internal": "0.48.0", "lexical": "0.48.0" }, "peerDependencies": { "typescript": ">=5.2" }, "optionalPeers": ["typescript"] }, "sha512-Uc0wTrEtHcYK6z/aHHjkgH3vX/R4Bf8mO+qH3VbxfSAKYzNYktM0j+ZGdq5kIEL4frnw/9SulNCXlH7xpjuDgA=="],
|
||||
|
||||
"@lexical/table": ["@lexical/table@0.48.0", "", { "dependencies": { "@lexical/clipboard": "0.48.0", "@lexical/extension": "0.48.0", "@lexical/html": "0.48.0", "@lexical/internal": "0.48.0", "@lexical/utils": "0.48.0", "lexical": "0.48.0" }, "peerDependencies": { "typescript": ">=5.2" }, "optionalPeers": ["typescript"] }, "sha512-t9Mz7q6ODLUz0lG5Xn9EY/5YiVpTHCqlPQP4EtFXlnBQT3DuKeDS3cC0Cn8sGSZc11YY5OLDfWpB64Frs9BL3g=="],
|
||||
|
||||
"@lexical/text": ["@lexical/text@0.48.0", "", { "dependencies": { "@lexical/internal": "0.48.0", "lexical": "0.48.0" }, "peerDependencies": { "typescript": ">=5.2" }, "optionalPeers": ["typescript"] }, "sha512-ktTMRbsX4wKxdG2OpZCkrqtt8k9Vg/ZpWdukOQ0r1xPRtCuL1T+q91l7cy2ywIuCfMGYS0aoZGB4LdpUMe/H1g=="],
|
||||
|
||||
"@lexical/utils": ["@lexical/utils@0.48.0", "", { "dependencies": { "@lexical/internal": "0.48.0", "@lexical/selection": "0.48.0", "lexical": "0.48.0" }, "peerDependencies": { "typescript": ">=5.2" }, "optionalPeers": ["typescript"] }, "sha512-W4k4P+y6jmRfna8+ad4X+iMd5h8es5PC3bUw5tbi7MRApxaaFG/0w+uJiZVSwbT2Q6JnA2xhBaqzPgt/Gn6djg=="],
|
||||
|
||||
"@lexical/yjs": ["@lexical/yjs@0.48.0", "", { "dependencies": { "@lexical/internal": "0.48.0", "@lexical/selection": "0.48.0", "lexical": "0.48.0" }, "peerDependencies": { "typescript": ">=5.2", "yjs": ">=13.5.22" }, "optionalPeers": ["typescript"] }, "sha512-fFsE8EnPM/2KK9rMJ0z6T+Da5UW5V4P+XiAA03LoHTY5YQ/Oy8Q0i7Wcmocv/B/SpsY2o8g07euZEfudl9MVKA=="],
|
||||
|
||||
"@lingui/babel-plugin-extract-messages": ["@lingui/babel-plugin-extract-messages@6.6.0", "", { "dependencies": { "@lingui/conf": "6.6.0" } }, "sha512-QfCS4SjZYvVZFaiF44e/mLsdzCqQvRSGoVJWkb8nc51FkNpOZkwFLiwLtRnWgvm0ce7GeG51czMsG7RFPPCXqw=="],
|
||||
|
||||
"@lingui/babel-plugin-lingui-macro": ["@lingui/babel-plugin-lingui-macro@6.6.0", "", { "dependencies": { "@lingui/conf": "6.6.0", "@lingui/message-utils": "6.6.0" }, "peerDependencies": { "@babel/core": "^7.20.12 || ^8.0.0", "@babel/types": "^7.20.7 || ^8.0.0" }, "optionalPeers": ["@babel/core", "@babel/types"] }, "sha512-FEBrnIx66lAeD9LGueRIen8IEj+aT7bDaMVmPNIITQNBCm2iC4mNcUtEV1/rAaVpfKKnh5uTdQowWmddB2D7iw=="],
|
||||
@@ -411,6 +466,84 @@
|
||||
|
||||
"@oxc-project/types": ["@oxc-project/types@0.139.0", "", {}, "sha512-r9gHphtCs+1M7J0pw6Sn/hh/Wpa/iQrOOkrNAlVLF/gHq+/CJmHIWKKUUhdWjcD6CIa8idarspCsASiXCXvFUw=="],
|
||||
|
||||
"@oxfmt/binding-android-arm-eabi": ["@oxfmt/binding-android-arm-eabi@0.61.0", "", { "os": "android", "cpu": "arm" }, "sha512-BaS+1OVvg9sr+Xav0+KdWedQRcAzrdoEcwMZeqoc2F6ieC1s/t5eM35YQoRPQ7vAqkZ+p3tbQb1r9I9mrV5oGA=="],
|
||||
|
||||
"@oxfmt/binding-android-arm64": ["@oxfmt/binding-android-arm64@0.61.0", "", { "os": "android", "cpu": "arm64" }, "sha512-of8atAV0M1egGcVOMbgZCvc10sFOP3ayQBNQV5h5G3fNq8gACdEswfFk9bzGrdbM23rtg0Coxi7np7oPLcueNw=="],
|
||||
|
||||
"@oxfmt/binding-darwin-arm64": ["@oxfmt/binding-darwin-arm64@0.61.0", "", { "os": "darwin", "cpu": "arm64" }, "sha512-7l8+5ov4BGwtAcmpzvEik/TG3bciwyw/S3e6j5GKH7pcQqcgCVxD3AuJeP6upto+SOTBKQ4wrrdbMt0gq8fHSQ=="],
|
||||
|
||||
"@oxfmt/binding-darwin-x64": ["@oxfmt/binding-darwin-x64@0.61.0", "", { "os": "darwin", "cpu": "x64" }, "sha512-Fnz4dDDXBb7udk+DmwelNjxbD6yptyxwCqwCH2ebo4RVLxVsRfFsn/AHJC49KIltPrVokamGv4SSOsiV50DTxQ=="],
|
||||
|
||||
"@oxfmt/binding-freebsd-x64": ["@oxfmt/binding-freebsd-x64@0.61.0", "", { "os": "freebsd", "cpu": "x64" }, "sha512-mddOebKNCP+AucmzfNsk3jgbr681qAUvgMqi865GW5gWLJ/AnzXbvjQRrny0e++NAN8aphav/aRSrfFxNsNjpA=="],
|
||||
|
||||
"@oxfmt/binding-linux-arm-gnueabihf": ["@oxfmt/binding-linux-arm-gnueabihf@0.61.0", "", { "os": "linux", "cpu": "arm" }, "sha512-svx59iYL+DbaZGZUIoice4W0CjRXGExnbz7Re+awIb60rVxBS2KrU7Hnlx+nZYanLGLpjneUEgo/VFEKkSZAyQ=="],
|
||||
|
||||
"@oxfmt/binding-linux-arm-musleabihf": ["@oxfmt/binding-linux-arm-musleabihf@0.61.0", "", { "os": "linux", "cpu": "arm" }, "sha512-BYK9MPJPCf6d+fLKMTruThmEyCtHzQ1zLcsrTlUVkmnoXIaHAbfpeLYQwX1tkjs7W11dyzoi6HFvKcdnvX1zNg=="],
|
||||
|
||||
"@oxfmt/binding-linux-arm64-gnu": ["@oxfmt/binding-linux-arm64-gnu@0.61.0", "", { "os": "linux", "cpu": "arm64" }, "sha512-QUaCNLq2/EC6G5ljOuFanl9Lgw6ZWp4co7rs4+KOMUzbGfA4Lq58FHRjjF9sVIG+93XSbo343MxFATrOU1qctA=="],
|
||||
|
||||
"@oxfmt/binding-linux-arm64-musl": ["@oxfmt/binding-linux-arm64-musl@0.61.0", "", { "os": "linux", "cpu": "arm64" }, "sha512-S6uvJ6MXnRXl+zTs0CARNDvkE+cymj0EVWEKKsyKnlLlqTyQJBjw5s4D2pSIOZc+S46cy4STefzcr/sm0VzVPA=="],
|
||||
|
||||
"@oxfmt/binding-linux-ppc64-gnu": ["@oxfmt/binding-linux-ppc64-gnu@0.61.0", "", { "os": "linux", "cpu": "ppc64" }, "sha512-6VDlRcytvZG6UlSIdAFKDLbppo9tvPxrWzle6vHldYFMeuDPQEfMKrkwezp7FaBq1wik9ra554ZZeRPsyIkFpg=="],
|
||||
|
||||
"@oxfmt/binding-linux-riscv64-gnu": ["@oxfmt/binding-linux-riscv64-gnu@0.61.0", "", { "os": "linux", "cpu": "none" }, "sha512-KkBTYbzExpbmn15XjKPLu2fRV2PVlq+KWt+brad5rwIa03vdYoaDRWiS7raHII/dCTR6Ro4UpYUCH4t6lif4WQ=="],
|
||||
|
||||
"@oxfmt/binding-linux-riscv64-musl": ["@oxfmt/binding-linux-riscv64-musl@0.61.0", "", { "os": "linux", "cpu": "none" }, "sha512-69tzIq7sJLVB9dxYYtvMzcSSsnZHSO+U2U19O2RqDqgj6+Q4O7HjSXdaszbcgqzhsUwzSH7z5kWvk8nmf6BHTg=="],
|
||||
|
||||
"@oxfmt/binding-linux-s390x-gnu": ["@oxfmt/binding-linux-s390x-gnu@0.61.0", "", { "os": "linux", "cpu": "s390x" }, "sha512-Oqi/N0OvtOVXsPKAOOhKgGH3msRYF8BLJaNBbWiupRiKoKVyc8JRKPCfarkQJC+RgP9U8raUKLe+bNwd0HUMiA=="],
|
||||
|
||||
"@oxfmt/binding-linux-x64-gnu": ["@oxfmt/binding-linux-x64-gnu@0.61.0", "", { "os": "linux", "cpu": "x64" }, "sha512-3TKwv/ed4uwJSemAA8P9XcoqETpjQI4waquF9UilhA9Mn/dhr1PdUEXWlL74mtc6ZNfmKPA9+NEJm01nRF8CVA=="],
|
||||
|
||||
"@oxfmt/binding-linux-x64-musl": ["@oxfmt/binding-linux-x64-musl@0.61.0", "", { "os": "linux", "cpu": "x64" }, "sha512-uFso4u4nLkVSlMCpgjyvWV60Gt7GvDQHnk1mmRxHIkZTMB0ljpUKwCD9FYGgN9H97x2wYl0UwEjgRZaPIuhEhw=="],
|
||||
|
||||
"@oxfmt/binding-openharmony-arm64": ["@oxfmt/binding-openharmony-arm64@0.61.0", "", { "os": "none", "cpu": "arm64" }, "sha512-keGLkzeOvkMpNmPp4hffXWpfoSsY6e1K8++KXD4mSSfxdvM8q9QUDsYY689TB1k6Co832DZn1MnaaVx6cIBMWQ=="],
|
||||
|
||||
"@oxfmt/binding-win32-arm64-msvc": ["@oxfmt/binding-win32-arm64-msvc@0.61.0", "", { "os": "win32", "cpu": "arm64" }, "sha512-VzsAISkFxmNhJ5LBDEL9VuH6tJsVJMtqYit2LyIUf/HLnsCe4Pg9SMOjjVQzGWt0bnpyfJ94CrqTqcpNZzK+ug=="],
|
||||
|
||||
"@oxfmt/binding-win32-ia32-msvc": ["@oxfmt/binding-win32-ia32-msvc@0.61.0", "", { "os": "win32", "cpu": "ia32" }, "sha512-xv4t7yzwJoYaLB6Zv28B3W+j7brEjsyv50rLTAQgmxJzddce9fAMCxed8dSAkbWES0zz2J29nYK5FaTuD2YBHg=="],
|
||||
|
||||
"@oxfmt/binding-win32-x64-msvc": ["@oxfmt/binding-win32-x64-msvc@0.61.0", "", { "os": "win32", "cpu": "x64" }, "sha512-6EZXFkqOwxdDYjIn3TSNnPk3ST5E5GiYd4FiM6UF/mCL/LZSfr6D6UygTfW3R1PCQP2quCKpCEGRlij8E3VYbg=="],
|
||||
|
||||
"@oxlint/binding-android-arm-eabi": ["@oxlint/binding-android-arm-eabi@1.76.0", "", { "os": "android", "cpu": "arm" }, "sha512-ZHIE5Zt9AsPDcY4nOlofXt0YfneEeo+QrKMPcPzLf2Z6Q8VtV2W73d7SFJ920WUwyik783u/doKCs3KXdwG+7w=="],
|
||||
|
||||
"@oxlint/binding-android-arm64": ["@oxlint/binding-android-arm64@1.76.0", "", { "os": "android", "cpu": "arm64" }, "sha512-shm/ngQilHK6bs+ElJWa4oHfNj5vL1Gl/iVEJldTQjpr0/67oSgr0KUpbmcnLig5Fo0v/l6j2567A7TOL89ONA=="],
|
||||
|
||||
"@oxlint/binding-darwin-arm64": ["@oxlint/binding-darwin-arm64@1.76.0", "", { "os": "darwin", "cpu": "arm64" }, "sha512-rvJmrAPKSQ9aWJ6wIS6CK2tJjwzfW0ApQH9qokq6sfDvmHwoyIHxHFMq7z7i7GiV6fdE6s8qvBqWKPTu8RmT6Q=="],
|
||||
|
||||
"@oxlint/binding-darwin-x64": ["@oxlint/binding-darwin-x64@1.76.0", "", { "os": "darwin", "cpu": "x64" }, "sha512-U/zYdb7VYKGY6pA9Vd2rYl9O/HlCylcOlb5PGPvVLtg+oLGsk6H3XGKEMHKyqD3nmmtmlmwb/8SwU2vfSAtvMw=="],
|
||||
|
||||
"@oxlint/binding-freebsd-x64": ["@oxlint/binding-freebsd-x64@1.76.0", "", { "os": "freebsd", "cpu": "x64" }, "sha512-WvKG9CAriuo0XNiFzpXjDngUZcRGFNpaK2kLyMUsnJlShxkT96u+BpJQ3KqdQwGOrvI14L6V8bAwXwAYNNY6Jg=="],
|
||||
|
||||
"@oxlint/binding-linux-arm-gnueabihf": ["@oxlint/binding-linux-arm-gnueabihf@1.76.0", "", { "os": "linux", "cpu": "arm" }, "sha512-qJ5+RH99TqFRq3UCDxkW0zJJu9c+OAHFY72vGlxZLEpuO+MpKo3POgqb8sYipL9KYm8XY6ofb0HsOuvY6hQNqQ=="],
|
||||
|
||||
"@oxlint/binding-linux-arm-musleabihf": ["@oxlint/binding-linux-arm-musleabihf@1.76.0", "", { "os": "linux", "cpu": "arm" }, "sha512-PvPCVptkgVARsucgIqFQQcSmJ6xc6GtnVB5bRBekRahTc9eObMtjHfMjy5M+C2tHt5UCMttWM9RuSk/H9NqYeg=="],
|
||||
|
||||
"@oxlint/binding-linux-arm64-gnu": ["@oxlint/binding-linux-arm64-gnu@1.76.0", "", { "os": "linux", "cpu": "arm64" }, "sha512-3KeFDx8Bu4HPAXbuHZOr/oHvN+QT+JQhMw/NYPz7Z071xLSsG27Jfh9PIQVEY7hk1I+jr43ExqRIeJ6VKk2yLw=="],
|
||||
|
||||
"@oxlint/binding-linux-arm64-musl": ["@oxlint/binding-linux-arm64-musl@1.76.0", "", { "os": "linux", "cpu": "arm64" }, "sha512-oPFkkKTgl0K/EIg9fQ8oA3IGcI05/Mq1en04iFa41mmNPT+6KEiByVazTOZZJiHMBBrbsns1YJ2e1Scqwzesjw=="],
|
||||
|
||||
"@oxlint/binding-linux-ppc64-gnu": ["@oxlint/binding-linux-ppc64-gnu@1.76.0", "", { "os": "linux", "cpu": "ppc64" }, "sha512-gN7yZ0eqflA5Fhf1wvHxGUltIV3FsvmB1zhNMDEK9vSHhc7E6qg9CuPeBgPZab66Tjzq6w6kHAtNEvnTHf4cyw=="],
|
||||
|
||||
"@oxlint/binding-linux-riscv64-gnu": ["@oxlint/binding-linux-riscv64-gnu@1.76.0", "", { "os": "linux", "cpu": "none" }, "sha512-S/HqMbn22mQrjtErUxEoS/a55u8kIeXvreIxiJu5G7Le3UecEd6SQZxrDIpuhtgaFnsY/nVra3ytP+pRljDilA=="],
|
||||
|
||||
"@oxlint/binding-linux-riscv64-musl": ["@oxlint/binding-linux-riscv64-musl@1.76.0", "", { "os": "linux", "cpu": "none" }, "sha512-ZIga3097VJZolGZk6SrIAUokIGfRkxRlhiHDUznZptGBfwrhD7pNfD1rzEzsCwvk/1DX0A1bLz+liuNh5QKIVQ=="],
|
||||
|
||||
"@oxlint/binding-linux-s390x-gnu": ["@oxlint/binding-linux-s390x-gnu@1.76.0", "", { "os": "linux", "cpu": "s390x" }, "sha512-ZGiiA7pFzMJSyMWYZTVlPgbTsx+Vl8ihLGMIujPwaslUF7kIPPWAbVmAlTc+9lWDV+DCiB8Ikixu+lSHeOIIWQ=="],
|
||||
|
||||
"@oxlint/binding-linux-x64-gnu": ["@oxlint/binding-linux-x64-gnu@1.76.0", "", { "os": "linux", "cpu": "x64" }, "sha512-JLiy5WuvEBFTT6ErIFV35SLzi0R7Iri6MKU6dZbTxfIx8pndbbPs3Mj780nMipBFcPkti+okAPOJ9POKkHFEgg=="],
|
||||
|
||||
"@oxlint/binding-linux-x64-musl": ["@oxlint/binding-linux-x64-musl@1.76.0", "", { "os": "linux", "cpu": "x64" }, "sha512-z7lgKQtbo/I1NIe8G5NHLesxJDv0tRSUWTpXKb9Pm3E9nKFKfO4IOSDtFroKgXtOYb0jQbcdH+0wzTyMXVes+A=="],
|
||||
|
||||
"@oxlint/binding-openharmony-arm64": ["@oxlint/binding-openharmony-arm64@1.76.0", "", { "os": "none", "cpu": "arm64" }, "sha512-JOjKymIpb9QcYfEhZsN6h4V9Ivd474W38cNIBRv6bg2TbIvogbMTH0Mg6YWW9TiRDqfcX+/Hyfsbo5vcSE5guQ=="],
|
||||
|
||||
"@oxlint/binding-win32-arm64-msvc": ["@oxlint/binding-win32-arm64-msvc@1.76.0", "", { "os": "win32", "cpu": "arm64" }, "sha512-pqDWZiwcmByWUEm1NFUBNiT6aentCcaoMWJv0HbXEmuYermJ4sg8ppVrshubYP2MZ6SHccJJcpr6x469PuDFIw=="],
|
||||
|
||||
"@oxlint/binding-win32-ia32-msvc": ["@oxlint/binding-win32-ia32-msvc@1.76.0", "", { "os": "win32", "cpu": "ia32" }, "sha512-Ba0O659kgMv6pwO3z9PdO+K3aMxQRaw9HnG+e6AtOfgwcKFvYilciQYBoUBmxfQvOCKZe1SwjMkuB542NkuDMQ=="],
|
||||
|
||||
"@oxlint/binding-win32-x64-msvc": ["@oxlint/binding-win32-x64-msvc@1.76.0", "", { "os": "win32", "cpu": "x64" }, "sha512-5qcirPHO8nKfkoowEVWtpAoVTcYDy6g0UT0NGic450Qv8J2NrOqg4uQ8QppRP4MDTC7Xx47lbZnmadTH03CGGA=="],
|
||||
|
||||
"@preact/signals-core": ["@preact/signals-core@1.14.4", "", {}, "sha512-HNB6HYeYKhQbJ1aKl+YRjrS4+QWHLKX6qKoUsfS/m0vqzsVaEBiZiaKbG/e+NKk2ch5ALQr/ihWaMHxiCuuWHA=="],
|
||||
|
||||
"@radix-ui/primitive": ["@radix-ui/primitive@1.1.7", "", {}, "sha512-rqWnm76nYT8HoNNqEjpgJ7Pw/DrBj5iBTrmEPo6HTX5+VJyBNOqTdv4g89G63HuR5g0AaENoAcH7Is5fF2kZ8Q=="],
|
||||
|
||||
"@radix-ui/react-compose-refs": ["@radix-ui/react-compose-refs@1.1.5", "", { "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-+48PbAAbq3didjJxa+OaWY2ZwgAKsNiRGyeHKszblZMQ+kcpd9pAaT11cMkGEie0vsOi3QdeTE6d5Fe3Gn61kA=="],
|
||||
@@ -577,20 +710,6 @@
|
||||
|
||||
"@ts-morph/common": ["@ts-morph/common@0.27.0", "", { "dependencies": { "fast-glob": "^3.3.3", "minimatch": "^10.0.1", "path-browserify": "^1.0.1" } }, "sha512-Wf29UqxWDpc+i61k3oIOzcUfQt79PIT9y/MWfAGlrkjg6lBC1hwDECLXPVJAhWjiGbfBCxZd65F/LIZF3+jeJQ=="],
|
||||
|
||||
"@turbo/darwin-64": ["@turbo/darwin-64@2.10.7", "", { "os": "darwin", "cpu": "x64" }, "sha512-/c9cSBRermWDv85oufLhoH6XRLOVbvzJLRd+WLyfJCP+i0HFLQj4PVNDrHcY17/ve5l8X0Oua4bJBqJUgJPnZA=="],
|
||||
|
||||
"@turbo/darwin-arm64": ["@turbo/darwin-arm64@2.10.7", "", { "os": "darwin", "cpu": "arm64" }, "sha512-8lpCCGWZBl9PIF8w8f2iEWrLMbHBWIfJeV6l2UEGqysD6HRIM4ySj/8R7HGEzbECJ4r/gnJcHmxEoG8yjFe64A=="],
|
||||
|
||||
"@turbo/gen": ["@turbo/gen@2.10.7", "", { "dependencies": { "@inquirer/prompts": "^7.10.1", "esbuild": "^0.28.1" }, "bin": { "gen": "dist/cli.js" } }, "sha512-kxLa//A+Q8oe10Pz2MdLKFwS5QUi1uVIp1oqbFraF1OSgSHyNKRWjpn5ITn2c91CzaVEBY4pGFM9ZlKvtnkcGw=="],
|
||||
|
||||
"@turbo/linux-64": ["@turbo/linux-64@2.10.7", "", { "os": "linux", "cpu": "x64" }, "sha512-Midw9Ed00yw9rqkWN82fY3LmLNFQ6yiL1GXB56DJKUEjWaEd27zh7ohCxzzrjfjQVrEeVWd3UPytTAV16XDQlA=="],
|
||||
|
||||
"@turbo/linux-arm64": ["@turbo/linux-arm64@2.10.7", "", { "os": "linux", "cpu": "arm64" }, "sha512-UVEy+MW/xn4BcsiV3v3uv0/oObyaQgVtRT+Jj4WE53rNH05VEYEc1Z13q3zV6276wCZR4Yxc4hiTTcjw7PjSpg=="],
|
||||
|
||||
"@turbo/windows-64": ["@turbo/windows-64@2.10.7", "", { "os": "win32", "cpu": "x64" }, "sha512-l2nH9KGLV46SWjcXvyc2+xo5gdf5J0NVADknQk9OCJhzJBpiNl/byd26yIfwZBjLupdqT6UOdPhPxcpUPxRykQ=="],
|
||||
|
||||
"@turbo/windows-arm64": ["@turbo/windows-arm64@2.10.7", "", { "os": "win32", "cpu": "arm64" }, "sha512-qjE1apG6RThuX49vUJd5ks2dV2ndXC2qktDChQonFMvUzrMrEnZMQzO+IgxBiYq1j+NbXQcRwj84nGnk1TBw1g=="],
|
||||
|
||||
"@tybys/wasm-util": ["@tybys/wasm-util@0.10.3", "", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-F3fo1MYrRJYL3zER0OUOmkutjr1Vp23m7OsSgp7nq4SP6OqX6C/56XFIPAl5bt3zaBRjmW7SGz3u/6LwFpYcOg=="],
|
||||
|
||||
"@types/aria-query": ["@types/aria-query@5.0.4", "", {}, "sha512-rfT93uj5s0PRL7EzccGMs3brplhcrghnDoV26NqKhCAS1hVo+WdNsPvE/yb6ilfr5hi2MEk6d5EWJTKdxg8jVw=="],
|
||||
@@ -631,6 +750,8 @@
|
||||
|
||||
"@types/react-dom": ["@types/react-dom@19.2.3", "", { "peerDependencies": { "@types/react": "^19.2.0" } }, "sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ=="],
|
||||
|
||||
"@types/trusted-types": ["@types/trusted-types@2.0.7", "", {}, "sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw=="],
|
||||
|
||||
"@types/use-sync-external-store": ["@types/use-sync-external-store@0.0.6", "", {}, "sha512-zFDAD+tlpf2r4asuHEj0XH6pY6i0g5NeAHPn+15wk3BV6JA69eERFXC1gyGThDkVa1zCyKr5jox1+2LbV/AMLg=="],
|
||||
|
||||
"@types/validate-npm-package-name": ["@types/validate-npm-package-name@4.0.2", "", {}, "sha512-lrpDziQipxCEeK5kWxvljWYhUvOiB2A9izZd9B2AFarYAkqZshb4lPbRs7zKEic6eGtH8V/2qJW+dPp9OtF6bw=="],
|
||||
@@ -683,6 +804,10 @@
|
||||
|
||||
"@workspace/i18n": ["@workspace/i18n@workspace:packages/i18n"],
|
||||
|
||||
"@workspace/icons": ["@workspace/icons@workspace:packages/icons"],
|
||||
|
||||
"@workspace/lexical": ["@workspace/lexical@workspace:packages/lexical"],
|
||||
|
||||
"@workspace/ui": ["@workspace/ui@workspace:packages/ui"],
|
||||
|
||||
"accepts": ["accepts@2.0.0", "", { "dependencies": { "mime-types": "^3.0.0", "negotiator": "^1.0.0" } }, "sha512-5cvg6CtKwfgdmVqY1WIiXKc3Q1bkRqGLi+2W/6ao+6Y7gu/RCwRuAhGEzh5B4KlszSuTLgZYuqFqo5bImjNKng=="],
|
||||
@@ -755,8 +880,6 @@
|
||||
|
||||
"chalk": ["chalk@5.6.2", "", {}, "sha512-7NzBL0rN6fMUW+f7A6Io4h40qQlG+xGmtMxfbnH/K7TAtt8JQWVQK+6g0UXKMeVJoyV5EkkNsErQ8pVD3bLHbA=="],
|
||||
|
||||
"chardet": ["chardet@2.2.0", "", {}, "sha512-rddelWYNPRrXq6PtNEN2S3f6t9ILzvqaN5pVgi4kqt9jHQaXIial9PznB5iSPVlQSLNaaH22ItWz3EJtQ10+OA=="],
|
||||
|
||||
"chokidar": ["chokidar@5.0.0", "", { "dependencies": { "readdirp": "^5.0.0" } }, "sha512-TQMmc3w+5AxjpL8iIiwebF73dRDF4fBIieAqGn9RGCWaEVwQ6Fb2cGe31Yns0RRIzii5goJ1Y7xbMwo1TxMplw=="],
|
||||
|
||||
"citty": ["citty@0.2.2", "", {}, "sha512-+6vJA3L98yv+IdfKGZHBNiGW5KHn22e/JwID0Strsz8h4S/csAu/OuICwxrg44k5MRiZHWIo8XXuJgQTriRP4w=="],
|
||||
@@ -769,8 +892,6 @@
|
||||
|
||||
"cli-table3": ["cli-table3@0.6.5", "", { "dependencies": { "string-width": "^4.2.0" }, "optionalDependencies": { "@colors/colors": "1.5.0" } }, "sha512-+W/5efTR7y5HRD7gACw9yQjqMVvEMLBHmboM/kPWam+H+Hmyrgjh6YncVKK122YZkXrLudzTuAukUw9FnMf7IQ=="],
|
||||
|
||||
"cli-width": ["cli-width@4.1.0", "", {}, "sha512-ouuZd4/dm2Sw5Gmqy6bGyNNNe1qt9RpmxveLSO7KcgsTnU7RXfsw+/bukWGo1abgBiMAic068rclZsO4IWmmxQ=="],
|
||||
|
||||
"clsx": ["clsx@2.1.1", "", {}, "sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA=="],
|
||||
|
||||
"cmdk": ["cmdk@1.1.1", "", { "dependencies": { "@radix-ui/react-compose-refs": "^1.1.1", "@radix-ui/react-dialog": "^1.1.6", "@radix-ui/react-id": "^1.1.0", "@radix-ui/react-primitive": "^2.0.2" }, "peerDependencies": { "react": "^18 || ^19 || ^19.0.0-rc", "react-dom": "^18 || ^19 || ^19.0.0-rc" } }, "sha512-Vsv7kFaXm+ptHDMZ7izaRsP70GgrW9NBNGswt9OZaVBLlE0SNpDq8eu/VGXyF9r7M0azK3Wy7OlYXsuyYLFzHg=="],
|
||||
@@ -1081,6 +1202,8 @@
|
||||
|
||||
"isexe": ["isexe@3.1.5", "", {}, "sha512-6B3tLtFqtQS4ekarvLVMZ+X+VlvQekbe4taUkf/rhVO3d/h0M2rfARm/pXLcPEsjjMsFgrFgSrhQIxcSVrBz8w=="],
|
||||
|
||||
"isomorphic.js": ["isomorphic.js@0.2.5", "", {}, "sha512-PIeMbHqMt4DnUP3MA/Flc0HElYjMXArsw1qwJZcm9sqR8mq3l8NYizFMty0pWwE/tzIGH3EKK5+jes5mAr85yw=="],
|
||||
|
||||
"jest-get-type": ["jest-get-type@29.6.3", "", {}, "sha512-zrteXnqYxfQh7l5FHyL38jL39di8H8rHoecLH3JNxH3BwOrBsNeabdap5e0I23lD4HHI8W5VFBZqG4Eaq5LNcw=="],
|
||||
|
||||
"jest-validate": ["jest-validate@29.7.0", "", { "dependencies": { "@jest/types": "^29.6.3", "camelcase": "^6.2.0", "chalk": "^4.0.0", "jest-get-type": "^29.6.3", "leven": "^3.1.0", "pretty-format": "^29.7.0" } }, "sha512-ZB7wHqaRGVw/9hST/OuFUReG7M8vKeq0/J2egIGLdvjHCmYqGARhzXmtgi+gVeZ5uXFF219aOc3Ls2yLg27tkw=="],
|
||||
@@ -1113,6 +1236,10 @@
|
||||
|
||||
"leven": ["leven@3.1.0", "", {}, "sha512-qsda+H8jTaUaN/x5vzW2rzc+8Rw4TAQ/4KjB46IwK5VH+IlVeeeje/EoZRpiXvIqjFgK84QffqPztGI3VBLG1A=="],
|
||||
|
||||
"lexical": ["lexical@0.48.0", "", { "dependencies": { "@lexical/internal": "0.48.0" }, "peerDependencies": { "typescript": ">=5.2" }, "optionalPeers": ["typescript"] }, "sha512-KK4Tyr/cPsleoZ7XvhGRiRmcrZidSmoFUdIXK9nPubIifoC+80Dc5THyc4xtGKtsW24S1TsHzk5gmfBU+TxmEg=="],
|
||||
|
||||
"lib0": ["lib0@0.2.117", "", { "dependencies": { "isomorphic.js": "^0.2.4" }, "bin": { "0serve": "bin/0serve.js", "0gentesthtml": "bin/gentesthtml.js", "0ecdsa-generate-keypair": "bin/0ecdsa-generate-keypair.js" } }, "sha512-DeXj9X5xDCjgKLU/7RR+/HQEVzuuEUiwldwOGsHK/sfAfELGWEyTcf0x+uOvCvK3O2zPmZePXWL85vtia6GyZw=="],
|
||||
|
||||
"lightningcss": ["lightningcss@1.32.0", "", { "dependencies": { "detect-libc": "^2.0.3" }, "optionalDependencies": { "lightningcss-android-arm64": "1.32.0", "lightningcss-darwin-arm64": "1.32.0", "lightningcss-darwin-x64": "1.32.0", "lightningcss-freebsd-x64": "1.32.0", "lightningcss-linux-arm-gnueabihf": "1.32.0", "lightningcss-linux-arm64-gnu": "1.32.0", "lightningcss-linux-arm64-musl": "1.32.0", "lightningcss-linux-x64-gnu": "1.32.0", "lightningcss-linux-x64-musl": "1.32.0", "lightningcss-win32-arm64-msvc": "1.32.0", "lightningcss-win32-x64-msvc": "1.32.0" } }, "sha512-NXYBzinNrblfraPGyrbPoD19C1h9lfI/1mzgWYvXUTe414Gz/X1FD2XBZSZM7rRTrMA8JL3OtAaGifrIKhQ5yQ=="],
|
||||
|
||||
"lightningcss-android-arm64": ["lightningcss-android-arm64@1.32.0", "", { "os": "android", "cpu": "arm64" }, "sha512-YK7/ClTt4kAK0vo6w3X+Pnm0D2cf2vPHbhOXdoNti1Ga0al1P4TBZhwjATvjNwLEBCnKvjJc2jQgHXH0NEwlAg=="],
|
||||
@@ -1191,8 +1318,6 @@
|
||||
|
||||
"ms": ["ms@2.1.3", "", {}, "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA=="],
|
||||
|
||||
"mute-stream": ["mute-stream@2.0.0", "", {}, "sha512-WWdIxpyjEn+FhQJQQv9aQAYlHoNVdzIzUySNV1gHUPDSdZJ3yZn7pAAbQcV7B56Mvu881q9FZV+0Vx2xC44VWA=="],
|
||||
|
||||
"nanoid": ["nanoid@3.3.16", "", { "bin": { "nanoid": "bin/nanoid.cjs" } }, "sha512-bzlKTyNJ7+LdGIIwy8ijFpIqEQIvafahV7eYykJ8Cvh42EdJeODoJ6gUJXpQJvej1BddH8OqTXZNE/KfbWAu8Q=="],
|
||||
|
||||
"negotiator": ["negotiator@1.0.0", "", {}, "sha512-8Ofs/AUQh8MaEcrlq5xOX0CQ9ypTF5dl78mjlMNfOK08fzpgTHQRQPBxcPlEtIw0yRpws+Zo/3r+5WRby7u3Gg=="],
|
||||
@@ -1235,6 +1360,10 @@
|
||||
|
||||
"ora": ["ora@9.4.1", "", { "dependencies": { "chalk": "^5.6.2", "cli-cursor": "^5.0.0", "cli-spinners": "^3.2.0", "is-interactive": "^2.0.0", "is-unicode-supported": "^2.1.0", "log-symbols": "^7.0.1", "stdin-discarder": "^0.3.2", "string-width": "^8.1.0" } }, "sha512-6VlU9MLXbjVQD04AZCMX28hVtA5bUoadvUqO76MUCVA0ilwJbMiHsITRPfyVm6p/BC0Av/BXMujx39WCe1LEqw=="],
|
||||
|
||||
"oxfmt": ["oxfmt@0.61.0", "", { "dependencies": { "tinypool": "2.1.0" }, "optionalDependencies": { "@oxfmt/binding-android-arm-eabi": "0.61.0", "@oxfmt/binding-android-arm64": "0.61.0", "@oxfmt/binding-darwin-arm64": "0.61.0", "@oxfmt/binding-darwin-x64": "0.61.0", "@oxfmt/binding-freebsd-x64": "0.61.0", "@oxfmt/binding-linux-arm-gnueabihf": "0.61.0", "@oxfmt/binding-linux-arm-musleabihf": "0.61.0", "@oxfmt/binding-linux-arm64-gnu": "0.61.0", "@oxfmt/binding-linux-arm64-musl": "0.61.0", "@oxfmt/binding-linux-ppc64-gnu": "0.61.0", "@oxfmt/binding-linux-riscv64-gnu": "0.61.0", "@oxfmt/binding-linux-riscv64-musl": "0.61.0", "@oxfmt/binding-linux-s390x-gnu": "0.61.0", "@oxfmt/binding-linux-x64-gnu": "0.61.0", "@oxfmt/binding-linux-x64-musl": "0.61.0", "@oxfmt/binding-openharmony-arm64": "0.61.0", "@oxfmt/binding-win32-arm64-msvc": "0.61.0", "@oxfmt/binding-win32-ia32-msvc": "0.61.0", "@oxfmt/binding-win32-x64-msvc": "0.61.0" }, "peerDependencies": { "svelte": "^5.0.0", "vite-plus": "*" }, "optionalPeers": ["svelte", "vite-plus"], "bin": { "oxfmt": "bin/oxfmt" } }, "sha512-DxdHBEMYpcEnHoUHjjOigUqV2TYKsvxLwUPXnVYBjgFdqrcQ/91OtwubtZ2PUodCs3sStI8R5Qw3fKNGK4e8wQ=="],
|
||||
|
||||
"oxlint": ["oxlint@1.76.0", "", { "optionalDependencies": { "@oxlint/binding-android-arm-eabi": "1.76.0", "@oxlint/binding-android-arm64": "1.76.0", "@oxlint/binding-darwin-arm64": "1.76.0", "@oxlint/binding-darwin-x64": "1.76.0", "@oxlint/binding-freebsd-x64": "1.76.0", "@oxlint/binding-linux-arm-gnueabihf": "1.76.0", "@oxlint/binding-linux-arm-musleabihf": "1.76.0", "@oxlint/binding-linux-arm64-gnu": "1.76.0", "@oxlint/binding-linux-arm64-musl": "1.76.0", "@oxlint/binding-linux-ppc64-gnu": "1.76.0", "@oxlint/binding-linux-riscv64-gnu": "1.76.0", "@oxlint/binding-linux-riscv64-musl": "1.76.0", "@oxlint/binding-linux-s390x-gnu": "1.76.0", "@oxlint/binding-linux-x64-gnu": "1.76.0", "@oxlint/binding-linux-x64-musl": "1.76.0", "@oxlint/binding-openharmony-arm64": "1.76.0", "@oxlint/binding-win32-arm64-msvc": "1.76.0", "@oxlint/binding-win32-ia32-msvc": "1.76.0", "@oxlint/binding-win32-x64-msvc": "1.76.0" }, "peerDependencies": { "oxlint-tsgolint": ">=7.0.2001", "vite-plus": "*" }, "optionalPeers": ["oxlint-tsgolint", "vite-plus"], "bin": { "oxlint": "bin/oxlint" } }, "sha512-6QoFioEU4fNdiUx/2Eo6TRd6NG7H7njnRCz8rhB66cZmMHDTqcm1Rjvl8Wry+ZTQMBAmyb4Mlf62Mk5X+eHSOw=="],
|
||||
|
||||
"p-limit": ["p-limit@2.3.0", "", { "dependencies": { "p-try": "^2.0.0" } }, "sha512-//88mFWSJx8lxCzwdAABTJL2MyWB12+eIY7MDL2SqLmAkeKU9qxRvWuSyTjm3FUmpBEMuFfckAIqEaVGUDxb6w=="],
|
||||
|
||||
"p-locate": ["p-locate@3.0.0", "", { "dependencies": { "p-limit": "^2.0.0" } }, "sha512-x+12w/To+4GFfgJhBEpiDcLozRJGegY+Ei7/z0tSLkMmxGZNybVMSfWj9aJn8Z5Fc7dBUNJOOVgPv2H7IwulSQ=="],
|
||||
@@ -1283,8 +1412,6 @@
|
||||
|
||||
"prettier": ["prettier@3.9.6", "", { "bin": { "prettier": "bin/prettier.cjs" } }, "sha512-OpN0zzVdiaiAhxpuuj5efpIS4sY9j7bY6uR5mnj5yPzGkdkjNKSJeUThPb60Jw29QuAZgA4o+/iB49kFiaBX6g=="],
|
||||
|
||||
"prettier-plugin-tailwindcss": ["prettier-plugin-tailwindcss@0.8.1", "", { "peerDependencies": { "@ianvs/prettier-plugin-sort-imports": "*", "@prettier/plugin-hermes": "*", "@prettier/plugin-oxc": "*", "@prettier/plugin-pug": "*", "@shopify/prettier-plugin-liquid": "*", "@trivago/prettier-plugin-sort-imports": "*", "@zackad/prettier-plugin-twig": "*", "prettier": "^3.0", "prettier-plugin-astro": "*", "prettier-plugin-css-order": "*", "prettier-plugin-jsdoc": "*", "prettier-plugin-marko": "*", "prettier-plugin-multiline-arrays": "*", "prettier-plugin-organize-attributes": "*", "prettier-plugin-organize-imports": "*", "prettier-plugin-sort-imports": "*", "prettier-plugin-svelte": "*" }, "optionalPeers": ["@ianvs/prettier-plugin-sort-imports", "@prettier/plugin-hermes", "@prettier/plugin-oxc", "@prettier/plugin-pug", "@shopify/prettier-plugin-liquid", "@trivago/prettier-plugin-sort-imports", "@zackad/prettier-plugin-twig", "prettier-plugin-astro", "prettier-plugin-css-order", "prettier-plugin-jsdoc", "prettier-plugin-marko", "prettier-plugin-multiline-arrays", "prettier-plugin-organize-attributes", "prettier-plugin-organize-imports", "prettier-plugin-sort-imports", "prettier-plugin-svelte"] }, "sha512-iaFMYqDsE4ffdDkn5qup0j5f2aCEBFZrdrZnvu9QKTlWx/iGPeQ4HHu7b7fCPMxeo9nwQBiOAh2nSypdFYWJkw=="],
|
||||
|
||||
"pretty-format": ["pretty-format@27.5.1", "", { "dependencies": { "ansi-regex": "^5.0.1", "ansi-styles": "^5.0.0", "react-is": "^17.0.1" } }, "sha512-Qb1gy5OrP5+zDf2Bvnzdl3jsTf1qXVMazbvCoKhtKqVs4/YK4ozX4gKQJJVyNe+cajNPn0KoC0MC3FUmaHWEmQ=="],
|
||||
|
||||
"pretty-ms": ["pretty-ms@9.3.0", "", { "dependencies": { "parse-ms": "^4.0.0" } }, "sha512-gjVS5hOP+M3wMm5nmNOucbIrqudzs9v/57bWRHQWLYklXqoXKrVfYW2W9+glfGsqtPgpiz5WwyEEB+ksXIx3gQ=="],
|
||||
@@ -1425,6 +1552,8 @@
|
||||
|
||||
"systeminformation": ["systeminformation@5.33.1", "", { "os": "!aix", "bin": { "systeminformation": "lib/cli.js" } }, "sha512-DEN6ICHk3Tk0Uf/hrAHh7xlt7iL5CJFBtPZinA0H62DrGG/KPKqq/Nzj6lCXPS4Ay/sf/14zNnk9LpqKzBIc+w=="],
|
||||
|
||||
"tabbable": ["tabbable@6.5.0", "", {}, "sha512-wieBHXygIm7OyQOu5hQlkk62/WyCFYGlWg7L6/ZCUZwx0o398Zkn4pVmMyfYhfMG8kGrj/Krt8eIk6UKC6VzwA=="],
|
||||
|
||||
"tailwind-merge": ["tailwind-merge@3.6.0", "", {}, "sha512-uxL7qAVQriqRQPAyK3pj66VqskWqoZ37PW94jwOTwNfq/z9oyu1V+eqrZqtR2+fCiXdYOZe/Modt8GtvqNzu+w=="],
|
||||
|
||||
"tailwindcss": ["tailwindcss@4.3.3", "", {}, "sha512-gOhV3P7ufE62QDGg1zVaTgCR+EtPv92k2nIhVcVKcLmxT1sUBsQGhnZj175j+MqRt4zLF7ic+sCYjfhxMxj7YQ=="],
|
||||
@@ -1461,8 +1590,6 @@
|
||||
|
||||
"tslib": ["tslib@2.8.1", "", {}, "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="],
|
||||
|
||||
"turbo": ["turbo@2.10.7", "", { "optionalDependencies": { "@turbo/darwin-64": "2.10.7", "@turbo/darwin-arm64": "2.10.7", "@turbo/linux-64": "2.10.7", "@turbo/linux-arm64": "2.10.7", "@turbo/windows-64": "2.10.7", "@turbo/windows-arm64": "2.10.7" }, "bin": { "turbo": "bin/turbo" } }, "sha512-GHx6WExIFSKNJ5qMlzDpXBXlu9ApxaMjqxAVrCNcW94xf/+uqgIz41SAuRUMbXva2ExNAaY/h8V0q90SWSzmRw=="],
|
||||
|
||||
"tw-animate-css": ["tw-animate-css@1.4.0", "", {}, "sha512-7bziOlRqH0hJx80h/3mbicLW7o8qLsH5+RaLR2t+OHM3D0JlWGODQKQ4cxbK7WlvmUxpcj6Kgu6EKqjrGFe3QQ=="],
|
||||
|
||||
"type-is": ["type-is@2.1.0", "", { "dependencies": { "content-type": "^2.0.0", "media-typer": "^1.1.0", "mime-types": "^3.0.0" } }, "sha512-faYHw0anBbc/kWF3zFTEnxSFOAGUX9GFbOBthvDdLsIlEoWOFOtS0zgCiQYwIskL9iGXZL3kAXD8OoZ4GmMATA=="],
|
||||
@@ -1531,8 +1658,6 @@
|
||||
|
||||
"why-is-node-running": ["why-is-node-running@2.3.0", "", { "dependencies": { "siginfo": "^2.0.0", "stackback": "0.0.2" }, "bin": { "why-is-node-running": "cli.js" } }, "sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w=="],
|
||||
|
||||
"wrap-ansi": ["wrap-ansi@6.2.0", "", { "dependencies": { "ansi-styles": "^4.0.0", "string-width": "^4.1.0", "strip-ansi": "^6.0.0" } }, "sha512-r6lPcBGxZXlIcymEu7InxDMhdW0KDxpLgoFLcguasxCaJ/SOIZwINatK9KY/tf+ZrlywOKU0UDj3ATXUBfxJXA=="],
|
||||
|
||||
"wrappy": ["wrappy@1.0.2", "", {}, "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ=="],
|
||||
|
||||
"wsl-utils": ["wsl-utils@0.3.1", "", { "dependencies": { "is-wsl": "^3.1.0", "powershell-utils": "^0.1.0" } }, "sha512-g/eziiSUNBSsdDJtCLB8bdYEUMj4jR7AGeUo96p/3dTafgjHhpF4RiCFPiRILwjQoDXx5MqkBr4fwWtR3Ky4Wg=="],
|
||||
@@ -1545,12 +1670,12 @@
|
||||
|
||||
"yallist": ["yallist@3.1.1", "", {}, "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g=="],
|
||||
|
||||
"yjs": ["yjs@13.6.31", "", { "dependencies": { "lib0": "^0.2.99" } }, "sha512-Eq+5BRfbeGyqGVrTJL3bEcr8gKkxPuyuoHmAwpk52fDb8kOVMrfVSTRPd6yiGgX5Fskb96qCRjzjbRjrL4YEnw=="],
|
||||
|
||||
"yocto-spinner": ["yocto-spinner@1.2.2", "", { "dependencies": { "yoctocolors": "^2.1.1" } }, "sha512-DODGl1wJjA/s5pnJFKau9lIYHT81lnhob1i3e1TjxZRxEhWRKl74nTbWE6H5KlkViQQTo/Z29YFdxzTZAMY3ng=="],
|
||||
|
||||
"yoctocolors": ["yoctocolors@2.2.0", "", {}, "sha512-xYqdZFUK/VYazNl/oCDYN+3WloWQwMfZxBoiNt6qNyk+xfOdi598muWE42rNZFp1kNOiqW936q5RhUdnpqElSg=="],
|
||||
|
||||
"yoctocolors-cjs": ["yoctocolors-cjs@2.1.3", "", {}, "sha512-U/PBtDf35ff0D8X8D0jfdzHYEPFxAI7jJlxZXwCSez5M3190m+QobIfh+sWDWSHMCWWJN2AWamkegn6vr6YBTw=="],
|
||||
|
||||
"zigpty": ["zigpty@0.2.1", "", {}, "sha512-MR9JqJx2wf5f4wz8zpx050AlqrmWeIW+1h0SO5iEyhG3HFRjY5luC3szS2ux2EGuPjE5OU9ZAuiCBeMWBTrqZw=="],
|
||||
|
||||
"zod": ["zod@4.4.3", "", {}, "sha512-ytENFjIJFl2UwYglde2jchW2Hwm4GJFLDiSXWdTrJQBIN9Fcyp7n4DhxJEiWNAJMV1/BqWfW/kkg71UDcHJyTQ=="],
|
||||
@@ -1647,10 +1772,6 @@
|
||||
|
||||
"type-is/content-type": ["content-type@2.0.0", "", {}, "sha512-j/O/d7GcZCyNl7/hwZAb606rzqkyvaDctLmckbxLzHvFBzTJHuGEdodATcP3yIRoDrLHkIATJuvzbFlp/ki2cQ=="],
|
||||
|
||||
"wrap-ansi/ansi-styles": ["ansi-styles@4.3.0", "", { "dependencies": { "color-convert": "^2.0.1" } }, "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg=="],
|
||||
|
||||
"wrap-ansi/strip-ansi": ["strip-ansi@6.0.1", "", { "dependencies": { "ansi-regex": "^5.0.1" } }, "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A=="],
|
||||
|
||||
"@dotenvx/dotenvx/execa/get-stream": ["get-stream@6.0.1", "", {}, "sha512-ts6Wi+2j3jQjqi70w5AlN8DFnkSwC+MqmxEzdEALB2qXZYV3X/b1CTfgPLGJNMeAWxdPfU8FO1ms3NUfaHCPYg=="],
|
||||
|
||||
"@dotenvx/dotenvx/execa/human-signals": ["human-signals@2.1.0", "", {}, "sha512-B4FFZ6q/T2jhhksgkbEW3HBvWIfDW85snkQgawt07S7J5QXTk6BkNV+0yAeZrM5QpMAdYlocGoljn0sJ/WQkFw=="],
|
||||
|
||||
+10
-7
@@ -3,15 +3,18 @@
|
||||
"version": "0.0.1",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"build": "turbo build",
|
||||
"dev": "turbo dev",
|
||||
"format": "turbo format",
|
||||
"typecheck": "turbo typecheck"
|
||||
"build": "bun run --filter web build",
|
||||
"check": "bun run format:check && bun run lint && bun run typecheck",
|
||||
"dev": "bun run --filter web dev",
|
||||
"format": "oxfmt apps packages package.json tsconfig.json .oxlintrc.json .oxfmtrc.json",
|
||||
"format:check": "oxfmt --check apps packages package.json tsconfig.json .oxlintrc.json .oxfmtrc.json",
|
||||
"lint": "oxlint .",
|
||||
"lint:fix": "oxlint --fix .",
|
||||
"typecheck": "bun run --workspaces typecheck"
|
||||
},
|
||||
"devDependencies": {
|
||||
"prettier": "^3.8.3",
|
||||
"prettier-plugin-tailwindcss": "^0.8.0",
|
||||
"turbo": "^2.9.18",
|
||||
"oxfmt": "^0.61.0",
|
||||
"oxlint": "^1.76.0",
|
||||
"typescript": "~6"
|
||||
},
|
||||
"packageManager": "bun@1.3.5",
|
||||
|
||||
@@ -4,7 +4,6 @@
|
||||
"type": "module",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"format": "prettier --write \"**/*.{ts,tsx}\"",
|
||||
"test": "vitest run",
|
||||
"typecheck": "tsc --noEmit"
|
||||
},
|
||||
@@ -12,13 +11,13 @@
|
||||
"@base-ui/react": "^1.6.0",
|
||||
"@base-ui/utils": "^0.3.1",
|
||||
"@floating-ui/utils": "^0.2.12",
|
||||
"@hugeicons/core-free-icons": "^4.2.3",
|
||||
"@hugeicons/react": "^1.1.9",
|
||||
"@tanstack/react-hotkeys": "^0.10.0",
|
||||
"@tanstack/react-query": "^5.101.4",
|
||||
"@tanstack/react-router": "^1.170.18",
|
||||
"@workspace/icons": "workspace:*",
|
||||
"@workspace/i18n": "workspace:*",
|
||||
"@workspace/ui": "workspace:*",
|
||||
"cmdk": "^1.1.1",
|
||||
"lucide-react": "^1.27.0",
|
||||
"react": "^19.2.6",
|
||||
"react-dom": "^19.2.6"
|
||||
@@ -45,11 +44,17 @@
|
||||
"./layout": "./src/blocks/layout/index.ts",
|
||||
"./layout/locales": "./src/blocks/layout/locales/catalogs.ts",
|
||||
"./layout/locales/*": "./src/blocks/layout/locales/*.ts",
|
||||
"./media": "./src/blocks/media/index.ts",
|
||||
"./media/locales": "./src/blocks/media/locales/catalogs.ts",
|
||||
"./media/locales/*": "./src/blocks/media/locales/*.ts",
|
||||
"./navigation": "./src/blocks/navigation/index.ts",
|
||||
"./navigation/locales": "./src/blocks/navigation/locales/catalogs.ts",
|
||||
"./navigation/locales/*": "./src/blocks/navigation/locales/*.ts",
|
||||
"./notifications": "./src/blocks/notifications/index.ts",
|
||||
"./notifications/locales": "./src/blocks/notifications/locales/catalogs.ts",
|
||||
"./notifications/locales/*": "./src/blocks/notifications/locales/*.ts"
|
||||
"./notifications/locales/*": "./src/blocks/notifications/locales/*.ts",
|
||||
"./search": "./src/blocks/search/index.ts",
|
||||
"./search/locales": "./src/blocks/search/locales/catalogs.ts",
|
||||
"./search/locales/*": "./src/blocks/search/locales/*.ts"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { useCallback, useState } from "react"
|
||||
import { useHotkey } from "@tanstack/react-hotkeys"
|
||||
import { useMessage } from "@workspace/i18n"
|
||||
import { LocalizedText, useMessage } from "@workspace/i18n"
|
||||
import { useResolvedTheme, useUiState } from "./ui-state"
|
||||
import {
|
||||
Dialog,
|
||||
@@ -16,7 +16,6 @@ import {
|
||||
import { appearanceDialogHandle } from "./dialog"
|
||||
import { CompactPreference, ModePreference } from "./preferences"
|
||||
import { ThemeConfigPanel } from "./theme-config-panel"
|
||||
import { LocalizedText } from "../../components/localized-text"
|
||||
import { appearanceMessages } from "./messages"
|
||||
|
||||
interface AppearanceHotkeysProps {
|
||||
|
||||
@@ -30,22 +30,29 @@ export const messages = {
|
||||
"blocks.appearance.color.violet": "Violett",
|
||||
"blocks.appearance.color.yellow": "Gelb",
|
||||
"blocks.appearance.color.zinc": "Zink",
|
||||
"blocks.appearance.command.description": "Design- und Layout-Einstellungen öffnen",
|
||||
"blocks.appearance.compact.description": "Eine kompakte Breite für den Hauptinhalt verwenden",
|
||||
"blocks.appearance.command.description":
|
||||
"Design- und Layout-Einstellungen öffnen",
|
||||
"blocks.appearance.compact.description":
|
||||
"Eine kompakte Breite für den Hauptinhalt verwenden",
|
||||
"blocks.appearance.compact.title": "Kompaktes Layout",
|
||||
"blocks.appearance.description": "Design- und Layout-Einstellungen",
|
||||
"blocks.appearance.mode.dark": "Dunkel",
|
||||
"blocks.appearance.mode.dark.description": "Die Oberfläche verwendet immer das dunkle Design",
|
||||
"blocks.appearance.mode.dark.description":
|
||||
"Die Oberfläche verwendet immer das dunkle Design",
|
||||
"blocks.appearance.mode.light": "Hell",
|
||||
"blocks.appearance.mode.light.description": "Die Oberfläche verwendet immer das helle Design",
|
||||
"blocks.appearance.mode.light.description":
|
||||
"Die Oberfläche verwendet immer das helle Design",
|
||||
"blocks.appearance.mode.system": "System",
|
||||
"blocks.appearance.mode.system.description": "Das Design der Oberfläche folgt der Systemeinstellung",
|
||||
"blocks.appearance.mode.system.description":
|
||||
"Das Design der Oberfläche folgt der Systemeinstellung",
|
||||
"blocks.appearance.mode.title": "Darstellungsmodus",
|
||||
"blocks.appearance.primaryColor": "Primärfarbe",
|
||||
"blocks.appearance.theme.dark": "Dunkles Design",
|
||||
"blocks.appearance.theme.dark.description": "Dieses Design wird im dunklen Systemmodus verwendet",
|
||||
"blocks.appearance.theme.dark.description":
|
||||
"Dieses Design wird im dunklen Systemmodus verwendet",
|
||||
"blocks.appearance.theme.light": "Helles Design",
|
||||
"blocks.appearance.theme.light.description": "Dieses Design wird im hellen Systemmodus verwendet",
|
||||
"blocks.appearance.theme.light.description":
|
||||
"Dieses Design wird im hellen Systemmodus verwendet",
|
||||
"blocks.appearance.title": "Darstellung",
|
||||
"blocks.appearance.toggleTheme": "Design wechseln",
|
||||
"blocks.appearance.toggleTheme.description":
|
||||
|
||||
@@ -30,23 +30,31 @@ export const messages = {
|
||||
"blocks.appearance.color.violet": "Violeta",
|
||||
"blocks.appearance.color.yellow": "Amarillo",
|
||||
"blocks.appearance.color.zinc": "Zinc",
|
||||
"blocks.appearance.command.description": "Abrir las preferencias de tema y diseño",
|
||||
"blocks.appearance.compact.description": "Usar un ancho compacto para el contenido principal",
|
||||
"blocks.appearance.command.description":
|
||||
"Abrir las preferencias de tema y diseño",
|
||||
"blocks.appearance.compact.description":
|
||||
"Usar un ancho compacto para el contenido principal",
|
||||
"blocks.appearance.compact.title": "Diseño compacto",
|
||||
"blocks.appearance.description": "Preferencias de tema y diseño",
|
||||
"blocks.appearance.mode.dark": "Oscuro",
|
||||
"blocks.appearance.mode.dark.description": "La interfaz siempre usará el tema oscuro",
|
||||
"blocks.appearance.mode.dark.description":
|
||||
"La interfaz siempre usará el tema oscuro",
|
||||
"blocks.appearance.mode.light": "Claro",
|
||||
"blocks.appearance.mode.light.description": "La interfaz siempre usará el tema claro",
|
||||
"blocks.appearance.mode.light.description":
|
||||
"La interfaz siempre usará el tema claro",
|
||||
"blocks.appearance.mode.system": "Sistema",
|
||||
"blocks.appearance.mode.system.description": "El tema de la interfaz seguirá la apariencia del sistema",
|
||||
"blocks.appearance.mode.system.description":
|
||||
"El tema de la interfaz seguirá la apariencia del sistema",
|
||||
"blocks.appearance.mode.title": "Modo de tema",
|
||||
"blocks.appearance.primaryColor": "Color principal",
|
||||
"blocks.appearance.theme.dark": "Tema oscuro",
|
||||
"blocks.appearance.theme.dark.description": "Este tema se usa cuando el sistema está en modo oscuro",
|
||||
"blocks.appearance.theme.dark.description":
|
||||
"Este tema se usa cuando el sistema está en modo oscuro",
|
||||
"blocks.appearance.theme.light": "Tema claro",
|
||||
"blocks.appearance.theme.light.description": "Este tema se usa cuando el sistema está en modo claro",
|
||||
"blocks.appearance.theme.light.description":
|
||||
"Este tema se usa cuando el sistema está en modo claro",
|
||||
"blocks.appearance.title": "Apariencia",
|
||||
"blocks.appearance.toggleTheme": "Cambiar tema",
|
||||
"blocks.appearance.toggleTheme.description": "Cambiar entre los temas claro y oscuro",
|
||||
"blocks.appearance.toggleTheme.description":
|
||||
"Cambiar entre los temas claro y oscuro",
|
||||
} as const satisfies AppearanceMessageCatalog
|
||||
|
||||
@@ -30,23 +30,31 @@ export const messages = {
|
||||
"blocks.appearance.color.violet": "Violet",
|
||||
"blocks.appearance.color.yellow": "Jaune",
|
||||
"blocks.appearance.color.zinc": "Zinc",
|
||||
"blocks.appearance.command.description": "Ouvrir les préférences de thème et de mise en page",
|
||||
"blocks.appearance.compact.description": "Utiliser une largeur compacte pour le contenu principal",
|
||||
"blocks.appearance.command.description":
|
||||
"Ouvrir les préférences de thème et de mise en page",
|
||||
"blocks.appearance.compact.description":
|
||||
"Utiliser une largeur compacte pour le contenu principal",
|
||||
"blocks.appearance.compact.title": "Mise en page compacte",
|
||||
"blocks.appearance.description": "Préférences de thème et de mise en page",
|
||||
"blocks.appearance.mode.dark": "Sombre",
|
||||
"blocks.appearance.mode.dark.description": "L’interface utilisera toujours le thème sombre",
|
||||
"blocks.appearance.mode.dark.description":
|
||||
"L’interface utilisera toujours le thème sombre",
|
||||
"blocks.appearance.mode.light": "Clair",
|
||||
"blocks.appearance.mode.light.description": "L’interface utilisera toujours le thème clair",
|
||||
"blocks.appearance.mode.light.description":
|
||||
"L’interface utilisera toujours le thème clair",
|
||||
"blocks.appearance.mode.system": "Système",
|
||||
"blocks.appearance.mode.system.description": "Le thème de l’interface suivra l’apparence du système",
|
||||
"blocks.appearance.mode.system.description":
|
||||
"Le thème de l’interface suivra l’apparence du système",
|
||||
"blocks.appearance.mode.title": "Mode du thème",
|
||||
"blocks.appearance.primaryColor": "Couleur principale",
|
||||
"blocks.appearance.theme.dark": "Thème sombre",
|
||||
"blocks.appearance.theme.dark.description": "Ce thème est utilisé lorsque le système est en mode sombre",
|
||||
"blocks.appearance.theme.dark.description":
|
||||
"Ce thème est utilisé lorsque le système est en mode sombre",
|
||||
"blocks.appearance.theme.light": "Thème clair",
|
||||
"blocks.appearance.theme.light.description": "Ce thème est utilisé lorsque le système est en mode clair",
|
||||
"blocks.appearance.theme.light.description":
|
||||
"Ce thème est utilisé lorsque le système est en mode clair",
|
||||
"blocks.appearance.title": "Apparence",
|
||||
"blocks.appearance.toggleTheme": "Changer de thème",
|
||||
"blocks.appearance.toggleTheme.description": "Basculer entre les thèmes clair et sombre",
|
||||
"blocks.appearance.toggleTheme.description":
|
||||
"Basculer entre les thèmes clair et sombre",
|
||||
} as const satisfies AppearanceMessageCatalog
|
||||
|
||||
@@ -31,7 +31,8 @@ export const messages = {
|
||||
"blocks.appearance.color.yellow": "イエロー",
|
||||
"blocks.appearance.color.zinc": "ジンク",
|
||||
"blocks.appearance.command.description": "テーマとレイアウト設定を開く",
|
||||
"blocks.appearance.compact.description": "メインページのコンテンツをコンパクトな幅で表示します",
|
||||
"blocks.appearance.compact.description":
|
||||
"メインページのコンテンツをコンパクトな幅で表示します",
|
||||
"blocks.appearance.compact.title": "コンパクトレイアウト",
|
||||
"blocks.appearance.description": "テーマとレイアウトの設定",
|
||||
"blocks.appearance.mode.dark": "ダーク",
|
||||
@@ -39,14 +40,18 @@ export const messages = {
|
||||
"blocks.appearance.mode.light": "ライト",
|
||||
"blocks.appearance.mode.light.description": "常にライトテーマを使用します",
|
||||
"blocks.appearance.mode.system": "システム",
|
||||
"blocks.appearance.mode.system.description": "インターフェースのテーマをシステムの外観に合わせます",
|
||||
"blocks.appearance.mode.system.description":
|
||||
"インターフェースのテーマをシステムの外観に合わせます",
|
||||
"blocks.appearance.mode.title": "テーマモード",
|
||||
"blocks.appearance.primaryColor": "プライマリカラー",
|
||||
"blocks.appearance.theme.dark": "ダークテーマ",
|
||||
"blocks.appearance.theme.dark.description": "システムがダークモードのときに使用するテーマです",
|
||||
"blocks.appearance.theme.dark.description":
|
||||
"システムがダークモードのときに使用するテーマです",
|
||||
"blocks.appearance.theme.light": "ライトテーマ",
|
||||
"blocks.appearance.theme.light.description": "システムがライトモードのときに使用するテーマです",
|
||||
"blocks.appearance.theme.light.description":
|
||||
"システムがライトモードのときに使用するテーマです",
|
||||
"blocks.appearance.title": "外観",
|
||||
"blocks.appearance.toggleTheme": "テーマを切り替える",
|
||||
"blocks.appearance.toggleTheme.description": "ライトテーマとダークテーマを切り替えます",
|
||||
"blocks.appearance.toggleTheme.description":
|
||||
"ライトテーマとダークテーマを切り替えます",
|
||||
} as const satisfies AppearanceMessageCatalog
|
||||
|
||||
@@ -31,22 +31,29 @@ export const messages = {
|
||||
"blocks.appearance.color.yellow": "노란색",
|
||||
"blocks.appearance.color.zinc": "아연색",
|
||||
"blocks.appearance.command.description": "테마 및 레이아웃 환경설정 열기",
|
||||
"blocks.appearance.compact.description": "기본 페이지 콘텐츠에 좁은 너비를 사용합니다",
|
||||
"blocks.appearance.compact.description":
|
||||
"기본 페이지 콘텐츠에 좁은 너비를 사용합니다",
|
||||
"blocks.appearance.compact.title": "컴팩트 레이아웃",
|
||||
"blocks.appearance.description": "테마 및 레이아웃 환경설정",
|
||||
"blocks.appearance.mode.dark": "어둡게",
|
||||
"blocks.appearance.mode.dark.description": "인터페이스에서 항상 어두운 테마를 사용합니다",
|
||||
"blocks.appearance.mode.dark.description":
|
||||
"인터페이스에서 항상 어두운 테마를 사용합니다",
|
||||
"blocks.appearance.mode.light": "밝게",
|
||||
"blocks.appearance.mode.light.description": "인터페이스에서 항상 밝은 테마를 사용합니다",
|
||||
"blocks.appearance.mode.light.description":
|
||||
"인터페이스에서 항상 밝은 테마를 사용합니다",
|
||||
"blocks.appearance.mode.system": "시스템",
|
||||
"blocks.appearance.mode.system.description": "인터페이스 테마가 시스템 화면 모드를 따릅니다",
|
||||
"blocks.appearance.mode.system.description":
|
||||
"인터페이스 테마가 시스템 화면 모드를 따릅니다",
|
||||
"blocks.appearance.mode.title": "테마 모드",
|
||||
"blocks.appearance.primaryColor": "주요 색상",
|
||||
"blocks.appearance.theme.dark": "어두운 테마",
|
||||
"blocks.appearance.theme.dark.description": "시스템이 어두운 모드일 때 사용하는 테마입니다",
|
||||
"blocks.appearance.theme.dark.description":
|
||||
"시스템이 어두운 모드일 때 사용하는 테마입니다",
|
||||
"blocks.appearance.theme.light": "밝은 테마",
|
||||
"blocks.appearance.theme.light.description": "시스템이 밝은 모드일 때 사용하는 테마입니다",
|
||||
"blocks.appearance.theme.light.description":
|
||||
"시스템이 밝은 모드일 때 사용하는 테마입니다",
|
||||
"blocks.appearance.title": "화면 모양",
|
||||
"blocks.appearance.toggleTheme": "테마 전환",
|
||||
"blocks.appearance.toggleTheme.description": "밝은 테마와 어두운 테마 사이를 전환합니다",
|
||||
"blocks.appearance.toggleTheme.description":
|
||||
"밝은 테마와 어두운 테마 사이를 전환합니다",
|
||||
} as const satisfies AppearanceMessageCatalog
|
||||
|
||||
@@ -43,9 +43,11 @@ export const messages = {
|
||||
"blocks.appearance.mode.title": "主题模式",
|
||||
"blocks.appearance.primaryColor": "主要颜色",
|
||||
"blocks.appearance.theme.dark": "深色主题",
|
||||
"blocks.appearance.theme.dark.description": "当系统设置为深色模式时,将使用此主题",
|
||||
"blocks.appearance.theme.dark.description":
|
||||
"当系统设置为深色模式时,将使用此主题",
|
||||
"blocks.appearance.theme.light": "浅色主题",
|
||||
"blocks.appearance.theme.light.description": "当系统设置为浅色模式时,将使用此主题",
|
||||
"blocks.appearance.theme.light.description":
|
||||
"当系统设置为浅色模式时,将使用此主题",
|
||||
"blocks.appearance.title": "界面外观",
|
||||
"blocks.appearance.toggleTheme": "切换主题",
|
||||
"blocks.appearance.toggleTheme.description": "在浅色与深色主题之间切换",
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { MonitorIcon, MoonIcon, SunIcon } from "lucide-react"
|
||||
import { useMessage } from "@workspace/i18n"
|
||||
import { LocalizedText, useMessage } from "@workspace/i18n"
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
@@ -11,7 +11,6 @@ import { Switch } from "@workspace/ui/components/switch"
|
||||
|
||||
import type { ThemeMode } from "./state"
|
||||
import { useUiState } from "./ui-state"
|
||||
import { LocalizedText } from "../../components/localized-text"
|
||||
import { appearanceMessages } from "./messages"
|
||||
|
||||
function ModeIcon({ mode }: { mode: ThemeMode | null }) {
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { MoonIcon, SunIcon } from "lucide-react"
|
||||
import { LocalizedText } from "@workspace/i18n"
|
||||
import { useUiState } from "./ui-state"
|
||||
import { Badge } from "@workspace/ui/components/badge"
|
||||
import {
|
||||
@@ -8,7 +9,6 @@ import {
|
||||
} from "./theme-color-picker"
|
||||
import { ThemePreview } from "./theme-preview"
|
||||
import type { ThemeScheme } from "./theme"
|
||||
import { LocalizedText } from "../../components/localized-text"
|
||||
import { appearanceMessages } from "./messages"
|
||||
|
||||
export function ThemeConfigPanel({
|
||||
|
||||
@@ -1,5 +1,11 @@
|
||||
export type BaseColor =
|
||||
"neutral" | "stone" | "zinc" | "mauve" | "olive" | "mist" | "taupe"
|
||||
| "neutral"
|
||||
| "stone"
|
||||
| "zinc"
|
||||
| "mauve"
|
||||
| "olive"
|
||||
| "mist"
|
||||
| "taupe"
|
||||
|
||||
export type AccentColor =
|
||||
| "amber"
|
||||
|
||||
@@ -131,13 +131,14 @@ export function UiStateProvider({
|
||||
storeRef.current.getSnapshot,
|
||||
storeRef.current.getServerSnapshot
|
||||
)
|
||||
const themeMode = state["theme-mode"]
|
||||
|
||||
React.useLayoutEffect(() => {
|
||||
applyUiState(state)
|
||||
}, [state])
|
||||
|
||||
React.useEffect(() => {
|
||||
if (state["theme-mode"] !== "system") return
|
||||
if (themeMode !== "system") return
|
||||
|
||||
const mediaQuery = window.matchMedia(systemThemeQuery)
|
||||
const applySystemTheme = () => {
|
||||
@@ -147,7 +148,7 @@ export function UiStateProvider({
|
||||
|
||||
mediaQuery.addEventListener("change", applySystemTheme)
|
||||
return () => mediaQuery.removeEventListener("change", applySystemTheme)
|
||||
}, [state["theme-mode"]])
|
||||
}, [themeMode])
|
||||
|
||||
const contextValue = React.useMemo(
|
||||
() => ({
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { Link } from "@tanstack/react-router"
|
||||
import * as React from "react"
|
||||
import { Icon } from "@workspace/icons"
|
||||
import { useMessage } from "@workspace/i18n"
|
||||
import {
|
||||
Breadcrumb,
|
||||
@@ -27,7 +28,6 @@ import {
|
||||
type NavigationInfo,
|
||||
useNavigationStack,
|
||||
} from "../navigation"
|
||||
import { Icon } from "../../components/icon"
|
||||
import { layoutMessages } from "./messages"
|
||||
|
||||
const MAX_VISIBLE_ENTRIES = 3
|
||||
|
||||
@@ -1,4 +1,10 @@
|
||||
import * as React from "react"
|
||||
import {
|
||||
BellIcon,
|
||||
Icon,
|
||||
MessageMultiple01Icon,
|
||||
Search01Icon,
|
||||
} from "@workspace/icons"
|
||||
import { Button } from "@workspace/ui/components/button"
|
||||
import { Kbd } from "@workspace/ui/components/kbd"
|
||||
import { useIsTablet } from "@workspace/ui/hooks/use-breakpoint"
|
||||
@@ -6,7 +12,6 @@ import { cn } from "@workspace/ui/lib/utils"
|
||||
import { ChevronLeftIcon, ChevronRightIcon, SearchIcon } from "lucide-react"
|
||||
|
||||
import { ChatPopover, ChatPopoverTrigger, type ChatThread } from "../chats"
|
||||
import { Icon } from "../../components/icon"
|
||||
import {
|
||||
MobileNavigationSheet,
|
||||
MobileNavigationSheetTrigger,
|
||||
@@ -18,12 +23,6 @@ import {
|
||||
NotificationCenterTrigger,
|
||||
type UseNotificationsOptions,
|
||||
} from "../notifications"
|
||||
import {
|
||||
BellIcon,
|
||||
MessageMultiple01Icon,
|
||||
Search01Icon,
|
||||
} from "@hugeicons/core-free-icons"
|
||||
|
||||
import { AppSidebar } from "./app-sidebar"
|
||||
import {
|
||||
headerActionIconClassName,
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { ReloadIcon } from "@workspace/icons"
|
||||
import { useMessage } from "@workspace/i18n"
|
||||
import { useRefresh } from "../../hooks/use-refresh"
|
||||
import {
|
||||
@@ -6,7 +7,6 @@ import {
|
||||
TooltipTrigger,
|
||||
} from "@workspace/ui/components/tooltip"
|
||||
import { HeaderActionButton } from "./header-action-button"
|
||||
import { ReloadIcon } from "@hugeicons/core-free-icons"
|
||||
import { cn } from "@workspace/ui/lib/utils"
|
||||
import { layoutMessages } from "./messages"
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { Icon, type IconData } from "../../components/icon"
|
||||
import { Icon, type IconData } from "@workspace/icons"
|
||||
import { Button, type ButtonProps } from "@workspace/ui/components/button"
|
||||
import { cn } from "@workspace/ui/lib/utils"
|
||||
|
||||
|
||||
@@ -25,7 +25,7 @@ export function SidebarStateProvider({
|
||||
|
||||
const contextValue = React.useMemo(
|
||||
() => ({ isCollapsed, setCollapsed }),
|
||||
[isCollapsed]
|
||||
[isCollapsed, setCollapsed]
|
||||
)
|
||||
|
||||
return (
|
||||
|
||||
@@ -1,4 +1,12 @@
|
||||
import { formatForDisplay } from "@tanstack/react-hotkeys"
|
||||
import {
|
||||
Icon,
|
||||
type IconData,
|
||||
KeyboardIcon,
|
||||
LogoutCircle02Icon,
|
||||
ShieldKeyIcon,
|
||||
SwatchIcon,
|
||||
} from "@workspace/icons"
|
||||
import { useMessage } from "@workspace/i18n"
|
||||
import { Button, type ButtonProps } from "@workspace/ui/components/button"
|
||||
import {
|
||||
@@ -35,13 +43,6 @@ import {
|
||||
HeaderActionButton,
|
||||
} from "./header-action-button"
|
||||
import { useOnScroll } from "../../hooks/use-on-scroll"
|
||||
import { Icon, type IconData } from "../../components/icon"
|
||||
import {
|
||||
KeyboardIcon,
|
||||
LogoutCircle02Icon,
|
||||
ShieldKeyIcon,
|
||||
SwatchIcon,
|
||||
} from "@hugeicons/core-free-icons"
|
||||
import { layoutMessages } from "./messages"
|
||||
|
||||
interface UserMenuItem {
|
||||
|
||||
@@ -0,0 +1,35 @@
|
||||
import * as React from "react"
|
||||
|
||||
import type { MediaAdapter, MediaNotice } from "./types"
|
||||
|
||||
interface MediaContextValue {
|
||||
adapter: MediaAdapter
|
||||
notify?: (notice: MediaNotice) => void
|
||||
}
|
||||
|
||||
const MediaContext = React.createContext<MediaContextValue | null>(null)
|
||||
|
||||
export interface MediaProviderProps {
|
||||
adapter: MediaAdapter
|
||||
children: React.ReactNode
|
||||
notify?: (notice: MediaNotice) => void
|
||||
}
|
||||
|
||||
/** Supplies all persistence and transport dependencies for the media blocks. */
|
||||
export function MediaProvider({
|
||||
adapter,
|
||||
children,
|
||||
notify,
|
||||
}: MediaProviderProps) {
|
||||
const value = React.useMemo(() => ({ adapter, notify }), [adapter, notify])
|
||||
|
||||
return <MediaContext.Provider value={value}>{children}</MediaContext.Provider>
|
||||
}
|
||||
|
||||
export function useMedia(): MediaContextValue {
|
||||
const value = React.useContext(MediaContext)
|
||||
if (!value) {
|
||||
throw new Error("Media blocks must be rendered inside a MediaProvider.")
|
||||
}
|
||||
return value
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
export interface MediaDimensions {
|
||||
height: number
|
||||
width: number
|
||||
}
|
||||
|
||||
const METADATA_TIMEOUT_MS = 10_000
|
||||
|
||||
function waitForDimensions(
|
||||
file: File,
|
||||
element: HTMLImageElement | HTMLVideoElement,
|
||||
successEvent: "load" | "loadedmetadata"
|
||||
): Promise<MediaDimensions | undefined> {
|
||||
return new Promise((resolve) => {
|
||||
const objectUrl = URL.createObjectURL(file)
|
||||
let settled = false
|
||||
const timeout = window.setTimeout(finish, METADATA_TIMEOUT_MS)
|
||||
|
||||
function finish(dimensions?: MediaDimensions) {
|
||||
if (settled) return
|
||||
settled = true
|
||||
window.clearTimeout(timeout)
|
||||
URL.revokeObjectURL(objectUrl)
|
||||
resolve(dimensions)
|
||||
}
|
||||
|
||||
element.addEventListener(
|
||||
successEvent,
|
||||
() => {
|
||||
const width =
|
||||
element instanceof HTMLVideoElement
|
||||
? element.videoWidth
|
||||
: element.naturalWidth
|
||||
const height =
|
||||
element instanceof HTMLVideoElement
|
||||
? element.videoHeight
|
||||
: element.naturalHeight
|
||||
finish(width > 0 && height > 0 ? { height, width } : undefined)
|
||||
},
|
||||
{ once: true }
|
||||
)
|
||||
element.addEventListener("error", () => finish(), { once: true })
|
||||
element.src = objectUrl
|
||||
})
|
||||
}
|
||||
|
||||
/** Reads intrinsic dimensions before an adapter uploads an image or video. */
|
||||
export function readMediaDimensions(
|
||||
file: File
|
||||
): Promise<MediaDimensions | undefined> {
|
||||
if (file.type.startsWith("image/")) {
|
||||
return waitForDimensions(file, new Image(), "load")
|
||||
}
|
||||
if (file.type.startsWith("video/")) {
|
||||
const video = document.createElement("video")
|
||||
video.preload = "metadata"
|
||||
return waitForDimensions(file, video, "loadedmetadata")
|
||||
}
|
||||
return Promise.resolve(undefined)
|
||||
}
|
||||
@@ -0,0 +1,112 @@
|
||||
import * as React from "react"
|
||||
import { useTranslate } from "@workspace/i18n"
|
||||
import { Button } from "@workspace/ui/components/button"
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@workspace/ui/components/dialog"
|
||||
import { Input } from "@workspace/ui/components/input"
|
||||
import {
|
||||
InputGroup,
|
||||
InputGroupAddon,
|
||||
InputGroupInput,
|
||||
InputGroupText,
|
||||
} from "@workspace/ui/components/input-group"
|
||||
|
||||
import { mediaMessages } from "./messages"
|
||||
|
||||
export interface MediaFolderDialogProps {
|
||||
description: string
|
||||
initialName?: string
|
||||
lockedSuffix?: string
|
||||
maxLength?: number
|
||||
open: boolean
|
||||
pending?: boolean
|
||||
title: string
|
||||
onOpenChange: (open: boolean) => void
|
||||
onSubmit: (name: string) => void
|
||||
}
|
||||
|
||||
export function MediaFolderDialog({
|
||||
description,
|
||||
initialName = "",
|
||||
lockedSuffix,
|
||||
maxLength = 50,
|
||||
open,
|
||||
pending,
|
||||
title,
|
||||
onOpenChange,
|
||||
onSubmit,
|
||||
}: MediaFolderDialogProps) {
|
||||
const t = useTranslate()
|
||||
const [name, setName] = React.useState(initialName)
|
||||
|
||||
React.useEffect(() => {
|
||||
if (open) setName(initialName)
|
||||
}, [initialName, open])
|
||||
|
||||
const normalizedName = name.trim()
|
||||
const input = (
|
||||
<Input
|
||||
autoFocus
|
||||
value={name}
|
||||
maxLength={maxLength}
|
||||
placeholder={t(mediaMessages.folderNamePlaceholder)}
|
||||
aria-label={t(mediaMessages.folderName)}
|
||||
onChange={(event) => setName(event.target.value)}
|
||||
/>
|
||||
)
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent>
|
||||
<form
|
||||
className="contents"
|
||||
onSubmit={(event) => {
|
||||
event.preventDefault()
|
||||
if (normalizedName) onSubmit(normalizedName)
|
||||
}}
|
||||
>
|
||||
<DialogHeader>
|
||||
<DialogTitle>{title}</DialogTitle>
|
||||
<DialogDescription>{description}</DialogDescription>
|
||||
</DialogHeader>
|
||||
{lockedSuffix ? (
|
||||
<InputGroup>
|
||||
<InputGroupInput
|
||||
autoFocus
|
||||
value={name}
|
||||
maxLength={maxLength}
|
||||
placeholder={t(mediaMessages.folderNamePlaceholder)}
|
||||
aria-label={t(mediaMessages.folderName)}
|
||||
onChange={(event) => setName(event.target.value)}
|
||||
/>
|
||||
<InputGroupAddon align="inline-end">
|
||||
<InputGroupText>{lockedSuffix}</InputGroupText>
|
||||
</InputGroupAddon>
|
||||
</InputGroup>
|
||||
) : (
|
||||
input
|
||||
)}
|
||||
<DialogFooter>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
disabled={pending}
|
||||
onClick={() => onOpenChange(false)}
|
||||
>
|
||||
{t(mediaMessages.cancel)}
|
||||
</Button>
|
||||
<Button type="submit" disabled={!normalizedName || pending}>
|
||||
{pending ? t(mediaMessages.saving) : t(mediaMessages.save)}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</form>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
export { MediaProvider, useMedia } from "./context"
|
||||
export type { MediaProviderProps } from "./context"
|
||||
export { MediaLibrary } from "./media-library"
|
||||
export type { MediaLibraryProps } from "./media-library"
|
||||
export { MediaPickerDialog } from "./media-picker-dialog"
|
||||
export type { MediaPickerDialogProps } from "./media-picker-dialog"
|
||||
export { defaultMediaReference, formatMediaFileSize } from "./utils"
|
||||
export { readMediaDimensions } from "./dimensions"
|
||||
export type { MediaDimensions } from "./dimensions"
|
||||
export type {
|
||||
CreateMediaFolderInput,
|
||||
MediaAdapter,
|
||||
MediaAsset,
|
||||
MediaAssetPage,
|
||||
MediaAssetQuery,
|
||||
MediaFilter,
|
||||
MediaFolder,
|
||||
MediaFolderSelection,
|
||||
MediaId,
|
||||
MediaKind,
|
||||
MediaNotice,
|
||||
MediaSelectionMode,
|
||||
MediaStorageTarget,
|
||||
MediaUploadInput,
|
||||
UpdateMediaFolderInput,
|
||||
} from "./types"
|
||||
@@ -0,0 +1,4 @@
|
||||
import type { mediaMessages } from "../messages"
|
||||
import type { BlockMessageCatalog } from "../../../i18n/catalogs"
|
||||
|
||||
export type MediaMessageCatalog = BlockMessageCatalog<typeof mediaMessages>
|
||||
@@ -0,0 +1,66 @@
|
||||
import type { MediaMessageCatalog } from "./catalogs"
|
||||
|
||||
export const locale = "en"
|
||||
export const languageTag = "en-US"
|
||||
export const messages = {
|
||||
"blocks.media.actions.cancel": "Cancel",
|
||||
"blocks.media.actions.copyInformation": "Copy information",
|
||||
"blocks.media.actions.copyLink": "Copy link",
|
||||
"blocks.media.actions.copyName": "Copy name",
|
||||
"blocks.media.actions.delete": "Delete",
|
||||
"blocks.media.actions.details": "View details",
|
||||
"blocks.media.actions.favorite": "Favorite",
|
||||
"blocks.media.actions.loadMore": "Load more",
|
||||
"blocks.media.actions.moveToFolder": "Move to folder",
|
||||
"blocks.media.actions.rename": "Rename",
|
||||
"blocks.media.actions.retry": "Try again",
|
||||
"blocks.media.actions.save": "Save",
|
||||
"blocks.media.actions.saving": "Saving…",
|
||||
"blocks.media.actions.select": "Select",
|
||||
"blocks.media.actions.selectFolder": "Select folder {name}",
|
||||
"blocks.media.actions.unfavorite": "Unfavorite",
|
||||
"blocks.media.actions.upload": "Upload",
|
||||
"blocks.media.actions.viewOriginal": "View original",
|
||||
"blocks.media.copy.failed": "Copy failed. Check browser clipboard permissions.",
|
||||
"blocks.media.deleteAsset.description": "This media item will no longer appear in the library.",
|
||||
"blocks.media.deleteAsset.title": "Delete media",
|
||||
"blocks.media.deleteFolder.description": "This folder and its child folders will be deleted. Their media items will become unclassified.",
|
||||
"blocks.media.deleteFolder.title": "Delete folder",
|
||||
"blocks.media.details.dimensions": "Dimensions",
|
||||
"blocks.media.details.fileName": "File name",
|
||||
"blocks.media.details.noDimensions": "Unknown",
|
||||
"blocks.media.details.size": "Size",
|
||||
"blocks.media.details.type": "Type",
|
||||
"blocks.media.details.uploadedAt": "Uploaded",
|
||||
"blocks.media.empty.description": "Upload images or videos to manage them here.",
|
||||
"blocks.media.empty.title": "No media",
|
||||
"blocks.media.emptySearch.description": "No media files match your search.",
|
||||
"blocks.media.filters.all": "All media",
|
||||
"blocks.media.filters.favorite": "Favorites",
|
||||
"blocks.media.filters.image": "Images",
|
||||
"blocks.media.filters.unclassified": "Unclassified",
|
||||
"blocks.media.filters.video": "Videos",
|
||||
"blocks.media.folders.classification": "Folders only organize media in this library and do not change file URLs.",
|
||||
"blocks.media.folders.create": "New folder",
|
||||
"blocks.media.folders.empty": "No folders yet. Create one.",
|
||||
"blocks.media.folders.name": "Folder name",
|
||||
"blocks.media.folders.newChild": "New child folder",
|
||||
"blocks.media.folders.placeholder": "Enter a folder name",
|
||||
"blocks.media.folders.rename": "Rename folder",
|
||||
"blocks.media.folders.title": "Folders",
|
||||
"blocks.media.load.error": "Unable to load media",
|
||||
"blocks.media.loading": "Loading media…",
|
||||
"blocks.media.picker.description": "Choose an uploaded image or video from the media library.",
|
||||
"blocks.media.picker.selectedCount": "{count} selected",
|
||||
"blocks.media.picker.title": "Select media",
|
||||
"blocks.media.picker.useSelected": "Use selected media",
|
||||
"blocks.media.renameMedia.description": "This changes the display name without changing the file or its URL.",
|
||||
"blocks.media.renameMedia.title": "Rename media",
|
||||
"blocks.media.search": "Search media files",
|
||||
"blocks.media.title": "Media library",
|
||||
"blocks.media.upload.chooseStorage": "Choose cloud storage",
|
||||
"blocks.media.upload.cloud": "Upload to cloud",
|
||||
"blocks.media.upload.error": "Unable to upload media.",
|
||||
"blocks.media.upload.server": "Upload to server",
|
||||
"blocks.media.upload.success": "{count} media files uploaded.",
|
||||
} as const satisfies MediaMessageCatalog
|
||||
@@ -0,0 +1,66 @@
|
||||
import type { MediaMessageCatalog } from "./catalogs"
|
||||
|
||||
export const locale = "zh-Hans"
|
||||
export const languageTag = "zh-CN"
|
||||
export const messages = {
|
||||
"blocks.media.actions.cancel": "取消",
|
||||
"blocks.media.actions.copyInformation": "复制媒体信息",
|
||||
"blocks.media.actions.copyLink": "复制链接",
|
||||
"blocks.media.actions.copyName": "复制名称",
|
||||
"blocks.media.actions.delete": "删除",
|
||||
"blocks.media.actions.details": "查看详情",
|
||||
"blocks.media.actions.favorite": "收藏",
|
||||
"blocks.media.actions.loadMore": "加载更多",
|
||||
"blocks.media.actions.moveToFolder": "移动到目录",
|
||||
"blocks.media.actions.rename": "重命名",
|
||||
"blocks.media.actions.retry": "重新加载",
|
||||
"blocks.media.actions.save": "保存",
|
||||
"blocks.media.actions.saving": "正在保存…",
|
||||
"blocks.media.actions.select": "选择",
|
||||
"blocks.media.actions.selectFolder": "选择目录:{name}",
|
||||
"blocks.media.actions.unfavorite": "取消收藏",
|
||||
"blocks.media.actions.upload": "上传",
|
||||
"blocks.media.actions.viewOriginal": "查看原文件",
|
||||
"blocks.media.copy.failed": "复制失败,请检查浏览器剪贴板权限。",
|
||||
"blocks.media.deleteAsset.description": "该媒体将不再显示在媒体库中。",
|
||||
"blocks.media.deleteAsset.title": "删除媒体",
|
||||
"blocks.media.deleteFolder.description": "该目录及其子目录将被删除,其中的媒体会变为未分类。",
|
||||
"blocks.media.deleteFolder.title": "删除目录",
|
||||
"blocks.media.details.dimensions": "尺寸",
|
||||
"blocks.media.details.fileName": "文件名称",
|
||||
"blocks.media.details.noDimensions": "未知",
|
||||
"blocks.media.details.size": "大小",
|
||||
"blocks.media.details.type": "类型",
|
||||
"blocks.media.details.uploadedAt": "上传时间",
|
||||
"blocks.media.empty.description": "上传图片或视频后,可在这里统一管理。",
|
||||
"blocks.media.empty.title": "暂无媒体",
|
||||
"blocks.media.emptySearch.description": "没有找到匹配的媒体文件。",
|
||||
"blocks.media.filters.all": "全部媒体",
|
||||
"blocks.media.filters.favorite": "收藏",
|
||||
"blocks.media.filters.image": "图片",
|
||||
"blocks.media.filters.unclassified": "未分类",
|
||||
"blocks.media.filters.video": "视频",
|
||||
"blocks.media.folders.classification": "目录仅用于媒体库内分类,不会改变文件链接。",
|
||||
"blocks.media.folders.create": "新建目录",
|
||||
"blocks.media.folders.empty": "暂无目录,点击新建。",
|
||||
"blocks.media.folders.name": "目录名称",
|
||||
"blocks.media.folders.newChild": "新建子目录",
|
||||
"blocks.media.folders.placeholder": "输入目录名称",
|
||||
"blocks.media.folders.rename": "重命名目录",
|
||||
"blocks.media.folders.title": "目录",
|
||||
"blocks.media.load.error": "无法加载媒体",
|
||||
"blocks.media.loading": "正在加载媒体…",
|
||||
"blocks.media.picker.description": "从媒体库中选择已上传的图片或视频。",
|
||||
"blocks.media.picker.selectedCount": "已选择 {count} 项",
|
||||
"blocks.media.picker.title": "选择媒体",
|
||||
"blocks.media.picker.useSelected": "使用所选媒体",
|
||||
"blocks.media.renameMedia.description": "仅修改展示名称,不会改变文件或已有链接。",
|
||||
"blocks.media.renameMedia.title": "修改媒体名称",
|
||||
"blocks.media.search": "搜索媒体文件",
|
||||
"blocks.media.title": "媒体库",
|
||||
"blocks.media.upload.chooseStorage": "选择云端存储",
|
||||
"blocks.media.upload.cloud": "上传到云端",
|
||||
"blocks.media.upload.error": "无法上传媒体文件。",
|
||||
"blocks.media.upload.server": "上传到服务器",
|
||||
"blocks.media.upload.success": "已上传 {count} 个媒体文件。",
|
||||
} as const satisfies MediaMessageCatalog
|
||||
@@ -0,0 +1,251 @@
|
||||
import * as React from "react"
|
||||
import {
|
||||
ClipboardCopyIcon,
|
||||
CloudUploadIcon,
|
||||
CopyIcon,
|
||||
FolderIcon,
|
||||
FolderInputIcon,
|
||||
HeartIcon,
|
||||
InfoIcon,
|
||||
LinkIcon,
|
||||
PencilIcon,
|
||||
Trash2Icon,
|
||||
UploadIcon,
|
||||
} from "lucide-react"
|
||||
import { useTranslate } from "@workspace/i18n"
|
||||
import {
|
||||
ContextMenu,
|
||||
ContextMenuContent,
|
||||
ContextMenuGroup,
|
||||
ContextMenuItem,
|
||||
ContextMenuLabel,
|
||||
ContextMenuSeparator,
|
||||
ContextMenuSub,
|
||||
ContextMenuSubContent,
|
||||
ContextMenuSubTrigger,
|
||||
ContextMenuTrigger,
|
||||
} from "@workspace/ui/components/context-menu"
|
||||
|
||||
import { useMedia } from "./context"
|
||||
import { mediaMessages } from "./messages"
|
||||
import type {
|
||||
MediaAsset,
|
||||
MediaFolder,
|
||||
MediaId,
|
||||
MediaStorageTarget,
|
||||
} from "./types"
|
||||
import { formatMediaFileSize, getMediaFolderPath } from "./utils"
|
||||
|
||||
export interface MediaContextMenuProps {
|
||||
asset?: MediaAsset
|
||||
children: React.ReactElement
|
||||
folders: readonly MediaFolder[]
|
||||
storageTargets: readonly MediaStorageTarget[]
|
||||
onDelete: (asset: MediaAsset) => void
|
||||
onDetails: (asset: MediaAsset) => void
|
||||
onFavorite: (asset: MediaAsset) => void
|
||||
onMove: (asset: MediaAsset, folderId: MediaId | null) => void
|
||||
onRename: (asset: MediaAsset) => void
|
||||
onUpload: (target: MediaStorageTarget) => void
|
||||
}
|
||||
|
||||
export function MediaContextMenu({
|
||||
asset,
|
||||
children,
|
||||
folders,
|
||||
storageTargets,
|
||||
onDelete,
|
||||
onDetails,
|
||||
onFavorite,
|
||||
onMove,
|
||||
onRename,
|
||||
onUpload,
|
||||
}: MediaContextMenuProps) {
|
||||
const { adapter, notify } = useMedia()
|
||||
const t = useTranslate()
|
||||
const resolveUrl = React.useCallback(
|
||||
(item: MediaAsset) => adapter.resolveUrl?.(item) ?? item.url,
|
||||
[adapter]
|
||||
)
|
||||
const localTargets = storageTargets.filter(
|
||||
(target) => target.kind === "local"
|
||||
)
|
||||
const cloudTargets = storageTargets.filter(
|
||||
(target) => target.kind === "cloud"
|
||||
)
|
||||
const runAfterClose = (action: VoidFunction) => window.setTimeout(action)
|
||||
|
||||
const copy = async (text: string, successMessage: string) => {
|
||||
try {
|
||||
await navigator.clipboard.writeText(text)
|
||||
notify?.({ message: successMessage, tone: "success" })
|
||||
} catch {
|
||||
notify?.({ message: t(mediaMessages.copyFailed), tone: "error" })
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<ContextMenu>
|
||||
<ContextMenuTrigger render={children} />
|
||||
<ContextMenuContent>
|
||||
{asset ? (
|
||||
<>
|
||||
<ContextMenuItem
|
||||
onClick={() => runAfterClose(() => onDetails(asset))}
|
||||
>
|
||||
<InfoIcon />
|
||||
{t(mediaMessages.details)}
|
||||
</ContextMenuItem>
|
||||
<ContextMenuItem onClick={() => onFavorite(asset)}>
|
||||
<HeartIcon
|
||||
className={asset.favorite ? "fill-current" : undefined}
|
||||
/>
|
||||
{asset.favorite
|
||||
? t(mediaMessages.unfavorite)
|
||||
: t(mediaMessages.favoriteAction)}
|
||||
</ContextMenuItem>
|
||||
<ContextMenuSub>
|
||||
<ContextMenuSubTrigger>
|
||||
<FolderInputIcon />
|
||||
{t(mediaMessages.moveToFolder)}
|
||||
</ContextMenuSubTrigger>
|
||||
<ContextMenuSubContent>
|
||||
<ContextMenuItem
|
||||
disabled={asset.folderId === null}
|
||||
onClick={() => onMove(asset, null)}
|
||||
>
|
||||
<FolderIcon />
|
||||
{t(mediaMessages.unclassified)}
|
||||
</ContextMenuItem>
|
||||
{folders.map((folder) => (
|
||||
<ContextMenuItem
|
||||
key={folder.id}
|
||||
disabled={asset.folderId === folder.id}
|
||||
onClick={() => onMove(asset, folder.id)}
|
||||
>
|
||||
<FolderIcon />
|
||||
{getMediaFolderPath(folder, folders)}
|
||||
</ContextMenuItem>
|
||||
))}
|
||||
</ContextMenuSubContent>
|
||||
</ContextMenuSub>
|
||||
<ContextMenuItem
|
||||
onClick={() => runAfterClose(() => onRename(asset))}
|
||||
>
|
||||
<PencilIcon />
|
||||
{t(mediaMessages.rename)}
|
||||
</ContextMenuItem>
|
||||
<ContextMenuSeparator />
|
||||
<ContextMenuItem
|
||||
onClick={() => void copy(asset.name, t(mediaMessages.copyName))}
|
||||
>
|
||||
<CopyIcon />
|
||||
{t(mediaMessages.copyName)}
|
||||
</ContextMenuItem>
|
||||
<ContextMenuItem
|
||||
onClick={() =>
|
||||
void copy(resolveUrl(asset), t(mediaMessages.copyLink))
|
||||
}
|
||||
>
|
||||
<LinkIcon />
|
||||
{t(mediaMessages.copyLink)}
|
||||
</ContextMenuItem>
|
||||
<ContextMenuItem
|
||||
onClick={() =>
|
||||
void copy(
|
||||
[
|
||||
`${t(mediaMessages.fileName)}: ${asset.name}`,
|
||||
`${t(mediaMessages.type)}: ${asset.mimeType}`,
|
||||
`${t(mediaMessages.size)}: ${formatMediaFileSize(asset.sizeBytes)}`,
|
||||
`${t(mediaMessages.uploadedAt)}: ${new Date(asset.createdAt).toLocaleString()}`,
|
||||
`${t(mediaMessages.copyLink)}: ${resolveUrl(asset)}`,
|
||||
].join("\n"),
|
||||
t(mediaMessages.copyInformation)
|
||||
)
|
||||
}
|
||||
>
|
||||
<ClipboardCopyIcon />
|
||||
{t(mediaMessages.copyInformation)}
|
||||
</ContextMenuItem>
|
||||
<ContextMenuSeparator />
|
||||
<ContextMenuItem
|
||||
variant="destructive"
|
||||
onClick={() => runAfterClose(() => onDelete(asset))}
|
||||
>
|
||||
<Trash2Icon />
|
||||
{t(mediaMessages.delete)}
|
||||
</ContextMenuItem>
|
||||
</>
|
||||
) : (
|
||||
<UploadItems
|
||||
cloudTargets={cloudTargets}
|
||||
localTargets={localTargets}
|
||||
onUpload={onUpload}
|
||||
/>
|
||||
)}
|
||||
</ContextMenuContent>
|
||||
</ContextMenu>
|
||||
)
|
||||
}
|
||||
|
||||
function UploadItems({
|
||||
cloudTargets,
|
||||
localTargets,
|
||||
onUpload,
|
||||
}: {
|
||||
cloudTargets: readonly MediaStorageTarget[]
|
||||
localTargets: readonly MediaStorageTarget[]
|
||||
onUpload: (target: MediaStorageTarget) => void
|
||||
}) {
|
||||
const t = useTranslate()
|
||||
return (
|
||||
<>
|
||||
{localTargets.map((target) => (
|
||||
<UploadTargetItem key={target.id} target={target} onUpload={onUpload} />
|
||||
))}
|
||||
{cloudTargets.length > 1 ? (
|
||||
<>
|
||||
{localTargets.length > 0 && <ContextMenuSeparator />}
|
||||
<ContextMenuGroup>
|
||||
<ContextMenuLabel>
|
||||
{t(mediaMessages.chooseStorage)}
|
||||
</ContextMenuLabel>
|
||||
{cloudTargets.map((target) => (
|
||||
<UploadTargetItem
|
||||
key={target.id}
|
||||
target={target}
|
||||
onUpload={onUpload}
|
||||
/>
|
||||
))}
|
||||
</ContextMenuGroup>
|
||||
</>
|
||||
) : (
|
||||
cloudTargets.map((target) => (
|
||||
<UploadTargetItem
|
||||
key={target.id}
|
||||
target={target}
|
||||
onUpload={onUpload}
|
||||
/>
|
||||
))
|
||||
)}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
function UploadTargetItem({
|
||||
target,
|
||||
onUpload,
|
||||
}: {
|
||||
target: MediaStorageTarget
|
||||
onUpload: (target: MediaStorageTarget) => void
|
||||
}) {
|
||||
const t = useTranslate()
|
||||
return (
|
||||
<ContextMenuItem onClick={() => onUpload(target)}>
|
||||
{target.kind === "local" ? <UploadIcon /> : <CloudUploadIcon />}
|
||||
{target.kind === "local"
|
||||
? t(mediaMessages.serverUpload)
|
||||
: `${t(mediaMessages.cloudUpload)}: ${target.label}`}
|
||||
</ContextMenuItem>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,191 @@
|
||||
import * as React from "react"
|
||||
import {
|
||||
ExternalLinkIcon,
|
||||
FilmIcon,
|
||||
HeartIcon,
|
||||
ImageIcon,
|
||||
Trash2Icon,
|
||||
} from "lucide-react"
|
||||
import { useTranslate } from "@workspace/i18n"
|
||||
import { Button } from "@workspace/ui/components/button"
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@workspace/ui/components/dialog"
|
||||
import { Separator } from "@workspace/ui/components/separator"
|
||||
import { cn } from "@workspace/ui/lib/utils"
|
||||
|
||||
import { mediaMessages } from "./messages"
|
||||
import type { MediaAsset } from "./types"
|
||||
import { formatMediaFileSize } from "./utils"
|
||||
|
||||
export interface MediaDetailDialogProps {
|
||||
asset?: MediaAsset
|
||||
deletePending?: boolean
|
||||
favoritePending?: boolean
|
||||
open: boolean
|
||||
resolveUrl: (asset: MediaAsset) => string
|
||||
onDelete: (asset: MediaAsset) => void
|
||||
onFavorite: (asset: MediaAsset) => void
|
||||
onOpenChange: (open: boolean) => void
|
||||
}
|
||||
|
||||
export function MediaDetailDialog({
|
||||
asset,
|
||||
deletePending,
|
||||
favoritePending,
|
||||
open,
|
||||
resolveUrl,
|
||||
onDelete,
|
||||
onFavorite,
|
||||
onOpenChange,
|
||||
}: MediaDetailDialogProps) {
|
||||
const t = useTranslate()
|
||||
const [zoom, setZoom] = React.useState(1)
|
||||
const imageSource = asset?.kind === "image" ? resolveUrl(asset) : undefined
|
||||
|
||||
React.useEffect(() => setZoom(1), [imageSource, open])
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent className="max-h-[calc(100dvh-2rem)] overflow-y-auto sm:max-w-2xl">
|
||||
<DialogHeader>
|
||||
<DialogTitle>{t(mediaMessages.details)}</DialogTitle>
|
||||
<DialogDescription className="truncate">
|
||||
{asset?.name}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
{asset && (
|
||||
<>
|
||||
<div className="grid max-h-[55dvh] min-h-56 place-items-center overflow-hidden rounded-lg bg-muted/50">
|
||||
{asset.kind === "image" ? (
|
||||
<img
|
||||
src={imageSource}
|
||||
alt={asset.name}
|
||||
draggable={false}
|
||||
className="max-h-[55dvh] max-w-full touch-none object-contain select-none"
|
||||
style={{ transform: `scale(${zoom})` }}
|
||||
onWheel={(event) => {
|
||||
event.preventDefault()
|
||||
setZoom((current) =>
|
||||
Math.min(4, Math.max(1, current - event.deltaY / 400))
|
||||
)
|
||||
}}
|
||||
onDoubleClick={() => setZoom(1)}
|
||||
/>
|
||||
) : (
|
||||
<video
|
||||
src={resolveUrl(asset)}
|
||||
controls
|
||||
preload="metadata"
|
||||
className="max-h-[55dvh] max-w-full bg-black object-contain"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex min-w-0 items-center gap-2">
|
||||
{asset.kind === "image" ? (
|
||||
<ImageIcon className="size-4 shrink-0" />
|
||||
) : (
|
||||
<FilmIcon className="size-4 shrink-0" />
|
||||
)}
|
||||
<strong className="truncate">{asset.name}</strong>
|
||||
</div>
|
||||
|
||||
<dl className="grid grid-cols-2 gap-x-6 gap-y-4 sm:grid-cols-[repeat(3,minmax(0,1fr))_auto]">
|
||||
<MediaMetadata
|
||||
label={t(mediaMessages.type)}
|
||||
value={asset.mimeType}
|
||||
/>
|
||||
<MediaMetadata
|
||||
label={t(mediaMessages.size)}
|
||||
value={formatMediaFileSize(asset.sizeBytes)}
|
||||
/>
|
||||
<MediaMetadata
|
||||
label={t(mediaMessages.dimensions)}
|
||||
value={
|
||||
asset.width && asset.height
|
||||
? `${asset.width} × ${asset.height}`
|
||||
: t(mediaMessages.noDimensions)
|
||||
}
|
||||
/>
|
||||
<MediaMetadata
|
||||
label={t(mediaMessages.fileName)}
|
||||
value={asset.name}
|
||||
/>
|
||||
<MediaMetadata
|
||||
label={t(mediaMessages.uploadedAt)}
|
||||
value={new Date(asset.createdAt).toLocaleString()}
|
||||
/>
|
||||
</dl>
|
||||
|
||||
<Separator />
|
||||
|
||||
<DialogFooter className="flex-col gap-2 sm:flex-row sm:items-center sm:justify-between">
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
disabled={favoritePending}
|
||||
onClick={() => onFavorite(asset)}
|
||||
>
|
||||
<HeartIcon
|
||||
className={asset.favorite ? "fill-current" : undefined}
|
||||
/>
|
||||
{asset.favorite
|
||||
? t(mediaMessages.unfavorite)
|
||||
: t(mediaMessages.favoriteAction)}
|
||||
</Button>
|
||||
<div className="flex flex-col gap-2 sm:flex-row">
|
||||
<Button
|
||||
nativeButton={false}
|
||||
variant="outline"
|
||||
render={
|
||||
<a
|
||||
href={resolveUrl(asset)}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
/>
|
||||
}
|
||||
>
|
||||
<ExternalLinkIcon />
|
||||
{t(mediaMessages.viewOriginal)}
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
variant="destructive"
|
||||
disabled={deletePending}
|
||||
onClick={() => onDelete(asset)}
|
||||
>
|
||||
<Trash2Icon />
|
||||
{t(mediaMessages.delete)}
|
||||
</Button>
|
||||
</div>
|
||||
</DialogFooter>
|
||||
</>
|
||||
)}
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
|
||||
function MediaMetadata({
|
||||
label,
|
||||
value,
|
||||
}: {
|
||||
label: string
|
||||
value: React.ReactNode
|
||||
}) {
|
||||
return (
|
||||
<div className="min-w-0">
|
||||
<dt className="text-xs text-muted-foreground">{label}</dt>
|
||||
<dd className={cn("mt-1 font-medium wrap-break-word tabular-nums")}>
|
||||
{value}
|
||||
</dd>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,102 @@
|
||||
import { CheckIcon, FilmIcon, HeartIcon } from "lucide-react"
|
||||
import { useTranslate } from "@workspace/i18n"
|
||||
import { cn } from "@workspace/ui/lib/utils"
|
||||
|
||||
import { mediaMessages } from "./messages"
|
||||
import type { MediaAsset } from "./types"
|
||||
|
||||
export interface MediaGridProps {
|
||||
activeId?: MediaAsset["id"]
|
||||
assets: readonly MediaAsset[]
|
||||
resolveUrl: (asset: MediaAsset) => string
|
||||
selectedIds: ReadonlySet<MediaAsset["id"]>
|
||||
onActivate: (asset: MediaAsset) => void
|
||||
onDetails: (asset: MediaAsset) => void
|
||||
onToggle?: (asset: MediaAsset) => void
|
||||
}
|
||||
|
||||
export function MediaGrid({
|
||||
activeId,
|
||||
assets,
|
||||
resolveUrl,
|
||||
selectedIds,
|
||||
onActivate,
|
||||
onDetails,
|
||||
onToggle,
|
||||
}: MediaGridProps) {
|
||||
const t = useTranslate()
|
||||
|
||||
return (
|
||||
<div className="columns-2 gap-3 p-3 sm:columns-3 lg:columns-4 2xl:columns-5">
|
||||
{assets.map((asset) => {
|
||||
const selected = selectedIds.has(asset.id)
|
||||
const active = activeId === asset.id
|
||||
|
||||
return (
|
||||
<button
|
||||
key={asset.id}
|
||||
type="button"
|
||||
data-media-id={asset.id}
|
||||
aria-label={`${t(mediaMessages.select)} ${asset.name}`}
|
||||
aria-pressed={selected}
|
||||
onClick={() => {
|
||||
onActivate(asset)
|
||||
onToggle?.(asset)
|
||||
}}
|
||||
onDoubleClick={() => onDetails(asset)}
|
||||
className={cn(
|
||||
"group relative mb-3 block w-full break-inside-avoid overflow-hidden rounded-lg bg-muted text-left ring-offset-2 ring-offset-background transition-shadow outline-none focus-visible:ring-3 focus-visible:ring-ring",
|
||||
(active || selected) && "ring-2 ring-primary"
|
||||
)}
|
||||
>
|
||||
{asset.kind === "image" ? (
|
||||
<img
|
||||
src={resolveUrl(asset)}
|
||||
alt={asset.name}
|
||||
loading="lazy"
|
||||
className="block min-h-24 w-full bg-muted object-cover"
|
||||
style={
|
||||
asset.width && asset.height
|
||||
? { aspectRatio: `${asset.width} / ${asset.height}` }
|
||||
: undefined
|
||||
}
|
||||
/>
|
||||
) : (
|
||||
<div
|
||||
className="relative aspect-video min-h-28 bg-black"
|
||||
style={
|
||||
asset.width && asset.height
|
||||
? { aspectRatio: `${asset.width} / ${asset.height}` }
|
||||
: undefined
|
||||
}
|
||||
>
|
||||
<video
|
||||
src={resolveUrl(asset)}
|
||||
muted
|
||||
preload="metadata"
|
||||
className="size-full object-cover"
|
||||
/>
|
||||
<span className="absolute inset-0 grid place-items-center bg-black/10">
|
||||
<FilmIcon className="size-8 text-white drop-shadow-md" />
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<span className="absolute inset-x-0 bottom-0 flex items-end justify-between gap-2 bg-linear-to-t from-black/75 to-transparent px-2 pt-8 pb-2 text-white opacity-0 transition-opacity group-hover:opacity-100 group-focus-visible:opacity-100">
|
||||
<span className="min-w-0 truncate text-xs">{asset.name}</span>
|
||||
{asset.favorite && (
|
||||
<HeartIcon className="size-3.5 shrink-0 fill-current" />
|
||||
)}
|
||||
</span>
|
||||
|
||||
{selected && (
|
||||
<span className="absolute top-2 right-2 grid size-6 place-items-center rounded-full bg-primary text-primary-foreground shadow">
|
||||
<CheckIcon className="size-4" strokeWidth={3} />
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,828 @@
|
||||
import * as React from "react"
|
||||
import {
|
||||
ChevronDownIcon,
|
||||
CloudIcon,
|
||||
CloudUploadIcon,
|
||||
FolderPlusIcon,
|
||||
LoaderCircleIcon,
|
||||
SearchIcon,
|
||||
UploadIcon,
|
||||
} from "lucide-react"
|
||||
import { useTranslate } from "@workspace/i18n"
|
||||
import {
|
||||
AlertDialog,
|
||||
AlertDialogAction,
|
||||
AlertDialogCancel,
|
||||
AlertDialogContent,
|
||||
AlertDialogDescription,
|
||||
AlertDialogFooter,
|
||||
AlertDialogHeader,
|
||||
AlertDialogTitle,
|
||||
} from "@workspace/ui/components/alert-dialog"
|
||||
import { Button } from "@workspace/ui/components/button"
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuGroup,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuLabel,
|
||||
DropdownMenuTrigger,
|
||||
} from "@workspace/ui/components/dropdown-menu"
|
||||
import {
|
||||
Empty,
|
||||
EmptyContent,
|
||||
EmptyDescription,
|
||||
EmptyMedia,
|
||||
EmptyTitle,
|
||||
} from "@workspace/ui/components/empty"
|
||||
import { Input } from "@workspace/ui/components/input"
|
||||
import { ScrollArea } from "@workspace/ui/components/scroll-area"
|
||||
import { cn } from "@workspace/ui/lib/utils"
|
||||
|
||||
import { useMedia } from "./context"
|
||||
import { MediaContextMenu } from "./media-context-menu"
|
||||
import { MediaDetailDialog } from "./media-detail"
|
||||
import { MediaFolderDialog } from "./folder-dialog"
|
||||
import { MediaGrid } from "./media-grid"
|
||||
import { mediaMessages } from "./messages"
|
||||
import { MediaFilterTabs, MediaSidebar } from "./sidebar"
|
||||
import type {
|
||||
MediaAsset,
|
||||
MediaFilter,
|
||||
MediaFolder,
|
||||
MediaFolderSelection,
|
||||
MediaId,
|
||||
MediaKind,
|
||||
MediaSelectionMode,
|
||||
MediaStorageTarget,
|
||||
} from "./types"
|
||||
import {
|
||||
defaultMediaReference,
|
||||
getMediaFolderPath,
|
||||
isMediaFile,
|
||||
mediaKindForFile,
|
||||
MEDIA_ACCEPT,
|
||||
splitMediaName,
|
||||
} from "./utils"
|
||||
|
||||
const PAGE_SIZE = 40
|
||||
const EMPTY_ASSETS: readonly MediaAsset[] = []
|
||||
const EMPTY_FOLDERS: readonly MediaFolder[] = []
|
||||
const DEFAULT_STORAGE_TARGET: MediaStorageTarget = {
|
||||
id: "local",
|
||||
kind: "local",
|
||||
label: "Local storage",
|
||||
}
|
||||
|
||||
type FolderDialogState =
|
||||
| { mode: "create"; parentId: MediaId | null }
|
||||
| { folder: MediaFolder; mode: "rename" }
|
||||
|
||||
export interface MediaLibraryProps extends Omit<
|
||||
React.ComponentProps<"section">,
|
||||
"onChange"
|
||||
> {
|
||||
allowedKinds?: readonly MediaKind[]
|
||||
footer?: React.ReactNode
|
||||
selectedAssets?: readonly MediaAsset[]
|
||||
selectionMode?: MediaSelectionMode
|
||||
onSelectionChange?: (assets: readonly MediaAsset[]) => void
|
||||
}
|
||||
|
||||
export function MediaLibrary({
|
||||
allowedKinds,
|
||||
className,
|
||||
footer,
|
||||
selectedAssets = EMPTY_ASSETS,
|
||||
selectionMode = "none",
|
||||
onSelectionChange,
|
||||
...props
|
||||
}: MediaLibraryProps) {
|
||||
const { adapter, notify } = useMedia()
|
||||
const t = useTranslate()
|
||||
const inputRef = React.useRef<HTMLInputElement>(null)
|
||||
const uploadTargetRef = React.useRef(DEFAULT_STORAGE_TARGET)
|
||||
const assetRequestId = React.useRef(0)
|
||||
const foldersRequestId = React.useRef(0)
|
||||
const targetsRequestId = React.useRef(0)
|
||||
const [filter, setFilter] = React.useState<MediaFilter>(
|
||||
allowedKinds?.length === 1 ? allowedKinds[0] : "all"
|
||||
)
|
||||
const [folder, setFolder] = React.useState<MediaFolderSelection>()
|
||||
const [keyword, setKeyword] = React.useState("")
|
||||
const deferredKeyword = React.useDeferredValue(keyword.trim())
|
||||
const [assets, setAssets] = React.useState<readonly MediaAsset[]>([])
|
||||
const [assetPage, setAssetPage] = React.useState(1)
|
||||
const [hasNextPage, setHasNextPage] = React.useState(false)
|
||||
const [assetError, setAssetError] = React.useState<Error>()
|
||||
const [isLoadingAssets, setIsLoadingAssets] = React.useState(true)
|
||||
const [isLoadingMore, setIsLoadingMore] = React.useState(false)
|
||||
const [folders, setFolders] = React.useState<readonly MediaFolder[]>([])
|
||||
const [storageTargets, setStorageTargets] = React.useState<
|
||||
readonly MediaStorageTarget[]
|
||||
>([DEFAULT_STORAGE_TARGET])
|
||||
const [activeAsset, setActiveAsset] = React.useState<MediaAsset>()
|
||||
const [contextAsset, setContextAsset] = React.useState<MediaAsset>()
|
||||
const [detailOpen, setDetailOpen] = React.useState(false)
|
||||
const [deleteTarget, setDeleteTarget] = React.useState<MediaAsset>()
|
||||
const [folderDeleteTarget, setFolderDeleteTarget] =
|
||||
React.useState<MediaFolder>()
|
||||
const [renameTarget, setRenameTarget] = React.useState<MediaAsset>()
|
||||
const [folderDialog, setFolderDialog] = React.useState<FolderDialogState>()
|
||||
const [pendingOperation, setPendingOperation] = React.useState<string>()
|
||||
|
||||
const allowedKindsKey = allowedKinds?.join(",") ?? ""
|
||||
const selectedIds = React.useMemo(
|
||||
() => new Set(selectedAssets.map((asset) => asset.id)),
|
||||
[selectedAssets]
|
||||
)
|
||||
const resolveUrl = React.useCallback(
|
||||
(asset: MediaAsset) => adapter.resolveUrl?.(asset) ?? asset.url,
|
||||
[adapter]
|
||||
)
|
||||
|
||||
React.useEffect(() => {
|
||||
if (
|
||||
(filter === "image" || filter === "video") &&
|
||||
allowedKinds &&
|
||||
!allowedKinds.includes(filter)
|
||||
) {
|
||||
setFilter(allowedKinds.length === 1 ? allowedKinds[0] : "all")
|
||||
}
|
||||
}, [allowedKinds, allowedKindsKey, filter])
|
||||
|
||||
const reportError = React.useCallback(
|
||||
(error: unknown, fallback: string) => {
|
||||
const message =
|
||||
error instanceof Error && error.message ? error.message : fallback
|
||||
notify?.({ message, tone: "error" })
|
||||
},
|
||||
[notify]
|
||||
)
|
||||
|
||||
const refreshFolders = React.useCallback(async () => {
|
||||
const requestId = foldersRequestId.current + 1
|
||||
foldersRequestId.current = requestId
|
||||
try {
|
||||
const nextFolders = await adapter.listFolders()
|
||||
if (foldersRequestId.current === requestId) setFolders(nextFolders)
|
||||
} catch (error) {
|
||||
if (foldersRequestId.current === requestId) {
|
||||
reportError(error, t(mediaMessages.loadError))
|
||||
}
|
||||
}
|
||||
}, [adapter, reportError, t])
|
||||
|
||||
const refreshStorageTargets = React.useCallback(async () => {
|
||||
if (!adapter.listStorageTargets) return
|
||||
const requestId = targetsRequestId.current + 1
|
||||
targetsRequestId.current = requestId
|
||||
try {
|
||||
const targets = await adapter.listStorageTargets()
|
||||
if (targetsRequestId.current === requestId) {
|
||||
setStorageTargets(targets.length ? targets : [DEFAULT_STORAGE_TARGET])
|
||||
}
|
||||
} catch (error) {
|
||||
if (targetsRequestId.current === requestId) {
|
||||
reportError(error, t(mediaMessages.loadError))
|
||||
}
|
||||
}
|
||||
}, [adapter, reportError, t])
|
||||
|
||||
const refreshAssets = React.useCallback(async () => {
|
||||
const requestId = assetRequestId.current + 1
|
||||
assetRequestId.current = requestId
|
||||
setIsLoadingAssets(true)
|
||||
setAssetError(undefined)
|
||||
try {
|
||||
const page = await adapter.listAssets({
|
||||
allowedKinds,
|
||||
filter,
|
||||
folder,
|
||||
keyword: deferredKeyword,
|
||||
page: 1,
|
||||
pageSize: PAGE_SIZE,
|
||||
})
|
||||
if (assetRequestId.current !== requestId) return
|
||||
setAssets(page.assets)
|
||||
setAssetPage(1)
|
||||
setHasNextPage(page.hasNextPage)
|
||||
} catch (error) {
|
||||
if (assetRequestId.current !== requestId) return
|
||||
setAssets([])
|
||||
setAssetError(
|
||||
error instanceof Error ? error : new Error(t(mediaMessages.loadError))
|
||||
)
|
||||
} finally {
|
||||
if (assetRequestId.current === requestId) setIsLoadingAssets(false)
|
||||
}
|
||||
}, [
|
||||
adapter,
|
||||
allowedKinds,
|
||||
allowedKindsKey,
|
||||
deferredKeyword,
|
||||
filter,
|
||||
folder,
|
||||
t,
|
||||
])
|
||||
|
||||
React.useEffect(() => {
|
||||
void refreshFolders()
|
||||
void refreshStorageTargets()
|
||||
}, [refreshFolders, refreshStorageTargets])
|
||||
|
||||
React.useEffect(() => {
|
||||
void refreshAssets()
|
||||
}, [refreshAssets])
|
||||
|
||||
const loadMore = async () => {
|
||||
if (isLoadingMore || !hasNextPage) return
|
||||
const nextPage = assetPage + 1
|
||||
setIsLoadingMore(true)
|
||||
try {
|
||||
const page = await adapter.listAssets({
|
||||
allowedKinds,
|
||||
filter,
|
||||
folder,
|
||||
keyword: deferredKeyword,
|
||||
page: nextPage,
|
||||
pageSize: PAGE_SIZE,
|
||||
})
|
||||
setAssets((current) => [...current, ...page.assets])
|
||||
setAssetPage(nextPage)
|
||||
setHasNextPage(page.hasNextPage)
|
||||
} catch (error) {
|
||||
reportError(error, t(mediaMessages.loadError))
|
||||
} finally {
|
||||
setIsLoadingMore(false)
|
||||
}
|
||||
}
|
||||
|
||||
const runOperation = async <Value,>(
|
||||
name: string,
|
||||
operation: () => Promise<Value>,
|
||||
onSuccess?: (value: Value) => void
|
||||
): Promise<{ ok: false } | { ok: true; value: Value }> => {
|
||||
setPendingOperation(name)
|
||||
try {
|
||||
const value = await operation()
|
||||
onSuccess?.(value)
|
||||
return { ok: true, value }
|
||||
} catch (error) {
|
||||
reportError(error, t(mediaMessages.loadError))
|
||||
return { ok: false }
|
||||
} finally {
|
||||
setPendingOperation(undefined)
|
||||
}
|
||||
}
|
||||
|
||||
const requestUpload = (target: MediaStorageTarget) => {
|
||||
uploadTargetRef.current = target
|
||||
inputRef.current?.click()
|
||||
}
|
||||
|
||||
const uploadFiles = async (files: readonly File[]) => {
|
||||
const acceptedFiles = files.filter(isMediaFile)
|
||||
if (!acceptedFiles.length) {
|
||||
reportError(
|
||||
new Error(t(mediaMessages.uploadError)),
|
||||
t(mediaMessages.uploadError)
|
||||
)
|
||||
return
|
||||
}
|
||||
if (
|
||||
allowedKinds &&
|
||||
acceptedFiles.some((file) => {
|
||||
const kind = mediaKindForFile(file)
|
||||
return !kind || !allowedKinds.includes(kind)
|
||||
})
|
||||
) {
|
||||
reportError(
|
||||
new Error(t(mediaMessages.uploadError)),
|
||||
t(mediaMessages.uploadError)
|
||||
)
|
||||
return
|
||||
}
|
||||
|
||||
const uploaded = await runOperation(
|
||||
"upload",
|
||||
() =>
|
||||
adapter.upload({
|
||||
files: acceptedFiles,
|
||||
folderId:
|
||||
folder !== "unclassified" &&
|
||||
(typeof folder === "string" || typeof folder === "number")
|
||||
? folder
|
||||
: undefined,
|
||||
target: uploadTargetRef.current,
|
||||
}),
|
||||
(nextAssets) => setActiveAsset(nextAssets[0])
|
||||
)
|
||||
if (!uploaded.ok) return
|
||||
notify?.({
|
||||
message: t(mediaMessages.uploadSuccess, {
|
||||
count: uploaded.value.length,
|
||||
}),
|
||||
tone: "success",
|
||||
})
|
||||
await Promise.all([refreshAssets(), refreshFolders()])
|
||||
}
|
||||
|
||||
const toggleSelection = (asset: MediaAsset) => {
|
||||
if (selectionMode === "none") return
|
||||
if (selectionMode === "single") {
|
||||
onSelectionChange?.(selectedIds.has(asset.id) ? [] : [asset])
|
||||
return
|
||||
}
|
||||
onSelectionChange?.(
|
||||
selectedIds.has(asset.id)
|
||||
? selectedAssets.filter((item) => item.id !== asset.id)
|
||||
: [...selectedAssets, asset]
|
||||
)
|
||||
}
|
||||
|
||||
const updateFavorite = async (asset: MediaAsset) => {
|
||||
const updated = await runOperation("favorite", () =>
|
||||
adapter.updateFavorite(asset.id, !asset.favorite)
|
||||
)
|
||||
if (!updated.ok) return
|
||||
setActiveAsset((current) =>
|
||||
current?.id === updated.value.id ? updated.value : current
|
||||
)
|
||||
setContextAsset(updated.value)
|
||||
onSelectionChange?.(
|
||||
selectedAssets.map((item) =>
|
||||
item.id === updated.value.id ? updated.value : item
|
||||
)
|
||||
)
|
||||
await refreshAssets()
|
||||
}
|
||||
|
||||
const moveAsset = async (asset: MediaAsset, folderId: MediaId | null) => {
|
||||
const updated = await runOperation("move", () =>
|
||||
adapter.moveAsset(asset.id, folderId)
|
||||
)
|
||||
if (!updated.ok) return
|
||||
setActiveAsset((current) =>
|
||||
current?.id === updated.value.id ? updated.value : current
|
||||
)
|
||||
setContextAsset(updated.value)
|
||||
onSelectionChange?.(
|
||||
selectedAssets.map((item) =>
|
||||
item.id === updated.value.id ? updated.value : item
|
||||
)
|
||||
)
|
||||
await Promise.all([refreshAssets(), refreshFolders()])
|
||||
}
|
||||
|
||||
const saveFolder = async (name: string) => {
|
||||
if (!folderDialog) return
|
||||
const saved = await runOperation("folder", () =>
|
||||
folderDialog.mode === "create"
|
||||
? adapter.createFolder({ name, parentId: folderDialog.parentId })
|
||||
: adapter.updateFolder({
|
||||
id: folderDialog.folder.id,
|
||||
name,
|
||||
parentId: folderDialog.folder.parentId,
|
||||
})
|
||||
)
|
||||
if (!saved.ok) return
|
||||
if (folderDialog.mode === "create") {
|
||||
setFilter("all")
|
||||
setFolder(saved.value.id)
|
||||
}
|
||||
setFolderDialog(undefined)
|
||||
await Promise.all([refreshAssets(), refreshFolders()])
|
||||
}
|
||||
|
||||
const saveRename = async (baseName: string) => {
|
||||
if (!renameTarget) return
|
||||
const { extension } = splitMediaName(renameTarget.name)
|
||||
const updated = await runOperation("rename", () =>
|
||||
adapter.renameAsset(renameTarget.id, `${baseName}${extension}`)
|
||||
)
|
||||
if (!updated.ok) return
|
||||
setActiveAsset((current) =>
|
||||
current?.id === updated.value.id ? updated.value : current
|
||||
)
|
||||
setContextAsset(updated.value)
|
||||
setRenameTarget(undefined)
|
||||
await refreshAssets()
|
||||
}
|
||||
|
||||
const deleteAsset = async () => {
|
||||
if (!deleteTarget) return
|
||||
const deleted = deleteTarget
|
||||
const result = await runOperation("deleteAsset", () =>
|
||||
adapter.deleteAsset(deleted.id)
|
||||
)
|
||||
if (!result.ok) return
|
||||
if (activeAsset?.id === deleted.id) {
|
||||
setActiveAsset(undefined)
|
||||
setDetailOpen(false)
|
||||
}
|
||||
if (selectedIds.has(deleted.id)) {
|
||||
onSelectionChange?.(
|
||||
selectedAssets.filter((asset) => asset.id !== deleted.id)
|
||||
)
|
||||
}
|
||||
setDeleteTarget(undefined)
|
||||
await Promise.all([refreshAssets(), refreshFolders()])
|
||||
}
|
||||
|
||||
const deleteFolder = async () => {
|
||||
if (!folderDeleteTarget) return
|
||||
const deleted = folderDeleteTarget
|
||||
const result = await runOperation("deleteFolder", () =>
|
||||
adapter.deleteFolder(deleted.id)
|
||||
)
|
||||
if (!result.ok) return
|
||||
if (folder === deleted.id) setFolder("unclassified")
|
||||
setFolderDeleteTarget(undefined)
|
||||
await Promise.all([refreshAssets(), refreshFolders()])
|
||||
}
|
||||
|
||||
const showFilter = (nextFilter: MediaFilter) => {
|
||||
setFilter(nextFilter)
|
||||
setFolder(undefined)
|
||||
}
|
||||
const showFolder = (nextFolder: MediaFolderSelection) => {
|
||||
setFolder(nextFolder)
|
||||
setFilter("all")
|
||||
}
|
||||
const showDetails = (asset: MediaAsset) => {
|
||||
setActiveAsset(asset)
|
||||
setDetailOpen(true)
|
||||
}
|
||||
const selectContextAsset = (event: React.MouseEvent<HTMLElement>) => {
|
||||
const target =
|
||||
event.target instanceof Element
|
||||
? event.target.closest<HTMLElement>("[data-media-id]")
|
||||
: null
|
||||
const asset = assets.find(
|
||||
(item) => String(item.id) === target?.dataset.mediaId
|
||||
)
|
||||
setContextAsset(asset)
|
||||
if (asset) setActiveAsset(asset)
|
||||
}
|
||||
|
||||
const currentFolder =
|
||||
typeof folder === "string" || typeof folder === "number"
|
||||
? folders.find((item) => item.id === folder)
|
||||
: undefined
|
||||
const searchPlaceholder = currentFolder
|
||||
? `${t(mediaMessages.search)}: ${getMediaFolderPath(currentFolder, folders)}`
|
||||
: t(mediaMessages.search)
|
||||
const localTarget =
|
||||
storageTargets.find((target) => target.kind === "local") ??
|
||||
DEFAULT_STORAGE_TARGET
|
||||
const cloudTargets = storageTargets.filter(
|
||||
(target) => target.kind === "cloud"
|
||||
)
|
||||
const renameName = splitMediaName(renameTarget?.name ?? "")
|
||||
const isUploading = pendingOperation === "upload"
|
||||
|
||||
return (
|
||||
<>
|
||||
<section
|
||||
className={cn(
|
||||
"grid min-h-0 overflow-hidden rounded-lg border bg-background md:grid-cols-[14rem_minmax(0,1fr)]",
|
||||
footer && "grid-rows-[minmax(0,1fr)_auto]",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<MediaSidebar
|
||||
filter={filter}
|
||||
folder={folder}
|
||||
folders={folders}
|
||||
allowedKinds={allowedKinds}
|
||||
onFilterChange={showFilter}
|
||||
onFolderChange={showFolder}
|
||||
onCreateFolder={(parentId) =>
|
||||
setFolderDialog({ mode: "create", parentId })
|
||||
}
|
||||
onRenameFolder={(item) =>
|
||||
setFolderDialog({ folder: item, mode: "rename" })
|
||||
}
|
||||
onDeleteFolder={setFolderDeleteTarget}
|
||||
className="hidden min-h-0 md:flex"
|
||||
/>
|
||||
<main className="flex min-h-0 min-w-0 flex-col">
|
||||
<div className="flex min-h-14 items-center gap-2 border-b p-2">
|
||||
<label className="relative min-w-0 flex-1">
|
||||
<SearchIcon className="pointer-events-none absolute start-3 top-1/2 size-4 -translate-y-1/2 text-muted-foreground" />
|
||||
<Input
|
||||
value={keyword}
|
||||
placeholder={searchPlaceholder}
|
||||
className="ps-9"
|
||||
onChange={(event) => setKeyword(event.target.value)}
|
||||
/>
|
||||
</label>
|
||||
<Button
|
||||
type="button"
|
||||
size="icon"
|
||||
variant="outline"
|
||||
className="md:hidden"
|
||||
aria-label={t(mediaMessages.createFolder)}
|
||||
onClick={() =>
|
||||
setFolderDialog({ mode: "create", parentId: null })
|
||||
}
|
||||
>
|
||||
<FolderPlusIcon />
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
disabled={isUploading}
|
||||
onClick={() => requestUpload(localTarget)}
|
||||
>
|
||||
{isUploading && uploadTargetRef.current.id === localTarget.id ? (
|
||||
<LoaderCircleIcon className="animate-spin" />
|
||||
) : (
|
||||
<UploadIcon />
|
||||
)}
|
||||
<span className="hidden sm:inline">
|
||||
{t(mediaMessages.serverUpload)}
|
||||
</span>
|
||||
</Button>
|
||||
{cloudTargets.length === 1 && (
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
disabled={isUploading}
|
||||
onClick={() => requestUpload(cloudTargets[0])}
|
||||
>
|
||||
<CloudUploadIcon />
|
||||
<span className="hidden sm:inline">
|
||||
{t(mediaMessages.cloudUpload)}
|
||||
</span>
|
||||
</Button>
|
||||
)}
|
||||
{cloudTargets.length > 1 && (
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger
|
||||
render={
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
disabled={isUploading}
|
||||
/>
|
||||
}
|
||||
>
|
||||
{isUploading && uploadTargetRef.current.kind === "cloud" ? (
|
||||
<LoaderCircleIcon className="animate-spin" />
|
||||
) : (
|
||||
<CloudUploadIcon />
|
||||
)}
|
||||
<span className="hidden sm:inline">
|
||||
{t(mediaMessages.cloudUpload)}
|
||||
</span>
|
||||
<ChevronDownIcon data-icon="inline-end" />
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end">
|
||||
<DropdownMenuGroup>
|
||||
<DropdownMenuLabel>
|
||||
{t(mediaMessages.chooseStorage)}
|
||||
</DropdownMenuLabel>
|
||||
{cloudTargets.map((target) => (
|
||||
<DropdownMenuItem
|
||||
key={target.id}
|
||||
onClick={() => requestUpload(target)}
|
||||
>
|
||||
<CloudIcon />
|
||||
{target.label}
|
||||
</DropdownMenuItem>
|
||||
))}
|
||||
</DropdownMenuGroup>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
)}
|
||||
<input
|
||||
ref={inputRef}
|
||||
type="file"
|
||||
multiple
|
||||
accept={
|
||||
allowedKinds?.length === 1 && allowedKinds[0] === "image"
|
||||
? "image/*"
|
||||
: MEDIA_ACCEPT
|
||||
}
|
||||
className="sr-only"
|
||||
onChange={(event) => {
|
||||
const files = Array.from(event.target.files ?? [])
|
||||
if (files.length) void uploadFiles(files)
|
||||
event.target.value = ""
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<MediaFilterTabs
|
||||
filter={filter}
|
||||
folder={folder}
|
||||
folders={folders}
|
||||
allowedKinds={allowedKinds}
|
||||
onFilterChange={showFilter}
|
||||
onFolderChange={showFolder}
|
||||
/>
|
||||
<ScrollArea className="min-h-0 flex-1">
|
||||
<MediaContextMenu
|
||||
asset={contextAsset}
|
||||
folders={folders}
|
||||
storageTargets={storageTargets}
|
||||
onUpload={requestUpload}
|
||||
onDetails={showDetails}
|
||||
onFavorite={(asset) => void updateFavorite(asset)}
|
||||
onRename={setRenameTarget}
|
||||
onMove={(asset, folderId) => void moveAsset(asset, folderId)}
|
||||
onDelete={setDeleteTarget}
|
||||
>
|
||||
<div
|
||||
className="min-h-full"
|
||||
onContextMenuCapture={selectContextAsset}
|
||||
>
|
||||
{isLoadingAssets ? (
|
||||
<MediaEmpty
|
||||
icon={<LoaderCircleIcon className="animate-spin" />}
|
||||
title={t(mediaMessages.loading)}
|
||||
/>
|
||||
) : assetError ? (
|
||||
<MediaEmpty
|
||||
description={assetError.message}
|
||||
title={t(mediaMessages.loadError)}
|
||||
action={
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => void refreshAssets()}
|
||||
>
|
||||
{t(mediaMessages.retry)}
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
) : assets.length ? (
|
||||
<>
|
||||
<MediaGrid
|
||||
assets={assets}
|
||||
activeId={activeAsset?.id}
|
||||
selectedIds={selectedIds}
|
||||
resolveUrl={resolveUrl}
|
||||
onActivate={setActiveAsset}
|
||||
onToggle={
|
||||
selectionMode === "none" ? undefined : toggleSelection
|
||||
}
|
||||
onDetails={showDetails}
|
||||
/>
|
||||
{hasNextPage && (
|
||||
<div className="flex justify-center p-4 pt-1">
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
disabled={isLoadingMore}
|
||||
onClick={() => void loadMore()}
|
||||
>
|
||||
{isLoadingMore && (
|
||||
<LoaderCircleIcon className="animate-spin" />
|
||||
)}
|
||||
{t(mediaMessages.loadMore)}
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
) : (
|
||||
<MediaEmpty
|
||||
icon={<UploadIcon />}
|
||||
title={t(mediaMessages.emptyTitle)}
|
||||
description={
|
||||
deferredKeyword
|
||||
? t(mediaMessages.emptySearchDescription)
|
||||
: t(mediaMessages.emptyDescription)
|
||||
}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</MediaContextMenu>
|
||||
</ScrollArea>
|
||||
</main>
|
||||
{footer && (
|
||||
<footer className="col-span-full border-t bg-background p-3">
|
||||
{footer}
|
||||
</footer>
|
||||
)}
|
||||
</section>
|
||||
|
||||
<MediaDetailDialog
|
||||
asset={activeAsset}
|
||||
open={detailOpen}
|
||||
resolveUrl={resolveUrl}
|
||||
favoritePending={pendingOperation === "favorite"}
|
||||
deletePending={pendingOperation === "deleteAsset"}
|
||||
onOpenChange={setDetailOpen}
|
||||
onFavorite={(asset) => void updateFavorite(asset)}
|
||||
onDelete={setDeleteTarget}
|
||||
/>
|
||||
<MediaFolderDialog
|
||||
open={Boolean(renameTarget)}
|
||||
title={t(mediaMessages.renameMedia)}
|
||||
description={t(mediaMessages.renameMediaDescription)}
|
||||
initialName={renameName.baseName}
|
||||
maxLength={255 - renameName.extension.length}
|
||||
lockedSuffix={renameName.extension}
|
||||
pending={pendingOperation === "rename"}
|
||||
onOpenChange={(open) => !open && setRenameTarget(undefined)}
|
||||
onSubmit={(name) => void saveRename(name)}
|
||||
/>
|
||||
<MediaFolderDialog
|
||||
open={Boolean(folderDialog)}
|
||||
title={t(
|
||||
folderDialog?.mode === "rename"
|
||||
? mediaMessages.renameFolder
|
||||
: mediaMessages.createFolder
|
||||
)}
|
||||
description={t(mediaMessages.folderOnlyClassifies)}
|
||||
initialName={
|
||||
folderDialog?.mode === "rename" ? folderDialog.folder.name : ""
|
||||
}
|
||||
pending={pendingOperation === "folder"}
|
||||
onOpenChange={(open) => !open && setFolderDialog(undefined)}
|
||||
onSubmit={(name) => void saveFolder(name)}
|
||||
/>
|
||||
<MediaConfirmDialog
|
||||
open={Boolean(deleteTarget)}
|
||||
pending={pendingOperation === "deleteAsset"}
|
||||
title={t(mediaMessages.deleteAssetTitle)}
|
||||
description={t(mediaMessages.deleteAssetDescription)}
|
||||
onOpenChange={(open) => !open && setDeleteTarget(undefined)}
|
||||
onConfirm={() => void deleteAsset()}
|
||||
/>
|
||||
<MediaConfirmDialog
|
||||
open={Boolean(folderDeleteTarget)}
|
||||
pending={pendingOperation === "deleteFolder"}
|
||||
title={t(mediaMessages.deleteFolderTitle)}
|
||||
description={t(mediaMessages.deleteFolderDescription)}
|
||||
onOpenChange={(open) => !open && setFolderDeleteTarget(undefined)}
|
||||
onConfirm={() => void deleteFolder()}
|
||||
/>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
function MediaEmpty({
|
||||
action,
|
||||
description,
|
||||
icon,
|
||||
title,
|
||||
}: {
|
||||
action?: React.ReactNode
|
||||
description?: string
|
||||
icon?: React.ReactNode
|
||||
title: string
|
||||
}) {
|
||||
return (
|
||||
<Empty className="min-h-72">
|
||||
<EmptyContent>
|
||||
{icon && <EmptyMedia variant="icon">{icon}</EmptyMedia>}
|
||||
<EmptyTitle>{title}</EmptyTitle>
|
||||
{description && <EmptyDescription>{description}</EmptyDescription>}
|
||||
{action}
|
||||
</EmptyContent>
|
||||
</Empty>
|
||||
)
|
||||
}
|
||||
|
||||
function MediaConfirmDialog({
|
||||
description,
|
||||
open,
|
||||
pending,
|
||||
title,
|
||||
onConfirm,
|
||||
onOpenChange,
|
||||
}: {
|
||||
description: string
|
||||
open: boolean
|
||||
pending: boolean
|
||||
title: string
|
||||
onConfirm: VoidFunction
|
||||
onOpenChange: (open: boolean) => void
|
||||
}) {
|
||||
const t = useTranslate()
|
||||
return (
|
||||
<AlertDialog open={open} onOpenChange={onOpenChange}>
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>{title}</AlertDialogTitle>
|
||||
<AlertDialogDescription>{description}</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel disabled={pending}>
|
||||
{t(mediaMessages.cancel)}
|
||||
</AlertDialogCancel>
|
||||
<AlertDialogAction
|
||||
variant="destructive"
|
||||
disabled={pending}
|
||||
onClick={onConfirm}
|
||||
>
|
||||
{t(mediaMessages.delete)}
|
||||
</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,101 @@
|
||||
import * as React from "react"
|
||||
import { useTranslate } from "@workspace/i18n"
|
||||
import { Button } from "@workspace/ui/components/button"
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@workspace/ui/components/dialog"
|
||||
|
||||
import { useMedia } from "./context"
|
||||
import { MediaLibrary } from "./media-library"
|
||||
import { mediaMessages } from "./messages"
|
||||
import type { MediaAsset, MediaKind } from "./types"
|
||||
import { defaultMediaReference } from "./utils"
|
||||
|
||||
const EMPTY_SELECTION: readonly MediaAsset[] = []
|
||||
|
||||
export interface MediaPickerDialogProps {
|
||||
allowedKinds?: readonly MediaKind[]
|
||||
initialSelection?: readonly MediaAsset[]
|
||||
multiple?: boolean
|
||||
open: boolean
|
||||
onConfirm: (assets: readonly MediaAsset[]) => void
|
||||
onOpenChange: (open: boolean) => void
|
||||
}
|
||||
|
||||
export function MediaPickerDialog({
|
||||
allowedKinds,
|
||||
initialSelection = EMPTY_SELECTION,
|
||||
multiple = false,
|
||||
open,
|
||||
onConfirm,
|
||||
onOpenChange,
|
||||
}: MediaPickerDialogProps) {
|
||||
const { adapter } = useMedia()
|
||||
const t = useTranslate()
|
||||
const [selected, setSelected] =
|
||||
React.useState<readonly MediaAsset[]>(initialSelection)
|
||||
|
||||
React.useEffect(() => {
|
||||
if (open) setSelected(initialSelection)
|
||||
}, [initialSelection, open])
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent
|
||||
className="flex h-[min(88dvh,54rem)] max-h-[calc(100dvh-2rem)] flex-col gap-3 p-4 sm:max-w-[min(94vw,86rem)]"
|
||||
showCloseButton={false}
|
||||
>
|
||||
<DialogHeader className="sr-only">
|
||||
<DialogTitle>{t(mediaMessages.selectMedia)}</DialogTitle>
|
||||
<DialogDescription>
|
||||
{t(mediaMessages.selectMediaDescription)}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<MediaLibrary
|
||||
allowedKinds={allowedKinds}
|
||||
selectionMode={multiple ? "multiple" : "single"}
|
||||
selectedAssets={selected}
|
||||
onSelectionChange={setSelected}
|
||||
className="min-h-0 flex-1"
|
||||
footer={
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<span className="text-sm text-muted-foreground">
|
||||
{t(mediaMessages.selectedCount, { count: selected.length })}
|
||||
</span>
|
||||
<div className="flex gap-2">
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
onClick={() => onOpenChange(false)}
|
||||
>
|
||||
{t(mediaMessages.cancel)}
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
disabled={selected.length === 0}
|
||||
onClick={() => {
|
||||
onConfirm(
|
||||
selected.map((asset) => {
|
||||
const url =
|
||||
adapter.resolveReference?.(asset) ??
|
||||
defaultMediaReference(asset)
|
||||
return url === asset.url ? asset : { ...asset, url }
|
||||
})
|
||||
)
|
||||
onOpenChange(false)
|
||||
}}
|
||||
>
|
||||
{t(mediaMessages.useSelected)}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
/>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,158 @@
|
||||
// @vitest-environment jsdom
|
||||
|
||||
import type { ReactNode } from "react"
|
||||
import {
|
||||
cleanup,
|
||||
fireEvent,
|
||||
render,
|
||||
screen,
|
||||
waitFor,
|
||||
} from "@testing-library/react"
|
||||
import { afterEach, describe, expect, it, vi } from "vitest"
|
||||
import { I18nProvider } from "@workspace/i18n"
|
||||
|
||||
import { MediaProvider } from "./context"
|
||||
import { MediaLibrary } from "./media-library"
|
||||
import { MediaPickerDialog } from "./media-picker-dialog"
|
||||
import type { MediaAdapter, MediaAsset } from "./types"
|
||||
import {
|
||||
defaultMediaReference,
|
||||
getMediaFolderPath,
|
||||
splitMediaName,
|
||||
} from "./utils"
|
||||
|
||||
const imageAsset: MediaAsset = {
|
||||
createdAt: "2026-08-01T00:00:00Z",
|
||||
favorite: false,
|
||||
folderId: null,
|
||||
height: 600,
|
||||
id: "image-1",
|
||||
kind: "image",
|
||||
mimeType: "image/png",
|
||||
name: "product.png",
|
||||
sizeBytes: 1024,
|
||||
url: "/media/product.png",
|
||||
width: 800,
|
||||
}
|
||||
|
||||
function createAdapter(): MediaAdapter {
|
||||
return {
|
||||
createFolder: vi.fn(async ({ name, parentId }) => ({
|
||||
id: name,
|
||||
name,
|
||||
parentId,
|
||||
})),
|
||||
deleteAsset: vi.fn(async () => undefined),
|
||||
deleteFolder: vi.fn(async () => undefined),
|
||||
listAssets: vi.fn(async () => ({
|
||||
assets: [imageAsset],
|
||||
hasNextPage: false,
|
||||
})),
|
||||
listFolders: vi.fn(async () => []),
|
||||
listStorageTargets: vi.fn(async () => [
|
||||
{ id: "local", kind: "local" as const, label: "Local" },
|
||||
]),
|
||||
moveAsset: vi.fn(async () => imageAsset),
|
||||
renameAsset: vi.fn(async () => imageAsset),
|
||||
updateFavorite: vi.fn(async () => imageAsset),
|
||||
updateFolder: vi.fn(async ({ id, name, parentId }) => ({
|
||||
id,
|
||||
name,
|
||||
parentId,
|
||||
})),
|
||||
upload: vi.fn(async () => [imageAsset]),
|
||||
}
|
||||
}
|
||||
|
||||
function MediaTestProvider({
|
||||
adapter,
|
||||
children,
|
||||
}: {
|
||||
adapter: MediaAdapter
|
||||
children: ReactNode
|
||||
}) {
|
||||
return (
|
||||
<I18nProvider locale="en">
|
||||
<MediaProvider adapter={adapter}>{children}</MediaProvider>
|
||||
</I18nProvider>
|
||||
)
|
||||
}
|
||||
|
||||
describe("media blocks", () => {
|
||||
afterEach(cleanup)
|
||||
|
||||
it("loads and uploads media only through the provider adapter", async () => {
|
||||
const adapter = createAdapter()
|
||||
const { container } = render(
|
||||
<MediaTestProvider adapter={adapter}>
|
||||
<MediaLibrary />
|
||||
</MediaTestProvider>
|
||||
)
|
||||
|
||||
expect(await screen.findByRole("img", { name: "product.png" })).toBeTruthy()
|
||||
const fileInput =
|
||||
container.querySelector<HTMLInputElement>('input[type="file"]')
|
||||
expect(fileInput).not.toBeNull()
|
||||
|
||||
fireEvent.change(fileInput!, {
|
||||
target: {
|
||||
files: [new File(["image"], "new-image.png", { type: "image/png" })],
|
||||
},
|
||||
})
|
||||
|
||||
await waitFor(() => expect(adapter.upload).toHaveBeenCalledOnce())
|
||||
expect(adapter.upload).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
files: expect.arrayContaining([expect.any(File)]),
|
||||
target: expect.objectContaining({ id: "local" }),
|
||||
})
|
||||
)
|
||||
})
|
||||
|
||||
it("returns image picker selections with an intrinsic-size reference URL", async () => {
|
||||
const adapter = createAdapter()
|
||||
const onConfirm = vi.fn()
|
||||
render(
|
||||
<MediaTestProvider adapter={adapter}>
|
||||
<MediaPickerDialog
|
||||
open
|
||||
onOpenChange={() => undefined}
|
||||
onConfirm={onConfirm}
|
||||
/>
|
||||
</MediaTestProvider>
|
||||
)
|
||||
|
||||
const image = await screen.findByRole("img", { name: "product.png" })
|
||||
fireEvent.click(image.closest("button")!)
|
||||
await waitFor(() =>
|
||||
expect(
|
||||
(
|
||||
screen.getByRole("button", {
|
||||
name: "Use selected media",
|
||||
}) as HTMLButtonElement
|
||||
).disabled
|
||||
).toBe(false)
|
||||
)
|
||||
fireEvent.click(screen.getByRole("button", { name: "Use selected media" }))
|
||||
|
||||
expect(onConfirm).toHaveBeenCalledWith([
|
||||
expect.objectContaining({ url: "/media/product.png?w=800&h=600" }),
|
||||
])
|
||||
})
|
||||
|
||||
it("builds stable media references and folder paths", () => {
|
||||
expect(defaultMediaReference(imageAsset)).toBe(
|
||||
"/media/product.png?w=800&h=600"
|
||||
)
|
||||
expect(splitMediaName("archive.tar.gz")).toEqual({
|
||||
baseName: "archive.tar",
|
||||
extension: ".gz",
|
||||
})
|
||||
expect(
|
||||
getMediaFolderPath({ id: "child", name: "Shoes", parentId: "root" }, [
|
||||
{ id: "root", name: "Products", parentId: null },
|
||||
{ id: "child", name: "Shoes", parentId: "root" },
|
||||
])
|
||||
).toBe("Products / Shoes")
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,166 @@
|
||||
import type { MessageDescriptor } from "@workspace/i18n"
|
||||
|
||||
export const mediaMessages = {
|
||||
allMedia: { id: "blocks.media.filters.all", message: "All media" },
|
||||
cancel: { id: "blocks.media.actions.cancel", message: "Cancel" },
|
||||
chooseStorage: {
|
||||
id: "blocks.media.upload.chooseStorage",
|
||||
message: "Choose cloud storage",
|
||||
},
|
||||
cloudUpload: {
|
||||
id: "blocks.media.upload.cloud",
|
||||
message: "Upload to cloud",
|
||||
},
|
||||
copyFailed: {
|
||||
id: "blocks.media.copy.failed",
|
||||
message: "Copy failed. Check browser clipboard permissions.",
|
||||
},
|
||||
copyInformation: {
|
||||
id: "blocks.media.actions.copyInformation",
|
||||
message: "Copy information",
|
||||
},
|
||||
copyLink: { id: "blocks.media.actions.copyLink", message: "Copy link" },
|
||||
copyName: { id: "blocks.media.actions.copyName", message: "Copy name" },
|
||||
createFolder: {
|
||||
id: "blocks.media.folders.create",
|
||||
message: "New folder",
|
||||
},
|
||||
delete: { id: "blocks.media.actions.delete", message: "Delete" },
|
||||
deleteAssetDescription: {
|
||||
id: "blocks.media.deleteAsset.description",
|
||||
message: "This media item will no longer appear in the library.",
|
||||
},
|
||||
deleteAssetTitle: {
|
||||
id: "blocks.media.deleteAsset.title",
|
||||
message: "Delete media",
|
||||
},
|
||||
deleteFolderDescription: {
|
||||
id: "blocks.media.deleteFolder.description",
|
||||
message:
|
||||
"This folder and its child folders will be deleted. Their media items will become unclassified.",
|
||||
},
|
||||
deleteFolderTitle: {
|
||||
id: "blocks.media.deleteFolder.title",
|
||||
message: "Delete folder",
|
||||
},
|
||||
details: { id: "blocks.media.actions.details", message: "View details" },
|
||||
dimensions: { id: "blocks.media.details.dimensions", message: "Dimensions" },
|
||||
emptyDescription: {
|
||||
id: "blocks.media.empty.description",
|
||||
message: "Upload images or videos to manage them here.",
|
||||
},
|
||||
emptySearchDescription: {
|
||||
id: "blocks.media.emptySearch.description",
|
||||
message: "No media files match your search.",
|
||||
},
|
||||
emptyTitle: { id: "blocks.media.empty.title", message: "No media" },
|
||||
favorite: { id: "blocks.media.filters.favorite", message: "Favorites" },
|
||||
favoriteAction: { id: "blocks.media.actions.favorite", message: "Favorite" },
|
||||
fileName: { id: "blocks.media.details.fileName", message: "File name" },
|
||||
folder: { id: "blocks.media.folders.title", message: "Folders" },
|
||||
folderName: {
|
||||
id: "blocks.media.folders.name",
|
||||
message: "Folder name",
|
||||
},
|
||||
folderNamePlaceholder: {
|
||||
id: "blocks.media.folders.placeholder",
|
||||
message: "Enter a folder name",
|
||||
},
|
||||
folderOnlyClassifies: {
|
||||
id: "blocks.media.folders.classification",
|
||||
message:
|
||||
"Folders only organize media in this library and do not change file URLs.",
|
||||
},
|
||||
image: { id: "blocks.media.filters.image", message: "Images" },
|
||||
library: { id: "blocks.media.title", message: "Media library" },
|
||||
loadError: {
|
||||
id: "blocks.media.load.error",
|
||||
message: "Unable to load media",
|
||||
},
|
||||
loadMore: { id: "blocks.media.actions.loadMore", message: "Load more" },
|
||||
loading: { id: "blocks.media.loading", message: "Loading media…" },
|
||||
moveToFolder: {
|
||||
id: "blocks.media.actions.moveToFolder",
|
||||
message: "Move to folder",
|
||||
},
|
||||
newChildFolder: {
|
||||
id: "blocks.media.folders.newChild",
|
||||
message: "New child folder",
|
||||
},
|
||||
noDimensions: { id: "blocks.media.details.noDimensions", message: "Unknown" },
|
||||
noFolders: {
|
||||
id: "blocks.media.folders.empty",
|
||||
message: "No folders yet. Create one.",
|
||||
},
|
||||
rename: { id: "blocks.media.actions.rename", message: "Rename" },
|
||||
renameFolder: {
|
||||
id: "blocks.media.folders.rename",
|
||||
message: "Rename folder",
|
||||
},
|
||||
renameMedia: {
|
||||
id: "blocks.media.renameMedia.title",
|
||||
message: "Rename media",
|
||||
},
|
||||
renameMediaDescription: {
|
||||
id: "blocks.media.renameMedia.description",
|
||||
message:
|
||||
"This changes the display name without changing the file or its URL.",
|
||||
},
|
||||
retry: { id: "blocks.media.actions.retry", message: "Try again" },
|
||||
save: { id: "blocks.media.actions.save", message: "Save" },
|
||||
saving: { id: "blocks.media.actions.saving", message: "Saving…" },
|
||||
search: { id: "blocks.media.search", message: "Search media files" },
|
||||
select: { id: "blocks.media.actions.select", message: "Select" },
|
||||
selectFolder: {
|
||||
id: "blocks.media.actions.selectFolder",
|
||||
message: "Select folder {name}",
|
||||
},
|
||||
selectMedia: {
|
||||
id: "blocks.media.picker.title",
|
||||
message: "Select media",
|
||||
},
|
||||
selectMediaDescription: {
|
||||
id: "blocks.media.picker.description",
|
||||
message: "Choose an uploaded image or video from the media library.",
|
||||
},
|
||||
selectedCount: {
|
||||
id: "blocks.media.picker.selectedCount",
|
||||
message: "{count} selected",
|
||||
},
|
||||
serverUpload: {
|
||||
id: "blocks.media.upload.server",
|
||||
message: "Upload to server",
|
||||
},
|
||||
size: { id: "blocks.media.details.size", message: "Size" },
|
||||
type: { id: "blocks.media.details.type", message: "Type" },
|
||||
uploadedAt: {
|
||||
id: "blocks.media.details.uploadedAt",
|
||||
message: "Uploaded",
|
||||
},
|
||||
unclassified: {
|
||||
id: "blocks.media.filters.unclassified",
|
||||
message: "Unclassified",
|
||||
},
|
||||
unfavorite: {
|
||||
id: "blocks.media.actions.unfavorite",
|
||||
message: "Unfavorite",
|
||||
},
|
||||
upload: { id: "blocks.media.actions.upload", message: "Upload" },
|
||||
uploadError: {
|
||||
id: "blocks.media.upload.error",
|
||||
message: "Unable to upload media.",
|
||||
},
|
||||
uploadSuccess: {
|
||||
id: "blocks.media.upload.success",
|
||||
message: "{count} media files uploaded.",
|
||||
},
|
||||
useSelected: {
|
||||
id: "blocks.media.picker.useSelected",
|
||||
message: "Use selected media",
|
||||
},
|
||||
video: { id: "blocks.media.filters.video", message: "Videos" },
|
||||
viewOriginal: {
|
||||
id: "blocks.media.actions.viewOriginal",
|
||||
message: "View original",
|
||||
},
|
||||
} as const satisfies Record<string, MessageDescriptor>
|
||||
@@ -0,0 +1,399 @@
|
||||
import * as React from "react"
|
||||
import {
|
||||
ChevronRightIcon,
|
||||
FilmIcon,
|
||||
FolderIcon,
|
||||
FolderOpenIcon,
|
||||
HeartIcon,
|
||||
ImagesIcon,
|
||||
LibraryIcon,
|
||||
MoreHorizontalIcon,
|
||||
PencilIcon,
|
||||
PlusIcon,
|
||||
Trash2Icon,
|
||||
UnlinkIcon,
|
||||
} from "lucide-react"
|
||||
import { useTranslate } from "@workspace/i18n"
|
||||
import { Button } from "@workspace/ui/components/button"
|
||||
import {
|
||||
Collapsible,
|
||||
CollapsibleContent,
|
||||
CollapsibleTrigger,
|
||||
} from "@workspace/ui/components/collapsible"
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuTrigger,
|
||||
} from "@workspace/ui/components/dropdown-menu"
|
||||
import { cn } from "@workspace/ui/lib/utils"
|
||||
|
||||
import { mediaMessages } from "./messages"
|
||||
import type {
|
||||
MediaFilter,
|
||||
MediaFolder,
|
||||
MediaFolderSelection,
|
||||
MediaId,
|
||||
MediaKind,
|
||||
} from "./types"
|
||||
import {
|
||||
getChildMediaFolders,
|
||||
getMediaFolderDepth,
|
||||
getMediaFolderPath,
|
||||
} from "./utils"
|
||||
|
||||
const filters = [
|
||||
{ icon: LibraryIcon, message: mediaMessages.allMedia, value: "all" },
|
||||
{ icon: ImagesIcon, message: mediaMessages.image, value: "image" },
|
||||
{ icon: FilmIcon, message: mediaMessages.video, value: "video" },
|
||||
{ icon: HeartIcon, message: mediaMessages.favorite, value: "favorite" },
|
||||
] as const satisfies readonly {
|
||||
icon: React.ComponentType<{ className?: string }>
|
||||
message: (typeof mediaMessages)[keyof typeof mediaMessages]
|
||||
value: MediaFilter
|
||||
}[]
|
||||
|
||||
export interface MediaSidebarProps {
|
||||
allowedKinds?: readonly MediaKind[]
|
||||
className?: string
|
||||
filter: MediaFilter
|
||||
folder: MediaFolderSelection
|
||||
folders: readonly MediaFolder[]
|
||||
onCreateFolder: (parentId: MediaId | null) => void
|
||||
onDeleteFolder: (folder: MediaFolder) => void
|
||||
onFilterChange: (filter: MediaFilter) => void
|
||||
onFolderChange: (folder: MediaFolderSelection) => void
|
||||
onRenameFolder: (folder: MediaFolder) => void
|
||||
}
|
||||
|
||||
function visibleFilters(allowedKinds?: readonly MediaKind[]) {
|
||||
return filters.filter(
|
||||
(filter) =>
|
||||
filter.value === "all" ||
|
||||
filter.value === "favorite" ||
|
||||
!allowedKinds ||
|
||||
allowedKinds.includes(filter.value)
|
||||
)
|
||||
}
|
||||
|
||||
export function MediaSidebar({
|
||||
allowedKinds,
|
||||
className,
|
||||
filter,
|
||||
folder,
|
||||
folders,
|
||||
onCreateFolder,
|
||||
onDeleteFolder,
|
||||
onFilterChange,
|
||||
onFolderChange,
|
||||
onRenameFolder,
|
||||
}: MediaSidebarProps) {
|
||||
const t = useTranslate()
|
||||
const rootFolders = getChildMediaFolders(folders, null)
|
||||
|
||||
return (
|
||||
<aside
|
||||
className={cn(
|
||||
"flex min-h-0 flex-col border-e bg-muted/15 py-3",
|
||||
className
|
||||
)}
|
||||
>
|
||||
<h2 className="px-4 py-2 text-sm font-semibold">
|
||||
{t(mediaMessages.library)}
|
||||
</h2>
|
||||
<nav aria-label={t(mediaMessages.library)} className="px-3">
|
||||
<ul className="space-y-1">
|
||||
{visibleFilters(allowedKinds).map(({ icon, message, value }) => (
|
||||
<li key={value}>
|
||||
<SidebarButton
|
||||
active={folder === undefined && filter === value}
|
||||
icon={icon}
|
||||
label={t(message)}
|
||||
onClick={() => onFilterChange(value)}
|
||||
/>
|
||||
</li>
|
||||
))}
|
||||
<li>
|
||||
<SidebarButton
|
||||
active={folder === "unclassified"}
|
||||
icon={UnlinkIcon}
|
||||
label={t(mediaMessages.unclassified)}
|
||||
onClick={() => onFolderChange("unclassified")}
|
||||
/>
|
||||
</li>
|
||||
</ul>
|
||||
</nav>
|
||||
|
||||
<div className="mt-4 flex min-h-0 flex-1 flex-col border-t pt-3">
|
||||
<div className="flex items-center px-4 py-1">
|
||||
<h3 className="text-xs font-medium text-muted-foreground">
|
||||
{t(mediaMessages.folder)}
|
||||
</h3>
|
||||
<Button
|
||||
type="button"
|
||||
size="icon-xs"
|
||||
variant="ghost"
|
||||
className="ms-auto"
|
||||
aria-label={t(mediaMessages.createFolder)}
|
||||
onClick={() => onCreateFolder(null)}
|
||||
>
|
||||
<PlusIcon />
|
||||
</Button>
|
||||
</div>
|
||||
<nav
|
||||
aria-label={t(mediaMessages.folder)}
|
||||
className="min-h-0 overflow-y-auto px-3"
|
||||
>
|
||||
{rootFolders.length ? (
|
||||
<ul className="space-y-1">
|
||||
{rootFolders.map((rootFolder) => (
|
||||
<FolderItem
|
||||
key={rootFolder.id}
|
||||
folder={rootFolder}
|
||||
folders={folders}
|
||||
depth={0}
|
||||
activeFolder={folder}
|
||||
onSelect={onFolderChange}
|
||||
onCreateFolder={onCreateFolder}
|
||||
onRename={onRenameFolder}
|
||||
onDelete={onDeleteFolder}
|
||||
/>
|
||||
))}
|
||||
</ul>
|
||||
) : (
|
||||
<button
|
||||
type="button"
|
||||
className="w-full px-3 py-4 text-start text-xs text-muted-foreground hover:text-foreground"
|
||||
onClick={() => onCreateFolder(null)}
|
||||
>
|
||||
{t(mediaMessages.noFolders)}
|
||||
</button>
|
||||
)}
|
||||
</nav>
|
||||
</div>
|
||||
</aside>
|
||||
)
|
||||
}
|
||||
|
||||
export function MediaFilterTabs({
|
||||
allowedKinds,
|
||||
filter,
|
||||
folder,
|
||||
folders,
|
||||
onFilterChange,
|
||||
onFolderChange,
|
||||
}: Omit<
|
||||
MediaSidebarProps,
|
||||
"className" | "onCreateFolder" | "onDeleteFolder" | "onRenameFolder"
|
||||
>) {
|
||||
const t = useTranslate()
|
||||
|
||||
return (
|
||||
<div className="flex gap-1 overflow-x-auto border-b p-2 md:hidden">
|
||||
{visibleFilters(allowedKinds).map(({ icon: Icon, message, value }) => (
|
||||
<button
|
||||
key={value}
|
||||
type="button"
|
||||
aria-pressed={folder === undefined && filter === value}
|
||||
onClick={() => onFilterChange(value)}
|
||||
className={cn(
|
||||
"flex h-8 shrink-0 items-center gap-1.5 rounded-lg px-3 text-xs",
|
||||
folder === undefined && filter === value
|
||||
? "bg-primary text-primary-foreground"
|
||||
: "bg-muted"
|
||||
)}
|
||||
>
|
||||
<Icon className="size-3.5" />
|
||||
{t(message)}
|
||||
</button>
|
||||
))}
|
||||
<select
|
||||
aria-label={t(mediaMessages.folder)}
|
||||
className="h-8 max-w-40 rounded-lg border bg-background px-2 text-xs outline-none"
|
||||
value={
|
||||
folder === undefined
|
||||
? ""
|
||||
: folder === "unclassified"
|
||||
? "unclassified"
|
||||
: String(folder)
|
||||
}
|
||||
onChange={(event) => {
|
||||
const next = event.target.value
|
||||
onFolderChange(
|
||||
next === ""
|
||||
? undefined
|
||||
: next === "unclassified"
|
||||
? "unclassified"
|
||||
: (folders.find((item) => String(item.id) === next)?.id ?? next)
|
||||
)
|
||||
}}
|
||||
>
|
||||
<option value="">{t(mediaMessages.allMedia)}</option>
|
||||
<option value="unclassified">{t(mediaMessages.unclassified)}</option>
|
||||
{folders.map((item) => (
|
||||
<option key={item.id} value={item.id}>
|
||||
{" ".repeat(getMediaFolderDepth(item, folders))}
|
||||
{getMediaFolderPath(item, folders)}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function SidebarButton({
|
||||
active,
|
||||
icon: Icon,
|
||||
label,
|
||||
onClick,
|
||||
}: {
|
||||
active: boolean
|
||||
icon: React.ComponentType<{ className?: string }>
|
||||
label: string
|
||||
onClick: VoidFunction
|
||||
}) {
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
aria-pressed={active}
|
||||
onClick={onClick}
|
||||
className={cn(
|
||||
"flex h-9 w-full items-center gap-2 rounded-lg px-3 text-start text-sm transition-colors outline-none hover:bg-muted focus-visible:ring-3 focus-visible:ring-ring/30",
|
||||
active && "bg-primary text-primary-foreground hover:bg-primary"
|
||||
)}
|
||||
>
|
||||
<Icon className="size-4" />
|
||||
{label}
|
||||
</button>
|
||||
)
|
||||
}
|
||||
|
||||
function FolderItem({
|
||||
folder,
|
||||
folders,
|
||||
depth,
|
||||
activeFolder,
|
||||
onSelect,
|
||||
onCreateFolder,
|
||||
onRename,
|
||||
onDelete,
|
||||
}: {
|
||||
folder: MediaFolder
|
||||
folders: readonly MediaFolder[]
|
||||
depth: number
|
||||
activeFolder: MediaFolderSelection
|
||||
onSelect: (folder: MediaFolderSelection) => void
|
||||
onCreateFolder: (parentId: MediaId | null) => void
|
||||
onRename: (folder: MediaFolder) => void
|
||||
onDelete: (folder: MediaFolder) => void
|
||||
}) {
|
||||
const t = useTranslate()
|
||||
const children = getChildMediaFolders(folders, folder.id)
|
||||
const [open, setOpen] = React.useState(true)
|
||||
const hasChildren = children.length > 0
|
||||
const active = activeFolder === folder.id
|
||||
const Folder = active || (hasChildren && open) ? FolderOpenIcon : FolderIcon
|
||||
|
||||
return (
|
||||
<li>
|
||||
<Collapsible open={open} onOpenChange={setOpen}>
|
||||
<div
|
||||
className={cn(
|
||||
"group flex h-9 items-center rounded-lg transition-colors hover:bg-muted",
|
||||
active && "bg-secondary text-secondary-foreground"
|
||||
)}
|
||||
>
|
||||
{hasChildren ? (
|
||||
<CollapsibleTrigger
|
||||
render={
|
||||
<button
|
||||
type="button"
|
||||
className="group/folder-toggle relative ms-2 grid size-7 shrink-0 place-items-center rounded-md outline-none hover:bg-muted-foreground/10 focus-visible:ring-3 focus-visible:ring-ring/30"
|
||||
aria-label={folder.name}
|
||||
/>
|
||||
}
|
||||
>
|
||||
<Folder className="size-4 transition-opacity group-hover/folder-toggle:opacity-0 group-focus-visible/folder-toggle:opacity-0" />
|
||||
<ChevronRightIcon
|
||||
className={cn(
|
||||
"absolute size-4 opacity-0 transition-[rotate,opacity] group-hover/folder-toggle:opacity-100 group-focus-visible/folder-toggle:opacity-100",
|
||||
open && "rotate-90"
|
||||
)}
|
||||
/>
|
||||
</CollapsibleTrigger>
|
||||
) : (
|
||||
<span className="ms-2 grid size-7 shrink-0 place-items-center">
|
||||
<Folder className="size-4" />
|
||||
</span>
|
||||
)}
|
||||
<button
|
||||
type="button"
|
||||
aria-label={t(mediaMessages.selectFolder, { name: folder.name })}
|
||||
className="flex min-w-0 flex-1 items-center gap-2 self-stretch py-0 ps-1 pe-1.5 text-start text-sm outline-none focus-visible:ring-3 focus-visible:ring-ring/30"
|
||||
onClick={() => onSelect(folder.id)}
|
||||
>
|
||||
<span className="min-w-0 flex-1 truncate">{folder.name}</span>
|
||||
{(folder.assetCount ?? 0) > 0 && (
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{folder.assetCount}
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger
|
||||
render={
|
||||
<Button
|
||||
type="button"
|
||||
size="icon-xs"
|
||||
variant="ghost"
|
||||
className="me-1 opacity-0 group-hover:opacity-100 aria-expanded:opacity-100"
|
||||
aria-label={folder.name}
|
||||
/>
|
||||
}
|
||||
>
|
||||
<MoreHorizontalIcon />
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end">
|
||||
{depth < 2 && (
|
||||
<DropdownMenuItem onClick={() => onCreateFolder(folder.id)}>
|
||||
<PlusIcon />
|
||||
{t(mediaMessages.newChildFolder)}
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
<DropdownMenuItem onClick={() => onRename(folder)}>
|
||||
<PencilIcon />
|
||||
{t(mediaMessages.rename)}
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem
|
||||
variant="destructive"
|
||||
onClick={() => onDelete(folder)}
|
||||
>
|
||||
<Trash2Icon />
|
||||
{t(mediaMessages.delete)}
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</div>
|
||||
{hasChildren && (
|
||||
<CollapsibleContent>
|
||||
<ul className="mt-1 space-y-1 ps-4">
|
||||
{children.map((child) => (
|
||||
<FolderItem
|
||||
key={child.id}
|
||||
folder={child}
|
||||
folders={folders}
|
||||
depth={depth + 1}
|
||||
activeFolder={activeFolder}
|
||||
onSelect={onSelect}
|
||||
onCreateFolder={onCreateFolder}
|
||||
onRename={onRename}
|
||||
onDelete={onDelete}
|
||||
/>
|
||||
))}
|
||||
</ul>
|
||||
</CollapsibleContent>
|
||||
)}
|
||||
</Collapsible>
|
||||
</li>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,94 @@
|
||||
export type MediaId = number | string
|
||||
export type MediaKind = "image" | "video"
|
||||
export type MediaFilter = "all" | MediaKind | "favorite"
|
||||
export type MediaFolderSelection = MediaId | "unclassified" | undefined
|
||||
export type MediaSelectionMode = "multiple" | "none" | "single"
|
||||
|
||||
export interface MediaAsset {
|
||||
createdAt: Date | number | string
|
||||
favorite: boolean
|
||||
folderId: MediaId | null
|
||||
height?: number | null
|
||||
id: MediaId
|
||||
kind: MediaKind
|
||||
mimeType: string
|
||||
name: string
|
||||
sizeBytes: number
|
||||
updatedAt?: Date | number | string
|
||||
url: string
|
||||
width?: number | null
|
||||
}
|
||||
|
||||
export interface MediaFolder {
|
||||
assetCount?: number
|
||||
createdAt?: Date | number | string
|
||||
id: MediaId
|
||||
name: string
|
||||
parentId: MediaId | null
|
||||
sortOrder?: number
|
||||
updatedAt?: Date | number | string
|
||||
}
|
||||
|
||||
export interface MediaStorageTarget {
|
||||
id: string
|
||||
kind: "cloud" | "local"
|
||||
label: string
|
||||
}
|
||||
|
||||
export interface MediaAssetQuery {
|
||||
allowedKinds?: readonly MediaKind[]
|
||||
filter: MediaFilter
|
||||
folder: MediaFolderSelection
|
||||
keyword: string
|
||||
page: number
|
||||
pageSize: number
|
||||
}
|
||||
|
||||
export interface MediaAssetPage {
|
||||
assets: readonly MediaAsset[]
|
||||
hasNextPage: boolean
|
||||
}
|
||||
|
||||
export interface CreateMediaFolderInput {
|
||||
name: string
|
||||
parentId: MediaId | null
|
||||
}
|
||||
|
||||
export interface UpdateMediaFolderInput extends CreateMediaFolderInput {
|
||||
id: MediaId
|
||||
}
|
||||
|
||||
export interface MediaUploadInput {
|
||||
files: readonly File[]
|
||||
folderId?: MediaId
|
||||
target: MediaStorageTarget
|
||||
}
|
||||
|
||||
/**
|
||||
* The host application owns all persistence and transport. This adapter can
|
||||
* be backed by a database API, direct-to-object-storage uploads, or mocks.
|
||||
*/
|
||||
export interface MediaAdapter {
|
||||
createFolder(input: CreateMediaFolderInput): Promise<MediaFolder>
|
||||
deleteAsset(id: MediaId): Promise<void>
|
||||
deleteFolder(id: MediaId): Promise<void>
|
||||
listAssets(query: MediaAssetQuery): Promise<MediaAssetPage>
|
||||
listFolders(): Promise<readonly MediaFolder[]>
|
||||
listStorageTargets?(): Promise<readonly MediaStorageTarget[]>
|
||||
moveAsset(id: MediaId, folderId: MediaId | null): Promise<MediaAsset>
|
||||
renameAsset(id: MediaId, name: string): Promise<MediaAsset>
|
||||
updateFavorite(id: MediaId, favorite: boolean): Promise<MediaAsset>
|
||||
updateFolder(input: UpdateMediaFolderInput): Promise<MediaFolder>
|
||||
upload(input: MediaUploadInput): Promise<readonly MediaAsset[]>
|
||||
|
||||
/** Resolves a stored media URL for rendering or opening the source file. */
|
||||
resolveUrl?(asset: MediaAsset): string
|
||||
|
||||
/** Produces the URL returned by MediaPickerDialog. */
|
||||
resolveReference?(asset: MediaAsset): string
|
||||
}
|
||||
|
||||
export interface MediaNotice {
|
||||
message: string
|
||||
tone: "error" | "success"
|
||||
}
|
||||
@@ -0,0 +1,106 @@
|
||||
import type { MediaAsset, MediaFolder, MediaId } from "./types"
|
||||
|
||||
export const MEDIA_ACCEPT =
|
||||
"image/png,image/jpeg,image/webp,image/gif,video/mp4,video/webm,video/ogg,video/quicktime"
|
||||
|
||||
export function formatMediaFileSize(bytes: number): string {
|
||||
if (bytes < 1024) return `${bytes} B`
|
||||
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`
|
||||
return `${(bytes / 1024 / 1024).toFixed(1)} MB`
|
||||
}
|
||||
|
||||
export function isMediaFile(file: File): boolean {
|
||||
return file.type.startsWith("image/") || file.type.startsWith("video/")
|
||||
}
|
||||
|
||||
export function mediaKindForFile(file: File): MediaAsset["kind"] | undefined {
|
||||
if (file.type.startsWith("image/")) return "image"
|
||||
if (file.type.startsWith("video/")) return "video"
|
||||
return undefined
|
||||
}
|
||||
|
||||
export function getChildMediaFolders(
|
||||
folders: readonly MediaFolder[],
|
||||
parentId: MediaId | null
|
||||
): MediaFolder[] {
|
||||
return folders
|
||||
.filter((folder) => folder.parentId === parentId)
|
||||
.sort(
|
||||
(left, right) =>
|
||||
(left.sortOrder ?? 0) - (right.sortOrder ?? 0) ||
|
||||
left.name.localeCompare(right.name)
|
||||
)
|
||||
}
|
||||
|
||||
export function getMediaFolderPath(
|
||||
folder: MediaFolder,
|
||||
folders: readonly MediaFolder[]
|
||||
): string {
|
||||
const names = [folder.name]
|
||||
const seen = new Set<MediaId>([folder.id])
|
||||
let current = folder
|
||||
|
||||
while (current.parentId !== null) {
|
||||
const parent = folders.find((item) => item.id === current.parentId)
|
||||
if (!parent || seen.has(parent.id)) break
|
||||
seen.add(parent.id)
|
||||
names.unshift(parent.name)
|
||||
current = parent
|
||||
}
|
||||
|
||||
return names.join(" / ")
|
||||
}
|
||||
|
||||
export function getMediaFolderDepth(
|
||||
folder: MediaFolder,
|
||||
folders: readonly MediaFolder[]
|
||||
): number {
|
||||
let depth = 0
|
||||
const seen = new Set<MediaId>([folder.id])
|
||||
let current = folder
|
||||
|
||||
while (current.parentId !== null) {
|
||||
const parent = folders.find((item) => item.id === current.parentId)
|
||||
if (!parent || seen.has(parent.id)) break
|
||||
seen.add(parent.id)
|
||||
depth += 1
|
||||
current = parent
|
||||
}
|
||||
|
||||
return depth
|
||||
}
|
||||
|
||||
export function splitMediaName(name: string) {
|
||||
const extensionIndex = name.lastIndexOf(".")
|
||||
if (extensionIndex <= 0 || extensionIndex === name.length - 1) {
|
||||
return { baseName: name, extension: "" }
|
||||
}
|
||||
|
||||
return {
|
||||
baseName: name.slice(0, extensionIndex),
|
||||
extension: name.slice(extensionIndex),
|
||||
}
|
||||
}
|
||||
|
||||
export function defaultMediaReference(asset: MediaAsset): string {
|
||||
if (
|
||||
asset.kind !== "image" ||
|
||||
!asset.width ||
|
||||
!asset.height ||
|
||||
asset.width <= 0 ||
|
||||
asset.height <= 0
|
||||
) {
|
||||
return asset.url
|
||||
}
|
||||
|
||||
const hashIndex = asset.url.indexOf("#")
|
||||
const url = hashIndex === -1 ? asset.url : asset.url.slice(0, hashIndex)
|
||||
const hash = hashIndex === -1 ? "" : asset.url.slice(hashIndex)
|
||||
const separator = url.includes("?")
|
||||
? url.endsWith("?") || url.endsWith("&")
|
||||
? ""
|
||||
: "&"
|
||||
: "?"
|
||||
|
||||
return `${url}${separator}w=${asset.width}&h=${asset.height}${hash}`
|
||||
}
|
||||
@@ -1,12 +1,10 @@
|
||||
import { Icon } from "@workspace/icons"
|
||||
import {
|
||||
DropdownMenuItem,
|
||||
DropdownMenuSub,
|
||||
DropdownMenuSubContent,
|
||||
DropdownMenuSubTrigger,
|
||||
} from "@workspace/ui/components/dropdown-menu"
|
||||
|
||||
import { Icon } from "../../components/icon"
|
||||
|
||||
import { resolveNavigationItemIcon } from "./item-icon"
|
||||
import { NavigationBadge, NavigationLink } from "./navigation-button"
|
||||
import { NavigationLabel } from "./navigation-label"
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import type { IconData } from "../../components/icon"
|
||||
import type { IconData } from "@workspace/icons"
|
||||
|
||||
import type { NavigationItem } from "./types"
|
||||
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { useLocation } from "@tanstack/react-router"
|
||||
import { Icon, Menu02Icon } from "@workspace/icons"
|
||||
import { useMessage } from "@workspace/i18n"
|
||||
import type { ButtonProps } from "@workspace/ui/components/button"
|
||||
import { Separator } from "@workspace/ui/components/separator"
|
||||
@@ -31,8 +32,6 @@ import {
|
||||
type GetNavigationRouteState,
|
||||
} from "./route-state"
|
||||
import type { NavigationGroup } from "./types"
|
||||
import { Icon } from "../../components/icon"
|
||||
import { Menu02Icon } from "@hugeicons/core-free-icons"
|
||||
|
||||
const mobileNavigationSheetHandle = createSheetHandle<void>()
|
||||
|
||||
|
||||
@@ -2,12 +2,12 @@ import { mergeProps } from "@base-ui/react/merge-props"
|
||||
import { useRender } from "@base-ui/react/use-render"
|
||||
import { Link } from "@tanstack/react-router"
|
||||
import * as React from "react"
|
||||
import { Icon, type IconData } from "@workspace/icons"
|
||||
import { Badge } from "@workspace/ui/components/badge"
|
||||
import { useRippleRef } from "@workspace/ui/hooks/use-ripple"
|
||||
import { cn } from "@workspace/ui/lib/utils"
|
||||
import { ChevronRightIcon } from "lucide-react"
|
||||
|
||||
import { Icon, type IconData } from "../../components/icon"
|
||||
import { NavigationLabel, TruncatedNavigationLabel } from "./navigation-label"
|
||||
import type { NavigationLabelValue } from "./types"
|
||||
|
||||
@@ -22,7 +22,9 @@ export interface NavigationButtonProps extends Omit<
|
||||
layout?: "inline" | "stacked" | "responsive"
|
||||
className?: string | (() => string | undefined) | undefined
|
||||
style?:
|
||||
React.CSSProperties | (() => React.CSSProperties | undefined) | undefined
|
||||
| React.CSSProperties
|
||||
| (() => React.CSSProperties | undefined)
|
||||
| undefined
|
||||
trailing?: React.ReactNode | (() => React.ReactNode) | undefined
|
||||
}
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { cn } from "@workspace/ui/lib/utils"
|
||||
import { LocalizedText } from "@workspace/i18n"
|
||||
|
||||
import { LocalizedText } from "../../components/localized-text"
|
||||
import type { NavigationLabelValue } from "./types"
|
||||
|
||||
export interface NavigationLabelProps {
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import type { IconData } from "@workspace/icons"
|
||||
import type { MessageDescriptor } from "@workspace/i18n"
|
||||
|
||||
import type { IconData } from "../../components/icon"
|
||||
|
||||
export type NavigationLabelValue = string | MessageDescriptor
|
||||
|
||||
export interface NavigationItem {
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { useLocation } from "@tanstack/react-router"
|
||||
import * as React from "react"
|
||||
import type { IconData } from "@workspace/icons"
|
||||
|
||||
import type { IconData } from "../../components/icon"
|
||||
import { resolveNavigationItemIcon } from "./item-icon"
|
||||
import type {
|
||||
NavigationGroup,
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import * as React from "react"
|
||||
import { Link } from "@tanstack/react-router"
|
||||
import { Icon } from "@workspace/icons"
|
||||
import { useFormatters, useMessage } from "@workspace/i18n"
|
||||
import {
|
||||
Avatar,
|
||||
@@ -17,7 +18,6 @@ import {
|
||||
} from "@workspace/ui/components/item"
|
||||
import { cn } from "@workspace/ui/lib/utils"
|
||||
|
||||
import { Icon } from "../../components/icon"
|
||||
import { notificationMessages } from "./messages"
|
||||
|
||||
import type {
|
||||
|
||||
@@ -1,7 +1,11 @@
|
||||
import type { IconData } from "../../components/icon"
|
||||
import type { IconData } from "@workspace/icons"
|
||||
|
||||
export type NotificationTone =
|
||||
"destructive" | "info" | "neutral" | "success" | "warning"
|
||||
| "destructive"
|
||||
| "info"
|
||||
| "neutral"
|
||||
| "success"
|
||||
| "warning"
|
||||
|
||||
export interface NotificationAvatarMedia {
|
||||
alt?: string
|
||||
|
||||
@@ -0,0 +1,54 @@
|
||||
import * as React from "react"
|
||||
|
||||
import type { SearchAdapter, SearchSelectionHandler } from "./types"
|
||||
|
||||
export interface SearchContextValue {
|
||||
adapter: SearchAdapter
|
||||
historyLimit: number
|
||||
historyStorageKey?: string
|
||||
onSelect?: SearchSelectionHandler
|
||||
}
|
||||
|
||||
const SearchContext = React.createContext<SearchContextValue | null>(null)
|
||||
|
||||
export interface SearchProviderProps {
|
||||
adapter: SearchAdapter
|
||||
children: React.ReactNode
|
||||
/** Maximum number of recent queries persisted by the default history hook. */
|
||||
historyLimit?: number
|
||||
/** Set to false to keep search history only in memory. */
|
||||
historyStorageKey?: string | false
|
||||
onSelect?: SearchSelectionHandler
|
||||
}
|
||||
|
||||
/** Supplies the host application's search implementation to the search blocks. */
|
||||
export function SearchProvider({
|
||||
adapter,
|
||||
children,
|
||||
historyLimit = 32,
|
||||
historyStorageKey = "workspace-search-history",
|
||||
onSelect,
|
||||
}: SearchProviderProps) {
|
||||
const value = React.useMemo<SearchContextValue>(
|
||||
() => ({
|
||||
adapter,
|
||||
historyLimit: Math.max(1, Math.floor(historyLimit)),
|
||||
historyStorageKey:
|
||||
historyStorageKey === false ? undefined : historyStorageKey,
|
||||
onSelect,
|
||||
}),
|
||||
[adapter, historyLimit, historyStorageKey, onSelect]
|
||||
)
|
||||
|
||||
return (
|
||||
<SearchContext.Provider value={value}>{children}</SearchContext.Provider>
|
||||
)
|
||||
}
|
||||
|
||||
export function useSearchContext(): SearchContextValue {
|
||||
const value = React.useContext(SearchContext)
|
||||
if (!value) {
|
||||
throw new Error("Search blocks must be rendered inside a SearchProvider.")
|
||||
}
|
||||
return value
|
||||
}
|
||||
@@ -0,0 +1,218 @@
|
||||
import * as React from "react"
|
||||
import { Command } from "cmdk"
|
||||
import { useHotkey } from "@tanstack/react-hotkeys"
|
||||
import { useTranslate } from "@workspace/i18n"
|
||||
import { Button } from "@workspace/ui/components/button"
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@workspace/ui/components/dialog"
|
||||
import { cn } from "@workspace/ui/lib/utils"
|
||||
import { LoaderCircleIcon, SearchIcon, XIcon } from "lucide-react"
|
||||
|
||||
import { useSearchContext } from "./context"
|
||||
import { searchDialogHandle } from "./handle"
|
||||
import { SearchHistory } from "./history"
|
||||
import { searchMessages } from "./messages"
|
||||
import { SearchResults } from "./results"
|
||||
import {
|
||||
SearchEmpty,
|
||||
SearchError,
|
||||
SearchInitial,
|
||||
SearchLoading,
|
||||
} from "./states"
|
||||
import type { SearchResultItem, SearchSelectionHandler } from "./types"
|
||||
import { useSearch } from "./use-search"
|
||||
import { useSearchHistory } from "./use-search-history"
|
||||
|
||||
export interface SearchDialogProps {
|
||||
className?: string
|
||||
/** Set to false when the host application registers its own shortcut. */
|
||||
hotkey?: Parameters<typeof useHotkey>[0] | false
|
||||
onOpenChange?: (open: boolean) => void
|
||||
onSelect?: SearchSelectionHandler
|
||||
placeholder?: string
|
||||
title?: string
|
||||
}
|
||||
|
||||
/**
|
||||
* A command-style global search dialog. Data fetching and result selection are
|
||||
* delegated to the nearest SearchProvider.
|
||||
*/
|
||||
export function SearchDialog({
|
||||
className,
|
||||
hotkey = "Mod+K",
|
||||
onOpenChange,
|
||||
onSelect,
|
||||
placeholder,
|
||||
title,
|
||||
}: SearchDialogProps) {
|
||||
const {
|
||||
historyLimit,
|
||||
historyStorageKey,
|
||||
onSelect: contextOnSelect,
|
||||
} = useSearchContext()
|
||||
const t = useTranslate()
|
||||
const [open, setOpen] = React.useState(false)
|
||||
const [query, setQuery] = React.useState("")
|
||||
const deferredQuery = React.useDeferredValue(query.trim())
|
||||
const history = useSearchHistory({
|
||||
limit: historyLimit,
|
||||
storageKey: historyStorageKey,
|
||||
})
|
||||
const search = useSearch({ active: open, query: deferredQuery })
|
||||
const hasResults = search.groups.some((group) => group.items.length > 0)
|
||||
|
||||
const setDialogOpen = React.useCallback(
|
||||
(nextOpen: boolean) => {
|
||||
setOpen(nextOpen)
|
||||
onOpenChange?.(nextOpen)
|
||||
},
|
||||
[onOpenChange]
|
||||
)
|
||||
|
||||
const toggle = React.useCallback(() => {
|
||||
if (open) {
|
||||
searchDialogHandle.close()
|
||||
} else {
|
||||
searchDialogHandle.open(null)
|
||||
}
|
||||
}, [open])
|
||||
|
||||
const selectResult = React.useCallback(
|
||||
(item: SearchResultItem) => {
|
||||
history.add(query)
|
||||
setQuery("")
|
||||
searchDialogHandle.close()
|
||||
const selectionHandler = onSelect ?? contextOnSelect
|
||||
selectionHandler?.({ item, query: query.trim() })
|
||||
},
|
||||
[contextOnSelect, history, onSelect, query]
|
||||
)
|
||||
|
||||
return (
|
||||
<Dialog handle={searchDialogHandle} onOpenChange={setDialogOpen}>
|
||||
{hotkey && <SearchHotkey hotkey={hotkey} onToggle={toggle} />}
|
||||
<DialogHeader className="sr-only">
|
||||
<DialogTitle>{title ?? t(searchMessages.title)}</DialogTitle>
|
||||
<DialogDescription>{t(searchMessages.description)}</DialogDescription>
|
||||
</DialogHeader>
|
||||
<DialogContent
|
||||
className={cn(
|
||||
"top-20 flex max-h-[calc(100dvh-2rem)] min-h-0 translate-y-0 flex-col gap-0 overflow-hidden bg-popover p-0 sm:max-w-[min(94vw,48rem)]",
|
||||
className
|
||||
)}
|
||||
showCloseButton={false}
|
||||
>
|
||||
<Command
|
||||
className="group/search flex min-h-0 flex-col overflow-hidden"
|
||||
shouldFilter={false}
|
||||
>
|
||||
<div className="flex h-14 items-center gap-2 border-b bg-popover px-3 py-1 text-popover-foreground">
|
||||
<label className="flex h-full min-w-0 flex-1 items-center gap-2">
|
||||
<span className="inline-flex size-8 shrink-0 items-center justify-center">
|
||||
{search.isLoading || search.isLoadingMore ? (
|
||||
<LoaderCircleIcon className="animate-spin text-primary" />
|
||||
) : (
|
||||
<SearchIcon className="opacity-50" />
|
||||
)}
|
||||
</span>
|
||||
<Command.Input
|
||||
autoFocus
|
||||
value={query}
|
||||
onValueChange={setQuery}
|
||||
className="min-w-0 flex-1 border-none bg-transparent text-lg outline-hidden disabled:cursor-not-allowed disabled:opacity-50"
|
||||
placeholder={placeholder ?? t(searchMessages.placeholder)}
|
||||
onKeyDown={(event) => {
|
||||
if (event.key === "Enter" && !event.nativeEvent.isComposing) {
|
||||
history.add(query)
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</label>
|
||||
{query.trim() && (
|
||||
<>
|
||||
<button
|
||||
type="button"
|
||||
className="shrink-0 appearance-none bg-transparent p-2 text-foreground underline underline-offset-4 transition-colors outline-none hover:text-destructive"
|
||||
onClick={() => setQuery("")}
|
||||
>
|
||||
{t(searchMessages.clear)}
|
||||
</button>
|
||||
<div className="h-4 w-px shrink-0 bg-border" />
|
||||
</>
|
||||
)}
|
||||
<Button
|
||||
type="button"
|
||||
size="icon"
|
||||
variant="ghost"
|
||||
className="shrink-0 text-foreground/75 hover:text-destructive"
|
||||
onClick={() => searchDialogHandle.close()}
|
||||
aria-label={t(searchMessages.close)}
|
||||
>
|
||||
<XIcon className="size-5" />
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{search.error && !hasResults ? (
|
||||
<div className="flex min-h-0 flex-1 flex-col">
|
||||
<SearchError query={deferredQuery} />
|
||||
<div className="pb-6 text-center">
|
||||
<Button variant="outline" onClick={search.retry}>
|
||||
{t(searchMessages.retry)}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
) : search.isLoading ? (
|
||||
<SearchLoading />
|
||||
) : hasResults ? (
|
||||
<SearchResults
|
||||
groups={search.groups}
|
||||
query={deferredQuery}
|
||||
onSelect={selectResult}
|
||||
onLoadMore={search.loadMore}
|
||||
hasMore={search.hasMore}
|
||||
isLoadingMore={search.isLoadingMore}
|
||||
/>
|
||||
) : deferredQuery ? (
|
||||
<SearchEmpty query={deferredQuery} />
|
||||
) : history.items.length > 0 ? (
|
||||
<SearchHistory
|
||||
items={history.items}
|
||||
onSelect={setQuery}
|
||||
onRemove={history.remove}
|
||||
onClear={history.clear}
|
||||
/>
|
||||
) : (
|
||||
<SearchInitial />
|
||||
)}
|
||||
</Command>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
|
||||
function SearchHotkey({
|
||||
hotkey,
|
||||
onToggle,
|
||||
}: {
|
||||
hotkey: Parameters<typeof useHotkey>[0]
|
||||
onToggle: VoidFunction
|
||||
}) {
|
||||
const t = useTranslate()
|
||||
|
||||
useHotkey(hotkey, onToggle, {
|
||||
ignoreInputs: true,
|
||||
preventDefault: true,
|
||||
stopPropagation: false,
|
||||
meta: {
|
||||
name: t(searchMessages.title),
|
||||
description: t(searchMessages.commandDescription),
|
||||
},
|
||||
})
|
||||
|
||||
return null
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
import { Dialog } from "@base-ui/react/dialog"
|
||||
|
||||
/** A shared handle for the optional global-search trigger. */
|
||||
export const searchDialogHandle = Dialog.createHandle<void>()
|
||||
@@ -0,0 +1,35 @@
|
||||
export interface HighlightSegment {
|
||||
highlighted: boolean
|
||||
text: string
|
||||
}
|
||||
|
||||
function escapeRegExp(value: string) {
|
||||
return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")
|
||||
}
|
||||
|
||||
export function splitHighlightSegments(
|
||||
text: string,
|
||||
query: string
|
||||
): readonly HighlightSegment[] {
|
||||
const normalizedQuery = query.trim()
|
||||
if (!normalizedQuery) return [{ highlighted: false, text }]
|
||||
|
||||
const matcher = new RegExp(escapeRegExp(normalizedQuery), "giu")
|
||||
const segments: HighlightSegment[] = []
|
||||
let cursor = 0
|
||||
|
||||
for (const match of text.matchAll(matcher)) {
|
||||
const index = match.index ?? 0
|
||||
if (index > cursor) {
|
||||
segments.push({ highlighted: false, text: text.slice(cursor, index) })
|
||||
}
|
||||
segments.push({ highlighted: true, text: match[0] })
|
||||
cursor = index + match[0].length
|
||||
}
|
||||
|
||||
if (cursor < text.length) {
|
||||
segments.push({ highlighted: false, text: text.slice(cursor) })
|
||||
}
|
||||
|
||||
return segments.length > 0 ? segments : [{ highlighted: false, text }]
|
||||
}
|
||||
@@ -0,0 +1,79 @@
|
||||
import { Command } from "cmdk"
|
||||
import { useTranslate } from "@workspace/i18n"
|
||||
import { Button } from "@workspace/ui/components/button"
|
||||
import { ScrollArea } from "@workspace/ui/components/scroll-area"
|
||||
import { cn } from "@workspace/ui/lib/utils"
|
||||
import { HistoryIcon, Trash2Icon, XIcon } from "lucide-react"
|
||||
|
||||
import { searchMessages } from "./messages"
|
||||
|
||||
export interface SearchHistoryProps {
|
||||
className?: string
|
||||
items: readonly string[]
|
||||
onSelect: (keyword: string) => void
|
||||
onRemove: (keyword: string) => void
|
||||
onClear: () => void
|
||||
}
|
||||
|
||||
export function SearchHistory({
|
||||
className,
|
||||
items,
|
||||
onClear,
|
||||
onRemove,
|
||||
onSelect,
|
||||
}: SearchHistoryProps) {
|
||||
const t = useTranslate()
|
||||
return (
|
||||
<div
|
||||
className={cn("flex h-[min(65dvh,36rem)] min-h-0 flex-col", className)}
|
||||
>
|
||||
<ScrollArea className="min-h-0 flex-1 p-4">
|
||||
<div className="sticky top-0 z-1 flex items-center justify-between bg-sidebar/90 ps-2 pb-2 backdrop-blur-xs">
|
||||
<span className="flex items-center gap-2 text-sm font-medium">
|
||||
{t(searchMessages.history)}
|
||||
</span>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
className="hover:bg-destructive/10 hover:text-destructive"
|
||||
size="xs"
|
||||
onClick={onClear}
|
||||
>
|
||||
<Trash2Icon />
|
||||
{t(searchMessages.clearHistory)}
|
||||
</Button>
|
||||
</div>
|
||||
<Command.List
|
||||
data-slot="command-list"
|
||||
className="gap-1 px-1 pb-1 **:[[cmdk-list-sizer]]:space-y-1"
|
||||
>
|
||||
{items.map((item) => (
|
||||
<Command.Item
|
||||
key={item}
|
||||
value={item}
|
||||
onSelect={() => onSelect(item)}
|
||||
className="flex cursor-pointer items-center gap-2 rounded-xl border border-sidebar-border/55 bg-popover p-2 shadow-xs transition-colors hover:bg-secondary [&>svg]:last:hidden"
|
||||
>
|
||||
<HistoryIcon className="size-4 text-muted-foreground opacity-65" />
|
||||
<span className="min-w-0 flex-1 truncate text-left">{item}</span>
|
||||
<Button
|
||||
type="button"
|
||||
variant="destructive"
|
||||
size="icon-xs"
|
||||
className="translate-x-1 opacity-100 group-hover/command-item:opacity-70 hover:opacity-100"
|
||||
aria-label={t(searchMessages.removeHistory, { query: item })}
|
||||
onClick={(event) => {
|
||||
event.preventDefault()
|
||||
event.stopPropagation()
|
||||
onRemove(item)
|
||||
}}
|
||||
>
|
||||
<XIcon />
|
||||
</Button>
|
||||
</Command.Item>
|
||||
))}
|
||||
</Command.List>
|
||||
</ScrollArea>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
export { SearchProvider, useSearchContext } from "./context"
|
||||
export type { SearchContextValue, SearchProviderProps } from "./context"
|
||||
export { SearchDialog, type SearchDialogProps } from "./dialog"
|
||||
export { searchDialogHandle } from "./handle"
|
||||
export { splitHighlightSegments, type HighlightSegment } from "./highlight"
|
||||
export { SearchTrigger, type SearchTriggerProps } from "./trigger"
|
||||
export type {
|
||||
SearchAdapter,
|
||||
SearchCursor,
|
||||
SearchPage,
|
||||
SearchRequest,
|
||||
SearchResultGroup,
|
||||
SearchResultItem,
|
||||
SearchSelectionEvent,
|
||||
SearchSelectionHandler,
|
||||
} from "./types"
|
||||
@@ -0,0 +1,34 @@
|
||||
import { describe, it } from "vitest"
|
||||
|
||||
import { expectCompleteCatalogs } from "../../../i18n/test-catalogs"
|
||||
import { searchMessages } from "../messages"
|
||||
import { searchCatalogLocales } from "./catalogs"
|
||||
import { messages as de } from "./de"
|
||||
import { messages as en } from "./en"
|
||||
import { messages as es } from "./es"
|
||||
import { messages as fr } from "./fr"
|
||||
import { messages as ja } from "./ja"
|
||||
import { messages as ko } from "./ko"
|
||||
import { messages as zhHans } from "./zh-Hans"
|
||||
import { messages as zhHant } from "./zh-Hant"
|
||||
|
||||
describe("search locale catalogs", () => {
|
||||
it("ship every search message in every built-in locale", () => {
|
||||
expectCompleteCatalogs({
|
||||
catalogs: {
|
||||
de,
|
||||
en,
|
||||
es,
|
||||
fr,
|
||||
ja,
|
||||
ko,
|
||||
"zh-Hans": zhHans,
|
||||
"zh-Hant": zhHant,
|
||||
},
|
||||
locales: searchCatalogLocales,
|
||||
messageIds: Object.values(searchMessages).map(
|
||||
(descriptor) => descriptor.id
|
||||
),
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,10 @@
|
||||
import type { searchMessages } from "../messages"
|
||||
import {
|
||||
blockCatalogLocales,
|
||||
type BlockCatalogLocale,
|
||||
type BlockMessageCatalog,
|
||||
} from "../../../i18n/catalogs"
|
||||
|
||||
export { blockCatalogLocales as searchCatalogLocales }
|
||||
export type SearchCatalogLocale = BlockCatalogLocale
|
||||
export type SearchMessageCatalog = BlockMessageCatalog<typeof searchMessages>
|
||||
@@ -0,0 +1,29 @@
|
||||
import type { SearchMessageCatalog } from "./catalogs"
|
||||
|
||||
export const locale = "de"
|
||||
export const languageTag = "de-DE"
|
||||
export const messages = {
|
||||
"blocks.search.actions.clear": "Suche löschen",
|
||||
"blocks.search.actions.clearHistory": "Verlauf löschen",
|
||||
"blocks.search.actions.close": "Suche schließen",
|
||||
"blocks.search.actions.loadMore": "Mehr laden",
|
||||
"blocks.search.actions.loadingMore": "Wird geladen…",
|
||||
"blocks.search.actions.retry": "Erneut versuchen",
|
||||
"blocks.search.actions.selectResult": "{title} öffnen",
|
||||
"blocks.search.command.description": "Im gesamten Arbeitsbereich suchen",
|
||||
"blocks.search.description": "Inhalte im gesamten Arbeitsbereich finden",
|
||||
"blocks.search.empty.description": "Keine Ergebnisse für „{query}“",
|
||||
"blocks.search.empty.title": "Keine Ergebnisse gefunden",
|
||||
"blocks.search.error.description":
|
||||
"Prüfe deine Verbindung und versuche es erneut.",
|
||||
"blocks.search.error.title": "Suche nach „{query}“ fehlgeschlagen",
|
||||
"blocks.search.history.remove": "„{query}“ aus den letzten Suchen entfernen",
|
||||
"blocks.search.history.title": "Letzte Suchen",
|
||||
"blocks.search.initial.description":
|
||||
"Gib ein Stichwort ein, um den Arbeitsbereich zu durchsuchen.",
|
||||
"blocks.search.initial.title": "Suche starten",
|
||||
"blocks.search.loading": "Suche läuft…",
|
||||
"blocks.search.placeholder": "Arbeitsbereich durchsuchen…",
|
||||
"blocks.search.results.count": "{count} Ergebnisse",
|
||||
"blocks.search.title": "Suche",
|
||||
} as const satisfies SearchMessageCatalog
|
||||
@@ -0,0 +1,28 @@
|
||||
import type { SearchMessageCatalog } from "./catalogs"
|
||||
|
||||
export const locale = "en"
|
||||
export const languageTag = "en-US"
|
||||
export const messages = {
|
||||
"blocks.search.actions.clear": "Clear query",
|
||||
"blocks.search.actions.clearHistory": "Clear history",
|
||||
"blocks.search.actions.close": "Close search",
|
||||
"blocks.search.actions.loadMore": "Load more",
|
||||
"blocks.search.actions.loadingMore": "Loading…",
|
||||
"blocks.search.actions.retry": "Try again",
|
||||
"blocks.search.actions.selectResult": "Open {title}",
|
||||
"blocks.search.command.description": "Search across your workspace",
|
||||
"blocks.search.description": "Find content across your workspace",
|
||||
"blocks.search.empty.description": "No results for “{query}”",
|
||||
"blocks.search.empty.title": "No results found",
|
||||
"blocks.search.error.description": "Check your connection and try again.",
|
||||
"blocks.search.error.title": "Could not search for “{query}”",
|
||||
"blocks.search.history.remove": "Remove “{query}” from recent searches",
|
||||
"blocks.search.history.title": "Recent searches",
|
||||
"blocks.search.initial.description":
|
||||
"Enter a keyword to search your workspace.",
|
||||
"blocks.search.initial.title": "Start searching",
|
||||
"blocks.search.loading": "Searching…",
|
||||
"blocks.search.placeholder": "Search your workspace…",
|
||||
"blocks.search.results.count": "{count} results",
|
||||
"blocks.search.title": "Search",
|
||||
} as const satisfies SearchMessageCatalog
|
||||
@@ -0,0 +1,30 @@
|
||||
import type { SearchMessageCatalog } from "./catalogs"
|
||||
|
||||
export const locale = "es"
|
||||
export const languageTag = "es-ES"
|
||||
export const messages = {
|
||||
"blocks.search.actions.clear": "Borrar búsqueda",
|
||||
"blocks.search.actions.clearHistory": "Borrar historial",
|
||||
"blocks.search.actions.close": "Cerrar búsqueda",
|
||||
"blocks.search.actions.loadMore": "Cargar más",
|
||||
"blocks.search.actions.loadingMore": "Cargando…",
|
||||
"blocks.search.actions.retry": "Intentar de nuevo",
|
||||
"blocks.search.actions.selectResult": "Abrir {title}",
|
||||
"blocks.search.command.description": "Buscar en todo tu espacio de trabajo",
|
||||
"blocks.search.description":
|
||||
"Encuentra contenido en todo tu espacio de trabajo",
|
||||
"blocks.search.empty.description": "No hay resultados para «{query}»",
|
||||
"blocks.search.empty.title": "No se encontraron resultados",
|
||||
"blocks.search.error.description":
|
||||
"Comprueba tu conexión e inténtalo de nuevo.",
|
||||
"blocks.search.error.title": "No se pudo buscar «{query}»",
|
||||
"blocks.search.history.remove": "Quitar «{query}» de las búsquedas recientes",
|
||||
"blocks.search.history.title": "Búsquedas recientes",
|
||||
"blocks.search.initial.description":
|
||||
"Introduce una palabra clave para buscar en tu espacio de trabajo.",
|
||||
"blocks.search.initial.title": "Empezar a buscar",
|
||||
"blocks.search.loading": "Buscando…",
|
||||
"blocks.search.placeholder": "Buscar en tu espacio de trabajo…",
|
||||
"blocks.search.results.count": "{count} resultados",
|
||||
"blocks.search.title": "Buscar",
|
||||
} as const satisfies SearchMessageCatalog
|
||||
@@ -0,0 +1,30 @@
|
||||
import type { SearchMessageCatalog } from "./catalogs"
|
||||
|
||||
export const locale = "fr"
|
||||
export const languageTag = "fr-FR"
|
||||
export const messages = {
|
||||
"blocks.search.actions.clear": "Effacer la recherche",
|
||||
"blocks.search.actions.clearHistory": "Effacer l’historique",
|
||||
"blocks.search.actions.close": "Fermer la recherche",
|
||||
"blocks.search.actions.loadMore": "Charger plus",
|
||||
"blocks.search.actions.loadingMore": "Chargement…",
|
||||
"blocks.search.actions.retry": "Réessayer",
|
||||
"blocks.search.actions.selectResult": "Ouvrir {title}",
|
||||
"blocks.search.command.description":
|
||||
"Rechercher dans tout l’espace de travail",
|
||||
"blocks.search.description":
|
||||
"Trouver du contenu dans votre espace de travail",
|
||||
"blocks.search.empty.description": "Aucun résultat pour « {query} »",
|
||||
"blocks.search.empty.title": "Aucun résultat trouvé",
|
||||
"blocks.search.error.description": "Vérifiez votre connexion et réessayez.",
|
||||
"blocks.search.error.title": "Impossible de rechercher « {query} »",
|
||||
"blocks.search.history.remove": "Retirer « {query} » des recherches récentes",
|
||||
"blocks.search.history.title": "Recherches récentes",
|
||||
"blocks.search.initial.description":
|
||||
"Saisissez un mot-clé pour rechercher dans votre espace de travail.",
|
||||
"blocks.search.initial.title": "Commencer la recherche",
|
||||
"blocks.search.loading": "Recherche…",
|
||||
"blocks.search.placeholder": "Rechercher dans l’espace de travail…",
|
||||
"blocks.search.results.count": "{count} résultats",
|
||||
"blocks.search.title": "Rechercher",
|
||||
} as const satisfies SearchMessageCatalog
|
||||
@@ -0,0 +1,28 @@
|
||||
import type { SearchMessageCatalog } from "./catalogs"
|
||||
|
||||
export const locale = "ja"
|
||||
export const languageTag = "ja-JP"
|
||||
export const messages = {
|
||||
"blocks.search.actions.clear": "検索をクリア",
|
||||
"blocks.search.actions.clearHistory": "履歴を削除",
|
||||
"blocks.search.actions.close": "検索を閉じる",
|
||||
"blocks.search.actions.loadMore": "さらに読み込む",
|
||||
"blocks.search.actions.loadingMore": "読み込み中…",
|
||||
"blocks.search.actions.retry": "再試行",
|
||||
"blocks.search.actions.selectResult": "{title} を開く",
|
||||
"blocks.search.command.description": "ワークスペース全体を検索",
|
||||
"blocks.search.description": "ワークスペース全体からコンテンツを探す",
|
||||
"blocks.search.empty.description": "「{query}」の結果はありません",
|
||||
"blocks.search.empty.title": "結果が見つかりません",
|
||||
"blocks.search.error.description": "接続を確認して、もう一度お試しください。",
|
||||
"blocks.search.error.title": "「{query}」を検索できませんでした",
|
||||
"blocks.search.history.remove": "最近の検索から「{query}」を削除",
|
||||
"blocks.search.history.title": "最近の検索",
|
||||
"blocks.search.initial.description":
|
||||
"キーワードを入力してワークスペースを検索します。",
|
||||
"blocks.search.initial.title": "検索を始める",
|
||||
"blocks.search.loading": "検索中…",
|
||||
"blocks.search.placeholder": "ワークスペースを検索…",
|
||||
"blocks.search.results.count": "{count} 件の結果",
|
||||
"blocks.search.title": "検索",
|
||||
} as const satisfies SearchMessageCatalog
|
||||
@@ -0,0 +1,28 @@
|
||||
import type { SearchMessageCatalog } from "./catalogs"
|
||||
|
||||
export const locale = "ko"
|
||||
export const languageTag = "ko-KR"
|
||||
export const messages = {
|
||||
"blocks.search.actions.clear": "검색어 지우기",
|
||||
"blocks.search.actions.clearHistory": "기록 지우기",
|
||||
"blocks.search.actions.close": "검색 닫기",
|
||||
"blocks.search.actions.loadMore": "더 불러오기",
|
||||
"blocks.search.actions.loadingMore": "불러오는 중…",
|
||||
"blocks.search.actions.retry": "다시 시도",
|
||||
"blocks.search.actions.selectResult": "{title} 열기",
|
||||
"blocks.search.command.description": "작업 공간 전체 검색",
|
||||
"blocks.search.description": "작업 공간 전체에서 콘텐츠 찾기",
|
||||
"blocks.search.empty.description": "“{query}”에 대한 결과가 없습니다",
|
||||
"blocks.search.empty.title": "결과를 찾을 수 없습니다",
|
||||
"blocks.search.error.description": "연결을 확인한 후 다시 시도하세요.",
|
||||
"blocks.search.error.title": "“{query}”을(를) 검색할 수 없습니다",
|
||||
"blocks.search.history.remove": "최근 검색에서 “{query}” 제거",
|
||||
"blocks.search.history.title": "최근 검색",
|
||||
"blocks.search.initial.description":
|
||||
"키워드를 입력하여 작업 공간을 검색하세요.",
|
||||
"blocks.search.initial.title": "검색 시작",
|
||||
"blocks.search.loading": "검색 중…",
|
||||
"blocks.search.placeholder": "작업 공간 검색…",
|
||||
"blocks.search.results.count": "결과 {count}개",
|
||||
"blocks.search.title": "검색",
|
||||
} as const satisfies SearchMessageCatalog
|
||||
@@ -0,0 +1,27 @@
|
||||
import type { SearchMessageCatalog } from "./catalogs"
|
||||
|
||||
export const locale = "zh-Hans"
|
||||
export const languageTag = "zh-CN"
|
||||
export const messages = {
|
||||
"blocks.search.actions.clear": "清除查询",
|
||||
"blocks.search.actions.clearHistory": "清空历史",
|
||||
"blocks.search.actions.close": "关闭搜索",
|
||||
"blocks.search.actions.loadMore": "加载更多",
|
||||
"blocks.search.actions.loadingMore": "正在加载…",
|
||||
"blocks.search.actions.retry": "重试",
|
||||
"blocks.search.actions.selectResult": "打开 {title}",
|
||||
"blocks.search.command.description": "搜索整个工作区",
|
||||
"blocks.search.description": "在工作区中查找内容",
|
||||
"blocks.search.empty.description": "没有找到与“{query}”相关的内容",
|
||||
"blocks.search.empty.title": "未找到结果",
|
||||
"blocks.search.error.description": "请检查网络连接后重试。",
|
||||
"blocks.search.error.title": "无法搜索“{query}”",
|
||||
"blocks.search.history.remove": "从搜索历史中删除“{query}”",
|
||||
"blocks.search.history.title": "搜索历史",
|
||||
"blocks.search.initial.description": "输入关键词以搜索工作区。",
|
||||
"blocks.search.initial.title": "开始搜索",
|
||||
"blocks.search.loading": "正在搜索…",
|
||||
"blocks.search.placeholder": "搜索工作区…",
|
||||
"blocks.search.results.count": "{count} 条结果",
|
||||
"blocks.search.title": "搜索",
|
||||
} as const satisfies SearchMessageCatalog
|
||||
@@ -0,0 +1,27 @@
|
||||
import type { SearchMessageCatalog } from "./catalogs"
|
||||
|
||||
export const locale = "zh-Hant"
|
||||
export const languageTag = "zh-TW"
|
||||
export const messages = {
|
||||
"blocks.search.actions.clear": "清除搜尋",
|
||||
"blocks.search.actions.clearHistory": "清除記錄",
|
||||
"blocks.search.actions.close": "關閉搜尋",
|
||||
"blocks.search.actions.loadMore": "載入更多",
|
||||
"blocks.search.actions.loadingMore": "載入中…",
|
||||
"blocks.search.actions.retry": "再試一次",
|
||||
"blocks.search.actions.selectResult": "開啟 {title}",
|
||||
"blocks.search.command.description": "搜尋整個工作區",
|
||||
"blocks.search.description": "在工作區中尋找內容",
|
||||
"blocks.search.empty.description": "沒有「{query}」的結果",
|
||||
"blocks.search.empty.title": "找不到結果",
|
||||
"blocks.search.error.description": "請檢查網絡連線後再試一次。",
|
||||
"blocks.search.error.title": "無法搜尋「{query}」",
|
||||
"blocks.search.history.remove": "從最近搜尋中移除「{query}」",
|
||||
"blocks.search.history.title": "最近搜尋",
|
||||
"blocks.search.initial.description": "輸入關鍵字以搜尋工作區。",
|
||||
"blocks.search.initial.title": "開始搜尋",
|
||||
"blocks.search.loading": "搜尋中…",
|
||||
"blocks.search.placeholder": "搜尋工作區…",
|
||||
"blocks.search.results.count": "{count} 項結果",
|
||||
"blocks.search.title": "搜尋",
|
||||
} as const satisfies SearchMessageCatalog
|
||||
@@ -0,0 +1,64 @@
|
||||
import type { MessageDescriptor } from "@workspace/i18n"
|
||||
|
||||
export const searchMessages = {
|
||||
clear: { id: "blocks.search.actions.clear", message: "Clear query" },
|
||||
clearHistory: {
|
||||
id: "blocks.search.actions.clearHistory",
|
||||
message: "Clear history",
|
||||
},
|
||||
close: { id: "blocks.search.actions.close", message: "Close search" },
|
||||
loadMore: { id: "blocks.search.actions.loadMore", message: "Load more" },
|
||||
loadingMore: {
|
||||
id: "blocks.search.actions.loadingMore",
|
||||
message: "Loading…",
|
||||
},
|
||||
retry: { id: "blocks.search.actions.retry", message: "Try again" },
|
||||
selectResult: {
|
||||
id: "blocks.search.actions.selectResult",
|
||||
message: "Open {title}",
|
||||
},
|
||||
commandDescription: {
|
||||
id: "blocks.search.command.description",
|
||||
message: "Search across your workspace",
|
||||
},
|
||||
description: {
|
||||
id: "blocks.search.description",
|
||||
message: "Find content across your workspace",
|
||||
},
|
||||
emptyDescription: {
|
||||
id: "blocks.search.empty.description",
|
||||
message: "No results for “{query}”",
|
||||
},
|
||||
emptyTitle: { id: "blocks.search.empty.title", message: "No results found" },
|
||||
errorDescription: {
|
||||
id: "blocks.search.error.description",
|
||||
message: "Check your connection and try again.",
|
||||
},
|
||||
errorTitle: {
|
||||
id: "blocks.search.error.title",
|
||||
message: "Could not search for “{query}”",
|
||||
},
|
||||
history: { id: "blocks.search.history.title", message: "Recent searches" },
|
||||
initialDescription: {
|
||||
id: "blocks.search.initial.description",
|
||||
message: "Enter a keyword to search your workspace.",
|
||||
},
|
||||
initialTitle: {
|
||||
id: "blocks.search.initial.title",
|
||||
message: "Start searching",
|
||||
},
|
||||
loading: { id: "blocks.search.loading", message: "Searching…" },
|
||||
placeholder: {
|
||||
id: "blocks.search.placeholder",
|
||||
message: "Search your workspace…",
|
||||
},
|
||||
removeHistory: {
|
||||
id: "blocks.search.history.remove",
|
||||
message: "Remove “{query}” from recent searches",
|
||||
},
|
||||
resultCount: {
|
||||
id: "blocks.search.results.count",
|
||||
message: "{count} results",
|
||||
},
|
||||
title: { id: "blocks.search.title", message: "Search" },
|
||||
} as const satisfies Record<string, MessageDescriptor>
|
||||
@@ -0,0 +1,173 @@
|
||||
import * as React from "react"
|
||||
import { Command } from "cmdk"
|
||||
import { useTranslate } from "@workspace/i18n"
|
||||
import { Button } from "@workspace/ui/components/button"
|
||||
import { ScrollArea } from "@workspace/ui/components/scroll-area"
|
||||
import {
|
||||
CornerDownLeftIcon,
|
||||
FileTextIcon,
|
||||
LoaderCircleIcon,
|
||||
} from "lucide-react"
|
||||
|
||||
import { splitHighlightSegments } from "./highlight"
|
||||
import { searchMessages } from "./messages"
|
||||
import type { SearchResultGroup, SearchResultItem } from "./types"
|
||||
|
||||
export interface SearchResultsProps {
|
||||
groups: readonly SearchResultGroup[]
|
||||
hasMore: boolean
|
||||
isLoadingMore: boolean
|
||||
onLoadMore: VoidFunction
|
||||
onSelect: (item: SearchResultItem) => void
|
||||
query: string
|
||||
}
|
||||
|
||||
export function SearchResults({
|
||||
groups,
|
||||
hasMore,
|
||||
isLoadingMore,
|
||||
onLoadMore,
|
||||
onSelect,
|
||||
query,
|
||||
}: SearchResultsProps) {
|
||||
const t = useTranslate()
|
||||
|
||||
return (
|
||||
<ScrollArea className="h-[min(65dvh,36rem)] p-4">
|
||||
<Command.List className="**:[[cmdk-list-sizer]]:space-y-6">
|
||||
{groups.map((group) => {
|
||||
if (group.items.length === 0) return null
|
||||
|
||||
return (
|
||||
<Command.Group
|
||||
key={group.id}
|
||||
className="space-y-2 **:[[cmdk-group-heading]]:sticky **:[[cmdk-group-heading]]:top-0 **:[[cmdk-group-heading]]:z-2 **:[[cmdk-group-heading]]:flex **:[[cmdk-group-heading]]:items-center **:[[cmdk-group-heading]]:gap-2 **:[[cmdk-group-heading]]:bg-popover/90 **:[[cmdk-group-heading]]:py-2 **:[[cmdk-group-heading]]:backdrop-blur-xs **:[[cmdk-group-items]]:space-y-1"
|
||||
heading={
|
||||
<>
|
||||
{group.icon}
|
||||
<span className="font-semibold">{group.label}</span>
|
||||
<span className="ms-auto text-xs text-muted-foreground">
|
||||
{t(searchMessages.resultCount, {
|
||||
count: group.total ?? group.items.length,
|
||||
})}
|
||||
</span>
|
||||
</>
|
||||
}
|
||||
>
|
||||
{group.items.map((item) => (
|
||||
<SearchResultRow
|
||||
key={`${group.id}:${item.id}`}
|
||||
item={item}
|
||||
onSelect={onSelect}
|
||||
query={query}
|
||||
/>
|
||||
))}
|
||||
</Command.Group>
|
||||
)
|
||||
})}
|
||||
{hasMore && (
|
||||
<div className="flex justify-center p-2">
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
disabled={isLoadingMore}
|
||||
onClick={onLoadMore}
|
||||
>
|
||||
{isLoadingMore && <LoaderCircleIcon className="animate-spin" />}
|
||||
{isLoadingMore
|
||||
? t(searchMessages.loadingMore)
|
||||
: t(searchMessages.loadMore)}
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</Command.List>
|
||||
</ScrollArea>
|
||||
)
|
||||
}
|
||||
|
||||
function SearchResultRow({
|
||||
item,
|
||||
onSelect,
|
||||
query,
|
||||
}: {
|
||||
item: SearchResultItem
|
||||
onSelect: (item: SearchResultItem) => void
|
||||
query: string
|
||||
}) {
|
||||
const t = useTranslate()
|
||||
const value = [
|
||||
item.id,
|
||||
item.title,
|
||||
item.description,
|
||||
...(item.meta ?? []),
|
||||
...(item.keywords ?? []),
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(" ")
|
||||
|
||||
return (
|
||||
<Command.Item
|
||||
value={value}
|
||||
disabled={item.disabled}
|
||||
onSelect={() => onSelect(item)}
|
||||
className="group/search-result flex items-center gap-3 rounded-xl border border-border/55 bg-background p-3 shadow-xs transition-colors hover:bg-muted"
|
||||
aria-label={t(searchMessages.selectResult, { title: item.title })}
|
||||
>
|
||||
<ResultMedia item={item} />
|
||||
<span className="min-w-0 flex-1 text-left">
|
||||
<span className="block truncate">
|
||||
<HighlightedText text={item.title} query={query} />
|
||||
</span>
|
||||
{item.description && (
|
||||
<span className="block truncate text-sm text-muted-foreground">
|
||||
<HighlightedText text={item.description} query={query} />
|
||||
</span>
|
||||
)}
|
||||
{item.meta?.map((line, index) => (
|
||||
<span
|
||||
key={`${line}:${index}`}
|
||||
className="block truncate text-xs text-muted-foreground"
|
||||
>
|
||||
<HighlightedText text={line} query={query} />
|
||||
</span>
|
||||
))}
|
||||
</span>
|
||||
<CornerDownLeftIcon className="shrink-0 text-primary opacity-0 transition-opacity group-hover/search-result:opacity-100 group-data-selected/search-result:opacity-100" />
|
||||
</Command.Item>
|
||||
)
|
||||
}
|
||||
|
||||
function ResultMedia({ item }: { item: SearchResultItem }) {
|
||||
if (item.image) {
|
||||
return (
|
||||
<img
|
||||
src={item.image.src}
|
||||
alt={item.image.alt ?? ""}
|
||||
loading="lazy"
|
||||
className="size-10 shrink-0 self-start rounded-xl border bg-muted object-cover"
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<span className="grid size-10 shrink-0 place-items-center self-start rounded-xl bg-muted text-muted-foreground">
|
||||
{item.icon ?? <FileTextIcon className="size-5" />}
|
||||
</span>
|
||||
)
|
||||
}
|
||||
|
||||
function HighlightedText({ text, query }: { text: string; query: string }) {
|
||||
return splitHighlightSegments(text, query).map((segment, index) =>
|
||||
segment.highlighted ? (
|
||||
<mark
|
||||
key={index}
|
||||
className="rounded-sm bg-primary/15 px-0.5 text-primary"
|
||||
>
|
||||
{segment.text}
|
||||
</mark>
|
||||
) : (
|
||||
<React.Fragment key={index}>{segment.text}</React.Fragment>
|
||||
)
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,140 @@
|
||||
// @vitest-environment jsdom
|
||||
|
||||
import {
|
||||
cleanup,
|
||||
fireEvent,
|
||||
render,
|
||||
screen,
|
||||
waitFor,
|
||||
} from "@testing-library/react"
|
||||
import { I18nProvider } from "@workspace/i18n"
|
||||
import { afterEach, describe, expect, it, vi } from "vitest"
|
||||
|
||||
import { SearchProvider } from "./context"
|
||||
import { SearchDialog } from "./dialog"
|
||||
import { searchDialogHandle } from "./handle"
|
||||
import { splitHighlightSegments } from "./highlight"
|
||||
import { messages as englishMessages } from "./locales/en"
|
||||
import type { SearchAdapter } from "./types"
|
||||
|
||||
class ResizeObserverStub {
|
||||
disconnect() {}
|
||||
observe() {}
|
||||
unobserve() {}
|
||||
}
|
||||
|
||||
vi.stubGlobal("ResizeObserver", ResizeObserverStub)
|
||||
Object.defineProperty(Element.prototype, "getAnimations", {
|
||||
configurable: true,
|
||||
value: () => [],
|
||||
})
|
||||
|
||||
function renderSearch({
|
||||
adapter,
|
||||
onSelect,
|
||||
}: {
|
||||
adapter: SearchAdapter
|
||||
onSelect?: (event: { item: { id: string }; query: string }) => void
|
||||
}) {
|
||||
return render(
|
||||
<I18nProvider locale="en" catalogs={{ en: englishMessages }}>
|
||||
<SearchProvider
|
||||
adapter={adapter}
|
||||
historyStorageKey={false}
|
||||
onSelect={onSelect}
|
||||
>
|
||||
<SearchDialog hotkey={false} />
|
||||
</SearchProvider>
|
||||
</I18nProvider>
|
||||
)
|
||||
}
|
||||
|
||||
describe("search blocks", () => {
|
||||
afterEach(() => {
|
||||
cleanup()
|
||||
window.localStorage.clear()
|
||||
})
|
||||
|
||||
it("delegates searching, pagination, and selection to the provider", async () => {
|
||||
const adapter: SearchAdapter = {
|
||||
search: vi.fn(async ({ cursor, query }) => {
|
||||
if (cursor) {
|
||||
return {
|
||||
groups: [
|
||||
{
|
||||
id: "people",
|
||||
label: "People",
|
||||
items: [{ id: "2", title: "Grace Hopper" }],
|
||||
},
|
||||
],
|
||||
nextCursor: null,
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
groups: [
|
||||
{
|
||||
id: "people",
|
||||
label: "People",
|
||||
total: 2,
|
||||
items: [
|
||||
{
|
||||
id: "1",
|
||||
title: "Ada Lovelace",
|
||||
description: `Matched ${query}`,
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
nextCursor: "next-page",
|
||||
}
|
||||
}),
|
||||
}
|
||||
const onSelect = vi.fn()
|
||||
renderSearch({ adapter, onSelect })
|
||||
|
||||
searchDialogHandle.open(null)
|
||||
const input = await screen.findByPlaceholderText("Search your workspace…")
|
||||
fireEvent.change(input, { target: { value: "ada" } })
|
||||
|
||||
await waitFor(() =>
|
||||
expect(adapter.search).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ query: "ada" })
|
||||
)
|
||||
)
|
||||
await waitFor(() =>
|
||||
expect(screen.getByRole("dialog").textContent).toContain("Ada Lovelace")
|
||||
)
|
||||
|
||||
fireEvent.click(screen.getByRole("button", { name: "Load more" }))
|
||||
await waitFor(() =>
|
||||
expect(screen.getByRole("dialog").textContent).toContain("Grace Hopper")
|
||||
)
|
||||
expect(adapter.search).toHaveBeenLastCalledWith(
|
||||
expect.objectContaining({ cursor: "next-page", query: "ada" })
|
||||
)
|
||||
|
||||
fireEvent.click(screen.getByRole("option", { name: "Open Ada Lovelace" }))
|
||||
await waitFor(() =>
|
||||
expect(onSelect).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
item: expect.objectContaining({ id: "1" }),
|
||||
query: "ada",
|
||||
})
|
||||
)
|
||||
)
|
||||
})
|
||||
|
||||
it("splits case-insensitive literal query matches for highlighting", () => {
|
||||
expect(splitHighlightSegments("Ada ADA Lovelace", "ada")).toEqual([
|
||||
{ highlighted: true, text: "Ada" },
|
||||
{ highlighted: false, text: " " },
|
||||
{ highlighted: true, text: "ADA" },
|
||||
{ highlighted: false, text: " Lovelace" },
|
||||
])
|
||||
expect(splitHighlightSegments("price [usd]", "[usd]")).toEqual([
|
||||
{ highlighted: false, text: "price " },
|
||||
{ highlighted: true, text: "[usd]" },
|
||||
])
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,83 @@
|
||||
import * as React from "react"
|
||||
import { useTranslate } from "@workspace/i18n"
|
||||
import {
|
||||
Empty,
|
||||
EmptyDescription,
|
||||
EmptyHeader,
|
||||
EmptyMedia,
|
||||
EmptyTitle,
|
||||
} from "@workspace/ui/components/empty"
|
||||
import { CircleAlertIcon, LoaderCircleIcon, SearchIcon } from "lucide-react"
|
||||
|
||||
import { searchMessages } from "./messages"
|
||||
|
||||
export function SearchInitial() {
|
||||
const t = useTranslate()
|
||||
return (
|
||||
<EmptyState
|
||||
icon={<SearchIcon />}
|
||||
title={t(searchMessages.initialTitle)}
|
||||
description={t(searchMessages.initialDescription)}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export function SearchLoading() {
|
||||
const t = useTranslate()
|
||||
return (
|
||||
<EmptyState
|
||||
icon={<LoaderCircleIcon className="animate-spin" />}
|
||||
description={t(searchMessages.loading)}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export function SearchEmpty({ query }: { query: string }) {
|
||||
const t = useTranslate()
|
||||
return (
|
||||
<EmptyState
|
||||
icon={<SearchIcon />}
|
||||
title={t(searchMessages.emptyTitle)}
|
||||
description={t(searchMessages.emptyDescription, { query })}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export function SearchError({ query }: { query: string }) {
|
||||
const t = useTranslate()
|
||||
return (
|
||||
<EmptyState
|
||||
icon={<CircleAlertIcon />}
|
||||
title={t(searchMessages.errorTitle, { query })}
|
||||
description={t(searchMessages.errorDescription)}
|
||||
destructive
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function EmptyState({
|
||||
description,
|
||||
destructive = false,
|
||||
icon,
|
||||
title,
|
||||
}: {
|
||||
description: React.ReactNode
|
||||
destructive?: boolean
|
||||
icon: React.ReactNode
|
||||
title?: React.ReactNode
|
||||
}) {
|
||||
return (
|
||||
<Empty className="min-h-72 border-0 py-16">
|
||||
<EmptyHeader>
|
||||
<EmptyMedia
|
||||
variant="icon"
|
||||
className={destructive ? "text-destructive" : undefined}
|
||||
>
|
||||
{icon}
|
||||
</EmptyMedia>
|
||||
{title && <EmptyTitle>{title}</EmptyTitle>}
|
||||
<EmptyDescription>{description}</EmptyDescription>
|
||||
</EmptyHeader>
|
||||
</Empty>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
import * as React from "react"
|
||||
import { DialogTrigger } from "@workspace/ui/components/dialog"
|
||||
|
||||
import { searchDialogHandle } from "./handle"
|
||||
|
||||
export type SearchTriggerProps = Omit<
|
||||
React.ComponentProps<typeof DialogTrigger>,
|
||||
"handle"
|
||||
>
|
||||
|
||||
/** Opens the nearest mounted SearchDialog through its shared dialog handle. */
|
||||
export function SearchTrigger(props: SearchTriggerProps) {
|
||||
return <DialogTrigger {...props} handle={searchDialogHandle} />
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
import type * as React from "react"
|
||||
|
||||
export type SearchCursor = number | string
|
||||
|
||||
/** A resource returned by a host application's global search implementation. */
|
||||
export interface SearchResultItem {
|
||||
/** Stable only within its group; used to merge paginated results. */
|
||||
id: string
|
||||
/** Primary text shown in the result row. */
|
||||
title: string
|
||||
description?: string
|
||||
disabled?: boolean
|
||||
/** Optional small visual. An image takes precedence when both are supplied. */
|
||||
icon?: React.ReactNode
|
||||
image?: {
|
||||
alt?: string
|
||||
src: string
|
||||
}
|
||||
/** Extra searchable text, not rendered by the default result row. */
|
||||
keywords?: readonly string[]
|
||||
/** Secondary lines rendered below the title. */
|
||||
meta?: readonly string[]
|
||||
/** Opaque application data consumed by the selection handler. */
|
||||
payload?: unknown
|
||||
}
|
||||
|
||||
/** A logical result source, such as products, projects, users, or documentation. */
|
||||
export interface SearchResultGroup {
|
||||
icon?: React.ReactNode
|
||||
id: string
|
||||
items: readonly SearchResultItem[]
|
||||
label: string
|
||||
total?: number
|
||||
}
|
||||
|
||||
export interface SearchPage {
|
||||
groups: readonly SearchResultGroup[]
|
||||
/** Omit or return null when there are no more results to load. */
|
||||
nextCursor?: SearchCursor | null
|
||||
}
|
||||
|
||||
export interface SearchRequest {
|
||||
cursor?: SearchCursor
|
||||
query: string
|
||||
signal: AbortSignal
|
||||
}
|
||||
|
||||
/**
|
||||
* The only data dependency of the search blocks. Implement this in the host
|
||||
* application with its own database, HTTP client, command registry, or index.
|
||||
*/
|
||||
export interface SearchAdapter {
|
||||
search(request: SearchRequest): Promise<SearchPage>
|
||||
}
|
||||
|
||||
export interface SearchSelectionEvent {
|
||||
item: SearchResultItem
|
||||
query: string
|
||||
}
|
||||
|
||||
export type SearchSelectionHandler = (event: SearchSelectionEvent) => void
|
||||
@@ -0,0 +1,101 @@
|
||||
import * as React from "react"
|
||||
|
||||
interface UseSearchHistoryOptions {
|
||||
limit: number
|
||||
storageKey?: string
|
||||
}
|
||||
|
||||
export function useSearchHistory({
|
||||
limit,
|
||||
storageKey,
|
||||
}: UseSearchHistoryOptions) {
|
||||
const [items, setItems] = React.useState<readonly string[]>(() =>
|
||||
readSearchHistory(storageKey, limit)
|
||||
)
|
||||
|
||||
React.useEffect(() => {
|
||||
const syncFromStorage = () => setItems(readSearchHistory(storageKey, limit))
|
||||
const handleStorage = (event: StorageEvent) => {
|
||||
if (event.key === storageKey) syncFromStorage()
|
||||
}
|
||||
|
||||
syncFromStorage()
|
||||
if (!storageKey || typeof window === "undefined") return
|
||||
|
||||
window.addEventListener("storage", handleStorage)
|
||||
return () => window.removeEventListener("storage", handleStorage)
|
||||
}, [limit, storageKey])
|
||||
|
||||
const update = React.useCallback(
|
||||
(updater: (current: readonly string[]) => readonly string[]) => {
|
||||
setItems((current) => {
|
||||
const next = updater(current).slice(0, limit)
|
||||
writeSearchHistory(storageKey, next)
|
||||
return next
|
||||
})
|
||||
},
|
||||
[limit, storageKey]
|
||||
)
|
||||
|
||||
const add = React.useCallback(
|
||||
(query: string) => {
|
||||
const normalizedQuery = query.trim()
|
||||
if (!normalizedQuery) return
|
||||
|
||||
update((current) => [
|
||||
normalizedQuery,
|
||||
...current.filter((item) => item !== normalizedQuery),
|
||||
])
|
||||
},
|
||||
[update]
|
||||
)
|
||||
|
||||
const remove = React.useCallback(
|
||||
(query: string) =>
|
||||
update((current) => current.filter((item) => item !== query)),
|
||||
[update]
|
||||
)
|
||||
const clear = React.useCallback(() => update(() => []), [update])
|
||||
|
||||
return { add, clear, items, remove }
|
||||
}
|
||||
|
||||
function readSearchHistory(storageKey: string | undefined, limit: number) {
|
||||
if (!storageKey || typeof window === "undefined") return []
|
||||
|
||||
try {
|
||||
const stored = window.localStorage.getItem(storageKey)
|
||||
if (!stored) return []
|
||||
|
||||
const parsed: unknown = JSON.parse(stored)
|
||||
if (!Array.isArray(parsed)) return []
|
||||
|
||||
return Array.from(
|
||||
new Set(
|
||||
parsed
|
||||
.filter((item): item is string => typeof item === "string")
|
||||
.map((item) => item.trim())
|
||||
.filter(Boolean)
|
||||
)
|
||||
).slice(0, limit)
|
||||
} catch {
|
||||
return []
|
||||
}
|
||||
}
|
||||
|
||||
function writeSearchHistory(
|
||||
storageKey: string | undefined,
|
||||
items: readonly string[]
|
||||
) {
|
||||
if (!storageKey || typeof window === "undefined") return
|
||||
|
||||
try {
|
||||
if (items.length === 0) {
|
||||
window.localStorage.removeItem(storageKey)
|
||||
} else {
|
||||
window.localStorage.setItem(storageKey, JSON.stringify(items))
|
||||
}
|
||||
} catch {
|
||||
// Storage can be disabled or unavailable in private browsing contexts.
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,135 @@
|
||||
import * as React from "react"
|
||||
|
||||
import type { SearchCursor, SearchPage, SearchResultGroup } from "./types"
|
||||
import { useSearchContext } from "./context"
|
||||
|
||||
interface UseSearchOptions {
|
||||
active: boolean
|
||||
query: string
|
||||
}
|
||||
|
||||
interface SearchState {
|
||||
error: unknown
|
||||
isLoading: boolean
|
||||
isLoadingMore: boolean
|
||||
pages: readonly SearchPage[]
|
||||
}
|
||||
|
||||
const INITIAL_STATE: SearchState = {
|
||||
error: null,
|
||||
isLoading: false,
|
||||
isLoadingMore: false,
|
||||
pages: [],
|
||||
}
|
||||
|
||||
export function useSearch({ active, query }: UseSearchOptions) {
|
||||
const { adapter } = useSearchContext()
|
||||
const normalizedQuery = query.trim()
|
||||
const [state, setState] = React.useState<SearchState>(INITIAL_STATE)
|
||||
const [retryKey, setRetryKey] = React.useState(0)
|
||||
const requestId = React.useRef(0)
|
||||
|
||||
React.useEffect(() => {
|
||||
if (!active || !normalizedQuery) {
|
||||
setState(INITIAL_STATE)
|
||||
return
|
||||
}
|
||||
|
||||
const controller = new AbortController()
|
||||
const currentRequest = ++requestId.current
|
||||
setState({ error: null, isLoading: true, isLoadingMore: false, pages: [] })
|
||||
|
||||
void adapter
|
||||
.search({ query: normalizedQuery, signal: controller.signal })
|
||||
.then((page) => {
|
||||
if (currentRequest !== requestId.current || controller.signal.aborted)
|
||||
return
|
||||
setState({
|
||||
error: null,
|
||||
isLoading: false,
|
||||
isLoadingMore: false,
|
||||
pages: [page],
|
||||
})
|
||||
})
|
||||
.catch((error: unknown) => {
|
||||
if (controller.signal.aborted || currentRequest !== requestId.current)
|
||||
return
|
||||
setState({ error, isLoading: false, isLoadingMore: false, pages: [] })
|
||||
})
|
||||
|
||||
return () => controller.abort()
|
||||
}, [active, adapter, normalizedQuery, retryKey])
|
||||
|
||||
const groups = React.useMemo(() => mergeGroups(state.pages), [state.pages])
|
||||
const cursor = state.pages.at(-1)?.nextCursor ?? null
|
||||
|
||||
const loadMore = React.useCallback(() => {
|
||||
if (!active || !normalizedQuery || cursor === null || state.isLoadingMore)
|
||||
return
|
||||
|
||||
const controller = new AbortController()
|
||||
const currentRequest = ++requestId.current
|
||||
setState((current) => ({ ...current, error: null, isLoadingMore: true }))
|
||||
|
||||
void adapter
|
||||
.search({
|
||||
cursor,
|
||||
query: normalizedQuery,
|
||||
signal: controller.signal,
|
||||
})
|
||||
.then((page) => {
|
||||
if (currentRequest !== requestId.current || controller.signal.aborted)
|
||||
return
|
||||
setState((current) => ({
|
||||
error: null,
|
||||
isLoading: false,
|
||||
isLoadingMore: false,
|
||||
pages: [...current.pages, page],
|
||||
}))
|
||||
})
|
||||
.catch((error: unknown) => {
|
||||
if (currentRequest !== requestId.current || controller.signal.aborted)
|
||||
return
|
||||
setState((current) => ({ ...current, error, isLoadingMore: false }))
|
||||
})
|
||||
}, [active, adapter, cursor, normalizedQuery, state.isLoadingMore])
|
||||
|
||||
const retry = React.useCallback(() => setRetryKey((value) => value + 1), [])
|
||||
|
||||
return {
|
||||
error: state.error,
|
||||
groups,
|
||||
hasMore: cursor !== null,
|
||||
isLoading: state.isLoading,
|
||||
isLoadingMore: state.isLoadingMore,
|
||||
retry,
|
||||
loadMore,
|
||||
}
|
||||
}
|
||||
|
||||
function mergeGroups(
|
||||
pages: readonly SearchPage[]
|
||||
): readonly SearchResultGroup[] {
|
||||
const groups = new Map<string, SearchResultGroup>()
|
||||
|
||||
for (const page of pages) {
|
||||
for (const group of page.groups) {
|
||||
const existing = groups.get(group.id)
|
||||
if (!existing) {
|
||||
groups.set(group.id, { ...group, items: [...group.items] })
|
||||
continue
|
||||
}
|
||||
|
||||
const items = new Map(existing.items.map((item) => [item.id, item]))
|
||||
for (const item of group.items) items.set(item.id, item)
|
||||
groups.set(group.id, {
|
||||
...existing,
|
||||
...group,
|
||||
items: [...items.values()],
|
||||
total: group.total ?? existing.total,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
return [...groups.values()]
|
||||
}
|
||||
@@ -10,7 +10,6 @@
|
||||
"workspace-i18n": "./src/cli/index.ts"
|
||||
},
|
||||
"scripts": {
|
||||
"format": "prettier --write \"**/*.{css,json,ts,tsx}\"",
|
||||
"test": "vitest run",
|
||||
"typecheck": "tsc --noEmit"
|
||||
},
|
||||
|
||||
@@ -2,10 +2,7 @@
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta
|
||||
name="viewport"
|
||||
content="width=device-width, initial-scale=1.0"
|
||||
/>
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<meta name="color-scheme" content="light dark" />
|
||||
<title>Message Studio</title>
|
||||
</head>
|
||||
@@ -14,4 +11,3 @@
|
||||
<script type="module" src="/main.tsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
|
||||
@@ -86,7 +86,8 @@ describe("MessagePanel", () => {
|
||||
|
||||
it("keeps the current content visible during a background refresh", async () => {
|
||||
let resolveRefresh:
|
||||
((messages: readonly DevtoolMessage[]) => void) | undefined
|
||||
| ((messages: readonly DevtoolMessage[]) => void)
|
||||
| undefined
|
||||
const repository: MessageRepository = {
|
||||
getMessages: vi
|
||||
.fn()
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import * as React from "react"
|
||||
import { useLingui } from "@lingui/react"
|
||||
import { LinguiContext, useLingui } from "@lingui/react"
|
||||
|
||||
import { I18nRuntimeContext } from "./context"
|
||||
import { createIntlFormatters } from "./intl-formatters"
|
||||
@@ -26,6 +26,14 @@ export function useTranslate(): TranslateFunction {
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Indicates whether the component is nested in an I18nProvider without
|
||||
* invoking the provider-dependent Lingui hooks.
|
||||
*/
|
||||
export function useI18nProvider(): boolean {
|
||||
return React.useContext(LinguiContext) !== null
|
||||
}
|
||||
|
||||
export function useMessage(descriptor: MessageDescriptor): string
|
||||
export function useMessage(
|
||||
id: MessageId,
|
||||
|
||||
@@ -1,9 +1,11 @@
|
||||
export { I18nProvider, type I18nProviderProps } from "./i18n-provider"
|
||||
export { LocalizedText, type LocalizedTextProps } from "./localized-text"
|
||||
export { Translate, type TranslateProps } from "./translate"
|
||||
export { createIntlFormatters } from "./intl-formatters"
|
||||
export { mergeMessageCatalogs, type MessageCatalogSource } from "./catalogs"
|
||||
export {
|
||||
useFormatters,
|
||||
useI18nProvider,
|
||||
useLocale,
|
||||
useLocaleDefinition,
|
||||
useLocales,
|
||||
|
||||
+3
-1
@@ -1,9 +1,11 @@
|
||||
import { useMessage, type MessageDescriptor } from "@workspace/i18n"
|
||||
import { useMessage } from "./hooks"
|
||||
import type { MessageDescriptor } from "./types"
|
||||
|
||||
export interface LocalizedTextProps {
|
||||
message: MessageDescriptor
|
||||
}
|
||||
|
||||
/** Renders a message descriptor as localized text without adding a DOM node. */
|
||||
export function LocalizedText({ message }: LocalizedTextProps) {
|
||||
return useMessage(message)
|
||||
}
|
||||
@@ -13,6 +13,7 @@ import {
|
||||
useMessage,
|
||||
useTranslate,
|
||||
} from "./hooks"
|
||||
import { LocalizedText } from "./localized-text"
|
||||
import { Translate } from "./translate"
|
||||
|
||||
const catalogs = {
|
||||
@@ -96,6 +97,18 @@ describe("i18n runtime", () => {
|
||||
expect(container.innerHTML).toBe("<p>Hello</p>")
|
||||
})
|
||||
|
||||
it("renders localized text without adding a DOM element", () => {
|
||||
const { container } = render(
|
||||
<I18nProvider catalogs={catalogs} locale="zh-Hans">
|
||||
<p>
|
||||
<LocalizedText message={{ id: "greeting", message: "Fallback" }} />
|
||||
</p>
|
||||
</I18nProvider>
|
||||
)
|
||||
|
||||
expect(container.innerHTML).toBe("<p>你好</p>")
|
||||
})
|
||||
|
||||
it("infers right-to-left locale direction", () => {
|
||||
function DirectionProbe() {
|
||||
return <span>{useLocales()[0]?.direction}</span>
|
||||
|
||||
@@ -21,7 +21,8 @@ export type MessageCatalog = Record<string, unknown>
|
||||
export type MessageCatalogs = Record<string, MessageCatalog>
|
||||
|
||||
export type MissingTranslationHandler =
|
||||
string | ((locale: string, id: string) => string)
|
||||
| string
|
||||
| ((locale: string, id: string) => string)
|
||||
|
||||
export type LocaleDirection = "ltr" | "rtl"
|
||||
|
||||
|
||||
@@ -0,0 +1,22 @@
|
||||
# @workspace/icons
|
||||
|
||||
Workspace icon primitives backed by Hugeicons.
|
||||
|
||||
## Usage
|
||||
|
||||
```tsx
|
||||
import { Icon, Search01Icon } from "@workspace/icons"
|
||||
|
||||
export function SearchButton() {
|
||||
return <Icon data={Search01Icon} size="lg" />
|
||||
}
|
||||
```
|
||||
|
||||
`Icon` defaults to the `md` size and inherits the current text color. A
|
||||
Tailwind size class supplied through `className` overrides the size preset:
|
||||
|
||||
```tsx
|
||||
<Icon data={Search01Icon} className="size-8 text-primary" />
|
||||
```
|
||||
|
||||
Available presets are `xs`, `sm`, `md`, `lg`, and `xl`.
|
||||
@@ -0,0 +1,22 @@
|
||||
{
|
||||
"name": "@workspace/icons",
|
||||
"version": "0.0.0",
|
||||
"type": "module",
|
||||
"private": true,
|
||||
"sideEffects": false,
|
||||
"scripts": {
|
||||
"typecheck": "tsc --noEmit"
|
||||
},
|
||||
"dependencies": {
|
||||
"@hugeicons/core-free-icons": "^4.2.3",
|
||||
"@hugeicons/react": "^1.1.9",
|
||||
"react": "^19.2.6"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/react": "^19",
|
||||
"typescript": "~6"
|
||||
},
|
||||
"exports": {
|
||||
".": "./src/index.tsx"
|
||||
}
|
||||
}
|
||||
@@ -1,29 +1,32 @@
|
||||
import { type HugeiconsIconProps, HugeiconsIcon } from "@hugeicons/react"
|
||||
import { cn } from "@workspace/ui/lib/utils"
|
||||
|
||||
export * from "@hugeicons/core-free-icons"
|
||||
|
||||
export type IconData = HugeiconsIconProps["icon"]
|
||||
export type IconSize = keyof typeof sizeValues
|
||||
|
||||
export type IconProps = Omit<HugeiconsIconProps, "icon"> & {
|
||||
export type IconProps = Omit<HugeiconsIconProps, "icon" | "size"> & {
|
||||
data: IconData
|
||||
size?: "xs" | "sm" | "md" | "lg" | "xl"
|
||||
size?: IconSize
|
||||
}
|
||||
|
||||
const sizeClasses = {
|
||||
xs: "size-3",
|
||||
sm: "size-4",
|
||||
md: "size-5",
|
||||
lg: "size-6",
|
||||
xl: "size-7",
|
||||
const sizeValues = {
|
||||
xs: 12,
|
||||
sm: 16,
|
||||
md: 20,
|
||||
lg: 24,
|
||||
xl: 28,
|
||||
}
|
||||
|
||||
export function Icon({ data, className, size = "md", ...props }: IconProps) {
|
||||
return (
|
||||
<HugeiconsIcon
|
||||
icon={data}
|
||||
size={sizeValues[size]}
|
||||
color="currentColor"
|
||||
aria-hidden="true"
|
||||
strokeWidth={1.75}
|
||||
className={cn(sizeClasses[size], className)}
|
||||
className={className}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
@@ -0,0 +1,17 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2022",
|
||||
"lib": ["ES2022", "DOM", "DOM.Iterable"],
|
||||
"module": "ESNext",
|
||||
"moduleResolution": "bundler",
|
||||
"jsx": "react-jsx",
|
||||
"skipLibCheck": true,
|
||||
"strict": true,
|
||||
"noEmit": true,
|
||||
"paths": {
|
||||
"@workspace/icons": ["./src/index.tsx"]
|
||||
}
|
||||
},
|
||||
"include": ["src"],
|
||||
"exclude": ["node_modules", "dist"]
|
||||
}
|
||||
@@ -0,0 +1,198 @@
|
||||
# `@workspace/lexical`
|
||||
|
||||
基于 Lexical 的声明式富文本编辑器。编辑器能力由
|
||||
`<LexicalActions />` 中出现的 action 组件决定;action 同时声明自己依赖的
|
||||
node、plugin 和 embed,`<LexicalRoot />` 会在创建 Composer 前自动收集并去重。
|
||||
|
||||
## 使用预设
|
||||
|
||||
`useDefaults` 提供 `minimal` 和 `full` 两套预设。使用预设时不再接收
|
||||
`children`,避免默认 action 与手动 action 的优先级不明确。
|
||||
|
||||
```tsx
|
||||
import {
|
||||
LexicalActions,
|
||||
LexicalBubbleToolbar,
|
||||
LexicalContent,
|
||||
LexicalFixedToolbar,
|
||||
LexicalFooter,
|
||||
LexicalRoot,
|
||||
} from "@workspace/lexical"
|
||||
|
||||
export function Editor() {
|
||||
return (
|
||||
<LexicalRoot value="" onChange={(html) => console.log(html)}>
|
||||
<LexicalActions useDefaults="full" />
|
||||
<LexicalFixedToolbar />
|
||||
<LexicalContent placeholder="开始输入…" />
|
||||
<LexicalBubbleToolbar />
|
||||
<LexicalFooter />
|
||||
</LexicalRoot>
|
||||
)
|
||||
}
|
||||
```
|
||||
|
||||
`LexicalFixedToolbar`、`LexicalBubbleToolbar` 和 `LexicalFooter` 都是可选的。
|
||||
当对应区域没有 action,也没有自定义 children 时,组件不会产生 DOM。
|
||||
action、node 与 plugin 会在编辑器首次挂载时确定;需要切换整套能力时,应为
|
||||
`LexicalRoot` 提供新的 `key` 以重新创建编辑器。
|
||||
|
||||
## 国际化
|
||||
|
||||
内置控件会使用 `@workspace/i18n` 的当前语言;包提供英文与简体中文 catalog。
|
||||
应用应将对应 locale source 加入自己的 `i18n.config.json`:
|
||||
|
||||
```json
|
||||
{
|
||||
"catalogSources": ["@workspace/lexical/locales/{locale}"]
|
||||
}
|
||||
```
|
||||
|
||||
未置于 `I18nProvider` 内时,编辑器会回退到英文。外部 action 的 `label`
|
||||
仍可直接传字符串,也可以传 `{ id, message }` 消息描述符。
|
||||
|
||||
## 自定义 action 布局
|
||||
|
||||
action 默认显示在固定工具栏。通过 `in` 可以指定单个区域或多个区域;
|
||||
`ActionGroup` 可以只做普通组合,也可以渲染成下拉菜单。
|
||||
|
||||
```tsx
|
||||
import {
|
||||
ActionGroup,
|
||||
Bold,
|
||||
BulletList,
|
||||
CheckList,
|
||||
ClearFormatting,
|
||||
Date,
|
||||
Heading,
|
||||
LexicalActions,
|
||||
NormalText,
|
||||
OrderedList,
|
||||
Quote,
|
||||
Redo,
|
||||
Undo,
|
||||
} from "@workspace/lexical"
|
||||
|
||||
;<LexicalActions>
|
||||
<Undo />
|
||||
<Redo />
|
||||
|
||||
<ActionGroup type="menu" label="段落样式" showActiveAction>
|
||||
<NormalText />
|
||||
<Heading level={1} />
|
||||
<Heading level={2} />
|
||||
<Heading level={3} />
|
||||
<OrderedList />
|
||||
<BulletList />
|
||||
<CheckList />
|
||||
<Quote />
|
||||
</ActionGroup>
|
||||
|
||||
<Bold in={["toolbar", "bubble"]} />
|
||||
<Date in="bubble" />
|
||||
<ClearFormatting in="footer" />
|
||||
</LexicalActions>
|
||||
```
|
||||
|
||||
## 定义扩展 action
|
||||
|
||||
外部 action 把行为和 Composer 依赖放在同一份定义中,不需要额外注册
|
||||
feature。只要 action 出现在 `LexicalActions` 中,它声明的 node 和 plugin
|
||||
就会自动启用。
|
||||
|
||||
```tsx
|
||||
import {
|
||||
defineLexicalAction,
|
||||
type LexicalActionDefinition,
|
||||
} from "@workspace/lexical"
|
||||
|
||||
const mentionAction: LexicalActionDefinition = {
|
||||
name: "mention",
|
||||
label: "插入提及",
|
||||
nodes: [MentionNode],
|
||||
plugins: [MentionPopoverPlugin],
|
||||
execute: ({ editor }) => openMentionPicker(editor),
|
||||
}
|
||||
|
||||
export const Mention = defineLexicalAction(mentionAction)
|
||||
```
|
||||
|
||||
action 组件可以用 render function 替换默认控件,但依赖收集仍由同一个
|
||||
action 完成:
|
||||
|
||||
```tsx
|
||||
<Mention>
|
||||
{({ disabled, execute }) => (
|
||||
<CustomMentionButton
|
||||
disabled={disabled}
|
||||
onSelect={(mention) => execute(mention)}
|
||||
/>
|
||||
)}
|
||||
</Mention>
|
||||
```
|
||||
|
||||
render context 中的 `execute(value?)` 会调用该 action,并保留 value 类型。
|
||||
`onClick` 是 `execute()` 的无参快捷方式,适合普通按钮。
|
||||
|
||||
图片和视频 action 在不传 value 时继续使用内置输入弹窗;自定义上传器可以
|
||||
在上传结束后直接把结果交给 `execute`,不需要操作 Lexical editor:
|
||||
|
||||
```tsx
|
||||
<Image>
|
||||
{({ execute }) => (
|
||||
<ImageUploader
|
||||
onUploaded={({ src, alt, caption }) =>
|
||||
execute({ src, alt, caption })
|
||||
}
|
||||
/>
|
||||
)}
|
||||
</Image>
|
||||
|
||||
<Video>
|
||||
{({ execute }) => (
|
||||
<VideoUploader
|
||||
onUploaded={({ src, poster, caption }) =>
|
||||
execute({ src, poster, caption })
|
||||
}
|
||||
/>
|
||||
)}
|
||||
</Video>
|
||||
```
|
||||
|
||||
## 粘贴图片
|
||||
|
||||
`full` 预设已包含 `<ClipboardImages />`:直接粘贴截图或拖入图片文件时,
|
||||
会立即插入本地预览;默认完成后把图片编码成可序列化的 `data:` URL。
|
||||
生产环境通常更适合传入 `resolveImage`,先把图片上传到对象存储,再返回
|
||||
持久 URL。上传期间可通过 `reportProgress` 汇报 `0` 到 `1` 的进度;不汇报
|
||||
时编辑器会显示不确定进度:
|
||||
|
||||
```tsx
|
||||
import { ClipboardImages, LexicalActions } from "@workspace/lexical"
|
||||
|
||||
;<LexicalActions>
|
||||
{/* 其他 action */}
|
||||
<ClipboardImages
|
||||
resolveImage={async (file, { reportProgress, signal }) => {
|
||||
const uploaded = await uploadImage(file, {
|
||||
signal,
|
||||
onProgress: reportProgress,
|
||||
})
|
||||
return {
|
||||
alt: file.name,
|
||||
src: uploaded.url,
|
||||
}
|
||||
}}
|
||||
onError={(error, file) => reportUploadError(error, file)}
|
||||
/>
|
||||
</LexicalActions>
|
||||
```
|
||||
|
||||
非图片文件不会被编辑器接管,粘贴仍由浏览器处理。图片被选中后可以通过
|
||||
控制点缩放、添加或修改说明、切换左/中/右对齐,也可以使用可见删除按钮、
|
||||
Delete 或 Backspace 删除。该能力同时复用 Lexical 的文件拖放命令,因此
|
||||
相同 resolver 也适用于拖入编辑器的图片。
|
||||
|
||||
没有可见控件、只提供编辑行为的能力也可以声明为 `hidden` action。包内的
|
||||
`<DraggableBlocks />` 就使用这种方式,因此它会启用拖拽 plugin,但不会占据
|
||||
任何工具栏位置。
|
||||
@@ -0,0 +1,41 @@
|
||||
{
|
||||
"name": "@workspace/lexical",
|
||||
"version": "0.0.0",
|
||||
"type": "module",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"test": "vitest run",
|
||||
"typecheck": "tsc --noEmit"
|
||||
},
|
||||
"dependencies": {
|
||||
"@lexical/extension": "^0.48.0",
|
||||
"@lexical/html": "^0.48.0",
|
||||
"@lexical/link": "^0.48.0",
|
||||
"@lexical/list": "^0.48.0",
|
||||
"@lexical/react": "^0.48.0",
|
||||
"@lexical/rich-text": "^0.48.0",
|
||||
"@lexical/selection": "^0.48.0",
|
||||
"@lexical/utils": "^0.48.0",
|
||||
"@workspace/i18n": "workspace:*",
|
||||
"@workspace/ui": "workspace:*",
|
||||
"lexical": "^0.48.0",
|
||||
"lucide-react": "^1.27.0",
|
||||
"react": "^19.2.6",
|
||||
"react-dom": "^19.2.6"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@testing-library/react": "^16.3.2",
|
||||
"@types/react": "^19",
|
||||
"@types/react-dom": "^19",
|
||||
"jsdom": "^30.0.1",
|
||||
"typescript": "~6",
|
||||
"vitest": "^4.1.10"
|
||||
},
|
||||
"exports": {
|
||||
".": "./src/index.ts",
|
||||
"./actions": "./src/actions/index.ts",
|
||||
"./globals.css": "./src/styles/globals.css",
|
||||
"./locales": "./src/locales/catalogs.ts",
|
||||
"./locales/*": "./src/locales/*.ts"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,528 @@
|
||||
"use client"
|
||||
|
||||
import * as React from "react"
|
||||
import type { ComponentType, ReactElement, ReactNode } from "react"
|
||||
import type { Klass, LexicalNode } from "lexical"
|
||||
import { AlignLeft, CaseSensitive, Pilcrow, Plus } from "lucide-react"
|
||||
|
||||
import {
|
||||
boldAction,
|
||||
bulletListAction,
|
||||
capitalizeAction,
|
||||
centerAlignAction,
|
||||
checkListAction,
|
||||
clearFormattingAction,
|
||||
createClipboardImagesAction,
|
||||
colorPickerAction,
|
||||
dateAction,
|
||||
fontSizeAction,
|
||||
heading1Action,
|
||||
heading2Action,
|
||||
heading3Action,
|
||||
horizontalRuleAction,
|
||||
indentAction,
|
||||
insertImageAction,
|
||||
insertLinkAction,
|
||||
insertVideoAction,
|
||||
italicAction,
|
||||
justifyAlignAction,
|
||||
leftAlignAction,
|
||||
lowercaseAction,
|
||||
normalAction,
|
||||
orderedListAction,
|
||||
outdentAction,
|
||||
quoteAction,
|
||||
redoAction,
|
||||
rightAlignAction,
|
||||
strikethroughAction,
|
||||
subscriptAction,
|
||||
superscriptAction,
|
||||
underlineAction,
|
||||
undoAction,
|
||||
uppercaseAction,
|
||||
} from "./actions"
|
||||
import type { CreateClipboardImagesActionOptions } from "./actions"
|
||||
import type { LexicalEmbedDefinition } from "./embed"
|
||||
import { LexicalDraggableBlockPlugin } from "./plugins/draggable-block-plugin"
|
||||
import { lexicalMessages } from "./messages"
|
||||
import type { LexicalMessage } from "./i18n"
|
||||
import type {
|
||||
LexicalActionDefinition,
|
||||
LexicalControlRenderProps,
|
||||
} from "./types"
|
||||
|
||||
export type LexicalActionArea = "toolbar" | "bubble" | "footer"
|
||||
export type LexicalActionPlacement =
|
||||
| LexicalActionArea
|
||||
| readonly LexicalActionArea[]
|
||||
|
||||
export interface LexicalActionProps<Value = string> {
|
||||
children?: (props: LexicalControlRenderProps<Value>) => ReactNode
|
||||
in?: LexicalActionPlacement
|
||||
}
|
||||
|
||||
export type ClipboardImagesProps = CreateClipboardImagesActionOptions
|
||||
|
||||
export interface ActionGroupProps {
|
||||
children?: ReactNode
|
||||
icon?: LexicalActionDefinition["icon"]
|
||||
in?: LexicalActionPlacement
|
||||
label?: LexicalMessage
|
||||
showActiveAction?: boolean
|
||||
type?: "group" | "menu"
|
||||
}
|
||||
|
||||
export type LexicalActionsPreset = "minimal" | "full"
|
||||
|
||||
export type LexicalActionsProps =
|
||||
| {
|
||||
children?: ReactNode
|
||||
useDefaults?: never
|
||||
}
|
||||
| {
|
||||
children?: never
|
||||
useDefaults: LexicalActionsPreset
|
||||
}
|
||||
|
||||
export interface CompiledLexicalAction {
|
||||
action: AnyLexicalActionDefinition
|
||||
key: string
|
||||
kind: "action"
|
||||
render?: AnyLexicalActionProps["children"]
|
||||
}
|
||||
|
||||
export interface CompiledLexicalActionGroup {
|
||||
children: readonly CompiledLexicalActionItem[]
|
||||
icon?: LexicalActionDefinition["icon"]
|
||||
key: string
|
||||
kind: "group"
|
||||
label?: LexicalMessage
|
||||
showActiveAction: boolean
|
||||
type: "group" | "menu"
|
||||
}
|
||||
|
||||
export type CompiledLexicalActionItem =
|
||||
| CompiledLexicalAction
|
||||
| CompiledLexicalActionGroup
|
||||
|
||||
export interface CompiledLexicalActions {
|
||||
actions: Readonly<
|
||||
Record<LexicalActionArea, readonly CompiledLexicalActionItem[]>
|
||||
>
|
||||
embeds: readonly LexicalEmbedDefinition[]
|
||||
nodes: readonly Klass<LexicalNode>[]
|
||||
plugins: readonly ComponentType[]
|
||||
}
|
||||
|
||||
const ACTION_MARKER = Symbol("lexical.action")
|
||||
const ACTION_RESOLVER_MARKER = Symbol("lexical.action-resolver")
|
||||
const GROUP_MARKER = Symbol("lexical.action-group")
|
||||
const EMPTY_ITEMS: readonly CompiledLexicalActionItem[] = Object.freeze([])
|
||||
|
||||
type AnyLexicalActionDefinition = LexicalActionDefinition<any>
|
||||
type AnyLexicalActionProps = LexicalActionProps<any>
|
||||
|
||||
interface LexicalActionComponent<Value = string> extends React.FC<
|
||||
LexicalActionProps<Value>
|
||||
> {
|
||||
[ACTION_MARKER]: LexicalActionDefinition<Value>
|
||||
}
|
||||
|
||||
interface LexicalActionResolverComponent<
|
||||
Props extends AnyLexicalActionProps,
|
||||
> extends React.FC<Props> {
|
||||
[ACTION_RESOLVER_MARKER]: (props: Props) => AnyLexicalActionDefinition
|
||||
}
|
||||
|
||||
export function defineLexicalAction<Value = string>(
|
||||
action: LexicalActionDefinition<Value>
|
||||
): LexicalActionComponent<Value> {
|
||||
// oxlint-disable-next-line unicorn/consistent-function-scoping -- Every declaration needs an independent component identity and metadata.
|
||||
const Action: LexicalActionComponent<Value> = () => null
|
||||
Action.displayName = `LexicalAction(${action.name})`
|
||||
Action[ACTION_MARKER] = action
|
||||
return Action
|
||||
}
|
||||
|
||||
export function LexicalActions(_props: LexicalActionsProps) {
|
||||
return null
|
||||
}
|
||||
|
||||
export function ActionGroup(_props: ActionGroupProps) {
|
||||
return null
|
||||
}
|
||||
|
||||
Object.assign(ActionGroup, { [GROUP_MARKER]: true })
|
||||
|
||||
export const Undo = defineLexicalAction(undoAction)
|
||||
export const Redo = defineLexicalAction(redoAction)
|
||||
export const NormalText = defineLexicalAction(normalAction)
|
||||
export const Heading1 = defineLexicalAction(heading1Action)
|
||||
export const Heading2 = defineLexicalAction(heading2Action)
|
||||
export const Heading3 = defineLexicalAction(heading3Action)
|
||||
export const OrderedList = defineLexicalAction(orderedListAction)
|
||||
export const BulletList = defineLexicalAction(bulletListAction)
|
||||
export const CheckList = defineLexicalAction(checkListAction)
|
||||
export const Quote = defineLexicalAction(quoteAction)
|
||||
export const FontSize = defineLexicalAction(fontSizeAction)
|
||||
export const Bold = defineLexicalAction(boldAction)
|
||||
export const Italic = defineLexicalAction(italicAction)
|
||||
export const Underline = defineLexicalAction(underlineAction)
|
||||
export const Link = defineLexicalAction(insertLinkAction)
|
||||
export const TextColor = defineLexicalAction(colorPickerAction)
|
||||
export const Lowercase = defineLexicalAction(lowercaseAction)
|
||||
export const Uppercase = defineLexicalAction(uppercaseAction)
|
||||
export const Capitalize = defineLexicalAction(capitalizeAction)
|
||||
export const Strikethrough = defineLexicalAction(strikethroughAction)
|
||||
export const Subscript = defineLexicalAction(subscriptAction)
|
||||
export const Superscript = defineLexicalAction(superscriptAction)
|
||||
export const ClearFormatting = defineLexicalAction(clearFormattingAction)
|
||||
export const HorizontalRule = defineLexicalAction(horizontalRuleAction)
|
||||
export const Date = defineLexicalAction(dateAction)
|
||||
export const Image = defineLexicalAction(insertImageAction)
|
||||
export const Video = defineLexicalAction(insertVideoAction)
|
||||
export const LeftAlign = defineLexicalAction(leftAlignAction)
|
||||
export const CenterAlign = defineLexicalAction(centerAlignAction)
|
||||
export const RightAlign = defineLexicalAction(rightAlignAction)
|
||||
export const JustifyAlign = defineLexicalAction(justifyAlignAction)
|
||||
export const Outdent = defineLexicalAction(outdentAction)
|
||||
export const Indent = defineLexicalAction(indentAction)
|
||||
export const DraggableBlocks = defineLexicalAction({
|
||||
name: "draggableBlocks",
|
||||
label: lexicalMessages.draggableBlocks,
|
||||
hidden: true,
|
||||
plugins: [LexicalDraggableBlockPlugin],
|
||||
execute: () => undefined,
|
||||
})
|
||||
|
||||
export function ClipboardImages(_props: ClipboardImagesProps) {
|
||||
return null
|
||||
}
|
||||
|
||||
Object.assign(ClipboardImages, {
|
||||
[ACTION_RESOLVER_MARKER]: (props: ClipboardImagesProps) =>
|
||||
createClipboardImagesAction(props),
|
||||
})
|
||||
|
||||
export function Heading({
|
||||
level,
|
||||
...props
|
||||
}: LexicalActionProps & { level: 1 | 2 | 3 }) {
|
||||
const Component = level === 1 ? Heading1 : level === 2 ? Heading2 : Heading3
|
||||
return <Component {...props} />
|
||||
}
|
||||
|
||||
Object.assign(Heading, {
|
||||
[ACTION_RESOLVER_MARKER]: ({
|
||||
level,
|
||||
}: LexicalActionProps & { level: 1 | 2 | 3 }) =>
|
||||
level === 1
|
||||
? heading1Action
|
||||
: level === 2
|
||||
? heading2Action
|
||||
: heading3Action,
|
||||
})
|
||||
|
||||
function getPresetActions(preset: LexicalActionsPreset): ReactNode {
|
||||
const formattingAreas = ["toolbar", "bubble"] as const
|
||||
const minimal = (
|
||||
<>
|
||||
<Undo />
|
||||
<Redo />
|
||||
<ActionGroup
|
||||
type="menu"
|
||||
label={lexicalMessages.paragraphStyle}
|
||||
icon={Pilcrow}
|
||||
showActiveAction
|
||||
>
|
||||
<NormalText />
|
||||
<Heading1 />
|
||||
<Heading2 />
|
||||
<Heading3 />
|
||||
<BulletList />
|
||||
<OrderedList />
|
||||
</ActionGroup>
|
||||
<Bold in={formattingAreas} />
|
||||
<Italic in={formattingAreas} />
|
||||
<Underline in={formattingAreas} />
|
||||
<Link in={formattingAreas} />
|
||||
<ClearFormatting in={formattingAreas} />
|
||||
</>
|
||||
)
|
||||
|
||||
if (preset === "minimal") return minimal
|
||||
|
||||
return (
|
||||
<>
|
||||
<Undo />
|
||||
<Redo />
|
||||
<ActionGroup
|
||||
type="menu"
|
||||
label={lexicalMessages.paragraphStyle}
|
||||
icon={Pilcrow}
|
||||
showActiveAction
|
||||
>
|
||||
<NormalText />
|
||||
<Heading1 />
|
||||
<Heading2 />
|
||||
<Heading3 />
|
||||
<OrderedList />
|
||||
<BulletList />
|
||||
<CheckList />
|
||||
<Quote />
|
||||
</ActionGroup>
|
||||
<FontSize />
|
||||
<Bold in={formattingAreas} />
|
||||
<Italic in={formattingAreas} />
|
||||
<Underline in={formattingAreas} />
|
||||
<Link in={formattingAreas} />
|
||||
<TextColor in={formattingAreas} />
|
||||
<ActionGroup
|
||||
type="menu"
|
||||
label={lexicalMessages.moreFormatting}
|
||||
icon={CaseSensitive}
|
||||
>
|
||||
<Lowercase />
|
||||
<Uppercase />
|
||||
<Capitalize />
|
||||
<Strikethrough />
|
||||
<Subscript />
|
||||
<Superscript />
|
||||
</ActionGroup>
|
||||
<Strikethrough in="bubble" />
|
||||
<Subscript in="bubble" />
|
||||
<Superscript in="bubble" />
|
||||
<ClearFormatting in={formattingAreas} />
|
||||
<ActionGroup
|
||||
type="menu"
|
||||
label={lexicalMessages.insert}
|
||||
icon={Plus}
|
||||
showActiveAction
|
||||
>
|
||||
<HorizontalRule />
|
||||
<Date />
|
||||
<Image />
|
||||
<Video />
|
||||
</ActionGroup>
|
||||
<ClipboardImages />
|
||||
<ActionGroup
|
||||
type="menu"
|
||||
label={lexicalMessages.textFormat}
|
||||
icon={AlignLeft}
|
||||
showActiveAction
|
||||
>
|
||||
<ActionGroup>
|
||||
<LeftAlign />
|
||||
<CenterAlign />
|
||||
<RightAlign />
|
||||
<JustifyAlign />
|
||||
</ActionGroup>
|
||||
<ActionGroup>
|
||||
<Outdent />
|
||||
<Indent />
|
||||
</ActionGroup>
|
||||
</ActionGroup>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
function normalizeAreas(
|
||||
placement: LexicalActionPlacement | undefined,
|
||||
inheritedAreas: readonly LexicalActionArea[]
|
||||
) {
|
||||
if (!placement) return inheritedAreas
|
||||
return typeof placement === "string" ? [placement] : [...placement]
|
||||
}
|
||||
|
||||
function isActionComponent(
|
||||
value: unknown
|
||||
): value is LexicalActionComponent<any> {
|
||||
return (
|
||||
typeof value === "function" &&
|
||||
ACTION_MARKER in (value as unknown as Record<PropertyKey, unknown>)
|
||||
)
|
||||
}
|
||||
|
||||
function isActionResolverComponent(
|
||||
value: unknown
|
||||
): value is LexicalActionResolverComponent<AnyLexicalActionProps> {
|
||||
return (
|
||||
typeof value === "function" &&
|
||||
ACTION_RESOLVER_MARKER in (value as unknown as Record<PropertyKey, unknown>)
|
||||
)
|
||||
}
|
||||
|
||||
function isActionGroupComponent(value: unknown) {
|
||||
return (
|
||||
value === ActionGroup ||
|
||||
(typeof value === "function" &&
|
||||
GROUP_MARKER in (value as unknown as Record<PropertyKey, unknown>))
|
||||
)
|
||||
}
|
||||
|
||||
function compileItems(
|
||||
children: ReactNode,
|
||||
inheritedAreas: readonly LexicalActionArea[],
|
||||
output: Record<LexicalActionArea, CompiledLexicalActionItem[]>,
|
||||
dependencies: {
|
||||
actions: Map<string, AnyLexicalActionDefinition>
|
||||
embeds: Map<string, LexicalEmbedDefinition>
|
||||
nextItemId: number
|
||||
nodes: Set<Klass<LexicalNode>>
|
||||
plugins: Set<ComponentType>
|
||||
}
|
||||
) {
|
||||
React.Children.forEach(children, (child) => {
|
||||
if (!React.isValidElement(child)) return
|
||||
|
||||
if (child.type === React.Fragment) {
|
||||
const fragment = child as ReactElement<{ children?: ReactNode }>
|
||||
compileItems(
|
||||
fragment.props.children,
|
||||
inheritedAreas,
|
||||
output,
|
||||
dependencies
|
||||
)
|
||||
return
|
||||
}
|
||||
|
||||
if (
|
||||
isActionComponent(child.type) ||
|
||||
isActionResolverComponent(child.type)
|
||||
) {
|
||||
const props = child.props as AnyLexicalActionProps
|
||||
const action = isActionComponent(child.type)
|
||||
? child.type[ACTION_MARKER]
|
||||
: child.type[ACTION_RESOLVER_MARKER](props)
|
||||
const areas = normalizeAreas(props.in, inheritedAreas)
|
||||
const registeredAction = dependencies.actions.get(action.name)
|
||||
|
||||
if (registeredAction && registeredAction !== action) {
|
||||
throw new Error(
|
||||
`Lexical action "${action.name}" is declared with conflicting definitions.`
|
||||
)
|
||||
}
|
||||
|
||||
dependencies.actions.set(action.name, action)
|
||||
action.nodes?.forEach((node) => dependencies.nodes.add(node))
|
||||
action.plugins?.forEach((plugin) => dependencies.plugins.add(plugin))
|
||||
action.embeds?.forEach((embed) =>
|
||||
dependencies.embeds.set(embed.type, embed)
|
||||
)
|
||||
|
||||
if (action.hidden) return
|
||||
|
||||
const key = `action:${action.name}:${dependencies.nextItemId}`
|
||||
dependencies.nextItemId += 1
|
||||
|
||||
for (const area of new Set(areas)) {
|
||||
output[area].push({
|
||||
action,
|
||||
key,
|
||||
kind: "action",
|
||||
render: props.children,
|
||||
})
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
if (!isActionGroupComponent(child.type)) return
|
||||
|
||||
const props = child.props as ActionGroupProps
|
||||
const areas = normalizeAreas(props.in, inheritedAreas)
|
||||
const groupedOutput: Record<
|
||||
LexicalActionArea,
|
||||
CompiledLexicalActionItem[]
|
||||
> = {
|
||||
toolbar: [],
|
||||
bubble: [],
|
||||
footer: [],
|
||||
}
|
||||
|
||||
compileItems(props.children, areas, groupedOutput, dependencies)
|
||||
|
||||
for (const area of ["toolbar", "bubble", "footer"] as const) {
|
||||
const areaChildren = groupedOutput[area]
|
||||
if (areaChildren.length === 0) continue
|
||||
|
||||
output[area].push({
|
||||
children: areaChildren,
|
||||
icon: props.icon,
|
||||
key: `group:${props.label ?? props.type ?? "group"}:${dependencies.nextItemId}`,
|
||||
kind: "group",
|
||||
label: props.label,
|
||||
showActiveAction: props.showActiveAction ?? false,
|
||||
type: props.type ?? "group",
|
||||
})
|
||||
dependencies.nextItemId += 1
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
export function compileLexicalActions(
|
||||
element: ReactElement<LexicalActionsProps> | undefined
|
||||
): CompiledLexicalActions {
|
||||
if (!element) {
|
||||
return {
|
||||
actions: {
|
||||
toolbar: EMPTY_ITEMS,
|
||||
bubble: EMPTY_ITEMS,
|
||||
footer: EMPTY_ITEMS,
|
||||
},
|
||||
embeds: [],
|
||||
nodes: [],
|
||||
plugins: [],
|
||||
}
|
||||
}
|
||||
|
||||
const output: Record<LexicalActionArea, CompiledLexicalActionItem[]> = {
|
||||
toolbar: [],
|
||||
bubble: [],
|
||||
footer: [],
|
||||
}
|
||||
const dependencies = {
|
||||
actions: new Map<string, AnyLexicalActionDefinition>(),
|
||||
embeds: new Map<string, LexicalEmbedDefinition>(),
|
||||
nextItemId: 0,
|
||||
nodes: new Set<Klass<LexicalNode>>(),
|
||||
plugins: new Set<ComponentType>(),
|
||||
}
|
||||
const children = element.props.useDefaults
|
||||
? getPresetActions(element.props.useDefaults)
|
||||
: element.props.children
|
||||
|
||||
compileItems(children, ["toolbar"], output, dependencies)
|
||||
|
||||
return {
|
||||
actions: {
|
||||
toolbar: Object.freeze(output.toolbar),
|
||||
bubble: Object.freeze(output.bubble),
|
||||
footer: Object.freeze(output.footer),
|
||||
},
|
||||
embeds: Object.freeze([...dependencies.embeds.values()]),
|
||||
nodes: Object.freeze([...dependencies.nodes]),
|
||||
plugins: Object.freeze([...dependencies.plugins]),
|
||||
}
|
||||
}
|
||||
|
||||
export function findLexicalActions(
|
||||
children: ReactNode
|
||||
): ReactElement<LexicalActionsProps> | undefined {
|
||||
let result: ReactElement<LexicalActionsProps> | undefined
|
||||
|
||||
React.Children.forEach(children, (child) => {
|
||||
if (result || !React.isValidElement(child)) return
|
||||
|
||||
if (child.type === LexicalActions) {
|
||||
result = child as ReactElement<LexicalActionsProps>
|
||||
return
|
||||
}
|
||||
|
||||
if (child.type === React.Fragment) {
|
||||
const fragment = child as ReactElement<{ children?: ReactNode }>
|
||||
result = findLexicalActions(fragment.props.children)
|
||||
}
|
||||
})
|
||||
|
||||
return result
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
"use client"
|
||||
|
||||
import * as React from "react"
|
||||
import type { ReactNode } from "react"
|
||||
|
||||
import type {
|
||||
CompiledLexicalActions,
|
||||
LexicalActionArea,
|
||||
} from "./action-declarations"
|
||||
|
||||
const EMPTY_ACTIONS: CompiledLexicalActions["actions"] = Object.freeze({
|
||||
toolbar: Object.freeze([]),
|
||||
bubble: Object.freeze([]),
|
||||
footer: Object.freeze([]),
|
||||
})
|
||||
|
||||
const LexicalActionsContext = React.createContext(EMPTY_ACTIONS)
|
||||
|
||||
export function LexicalActionsProvider({
|
||||
actions,
|
||||
children,
|
||||
}: {
|
||||
actions: CompiledLexicalActions["actions"]
|
||||
children: ReactNode
|
||||
}) {
|
||||
return (
|
||||
<LexicalActionsContext.Provider value={actions}>
|
||||
{children}
|
||||
</LexicalActionsContext.Provider>
|
||||
)
|
||||
}
|
||||
|
||||
export function useLexicalActions(area: LexicalActionArea) {
|
||||
return React.useContext(LexicalActionsContext)[area]
|
||||
}
|
||||
@@ -0,0 +1,72 @@
|
||||
// @vitest-environment jsdom
|
||||
|
||||
import { cleanup, render, screen } from "@testing-library/react"
|
||||
import { afterEach, describe, expect, it } from "vitest"
|
||||
|
||||
import {
|
||||
ActionGroup,
|
||||
Bold,
|
||||
Date,
|
||||
LexicalActions,
|
||||
LexicalBubbleToolbar,
|
||||
LexicalContent,
|
||||
LexicalFixedToolbar,
|
||||
LexicalFooter,
|
||||
LexicalRoot,
|
||||
} from "."
|
||||
|
||||
afterEach(cleanup)
|
||||
|
||||
describe("declarative Lexical actions", () => {
|
||||
it("renders actions only in their declared regions", () => {
|
||||
render(
|
||||
<LexicalRoot value="" onChange={() => undefined}>
|
||||
<LexicalActions>
|
||||
<Bold in={["toolbar", "bubble"]} />
|
||||
<Date in="footer" />
|
||||
</LexicalActions>
|
||||
<LexicalFixedToolbar aria-label="fixed" />
|
||||
<LexicalBubbleToolbar />
|
||||
<LexicalContent />
|
||||
<LexicalFooter aria-label="footer" />
|
||||
</LexicalRoot>
|
||||
)
|
||||
|
||||
expect(screen.getByRole("toolbar", { name: "fixed" })).not.toBeNull()
|
||||
expect(screen.getByLabelText("footer")).not.toBeNull()
|
||||
expect(screen.getByRole("button", { name: "Bold" })).not.toBeNull()
|
||||
expect(screen.getByRole("button", { name: "Date" })).not.toBeNull()
|
||||
})
|
||||
|
||||
it("supports menu groups", () => {
|
||||
render(
|
||||
<LexicalRoot value="" onChange={() => undefined}>
|
||||
<LexicalActions>
|
||||
<ActionGroup type="menu" label="格式">
|
||||
<Bold />
|
||||
</ActionGroup>
|
||||
</LexicalActions>
|
||||
<LexicalFixedToolbar />
|
||||
<LexicalContent />
|
||||
</LexicalRoot>
|
||||
)
|
||||
|
||||
expect(screen.getByRole("button", { name: "格式" })).not.toBeNull()
|
||||
})
|
||||
|
||||
it("does not render optional regions without matching actions", () => {
|
||||
render(
|
||||
<LexicalRoot value="" onChange={() => undefined}>
|
||||
<LexicalActions>
|
||||
<Bold />
|
||||
</LexicalActions>
|
||||
<LexicalBubbleToolbar aria-label="bubble" />
|
||||
<LexicalFooter aria-label="footer" />
|
||||
<LexicalContent />
|
||||
</LexicalRoot>
|
||||
)
|
||||
|
||||
expect(screen.queryByLabelText("bubble")).toBeNull()
|
||||
expect(screen.queryByLabelText("footer")).toBeNull()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,135 @@
|
||||
import { Check, ChevronDown } from "lucide-react"
|
||||
import { Button } from "@workspace/ui/components/button"
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuTrigger,
|
||||
} from "@workspace/ui/components/dropdown-menu"
|
||||
import { cn } from "@workspace/ui/lib/utils"
|
||||
|
||||
import type {
|
||||
CompiledLexicalAction,
|
||||
CompiledLexicalActionGroup,
|
||||
CompiledLexicalActionItem,
|
||||
} from "./action-declarations"
|
||||
import { LexicalControl } from "./control"
|
||||
import { useLexicalActionContext } from "./context"
|
||||
import { useLexicalMessage } from "./i18n"
|
||||
import { lexicalMessages } from "./messages"
|
||||
|
||||
export function LexicalActionTree({
|
||||
items,
|
||||
}: {
|
||||
items: readonly CompiledLexicalActionItem[]
|
||||
}) {
|
||||
return items.map((item) =>
|
||||
item.kind === "action" ? (
|
||||
<LexicalControl
|
||||
key={item.key}
|
||||
action={item.action}
|
||||
render={item.render}
|
||||
/>
|
||||
) : item.type === "menu" ? (
|
||||
<LexicalActionMenu key={item.key} group={item} />
|
||||
) : (
|
||||
<LexicalActionTree key={item.key} items={item.children} />
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
function getGroupActions(
|
||||
items: readonly CompiledLexicalActionItem[]
|
||||
): readonly CompiledLexicalAction[] {
|
||||
return items.flatMap((item) =>
|
||||
item.kind === "action" ? [item] : getGroupActions(item.children)
|
||||
)
|
||||
}
|
||||
|
||||
function LexicalActionMenu({ group }: { group: CompiledLexicalActionGroup }) {
|
||||
const context = useLexicalActionContext()
|
||||
const actions = getGroupActions(group.children)
|
||||
const activeItem = group.showActiveAction
|
||||
? actions.find((item) => item.action.isActive?.(context))
|
||||
: undefined
|
||||
const TriggerIcon = activeItem?.action.icon ?? group.icon
|
||||
const label = useLexicalMessage(
|
||||
activeItem?.action.label ?? group.label ?? lexicalMessages.action
|
||||
)
|
||||
const ariaLabel = useLexicalMessage(group.label ?? lexicalMessages.action)
|
||||
|
||||
if (actions.length === 0) return null
|
||||
|
||||
return (
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger
|
||||
render={
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
className="px-2"
|
||||
variant={activeItem ? "secondary" : "ghost"}
|
||||
aria-label={ariaLabel}
|
||||
onMouseDown={(event) => event.preventDefault()}
|
||||
/>
|
||||
}
|
||||
>
|
||||
{TriggerIcon ? <TriggerIcon /> : null}
|
||||
<span>{label}</span>
|
||||
<ChevronDown className="opacity-60" />
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent className="w-max min-w-36" showArrow>
|
||||
<MenuItems items={group.children} />
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
)
|
||||
}
|
||||
|
||||
function MenuItems({ items }: { items: readonly CompiledLexicalActionItem[] }) {
|
||||
return items.map((item, index) => {
|
||||
if (item.kind === "action") {
|
||||
return <ActionMenuItem key={item.key} item={item} />
|
||||
}
|
||||
|
||||
return (
|
||||
<div key={item.key}>
|
||||
{index > 0 && <DropdownMenuSeparator />}
|
||||
<MenuItems items={item.children} />
|
||||
</div>
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
function ActionMenuItem({ item }: { item: CompiledLexicalAction }) {
|
||||
const { action, render } = item
|
||||
const Icon = action.icon
|
||||
|
||||
if (render || action.control) {
|
||||
return (
|
||||
<LexicalControl
|
||||
action={action}
|
||||
presentation="menu-item"
|
||||
render={render}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<LexicalControl
|
||||
action={action}
|
||||
render={({ active, disabled, label, onClick }) => (
|
||||
<DropdownMenuItem
|
||||
disabled={disabled}
|
||||
className={cn(active && "bg-muted")}
|
||||
onMouseDown={(event) => event.preventDefault()}
|
||||
onClick={onClick}
|
||||
>
|
||||
{Icon ? <Icon /> : null}
|
||||
<span>{label}</span>
|
||||
{active && <Check className="ml-auto" />}
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
/>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
import { createElement } from "react"
|
||||
import { describe, expect, it } from "vitest"
|
||||
|
||||
import { compileLexicalActions, LexicalActions } from "./action-declarations"
|
||||
|
||||
describe("Lexical action presets", () => {
|
||||
it("collects controls, nodes, and plugins from the full preset", () => {
|
||||
const compiled = compileLexicalActions(
|
||||
createElement(LexicalActions, { useDefaults: "full" })
|
||||
)
|
||||
|
||||
expect(compiled.actions.toolbar.length).toBeGreaterThan(0)
|
||||
expect(compiled.actions.bubble.length).toBeGreaterThan(0)
|
||||
expect(compiled.nodes.length).toBeGreaterThan(0)
|
||||
expect(compiled.plugins.length).toBeGreaterThan(0)
|
||||
})
|
||||
|
||||
it("keeps the minimal preset smaller than the full preset", () => {
|
||||
const minimal = compileLexicalActions(
|
||||
createElement(LexicalActions, { useDefaults: "minimal" })
|
||||
)
|
||||
const full = compileLexicalActions(
|
||||
createElement(LexicalActions, { useDefaults: "full" })
|
||||
)
|
||||
|
||||
expect(minimal.actions.toolbar.length).toBeLessThan(
|
||||
full.actions.toolbar.length
|
||||
)
|
||||
})
|
||||
})
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user